Do not store the suspension sentinel when an await suspends a right-hand side - #2855
Merged
lahma merged 1 commit intoJul 28, 2026
Conversation
lahma
enabled auto-merge (squash)
July 28, 2026 15:21
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
force-pushed
the
fix/await-suspension-clobbers-assignment-target
branch
from
July 28, 2026 16:04
0150fb1 to
7992d0d
Compare
lahma
enabled auto-merge (squash)
July 28, 2026 16:07
This was referenced Jul 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
x = await pwrote the suspension sentinel intoxon the suspend pass; arejecting
pleftxclobbered toundefinedforever.Details
x = await pevaluates its right-hand side, suspends the async function atthe await, and returns a sentinel value standing in for "no value yet". The
assignment lanes guarded that store with
IsGeneratorAborted()only, so onthe suspend pass the sentinel was written straight into the target.
When
pRESOLVES the resumed re-entry re-evaluates and stores the real value,which hid the damage. When
pREJECTS the resumption throws before it reachesthe 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 asstate 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) oncontext.IsSuspended()in addition toIsGeneratorAborted().JintMemberExpression.TryAssignFastneeds no equivalent guard — it alreadydeclines whenever the execution context is suspendable.
Linked issue
No existing issue covers this; happy to file one if preferred.
Test plan
Jint.Tests— four tests inRuntime/AsyncTests.cs: rejected-await clobber of a local, of a membertarget, 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.
dotnet test --configuration Releaselocally — no new failures(identical failure set to unpatched
main).Jint.Tests.Test262and confirmed noregressions (identical results to unpatched
main).only executes when the context is already suspending.
Breaking change?
No.