SynchronousModuleQueue: replay diverted reactions against their own realm - #371
SynchronousModuleQueue: replay diverted reactions against their own realm#371robobun wants to merge 1 commit into
Conversation
|
Warning Review limit reached
Next review available in: 5 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
…ealm drainSynchronousModuleQueue ran every captured reaction with the caller's globalObject. That was fine for the original require(esm) path (one realm), but when a ShadowRealm's module loader is entered while the queue is active, its reactions were replayed against the main realm's module registry instead of the ShadowRealm's. The module ended up registered in the wrong place, so a second importValue for the same specifier fetched a fresh instance instead of the cached one. Store the diverting realm on SynchronousModuleTask (it is already in scope at all four diversion points as the value queueMicrotask would have used), replay against it in drainSynchronousModuleQueue, and visit it in visitAggregateImpl.
7988891 to
4e9f003
Compare
There was a problem hiding this comment.
I didn't find any issues, but this touches cross-realm promise-reaction dispatch and GC rooting in core JSC runtime code, so it's worth a human look.
What was reviewed:
- The four diversion sites in
JSPromise.cppeach capture the sameglobalObjectthe fallbackqueueMicrotaskwould have used (realm()or the caller's param), so replay realm matches non-diverted behavior. - Struct field order in
SynchronousModuleTaskmatches all four brace-init lists. visitor.appendUnbarriered(t.globalObject)resolves to theJSCell*overload (precedent inScriptExecutable.cpp);t.globalObjectis never null at any append site.- Exception-path re-queue in
drainSynchronousModuleQueuealso uses the captured realm, so remaining reactions land on the correct global's microtask queue.
Extended reasoning...
Overview
This PR threads a JSGlobalObject* through VM::SynchronousModuleTask so that when drainSynchronousModuleQueue replays diverted module-loader promise reactions, each runs against the realm it was diverted from rather than the drain caller's realm. Changes span four files: the struct definition in VM.h, four brace-initializer updates in JSPromise.cpp (all inside USE(BUN_JSC_ADDITIONS) synchronous-queue branches), the drain loop in JSModuleLoader.cpp (both the runInternalMicrotask call and the exception-path queueMicrotask), and a GC-visit line in VM::visitAggregateImpl.
Security risks
None identified. This is an internal correctness fix for which realm's module registry receives loader reactions. It doesn't touch parsing, auth, or untrusted-input handling; the captured globalObject is always the promise's own realm(), which was already reachable.
Level of scrutiny
High. While the mechanical change is small (~15 lines), it sits at the intersection of promise reaction dispatch, ES module loading, cross-realm (ShadowRealm) semantics, and GC rooting — all subtle areas of JSC's runtime. The Bun-specific SynchronousModuleQueue machinery is itself a delicate deviation from normal microtask ordering. A reviewer familiar with why the queue exists and how it interacts with ShadowRealm's module map should confirm the realm choice is correct in all cases (e.g., that realm() on the settling promise is always the right module-loader owner, not just coincidentally so in the repro).
Other factors
- The four
globalObjectvalues captured are exactly what each site would have passed toglobalObject->queueMicrotask(...)on the non-diverted path, so behavior converges with the async path by construction. appendUnbarriered(JSCell*)exists and has precedent for global-object pointers, so the GC-visit addition compiles and behaves as intended; the author reports runningrequire-esm-gc-rootsundercollectContinuously=1.- The struct uses aggregate brace-init at all four sites; field order was verified to match the new layout (
task, payload, globalObject, arg0, arg1, arg2). - The PR description includes a concrete repro plus verification against Node's
test-shadow-realm-module.jsand existing sync-queue tests, which raises confidence but doesn't substitute for a domain expert's sign-off on cross-realm module-loader semantics.
There was a problem hiding this comment.
I didn't find any issues, but this touches cross-realm module-loader dispatch and GC root marking in JSC, so it's worth a human look.
Checked: all four m_synchronousModuleQueue->tasks.append sites now pass the same globalObject their fallback queueMicrotask call uses; struct field order matches every aggregate initializer.
Checked: visitor.appendUnbarriered(t.globalObject) binds to the JSCell* overload, consistent with the existing arg marking.
Checked: no other SynchronousModuleTask constructions exist in the tree; the drain loop's now-unused globalObject parameter is harmless.
Extended reasoning...
Overview
This PR fixes a cross-realm bug in Bun's synchronous module queue (require(esm) path). It adds a JSGlobalObject* field to VM::SynchronousModuleTask, populates it at the four diversion points in JSPromise.cpp, uses it in drainSynchronousModuleQueue (both the runInternalMicrotask call and the exception-path re-queue), and marks it in VM::visitAggregateImpl. All changes are under USE(BUN_JSC_ADDITIONS).
Security risks
None identified. The stored globalObject at each diversion point is the exact value the non-diverted path would have passed to queueMicrotask on the next line, so this restores parity rather than introducing new realm-crossing behavior. GC marking of the new field prevents a potential use-after-collect of a ShadowRealm global while its reaction sits in the queue.
Level of scrutiny
High. Although the diff is ~15 lines and mechanically consistent, it changes which realm module-loader reactions execute against and adds a GC root. Cross-realm semantics in JSC's module loader and promise-reaction dispatch are subtle enough that a maintainer familiar with the SynchronousModuleQueue design should confirm the fix, particularly the exception-path re-queue to rest.globalObject.
Other factors
The PR description includes a clear repro, before/after output, and lists the regression tests run (test-shadow-realm-module.js, require-esm-*, GC roots under collectContinuously=1). I verified via grep that there are no other construction sites for SynchronousModuleTask and that appendUnbarriered has a JSCell* overload. The change looks correct, but JSC runtime internals warrant human sign-off.
Preview Builds
|
Problem
JSModuleLoader::drainSynchronousModuleQueueruns every captured module-loader reaction with the caller'sglobalObject. That was fine for the originalrequire(esm)path (one realm), butvm.m_synchronousModuleQueueis VM-wide: when another realm's module loader is entered while the queue is active, its reactions are diverted to the queue and then replayed against the wrong realm's module registry.Repro
Reproducible on bun main today via
require(esm):The second
importValueloads a fresh copy ofcounter.mjsbecause the first load was registered in the main realm's module map instead of the ShadowRealm's.oven-sh/bun#34121 wraps every CommonJS entry in
SynchronousModuleQueue, which makes this trip on Node'stest/parallel/test-shadow-realm-module.js(a.cjsentry that callsrealm.importValuefrom an immediately-invoked async function).Cause
The four diversion points in
JSPromise.cpp(performPromiseThenWithInternalMicrotaskfulfilled/rejected,settleInlineInternalMicrotask,triggerPromiseReactions) all have the reaction's ownglobalObjectin scope (realm()or a parameter, exactly whatqueueMicrotaskwould have used) but don't store it onSynchronousModuleTask, sodrainSynchronousModuleQueuehas no way to recover it and substitutes the caller's.Fix
Add
JSGlobalObject* globalObjecttoSynchronousModuleTask, populate it at the four diversion points, run againstt.globalObjectin the drain loop (both therunInternalMicrotaskcall and the exception-pathqueueMicrotask), and mark it inVM::visitAggregateImplalongside the existing arg slots.Verification
Built bun against this branch locally. Before:
After:
Also ran
test/parallel/test-shadow-realm-module.js,test/js/bun/jsc/shadow.test.js, and therequire(esm)sync-queue tests (require-esm-transitive-tla,require-esm-microtask-order, therequire-esm-gc-rootsbody undercollectContinuously=1) to confirm the single-realm path and GC visiting still work.Unblocks oven-sh/bun#34121 for
test-shadow-realm-module.js. Related: oven-sh/bun#34115.