jsc: require T: Send/T: Sync for JsCell<T>'s Send/Sync impls - #31500
jsc: require T: Send/T: Sync for JsCell<T>'s Send/Sync impls#31500robobun wants to merge 2 commits into
T: Send/T: Sync for JsCell<T>'s Send/Sync impls#31500Conversation
JsCell<T> is a repr(transparent) UnsafeCell<T> wrapper whose get(&self) -> &T is a *safe* function. Its Send and Sync impls were unconditional (`unsafe impl<T> Sync`/`Send`), so a &JsCell<!Sync> was shareable across threads via any Sync container and the safe get() would then surface a &T to a !Sync T (Rc, Cell, RefCell) on another thread — UB with zero unsafe at the call site. Anyone writing JsCell<Rc<..>> directly or through a struct field broke the single-JS-thread model with no unsafe marker. Gate the impls on the inner bound: `unsafe impl<T: Sync> Sync` / `unsafe impl<T: Send> Send`. This keeps the thread-affinity escape hatch (VirtualMachine carries its own unsafe impl Sync/Send and is unaffected; every other JsCell<!Sync> field lives in a struct that is already !Sync via a Cell/RefCount field and is never required to be Send), while making JsCell<!Sync>/JsCell<!Send> correctly !Sync/!Send so such a field can no longer silently make its container cross-thread-shareable. The whole workspace still builds. A co-located compile-time proof module (jscell_send_sync_proof) asserts both directions: JsCell<u32> stays Send+Sync, and JsCell<Rc<u32>> is !Send+!Sync via the auto-trait-ambiguity trick (same shape as subproc.rs's __pipe_reader_thread_confined). If the bound is ever reverted, the negative assertion fails to compile — caught by the rust build/check CI lanes. ConcurrentPromiseTask/WorkTask keep their hand-written `unsafe impl Send`: their Context implementors (TransformTask, WalkTask, PipelineTask, GetAddrInfoRequest) are genuinely !Send — they hold a &JSGlobalObject and JS-heap raw pointers that then() touches back on the JS thread, with any field that truly crosses to the worker already wrapped in ThreadSafe. A Send bound there would reject that correct-by-construction design, so those impls get expanded SAFETY docs naming the single-JS-thread contract instead.
WalkthroughThis PR strengthens thread-safety guarantees for ChangesJsCell Send/Sync Safety Tightening
Possibly related issues
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
Updated 12:02 AM PT - May 28th, 2026
❌ @robobun, your commit 06034bb has 1 failures in
🧪 To try this PR locally: bunx bun-pr 31500That installs a local version of the PR into your bun-31500 --bun |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
Adds test/internal/jscell-send-sync-bounds.test.ts. The fix is a type-level invariant with no runtime surface, so the test checks it with the compiler: a throwaway crate depends on the real bun_jsc and asserts JsCell<Rc<u32>> is !Send + !Sync via the auto-trait-ambiguity trick. `cargo check` (no link, so no C++/JSC objects needed — only the codegen the debug build already emitted) succeeds only when the bounds are present; with the old unconditional impls JsCell<Rc<u32>> is Send + Sync, both ambiguity blanket impls apply, and the check fails with E0283. Verified fail-before/pass-after: reverting src/jsc/JSCell.rs to the unconditional impls makes this test fail with E0283; with the bounds it passes. Skips when cargo / the debug codegen / the pinned toolchain are unavailable (e.g. release-only lanes), and runs fully offline.
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 004e7ef4-7588-4292-a5ff-e3aaaa3a62ca
📒 Files selected for processing (4)
src/jsc/ConcurrentPromiseTask.rssrc/jsc/JSCell.rssrc/jsc/WorkTask.rstest/internal/jscell-send-sync-bounds.test.ts
|
Closing in favor of #31181 (by @Jarred-Sumner), which addresses #31498's On the other two locations in #31498 ( Superseded; no reason to keep a competing, narrower PR open. |
Closes #31498.
What
JsCell<T>(src/jsc/JSCell.rs) is a#[repr(transparent)]wrapper overUnsafeCell<T>whoseget(&self) -> &Tis a safe function. ItsSend/Syncimpls were unconditional:Because
get()is safe and hands out&T, an unconditionalSynclets a&JsCell<T>cross threads (via anySynccontainer) and surface a&Tto a!SyncT(Rc,Cell,RefCell) on another thread — UB with zerounsafeat the call site. Anyone writingJsCell<Rc<…>>directly, or through a struct field, would break the single-JS-thread model with nounsafemarker. This is location #1 from the issue, and the one the reporter flagged as the real leak (its dangerous surface is a public, safe fn).Cause
The single-JS-thread invariant is a runtime contract, not something the unconditional impls enforce. The
Syncimpl is a deliberate, documented escape hatch so&'static VirtualMachine(which embedsJsCellfields) satisfies'statictrait objects without hand-written impls cascading everywhere — but it was stated as<T>rather than<T: Sync>, so it covered!Syncinner types it never should have.Fix
Gate each impl on the corresponding inner bound:
This keeps the escape hatch intact —
VirtualMachinecarries its ownunsafe impl Sync/Sendand is unaffected, and every otherJsCell<!Sync>field in the tree (JsCell<Strong>,JsCell<KeepAlive>,JsCell<EventLoopTimer>,JsCell<Vec<u8>>, …) lives in a struct that is already!Syncvia aCell/RefCountfield and is never required to beSend. AJsCell<!Sync>/JsCell<!Send>is now correctly!Sync/!Send, so such a field can no longer silently make its container cross-thread-shareable. The fullbun bdbuild stays green (this was the open question — it compiles workspace-wide).ConcurrentPromiseTask/WorkTask(issue locations #2 and #3)The issue proposed adding a
Sendbound (on the trait or theunsafe impl) for these, on the premise that "every implementor is alreadySend." That premise does not hold — I tried it and the build fails. All four/three implementors are genuinely!Send:TransformTask,WalkTask,PipelineTaskhold a&JSGlobalObject(an opaque!SyncFFI handle) plus JS-heap raw pointers.GetAddrInfoRequestholds*mut DNSLookup/*mut Resolver.These fields are JS-thread-bound and are touched by
then()back on the JS thread; the worker only runsrun(), which operates on the thread-safe subset. Fields that genuinely cross to the worker are already wrapped at the field level inbun_jsc::ThreadSafe(e.g.TransformTask::input_code). AC: Sendbound would reject this correct-by-construction design. Theseunsafe impl Sends are the same class of audited escape hatch asJsCell/VirtualMachine, so they keep the hand-written impl and I expanded their SAFETY docs to name the single-JS-thread contract and the strictschedule → run_from_thread_pool → on_finish → run_from_jssequencing, rather than adding a bound that breaks compilation.Verification
bun bddebug build is green with the tightenedJsCellbounds — theJsCell<!Send/!Sync>-heavy modules (Response, Request, FileSink/FileReader, sockets, valkey, dns) all compile.Bun.Transpiler().transform()(ConcurrentPromiseTask<TransformTask>) anddns.lookup(WorkTask<GetAddrInfoRequest>) — both work.jscell_send_sync_proof) asserts both directions —JsCell<u32>staysSend + Sync, andJsCell<Rc<u32>>is!Send + !Syncvia the auto-trait-ambiguity trick (same shape assrc/runtime/shell/subproc.rs's__pipe_reader_thread_confined). I confirmed with a standalonerustcthat this negative assertion fails to compile under the old unconditional impls (E0283, "multiple impls satisfyingJsCell<Rc<u32>>: …") and passes under the tightened bounds. It is not behind#[cfg(test)], so it compiles on every build andrust:check/rust:clippyrun — reverting the bound turns CI red.Tests
test/internal/jscell-send-sync-bounds.test.tschecks the invariant with the compiler, since it has no runtime surface: itcargo checks a throwaway crate that depends on the realbun_jscand assertsJsCell<Rc<u32>>is!Send + !Syncvia the auto-trait-ambiguity trick.cargo checkdoes not link (no C++/JSC objects needed — only the codegen the debug build already emitted), so it's cheap and self-contained. Verified fail-before/pass-after: with the old unconditional implsJsCell<Rc<u32>>isSend + Sync, both ambiguity blankets apply, and the check fails withE0283; with the tightened bounds it passes. The test skips on lanes without the debug codegen / cargo / pinned toolchain (e.g. release-only), and runs fully offline.A co-located compile-time proof (
jscell_send_sync_proofinJSCell.rs, same shape assrc/runtime/shell/subproc.rs's__pipe_reader_thread_confined) additionally guards both directions on every build, so reverting the bound also turns therust:check/rust:clippyCI lanes red.