Skip to content

watch: report a failed watcher init as an error instead of a crash - #39629

Open
robobun wants to merge 2 commits into
mainfrom
farm/8072b486/watcher-init-no-panic
Open

robobun wants to merge 2 commits into
mainfrom
farm/8072b486/watcher-init-no-panic

Conversation

@robobun

@robobun robobun commented Aug 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Watch mode (--watch, --hot, bun test --watch, bun build --watch) aborts with panic: Failed to enable File Watcher: EMFILE when Watcher::init fails. An environment limit produces a crash report (Sentry BUN-402B) and SIGABRT.
  • The Err arm in enable_hot_module_reloading (src/jsc/hot_reloader.rs:728 on main) calls Output::panic. The start() arm below already exits 1.

Fix

  • Both arms call one cold helper, exit_on_watcher_error. It prints error: Failed to enable File Watcher: <name> and exits 1.
  • One note: line, only for EMFILE from Watcher::init on Linux and Android. It names fs.inotify.max_user_instances and ulimit -n, because inotify_init1 returns EMFILE for both.
  • Verified: test/cli/watch/watch.test.ts, block watcher init failure. 10/10 rows fail on the unfixed build and pass here.
  • Self-reviewed: 2 concerns raised, 2 addressed (see Notes). The code-level part of that review did not complete.

Related to #15329 and #19051. Carries #34070 forward.

Background

  • Watcher::init creates the kernel object (inotify_init1, kqueue). Its Err arm is the one frame all five call sites cross.
  • bun test --watch and bun build --watch block on the watch flag alone, so the arm must not return.
  • Considered: handle_root_error (it cannot know which syscall failed), an advice table in bun_watcher (a second owner of descriptor advice), a Result to the callers (Run::start never returns).

Downsides

Notes

Output with the change (Linux, inotify_init1 returns EMFILE):

error: Failed to enable File Watcher: EMFILE
note: this user is out of inotify instances (sysctl fs.inotify.max_user_instances), or this process is out of file descriptors (ulimit -n). Close other file watchers or raise the limit.

Measurements

Two release builds of the same tree, linux-x64. A has main's src/jsc/hot_reloader.rs (bf42a52). B is this PR (3742fa9).

  • release binary (nm + size, A vs B): .text 58,159,221 -> 58,158,965 B (-256 B), .rodata 19,804,908 B in both. enable_hot_module_reloading x3: 1306/1306/1374 B -> 1020/1020/1097 B. exit_on_watcher_error: 369 B. The stripped binary is 80,844,360 B in both. The note text is 370 B in B (stored plain and with color codes).
  • no-flag path (nm + llvm-objdump, A vs B): Run::start 3282 B / 745 instructions in both builds. Mnemonic diff over Run::start, RunCommand::boot, TestCommand::exec, BundleV2::init: 0 lines.
  • failed inotify_init1 to exit, bun --watch under the EMFILE shim (gdb catch syscall): A 182 syscalls (write 89, getpid 30, process_vm_readv 29, clone 1, tgkill 1, and others) -> B 2 syscalls (write 1, exit_group 1, clone 0, prlimit64 0, fcntl 0).
  • per failed watcher init, 10/10 rows, A vs B: crash-report requests 1 -> 0, exit code 134 -> 1, signal SIGABRT -> none.
  • watcher init failure rows (linux-x64 glibc): 10/10 fail with USE_SYSTEM_BUN=1 (release build 367d939) and with build A. 10/10 pass with bun bd test and with build B.
  • limit-advice strings outside src/crash_handler (git grep -E 'ulimit -n|out of file descriptors|max_user_instances'): main 0, the first shape of this PR 4 (431 B), this PR 1 (179 B, Linux and Android only).
  • bun run rust:check-all: 12/12 targets compile. That run was on this code before a one-line comment edit. The kqueue and Windows arms are type-checked only. No test injects a failure there.

Self-review findings and what this PR does about them

Test details

  • An LD_PRELOAD shim makes inotify_init1 fail with EMFILE, ENFILE or ENOMEM. Rows: bun --watch FILE, bun --hot FILE, bun run --watch FILE, bun --watch -e, BUN_OPTIONS=--watch, bun test --watch, bun test --hot, bun build --watch, plus ENFILE and ENOMEM on bun --watch FILE.
  • Each row compares stdout, stderr, the crash-report request count, the signal and the exit code in one toEqual. Each child gets BUN_CRASH_REPORT_URL at a local server, so the unfixed build uploads to that server and not to bun.report.
  • The upload is a forked curl that keeps the stderr pipe open (src/crash_handler/lib.rs, report()), so the count is final when stderr reaches EOF.
  • The shim tests are not limited to glibc. CI build 121948 ran the earlier version of them on alpine x64 and aarch64 with the same counts as on debian.
  • No compiled-executable row. With the debug build bun build --compile writes an 823 MB file, and the setup hook passed the 5 s default timeout in a whole-file run.
  • The existing start() test shares the shim helpers. Its assertions did not change.
  • CI runs this file with LeakSanitizer off (test/no-validate-leaksan.txt).

Other facts


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/watch/watch.test.ts

@coderabbitai

coderabbitai Bot commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: ec08562c-e046-4dc9-9ce7-b11c52072de5

📥 Commits

Reviewing files that changed from the base of the PR and between bf42a52 and a4d107a.

📒 Files selected for processing (3)
  • src/jsc/hot_reloader.rs
  • src/watcher/error.rs
  • test/cli/watch/watch.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.


Walkthrough

Watcher initialization and startup failures now use shared error reporting and exit with status 1. Unix EMFILE and ENFILE errors can include platform-specific limit advice. Linux tests inject EMFILE across four watch-mode commands.

Changes

Watcher error handling

Layer / File(s) Summary
Report watcher errors with limit hints
src/watcher/error.rs, src/jsc/hot_reloader.rs
Error::limit_hint returns platform-specific advice for Unix EMFILE and ENFILE errors. Watcher initialization and startup failures use a shared helper that reports the error and exits with status 1.
Exercise watcher EMFILE failures
test/cli/watch/watch.test.ts
Shared helpers compile and run preload shims. Linux tests inject EMFILE in inotify_init1 for watch, hot, test-watch, and build-watch commands, then check the error output, exit status, signal status, and fixture output.

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to a4d10

Watcher initialization failures now produce a clear error with a limit hint and exit code 1 instead of a crash. No merge-blocking risk was identified from the supplied evidence.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The directly linked issue [#15329] reports a watcher-initialization crash. The PR replaces the Watcher::init panic path in src/jsc/hot_reloader.rs with exit_on_watcher_error, which prints the wa…
Out of Scope Changes check ✅ Passed The changed source files directly implement the watcher error behavior for [#15329]. The limit_hint method supports the new error output. The test helper extraction and new fault-injection tests ver…
Title check ✅ Passed The title clearly summarizes the main change: watcher initialization failures now report an error instead of causing a crash.
Description check ✅ Passed The description explains the problem, implementation, verification steps, test results, scope, and known limitations. It does not use the exact template headings, but it provides the required change s…
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. Reworked in 3742fa9. CI is green on that commit (Buildkite build 122806).

Reproduced on an unfixed release build (Linux x64) two ways:

  • (ulimit -n 7; bun --watch app.js) prints panic: Failed to enable File Watcher: EMFILE and the crash report. The number depends on how many descriptors are open before the watcher starts.
  • An LD_PRELOAD shim that makes inotify_init1 return EMFILE. The test uses this. All 10 rows of watcher init failure in test/cli/watch/watch.test.ts fail on the unfixed build (1 crash-report request, SIGABRT, exit 134) and pass with this branch.

With this branch the same run prints:

error: Failed to enable File Watcher: EMFILE
note: this user is out of inotify instances (sysctl fs.inotify.max_user_instances), or this process is out of file descriptors (ulimit -n). Close other file watchers or raise the limit.

and exits with code 1.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — replaces the Output::panic on Watcher::init failure with a proper CLI error + exit(1), matching what the start() arm already did.

What was reviewed:

  • exit_on_watcher_error is -> !, so the match arm typechecks; the dropped handle_error_return_trace is a no-op shim in bun_core.
  • Error::limit_hint: #[cfg(not(unix))] returns None (Windows variant never reaches the unix match); Error is Copy so by-value self is fine.
  • Test refactor: the extracted compileShim/runWatcheeWithShim helpers preserve the existing start() test's assertions; new block covers all four watch entry points concurrently and asserts signalCode === null + exitCode === 1.
Extended reasoning...

Overview

This PR changes one error-handling arm in enable_hot_module_reloading (src/jsc/hot_reloader.rs) from Output::panic to a printed error: line plus Global::exit(1), and factors both the init and start failure arms into a shared #[cold] fn exit_on_watcher_error(action, err) -> !. It adds Error::limit_hint() in src/watcher/error.rs to name the exhausted resource for EMFILE/ENFILE per-platform, and adds a Linux-only LD_PRELOAD fault-injection test block in test/cli/watch/watch.test.ts that covers --watch, --hot, bun test --watch, and bun build --watch. The existing start()-failure test is refactored to share the shim compile/spawn helpers with the new block; its assertions are unchanged.

Security risks

None. This is CLI error-reporting on a startup-time failure path. No user input is parsed, no new syscalls are made, and the process exits immediately after printing. The hint strings are static &'static str constants.

Level of scrutiny

Low-to-medium. The functional change is a one-arm swap of panic → error + exit(1), which strictly improves behavior (a user-reachable environment limit was going through the crash reporter). The sibling start() arm already did exactly this; the PR unifies them. limit_hint is pure and cfg-gated correctly for Linux/Android vs other unix vs Windows. The removed bun_core::handle_error_return_trace(&err) call is documented as a no-op tier-0 shim, so nothing is lost.

Other factors

  • Tests are strong for this class of change: the LD_PRELOAD shim forces inotify_init1 to return EMFILE, and the block asserts the exact error text, the presence of the note: line naming fs.inotify.max_user_instances, that the script body never ran, signalCode === null (proving no abort), and exitCode === 1. It runs concurrently across all four watch entry points including the BundleV2 path.
  • The PR description confirms cross-target cargo check for macOS/Windows/FreeBSD, clippy/fmt clean, and that the tests fail on the release build.
  • The leaked reloader box on the exit path is called out in the description and is harmless (process exits in the same frame; LSAN is silent because the pointer is still stack-live). This matches how the pre-existing start() arm already behaved.
  • No prior human review comments to address; no CODEOWNERS on these paths that I can see.

When Watcher::init fails (inotify_init1 or kqueue returns EMFILE or
ENFILE), --watch, --hot, bun test --watch and bun build --watch went
through Output::panic. The user got a crash report for a descriptor
limit in their environment.

Print the errno as a normal CLI error, add a note that names the limit,
and exit with code 1. The start() failure path below it already did
this. Both paths now share one helper.
@robobun
robobun force-pushed the farm/8072b486/watcher-init-no-panic branch from d3f29c9 to a4d107a Compare September 30, 2026 19:49
Comment thread src/jsc/hot_reloader.rs Outdated
Comment thread src/watcher/error.rs Outdated
@robobun

robobun commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased on main (bf42a52, force pushed). The old head was d3f29c9, the new head is a4d107a.

Two conflicts, both in test/cli/watch/watch.test.ts:

  • The import block. Main added names to the harness and node:fs imports. This PR adds describe. The block now has both.
  • Main added the test a --watch reload does not fail pthread_create on the other threads directly above the comment that this PR rewrites. I kept that test, then this PR's comment and shim helpers.

No source line of the diff changed. The test lines that this PR adds and removes are also the same as before.

Checked on Linux x64 with a debug (ASAN) build:

  • On main, the four watcher init failure tests fail with a crash report.
  • With the change, all four pass, the refactored thread spawn test passes, and watch.test.ts passes as a whole.

The build with the change also had the other branches that I rebased in the same pass. CI is the check for this branch alone.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked whether dropping the two bun_core::handle_error_return_trace(&err) calls loses any output — it is a no-op (pub fn handle_error_return_trace<E>(_err: E) {} in src/bun_core/lib.rs), so the shared exit_on_watcher_error path prints exactly what the old start() arm did. Passing err by value is fine since bun_watcher::Error derives Copy.

Extended reasoning...

The change reroutes the Watcher::init failure in src/jsc/hot_reloader.rs from Output::panic to a shared cold exit helper that prints an error plus an optional EMFILE/ENFILE note from the new bun_watcher::Error::limit_hint, and adds a Linux-only LD_PRELOAD test matrix. It touches no security-sensitive surface. The one confirmed finding (a vacuous stdout assertion for the bun build --watch row) is posted inline; the trace-call removal and Copy-by-value concerns were ruled out by reading the helper and the enum derive.

Comment thread test/cli/watch/watch.test.ts Outdated
@robobun
robobun marked this pull request as draft October 1, 2026 17:04
The note for a failed watcher init now lives in hot_reloader.rs and
prints in one case: Watcher::init on Linux or Android fails with
EMFILE. inotify_init1 returns EMFILE both when the user is out of
inotify instances and when the process is out of file descriptors.

bun_watcher::Error::limit_hint is removed, so src/watcher/error.rs is
the same as on main. Generic descriptor advice stays with
handle_root_error. ENFILE, ENOMEM, the kqueue errors and the Windows
errors print the error line only.

The helper takes the failed step as an enum, so the start() arm cannot
print the note. The test covers eight command forms plus ENFILE and
ENOMEM, compares the whole output, and checks that no crash report is
sent.
@robobun
robobun marked this pull request as ready for review October 2, 2026 11:49

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — small, self-contained change that turns a watcher-init failure into a plain error: + exit 1 instead of a crash report, and the follow-up commit addresses the earlier vacuous-stdout point (every row now asserts stdout: "" exactly via one toEqual).

What was reviewed:

  • exit_on_watcher_error in src/jsc/hot_reloader.rs: bun_watcher::Error derives PartialEq/Eq so the == on Sys(EMFILE) compiles; bun_core::note! exists and prefixes note: as the test expects; bun_errno is already a dependency of the bun_jsc crate; the dropped handle_error_return_trace is a no-op in src/bun_core/lib.rs:1109, so nothing observable is lost.
  • The Start arm's output and exit code are unchanged from before; all five watch entry points (run_command.rs, test_command.rs, bundle_v2.rs) route through the modified Init arm, so the matrix rows cover real paths.
  • Test harness use: leading --debug-crash-handler-use-trace-string before test/build is skipped by command::which and unrecognized-flag warnings are off by default, so it does not pollute the exact-stderr assertion; tempDir disposal in afterAll, port: 0, and concurrent pipe draining are all in place. I did not execute the Linux-only shim tests locally (no debug build here), so CI remains the proof for those rows.
Extended reasoning...

The PR touches one Rust function pair in src/jsc/hot_reloader.rs (replacing an Output::panic and a duplicated exit path with one cold exit_on_watcher_error helper, plus a Linux/Android-only inotify EMFILE note) and extends test/cli/watch/watch.test.ts with shared LD_PRELOAD shim helpers and a 10-row it.concurrent.each matrix asserting exact stdout/stderr, zero crash-report uploads, null signal and exit 1. It touches no security-sensitive surface (no auth, parsing of untrusted data, or memory-unsafe code added). The diff is about 35 lines of production code with intent clearly stated, the behavior change (exit 1 instead of SIGABRT) is deliberate and acknowledged in the description, no CODEOWNERS entry covers either file, the bug hunt ran dry, and the only prior inline finding from this bot was fixed by the second commit. The remaining judgment call the author flags (exiting 1 for KQueueError/Windows init errors as well) mirrors what the start() arm already did and is documented in the PR.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants