Click here to Skip to main content
15,860,972 members
Articles / Programming Languages / C#
Article

Generic Directory Watcher Service

Rate me:
Please Sign up or sign in to vote.
4.76/5 (29 votes)
8 Feb 2007GPL314 min read 194.8K   2.8K   148   62
This service watches for filesystem events in directories and runs specified programs in response to those events.

Introduction

As I was experimenting with the media center functionality of Windows Vista RC2 one afternoon, I realized that it would be great to have a service running that would automatically transcode recordings from Microsoft's heavyweight DVR-MS format to a more svelte WMV file. Well, a utility (DVRMSToolbox) already exists to handle this scenario. But what if you wanted to add more functionality, like reorganizing the recording by automatically renaming it and copying it to another directory? Thus was born the idea for the Directory Watcher, a generic service that watches directories contained in the configuration file for fileysystem events and then runs specified applications in response to those events.

Background

The Directory Watcher service makes use of several .NET features and programming concepts to do its job, the first and foremost of which is the FileSystemWatcher class. This is a built-in class in the .NET framework that resides in the System.IO namespace and handles all of the heavy lifting involved in watching a directory for filesystem events. The real work that this service does is to spawn and regulate handler threads in response to these events which segues nicely into the next concept, the CountingSempahore.

The CountingSemaphore class is a custom class that is implemented by this service and extends the standard, binary semaphore behavior provided by the built-in .NET Monitor class. Semaphores are used to provide thread synchronization by controlling access to a critical section by multiple executing threads. .NET provides binary semaphore, or mutex, functionality through use of the lock keyword or through direct use of the Monitor class: these two constructs ensure that only one thread at a time can access a section of code.

However, what we need in this application is a resource-based semaphore: instead of restricting access to a section of code to a single thread, we want n number of threads to be able to access the section at a given time. Through this, we can control the number of processes being executed at a given time in response to filesystem events. The CountingSemaphore class provides this: the first n number of threads entering a critical section will claim one resource and be allowed to execute, but subsequent threads will block trying to claim a resource and will be forced to wait until one of the initial threads finishes before it can execute.

Finally, we make use of framework's ability to runtime-compile code in order to provide a measure of scripting support. When trying to do something extremely simple, like send an email to someone, to handle a filesystem event it's often a PITA to compile and maintain an entirely separate executable for the task. So, the service allows small snippets of .NET code to be specified that will handle events (they must meet certain criteria that will be covered later). We make use of the classes in the CodeDom namespace to perform runtime-compilation and generate assemblies in memory for these snippets. The service's filesystem event handler code then invokes the handler classes defined therein in addition to giving the user the option of using traditional, pre-compiled applications to handle events.

Implementation details

Configuration

One of my favorite improvements in .NET 2.0 are the dramatically improved configuration classes: ConfigurationElement, ConfigurationSection, ConfigurationElementCollection, etc. They make it relatively painless to define configuration data classes, declaratively populate them through the App.config file, and access them programatically (as opposed to being forced to load the config file contents into an XmlDocument object and perform XPath queries against it). The Directory Watcher service makes full use of this functionality; below is a sample App.config file that mimics the IIS SMTP pickup directory behavior, but uses Cygwin's exim MTA instead:

XML
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <configSections>
    <section name="watchInformation"
       type="DirectoryWatcher.WatchInformation, DirectoryWatcher"/>
  </configSections>
  <appSettings>
    <add key="maxConcurrentProcesses" value="20"/>
  </appSettings>
  <watchInformation>
    <directoriesToWatch>
      <directoryToWatch path="c:\Cygwin\var\spool\exim\pickup">
        <fileSetsToWatch>
          <fileSetToWatch>
            <eventsToWatch>
              <eventToWatch type="Created"/>
            </eventsToWatch>
            <programsToExecute>
              <programToExecute path="c:\Cygwin\bin\exim-4.52-2.exe"
                 arguments="-odf -t" redirectFileToStdin="true"/>
              <programToExecute path="c:\Cygwin\bin\rm.exe"
                 arguments="&quot;{P}&quot;"/>
            </programsToExecute>
          </fileSetToWatch>
        </fileSetsToWatch>
      </directoryToWatch>
    </directoriesToWatch>
  </watchInformation>
</configuration>

All of the data contained therein is referenced through classes that inherit from ConfigurationElement, ConfigurationSection, or ConfigurationElementCollection. The <configSections/> node contains the class and assembly reference that tells .NET's ConfigurationManager class that the <watchInformation/> node and all of its children are represented by the WatchInformation class. If we look at the code for this class, we see that it is very simple:

C#
public class WatchInformation : ConfigurationSection
{
  /// <summary>
  /// Collection of directories that we are to watch for filesystem changes.
  /// </summary>
  [ConfigurationProperty("directoriesToWatch", IsRequired = true)]
  public DirectoryToWatchCollection DirectoriesToWatch
  {
    get
    {
      return (DirectoryToWatchCollection)base["directoriesToWatch"];
    }
  }
}

By flagging the DirectoriesToWatch property with the ConfigurationProperty attribute, it tells us that the <directoriesToWatch/> node is represented by an object of type DirectoryToWatchCollection. To retrieve the information from the configuration file for a property, all you have to do is reference base["nodeOrAttributeName"] and cast it as the type the node represents. .NET's configuration classes will take care of all of the rest. This behavior follows recursively through the rest of the child nodes; an exhaustive analysis of all of the configuration classes used in this application isn't necessary since the classes basically just map nodes and attributes in the config file to properties in the classes. The MSDN documentation on the System.Configuration namespace provides a thorough coverage of the topic here.

However, there is definitely a gotcha with regards to the configuration classes: they don't provide a direct way for you to store configuration information for a property in a text node instead of an attribute. For instance, when you look at a typical line in the config file,

XML
<programToExecute path="c:\Cygwin\bin\exim-4.52-2.exe" arguments="-odf -t"
  redirectFileToStdin="true"/>

you see that values for each property (of the ProgramToExecute class in this case) are contained in an attribute. This works fine for simple values, but what about for free-form text like snippets of .NET code that we need to be able to specify in order to provide runtime-compilation support? Storing that sort of data in an attribute just isn't practical, but the configuration classes just don't provide default functionality for storing data in text nodes: they see all nodes as complex objects and all attributes as simple values. This is where the old way of doing XML serialization provided a lot more flexibility: you would simply flag the property that you wanted stored in a node with an [XmlElement()] attribute instead of with an [XmlAttribute()] attribute and you were off to the races. While there is a way to get this functionality in the configuration classes, it's not really documented by Microsoft (or anyone else, as far as I could tell) and involved me hacking around in the System.Configuration assembly (thank God for Lutz Roeder's .NET Reflector) to figure out what was going on. It's actually pretty simple and here's the code for ProgramCode, the configuration class for runtime-compiled code where we had to implement this functionality:

C#
public class ProgramCode : ConfigurationElement
{
  /// <summary>
  /// The text of the actual code that we are to compile.
  /// </summary>
  private string text = null;
  /// <summary>
  /// Line number in the configuration file for this element 
  /// (used for possible exception messages).
  /// </summary>
  private int lineNumber = 0;
  /// <summary>
  /// Path to the configuration file in which this element resides 
  /// (used for possible exception messages).
  /// </summary>
  private string fileName = "";
  /// <summary>
  /// Assembly that results when we compile the code.
  /// </summary>
  private Assembly assembly = null;

  /// <summary>
  /// The language for this code snippet.
  /// </summary>
  [ConfigurationProperty("language", IsRequired = true)]
  public ProgramLanguage Language
  {
    get
    {
      return (ProgramLanguage)base["language"];
    }
  }

  /// <summary>
  /// Text representing the actual code.
  /// </summary>
  public string Text
  {
    get
    {
      return text;
    }
  }

  /// <summary>
  /// Collection of referenced assemblies for this snippet of code.
  /// </summary>
  [ConfigurationProperty("referencedAssemblies")]
  public ReferencedAssemblyCollection ReferencedAssemblies
  {
    get
    {
      return (ReferencedAssemblyCollection)base["referencedAssemblies"];
    }
  }

  /// <summary>
  /// Assembly representing the compiled results of the code.
  /// </summary>
  public Assembly Assembly
  {
    get
    {
      // Omitted for brevity's sake, we'll cover this later
    }
  }

  /// <summary>
  /// Handler for the case where we encounter an unrecognized element 
  /// while attempting to deserialize the class from XML; deals with
  /// "custom" properties, specifically the Text property, whose value is set 
  /// in an element node instead of an attribute.
  /// </summary>
  /// <param name="elementName">
  /// Name of the unrecognized element.
  /// </param>
  /// <param name="reader">
  /// Reader object that is involved in the deserialization.
  /// </param>
  /// <returns>
  /// True if we actually recognize the element, false otherwise.
  /// </returns>
  protected override bool OnDeserializeUnrecognizedElement(string elementName,
                                                           XmlReader reader)
  {
    if (elementName == "text")
    {
      text = reader.ReadString();
      reader.Read();

      return true;
    }

    return base.OnDeserializeUnrecognizedElement(elementName, reader);
  }

  /// <summary>
  /// Instantiates the object using data stored in XML; 
  /// records the filename and line number
  /// (for use later in possible exceptions) and then calls the base method.
  /// </summary>
  /// <param name="reader">
  /// Reader object that is involved in the deserialization.
  /// </param>
  /// <param name="serializeCollectionKey">
  /// True to serialize only the collection key properties, false otherwise.
  /// </param>
  protected override void DeserializeElement(XmlReader reader,
                                             bool serializeCollectionKey)
  {
    lineNumber = ((IConfigErrorInfo)reader).LineNumber;
    fileName = ((IConfigErrorInfo)reader).Filename;

    base.DeserializeElement(reader, serializeCollectionKey);
  }

  /// <summary>
  /// Called after the deserialization process is complete; 
  /// validates the object's data. 
  /// </summary>
  protected override void PostDeserialize()
  {
    if (text == null)
      throw new ConfigurationErrorsException
        ("\"text\" is a required element.", fileName, lineNumber);

      base.PostDeserialize();
  }
}

First, you see that the Text property (which holds the actual text of the code that we're going to compile) is not decorated with a ConfigurationProperty attribute. This is because we don't want the configuration deserialization logic to try to automatically deserialize the node. Instead, we're going to rely on OnDeserializeUnrecognizedElement: this method is called during deserialization when a node that is not mapped to a property is encountered. It passes in the name of the element and the XmlReader object involved in the deserialization and allows for custom deserialization within the method. So we override this method: since the <text/> node won't be mapped to a configuration property, this method will be invoked when the deserializer encounters it. We check to see if the element name is "text" and, if so, all we have to do is set an internal member, text, to the string value of the node, advance the reader to the next node in the DOM, and return true. The return value seems to contradict what's documented on MSDN for this method: I don't know if I'm misreading their documentation but, to set the record straight, you want to return true when the node was actually valid and was processed, and false otherwise. A false return value will cause the configuration deserializer to throw an exception.

Service

Startup logic

The code in the service's OnStart event handler is fairly straightforward:

C#
protected override void OnStart(string[] args)
{
  WriteToEventLog(EventLogEntryType.Information,
                  "Starting up the Directory Watcher service.");

  try
  {
    // Get the section from the configuration file that contains the
    // directories/file sets that we are to watch
    watchInformation =
       (WatchInformation)ConfigurationManager.GetSection("watchInformation");

    // Get the maximum number of concurrent processes that can be active
    // (if specified)
    if (ConfigurationManager.AppSettings["maxConcurrentProcesses"] != null)
      maxConcurrentProcesses =
         Convert.ToUInt32(ConfigurationManager.AppSettings
                        ["maxConcurrentProcesses"]);

    WriteToEventLog(EventLogEntryType.Information,
                    "Using a concurrent process count of {0}.",
                    maxConcurrentProcesses);

    // Instantiate the regulation semaphore to enforce the maximum process
    // count
    executionRegulator = new CountingSemaphore(maxConcurrentProcesses);

    foreach (DirectoryToWatch directoryToWatch in
             watchInformation.DirectoriesToWatch)
    {
      foreach (FileSetToWatch fileSetToWatch in
               directoryToWatch.FileSetsToWatch)
      {
        // If we're using any runtime-compiled code, validate each assembly
        // that was generated to make sure that only one class exists in it
        // that implements the IFileSystemEventHandler interface
        foreach (ProgramToExecute programToExecute in
                 fileSetToWatch.ProgramsToExecute)
        {
          if (programToExecute.Code.Text != null)
            ValidateAssembly(programToExecute.Code.Assembly);
        }

        // Create and instantiate an individual FileSystemWatcher for
        // each wildcard file set and a single FileSystemWatcher object
        // to handle all regular expression file sets
        if (fileSetToWatch.MatchExpressionType ==
            MatchExpressionType.Wildcard ||
            watchers[directoryToWatch.Path][""] == null)
        {
          FileSystemWatcher watcher =
             new FileSystemWatcher(directoryToWatch.Path);

          // Set the filter to the match expression for wildcard file
          // sets, and blank (to capture changes for all files and do
          // the actual matching in the event handler) for regular
          // expression file sets
          watcher.Filter =
            (fileSetToWatch.MatchExpressionType ==
             MatchExpressionType.Wildcard ?
             fileSetToWatch.MatchExpression : "");

          // Attach handlers to the various filesystem events that we're
          // supposed to watch for
          if (fileSetToWatch.EventsToWatch["All"] != null)
          {
            watcher.Changed += new FileSystemEventHandler(watcher_OnChanged);
            watcher.Created += new FileSystemEventHandler(watcher_OnChanged);
            watcher.Deleted += new FileSystemEventHandler(watcher_OnChanged);
            watcher.Renamed += new RenamedEventHandler(watcher_OnChanged);
          }

          else
          {
            foreach (EventToWatch eventToWatch in
                     fileSetToWatch.EventsToWatch)
            {
              if (eventToWatch.Type == WatcherChangeTypes.Changed)
                watcher.Changed +=
                   new FileSystemEventHandler(watcher_OnChanged);

              else if (eventToWatch.Type == WatcherChangeTypes.Created)
                watcher.Created +=
                   new FileSystemEventHandler(watcher_OnChanged);

              else if (eventToWatch.Type == WatcherChangeTypes.Deleted)
                watcher.Deleted +=
                   new FileSystemEventHandler(watcher_OnChanged);

              else if (eventToWatch.Type == WatcherChangeTypes.Renamed)
                watcher.Renamed +=
                   new RenamedEventHandler(watcher_OnChanged);
            }
          }

          // Create a new dictionary entry for this directory path if
          // it doesn't exist already
          if (!watchers.ContainsKey(directoryToWatch.Path))
            watchers[directoryToWatch.Path] =
               new Dictionary<string, FileSystemWatcher>();

          // Add the watcher to the list for this directory and
          // enable it
          watchers[directoryToWatch.Path][watcher.Filter] = watcher;
          watcher.EnableRaisingEvents = true;
        }

        WriteToEventLog(EventLogEntryType.Information,
                        "Added watcher for the path \"{0}\"" +
                        "and the {1} \"{2}\".",
                        directoryToWatch.Path,
                        (fileSetToWatch.MatchExpressionType ==
                         MatchExpressionType.Wildcard ?
                         "wildcard expression" : "regular expression"),
                        fileSetToWatch.MatchExpression);
      }
    }
  }

  // Log any exceptions that occur during startup
  catch (Exception exception)
  {
    WriteToEventLog(EventLogEntryType.Error,
                    "Exception occurred while starting the service." +
                    "\n\nType: {0}\nMessage:{1}",
                    exception.GetType().FullName, exception.Message);
    throw;
  }

  base.OnStart(args);
}

We start by getting access to the configuration data in the App.config file by using .NET's built-in ConfigurationManager class. That one line of code is all that's necessary; the ConfigurationManager takes care of reading the data from the config file and instantiating all of the child properties:

C#
watchInformation = (WatchInformation)ConfigurationManager.GetSection
                            ("watchInformation");

For each file set, we then check its handler programs and if any are represented by runtime compiled code, then we do a validation of the assembly that was generated to make sure that it meets our requirements. The validation functions that are involved are as follows:

C#
protected static void ValidateAssembly(Assembly assembly)
{
  FindEventHandlerType(assembly);
}

protected static Type FindEventHandlerType(Assembly assembly)
{
  Type eventHandlerType = null;

  foreach (Type type in assembly.GetTypes())
  {
    if (type.GetInterface("IFileSystemEventHandler") != null)
    {
      // If we've already found a qualifying type, then throw an exception
      if (eventHandlerType != null)
        throw new ArgumentException(
           String.Format("Multiple classes implementing " +
                         "IFileSystemEventHandler were found in {0}.",
                         assembly.FullName));

      eventHandlerType = type;
    }
  }

  // If no qualifying types were found, then throw an exception
  if (eventHandlerType == null)
    throw new ArgumentException(
       String.Format("No classes implementing IFileSystemEventHandler " +
                     "were found in {0}.", assembly.FullName));

  return eventHandlerType;
}

It just does a simple check of the assembly's types and makes sure that one, and only one, class (the main handler class) implements the IFileSystemEventHandler interface which is defined as follows:

C#
public interface IFileSystemEventHandler
{
  /// <summary>
  /// Handler function that is called whenever a filesystem event occurs.
  /// </summary>
  /// <param name="e">
  /// Arguments (file name, directory, event type, etc.) associated with
  /// the event.
  /// </param>
  void OnFileSystemEvent(FileSystemEventArgs e);
}

It's then a simple matter of iterating over each directory, iterating over each file set contained within the directory, and creating FileSystemWatcher objects appropriately. The only wrinkle occurs when handling wildcard (*.txt) vs. regular expression (^log_(october|november)) matched file sets. The FileSystemWatcher class contains a Filter property that, when set, instructs the object to watch for events only for files matching the specified wildcard. So, when we preparing to watch a wildcard matched file set, we just set the Filter property and move on. However, for regular expression matched file sets, we have to do an evaluation of whether or not a file is within a particular set in the event handling code, so, for each directory path containing at least one regular expression matched file set, we declare a single, catch-all FileSystemWatcher object by setting its Filter property to a blank string (""). The event handler function (covered later) that is invoked whenever a change is detected will then take care of the actual regular expression evaluation to decide which file set (if any) a file belongs to and will invoke its handler applications accordingly.

Runtime compilation

Support for runtime-compiled code in the service is accomplished through the use of the CodeDom namespace and is implemented in the ProgramCode configuration class in the form of a property called Assembly, which is shown below:

C#
public Assembly Assembly
{
  get
  {
    // If the code has not already been compiled, do so now
    if (assembly == null)
    {
      CodeDomProvider codeProvider = null;

      // Get the proper code provider based on the code's language
      if (Language == ProgramLanguage.CSharp)
        codeProvider = new CSharpCodeProvider();

      else if (Language == ProgramLanguage.VisualBasic)
        codeProvider = new VBCodeProvider();

      CompilerParameters compilerParameters = new CompilerParameters();

      // Set the compiler options so that we don't generate an assembly
      // on disk and we create a library assembly that does not contain
      // debug information
      compilerParameters.GenerateExecutable = false;
      compilerParameters.GenerateInMemory = true;
      compilerParameters.IncludeDebugInformation = false;
      compilerParameters.CompilerOptions = "/target:library /optimize";
      compilerParameters.ReferencedAssemblies.Add("System.dll");
      compilerParameters.ReferencedAssemblies.Add(
         AppDomain.CurrentDomain.BaseDirectory +
         "\\DirectoryWatcher.exe");

      // Add any assembly references (besides System.dll and
      // DirectoryWatcher.exe, which everyone gets) specified for this
      // code
      foreach (ReferencedAssembly referencedAssembly in
               ReferencedAssemblies)
        compilerParameters.ReferencedAssemblies.Add(
           referencedAssembly.Name);

      // Generate the assembly
      CompilerResults results =
         codeProvider.CompileAssemblyFromSource(
             compilerParameters, text);

      // Check the return code and throw an exception if the compilation failed
      if (results.NativeCompilerReturnValue != 0)
        throw new CompilationException(results.Errors);

      assembly = results.CompiledAssembly;
    }

    return assembly;
  }
}

The CodeDom classes make it very easy to do this compilation. To start, we have to get the proper code provider type based on the language of the code snippet, then we have to set the compiler parameters appropriately. We want the resulting assembly to be as compact as possible so we explicitly exclude debug information and specify a compiler option for optimization. We also don't want to create any assembly files on disk, so we set the option telling the compiler to generate it in memory only. We then add the necessary assembly references (everyone gets System and DirectoryWatcher.exe since they're the bare minimum required for a handler class to operate) and invoke the compilation function. If we get a return code of 0, we know we succeeded and now have an Assembly object from which we can create types and handle filesystem events. Here is an example runtime-compiled program that sends an email notification by creating a file in the SMTP pickup directory used as an example earlier ("Use your EasyButton to find my EasyButton? Won't that, like . . . tear a hole in the universe or something?"):

XML
<programToExecute>
  <code language="CSharp">
    <text>
      <![CDATA[
        using System;
        using System.IO;
        using DirectoryWatcher;

        public class NotifyClass : IFileSystemEventHandler
        {
          public NotifyClass()
          {
          }

          public void OnFileSystemEvent(FileSystemEventArgs e)
          {
            string tempFileName = Path.GetTempFileName();
            StreamWriter writer = new StreamWriter(tempFileName);

            writer.WriteLine("To: lstratman@gmail.com");
            writer.WriteLine("From: lstratman@gmail.com");
            writer.WriteLine("Subject: File change notification");
            writer.WriteLine("");
            writer.WriteLine(e.FullPath + " has changed.");
            writer.Close();

            File.Move(tempFileName,
                      "c:\\Cygwin\\var\\spool\\exim\\" +
                      pickup\\email.txt");
          }
        }
      ]]>
    </text>
  </code>
</programToExecute>

Event handling logic

When a filesystem event is detected, a handler function is invoked that is responsible for running the necessary applications to respond to the event:

C#
protected void watcher_OnChanged(object source, FileSystemEventArgs e)
{
  FileSystemWatcher watcher = (FileSystemWatcher)source;
  DirectoryToWatch directoryToWatch =
     watchInformation.DirectoriesToWatch[watcher.Path];

  // If this watcher is a wildcard watcher, grab its programs from the
  // configuration section
  if (watcher.Filter != "")
  {
    ProgramToExecuteCollection programsToExecute =
       directoryToWatch.FileSetsToWatch[watcher.Filter].ProgramsToExecute;
    AddProgramsToQueue(programsToExecute, e.FullPath, e.ChangeType);
  }

  // Otherwise, go through the list of regular expression file sets for this
  // directory, see if any of them match the file that was modified, and, if
  // they do, get their programs from the configuration section
  else
  {
    foreach (FileSetToWatch fileSetToWatch in
             directoryToWatch.FileSetsToWatch)
    {
      if (fileSetToWatch.MatchExpressionType ==
          MatchExpressionType.RegularExpression &&
          fileSetToWatch.MatchRegex.IsMatch(e.Name))
        AddProgramsToQueue(fileSetToWatch.ProgramsToExecute, e.FullPath,
                           e.ChangeType);
    }
  }
}

Again, it's pretty straightforward: if the source FileSystemWatcher object wasn't a catch-all for regular expression matched file sets (i.e. didn't have its Filter property set to ""), then we reference the config data to get the list of programs that we're supposed to execute for this file set and pass them to another function responsible for starting up the programs. Otherwise, we iterate over each file set for the directory and, if it's a regular expression file set, we apply it against the file name and, if it matches, we pass its program list to the aforementioned program function. That program function is as follows:

C#
protected void AddProgramsToQueue(ProgramToExecuteCollection programsToExecute,
                                  string filePath,
                                  WatcherChangeTypes eventType)
{
  List<ExecutionInstance> executionInstances =
     new List<ExecutionInstance>();
  Thread executionThread =
     new Thread(new ParameterizedThreadStart(RunPrograms));

  // Loop through each program and create an ExecutionInstance object for it
  foreach (ProgramToExecute programToExecute in programsToExecute)
  {
    ExecutionInstance executionInstance;

    // If we're running a pre-compiled application, create the necessary
    // ProcessStartInfo object
    if (programToExecute.Code.Text == null)
    {
      ProcessStartInfo startInfo =
         new ProcessStartInfo(programToExecute.Path);
      FileInfo fileInfo = new FileInfo(filePath);

      startInfo.Arguments =
         programToExecute.Arguments.Replace("{P}", filePath);
      startInfo.Arguments =
         startInfo.Arguments.Replace("{F}", fileInfo.Name);
      startInfo.Arguments =
         startInfo.Arguments.Replace("{E}", fileInfo.Extension);
      startInfo.Arguments =
         startInfo.Arguments.Replace("{D}", fileInfo.DirectoryName);

      if (fileInfo.Extension != "")
        startInfo.Arguments =
           startInfo.Arguments.Replace("{f}", fileInfo.Name.Substring(0,
                                       fileInfo.Name.Length -
                                       fileInfo.Extension.Length - 1));

      else
        startInfo.Arguments =
           startInfo.Arguments.Replace("{f}", fileInfo.Name);

      startInfo.UseShellExecute = false;
      startInfo.RedirectStandardInput =
         programToExecute.RedirectFileToStdin;

      executionInstance =
         new ExecutionInstance(startInfo, eventType, filePath,
                               programToExecute.RedirectFileToStdin);
    }

    // Otherwise, we're using runtime-compiled code and we need to create
    // an instance of the class that implements IFileSystemEventHandler
    else
    {
      IFileSystemEventHandler eventHandler =
         CreateEventHandlerInstance(programToExecute.Code.Assembly);
      executionInstance = new ExecutionInstance(eventHandler, eventType,
                                                filePath);
    }

    executionInstances.Add(executionInstance);
  }

  // Start the thread that will execute the programs
  executionThread.Start(executionInstances);
}

This function is responsible for taking a list of programs to execute and then spinning off a worker thread to execute those programs in sequence. The fact that an event can invoke more than one program is one reason why this must be handled in a separate thread: using the example of the SMTP pickup directory from the configuration section, we run one command to send the email and another to clean the message file up from the pickup directory. We obviously don't want the cleanup command run until the message sending command finishes, so we use a thread to start a program, wait until it completes, start the next program, and repeat until we reach the end of the list.

Another reason for spinning off a thread is that we want the calling event handler function to return as quickly as possible: if the event handler actually blocked waiting for the handling programs to run, then it's possible for the FileSystemWatcher's internal buffers to fill up and for events to be dropped. So, this function creates a list of ProcessStartInfo (if the program is a traditional, pre-compiled application) and IFileSystemEventHandler (if the program is represented by runtime-compiled code) objects representing each program that should be run and then starts up another thread to run them in sequence. Finally, that thread's startup function looks like this:

C#
public static void RunPrograms(object source)
{
  List<ExecutionInstance> executionInstances = 
                (List<ExecutionInstance>)source;

  // If we're watching for a create event, we first try to open the file in
  // exclusive mode; this is to account for the "long copy" scenario where
  // the create event is fired when the copy first starts, but we need to
  // wait until the copy completes before we begin our processing
  if (executionInstances[0].EventType == WatcherChangeTypes.Created)
  {
    FileStream fileStream = null;

    while (fileStream == null)
    {
      try
      {
        fileStream =
           File.Open(executionInstances[0].FilePath, FileMode.Open,
                     FileAccess.Read, FileShare.None);
      }

      // Catch the IOException that will be thrown when we fail to open
      // the file in exclusive mode
      catch (IOException exception)
      {
        string warningTrap = exception.Message;
        Thread.Sleep(1000);
      }

      // Log any other unhandled exceptions that are thrown
      catch (Exception exception)
      {
        WriteToEventLog(EventLogEntryType.Error,
                        "Unhandled exception occurred while waiting for " +
                        "\"{0}\" to become available." +
                        "\n\nType: {1}\nMessage:{2}",
                        executionInstances[0].FilePath,
                        exception.GetType().FullName,
                        exception.Message);
      }
    }

    fileStream.Close();
  }

  // Claim a resource from the counting semaphore and enter the critical
  // section
  executionRegulator.P();

  foreach (ExecutionInstance executionInstance in executionInstances)
  {
    try
    {
      // If we're running a pre-compiled application, start the
      // process
      if (executionInstance.EventHandler == null)
      {
        WriteToEventLog(EventLogEntryType.Information,
                        "Running program in response to event for " +
                        "{3} being {4}:\n\"{0}\"{1}{2}.",
                        executionInstance.StartInfo.FileName,
                        (executionInstance.StartInfo.Arguments != "" ?
                         " " + executionInstance.StartInfo.Arguments :
                         ""),
                        (executionInstance.RedirectFileToStdin ?
                         " < \"" + executionInstance.FilePath +
                         "\"" : ""),
                        executionInstance.FilePath,
                        executionInstance.EventType.ToString().ToLower());

        Process executionProcess =
           Process.Start(executionInstance.StartInfo);

        // If we're redirecting the file to the standard input stream, open
        // it up and read its contents into the stream in 1 KB chunks
        if (executionInstance.RedirectFileToStdin)
        {
          BinaryReader binaryReader =
             new BinaryReader(File.Open(executionInstance.FilePath,
                                        FileMode.Open));
          BinaryWriter binaryWriter =
             new BinaryWriter(executionProcess.StandardInput.BaseStream);
          byte[] buffer = new byte[1024];
          int readSize = binaryReader.Read(buffer, 0, buffer.Length);

          while (readSize != 0)
          {
            binaryWriter.Write(buffer, 0, readSize);
            readSize = binaryReader.Read(buffer, 0, buffer.Length);
          }

          binaryReader.Close();
          binaryWriter.Close();
        }

        // Wait for the process to exit and then clean it up
        executionProcess.WaitForExit();
        executionProcess.Close();
      }

      // Otherwise, invoke the OnFileSystemEvent() method for the
      // handler class defined in the runtime-compiled code
      else
      {
        WriteToEventLog(EventLogEntryType.Information,
                        "Invoking {0}.OnFileSystemEvent() in " +
                        "response to event for {1} being {2}.",
                        executionInstance.EventHandler.GetType().Name,
                        executionInstance.FilePath,
                        executionInstance.EventType.ToString().ToLower());

        FileInfo fileInfo = new FileInfo(executionInstance.FilePath);
        FileSystemEventArgs eventArguments =
           new FileSystemEventArgs(executionInstance.EventType,
                                   fileInfo.DirectoryName,
                                   fileInfo.Name);

        executionInstance.EventHandler.OnFileSystemEvent(
           eventArguments);
      }
    }

    // Log any exceptions that occur while running the program
    catch (Exception exception)
    {
      WriteToEventLog(EventLogEntryType.Error,
                      "Exception occurred while running \"{0}\"." +
                      "\n\nType: {1}\nMessage:{2}",
                      executionInstance.StartInfo.FileName,
                      exception.GetType().FullName, exception.Message);
    }
  }

  // Release the resource and exit the critical section
  executionRegulator.V();
}

The first thing it does is a trick to account for the "long copy" scenario. If a file is being created, but is being copied from somewhere, it could take a while if the file is large or if the transport protocol is slow. In either case, we don't want to start our handler applications until the file finishes copying, but the create event is fired when the copy operation first starts. So, we try to open the file with an exclusive lock, which will throw an IOException while the copy is still in progress. So, we catch that exception, sleep for one second, try again, and repeat until the open operation is successful. We then close the handle and continue executing. Before actually starting the programs, we claim a resource from the CountingSemaphore:

C#
// Claim a resource from the counting semaphore and enter the critical section
executionRegulator.P();

If there are resources available, then the call will return immediately and we will continue executing. Otherwise, the call will block until a resource becomes available. This gives us a way to control the number of running processes in an environment where the churn on a directory may be very high. Once a resource is claimed, we branch depending on what type of program (pre- or runtime-compiled) this is. If it's a pre-compiled program, we start the process and, if we're supposed to redirect the file to the process' standard input, we open a handle to the file and read it into the standard input stream. If it's a runtime compiled program, we create a FileSystemEventArgs object and invoke the OnFileSystemEvent() method of the IFileSystemEventHandler interface. After we've finished executing the program, we release our resource back to the CountingSemaphore:

C#
// Release the resource and exit the critical section
executionRegulator.V();

CountingSemaphore

The implementation of the CountingSemaphore class used for this service is basically a simple wrapper around the built-in Monitor class. It's all that was needed for this project, but you can check out a more full-featured implementation here.

C#
public class CountingSemaphore
{
  /// <summary>
  /// Resource limit for the semaphore
  /// </summary>
  private uint count;

  /// <summary>
  /// Default constructor; resource count is 1, meaning the class will act
  /// like a standard, binary semaphore.
  /// </summary>
  public CountingSemaphore() : this(1)
  {
  }

  /// <summary>
  /// Constructor that allows you to set the number of resources that should
  /// be available for consumption.
  /// </summary>
  /// <param name="count">Number of resources that should be available
  /// for consumption</param>
  public CountingSemaphore(uint count)
  {
    this.count = count;
  }

  /// <summary>
  /// Function that should be called when leaving the critical section; frees
  /// up one resource.
  /// </summary>
  public void AddOne()
  {
    V();
  }

  /// <summary>
  /// Function that should be called when entering a critical section; claims
  /// one resource or waits if no resources are available.
  /// </summary>
  public void WaitOne()
  {
    P();
  }

  /// <summary>
  /// Function that should be called when entering a critical section; claims
  /// one resource or waits if no resources are available.
  /// </summary>
  public void P()
  {
    lock(this)
    {
      while (count <= 0)
        Monitor.Wait(this, Timeout.Infinite);

      count--;
    }
  }

  /// <summary>
  /// Function that should be called when leaving the critical section; 
  /// frees up one resource.
  /// </summary>
  public void V()
  {
    lock(this)
    {
      count++;
      Monitor.Pulse(this);
    }
  }
}

Errata

Be sure to alter the account that the service runs under according to your needs. When watching folders only on the local machine, you can typically leave it as the Local System account, unless the ACLs on a directory explicitly exclude the SYSTEM user in which case you'll have to switch to a user with rights to that directory. When watching folders on a network share, however, you need to switch the account to one with local network access: on Windows XP and 2003 you can use the Network Service account (provided that the share is open to everyone), and on Windows 2000 you can use a domain account. However please note that, per the comments by Jeffrey Walton, this service (specifically the FileSystemWatcher class that it depends on) will not work when watching network shares on a non-Windows OS box, such as a share on an IBM eServer or a Samba share on a Linux box.

History

  • 2007-01-01 - Initial publication.
  • 2007-01-02 - Added the errata section.
  • 2007-01-06 - Added support for runtime-compiled event handling code.
  • 2007-02-08 - Fixed a bug where specifying <eventToWatch type="All"/> in the config file wasn't attaching event handlers properly.

License

This article, along with any associated source code and files, is licensed under The GNU General Public License (GPLv3)


Written By
Software Developer (Senior) AOC Solutions
United States United States
I'm a software architect in the truest sense of the word: I love writing code, designing systems, and solving tough problems in elegant ways. I got into this industry not for the money, but because I honestly can't imagine myself doing anything else. Over my career, I've worked on just about every major Microsoft web technology, running the gamut from SQL Server 7.0 to 2008, from .NET 1.1 to 4.0, and many others. I've made a name for myself and have risen to my current position by being able to visualize and code complex systems, all while keeping the code performant and extensible.

Both inside and outside of work, I love researching new technologies and using them sensibly to solve problems, not simply trying to force the newest square peg into the round hole. From emerging Microsoft projects like AppFabric to third party or open source efforts like MongoDB, nServiceBus, or ZeroMQ, I'm constantly trying to find the next technology that can eliminate single points of failure or help scale my data tier.

Outside of work, I'm a rabid DC sports fan and I love the outdoors, especially when I get a chance to hike or kayak.

Comments and Discussions

 
GeneralCan't get the service to start..... Pin
hognose2-Jun-07 9:41
hognose2-Jun-07 9:41 
GeneralRe: Can't get the service to start..... Pin
Luke Stratman2-Jun-07 10:07
Luke Stratman2-Jun-07 10:07 
GeneralRe: Can't get the service to start..... Pin
hognose4-Jun-07 14:20
hognose4-Jun-07 14:20 
GeneralRe: Can't get the service to start..... Pin
Luke Stratman4-Jun-07 14:41
Luke Stratman4-Jun-07 14:41 
GeneralRe: Can't get the service to start..... Pin
knightofbmc25-Mar-09 13:22
knightofbmc25-Mar-09 13:22 
GeneralGreat article! IncludeSubdirectories option question Pin
Denis Zabavchik19-Feb-07 14:28
Denis Zabavchik19-Feb-07 14:28 
GeneralNeed Help its urgent Pin
vikascdac18-Feb-07 6:19
vikascdac18-Feb-07 6:19 
GeneralRe: Need Help its urgent Pin
Luke Stratman18-Feb-07 7:57
Luke Stratman18-Feb-07 7:57 
I can answer your first and last questions, but the others sound specific to your event handling code or whatever library you're using to process the PDFs, so I'm not sure I can help there. As for the first question, my DirectoryWatcher service handles this exact scenario in the first few lines of the RunProgram() method: basically, if it's watching for a Created event then it tries to get an exclusive lock on the file by calling File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.None). If another process is still accessing the file (i.e. copying it over FTP or something similar), this call will throw an IOException. So, we catch that exception, sleep for one second, and then call File.Open() again. We repeat until we don't get an IOException, at which point we know the file is free for processing. Your last question seems related to this: I've never had a problem with using FileSystemWatcher in windows services once I understood how it works. Since the Created event is fired when a file is first created and not necessarily when whatever created the file is done creating or copying it, your event handling code has to take into account the fact that the file may not be available when it's invoked. Once you start using the File.Open() check, you should be able to deal with large files with FileSystemWatcher without any problems.
GeneralRe: Need Help its urgent Pin
vikascdac18-Feb-07 21:59
vikascdac18-Feb-07 21:59 
QuestionMultiple Watch Events Pin
Steve.H5-Feb-07 8:32
Steve.H5-Feb-07 8:32 
AnswerRe: Multiple Watch Events Pin
Luke Stratman5-Feb-07 11:01
Luke Stratman5-Feb-07 11:01 
AnswerRe: Multiple Watch Events Pin
Luke Stratman8-Feb-07 14:11
Luke Stratman8-Feb-07 14:11 
GeneralTop Stuff, Well Written Pin
robvon11-Jan-07 10:08
robvon11-Jan-07 10:08 
GeneralJust what the doctor ordered Pin
James H9-Jan-07 8:46
James H9-Jan-07 8:46 
GeneralRe: Just what the doctor ordered Pin
Luke Stratman9-Jan-07 9:21
Luke Stratman9-Jan-07 9:21 
GeneralRe: Just what the doctor ordered Pin
James H9-Jan-07 9:26
James H9-Jan-07 9:26 
GeneralVery Nice Pin
Jeffrey Walton2-Jan-07 0:17
Jeffrey Walton2-Jan-07 0:17 
GeneralRe: Very Nice Pin
Luke Stratman2-Jan-07 4:21
Luke Stratman2-Jan-07 4:21 
GeneralRe: Very Nice Pin
Jeffrey Walton2-Jan-07 9:33
Jeffrey Walton2-Jan-07 9:33 
GeneralNice work + humble request Pin
Ben Daniel1-Jan-07 19:54
Ben Daniel1-Jan-07 19:54 
GeneralRe: Nice work + humble request Pin
Luke Stratman2-Jan-07 7:14
Luke Stratman2-Jan-07 7:14 
GeneralRe: Nice work + humble request Pin
Luke Stratman6-Jan-07 6:40
Luke Stratman6-Jan-07 6:40 
GeneralRe: Nice work + humble request Pin
Ben Daniel8-Feb-07 16:19
Ben Daniel8-Feb-07 16:19 
GeneralRe: Nice work + humble request Pin
Luke Stratman9-Feb-07 2:28
Luke Stratman9-Feb-07 2:28 
GeneralJust in time !!! Pin
Garth J Lancaster1-Jan-07 15:28
professionalGarth J Lancaster1-Jan-07 15:28 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.