1

I'm setting up a FileSystemWatcher to watch a file for changes. This works. I also want to keep the script that sets this up running until the user manually terminates the script. This also works. However, I'd also like the event subscription on the FileSystemWatcher to get automatically cleaned up when the script exits (either normally or abnormally). This doesn't work, because event subscriptions are part of the session, and the script doesn't have its own session.

I tried creating a new session object inside the script and using it for the watcher setup and event registration, which seemed to do a great job cleaning up the event subscription on script termination, but it also seemed to cause all my console activity to get swallowed up in that child session.

How can I make it so that whenever the script exits (normally or abnormally), the event subscription is cleaned up automatically? (And doing this while maintaining visibility of my console output.)

In case the context matters, this is a simple ZIP file build script. I'm trying to add a "watch mode" to it so that when the ZIP is updated by another app, the ZIP is decompressed back to the folder from which it was created. So this script is meant to be executed from a PowerShell command line that remains active and is possibly used for other things before and after this script runs. In other words, the mighty hammer of Get-EventSubscriber | Unregister-Event is potentially a little too mighty, in addition to being another command that the script user would have to invoke on their own.

This is a condensed version of my script:

$watcher = New-Object System.IO.FileSystemWatcher ".", $fileName -Property @{
    NotifyFilter = [IO.NotifyFilters]::LastWrite
}

Register-ObjectEvent $watcher -EventName Changed -Action {
    Write-Host "File change detected."
    # other things, scripts, etc
}

$watcher.EnableRaisingEvents = $true

Write-Host "Press Ctrl+C to stop watching the file."

while ($true)
{
    if ([Console]::KeyAvailable)
    {
        $keyInfo = [Console]::ReadKey($true)
        if ($keyInfo.Modifiers -eq [ConsoleModifiers]::Control -and $keyInfo.Key -eq [ConsoleKey]::C)
        {
            Exit
        }
    }
    else
    {
        Start-Sleep 0.5
    }
}
William
  • 1,993
  • 2
  • 23
  • 40
  • 1
    Why can't you unregister the event on your `if` condition before exiting? – Santiago Squarzon Mar 31 '22 at 04:49
  • @SantiagoSquarzon That's a good question, and I did experiment with that approach, but I was using `Unregister-Event` incorrectly somehow, and couldn't get it to actually get rid of the event subscription in the session. At that point, my instinct told me there was a better "PowerShell way" I just had no clue about. That's when I relented and came to post here. – William Mar 31 '22 at 15:56
  • 2
    From what I'm seeing, that approach should work however what I'm not seeing on your code is `[console]::TreatControlCAsInput = $true` so, as you have it now, `Ctrl + C` will simply terminate your script instead of evaluating what's inside your `if` condition. – Santiago Squarzon Mar 31 '22 at 15:58
  • 1
    @SantiagoSquarzon I did not know about that property. That is definitely a strong candidate for what I was doing wrong initially! Even if I eventually go with try/Wait-Event/finally/Remove-Job, I'm definitely going to go back and see if this was the missing bit of my first approach. Thanks for enlightening me! – William Mar 31 '22 at 17:08
  • For sure, happy to help :) if you enable `Ctrl + C` as input then you would be entering the `if` condition without terminating the script, so you can dispose of your event there before `exit` , that _should work in theory_. – Santiago Squarzon Mar 31 '22 at 17:10
  • 2
    Since Ctrl-C aborts the script by default, handling the cleanup in a `finally` block is simpler and allows you to use `Wait-Event`, without wasting CPU cycles on periodic sleeping and polling for keystrokes. /cc @Santiago – mklement0 Mar 31 '22 at 22:27
  • 1
    @mklement0 I agree, `finally` is the more elegant and cleaner way to do it. I was just pointing out what OP's code was missing to work properly – Santiago Squarzon Mar 31 '22 at 22:58
  • 1
    @SantiagoSquarzon I just confirmed by experiment that what you pointed out was indeed the missing bit in my first try. Thanks again. – William Mar 31 '22 at 23:21
  • 2
    I would still recommend you to go with a `finally` block as @mklement0 demonstrates in his answer – Santiago Squarzon Mar 31 '22 at 23:27

1 Answers1

2

Try the following:

$watcher = New-Object System.IO.FileSystemWatcher $pwd, $fileName -Property @{
  NotifyFilter = [IO.NotifyFilters]::LastWrite
}

# Register with a self-chosen source identifier.
$evtJob = Register-ObjectEvent -SourceIdentifier fileWatcher $watcher -EventName Changed -Action {
  Write-Host "File change detected."
  # other things, scripts, etc
}

$watcher.EnableRaisingEvents = $true

Write-Host "Press Ctrl+C to stop watching the file."

try {
  # This blocks execution indefinitely while allowing
  # events to be processed in the -Action script block.
  # Ctrl-C aborts the script by default, which will execute
  # the `finally` block.
  Wait-Event -SourceIdentifier fileWatcher
}
finally {
  # Clean up the event job, and with it the event subscription.
  # Note: If the -Action script block produces output, you
  #       can collect it with Receive-Job first.
  $evtJob | Remove-Job -Force
}
mklement0
  • 382,024
  • 64
  • 607
  • 775
  • 1
    This looks and feels like The Right Way, thanks! One thing that might be a drawback (or at least I couldn't figure out how to tweak this to avoid it) is that other keystrokes aren't ignored during the Wait-Event. Once the script terminates, they fall out onto the prompt, including Enter presses. It's not a huge deal, as I've used at least a couple popular build tools with watch modes that have the same behavior. – William Mar 31 '22 at 23:29
  • 1
    Glad to hear it, @William. Re type-ahead buffer: if you terminate with Ctrl-C, it should get cleared automatically. Otherwise, you can use the following statement to clear it manually: `$null = while ([Console]::KeyAvailable) { [Console]::ReadKey($true) }` – mklement0 Apr 01 '22 at 13:44
  • 1
    Perfect! It definitely wasn't clearing when terminating with Ctrl+C, but the `while` loop did the trick! That loop was actually the "tweak" I tried, but not with the `$null = ...` in front... Hmm, probably a good sign I need to spend some time actually learning the structure and sense of PS the same way I know the structure and sense of C#, haha... – William Apr 01 '22 at 22:48
  • 1
    @William :) PowerShell allows you to use entire statements as expressions, and assigning to `$null` discards their output. Inside the `while` loop, `[Console]::ReadKey($true)` _implicitly_ creates output - see [this answer](https://stackoverflow.com/a/69792182/45375). – mklement0 Apr 01 '22 at 23:00