Skip to content

JSModuleRecord::evaluate: resume suspended TLA body when a cycle sibling throws#281

Open
robobun wants to merge 1 commit into
mainfrom
robobun/tla-cycle-sibling-throw-resume
Open

JSModuleRecord::evaluate: resume suspended TLA body when a cycle sibling throws#281
robobun wants to merge 1 commit into
mainfrom
robobun/tla-cycle-sibling-throw-resume

Conversation

@robobun

@robobun robobun commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Repro

// a.mjs: import "./b.mjs"; import "./c.mjs";
// b.mjs: import "./a.mjs"; /* cycle */ globalThis.FLAG = "started"; await 0; globalThis.FLAG = "done";
// c.mjs: throw new Error("boom");
try { await import("./a.mjs") } catch {}
console.log(FLAG);  // bun: "started"   node/spec: "done"

When a top-level-await module is part of an import cycle and suspends at its first await, it is still on the InnerModuleEvaluation stack with status Evaluating. If a sibling in the same Evaluate() pass then throws, step 9 of Evaluate() stamps that error onto every stack member as evaluationError and transitions them to Evaluated.

When the suspended body's resume microtask (AsyncModuleExecutionResume) later fires, it re-enters JSModuleRecord::evaluate(), which would see evaluationError() set and re-throw it instead of resuming the body. Everything after the await (finally blocks, export initialisers, resource registration) is then silently skipped while the process continues.

Node and the spec resume the body: AsyncBlockStart runs it as an independent async context, and AsyncModuleExecutionFulfilled / AsyncModuleExecutionRejected already handle the "status is already Evaluated" case afterwards (step 1 of each).

Fix

Gate the evaluationError() early-return on the generator State still being Init (body not yet started). A suspended body (State > 0) proceeds to executeModuleProgram and runs to completion.

Verification

Tests live in the companion bun PR (test/js/bun/resolve/dynamic-import-tla-cycle.test.ts, 3 new cases). All 9 tests in that file pass with this change applied via bun run build:local; the 3 new tests fail on stock main. BUN_JSC_validateExceptionChecks=1 passes.


no test proof · iteration 0 · docs-only change; test-proof not applicable

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

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.

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 configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ea7fd997-8211-4b71-ab08-41e3c2a50b16

📥 Commits

Reviewing files that changed from the base of the PR and between e2d48d2 and 0e491da.

📒 Files selected for processing (1)
  • Source/JavaScriptCore/runtime/JSModuleRecord.cpp

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

@robobun

robobun commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author

Companion bun PR with tests: oven-sh/bun#33961

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I didn't find any bugs, but this touches core JSC module-evaluation semantics (TLA resume vs. graph-level evaluationError in cycles), so it's worth a human look to confirm the spec reasoning.

What was reviewed:

  • The State::Init gate: Init = 0, suspended-at-await states are positive resume labels, so the check correctly distinguishes "body not yet started" from "suspended mid-body".
  • Confirmed asyncExecutionFulfilled/Rejected step 1 already no-ops when status == Evaluated, so letting the body run to completion doesn't double-resolve the graph.
  • Ruled out re-execution of a sync-thrown body via this gate — after completion the executable is cleared and the top-of-function null-check returns first.
Extended reasoning...

Overview

This PR changes one guard in JSModuleRecord::evaluate() (JSModuleRecord.cpp). Previously, any call into evaluate() with a non-empty evaluationError() would re-throw and bail. Now, that early-return only fires when the generator State field is still Init (0) — i.e., the module body has not yet begun executing. A body that has already started and is suspended at an await (positive state = resume-point label) will proceed into executeModuleProgram and run to completion, even if a cycle sibling stamped an evaluationError onto this record while it was suspended on the InnerModuleEvaluation stack.

Security risks

None identified. This is a spec-compliance behavioral change in module evaluation ordering; no new inputs, parsing, or trust boundaries are introduced.

Level of scrutiny

High. JSModuleRecord::evaluate sits on the hot path for every ES module and directly implements TC39 Evaluate() / AsyncBlockStart semantics. The interaction between the generator-level State (Init/Executing/Completed/resume-label) and the graph-level Status + evaluationError is subtle and spec-driven. A regression here could skip or double-run module bodies. This is not a mechanical change; it encodes a specific reading of spec steps (Evaluate step 9, AsyncModuleExecutionFulfilled/Rejected step 1) that a JSC-familiar reviewer should confirm.

Other factors

  • Verified the State enum in JSGenerator.h: Init = 0, Completed = -1, Executing = -2; suspended states are positive integers, so asInt32AsAnyInt() == Init is the right "never started" test and mirrors the existing state.isNumber() && ... == Executing idiom used a few lines below and in asyncModuleExecutionResume (JSMicrotask.cpp:878).
  • Verified CyclicModuleRecord::asyncExecutionFulfilled/Rejected both early-return when status() == Evaluated (step 1), so resuming the body after the graph has already been marked failed does not corrupt graph state or double-reject the top-level capability — matching the PR description's claim.
  • The candidate concern "State::Init gate may allow re-execution of a synchronously-thrown module body" was examined and ruled out: once a body reaches State::Executing and the executable is cleared, the !m_moduleProgramExecutable guard at the top of evaluate() returns before the new check is reached.
  • Tests exist but live in a companion bun repo PR (oven-sh/bun#33961), not here, so this change lands without in-tree test coverage.

Given the critical code path and the spec-interpretation nature of the fix, deferring to a human reviewer rather than auto-approving.

@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
3a40c5b6 autobuild-preview-pr-281-3a40c5b6 2026-07-14 13:04:32 UTC
0e491dad autobuild-preview-pr-281-0e491dad 2026-07-11 03:49:29 UTC

…ing throws

When a top-level-await module is part of an import cycle and suspends at
its first await, it is still on the InnerModuleEvaluation stack with
status Evaluating. If a sibling in the same Evaluate() pass then throws,
step 9 of Evaluate() stamps that error onto every stack member as
evaluationError and transitions them to Evaluated.

When the suspended body's resume microtask later fires, it re-enters
JSModuleRecord::evaluate(), which would see evaluationError() set and
throw it instead of resuming the body. Everything after the await
(finally blocks, export initialisers, resource registration) is then
silently skipped while the process continues. Node and the spec resume
the body: AsyncBlockStart runs it as an independent async context, and
AsyncModuleExecutionFulfilled/Rejected already handle the "status is
already Evaluated" case afterwards.

Gate the evaluationError() early-return on the generator State still
being Init (body not yet started). A suspended body (State > 0) proceeds
to executeModuleProgram and runs to completion.
@robobun
robobun force-pushed the robobun/tla-cycle-sibling-throw-resume branch from 0e491da to 3a40c5b Compare July 14, 2026 12:30
robobun added a commit to oven-sh/bun that referenced this pull request Jul 14, 2026
A top-level-await module that is suspended at its await inside an import
cycle was never resumed if a sibling module threw during the same
Evaluate() pass: everything after the await (finally blocks, export
initialisers) was silently skipped while the process continued. Node and
the spec run the continuation.

The resume microtask re-enters JSModuleRecord::evaluate(), which took the
evaluationError() early-return once the cycle root had stamped the
sibling's error onto the suspended module. The WebKit change gates that
early-return on the generator State still being Init; a suspended body
proceeds to executeModuleProgram and runs to completion, and
asyncExecution{Fulfilled,Rejected} already no-op when status is Evaluated.

WebKit change: oven-sh/WebKit#281 (autobuild-preview-pr-281-0e491dad)
Comment on lines +118 to +119
JSValue state = internalField(Field::State).get();
if (state.isNumber() && state.asInt32AsAnyInt() == std::to_underlying(State::Init)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit: The new function-scope state local (line 118) is shadowed by the pre-existing if-init JSValue state = ... on line 130. Both reads are correct — line 130 intentionally re-reads after executeModuleProgram may mutate the field — but renaming this one to something like initialState (or scoping it in its own if-init) would make it clearer that it's the pre-execution snapshot.

Extended reasoning...

What

The PR introduces JSValue state = internalField(Field::State).get(); at function scope on line 118 of JSModuleRecord::evaluate. A few lines below, the pre-existing code on line 130 has:

if (JSValue state = internalField(Field::State).get(); !state.isNumber() || ...)

which declares a second state in the C++17 if-init scope, shadowing the outer one added by this PR.

Why it's only a nit

Both reads are semantically correct and intentionally distinct:

  • Line 118 reads the state before executeModuleProgram runs, to decide whether the evaluationError() early-return applies (only when the body is still in State::Init).
  • Line 130 re-reads the state after executeModuleProgram returns, because the interpreter mutates Field::State during execution (e.g. to Executing on completion, or a suspend index on await). Reusing the outer state here would be wrong — it holds a stale pre-execution value — so the re-read is required regardless of naming.

There is no build impact: JavaScriptCore does not compile with -Wshadow (that flag only appears under Source/ThirdParty for gtest/opus/abseil/libwebrtc, and OptionsIOS.cmake explicitly passes -Wno-shadow). So this cannot cause a warning or failure on any supported configuration.

Step-by-step

  1. evaluate() is entered; line 118 binds outer state to, say, jsNumber(0) (Init) or a suspend index.
  2. The Init check on line 119 uses outer state — correct.
  3. executeModuleProgram runs and writes a new value into internalField(Field::State).
  4. Line 130's if-init declares a new state bound to the fresh field value and uses it in the condition. The outer state is shadowed but never referenced again, so behavior is unchanged.

Addressing the refutation

One verifier argued this shouldn't be reported at all because scoped if-init reuse is idiomatic in this file (e.g. execute() uses the same pattern) and JSC doesn't build with -Wshadow. That's fair — this is purely a readability suggestion, not a defect, and it should not block the PR. It's flagged only because the two reads are deliberately different snapshots of the same field within ~12 lines, and a distinct name makes that intent self-documenting.

Suggested fix

Rename the new variable to initialState (or move it into its own if-init so both declarations are equally scoped):

if (JSValue initialState = internalField(Field::State).get();
    initialState.isNumber() && initialState.asInt32AsAnyInt() == std::to_underlying(State::Init)) {
    if (JSValue error = evaluationError()) { ... }
}

Entirely optional.

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.

1 participant