Skip to content

fs.watch(macOS): make FSEventsLoop Sync and retain the CFRunLoop across shutdown - #30758

Closed
robobun wants to merge 5 commits into
mainfrom
farm/50928778/fs-watch-provenance
Closed

fs.watch(macOS): make FSEventsLoop Sync and retain the CFRunLoop across shutdown#30758
robobun wants to merge 5 commits into
mainfrom
farm/50928778/fs-watch-provenance

Conversation

@robobun

@robobun robobun commented May 15, 2026

Copy link
Copy Markdown
Collaborator

Crash

test/js/node/async_hooks/AsyncLocalStorage-tracking.test.ts
  → async-context-fs-watch.js
panic(main thread): Segmentation fault at address 0xC

on macOS aarch64 (and, under stress, x64) release. The fixture does fs.watch(file, cb) → trigger → inside cb: watcher.close() + process.exit(0).

Cause — two halves

1. Visibility (Rust-port regression)

FSEventsLoop spawned the CoreFoundation thread by laundering *mut FSEventsLoop through usize, and cf_thread_loop(&mut self) then held a noalias &mut FSEventsLoop for the entire CF-thread lifetime (across CFRunLoopRun()). The JS thread concurrently forms its own &mut FSEventsLoop in register_watcher / unregister_watcher / Drop. Two live &mut to one allocation is UB regardless of synchronization; under noalias LLVM is free to treat the CF thread's self.loop_ = CFRunLoopGetCurrent() write as invisible to the JS thread's read.

The same usize round-trip existed in the Linux inotify and FreeBSD kqueue reader-thread spawns in path_watcher.rs.

2. Lifetime (present in the Zig original too)

CFRunLoopGetCurrent() follows CF's Get-rule — the caller doesn't own a reference. When the CF thread function returns, the pthread TSD destructor releases the thread's run loop. enqueue_task_concurrent() does CFRunLoopSourceSignal(src); CFRunLoopWakeUp(loop); on the _stop enqueue in shutdown() the CF thread can drain _stop off the signal alone, run CFRunLoopStop, return from CFRunLoopRun(), and fully exit (freeing the CFRunLoop via TSD) before the JS thread reaches CFRunLoopWakeUp(loop) — which then faults at CFRuntimeBase._rc (+0xC).

The new stress test caught (2) on darwin 14 x64 in CI after (1) alone was applied.

Fix

FSEventsLoop (macOS):

  • loop_ / signal_sourceAtomicPtr<c_void>; watchers/stream state → UnsafeCell<FSEventsLoopState> under the existing mutex; unsafe impl Sync; every method takes &self; init() leaks a &'static FSEventsLoop into a OnceLock; the CF-thread closure captures that reference directly (T: Sync&'static T: Send)
  • shutdown(&self) replaces Drop (a &mut self drop would alias the CF thread's &'static)
  • cf_thread_loop CFRetains the run loop before publishing it; shutdown() CFReleases after thread.join() — the run loop now outlives every JS-thread reader, and CFRunLoopWakeUp on a stopped-but-alive loop is a documented no-op
  • Task::new takes &'static T / fn(&T) so the stored ctx ptr is stable under Stacked Borrows

PathWatcherManager (Linux/FreeBSD): Platform::init does its fallible syscall first, then leaks the manager and captures &'static PathWatcherManager in the reader-thread closure. No unsafe in the threading; retry path doesn't leak.

bake-codegen.ts: JSON.stringify the OVERLAY_CSS define value (defensive; #30679 now also fixes the lexer side; output byte-identical).

Verification

  • cargo check -p bun_runtime on x86_64-linux, aarch64-apple-darwin, x86_64-unknown-freebsd, aarch64-pc-windows-msvc
  • bun bd test test/js/node/watch/ — full fs.watch suite
  • test/js/node/watch/fs.watch.close-exit.test.ts — new stress test (watch → close-in-callback → process.exit) × 40 subprocesses; this is what caught the lifetime half on CI
  • bun bd test test/js/node/async_hooks/AsyncLocalStorage-tracking.test.ts -t fs-watch — original crashing test

Rebase notes

Post-#31116 (workspace clippy):

  • local Semaphorebun_threading::Semaphore (main deleted the local one)
  • UnboundedQueue::push now takes NonNull<T>
  • removed the &self → &mut state() accessor (mut_from_ref is deny); the UnsafeCell access is now inlined at each call site with a SAFETY comment, matching PathWatcherManager
  • #[cfg(not(windows))] on PathWatcherManager::leak() (dead_code is deny); per review, the whole fs_events module is declared behind #[cfg(target_os = "macos")] in node.rs instead of per-item attributes
  • CF thread named via Builder::name, matching main's convention

Post-#31783 (TODO(port)/Zig-comment sweep): the sweep rewrote comments in the pre-refactor fs_events.rs/path_watcher.rs, which this PR replaces. Resolved by keeping this PR's code and applying the same comment convention to it: removed all Zig cross-references, PORT NOTE/PERF(port) tags, and trailing ported from: lines from both files (reusing the sweep's exact replacement wording where the text overlapped). No code changes from the resolution; re-verified with cross-target cargo check/clippy and the full fs.watch suite.

@robobun

robobun commented May 15, 2026

Copy link
Copy Markdown
Collaborator Author

@robobun

robobun commented May 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status — diff green on all macOS lanes, ready for merge

Rebased over main (now includes #31783) at tip 577d9ca (56e9ca5 + two comment-only fixups finishing the #31783 comment-convention sweep, per review); build 61141 is the current CI run. The #31783 comment-convention sweep conflicted with both refactored files; resolved by keeping this PR's code and applying the same convention (no Zig cross-references / port tags) to it — no code changes. Re-verified locally: cargo check + clippy on aarch64/x86_64-apple-darwin and host, full bun bd build, test/js/node/watch/ suite, the new stress test, and AsyncLocalStorage-tracking.test.ts all pass.

Fix for the Segmentation fault at address 0xC in async-context-fs-watch.js on macOS. Two root causes fixed: (1) aliased &mut FSEventsLoop across CFRunLoopRun()FSEventsLoop is now properly Sync (AtomicPtr + UnsafeCell-under-mutex + &'static/OnceLock, shutdown(&self) replaces Drop); (2) CFRunLoopWakeUp racing the CF thread's pthread-TSD teardown of the run loop → CFRetain in cf_thread_loop, CFRelease in shutdown() after join().

Latest push (5910806) addresses review feedback: the whole fs_events module is declared behind #[cfg(target_os = "macos")] in src/runtime/node.rs instead of #[allow(dead_code)] — other platforms no longer compile any CF-specific code.

Build 60234 (current tip, complete): 270 passed / 16 failed — every failure is unrelated flake

  • 15 of 16 failed jobs (Linux/Alpine/Windows/macOS) failed solely on test/cli/install/bunx.test.ts"should handle package that requires node 24": it runs bunx --bun @angular/cli@latest --help against the live npm registry and the current Angular release exits 3 — on every retry, every platform, every branch fleet-wide since ~01:40 UTC (e.g. unrelated build 60233 fails on the identical single test).
  • The 16th (darwin 14 aarch64 shard) failed on that same bunx test plus one HTTP3HandshakeFailed timing flake in fetch-http3-client.test.ts ("rapid serve/stop cycles", QUIC handshake timeout) which passed on attempt Fix calling #private() functions in classes #2.
  • On the original crash platform (darwin 14 aarch64): AsyncLocalStorage-tracking.test.ts and the new fs.watch.close-exit.test.ts stress test both passed on both shards. Same on darwin 14 x64, darwin 26 aarch64, and every Linux/Windows lane. Zero ASAN findings, zero panics, no cores. Format/clippy/lint and all build steps green.

Prior macOS validation

56886 (same functional code, pre-cfg-gate) 285/286, only a known gRPC flake; 55268 all six macOS shards green; 54695 the stress test caught the lifetime race on darwin 14 x64 (pre-CFRetain); 54561 Sync refactor green.

None of the changed areas (fs_events, path_watcher, bake-codegen, fs.watch) appear in any failure on any build. The diff is green — the PR needs a maintainer's review (unsafe impl Sync sign-off) and merge.

Also in this PR

  • PathWatcherManager (Linux/FreeBSD): Platform::init does its fallible syscall first, then leaks &'static for the reader-thread closure — no unsafe in threading.
  • bake-codegen.ts: JSON.stringify the OVERLAY_CSS define (defensive; output byte-identical).
  • Rebased over clippy: 33 deny lints + fix 2735 violations across workspace #31116 (workspace clippy); resolutions documented in the PR description.

@coderabbitai

coderabbitai Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Convert macOS FSEvents loop to a leak-backed &'static singleton with UnsafeCell-held mutable state and atomic handles; reshape PathWatcher init to return/leak &'static manager captured by spawned threads; add a concurrent regression test for fs.watch close-then-exit behavior.

Changes

FSEvents loop refactor (macOS)

Layer / File(s) Summary
Core loop type & state, Task ABI, CF thread
src/runtime/node/fs_events.rs
Add UnsafeCell-backed FSEventsLoopState, convert global singleton to OnceLock<&'static FSEventsLoop>, change Task::new to accept &'static context and fn(&T), and implement cf_thread_loop(&'static self) with atomic run-loop pointer handling.
Init, enqueue, CF callbacks, scheduling
src/runtime/node/fs_events.rs
Rewrite FSEventsLoop::init to allocate/leak and return &'static FSEventsLoop, set CFRunLoopSourceContext.info from the shared reference, store signal_source/loop_ atomically, update enqueue_task_concurrent to use atomic loads before signaling, route CF callbacks to recover &FSEventsLoop, and route scheduling through state() under the mutex.
Watcher registration, stream bookkeeping, shutdown
src/runtime/node/fs_events.rs
Move stream/path/watch bookkeeping into FSEventsLoopState, update register_watcher/unregister_watcher to take &'static self and enqueue schedule/stop tasks, change FSEventsWatcher to store Cell<Option<&'static FSEventsLoop>>, and implement shutdown(&'static self) that enqueues stop, joins the CF thread, releases CF handles, and clears watchers.

PathWatcher static init and thread capture

Layer / File(s) Summary
PathWatcherManager::get & DEFAULT_MANAGER publication
src/runtime/node/path_watcher.rs
PathWatcherManager::get now obtains an &'static PathWatcherManager from Platform::init()/leak() and publishes it; the prior error-path heap reclamation was removed.
Platform init signatures and thread spawn capture
src/runtime/node/path_watcher.rs
Platform init implementations (Linux inotify, kqueue, Darwin stub, Windows stub) now perform fallible syscalls first, then leak/publish an &'static PathWatcherManager, set platform fd, and spawn detached reader threads that capture the shared &'static manager for thread_main.

fs.watch close-exit regression test

Layer / File(s) Summary
Regression test: fs.watch close-exit race condition
test/js/node/watch/fs.watch.close-exit.test.ts
Adds a bun:test test.concurrent that spawns subprocesses which set up an fs.watch on a per-PID file, trigger a modification, close the watcher and unlink the file inside the watch callback, then call process.exit(0). Parent runs 40 iterations in batches of 8, asserting empty stdout/stderr, exit code 0, no signal termination, and overall timeout 60s.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately describes the main change: converting FSEventsLoop to be Sync and retaining the CFRunLoop across shutdown, which directly addresses the core fix for the macOS crash.
Description check ✅ Passed The PR description covers both required sections: 'What does this PR do?' (detailed explanation of the crash, cause, and fix) and 'How did you verify your code works?' (comprehensive verification across multiple platforms and test suites).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/watch/fs.watch.close-exit.test.ts`:
- Around line 55-60: The fallback timeout currently calls process.exit(0) which
masks a missed watch callback; change the fallback in the setTimeout block (the
watcher.close() / process.exit call) to fail loudly instead of exiting with 0 —
e.g., make the timeout invoke process.exit(1) or throw an Error with a
descriptive message so the test fails if the watch callback path never ran;
ensure the watcher.close() call remains but the final process exit indicates
failure rather than success.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6d92e6d1-e07b-45e3-b0e8-2b636e35ea3f

📥 Commits

Reviewing files that changed from the base of the PR and between bbd3e62 and f0e81cd.

📒 Files selected for processing (3)
  • src/runtime/node/fs_events.rs
  • src/runtime/node/path_watcher.rs
  • test/js/node/watch/fs.watch.close-exit.test.ts

Comment thread test/js/node/watch/fs.watch.close-exit.test.ts Outdated
Comment thread test/js/node/watch/fs.watch.close-exit.test.ts Outdated
@robobun robobun changed the title fs.watch: preserve pointer provenance across reader-thread spawn fs.watch: make FSEventsLoop Sync; share &'static across reader threads May 15, 2026

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/node/fs_events.rs (1)

955-985: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Release the final stream/path CF objects during shutdown.

shutdown() stops the thread and releases signal_source, but it never tears down state.fsevent_stream, state.paths, or state.cf_paths. The last active stream therefore leaks unless _schedule() happened to run a cleanup pass first.

Suggested fix
     let _guard = self.mutex.lock_guard();
     // SAFETY: holding `mutex` — see `FSEventsLoop::state`.
     let state = unsafe { self.state() };
+    let cs = CoreServices::get();
+
+    if !state.fsevent_stream.is_null() {
+        unsafe {
+            (cs.fs_event_stream_stop)(state.fsevent_stream);
+            (cs.fs_event_stream_invalidate)(state.fsevent_stream);
+            (cs.fs_event_stream_release)(state.fsevent_stream);
+        }
+        state.fsevent_stream = ptr::null_mut();
+    }
+
+    if let Some(paths) = state.paths.take() {
+        for s in paths.iter() {
+            if !s.is_null() {
+                unsafe { (cf.release)(*s) };
+            }
+        }
+    }
+
+    if !state.cf_paths.is_null() {
+        let cf_paths = state.cf_paths;
+        state.cf_paths = ptr::null_mut();
+        unsafe { (cf.release)(cf_paths) };
+    }
+
     if state.watcher_count > 0 {
         while let Some(watcher) = state.watchers.pop() {
             if let Some(w) = watcher {
                 bun_ptr::BackRef::from(w).loop_.set(None);
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/node/fs_events.rs` around lines 955 - 985, The shutdown() path
currently joins the thread and releases signal_source but does not free the
CoreFoundation objects held in the FSEventsLoop state; update shutdown() (after
acquiring the mutex and reading state via FSEventsLoop::state) to explicitly
release state.fsevent_stream, iterate and release any CF objects in
state.cf_paths and clear state.paths, and then set state.fsevent_stream and
state.cf_paths to null/empty so the last active stream does not leak; use
CoreFoundation::get().release(...) to drop each CF object and ensure any
Rust-owned Vecs (state.paths) are cleared/consumed while holding the mutex so
invariants are preserved (similar to how signal_source is released).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/node/fs_events.rs`:
- Around line 1057-1061: The fast-path in watch() must observe shutdown state so
it doesn't use a loop that's being torn down; add an atomic idempotent
"closing/closed" flag (e.g., FSEVENTS_CLOSING) that is set under
close_and_wait() before teardown and cleared/kept set after teardown, then
update the fast path that reads FSEVENTS_DEFAULT_LOOP (and currently skips
FSEVENTS_DEFAULT_LOOP_MUTEX) to check that flag and refuse/reinitialize the fast
path when closing is true; ensure close_and_wait() sets the flag early and that
any code paths (enqueue_task_concurrent(), signal_source usage, loop_ use)
verify the flag and either return an error or fall back to the slow path so
teardown cannot race with new watchers, and make the flag transitions idempotent
so multiple close_and_wait() calls are safe.

In `@src/runtime/node/path_watcher.rs`:
- Around line 146-151: Create the PathWatcherManager on the heap but do not call
bun_core::heap::release() until Platform::init succeeds: allocate let mut
manager = Box::new(PathWatcherManager::default()), call
Platform::init(&*manager) (update Platform::init's signature to accept
&PathWatcherManager if it currently requires &'static), and only after
Platform::init returns Ok(...) call heap::release(manager) (or
Box::leak(manager)) to publish the singleton; this preserves RAII so a failed
Platform::init (inotify/kqueue/thread spawn) drops the Box instead of leaking.

---

Outside diff comments:
In `@src/runtime/node/fs_events.rs`:
- Around line 955-985: The shutdown() path currently joins the thread and
releases signal_source but does not free the CoreFoundation objects held in the
FSEventsLoop state; update shutdown() (after acquiring the mutex and reading
state via FSEventsLoop::state) to explicitly release state.fsevent_stream,
iterate and release any CF objects in state.cf_paths and clear state.paths, and
then set state.fsevent_stream and state.cf_paths to null/empty so the last
active stream does not leak; use CoreFoundation::get().release(...) to drop each
CF object and ensure any Rust-owned Vecs (state.paths) are cleared/consumed
while holding the mutex so invariants are preserved (similar to how
signal_source is released).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f8628fec-1530-4b4b-a271-1d1ff4deb83a

📥 Commits

Reviewing files that changed from the base of the PR and between 666cf8e and 60c7b05.

📒 Files selected for processing (3)
  • src/runtime/node/fs_events.rs
  • src/runtime/node/path_watcher.rs
  • test/js/node/watch/fs.watch.close-exit.test.ts

Comment thread src/runtime/node/fs_events.rs
Comment thread src/runtime/node/path_watcher.rs Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/runtime/node/path_watcher.rs (1)

685-697: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don't leak the singleton on spawn() failure.

PathWatcherManager::leak() still happens before std::thread::Builder::spawn() in both backends. spawn() can fail for thread/resource limits as well as memory pressure, so repeated fs.watch() retries can still leak one manager per call here. These branches also collapse the underlying failure to ENOMEM, which hides the actual OS error.

Keep the manager owned until the reader thread is successfully created, then leak/publish it; on failure, close the fd and translate err.raw_os_error() instead of hard-coding ENOMEM.

Possible shape
-        let manager = PathWatcherManager::leak();
+        let manager = Box::new(PathWatcherManager::default());
         manager.platform_fd.set(Fd::from_native(rc));
-        match std::thread::Builder::new().spawn(move || Linux::thread_main(manager)) {
+        let manager_ptr = core::ptr::NonNull::from(manager.as_ref());
+        match std::thread::Builder::new().spawn(move || unsafe {
+            Linux::thread_main(manager_ptr.as_ref())
+        }) {
             Ok(handle) => drop(handle),
-            Err(_) => {
+            Err(err) => {
                 manager.platform_fd.get().close();
-                return Err(sys::Error::from_code(E::ENOMEM, Tag::watch));
+                return Err(sys::Error::from_code_int(
+                    err.raw_os_error().unwrap_or(E::ENOMEM as i32),
+                    Tag::watch,
+                ));
             }
         }
-        Ok(manager)
+        Ok(Box::leak(manager))

Apply the same pattern in Kqueue::init().

Does Rust `std::thread::Builder::spawn` only fail on OOM, or can it also fail with OS resource-limit errors such as `EAGAIN` / `WouldBlock`? What error kinds or raw OS errors should callers expect?

Also applies to: 1231-1243

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/node/path_watcher.rs` around lines 685 - 697, Currently
PathWatcherManager::leak() is called before spawning the reader thread (in the
Linux branch calling Linux::thread_main), which causes a leak if
Builder::spawn() fails and also masks the real OS error by returning ENOMEM;
change the flow to create the manager as an owned value (do NOT call
PathWatcherManager::leak() yet), set platform_fd on it, attempt
std::thread::Builder::new().spawn(move ||
Linux::thread_main(manager_ref_or_owned)), and only call
PathWatcherManager::leak() (publish/leak the singleton) after spawn() succeeds;
on spawn failure close the manager.platform_fd.get().close() and return
Err(sys::Error::from_code(err.raw_os_error().map_or(E::UNKNOWN, |c| c),
Tag::watch)) (i.e., translate the actual raw OS error instead of hard-coding
ENOMEM); apply the same pattern to Kqueue::init().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/node/fs_events.rs`:
- Around line 633-639: The CF wakeup call can race with shutdown() nulling
loop_, so before calling cf.run_loop_wake_up ensure the loaded loop_ value is
non-null (e.g. check loop_ != 0 / not null) and only call
cf.run_loop_wake_up(loop_) when that check passes; still call
cf.run_loop_source_signal(signal_source) as before. Update the unsafe block
around cf.run_loop_source_signal and cf.run_loop_wake_up to perform this
defensive null check on loop_ (referencing loop_, signal_source,
cf.run_loop_wake_up, cf.run_loop_source_signal, and
enqueue_task_concurrent/shutdown()) so run_loop_wake_up is never invoked with a
null handle.

---

Duplicate comments:
In `@src/runtime/node/path_watcher.rs`:
- Around line 685-697: Currently PathWatcherManager::leak() is called before
spawning the reader thread (in the Linux branch calling Linux::thread_main),
which causes a leak if Builder::spawn() fails and also masks the real OS error
by returning ENOMEM; change the flow to create the manager as an owned value (do
NOT call PathWatcherManager::leak() yet), set platform_fd on it, attempt
std::thread::Builder::new().spawn(move ||
Linux::thread_main(manager_ref_or_owned)), and only call
PathWatcherManager::leak() (publish/leak the singleton) after spawn() succeeds;
on spawn failure close the manager.platform_fd.get().close() and return
Err(sys::Error::from_code(err.raw_os_error().map_or(E::UNKNOWN, |c| c),
Tag::watch)) (i.e., translate the actual raw OS error instead of hard-coding
ENOMEM); apply the same pattern to Kqueue::init().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dbc09d96-203d-4999-84d7-1f91a49e0a25

📥 Commits

Reviewing files that changed from the base of the PR and between 281fe74 and 4b3eb59.

📒 Files selected for processing (2)
  • src/runtime/node/fs_events.rs
  • src/runtime/node/path_watcher.rs

Comment thread src/runtime/node/fs_events.rs
Comment thread src/runtime/node/fs_events.rs Outdated
Comment thread src/runtime/node/fs_events.rs Outdated
Comment thread src/runtime/node/fs_events.rs
@robobun
robobun force-pushed the farm/50928778/fs-watch-provenance branch from 62d5734 to 480b349 Compare May 15, 2026 09:43
@robobun robobun changed the title fs.watch: make FSEventsLoop Sync; share &'static across reader threads fs.watch(macOS): make FSEventsLoop Sync and retain the CFRunLoop across shutdown May 15, 2026
Comment thread src/runtime/node/fs_events.rs Outdated
robobun added a commit that referenced this pull request May 15, 2026
Workaround so the async_hooks PR can be built + verified while #30758
is pending. The Zig-era \`define\` parser auto-quoted non-JSON raw
values as strings; the Rust-port parser rejects the leading \`*\` in the
minified CSS. The consumer is \`declare const OVERLAY_CSS: string\`, so
JSON.stringify is the intended shape regardless. trimEnd() keeps the
embedded text byte-identical to the previous codegen path.

Identical to the bake-codegen hunk in #30758; drop whichever lands
second.
robobun added a commit that referenced this pull request May 15, 2026
Workaround so the async_hooks PR can be built + verified while #30758
is pending. The Zig-era \`define\` parser auto-quoted non-JSON raw
values as strings; the Rust-port parser rejects the leading \`*\` in the
minified CSS. The consumer is \`declare const OVERLAY_CSS: string\`, so
JSON.stringify is the intended shape regardless. trimEnd() keeps the
embedded text byte-identical to the previous codegen path.

Identical to the bake-codegen hunk in #30758; drop whichever lands
second.
@robobun
robobun force-pushed the farm/50928778/fs-watch-provenance branch from 4b3e28b to f52827a Compare May 16, 2026 15:23

@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.

No issues found in the current revision and all prior review feedback has been addressed, but this is a ~400-line unsafe-concurrency refactor (unsafe impl Sync, hand-rolled Acquire/Release on AtomicPtr, CFRetain/CFRelease lifetime across pthread-TSD teardown, UnsafeCell-under-mutex discipline) — worth a human pass on the Sync model and shutdown ordering before merge.

Extended reasoning...

Overview

This PR restructures the macOS fs.watch backend (src/runtime/node/fs_events.rs, ~400 lines of diff) to fix a segfault at 0xC caused by two issues: (1) aliasing UB from &mut FSEventsLoop held across CFRunLoopRun() on the CF thread while the JS thread forms its own &mut, and (2) a use-after-free of the CFRunLoop via pthread-TSD destruction racing CFRunLoopWakeUp. The fix converts FSEventsLoop to a leaked &'static singleton with unsafe impl Sync, moves mutable state into UnsafeCell<FSEventsLoopState> under the existing mutex, makes signal_source/loop_ into AtomicPtr with explicit Acquire/Release, CFRetains the run loop and releases it in a new shutdown(&'static self) after thread.join(), and replaces the Drop impl. path_watcher.rs gets the analogous (smaller) fix for Linux/FreeBSD reader-thread spawns. There's also a new 40-subprocess stress test and a defensive 2-line bake-codegen.ts change.

Security risks

No injection/auth/data-exposure surface. The risk class here is memory safety: unsafe impl Sync with a per-field SAFETY justification, raw-pointer recovery in CF callbacks, manual memory ordering, and CF object lifetime across thread exit. A mistake in any of these invariants is a UAF or data race in a runtime hot path. The reasoning in the PR is detailed and internally consistent (sem handshake orders loop_ store before all enqueues; thread field is JS-thread-only; state only touched under mutex; CFRetain keeps the run loop alive past TSD teardown), and CI including the new stress test is green on all macOS lanes that ran.

Level of scrutiny

High. This is production-critical runtime code (fs.watch on macOS) with hand-written unsafe impl Sync, cross-thread atomics, and FFI lifetime management. The design choices — leak-as-&'static + OnceLock (never reset), shutdown() instead of Drop, the "watch()-after-shutdown is unreachable because Bun__onExit runs after VM stops scheduling" invariant, and the Stacked-Borrows provenance reasoning for Task::new — are all sound as argued but are exactly the kind of architectural decisions a maintainer should sign off on, not a bot.

Other factors

The PR has been through multiple review rounds (CodeRabbit + my own inline comments on argv indexing, Builder::spawn, SAFETY-comment accuracy, and leak-comment wording); every comment has been addressed and resolved. The bug-hunting system found nothing in the current revision. The author's status comment reports the diff green across 284 CI shards. None of that changes the fact that this is a substantial unsafe-Rust concurrency redesign rather than a mechanical fix.

@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.

All prior review feedback has been addressed and the bug-hunter found nothing on the rebased commits, but this is a hand-rolled unsafe impl Sync + AtomicPtr/UnsafeCell concurrency refactor with CF object lifetime management across two threads — worth a maintainer's eyes before merge.

Extended reasoning...

Overview

This PR refactors the macOS FSEventsLoop (~330 lines net in fs_events.rs) from a &mut self-everywhere design to a properly Sync shared-&'static singleton: AtomicPtr<c_void> for the cross-thread CF handles, UnsafeCell<FSEventsLoopState> under the existing mutex for watcher/stream bookkeeping, shutdown(&self) replacing Drop, and CFRetain/CFRelease on the run loop so it outlives the CF thread's pthread-TSD destructor. It also reshapes PathWatcherManager init on Linux/FreeBSD so the reader-thread closure captures &'static PathWatcherManager directly (no more usize pointer laundering), with the fallible syscall hoisted before the leak. A new 40-subprocess stress test and a small bake-codegen.ts define-quoting fix round it out.

Security risks

None identified. No user-controlled input reaches new unsafe blocks; the changes are internal threading/lifetime mechanics for fs.watch(). No auth, crypto, network, or permission surfaces are touched.

Level of scrutiny

High. The core of this PR is an unsafe impl Sync for FSEventsLoop whose soundness rests on hand-stated per-field invariants (which threads touch which UnsafeCell/AtomicPtr fields, under which lock, in which order), plus Acquire/Release memory ordering on AtomicPtr paired with a semaphore handshake, plus CF Get-rule lifetime reasoning for CFRunLoopGetCurrent(). The reasoning is thorough and well-commented, and the stress test caught the second (lifetime) half of the bug in CI — but this is exactly the class of change where a maintainer familiar with the FSEvents/CFRunLoop code paths and the project's unsafe-Rust conventions should sign off.

Other factors

  • All five of my prior inline comments and all four CodeRabbit findings have been addressed and resolved by the author with point-by-point fixes.
  • The branch was rebased over #31116 (workspace clippy) with non-trivial resolutions documented in the PR description (e.g., mut_from_ref deny → inlined UnsafeCell access at each site, UnboundedQueue::push now NonNull<T>); these resolutions look correct against the current diff.
  • CI is reported green on all macOS lanes including the original crash platform.
  • The bake-codegen.ts and test-file changes are straightforward; the substance is entirely in the two .rs files.

I'm not approving because this is neither simple nor mechanical — it's a correctness-critical concurrency redesign with unsafe impl Sync, and the guidelines explicitly call out complex changes touching critical code paths as requiring human review.

@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.

No bugs found and all earlier nits are addressed, but the core of this PR is an unsafe impl Sync over UnsafeCell state plus cross-thread CFRunLoop retain/release ordering — worth a human pass over the SAFETY justifications in fs_events.rs before merge.

Extended reasoning...

Overview

This PR refactors the macOS fs.watch backend (src/runtime/node/fs_events.rs, ~400 lines reshaped) to fix a segfault at 0xC caused by two issues: aliased &mut FSEventsLoop across the CF thread's CFRunLoopRun() (Rust-port UB), and a CFRunLoop use-after-free during shutdown (present in the Zig original). The fix converts FSEventsLoop to a leaked &'static singleton with AtomicPtr scalars + UnsafeCell<FSEventsLoopState> under a mutex, adds unsafe impl Sync, replaces Drop with shutdown(&self), and CFRetains the run loop so it outlives the CF thread's pthread-TSD destructor. It also reshapes PathWatcherManager init on Linux/FreeBSD to capture &'static in the reader-thread closure (removing usize pointer laundering), adds a 40-subprocess stress test, and includes a small defensive JSON.stringify fix in bake-codegen.ts.

Security risks

None identified. No auth, crypto, network, or user-input parsing is touched. The changes are internal threading/lifetime mechanics for filesystem watching.

Level of scrutiny

High. The PR hand-writes unsafe impl Sync for FSEventsLoop with per-field SAFETY justifications, reasons about Acquire/Release ordering across a semaphore handshake, manages CoreFoundation refcounts across thread boundaries, and relies on Stacked Borrows provenance arguments for why a single &'static reborrow is sound. The reasoning in the comments is detailed and looks correct to me, and the new stress test caught the lifetime half on CI — but this is exactly the class of change (hand-rolled Sync over UnsafeCell, FFI lifetime management at process exit) where a second human reading of the invariants is valuable. This is production code on the fs.watch hot path for every macOS user.

Other factors

  • All five of my earlier inline comments (argv index bug in the test, Builder::spawn vs panic, two SAFETY-comment inaccuracies, and a stale field doc) were addressed and resolved.
  • CodeRabbit's concerns (retry-leak in PathWatcherManager, idempotent shutdown) were also addressed.
  • CI is green on all macOS lanes per the robobun status comment, including the originally-crashing test and the new stress test.
  • The bake-codegen.ts change is unrelated to the crash fix but is a small, defensive, byte-identical-output change.
  • The branch was rebased over the #31116 workspace clippy sweep with documented non-trivial resolutions (e.g. inlining UnsafeCell access to satisfy mut_from_ref deny).

@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.

No further findings from me — all prior nits addressed. That said, this is a ~400-line unsafe concurrency refactor (manual unsafe impl Sync, UnsafeCell under a hand-rolled mutex, AtomicPtr ordering, CFRunLoop retain/release across thread join), so I'd like a human to sign off on the soundness argument rather than auto-approving.

Extended reasoning...

Overview

This PR rewrites the macOS FSEventsLoop (src/runtime/node/fs_events.rs, ~400 net lines changed) to fix a segfault caused by two distinct issues: (1) aliased &mut FSEventsLoop held across CFRunLoopRun() on the CF thread while the JS thread forms its own &mut (UB under Stacked Borrows / noalias), and (2) a CFRunLoop lifetime race where CFRunLoopWakeUp could be called on a freed run loop after the CF thread's pthread-TSD destructor ran. The fix converts FSEventsLoop to a leaked &'static singleton with AtomicPtr cross-thread scalars, UnsafeCell<FSEventsLoopState> under mutex, unsafe impl Sync, and CFRetain/CFRelease bracketing the run loop across thread.join(). It also reshapes PathWatcherManager::Platform::init (Linux/FreeBSD/macOS/Windows) to do the fallible syscall before leaking and capture &'static directly in the reader-thread closure, cfg-gates the fs_events module to macOS, adds a 40-subprocess stress test, and applies a defensive JSON.stringify to a bake-codegen define value.

Security risks

None identified. No auth/crypto/permissions surface; the change is internal threading/FFI plumbing for fs.watch. The bake-codegen.ts change is a build-time codegen script wrapping a CSS string in JSON.stringify — defensive, output-neutral per the PR description.

Level of scrutiny

High. This is exactly the class of change that should not be auto-approved:

  • Hand-written unsafe impl Sync for FSEventsLoop with a multi-bullet SAFETY justification whose correctness depends on every access site honoring the "touch state ⇒ hold mutex" invariant and the "thread is JS-thread-only" invariant.
  • Manual AtomicPtr with explicit Acquire/Release ordering paired with a semaphore handshake — the soundness argument spans init(), cf_thread_loop(), enqueue_task_concurrent(), and shutdown().
  • CoreFoundation FFI lifetime management (CFRetain/CFRelease) reasoning about pthread TSD destructor ordering vs. cross-thread CFRunLoopWakeUp.
  • shutdown() replaces Drop specifically because &mut self would alias the CF thread's &'static — a subtle Stacked Borrows argument.
  • Touches four platform backends (Linux inotify, FreeBSD kqueue, macOS FSEvents, Windows stub) in path_watcher.rs.

The reasoning in the code comments and PR description is exceptionally thorough, and CI is green on all six macOS shards (including the new stress test that caught the lifetime half on darwin 14 x64). But the correctness of unsafe impl Sync + manual memory ordering in a runtime-critical path is the kind of thing a human Rust reviewer should validate.

Other factors

  • All five of my prior inline comments (argv index bug, thread::spawn panic, two SAFETY-comment inaccuracies, stale loop_ doc) were addressed by the author and resolved.
  • All CodeRabbit findings were addressed or convincingly rebutted; all threads resolved.
  • No outstanding reviewer comments.
  • The bug-hunting system found nothing in the current revision.
  • The PR has been rebased over a workspace clippy sweep with documented non-trivial resolutions.

Deferring solely on complexity/scope, not on any identified defect.

@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.

All prior review feedback has been addressed and the bug hunter found nothing on this revision, but this is a ~400-line refactor of cross-thread unsafe Rust (hand-justified unsafe impl Sync, UnsafeCell-under-mutex state, CFRetain/CFRelease lifecycle across a joined thread) — worth a human pass over the SAFETY invariants before merge.

Extended reasoning...

Overview

This PR fixes a macOS segfault in fs.watch by restructuring FSEventsLoop (src/runtime/node/fs_events.rs, ~400 lines changed) from a &mut self-everywhere design — which created aliased &mut UB across the CoreFoundation thread and the JS thread — into a properly Sync type: AtomicPtr for cross-thread scalars, UnsafeCell<FSEventsLoopState> guarded by an existing mutex, leaked as &'static into a OnceLock, with shutdown(&self) replacing Drop. It also adds CFRetain of the run loop so it outlives the CF thread's pthread-TSD destructor (closing a signal/wake race that existed in the Zig original too). path_watcher.rs gets the analogous fix for the Linux/FreeBSD reader-thread spawn (fallible syscall before leak; capture &'static directly instead of usize round-trip). node.rs cfg-gates the module to macOS. bake-codegen.ts gets a small defensive JSON.stringify wrap. A new 40-subprocess stress test is added.

Security risks

None identified. This is internal runtime concurrency plumbing for fs.watch; no auth, crypto, network, or user-input parsing is touched. The unsafe blocks are about memory-model soundness (aliasing, ordering, CF refcounts), not privilege boundaries.

Level of scrutiny

High. The correctness of this change rests entirely on hand-written SAFETY justifications for unsafe impl Sync for FSEventsLoop, the per-field access discipline (which thread touches what, under which lock, with which ordering), and the CFRetain/CFRelease pairing across cf_thread_loopshutdown() after thread.join(). These invariants are extensively documented in-line and look correct to me, but getting any one of them wrong reintroduces UB that only manifests as a rare crash under load on one platform. This is exactly the kind of change where a second human familiar with Rust's aliasing model and CoreFoundation should read the SAFETY comments against the code.

Other factors

  • All five of my prior inline comments (one 🔴 on the test's argv index, four 🟡 nits on SAFETY/doc-comment accuracy and Builder::spawn error handling) were addressed by the author and are resolved.
  • All three CodeRabbit findings were addressed or withdrawn after discussion.
  • CI is green on all six macOS shards per the author's status comment; the new stress test caught the lifetime half of the bug on darwin x64 before the CFRetain fix was added.
  • The PR description is thorough and the rebase-over-clippy-sweep resolutions are documented.
  • This does not fit the auto-approve criteria (simple/mechanical/obvious); deferring.

…ss shutdown

Fixes a segfault at address 0xC in fs.watch on macOS (reported from
test/js/node/async_hooks/async-context/async-context-fs-watch.js).

Two root causes:

1. Visibility (Rust-port regression): cf_thread_loop(&mut self) held a
   noalias &mut FSEventsLoop across CFRunLoopRun() while the JS thread
   formed its own &mut in register_watcher/unregister_watcher/Drop —
   aliasing UB. FSEventsLoop is now properly Sync: loop_/signal_source
   are AtomicPtr, watcher/stream state lives in UnsafeCell under the
   existing mutex, every method takes &self, init() leaks a
   &'static FSEventsLoop into a OnceLock, and the CF-thread closure
   captures that reference directly. shutdown(&self) replaces Drop.

2. Lifetime (present in the Zig original too): CFRunLoopGetCurrent()
   follows the Get-rule; when the CF thread exits, pthread TSD frees its
   run loop, so the JS thread's trailing CFRunLoopWakeUp(loop) after
   CFRunLoopSourceSignal on the _stop enqueue could hit a freed pointer
   (fault at CFRuntimeBase._rc, +0xC). cf_thread_loop now CFRetains the
   run loop and shutdown() CFReleases it after thread.join().

Also:
- PathWatcherManager (Linux/FreeBSD): Platform::init does its fallible
  syscall first, then leaks the manager and captures
  &'static PathWatcherManager in the reader-thread closure — no unsafe
  in the threading, and the EMFILE retry path no longer leaks.
- bake-codegen.ts: JSON.stringify the OVERLAY_CSS define value.
- New stress test test/js/node/watch/fs.watch.close-exit.test.ts
  (watch -> close-in-callback -> process.exit across 40 subprocesses);
  it caught the lifetime half on darwin 14 x64 in CI.
robobun added 2 commits June 6, 2026 15:38
- inline the UnsafeCell state access at each call site (mut_from_ref
  forbids the &self -> &mut accessor; matches PathWatcherManager)
- cfg(macos) shutdown(), cfg(not(windows)) PathWatcherManager::leak()
  (dead_code is deny now)
- name the CF thread via Builder::name, matching main
- drop the no-longer-needed # Safety on FSEventsWatcher::init
  (&'static param replaced the raw-pointer contract)
The FSEvents backend is only reachable from path_watcher's Darwin code
(whose import was already cfg(target_os = "macos")), so declare the
module itself behind the same cfg in node.rs. That removes every
allow(dead_code) escape hatch, the cfg(not(unix)) dlsym stub, and the
per-item macos gates (shutdown, close_and_wait body) that only existed
because the file used to compile on every platform.
@robobun
robobun force-pushed the farm/50928778/fs-watch-provenance branch from 5910806 to 56e9ca5 Compare June 6, 2026 15:49
Comment thread src/runtime/node/fs_events.rs Outdated
The rebase over #31783 reverted the CoreFoundation/CoreServices no-deinit
comments back to commented-out Zig blocks; restore main's wording, which
also carries the leaked-for-process-lifetime invariant the Send SAFETY
comment references.
Comment thread src/runtime/node/path_watcher.rs Outdated
Restore main's wording for the two reverted hunks (module doc, PathWatcher::new
doc) and replace the remaining Zig-era identifiers in comments with the real
Rust names (deinit -> FSEventsWatcher::drop / remove_watch, addWatch/removeWatch,
onFSEvent, unregisterWatcher, walkAndAdd, addOne, getOrPut, getFdPath,
onPathUpdatePosix, runExitCallbacks, Zig enum-literal syntax).
@robobun

robobun commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #33303, which landed this work on main as 51074e3 (fs.watch(macOS): make FSEventsLoop Sync and retain the CFRunLoop across shutdown). That commit carries both root-cause fixes (the aliased &mut FSEventsLoop across the CF thread, and the unretained CFRunLoopRef freed by pthread TSD before the trailing CFRunLoopWakeUp), the PathWatcherManager &'static init for Linux/FreeBSD, and the test/js/node/watch/fs.watch.close-exit.test.ts stress test.

Closing this PR as redundant. Thanks for picking it up.

@robobun robobun closed this Jul 4, 2026
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.

1 participant