Skip to content

Report watcher backend errors without terminating Metro - #2147

Open
sorinc03 wants to merge 3 commits into
react:mainfrom
sorinc03:fix/report-watcher-backend-errors
Open

sorinc03 wants to merge 3 commits into
react:mainfrom
sorinc03:fix/report-watcher-backend-errors

Conversation

@sorinc03

@sorinc03 sorinc03 commented Nov 2, 2026 •

Copy link
Copy Markdown

Summary

Watcher backend errors can terminate Metro or leave users unaware that file changes have stopped arriving. Handle those errors through the existing diagnostics and preserve startup failures for callers.

  • Forward native filesystem watcher errors, including late errors after shutdown.
  • Reject failed backend starts with their original error and clear the startup deadline.
  • Retry failed Watchman reconnects with bounded exponential backoff. Warn after successful reconnection that changes during the outage may have been missed and a restart resynchronizes the file map.
  • Include the affected directory and restart guidance when a FallbackWatcher loses a watch. Preserve the original error as its cause and retain the error code for existing ignore and resource-limit handling.
  • Bound ENOSPC/EMFILE reports per backend and close retired Watchman clients.

Fixes #2143.

Changelog: [Fix] Report file-watcher errors and recover Watchman connections without unhandled backend errors

Test plan

  • Full yarn test --no-watchman --runInBand passes Flow, ESLint, Markdown lint, format, declaration/build generation, and all 165 Jest suites.
  • 3,094 tests passed, 14 existing tests skipped, and 495 snapshots passed.
  • Focused coverage exercises startup rejection and synchronous throws, the startup deadline, native error forwarding and late errors, reconnect recovery warnings, and directory-specific lost-watch diagnostics.
  • A real NativeWatcher missing-root regression fails against the original async Promise executor and passes with this change, preserving ENOENT and leaving no deadline timer.
  • Prior reconnect tests use the real fb-watchman client with its process/socket boundary controlled.
  • Diff checks pass.

Validation ran on macOS. The separate startup-cleanup PR #2150 covers sibling, pending, and late-start resource cleanup and should be coordinated when landing. Reconnect subscriptions retain the existing fresh-clock behavior, with restart guidance for missed changes.

@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 Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Metro review: Changes suggested

Risk: medium · Commit: 0faadf1 · 10 commands run, in a restricted sandbox after an automated safety assessment

This PR routes watcher backend errors to diagnostics instead of crashing Metro, rejects failed starts with their original error, and retries Watchman with backoff; #2151 was closed in favor of it, and its author notes overlap with #2150 to coordinate at landing. All seven replied findings are fixed and hold up, and the PR's tests pass on macOS, Linux and Windows. One gap remains: when NativeWatcher's only handle fails, Metro keeps running but stops seeing changes for that root, and the log doesn't say so.

Findings

Verified
  • ❌ yarn jest --no-watchman packages/metro-file-map/src/__tests__/Watcher-test.js packages/metro-file-map/src/__tests__/WatcherErrors-test.js packages/metro-file-map/src/watchers — macOS sandbox, head: 121 of 132 pass. All 11 failures are Watchman integration tests, because no Watchman binary is available here.
  • ❌ yarn jest --no-watchman packages/metro-file-map/src/watchers/__tests__/integration-test.js (base, compared with head) — Same 11 failed, 23 passed on head and base, so the failures aren't from this PR.
  • ✅ yarn jest --no-watchman packages/metro-file-map/src/__tests__/review-native-lost-test.js (failed as expected) — Repro on head: after a real handle fails, a later change isn't delivered and the log doesn't explain the loss.
  • ✅ yarn jest --no-watchman review-WatcherErrors-test.js -t 'rejects with the native' (PR test copied to base) (failed as expected) — Base gives 'Failed to start watch mode.' instead of ENOENT, confirming the test plan's claim that this regression fails without the change.
  • ✅ yarn eslint packages/metro-file-map/src/Watcher.js packages/metro-file-map/src/watchers packages/metro-file-map/src/__tests__/WatcherErrors-test.js
  • ✅ yarn typecheck — Flow: no errors.
  • ✅ linux_run jest <6 changed test suites> — Linux, Node 24: 63/63.
  • ✅ windows_run jest <6 changed test suites> (Windows) — Windows, Node 24: 63/63.
  • ✅ linux_run jest packages/metro-file-map — 36/36 suites; 809 passed, 25 skipped.
  • ✅ windows_run jest packages/metro-file-map/src/watchers (Windows) — 7/7 suites; 77 passed, 23 skipped.

Not run: No benchmark: the change adds one error listener per backend plus error and reconnect paths, with no per-file or per-event cost. Not run: the full yarn test totals in the test plan, a real Watchman daemon (reconnects rest on the PR's tests and code reading this round), and real OS watch-limit exhaustion. The late-start cleanup in #2150 couldn't be read, so that overlap wasn't assessed.

Automated review by Claude. Advisory only: it never approves or blocks, and a maintainer makes the call.

MAX_WAIT_TIME,
);

watcher.onError(error => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix · A failed Watchman reconnect now leaves a permanently dead watcher with one log line

Logging is the right result for a one-off lstat failure, but for a Watchman client error it means watching has stopped for good, and nothing recovers or says so.

Failure case: Watchman goes away, so the client gets end and WatchmanWatcher.#init reconnects once. If that attempt fails (socket connect error, or watchman get-sockname can't spawn), fb-watchman emits error but not end, so no further retry happens. Before this PR the process exited and the user restarted it. Now Metro keeps running and serves stale bundles, because no file event ever arrives again. The only signal is one watchman watcher error line that doesn't say changes are no longer seen. The health check that could expose this defaults to enabled: false.

From reading fb-watchman, a socket error without end on an established connection (e.g. ECONNRESET) reaches the same state without even one reconnect attempt. I did not exercise that path.

Suggested fix: handle this in WatchmanWatcher: on a client error or failed #init, retry with backoff until a new subscription is live, since a re-subscribe can recover. If retrying is out of scope, keep Watchman connection failures fatal, or at least log clearly that file watching has stopped and Metro must be restarted. A test should cover connection loss followed by a failed reconnect.

Reproduced: repro/watchman-repro.js dead, using the real Watcher and fb-watchman against a fake Watchman socket. The server closes, the reconnect fails, then the server returns. Head logs watchman watcher error ... connect ENOENT, then prints RESULT connections after Watchman came back: 0 after 5s. Base exits with RESULT UNCAUGHT: connect ENOENT. · id 79065045

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e051d28. Failed Watchman reconnects now retry with exponential backoff from 1 second up to 30 seconds, resetting after a successful subscription. Coverage includes transport, watch-project, clock and subscribe failures, a later successful subscription, continued file events and cancellation on stop. The existing fresh-clock semantics are unchanged; this does not claim replay of changes during an outage. Full validation passes: 165 suites, 3,076 tests, 495 snapshots (14 skipped), plus Flow/lint/format/build.

);

watcher.onError(error => {
this.#options.console.error(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix · Hitting the watch limit logs a separate error and stack for every remaining directory

FallbackWatcher.#watchdir reports each fs.watch failure and carries on, so with this listener ENOSPC or EMFILE produces one console.error, with its stack, per unwatched directory.

Failure case: on Linux, a project with more directories than fs.inotify.max_user_watches used to stop on the first error, with a clear message. It now starts normally after printing an error for every directory past the limit. That can be many thousands of stack traces, burying the Metro UI and the one useful line, and the directories past the limit go unwatched.

Suggested fix: report resource-limit errors once per backend, or as one summary after startWatching, saying that changes in unwatched directories won't be detected and how to raise the limit. Add a test that fails fs.watch for many directories through the real Watcher and FallbackWatcher.

Reproduced: repro/flood-repro.js <checkout> 3000 100, using the real Watcher and FallbackWatcher with fs.watch throwing ENOSPC after 100 watches (a simulated limit, not real inotify exhaustion). Head prints RESULT started normally; console.error calls: 2901 of 3000 dirs (limit 100). Base prints RESULT process would exit, unhandled rejection: ENOSPC.... · id 488248c3

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e051d28. ENOSPC and EMFILE are reported once per code per backend instead of once per remaining directory, with explicit guidance that unwatched directories will not deliver changes. The regression uses the real FallbackWatcher over 128 temporary directories with simulated fs.watch limit failures, verifies the crawl still visits them, and verifies a separate root gets its own diagnostic. No actual OS-wide watch limit was exhausted.

this.#emitter.removeAllListeners();
// An in-flight filesystem operation can still report an error after the
// owner has detached. Do not turn that late error into an uncaught throw.
this.#emitter.on('error', () => {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix · A Watchman client error after shutdown still crashes the process

The no-op listener only protects the backend's own emitter. WatchmanWatcher.stopWatching then calls this.#client.removeAllListeners() and this.#client.end(), which leaves the fb-watchman Client with no error listener while its socket can still fail. The client re-emits socket errors on itself, so a late one is an uncaught exception. The changelog says errors from in-flight work after shutdown are covered, and this is one such case.

Failure case: Watcher.close() runs while the backend is reconnecting after losing Watchman. The pending connect then fails and the process dies with an uncaught connect ENOENT. The window is narrow, but it's the crash this PR sets out to remove, and it can hit during normal shutdown.

Suggested fix: after removeAllListeners() in WatchmanWatcher.stopWatching, add this.#client.on('error', () => {}) before end(). Do the same for the old client that #init detaches on reconnect. Add a regression test that emits a client error after stopWatching; the new AbstractWatcher test can't catch this because it never involves a client.

Reproduced: repro/watchman-repro.js stop on head, using the real fb-watchman client and calling Watcher.close() on the tick after the Lost connection warning, prints close() called during reconnect, then RESULT UNCAUGHT: connect ENOENT .../w34883.sock. · id 8665ef2f

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e051d28. Stopping now cancels reconnect timers and retires the client; stale command/subscription callbacks are ignored. Retired clients close late connections and absorb/close late errors, including a socket discovered after teardown. Two regressions exercise the actual fb-watchman client while mocking only its process/socket boundary, alongside lifecycle tests for replaced clients and in-flight replies. Full validation passes: 165 suites and 3,076 tests. This overlaps the Watchman shutdown portion of #2150, so those should be coordinated when landing.

this.emitError(error);
this.#scheduleReconnect();
} else {
onError(error);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix · A backend that fails during startup still terminates Metro, now through an unhandled rejection

onFailure now routes a client error during startup to onError, which rejects startWatching(). That promise is awaited inside the async executor at Watcher.js:382 (new Promise(async (resolve, reject) => {... await watcher.startWatching(); ...})), so nothing catches the rejection. The executor's own promise rejects unhandled, the promise watch() is waiting on stays pending, and Node exits the process.

Failure case: Watchman goes away between a successful crawl and the watcher's subscription, so the socket connect fails with connect ENOENT. Metro dies with an uncaught error from a promise, and watch() never gets to reject. The base also crashes here, by the unhandled error event, so this isn't a regression. But the changelog says file-watcher errors no longer terminate Metro, and this path still does. The same executor covers every backend: NativeWatcher.startWatching throwing ENOENT from fs.watch for a missing root kills the process the same way, and so can a failure while FileMap adds a root through watchRoot.

Suggested fix: in createWatcherBackend, wrap the await watcher.startWatching() in try/catch, then clear the timeout and call reject(error). watch() and watchRoot() then reject normally and their callers can report the failure. Add a test with a real backend whose startWatching rejects.

Reproduced: repro/startup-failure.js runs the real Watcher in a plain Node process. With WATCHMAN_SOCK pointing at a dead socket, head dies at triggerUncaughtException(err, true /* fromPromise */) with connect ENOENT, and the watch() handlers never run. The native-missing-root scenario dies with ENOENT on syscall: 'watch'. Base dies in both scenarios too, through emitError for Watchman. Exit codes weren't captured; the fatal stack traces show the deaths. · id 5feb8592

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0faadf1. Backend startup uses a synchronous Promise executor and forwards the original rejection while clearing the deadline. The added real NativeWatcher missing-root regression reports ENOENT promptly and leaves no timer. Broader pending/sibling/late-start cleanup remains covered by #2150, so those changes should be coordinated when landing.

this.#deferringStates = new Set(resp['asserted-states']);
}

ready = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix · After a Watchman outage, Metro recovers without saying that changes made during it were missed

Each reconnect subscribes from a fresh clock, and a successful one logs nothing. Retrying is the right fix for the dead watcher, but it makes this state longer-lived and more likely to go unnoticed. Previously a failed reconnect left a dead watcher or a crash, and a restart meant a fresh crawl. Now Metro keeps serving through an outage of any length and then appears healthy.

Failure case: Watchman stops for 30 seconds, and the user edits src/a.js and deletes src/b.js. The subscription returns with a clock taken after those changes, so neither is ever reported. Metro keeps serving the old a.js and still resolves b.js, until each file happens to change again. The only output is the Lost connection warning and connect ENOENT errors, and none of it mentions missed changes. Even a clean Watchman restart with no failed attempts now has a blind window of at least 1 second, because the first reconnect used to be immediate and is now delayed.

The PR description declares replay out of scope, which is a reasonable place to draw the line, but users should still be told.

Suggested fix: at minimum, warn once when a reconnect succeeds, saying that changes made while disconnected may have been missed and that a restart resyncs them. Better, resynchronize: resubscribe since the last clock seen, handling is_fresh_instance if Watchman itself restarted, or have Watcher recrawl the root after a reconnect. Watcher currently rejects recrawl events from Watchman, so the recrawl route would need a change there. Consider making the first reconnect after a clean end immediate.

Evidence: repro/outage.js runs the real WatchmanWatcher and fb-watchman against a fake BSER server on a Unix socket. With the server down for 4 seconds, head retried at about 1s and 3s and resubscribed at about 7s, with since set to a new clock, not the last one. The only output was the Lost connection warning and two connect ENOENT errors, with nothing logged on recovery. The fake server emits no events, so the missed edit itself follows from the fresh since; it wasn't observed against real Watchman. · id f6ccda9f

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0faadf1. A successful reconnect now emits one warning naming the root, explaining that changes during disconnection may have been missed, and directing a restart to resynchronize. Tests cover failed retries, successful recovery, repeated outages, and startup without a recovery warning. Subscription clock behavior is preserved.

MAX_WAIT_TIME,
);

watcher.onError(error => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix · An error on NativeWatcher's FSWatcher still terminates Metro

This listener only sees errors that go through emitError. NativeWatcher.startWatching (NativeWatcher.js:84) creates its FSWatcher without an error listener, unlike FallbackWatcher.js:216, so an error event on that object is still uncaught.

Failure case: on macOS without Watchman, Metro is running on NativeWatcher and its recursive FSWatcher emits error. The event throws instead of reaching the new reporting, and the process exits. The PR's changelog and summary cover filesystem errors from watcher backends generally, so this path is in scope. I triggered it by emitting the event on the real FSWatcher, and didn't establish which OS condition produces it.

Suggested fix: in NativeWatcher.startWatching, add this.#fsWatcher.on('error', error => this.emitError(error)), plus a test.

Reproduced: repro.js native-fswatcher-error on head and base, on macOS with the real Watcher and NativeWatcher: after watch() resolved, emitting error (code EIO) on the backend's FSWatcher threw Error: watch failed out of emit, so the survival log line never printed. · id dd9e1d91

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0faadf1. NativeWatcher forwards the filesystem handle's error event through the backend error channel. Tests cover original-error identity, continued file delivery, and safe late errors after stopping.

? 'Increase fs.inotify.max_user_watches on Linux.'
: 'Increase the open file limit (ulimit -n).');
}
this.#options.console.error(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix · A lost FallbackWatcher directory watch is logged without saying changes will be missed

When a directory's FSWatcher errors, FallbackWatcher.js:216-224 forgets the handle and nothing watches that directory again. On base the unhandled error ended the process. Now the error is logged and Metro keeps running, with that directory silently out of date for the rest of the session.

Failure case: on Linux or Windows, the FSWatcher for src/feature errors with a code that isn't ignorable, such as EIO on a flaky mount. Metro logs fallback watcher error in <root>: watch failed, and later edits under src/feature produce no events. The message names neither the directory nor the consequence. The new ENOSPC/EMFILE guidance does state the consequence, but other codes get none. I triggered this by closing the real handle and emitting error, as Node does when it closes a failed watcher; I didn't produce a real OS failure.

Suggested fix: in FallbackWatcher, try to re-watch the directory and emit a recrawl for it. At minimum, report that changes under that directory won't be detected until a restart, and include the path.

Reproduced: repro.js fallback-lost-watch on head (macOS, real FallbackWatcher forced on): events before the error were ["sub/before.js","sub"]. After close() and emit('error') on the handle for sub, writing sub/after.js gave events after error [], with one generic console.error. On base the same emit threw. · id bb4c86e5

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0faadf1. A lost directory watch reports its path, the missed-change consequence, and restart guidance. The wrapper preserves the original cause and error code, retaining ENOSPC/EMFILE deduplication and existing ignorable-error behavior. Tests exercise real fallback watcher startup with controlled failing handles.

@vzaidman
vzaidman requested a review from robhogan November 3, 2026 12:02
@ramya-2196

Copy link
Copy Markdown
Contributor

Closed #2151 in favour of this. As this one fixes the same crash and also covers the follow-ups raised in review of #2151 (de-duplicating ENOSPC/EMFILE, Watchman reconnects and late client errors). Thanks @vzaidman for the pointer and the review.

One gap that's still open that I hit while testing this PR: NativeWatcher creates its recursive fs.watch handle without an 'error' listener (unlike FallbackWatcher), so an error emitted on that handle is still uncaught and ends the process. Forwarding it fixes it:

fsWatcher.on('error', error => {
  this.emitError(error);
});

It can be tested with the existing fs.watch mock in NativeWatcher-test.js by returning an EventEmitter as the handle and emitting 'error' on it. @sorinc03, happy to help there if useful.

Propagate backend startup failures, forward native watch errors, and report lost directory watches and successful Watchman recovery. Preserve the underlying error and resource-limit handling.

Test Plan: All 165 suites pass with 3094 tests passed, 14 skipped, and 495 snapshots. The added real NativeWatcher missing-root regression fails against the original async executor and passes with this change.
@sorinc03

sorinc03 commented Nov 5, 2026

Copy link
Copy Markdown
Author

The NativeWatcher gap reported here is fixed in 0faadf1. Filesystem-handle errors now reach the backend error channel, including safe handling after shutdown. The update also forwards startup failures correctly and adds recovery/lost-watch diagnostics. Full checks pass with 3,094 tests and 495 snapshots, with 14 existing skips. Coverage includes a real NativeWatcher missing-root regression.

});
},
);
this.#fsWatcher.on('error', error => this.emitError(error));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix · A NativeWatcher FSWatcher error stops all watching for the root without saying so

Forwarding the error stops the crash, but Node has already closed the handle by the time FSWatcher emits error. NativeWatcher uses one recursive handle per root and never re-creates it, so every later change under that root is lost. On base this ended the process. Now Metro keeps running and looks healthy.

Failure case: on macOS without Watchman, the recursive FSWatcher for the project root fails. Metro logs metro-file-map: native watcher error in <root>: Error: EIO: i/o error, watch and nothing else. Later edits produce no events, so Metro keeps serving stale code until it restarts. The message gives neither the consequence nor the remedy. FallbackWatcher now gives both when it loses a single directory, while this case loses the whole root. The PR description names users being unaware that changes have stopped arriving as a problem it addresses.

Suggested fix: at minimum, wrap the error as FallbackWatcher does with WatchLostError: keep the cause and code, and say changes under the root won't be detected until a restart. Better, re-create the fs.watch handle and emit a recrawl for the root, unless the watcher has stopped. Add a test that checks the reported message, using the existing fs.watch mock in NativeWatcher-test.js.

Reproduced: Repro test packages/metro-file-map/src/__tests__/reviewNativeDead-test.js runs a real Watcher and NativeWatcher on macOS and calls the real handle's _handle.onchange(-5, ...), Node's path for a negative libuv status. Output: events [..., 'before.js'] with no after.js, handle: null, and only the one log line quoted above. The negative status was injected; I didn't produce a real OS failure or establish which condition causes one. · id 9e975cb7

This branch has not been deployed

No deployments
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.

[metro-file-map] An error a watcher backend reports ends the server, since nothing listens for it

2 participants