Monitoring Folders for File Changes

With a FileSystemWatcher, you can monitor folders for file changes and respond immediately when changes are detected. This way, you can create “drop” folders and respond to log file changes.

With a FileSystemWatcher, you can monitor folders for file changes and respond immediately when changes are detected. This way, you can create “drop” folders and respond to log file changes.

The FileSystemWatcher object can monitor files or folders and notify PowerShell when changes occur. It can monitor a single folder or include all subfolders, and there is a variety of filters.

Synchronously or Asynchronously?

You can invoke the FileSystemWatcher in two ways:

  • Simple Mode: In synchronous mode, you ask the FileSystemWatcher to wait for a single change. This blocks PowerShell until either the change occurs or a timeout is reached. This approachis very simple to implement however there is a chance to miss change events when they occur in rapid succession.
  • Advanced Mode: In asynchronous mode, the FileSystemWatcher does not block PowerShell. Instead, whenever a change occurs, an event is fired, and your script can respond to the events. This way, you cannot miss change events because the FileSystemWatcher is constantly monitoring. However, responding to events is not trivial in a single-threaded environment like PowerShell.

Simple Mode (synchronous)

This is straight-forward: the script below monitors your desktop and all of its subfolders for new files and for deletion of files. Whenever a change is detected, Invoke-SomeAction is called.

# find the path to the desktop folder:
$desktop = [Environment]::GetFolderPath('Desktop')

# specify the path to the folder you want to monitor:
$Path = $desktop

# specify which files you want to monitor
$FileFilter = '*'  

# specify whether you want to monitor subfolders as well:
$IncludeSubfolders = $true

# specify the file or folder properties you want to monitor:
$AttributeFilter = [IO.NotifyFilters]::FileName, [IO.NotifyFilters]::LastWrite 

# specify the type of changes you want to monitor:
$ChangeTypes = [System.IO.WatcherChangeTypes]::Created, [System.IO.WatcherChangeTypes]::Deleted

# specify the maximum time (in milliseconds) you want to wait for changes:
$Timeout = 1000

# define a function that gets called for every change:
function Invoke-SomeAction
{
  param
  (
    [Parameter(Mandatory)]
    [System.IO.WaitForChangedResult]
    $ChangeInformation
  )
  
  Write-Warning 'Change detected:'
  $ChangeInformation | Out-String | Write-Host -ForegroundColor DarkYellow


}

# use a try...finally construct to release the
# filesystemwatcher once the loop is aborted
# by pressing CTRL+C

try
{
  Write-Warning "FileSystemWatcher is monitoring $Path"
  
  # create a filesystemwatcher object
  $watcher = New-Object -TypeName IO.FileSystemWatcher -ArgumentList $Path, $FileFilter -Property @{
    IncludeSubdirectories = $IncludeSubfolders
    NotifyFilter = $AttributeFilter
  }

  # start monitoring manually in a loop:
  do
  {
    # wait for changes for the specified timeout
    # IMPORTANT: while the watcher is active, PowerShell cannot be stopped
    # so it is recommended to use a timeout of 1000ms and repeat the
    # monitoring in a loop. This way, you have the chance to abort the
    # script every second.
    $result = $watcher.WaitForChanged($ChangeTypes, $Timeout)
    # if there was a timeout, continue monitoring:
    if ($result.TimedOut) { continue }
    
    Invoke-SomeAction -Change $result
    # the loop runs forever until you hit CTRL+C    
  } while ($true)
}
finally
{
  # release the watcher and free its memory:
  $watcher.Dispose()
  Write-Warning 'FileSystemWatcher removed.'
}

The monitoring occurs in an endless loop. To abort monitoring, press CTRL+C.

When you now create or delete files or folders on your desktop or in one of its subfolders, each change calls Invoke-SomeAction which in turn emits the change information it received:

ChangeType Name                      OldName TimedOut
---------- ----                      ------- --------
   Deleted New Text Document (2).txt            False

By adjusting Invoke-SomeAction to your needs, you could for example implement a “drop” folder: when you share the folder you monitor, others could drop files into this folder, and whenever this occurs, your function could start a task.

Or - and this was coincidentally why I looked into FileSystemWatcher in the first place - whenver the source files for a website change, your script could trigger some compiler to build the website.

When you are done monitoring, press CTRL+C to abort the endless loop. Another warning message appears telling you that the FileSystemWatcher object was properly disposed.

Limitations

The simple synchronous FileSystemWatcher has a blind spot and can miss changes: WaitForChanged() waits for exactly one change and passes control to Invoke-SomeAction. This is when the blind spot starts, and it lasts until your code is done, and the loop calls WaitForChanged() again.

So this approach is very useful for single changes that occur relatively infrequent like the change in source files that triggers a re-compilation. It wouldn’t be right for implementing a public “drop” folder where people could drop files simultaneously. For this, take a look at the asynchronous approach.

Learning Points

There is a wealth of learning points in this code:

  • Finding System Folders: in this example, I wanted to monitor the desktop folder. Finding the path to the desktop isn’t trivial because it can change based on whether you are using OneDrive or not.

  • Instantiating New Objects: New-Object can instantiate new objects. By submitting a hashtable, you can pre-initialize the new object with a set of property values.

  • Keeping Script Responsive: PowerShell is single-threaded, so when it passes control to some other functionality like the FileSystemWatcher, it is not responsive anymore: you can’t abort the script, and pressing CTRL+C does nothing.

    So the script uses a relatively short timeout of 1000ms. That way, you get the chance to abort the script every second.

  • Cleaning Up: whenever you use system functions, it is your responsibility to check whether they need to be released after use, or else you produce memory leaks.

    Since the script uses an endless loop that can only be aborted via CTRL+C, it uses a try...finally construct: whenever the script is stopped, the code in finally{} will be executed, and the FileSystemWatcher object can be properly disposed.

Finding System Folders

To find well-known system folders such as the desktop, the script uses GetFolderPath() which accepts a predefined key name of type [System.Environment+SpecialFolder].

To find out the folder for your autostart files, try this:

[Environment]::GetFolderPath('Startup')

[System.Environment+SpecialFolder] is an enumeration. The acceptable names are:

PS> [Enum]::GetNames([System.Environment+SpecialFolder]) | Sort-Object
AdminTools
ApplicationData
CDBurning
CommonAdminTools
CommonApplicationData
CommonDesktopDirectory
CommonDocuments
CommonMusic
CommonOemLinks
CommonPictures
CommonProgramFiles
CommonProgramFilesX86
CommonPrograms
CommonStartMenu
CommonStartup
CommonTemplates
CommonVideos
Cookies
Desktop
DesktopDirectory
Favorites
Fonts
History
InternetCache
LocalApplicationData
LocalizedResources
MyComputer
MyDocuments
MyMusic
MyPictures
MyVideos
NetworkShortcuts
Personal
PrinterShortcuts
ProgramFiles
ProgramFilesX86
Programs
Recent
Resources
SendTo
StartMenu
Startup
System
SystemX86
Templates
UserProfile
Windows

Instantiating New Objects

You can use New-Object to instantiate a new object from a type name. Some constructors require arguments to be passed in order to create the new object. The FileSystemWatcher requires the path to the folder to monitor, and the file filter:

New-Object -TypeName IO.FileSystemWatcher -ArgumentList $Path, $FileFilter

If you don’t know the arguments a constructor requires, beginning in PowerShell 5 you can use an alternate syntax:

[IO.FileSystemWatcher]::new($Path, $FileFilter)

This new syntax is not only a lot faster, it also emits the required arguments:

PS> [IO.FileSystemWatcher]::new

OverloadDefinitions                                                            
-------------------                                                            
System.IO.FileSystemWatcher new()                                              
System.IO.FileSystemWatcher new(string path)                                   
System.IO.FileSystemWatcher new(string path, string filter) 

Once the FileSystemObject object is instantiated, a number of properties need to be set, i.e. IncludeSubdirectories and NotifyFilter. Rather than assigning these properties manually, you can submit a hashtable with the property names as key and their intended values as value:

New-Object -TypeName IO.FileSystemWatcher -ArgumentList $Path, $FileFilter -Property @{
    IncludeSubdirectories = $IncludeSubfolders
    NotifyFilter = $AttributeFilter
  }

Advanced Mode (asynchonous)

If you expect changes to happen in rapid succession or even simultaneously, you can use the FileSystemWatcher in asynchronous mode: the FileSystemWatcher now works in the background and no longer blocks PowerShell. Instead, whenever a change occurs, an event is raised. So with this approach, you get a queue and won’t miss any change.

On the back side, this approach has two challenges:

  • Handling Events: since PowerShell is single-threaded by nature, it is not trivial to respond to events, and even more cumbersome to debug event handler code.
  • Keeping PowerShell running: ironically, because the FileSystemWatcher now no longer blocks PowerShell, this leads to another problem. You need to keep PowerShell waiting for events but you cannot use Start-Sleep or and endless loop because as long as PowerShell is busy - and it is considered busy even if it sleeps - no events can be handled.

Implementation

The script below does the exact same thing as the synchronous version from above, only it is event-based and won’t miss any events anymore:

# find the path to the desktop folder:
$desktop = [Environment]::GetFolderPath('Desktop')

# specify the path to the folder you want to monitor:
$Path = $desktop

# specify which files you want to monitor
$FileFilter = '*'  

# specify whether you want to monitor subfolders as well:
$IncludeSubfolders = $true

# specify the file or folder properties you want to monitor:
$AttributeFilter = [IO.NotifyFilters]::FileName, [IO.NotifyFilters]::LastWrite 

try
{
  $watcher = New-Object -TypeName System.IO.FileSystemWatcher -Property @{
    Path = $Path
    Filter = $FileFilter
    IncludeSubdirectories = $IncludeSubfolders
    NotifyFilter = $AttributeFilter
  }

  # define the code that should execute when a change occurs:
  $action = {
    # the code is receiving this to work with:
    
    # change type information:
    $details = $event.SourceEventArgs
    $Name = $details.Name
    $FullPath = $details.FullPath
    $OldFullPath = $details.OldFullPath
    $OldName = $details.OldName
    
    # type of change:
    $ChangeType = $details.ChangeType
    
    # when the change occured:
    $Timestamp = $event.TimeGenerated
    
    # save information to a global variable for testing purposes
    # so you can examine it later
    # MAKE SURE YOU REMOVE THIS IN PRODUCTION!
    $global:all = $details
    
    # now you can define some action to take based on the
    # details about the change event:
    
    # let's compose a message:
    $text = "{0} was {1} at {2}" -f $FullPath, $ChangeType, $Timestamp
    Write-Host ""
    Write-Host $text -ForegroundColor DarkYellow
    
    # you can also execute code based on change type here:
    switch ($ChangeType)
    {
      'Changed'  { "CHANGE" }
      'Created'  { "CREATED"}
      'Deleted'  { "DELETED"
        # to illustrate that ALL changes are picked up even if
        # handling an event takes a lot of time, we artifically
        # extend the time the handler needs whenever a file is deleted
        Write-Host "Deletion Handler Start" -ForegroundColor Gray
        Start-Sleep -Seconds 4    
        Write-Host "Deletion Handler End" -ForegroundColor Gray
      }
      'Renamed'  { 
        # this executes only when a file was renamed
        $text = "File {0} was renamed to {1}" -f $OldName, $Name
        Write-Host $text -ForegroundColor Yellow
      }
        
      # any unhandled change types surface here:
      default   { Write-Host $_ -ForegroundColor Red -BackgroundColor White }
    }
  }

  # subscribe your event handler to all event types that are
  # important to you. Do this as a scriptblock so all returned
  # event handlers can be easily stored in $handlers:
  $handlers = . {
    Register-ObjectEvent -InputObject $watcher -EventName Changed  -Action $action 
    Register-ObjectEvent -InputObject $watcher -EventName Created  -Action $action 
    Register-ObjectEvent -InputObject $watcher -EventName Deleted  -Action $action 
    Register-ObjectEvent -InputObject $watcher -EventName Renamed  -Action $action 
  }

  # monitoring starts now:
  $watcher.EnableRaisingEvents = $true

  Write-Host "Watching for changes to $Path"

  # since the FileSystemWatcher is no longer blocking PowerShell
  # we need a way to pause PowerShell while being responsive to
  # incoming events. Use an endless loop to keep PowerShell busy:
  do
  {
    # Wait-Event waits for a second and stays responsive to events
    # Start-Sleep in contrast would NOT work and ignore incoming events
    Wait-Event -Timeout 1

    # write a dot to indicate we are still monitoring:
    Write-Host "." -NoNewline
        
  } while ($true)
}
finally
{
  # this gets executed when user presses CTRL+C:
  
  # stop monitoring
  $watcher.EnableRaisingEvents = $false
  
  # remove the event handlers
  $handlers | ForEach-Object {
    Unregister-Event -SourceIdentifier $_.Name
  }
  
  # event handlers are technically implemented as a special kind
  # of background job, so remove the jobs now:
  $handlers | Remove-Job
  
  # properly dispose the FileSystemWatcher:
  $watcher.Dispose()
  
  Write-Warning "Event Handler disabled, monitoring ends."
}

When you run this script, PowerShell emits a dot every second indicating that monitoring is active. When you now create, delete, rename, or change a file on your desktop or an of its subfolders, the event handler in $action triggers.

To illustrate that you won’t miss any event anymore, create a number of files on your desktop. Each time you create a new file, the appropriate events trigger. Next, select all the files you just created, and delete them all.

With the synchronous approach, the FileSystemWatcher would only pick up the first deletion. With the new asynchronous approach, you don’t miss anything. In fact, the event handler in $action purposely adds a Start-Sleep whenever a Delete event comes in, simulating a lengthy action script. Still, all deleted files are picked up. PowerShell can’t process events simultaneously, but it queues up all events and processes them one by one.

Press CTRL+C to abort monitoring. The script turns off monitoring, removes all event handlers, and properly disposes the FileSystemWatcher.

The event handler scriptblock in $action defined a global variable $all with the event information submitted by the newest event:

# save information to a global variable for testing purposes
# so you can examine it later
# MAKE SURE YOU REMOVE THIS IN PRODUCTION!
$global:all = $details

Now that the script is stopped, you can examine $all to see the information passed from the event to your event handler code:

PS> $all

ChangeType FullPath                                              Name          
---------- --------                                              ----          
   Deleted C:\Users\tobia\OneDrive\Desktop\New Text Document.txt New Text Do...

As it turns out, the information is exactly what was passed by WaitForChanged() in the synchronous example.

Make sure you remove the global variable from your code when you want to use it in production.

Learning Points

Again, here are some important learning points to take home:

  • Instantiating Object: The script instantiates the very same FileSystemWatcher object but this time uses a parameterless constructor. As it turns out, the parameters can also be set via properties, so this script sets all parameters in the hashtable it submits to New-Object. It’s your choice.
  • Event Handlers: Since the FileSystemWatcher is working in asynchronous mode this time, the script needs to implement event handlers and needs to also take care of properly removing them when monitoring ends.
  • Properly Pausing PowerShell: since the FileSystemWatcher is no longer blocking PowerShell, the script must be kept busy in a way that still responds to events. Instead of Start-Sleep, the script uses Wait-Event which basically does the same but waits for events. Even better: if an event comes in, it immediately handles the event.

Defining an Event Handler

Event handlers are just scriptblocks that get called when the event fires. They tell PowerShell what to do when the event fires.

The script chose to use just one universal event handler (defined in $action) for all four types of filesystem changes, but you could as well define four different event handlers.

Of course your event handler code needs to know details about the event it should handle. So within the event handler scriptblock, there is one special variable: $event:

Property Purpose
MessageData can be set from Register-ObjectEvent via -MessageData
Sender The object that emitted the event. In our example the FileSystemWatcher
SourceEventArgs The information submitted by the event. In our example the information about the file change. This is the same information that was returned by WaitForChanged() in the synchronous example.
SourceArgs An array with the Sender and SourceEventArgs
SourceIdentifier can be set from Register-ObjectEvent via -SourceIdentifier
TimeGenerated Time and Date when the event was fired

Registering Event Handlers

For event handler code to become active, it needs to be attached to an event emitted by an object. This is done by Register-ObjectEvent.

In our example, the FileSystemWatcher fires four different events:

PS> $watcher = New-Object -TypeName System.IO.FileSystemWatcher

PS> $watcher | Get-Member -MemberType Event


   TypeName: System.IO.FileSystemWatcher

Name     MemberType Definition                                                 
----     ---------- ----------                                                 
Changed  Event      System.IO.FileSystemEventHandler Changed(System.Object, ...
Created  Event      System.IO.FileSystemEventHandler Created(System.Object, ...
Deleted  Event      System.IO.FileSystemEventHandler Deleted(System.Object, ...
Disposed Event      System.EventHandler Disposed(System.Object, System.Event...
Error    Event      System.IO.ErrorEventHandler Error(System.Object, System....
Renamed  Event      System.IO.RenamedEventHandler Renamed(System.Object, Sys...

The script assigns the same event handler to all four events:

$handlers = . {
  Register-ObjectEvent -InputObject $watcher -EventName Changed  -Action $Action -MessageData 1
  Register-ObjectEvent -InputObject $watcher -EventName Created  -Action $Action -MessageData 2
  Register-ObjectEvent -InputObject $watcher -EventName Deleted  -Action $Action -MessageData 3
  Register-ObjectEvent -InputObject $watcher -EventName Renamed  -Action $Action -MessageData 4
}

Register-ObjectEvent takes the event handler scriptblock and creates and returns a special type of background job from it that now listens to the event.

Since we need to keep track of the returned background jobs so we can later remove them, the script uses a scriptblock to call all four Register-ObjectEvent commands and runs the scriptblock dot-sourced. That’s a clever trick to store the return values of all four calls as an array in $handlers.

Enabling Events

Once the event handlers are in place, the FileSystemWatcher can be allowed to fire events:

$watcher.EnableRaisingEvents = $true

Whenever an event is raised, the registered event handler will now pick it up.

Technically, the event handler runs as a special type of background job. In contrast to normal background jobs, it shares the runspace with your script and does not run in a separate PowerShell instance. So event handlers share the runspace with your script.

That’s good because your event handler can share all variables with your script and also output information into the shell. It’s also bad because PowerShell by nature is single-threaded, so event handlers will only run when the script is currently idle or else be queued.

Cleaning Up

An endless loop keeps PowerShell busy while waiting for events, so to end monitoring, press CTRL+C.

These are the clean-up steps to end eventing:

  • Tell the FileSystemWatcher to no longer emit events by setting EnableRisingEvents to $false
  • Call Unregister-Event for each registered event handler, and use the automatically assigned names from the background jobs stored in $handlers
  • Call Remove-Job to remove all background jobs in $handlers
  • Dispose the FileSystemWatcher object.