FileSystemWatcher

A precise guide to what the watcher delivers, how it rearms and stops, and where the Linux backend remains intentionally partial.

Linux inotifyCallbacksRemediated at source pin

Current scope

System::IO::FileSystemWatcher has a real Linux backend built on inotify, poll, and eventfd. It watches one configured directory on a dedicated native thread and raises Created, Deleted, Changed, Renamed, and Error handlers. The common single-directory case is implemented; the complete cross-platform .NET watcher is not.

PlatformBackendWhat enabling does
Linuxinotify plus a background dispatch threadArms the configured directory for the selected event classes
WindowsNoneThrows PlatformNotSupportedException; there is no ReadDirectoryChangesW backend
macOSNoneThrows PlatformNotSupportedException; there is no FSEvents/kqueue backend
Emscripten and other targetsNoneThrows PlatformNotSupportedException

The watcher is non-copyable and non-movable. Its thread captures this, so changing the object’s address while the thread runs would invalidate the callback owner. Destruction stops or reaps the thread before releasing its descriptors.

Public surface and configuration

MemberCurrent meaningImportant boundary
PathExisting directory to watchA live change retires the old watch before storing and arming the new path
FilterConvenience view over the first entry in FiltersPattern changes are not synchronized with the dispatch thread
FiltersMutable vector of name patternsEmpty vector admits all names; matching is current-implementation glob translation
NotifyFilterSelects name-event and/or content-event classesValues within each class are not faithfully distinguishable on inotify
IncludeSubdirectoriesStored and returnedHas no effect; only the configured directory is watched
InternalBufferSizeStored, with values below 4,096 clampedDoes not size the current inotify read buffer
EnableRaisingEventsStarts or stops deliveryStarting without a path records the enabled state but cannot arm until a path is assigned
Handler vectorsPublic vectors for five event familiesHandlers execute on the watcher thread; subscription mutation is not synchronized

The constructors that accept a path require a non-empty existing directory. A default-constructed watcher can be enabled before its path is assigned; no watch is created until a valid path arrives. Invalid NotifyFilter bits throw ArgumentException. NotifyFilters(0) is accepted as “watch no event class”: the enabled property remains set, no inotify watch is armed, and a later filter change can arm one.

Event flow

On Linux, enabling allocates an inotify descriptor, registers the directory, allocates a close-on-exec eventfd used to wake shutdown, and starts the watch thread. The thread blocks in poll on the inotify and stop descriptors. A stop signal wins before queued inotify data is dispatched, so queued-but-not-delivered events can be discarded when the watch is disabled or reconfigured.

Each native record becomes a Sharp Runtime event after name-pattern matching:

inotify inputSharp Runtime eventDetails
IN_CREATECreatedName and full path are built under the configured directory
IN_DELETEDeletedReports the removed directory entry
IN_MODIFY or IN_ATTRIBChangedThe backend cannot identify every .NET content property independently
Paired IN_MOVED_FROM/IN_MOVED_TORenamedPairs by inotify cookie within one read batch and carries old/new names
Unpaired IN_MOVED_TOCreatedRepresents an entry moved into the watched directory
Unpaired IN_MOVED_FROMDeletedRepresents an entry moved out of the watched directory

Handlers in each vector run in vector order on the same watcher thread. A slow handler delays every later handler and all subsequent event parsing. There is no dispatcher, synchronization context, thread pool handoff, or main-thread marshalling layer. Applications that need one should enqueue a small immutable message and return promptly.

Name filters

Filters is translated from a simple */? glob into a regular expression. *.* is treated as *, so extensionless names are not excluded. The regular expression uses case-insensitive matching even on Linux. An empty filter vector admits every name.

This filter is applied after the kernel reports an event. It reduces handler delivery, not kernel work. It is also a public mutable vector that the watch thread reads without a lock. Configure Filter/Filters before enabling, or disable and externally synchronize before mutation. Concurrent vector modification and event dispatch is not supported.

NotifyFilter: class separation is implemented

The current implementation divides NotifyFilters into two classes. A filter that names only one class no longer admits events from the other class, and changing NotifyFilter while enabled rebuilds the kernel watch so the new mask actually takes effect.

NotifyFilters valuesKernel maskPossible Sharp Runtime events
FileName, DirectoryNameIN_CREATE, IN_DELETE, IN_MOVED_FROM, IN_MOVED_TOCreated, Deleted, Renamed
Attributes, Size, LastWrite, LastAccess, CreationTime, SecurityIN_MODIFY, IN_ATTRIBChanged
No bitsNo watch maskNo event delivery

Within-class mapping remains reduced

The names in each class are not independently implemented:

  • FileName and DirectoryName behave alike; the current dispatch does not use IN_ISDIR to split them.
  • Size and LastWrite both rely on IN_MODIFY, which does not say which managed property changed.
  • IN_ATTRIB covers several metadata changes without identifying attributes, security, timestamps, or link-count changes separately.
  • Creation-time changes have no direct inotify event.
  • The current mask does not register IN_ACCESS, so LastAccess has no distinct source.

Accordingly, NotifyFilter is useful for choosing name events, content events, both, or none. It should not be described as a complete .NET property-level filter.

Live Path changes are rearmed safely

At older revisions, assigning Path on an enabled watcher changed only the stored string. The inotify descriptor continued watching the old directory, while event arguments were built under the new path. That could report a full path naming a file that never existed and also raced a string write against the dispatch thread.

At the selected source pin, a live path change follows a defined sequence:

  1. Validate the candidate while leaving the current watch untouched.
  2. Signal and join the current watcher thread.
  3. Remove and close the old native watch resources.
  4. Store the new directory only after the old thread can no longer read the field.
  5. Arm a new watch if the component was enabled.

An invalid candidate leaves the existing live watch intact. If the new directory validated but native rearming later fails, the old watch is not restored; the enabled state becomes false and the error path is raised. Queued events from the old watch are discarded during reconfiguration.

Callback reentrancy and the self-stop repair

Handlers run on the watcher thread. Earlier code treated every reconfiguration as an external call and unconditionally joined that thread. A handler that disabled its own watcher therefore attempted to join itself, raised std::system_error, and—because event-handler calls were not caught—could reach std::terminate.

The selected revision includes the dedicated callback self-stop repair:

Operation inside an event handlerCurrent resultReason
Set EnableRaisingEvents=falsePermittedSignals stop without self-joining; the loop exits after the handler returns
Set PathThrows InvalidOperationExceptionRearming must retire the thread that is currently dispatching
Set NotifyFilterThrows InvalidOperationExceptionThe kernel mask also requires a rearm
Throw from a normal event handlerException is delivered to Error handlersIt no longer escapes the native watch thread into std::terminate
Throw from an Error handler while a callback fault is being forwardedSwallowedPrevents recursive error delivery and watch-thread termination on that forwarding path

A thread-local marker identifies the watcher currently dispatching on each thread, avoiding a race on the std::thread object itself. A callback-triggered stop leaves the finished thread joinable; the next safe external reconfiguration or the destructor reaps it and closes the descriptors. Do not attempt a stop-and-immediate-reenable sequence inside the same callback; perform later reconfiguration from another thread after the callback returns.

The old termination warning is obsolete

Callback-triggered disable is no longer a self-join/termination risk at this source pin. The remaining restrictions are narrower: path/filter reconfiguration from the callback is rejected, and general concurrent mutation of public vectors is still outside the synchronized contract.

Disabling and teardown

An external disable writes the stop eventfd, joins the watcher thread, removes the inotify watch, and closes all descriptors before returning. Activity occurring after the setter returns does not produce a handler. Repeated disable is safe, and the watcher can be armed again.

The source records a narrower unanswered concurrency question: it has not established a separately measured contract for whether a handler that was already executing at the instant another thread begins disabling can still be considered running at the setter’s return boundary. Code should not use watcher disable as its only application-level barrier for arbitrary work a handler launched elsewhere.

Errors, buffering, and loss

Native startup failures can disable the watcher and report an IOException through the Error handler path. Exceptions from normal event handlers are also wrapped in ErrorEventArgs and delivered on the watcher thread. The callback-fault forwarding path catches exceptions from Error handlers to prevent recursion; startup/rearm error delivery is synchronous on the configuring thread and does not use that wrapper.

InternalBufferSize does not currently control the allocation used by read(inotify). The loop allocates a fixed vector sized for sixteen maximum-name events. More importantly, Linux maintains its own inotify queue; a long-running handler or burst of filesystem activity can overflow that queue. The implementation has no explicit IN_Q_OVERFLOW translation into InternalBufferOverflowException or Error. Applications must treat events as change hints and reconcile important state by rescanning the directory.

Recursive watching is not implemented

IncludeSubdirectories is stored but ignored. A recursive implementation would need a watch per existing subdirectory, dynamic registration for directories created later, removal handling, rename/move reconciliation, queue-overflow recovery, and a platform-independent policy. None of that is provided by the current single-watch loop.

Thread-safety boundary

The lifecycle fields needed by callback self-stop are atomic or confined by joining, but the object is not generally thread-safe. Handler vectors and name-filter vectors are public containers read directly by the dispatch loop. Adding/removing a handler, replacing Filter, or mutating Filters concurrently with delivery can race and can invalidate iterators.

A safe application pattern is:

  1. Construct and fully configure the watcher.
  2. Register handlers before enabling.
  3. Keep callbacks short and transfer work to an application-owned queue.
  4. For configuration changes, disable or use the implemented external Path/NotifyFilter rearm, and ensure no other thread mutates public vectors.
  5. Destroy the watcher only after application work no longer retains its address.
  6. Periodically reconcile authoritative directory state instead of assuming an event stream is lossless.

C#/.NET comparison

C# / .NET expectationSharp Runtime at this pinPorting consequence
Windows, Linux, and macOS backendsLinux/inotify onlyGate the feature or supply another backend outside Linux
Optional recursive tree watchingProperty stored, behavior absentImplement explicit traversal/polling or restrict the product requirement
Notify filters identify managed propertiesName/content classes only; values within a class collapseInspect the changed path rather than trusting a fine-grained reason
Internal buffer size affects event bufferingProperty does not size the actual read bufferDo not tune reliability through this property
Callback may disable watcherSupported by deferred self-stopNo special catch around the disable is required
Callback reconfigures path/filterRejected with InvalidOperationExceptionSchedule reconfiguration on another thread
Events are notifications, not a transactional logSame practical rule, with explicit overflow gapsRescan after bursts, errors, and startup/rearm boundaries