dotnet / dotnet/runtime

FileSystemWatcher on macOS: process-wide serialization + blocking fseventsd RPC convoys under watcher churn (seconds-to-minutes stalls)

Open
#131,323 4 comments 0 reactions 0 assignees View on GitHub
area-System.IO needs-further-triage tenet-performance
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

### Description

On macOS, `FileSystemWatcher` start/stop operations serialize **process-wide** on a static lock in `StaticWatcherRunLoopManager` ([FileSystemWatcher.OSX.cs](https://github.com/dotnet/runtime/blob/v10.0.9/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.OSX.cs) — `s_lockObject` guarding `ScheduleEventStream`/`UnscheduleFromRunLoop`), and the start path performs a blocking mach RPC to the machine-global `fseventsd` daemon (`FSEventStreamStart → register_with_server → f2d_register_rpc → mach_msg`).

Under watcher churn — many short-lived watchers created and disposed from several threads — these two serialization points convoy: individual `EnableRaisingEvents = true` calls stall for **seconds to over a minute** on an otherwise idle machine. Because `fseventsd` is machine-global, concurrent processes churning watchers amplify each other across process boundaries.

The mainstream way to hit this without ever touching `FileSystemWatcher` directly is integration testing: `Host`/`WebApplication` config uses `reloadOnChange: true` by default (appsettings.json, appsettings.{env}.json, user secrets — ~3 watchers per host), and a `WebApplicationFactory`-per-test suite builds hundreds of hosts per run. In our real suite (~650 hosts/run) the stalls routinely exceeded `Microsoft.AspNetCore.Mvc.Testing`'s 5-minute entry-point wait, producing the widely-reported *"Timed out waiting for the entry point to build the IHost after 00:05:00"* — and worse: after the factory times out and unsubscribes its diagnostic-listener capture, the still-parked entry point eventually finishes building **un-intercepted** and runs the *real* application (real Kestrel, real database connections) inside the test process. Disabling the reload watchers (`hostBuilder:reloadConfigOnChange=false`) took the same suite from 11–35 minutes with 2–41 such timeouts per run to **~20 seconds with zero**, which is what pinned the watchers as the cause.

### Reproduction Steps

Self-contained console app (no ASP.NET involved). 8 threads × 100 iterations; each iteration creates 3 watchers on the thread's own temp directory with `EnableRaisingEvents = true`, then disposes them — mimicking a config-reload host churn.

```xml


Exe
net10.0
enable
enable

```

```csharp
// Program.cs
using System.Collections.Concurrent;
using System.Diagnostics;

int threads = args.Length > 0 ? int.Parse(args[0]) : 8;
int iterations = args.Length > 1 ? int.Parse(args[1]) : 100;
int watchersPerOp = args.Length > 2 ? int.Parse(args[2]) : 3;

var latencies = new ConcurrentBag();
var stalls = new ConcurrentBag();

var workers = Enumerable.Range(0, threads).Select(w => new Thread(() =>
{
var dir = Directory.CreateTempSubdirectory($"fsw-{w}-").FullName;
for (var i = 0; i < iterations; i++)
{
var sw = Stopwatch.StartNew();
var ws = new List();
for (var k = 0; k < watchersPerOp; k++)
{
ws.Add(new FileSystemWatcher(dir, "*.json") { EnableRaisingEvents = true });
}
foreach (var fw in ws) fw.Dispose();
sw.Stop();
latencies.Add(sw.Elapsed.TotalMilliseconds);
if (sw.Elapsed.TotalSeconds >= 1) stalls.Add(sw.Elapsed.TotalSeconds);
}
Directory.Delete(dir, true);
})).ToList();

var total = Stopwatch.StartNew();
workers.ForEach(t => t.Start());
workers.ForEach(t => t.Join());
total.Stop();

var sorted = latencies.OrderBy(x => x).ToArray();
Console.WriteLine($"threads={threads} iterations={iterations} watchersPerOp={watchersPerOp}");
Console.WriteLine($"total={total.Elapsed.TotalSeconds:F1}s ops={sorted.Length}");
Console.WriteLine($"latency ms: p50={sorted[sorted.Length/2]:F0} p90={sorted[(int)(sorted.Length*0.9)]:F0} p99={sorted[(int)(sorted.Length*0.99)]:F0} max={sorted[^1]:F0}");
Console.WriteLine($"ops taking >=1s: {stalls.Count}" + (stalls.Count > 0 ? $" (worst {stalls.Max():F1}s)" : ""));
```

`dotnet run -c Release`

### Expected behavior

Starting and stopping a `FileSystemWatcher` is a lightweight operation; concurrent watcher churn from a few threads should complete in milliseconds per operation, without process-wide serialization or unbounded stalls.

### Actual behavior

On an otherwise idle machine:

```
threads=8 iterations=100 watchersPerOp=3
total=91.3s ops=800
latency ms: p50=337 p90=356 p99=8224 max=68753
ops taking >=1s: 8 (worst 68.8s)
```

A median of **337 ms** to start+stop three watchers, a p99 of **8.2 s**, and a worst case of **68.8 s** for a single iteration. Under additional machine load (e.g. two test runs in parallel — `fseventsd` is machine-global) the tail grows into minutes; native stacks captured with macOS `sample` during a stalled run show the parked threads inside the FSEvents registration RPC (excerpt attached):

```
FSEventStreamStart (in FSEvents)
register_with_server (in FSEvents)
f2d_register_rpc (in FSEvents)
mach_msg / mach_msg2_trap (in libsystem_kernel.dylib)
```

with the queue behind them waiting on `StaticWatcherRunLoopManager.s_lockObject` (and others in `FSEventStreamScheduleWithRunLoop` / `FSEventStreamInvalidate` on the shared run loop).

[sample-stacks-excerpt.txt](https://github.com/user-attachments/files/30351570/sample-stacks-excerpt.txt)

### Regression?

Not believed to be a regression; the shared-run-loop design with the static lock has been in place for years (dotnet/runtime#26577 replaced per-watcher threads with the shared run loop). The impact has grown with the prevalence of `WebApplicationFactory`-per-test integration suites on minimal hosting.

### Known Workarounds

- For hosting-based test suites: pass `hostBuilder:reloadConfigOnChange=false` (host configuration) so test hosts create no reload watchers. This took our ~650-host suite from 11–35 min with 2–41 five-minute IHost-build timeouts per run to ~20 s with zero.
- Generally: avoid short-lived `FileSystemWatcher` churn on macOS.

### Configuration

- .NET SDK 10.0.302, runtime 10.0.10 (`Microsoft.NETCore.App 10.0.10`)
- macOS 26.5.1, arm64 (Apple Silicon, 18 cores)
- Reproduces in-process with the plain console app above; amplification observed across processes (machine-global `fseventsd`)

### Other information

The serialization points are: (1) `StaticWatcherRunLoopManager.ScheduleEventStream` — a single static lock for every watcher schedule/unschedule in the process, held around CFRunLoop bookkeeping, with the first scheduler also blocking on run-loop thread startup; (2) `FSEventStreamStart`'s synchronous registration RPC to `fseventsd`. Possible directions: per-stream `dispatch_queue_t` scheduling via `FSEventStreamSetDispatchQueue` (avoids the shared run loop entirely), and/or moving `FSEventStreamStart` off the lock.

Contributor guide

Open the contributing guide

Research direction

Start in src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.OSX.cs, focusing on StaticWatcherRunLoopManager, ScheduleEventStream, and UnscheduleFromRunLoop. Run the supplied Program.cs reproduction with dotnet run -c Release, then trace the lock and FSEventStreamStart behavior; done should include a validated design and tests or measurements showing watcher churn no longer causes the reported stalls.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp, macos
Domain
operating-systems
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.