Skip to content

Fix NativeWatcher missing changes made straight after startup - #2003

Merged
robhogan merged 2 commits into
mainfrom
pr2003
Oct 26, 2026
Merged

robhogan merged 2 commits into
mainfrom
pr2003

Conversation

@robhogan

@robhogan robhogan commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

NativeWatcher.startWatching resolves as soon as fs.watch returns, but on macOS that can before we’re actually listening for changes. libuv only signals another thread to (re)create the FSEvents stream, and the stream reports only changes made after it starts:

https://github.com/libuv/libuv/blob/2b4b918d3381100854250c89d5159d4206daafb7/src/unix/fsevents.c#L362-L372

So a change made straight after startup or during crawl (since #2054) can be silently missed.

This is real - especially on a slow host the gap is long enough to hit regularly - it's the remaining flake in the NativeWatcher integration tests, where the test’s first event never arrives. 14 of 297 macOS test jobs in CI since #1974 hit it.

This change has Watcher confirm each root's watch before relying on it, if the backend declares itself not to be live on start, using the established pattern of a health check cookie. Once every backend has started, it writes a cookie into each root and waits for it to be reported. If a watch can't be confirmed within 10s, Metro warns and carries on. Roots added later with addRoot are confirmed the same way.

Only the NativeWatcher backend returns false from the new static isLiveOnStart() mechanism. Watchman doesn’t need it due to the clock mechanism, and FallbackWatcher doesn’t need it on Windows or Linux because fs.watch establishes a shallow watch before returning.

This adds ~10ms to a watch-mode start on macOS without Watchman (145ms -> 155ms on our benchmark app). We’ll recover that in the stack of #2104.

Changelog:

 - **[Fix]**: Fix changes made immediately after startup sometimes being missed on macOS

Test plan:
New Watcher-test.js cases cover confirming each root before the crawl, warning and carrying on when a watch can't be confirmed, confirming a root added later, and leaving backends that are live on start alone. The backend integration tests now wait for a cookie of their own, as Watcher does.

To reproduce the race, I patched NativeWatcher to drop all events for its first 300ms. A FileMap in watch mode with two roots, writing a file to each as soon as build() resolves, sees neither change without this PR (3/3 runs) and both with it (3/3).

The metro-file-map suite passes on macOS, Linux and Windows. Benchmark (AI-driven): the harness from #2104, 8 interleaved rounds on an M4 Pro Mac mini, n=32 warm watch-mode starts per variant (+6.7%, 95% CI +5.2% to +8.0%).

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Sep 30, 2026
`NativeWatcher.startWatching` resolves as soon as `fs.watch` returns, but on macOS that can be before any changes are reported. libuv only signals another thread to (re)create the FSEvents stream, and the stream reports only changes made after it starts:

https://github.com/libuv/libuv/blob/2b4b918d3381100854250c89d5159d4206daafb7/src/unix/fsevents.c#L362-L372

So a change made straight after startup can be silently missed, and since #2054 started non-Watchman backends before the crawl, so can one made during the crawl. On a slow host the gap is long enough to hit regularly - it's the remaining flake in the Native watcher integration tests, where the suite's first event never arrives. 14 of 297 macOS test jobs since #1974 hit it, across all three Node versions.

This has `Watcher` confirm each root's watch before relying on it. Once every backend has started, it writes a health check file into each root and waits for it to be reported, as `checkHealth()` already does for the project root. If a watch can't be confirmed within 10s, Metro warns and carries on. Roots added later with `addRoot` are confirmed the same way.

A backend opts in by returning `false` from a new static `isLiveOnStart()`, which only `NativeWatcher` does - Watchman subscribes from the crawl's clock, and `FallbackWatcher`'s watches are live as soon as `fs.watch` returns. It's done in `Watcher`, after all the backends have started, because `NativeWatcher`s for multiple roots share one FSEvents stream, which restarts as each root is added.

This adds ~10ms to a watch-mode start on macOS without Watchman (145ms -> 155ms on our benchmark app).

Changelog:
```
 - **[Fix]**: Fix changes made immediately after startup sometimes being missed on macOS
```

Test plan:
New `Watcher-test.js` cases cover confirming each root before the crawl, warning and carrying on when a watch can't be confirmed, confirming a root added later, and leaving backends that are live on start alone. The backend integration tests now wait for a cookie of their own, as `Watcher` does.

To reproduce the race, I patched `NativeWatcher` to drop all events for its first 300ms. A `FileMap` in watch mode with two roots, writing a file to each as soon as `build()` resolves, sees neither change without this PR (3/3 runs) and both with it (3/3).

The `metro-file-map` suite passes on macOS, Linux and Windows. Benchmark (AI-driven): the harness from #2104, 8 interleaved rounds on an M4 Pro Mac mini, n=32 warm watch-mode starts per variant (+6.7%, 95% CI +5.2% to +8.0%).
@robhogan
robhogan marked this pull request as ready for review October 26, 2026 10:20
@robhogan
robhogan requested review from huntie and vzaidman October 26, 2026 10:21
@robhogan
robhogan merged commit f3b2eeb into main Oct 26, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants