Using a FileSystemWatcher from PowerShell
The other day I had an idea: a PowerShell Script Server. Basically, it monitors a folder and when files appear in it, it takes some action on them, in this case: execute them. I've written servers like that before, but always in C#, using the System.IO.FileSystemWatcher class. Once again, it's not rocket science: the main challenge is in error handling and logging. The heavy lifting is done by the FileSystemWatcher, which lives up to its name: it watches a (single) folder, with or without it subfolders, and raises event when files are Created, Changed, Deleted or Renamed. (Those are also the names of the events). Each event gets an argument specifying which file was afffected; the Renamed event also receives the previous name of the file. So after setting up the FileSystemWatcher, all there's left to do is to implement the event handlers.
This time I wanted the ability to run PowerShell scripts. I could have used the same approach (using C#) but that would either have required the server to start a PowerShell process for every script to run, or to be a PowerShell host itself. Both struck me as more complicated than writing the server itself in PowerShell. So I got to work.
PowerShell Events
The first thing I realized was that events in PowerShell aren't as simple as in .NET code. Normally, you add a few handlers to a FileSystemWatcher and then keep running until shut down. But how does that even work in PowerShell?
The answer is that PowerShell has its own type of event handling. It catches events and then "replays" them to event subscribers. It took a while to figure out the details, but here's the gist of it:
- You attach an event handler to an event of an object using Register-ObjectEvent
- You then wait for a single type of event, or all types of events, using Wait-Event
- You handle the event
- You remove the event using Remove-Event to prevent it from being fired again by PowerShell
- Keep going until stopped
Start-FlleSystemWatcher.ps1
The result is a PowerShell script that's used as follows:
Start-FileSystemWatcher.ps1 [-Path] <string> [[-Filter] <string>] [-Recurse] <Actions>
The Path is mandatory; Filter defaults to *.* and Recurse is optional. Of course, you need to specify what needs to be done when, so you can supply up to four script blocks:
-CreatedAction { ... }
-ChangedAction { ... }
-DeletedAction { ... }
-RenamedAction { ... }
All are optional, but at least one needs to be specified.
Here's an example called Monitor-Path that used Start-FileSystemWatcher to monitor and log all four types of events:
[Cmdletbinding()]
Param(
[Parameter(Mandatory=$false, Position=0)]
[string]$Path = "."
)
.\Start-FileSystemWatcher.ps1 $Path -Recurse -CreatedAction {
Write-Output "$(Get-Date -format 'yyyy-MM-dd HH:mm:ss') File '$($e.FullPath)' was created"
} -ChangedAction {
Write-Output "$(Get-Date -format 'yyyy-MM-dd HH:mm:ss') File '$($e.FullPath)' was changed"
} -DeletedAction {
Write-Output "$(Get-Date -format 'yyyy-MM-dd HH:mm:ss') File '$($e.FullPath)' was deleted"
} -RenamedAction {
Write-Output "$(Get-Date -format 'yyyy-MM-dd HH:mm:ss') File '$($e.OldFullPath)' was renamed to '$($e.FullPath)'"
}
When called with
Monitor-Path c:\temp
it will write a line to output for every event fired. It will keep running until you press Ctrl+C:
Monitoring 'C:\Tools\*.*' and subdirectories. Press Ctrl+C to stop.
Writing a FileSystemWatcher event handler
All event handler script blocks you supply to Start-FileSystemWatcher have access to the following variables:
- $_ - The name of the file the event is about, relative to the path being watched
- $e - the argument to the original FileSystemWatcher event handler. For all events:
- $e.ChangeType determines the type of the event
- $e.Name is the name of the file (same as $_)
- $e.FullPath contains the full path name of the file
for the Renamed event, the following are also available:
- $e.OldName - the name of the file before it was renamed
- $e.OldFullPath - the full path of the file before it was renamed
- $eventArgs - the event that was returned from Wait-Event
This is set up by calling the PowerShell DoAction function for every event handler.
The cleanup problem
But one of the side effects of the PowerShell event system is that events will keep getting buffered, even when the monitoring script stops. To prevent that, we need to Unregister-Event the events we registered before. Start-FileSystemWatcher does that, but it can only do it when not stopped with Ctrl+C, because that terminates the script.
That's what the -KeyBoardTimeout<int> argument is for. It forces Start-FileSystemWatcher to check every n seconds if a key was pressed and to terminate when that key was ESC. When started with -KeyboardTimeout 5, it will report:
Monitoring 'C:\tools*.*' and subdirectories. Press ESC to cancel in at most 5 seconds, or Ctrl+C to abort.
2017-07-15 10:44:14 File 'C:\tools\Monitor-Path.ps1' was changed
2017-07-15 10:44:14 File 'C:\tools\Monitor-Path.ps1' was changed
ESC pressed. Exiting...
Exited.
Logging
Finally, because a server typically runs unattended, it's convenient to be able to append all output of the event handlers to a log file. Start-FileSystemWatcher can do that for you when you supply the -LogFile <string> argument. When an event handler action produces output, it's written to output and appened to the log fie. You can, of course, also have your event handlers use Write-Verbose or Write-Host, which are not wrtitten to the log file.
The Script
So here's the complete command line of Start-FileSystemWatcher.ps1:
Start-FileSystemWatcher.ps1
[-Path] <string>
[[-Filter] <string>]
[-Recurse]
[-CreatedAction <scriptblock>]
[-DeletedAction <scriptblock>]
[-ChangedAction <scriptblock>]
[-RenamedAction <scriptblock>]
[-KeyboardTimeout <int>]
[-LogFile <string>]
[<CommonParameters>]
And the script itself is in this Gist: