Skip to content

fs.watch: type FSWatcher's wrapper self-reference as JsRef instead of a bare JSValue#31848

Merged
Jarred-Sumner merged 3 commits into
mainfrom
claude/complex-17-fs-watch-type-fswatcher-s-wrapper-self-r
Jun 5, 2026
Merged

fs.watch: type FSWatcher's wrapper self-reference as JsRef instead of a bare JSValue#31848
Jarred-Sumner merged 3 commits into
mainfrom
claude/complex-17-fs-watch-type-fswatcher-s-wrapper-self-r

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

FSWatcher stored its reference to its own JS wrapper as a bare Cell, an untyped encoded value with no expression of its GC contract. The wrapper's liveness is rooted by hasPendingActivity() (pending_activity_count starts at 1 and stays positive until close/detach), so the field is now a JsCell held weak on purpose — a Strong here would be a self-reference cycle that pins the wrapper forever. All readers (emit, emit_with_filename, emit_error, emit_abort, close, the js_this() accessor) now go through JsRef::try_get() and copy the value out before any re-entrant listener call, and detach() idempotently resets the ref to JsRef::empty(). Behavior is unchanged; the field is now typed for its GC role and every read is null-checked through one audited path. Tested with a new subprocess GC-stress test in fs.watch.test.ts that forces Bun.gc(true) between watcher creation, event delivery, abort-signal abort, re-entrant close-from-listener, double close, and close-event emission, asserting all events arrive and the process exits cleanly.

Verification

Implemented and verified on a unified integration branch: full debug build (linux-x64, ASAN), cargo check across the workspace, and the affected test files run against the debug build (failures cross-checked against main's build to exclude pre-existing issues). Each change was reviewed twice (compile/API correctness and GC/concurrency/semantics lenses) with findings repaired before landing.

@robobun

robobun commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Comment thread test/js/node/watch/fs.watch.test.ts
Base automatically changed from claude/todo-audit-fixes to main June 5, 2026 03:30
@Jarred-Sumner Jarred-Sumner requested a review from alii as a code owner June 5, 2026 03:30
… a bare JSValue

FSWatcher stored its reference to its own JS wrapper as a bare Cell<JSValue>, an untyped encoded value with no expression of its GC contract. The wrapper's liveness is rooted by hasPendingActivity() (pending_activity_count starts at 1 and stays positive until close/detach), so the field is now a JsCell<JsRef> held weak on purpose — a Strong here would be a self-reference cycle that pins the wrapper forever. All readers (emit, emit_with_filename, emit_error, emit_abort, close, the js_this() accessor) now go through JsRef::try_get() and copy the value out before any re-entrant listener call, and detach() idempotently resets the ref to JsRef::empty(). Behavior is unchanged; the field is now typed for its GC role and every read is null-checked through one audited path. Tested with a new subprocess GC-stress test in fs.watch.test.ts that forces Bun.gc(true) between watcher creation, event delivery, abort-signal abort, re-entrant close-from-listener, double close, and close-event emission, asserting all events arrive and the process exits cleanly.
@Jarred-Sumner Jarred-Sumner force-pushed the claude/complex-17-fs-watch-type-fswatcher-s-wrapper-self-r branch from c1d5bf6 to 1123295 Compare June 5, 2026 03:31
@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@Jarred-Sumner, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 10 minutes and 23 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7d0fd0cd-042e-4b55-b8e4-b2df325c83d6

📥 Commits

Reviewing files that changed from the base of the PR and between 8553428 and e3802d2.

📒 Files selected for processing (3)
  • src/jsc/JSRef.rs
  • src/runtime/node/node_fs_watcher.rs
  • test/js/node/watch/fs.watch.test.ts

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

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

The Cell → JsCell swap looks mechanically 1:1 and behavior-preserving (JsRef::Weak is just a tagged JSValue), but since it re-expresses the GC rooting contract for FSWatcher's self-wrapper in native runtime code, it's worth a quick human look.

Extended reasoning...

Overview

This PR retypes FSWatcher.js_this from Cell<JSValue> to JsCell<JsRef> in src/runtime/node/node_fs_watcher.rs, with the ref held in Weak mode (which is literally enum { Weak(JSValue), ... } — the same payload as before, just tagged). All read sites move from if !js_this.is_empty() to if let Some(js_this) = ...try_get(), the sentinel moves from JSValue::ZERO to JsRef::empty() (= Weak(UNDEFINED)), detach() clears via JsRef::empty(), and the field goes from pub(super) to private (verified: no super-module reads it; win_watcher only touches encoding). The only external caller of js_this() is NodeFS::watch at node_fs.rs:8184, which reads it immediately after init_js has populated it, so the ZERO→UNDEFINED sentinel change for the cleared state is unobservable. A new subprocess GC-stress test is added to fs.watch.test.ts.

Security risks

None. No new inputs, no parsing, no auth/crypto/permissions surface. This is a storage-type refactor of an internal field.

Level of scrutiny

Medium. The transformation is mechanical and JsRef::Weak engages no additional GC machinery (it's a bare JSValue inside an enum), so the actual rooting story — wrapper kept alive by hasPendingActivity() while pending_activity_count > 0 — is unchanged. try_get() checks is_empty_or_undefined_or_null() rather than just is_empty(), which is strictly more conservative and equivalent here since the field is only ever a wrapper object or the cleared sentinel. JsCell::get() returns &JsRef and try_get() immediately copies out an Option<JSValue>, so no borrow is held across the re-entrant listener calls. That said, this is native JSC-GC-adjacent code where rooting mistakes are subtle, so a human familiar with the JsRef/hasPendingActivity pattern signing off seems prudent.

Other factors

  • The bug-hunting system found nothing.
  • My previous nit about the explicit 30_000 test timeout was answered by the author (file-local precedent + bounded ~20s worst case under slow CI) and the thread is resolved; I'm satisfied with that.
  • No CODEOWNERS cover these paths.
  • The new test is additive and follows the existing subprocess-fixture pattern in the same file.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

get().try_get()

Really? We're doing .get().get() and .get().try_get()? This is slop.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Added forwarding accessors on JsCell<JsRef> (try_get() and get_or_undefined() in JSRef.rs) and flattened all 6 call sites in node_fs_watcher.rs to a single step — no more .get().get() / .get().try_get(). The same double-call pattern predates this PR in ~20 other files (subprocess, cron, Terminal, h2_frame_parser, sql, sockets, …); they can now migrate to the same accessors in a follow-up.

@Jarred-Sumner Jarred-Sumner merged commit 08226e2 into main Jun 5, 2026
72 of 78 checks passed
@Jarred-Sumner Jarred-Sumner deleted the claude/complex-17-fs-watch-type-fswatcher-s-wrapper-self-r branch June 5, 2026 22:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants