Skip to content

Fix async resume through control-flow statements#2469

Merged
lahma merged 6 commits into
sebastienros:mainfrom
nfcampos:nc/async-resume-control-flow
May 14, 2026
Merged

Fix async resume through control-flow statements#2469
lahma merged 6 commits into
sebastienros:mainfrom
nfcampos:nc/async-resume-control-flow

Conversation

@nfcampos

@nfcampos nfcampos commented May 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Fix async resume through control-flow statements.

Details

  • Resume async execution inside catch/finally without rerunning the try block or leaking catch bindings.
  • Resume selected if/while/do-while/for/switch paths without rerunning already-completed guards, bodies, discriminants, or case tests.
  • Preserve accumulated loop/switch completion values and binary-expression left operands across await suspension.
  • Add focused AsyncTests coverage for catch/finally, branch, loop, switch, and binary-expression resume behavior.

Linked issue

Refs #

Test plan

  • Added or updated unit tests in Jint.Tests
  • Ran dotnet test --configuration Release locally
  • For ECMAScript spec changes: ran Jint.Tests.Test262 and confirmed no regressions
  • For interop changes: covered in Jint.Tests/Runtime/Interop
  • For perf changes: included before/after numbers from Jint.Benchmark

Additional local validation:

  • dotnet test Jint.Tests/Jint.Tests.csproj --filter "FullyQualifiedName~AsyncTests"
  • dotnet build Jint/Jint.csproj --configuration Release

Breaking change?

No.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c17b6d6b18

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Jint/Runtime/Interpreter/Statements/JintTryStatement.cs Outdated
@nfcampos
nfcampos force-pushed the nc/async-resume-control-flow branch from c17b6d6 to 4f777d3 Compare May 11, 2026 11:25

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4f777d3766

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Jint/Runtime/Interpreter/Statements/JintSwitchBlock.cs
@nfcampos
nfcampos force-pushed the nc/async-resume-control-flow branch from 4f777d3 to 278d35f Compare May 11, 2026 11:50

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

var value = currentEnv.GetBindingValue(name, strict: false);

P2 Badge Avoid reading uninitialized for-loop bindings on suspend

When suspension happens while evaluating a let/const initializer in the for header, e.g. for (let i = await Promise.resolve(0); i < 1; i++), the loop binding has been created but not initialized yet. This new save path walks every bound name and calls GetBindingValue, which throws the TDZ ReferenceError while trying to save suspend state, so the async function rejects instead of resuming the initializer.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@nfcampos
nfcampos force-pushed the nc/async-resume-control-flow branch from 278d35f to f9e081c Compare May 11, 2026 12:11

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f9e081c258

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Jint/Runtime/Interpreter/Statements/JintIfStatement.cs Outdated
@nfcampos
nfcampos force-pushed the nc/async-resume-control-flow branch from f9e081c to 23adf97 Compare May 11, 2026 13:01

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 23adf97a81

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Jint/Runtime/Interpreter/Statements/JintSwitchBlock.cs
@nfcampos
nfcampos force-pushed the nc/async-resume-control-flow branch from 23adf97 to a53c4ae Compare May 11, 2026 13:41
nfcampos and others added 6 commits May 14, 2026 12:40
## Summary

Fix async resume through control-flow statements.

## Details

- Resume async execution inside catch/finally without rerunning the try block or leaking catch bindings.
- Resume selected if/while/do-while/for/switch paths without rerunning already-completed guards, bodies, discriminants, or case tests.
- Preserve accumulated loop/switch completion values and binary-expression left operands across await suspension.
- Add focused AsyncTests coverage for catch/finally, branch, loop, switch, and binary-expression resume behavior.

## Linked issue

Refs #

## Test plan

- [x] Added or updated unit tests in Jint.Tests
- [ ] Ran dotnet test --configuration Release locally
- [ ] For ECMAScript spec changes: ran Jint.Tests.Test262 and confirmed no regressions
- [ ] For interop changes: covered in Jint.Tests/Runtime/Interop
- [ ] For perf changes: included before/after numbers from Jint.Benchmark

Additional local validation:

- dotnet test Jint.Tests/Jint.Tests.csproj --filter "FullyQualifiedName~AsyncTests"
- dotnet build Jint/Jint.csproj --configuration Release

## Breaking change?

No.
GetSuspensionNode and IsNodeInsideRange in JintStatement are consumed by the JintStatement subclasses and by JintSwitchBlock (a non-derived internal class). Neither caller is outside the Jint assembly, so 'protected internal' over-promises -- 'internal static' is the narrowest that compiles. Follows the internal-first guideline in CLAUDE.md.
…sions

The same "left side re-evaluated on resume" bug fixed for binary
expressions in the previous commit also affected `a && b`, `a || b`,
and `a ? b : c` when only the right side (or the consequent / alternate)
contained the await. Example:

    let d = 0;
    const ok = (++d > 0) && (await Promise.resolve(true));
    // d should be 1, was 2 — `++d` ran on resume too

Generalize the previous fix:

  - Rename BinaryExpressionSuspendData to LeftOperandSuspendData (a
    single-field container holding the already-evaluated left side).
  - Reuse it in JintLogicalAndExpression, JintLogicalOrExpression and
    JintConditionalExpression. On right-side / branch suspension, save
    the left/test value keyed by `this`; on resume restore it and skip
    re-evaluation. On short-circuit, clear suspend data so a later use
    of the same expression starts fresh.

Add 6 AsyncTests covering binary, &&, ||, ?: (consequent and alternate),
and the short-circuit-clears-data invariant.
A compound assignment like `obj[++i] += await y` would re-evaluate the
LHS on resume after the right side suspended — running the `++i` side
effect twice and aiming the assignment at a different slot. Generalize
the resume model used by binary/logical/conditional expressions:

  - Add AssignmentSuspendData storing the resolved Reference and the
    pre-mutation original value.
  - In JintAssignmentExpression, detect resume state up front and reuse
    the saved Reference + original value instead of re-running
    `_left.Evaluate(context)`. Only the slow path (anything other than
    plain identifier) is preserved — the fast path has no observable
    LHS side effect, so re-resolving on resume is fine and we keep
    pool churn down.
  - Replace each operator branch's `_referencePool.Return(lref)` on
    suspension with a single HandleSuspendedRight helper that either
    saves the data (slow path) or returns the pooled reference (fast
    path).
  - Clear suspend data on every successful exit, including
    short-circuit returns from `??=`, `&&=`, `||=`, to avoid stale
    state when the same expression is re-entered (e.g. in a loop).

Add 4 AsyncTests covering computed indexer +=, property accessor with
observable side effect, nullish-coalescing `??=`, and the simple
identifier (fast-path) baseline.
A call expression like `foo(++i, ++i, await Promise.resolve(++i))` was
re-running the entire argument list on resume after the await
suspended, re-incrementing earlier counters even though the await
result was already cached. Same pattern for `new X(...)`.

  - Add CallArgumentsSuspendData carrying the rented buffer and the
    next index to resume at.
  - ExpressionCache.ArgumentListEvaluation now takes a `key` (the
    owning call/new expression). On resume it reuses the saved buffer
    and starts BuildArguments at the saved index; on suspension it
    stows the buffer + index in suspend data and tells the caller
    rented=false (so the caller does not return the buffer to the
    pool). On normal completion it clears the suspend data and
    transfers buffer ownership to the caller with rented=true.
  - Update the three JintCallExpression call sites (regular call,
    eval, super) and JintNewExpression to pass `this` as the key.
  - JintNewExpression also gains an IsSuspended check around the
    arg-list evaluation that it was previously missing — without it,
    `new X(await y)` would proceed into Construct() with a partially
    filled buffer.
  - Spread-argument path (BuildArgumentsWithSpreads) is left
    untouched; same suspend-and-replay bug exists there but iterator
    state makes it a more involved fix. Worth a follow-up.

Add 4 AsyncTests covering plain call, call with multiple awaits,
new-expression, and the suspend-data-cleared-between-calls invariant.
Sync generators (yield), async generators (yield in async function*),
and async functions (await) all share the same ISuspendable.Data
suspend infrastructure plus the GetSuspensionNode / IsNodeInsideRange
helpers introduced by the control-flow resume fix. The fixes in the
previous commits are therefore generic, but the AsyncTests in the PR
only exercise the async-function path.

Add 11 GeneratorTests that mirror the key bug patterns:

  - yield inside catch — try block not re-run
  - yield inside if — test not re-run
  - yield inside for body — test/update not re-run
  - yield inside switch case — discriminant not re-run
  - binary, logical, conditional left-operand preservation
  - compound assignment LHS preservation
  - call-argument preservation
  - switch lexical binding preserved across yield
  - switch suspend-data cleared after resumed break in nested loop
  - async-generator equivalent of catch resume

These are regression guards against drift between the three suspend
paths now that the resume machinery is shared.
@lahma
lahma force-pushed the nc/async-resume-control-flow branch from a53c4ae to 455e5e0 Compare May 14, 2026 09:42
@lahma

lahma commented May 14, 2026

Copy link
Copy Markdown
Collaborator

Rebased onto current main (post #2474 / AwesomeAssertions migration) and pushed 5 follow-up commits on top of the original fix, each with test coverage. The original commit is unchanged.

Stack on top of eb677356c:

SHA
1 c15749dc8 Fix async resume through control-flow statements (original)
2 894b43081 Async resume helpers: tighten visibility to internal
3 a2be00fef Async resume: preserve left operand of logical and conditional expressions
4 ba86a7fdd Async resume: preserve compound assignment LHS across await
5 13dd98334 Async resume: preserve already-evaluated call/new arguments across await
6 455e5e099 Async resume: parity tests for generators and async generators

Why these specifically: the same "left half re-evaluated on resume" bug the original fix repairs for binary expressions also exists in && / || / ?: (commit 3), in compound-assignment LHS (obj[++i] += await y re-running ++i — commit 4), and in call/new argument lists (foo(++i, ++i, await x) re-running the earlier ++is — commit 5). All four use the same ISuspendable.Data infrastructure introduced by the original commit; commit 3 generalizes BinaryExpressionSuspendDataLeftOperandSuspendData and the others add focused suspend-data types (AssignmentSuspendData, CallArgumentsSuspendData).

Commit 6 adds 11 new tests in GeneratorTests.cs that mirror the AsyncTests with yield, since sync/async generators share the same resume machinery — these are regression guards against drift.

Verification on the rebased branch:

  • dotnet build --configuration Release clean (warnings-as-errors)
  • dotnet test Jint.Tests/Jint.Tests.csproj: 2973 passed / 0 failed / 5 skipped (net10.0); 2925 / 0 / 5 (net472) — 21 of those are new tests added in commits 3-6
  • dotnet test Jint.Tests.Test262/Jint.Tests.Test262.csproj: 98957 / 0 / 133 — no test262 regressions

One follow-up explicitly left out of this PR: ExpressionCache.BuildArgumentsWithSpreads has the same suspend-and-replay bug, but iterator state on resume is more involved. Worth a separate PR.

@lahma
lahma enabled auto-merge (squash) May 14, 2026 09:53
@lahma
lahma merged commit 8dd17e9 into sebastienros:main May 14, 2026
4 checks passed
@nfcampos
nfcampos deleted the nc/async-resume-control-flow branch May 14, 2026 09:55
lahma added a commit that referenced this pull request May 14, 2026
…2469 (#2475)

* Async resume: rename CallArgumentsSuspendData to ExpressionBufferSuspendData

The type is about to back both call/new argument lists and array literal
element lists, which share the same shape (JsValue[] Buffer + int NextIndex).
Mechanical rename only; no behavior change.

* Async resume: preserve evaluated array literal elements across await

`[++i, ++i, await Promise.resolve(++i)]` re-ran the first two `++i`
expressions on resume because JintArrayExpression.EvaluateInternal
called BuildArguments without a suspend key. Apply the same pattern
PR #2469 introduced for call/new arguments: on the non-spread path,
save the partial buffer + next index in ExpressionBufferSuspendData
(keyed by `this`); on resume, restart BuildArguments at the saved
index reusing the same buffer; clear on completion.

Spread path (`[a, ...iter, await x]`) is not addressed here — it has
the same suspend-and-replay bug but lives in
ExpressionCache.BuildArgumentsWithSpreads, handled in the next commit.

Add 3 AsyncTests (single await, multiple awaits, suspend-data cleared
between successive evaluations) and a sync-generator parity test.

* Async resume: preserve spread argument target list across await

`foo(...g, await x)` and `[...g, await x]` previously re-iterated a
custom (one-shot) iterator on resume, producing a wrong result —
sync iterators are drained after the first pass and yield empty on
the second. The same code path also unnecessarily re-evaluated
non-spread side-effectful elements that had already been added.

Changes:

  - `BuildArgumentsWithSpreads` now returns the next expression index
    and accepts a `startIndex`. Suspension at a non-spread element
    now bails BEFORE `target.Add(...)`, so the resume re-evaluates
    and appends exactly once.
  - New `ArgumentListEvaluationWithSpreadsResumable(context, key)`
    helper that wraps `BuildArgumentsWithSpreads` with suspend-data
    save/restore, keyed by the owning call/new/array expression.
  - `SpreadArgumentsSuspendData(List<JsValue> Target, int NextExpressionIndex)`
    holds the partial target list across suspension.
  - `ExpressionCache.ArgumentListEvaluation`'s `HasSpreads` branch
    and `JintArrayExpression.EvaluateInternal`'s spread branch both
    delegate to the new helper, passing `this` as the suspend key.

Spread iteration itself (the `JsArray { HasOriginalIterator: true }`
fast path and the `ArraySpreadProtocol.Execute()` generic path) is
synchronous and runs to completion once started — no mid-iterator
state needs preserving.

Add 4 AsyncTests (array-literal spread with one-shot iterator, call
spread with one-shot iterator, target survives multiple suspensions,
no spurious post-suspend side-effect) and 1 GeneratorTests parity.

* Async resume: preserve left operand of ?? across right-side await

`getX() ?? (await y)` re-ran `getX()` on resume. Sibling of the
`&&` / `||` / `?:` fix in PR #2469 commit 3 that was overlooked —
reuse the same `LeftOperandSuspendData` pattern.

Add an AsyncTests case and a sync-generator parity test.

* Async resume: preserve member expression base across property-side await

`getObj()[await x]` re-ran `getObj()` on resume because the slow path
of JintMemberExpression.EvaluateInternal resolves the object side
(possibly via _objectExpression.Evaluate) before _propertyExpression
may suspend. On resume the entire EvaluateInternal re-runs, doubling
any side effects in getObj().

Add MemberExpressionSuspendData carrying the resolved
(BaseValue, BaseReferenceName, ActualThis) triple keyed by `this`.
On resume, skip object-side resolution and the optional-chaining null
check (which already passed on the original run). Save on property
suspension; clear on completion.

GetValue's fast path is intentionally NOT changed: it has a property
that's already a JsString constant (no _propertyExpression), so
suspension there can only happen INSIDE _objectExpression itself —
and that expression has its own resume infrastructure.

Add 2 AsyncTests (computed property and chained member) plus a
sync-generator parity test.

* Async resume: preserve object literal properties across await

`{ a: ++i, b: await x, c: ++j }` re-evaluated `++i` on resume because
both BuildObjectFast and BuildObjectNormal restart their property loop
from index 0.

Add ObjectExpressionSuspendData carrying:
  - Target — the partially-built ObjectInstance (fast path uses JsObject,
    normal path uses Realm.Intrinsics.Object.Construct's result; both
    are ObjectInstance subtypes).
  - FastProperties — the in-flight PropertyDictionary used only by the
    fast-path builder (installed on Target via SetProperties at the end).
  - NextIndex — where to resume in _properties.

Both BuildObjectFast and BuildObjectNormal:
  - On resume, restore Target (+ FastProperties for the fast path) and
    start at NextIndex.
  - On suspension at any of the three points in the normal path
    (spread value, computed property key, init value), save Target +
    current index in a new helper SaveObjectExpressionSuspendState.
  - Clear suspend data on successful completion.

Add 3 AsyncTests (init properties, multiple awaits, computed key)
and a sync-generator parity test.

* Async resume: preserve template literal accumulator across await

Both untagged template literals (\`\${expr}\`) and tagged templates
(tag\`\${expr}\`) had two related bugs:

  1. No `IsSuspended` check inside the interpolation loop — when one
     interpolation suspended, later interpolations continued to run
     during the suspended pass, doubling their side effects.
  2. On resume, the entire interpolation loop restarted from index 0,
     re-evaluating leading interpolations (whose awaits are cached, but
     surrounding side effects in their expressions like `++i` are not).

For JintTemplateLiteralExpression:
  - Add `TemplateLiteralSuspendData(StringBuilder Accumulator, int NextExpressionIndex)`.
    System.Text.StringBuilder is used (rather than ValueStringBuilder
    which is a ref struct and can't live in suspend data) — a one-time
    string copy happens on suspension, which is the rare path.
  - On suspension after appending quasi[i]: save the accumulator + the
    expression index i. On resume: restore the accumulator (which
    already includes quasi[i]) and skip the quasi append for iteration
    i, going straight to the interpolation evaluation.

For JintTaggedTemplateExpression:
  - Add `TaggedTemplateSuspendData(ICallable Tagger, JsValue ThisObject, JsValue[] Args, int NextExpressionIndex)`.
  - Resolve the tagger + thisObject before the interpolation loop.
    Save them along with the partial args buffer.
  - On suspension during interpolation evaluation, save state and bail.
  - On resume, skip tag resolution and continue from saved index.
  - Add the missing `IsSuspended` break after `_tagIdentifier.Evaluate`.

Add 3 AsyncTests (template interpolation preservation, no post-suspend
side effects, tagged template) plus a sync-generator parity test.

* Async resume: fix double-evaluation of RHS in operator-overloaded compound assignment

When OperatorOverloadingAllowed is enabled, JintAssignmentExpression
runs EvaluateOperatorOverloading before the operator switch. Both call
_right.GetValue(context). If RHS suspended in the first call, the
switch would call it again on the same execution pass:

  - First _right.GetValue: suspends, registers promise handlers H1,
    inner side effects run.
  - TryOperatorOverloading called with the suspended sentinel; returns
    false (no CLR match).
  - Switch case runs: _right.GetValue called AGAIN.
  - JintAwaitExpression doesn't yet have IsResuming=true (we're still
    on the first pass) and _completedAwaits is empty, so it falls
    through to _awaitExpression.GetValue — re-running the inner side
    effect (e.g. `Promise.resolve(new Vector2(++counter, 10))` evaluates
    `++counter` twice).
  - Then registers a second pair of handlers H2 on a new promise.

When H1 fires, the function completes; when H2 fires, AsyncFunctionResume
sees state=Completed and bails. So output is correct but side effects
in the awaited expression have doubled.

Fix:
  - In EvaluateOperatorOverloading, return immediately after _right.GetValue
    if IsSuspended.
  - In EvaluateInternal, after calling EvaluateOperatorOverloading, check
    IsSuspended and call the existing HandleSuspendedRight helper to save
    LHS state and bail.

Add a test in OperatorOverloadingTests that uses the existing Vector2
overload and a counter callable: verifies the RHS side effect runs
exactly once across the full suspend/resume cycle.
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