Skip to content

Do not store the suspension sentinel when an await suspends a right-hand side - #2855

Merged
lahma merged 1 commit into
sebastienros:mainfrom
HermanusMuellerEU:fix/await-suspension-clobbers-assignment-target
Jul 28, 2026
Merged

Do not store the suspension sentinel when an await suspends a right-hand side#2855
lahma merged 1 commit into
sebastienros:mainfrom
HermanusMuellerEU:fix/await-suspension-clobbers-assignment-target

Conversation

@HermanusMuellerEU

Copy link
Copy Markdown
Contributor

Summary

x = await p wrote the suspension sentinel into x on the suspend pass; a
rejecting p left x clobbered to undefined forever.

Details

x = await p evaluates its right-hand side, suspends the async function at
the await, and returns a sentinel value standing in for "no value yet". The
assignment lanes guarded that store with IsGeneratorAborted() only, so on
the suspend pass the sentinel was written straight into the target.

When p RESOLVES the resumed re-entry re-evaluates and stores the real value,
which hid the damage. When p REJECTS the resumption throws before it reaches
the store, and the target is left clobbered for good:

js let value = 'initial'; try { value = await Promise.reject(new Error('boom')); } catch (e) { value; // undefined under Jint; 'initial' per spec and other engines } ​

Same for member targets (obj.prop = await p) and global bindings.
Reproduces on 4.12.0 and current main. We hit it in a real workload as
state that "reset itself" after a failed fetch: every cache = await refresh() in a try/catch wiped the previous cache on failure.

The fix

Per the assignment-operator evaluation semantics
(https://tc39.es/ecma262/#sec-assignment-operators-runtime-semantics-evaluation)
PutValue runs only after GetValue(rref) has actually produced a value — an
await that suspends never reaches the store. Guard the three store sites in
JintAssignmentExpression (SetValue, AssignToIdentifier,
AssignToCachedGlobalBinding) on context.IsSuspended() in addition to
IsGeneratorAborted().

JintMemberExpression.TryAssignFast needs no equivalent guard — it already
declines whenever the execution context is suspendable.

Linked issue

No existing issue covers this; happy to file one if preferred.

Test plan

  • Added or updated unit tests in Jint.Tests — four tests in
    Runtime/AsyncTests.cs: rejected-await clobber of a local, of a member
    target, of a global through the warmed cached-global-binding lane (looped so
    the cache is hit), and a resolved-await control that must keep assigning.
    Three fail before the fix; all four pass after.
  • Ran dotnet test --configuration Release locally — no new failures
    (identical failure set to unpatched main).
  • For ECMAScript spec changes: ran Jint.Tests.Test262 and confirmed no
    regressions (identical results to unpatched main).
  • For interop changes: n/a
  • For perf changes: n/a — the added check is two flag tests on a path that
    only executes when the context is already suspending.

Breaking change?

No.

@lahma lahma left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thank you!

@lahma
lahma enabled auto-merge (squash) July 28, 2026 15:21
@HermanusMuellerEU

Copy link
Copy Markdown
Contributor Author

I'll resolve the conflicts as soon as I can.

…and side

`x = await p` evaluates its right-hand side, suspends the async function at
the await, and returns a sentinel value standing in for "no value yet". The
three assignment lanes guarded that store with IsGeneratorAborted() only, so
on the suspend pass the sentinel was written straight into the target.

When p RESOLVES the resumed re-entry re-evaluates and stores the real value,
which hid the damage. When p REJECTS the resumption throws before it reaches
the store, and the target is left clobbered to undefined for good:

    let value = 'initial';
    try {
        value = await Promise.reject(new Error('boom'));
    } catch (e) {
        value; // undefined under Jint; 'initial' per spec and other engines
    }

Per https://tc39.es/ecma262/#sec-assignment-operators-runtime-semantics-evaluation
PutValue runs only after the right-hand side has actually produced a value, so
the suspend pass must not assign at all. Guard on context.IsSuspended() too in
SetValue, AssignToIdentifier and AssignToCachedGlobalBinding.

JintMemberExpression.TryAssignFast needs no equivalent guard — it already
declines whenever the execution context is suspendable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
auto-merge was automatically disabled July 28, 2026 16:04

Head branch was pushed to by a user without write access

@HermanusMuellerEU
HermanusMuellerEU force-pushed the fix/await-suspension-clobbers-assignment-target branch from 0150fb1 to 7992d0d Compare July 28, 2026 16:04
@lahma
lahma enabled auto-merge (squash) July 28, 2026 16:07
@lahma
lahma merged commit 862d0aa into sebastienros:main Jul 28, 2026
4 checks passed
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.

3 participants