FileSystemWatcher
A precise guide to what the watcher delivers, how it rearms and stops, and where the Linux backend remains intentionally partial.
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.
| Platform | Backend | What enabling does |
|---|---|---|
| Linux | inotify plus a background dispatch thread | Arms the configured directory for the selected event classes |
| Windows | None | Throws PlatformNotSupportedException; there is no ReadDirectoryChangesW backend |
| macOS | None | Throws PlatformNotSupportedException; there is no FSEvents/kqueue backend |
| Emscripten and other targets | None | Throws 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
| Member | Current meaning | Important boundary |
|---|---|---|
Path | Existing directory to watch | A live change retires the old watch before storing and arming the new path |
Filter | Convenience view over the first entry in Filters | Pattern changes are not synchronized with the dispatch thread |
Filters | Mutable vector of name patterns | Empty vector admits all names; matching is current-implementation glob translation |
NotifyFilter | Selects name-event and/or content-event classes | Values within each class are not faithfully distinguishable on inotify |
IncludeSubdirectories | Stored and returned | Has no effect; only the configured directory is watched |
InternalBufferSize | Stored, with values below 4,096 clamped | Does not size the current inotify read buffer |
EnableRaisingEvents | Starts or stops delivery | Starting without a path records the enabled state but cannot arm until a path is assigned |
| Handler vectors | Public vectors for five event families | Handlers 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 input | Sharp Runtime event | Details |
|---|---|---|
IN_CREATE | Created | Name and full path are built under the configured directory |
IN_DELETE | Deleted | Reports the removed directory entry |
IN_MODIFY or IN_ATTRIB | Changed | The backend cannot identify every .NET content property independently |
Paired IN_MOVED_FROM/IN_MOVED_TO | Renamed | Pairs by inotify cookie within one read batch and carries old/new names |
Unpaired IN_MOVED_TO | Created | Represents an entry moved into the watched directory |
Unpaired IN_MOVED_FROM | Deleted | Represents 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 values | Kernel mask | Possible Sharp Runtime events |
|---|---|---|
FileName, DirectoryName | IN_CREATE, IN_DELETE, IN_MOVED_FROM, IN_MOVED_TO | Created, Deleted, Renamed |
Attributes, Size, LastWrite, LastAccess, CreationTime, Security | IN_MODIFY, IN_ATTRIB | Changed |
| No bits | No watch mask | No event delivery |
Within-class mapping remains reduced
The names in each class are not independently implemented:
FileNameandDirectoryNamebehave alike; the current dispatch does not useIN_ISDIRto split them.SizeandLastWriteboth rely onIN_MODIFY, which does not say which managed property changed.IN_ATTRIBcovers 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, soLastAccesshas 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:
- Validate the candidate while leaving the current watch untouched.
- Signal and join the current watcher thread.
- Remove and close the old native watch resources.
- Store the new directory only after the old thread can no longer read the field.
- 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 handler | Current result | Reason |
|---|---|---|
Set EnableRaisingEvents=false | Permitted | Signals stop without self-joining; the loop exits after the handler returns |
Set Path | Throws InvalidOperationException | Rearming must retire the thread that is currently dispatching |
Set NotifyFilter | Throws InvalidOperationException | The kernel mask also requires a rearm |
| Throw from a normal event handler | Exception is delivered to Error handlers | It no longer escapes the native watch thread into std::terminate |
Throw from an Error handler while a callback fault is being forwarded | Swallowed | Prevents 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.
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:
- Construct and fully configure the watcher.
- Register handlers before enabling.
- Keep callbacks short and transfer work to an application-owned queue.
- For configuration changes, disable or use the implemented external
Path/NotifyFilterrearm, and ensure no other thread mutates public vectors. - Destroy the watcher only after application work no longer retains its address.
- Periodically reconcile authoritative directory state instead of assuming an event stream is lossless.
C#/.NET comparison
| C# / .NET expectation | Sharp Runtime at this pin | Porting consequence |
|---|---|---|
| Windows, Linux, and macOS backends | Linux/inotify only | Gate the feature or supply another backend outside Linux |
| Optional recursive tree watching | Property stored, behavior absent | Implement explicit traversal/polling or restrict the product requirement |
| Notify filters identify managed properties | Name/content classes only; values within a class collapse | Inspect the changed path rather than trusting a fine-grained reason |
| Internal buffer size affects event buffering | Property does not size the actual read buffer | Do not tune reliability through this property |
| Callback may disable watcher | Supported by deferred self-stop | No special catch around the disable is required |
| Callback reconfigures path/filter | Rejected with InvalidOperationException | Schedule reconfiguration on another thread |
| Events are notifications, not a transactional log | Same practical rule, with explicit overflow gaps | Rescan after bursts, errors, and startup/rearm boundaries |