Fix #2477: spec-compliant async suspension for await using dispose#2478
Merged
lahma merged 4 commits intoMay 17, 2026
Conversation
…g dispose DisposeCapability.DisposeResources collapsed the three Await(...) suspension points defined by the Explicit Resource Management spec (3.d.i, 3.e.ii.1, 4.a) into a single synchronous UnwrapIfPromise. This blocked the calling thread and deadlocked the EvaluateAsync path when dispose ran inside an event-loop continuation: the outer RunAvailableContinuations held _isProcessing, the nested call from UnwrapIfPromise returned early via the re-entry guard, and the dispose-resolve callback sat in the queue until the 10s timeout. Refactor DisposeCapability into a coroutine-style state machine (BeginDispose / ContinueDispose) that exposes each spec-defined Await as a DisposeStepResult.Suspend(promise) the caller is responsible for awaiting. Drive the state machine from two integration patterns: Pattern A (JintBlockStatement) - dispose runs mid-function-body; suspend the async function on each pending dispose promise via the same machinery as a JS `await`, resume by advancing the state machine. Pattern B (function/generator/module exit) - dispose runs after the body has already finished; chain Promise.then handlers via a new DisposeResourcesHelper.DisposeAndThen so the function/generator's return promise is settled only when the full dispose chain has completed. Pattern B applies to: - JintFunctionDefinition.AsyncBlockStart - JintAwaitExpression.AsyncFunctionResume - AsyncGeneratorInstance (both ResumeExecution branches) - SourceTextModule top-level-await execution The legacy DisposeResources(Completion) wrapper still drives the state machine via UnwrapIfPromise for the remaining sync-only callers, where no async-dispose resource can occur (sync syntax). Tests cover: the user's repro (EvaluateAsync + Task<IAsyncDisposable>), genuinely-async DisposeAsync via Task.Delay, LIFO ordering across multiple await using, nested async functions, SuppressedError chaining, dispose at async function exit, and dispose after unhandled throw. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… path CI on the prior commit caught 4 regressions in AsyncDisposableStack/prototype/ disposeAsync where the legacy synchronous DisposeResources path no longer drained microtasks the way the previous in-loop RunAvailableContinuations did, so disposeAsync()'s capability was settled in the same tick as it was called and the spec-required Await(undefined) tick disappeared. Address both root causes: 1. Route AsyncDisposableStack.prototype.disposeAsync through Pattern B so each spec-defined Await consumes a real microtask tick via PerformPromiseThen rather than relying on the legacy sync drain. Add a DisposeCapability overload to DisposeResourcesHelper so DisposableStack can drive its own capability without owning an Environment. 2. Avoid the closure allocation that the helper otherwise forces on every async function/generator/module exit. Expose Environment.HasDisposeResources as a fast bool, and at each Pattern B call site take a fast path that calls the settle logic directly when no disposables are registered — preserving the pre-refactor zero-allocation hot path for the common case (no using declarations in scope). Tests: full Jint.Tests (3004 pass) and the Test262 async/dispose/for-loop sweep (3237 pass, including all 38 AsyncDisposableStack_prototype_disposeAsync cases that regressed on the prior commit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review fixes: - Drop the redundant asyncFn._isResuming = false in JintBlockStatement.ResumeDispose (the suspendable.IsResuming setter already routes to the same field). - Remove the unreachable catch (PromiseRejectedException) in DisposeCapability.Advance; method.Call surfaces JS rejection as JavaScriptException, not as the C# type. - Document that DisposeCapability's state machine is non-reentrant on a single instance, matching Jint's single-thread-per-Engine assumption. Latent async-generator context-leak fixed: - AsyncGeneratorInstance.ResumeExecution's slow path (HasDisposeResources == true) used to defer LeaveExecutionContext until the dispose chain's continueWith fired, leaving the generator's context on the engine's execution-context stack while PerformPromiseThen scheduled the dispose tick. Any synchronous `await` in the caller that ran between ResumeExecution returning and the dispose tick firing saw `engine.ExecutionContext.AsyncGenerator` still set and mis-routed through SuspendForAwaitInAsyncGenerator, eventually NRE-ing on a captured (later nulled) _currentPromiseCapability. - Fix by leaving the generator's context BEFORE dispatching the dispose chain in both ResumeExecution and ResumeAfterDelegation; the Settle helpers no longer call LeaveExecutionContext (now a caller responsibility). New local tests: - AsyncDisposableStack.disposeAsync microtask ordering (mirrors Test262 explicit-await-for-null.js — caught the prior CI-regression locally). - Top-level `await using` in a TLA module (Pattern B in SourceTextModule). - `await using` in an async generator body (Pattern B in AsyncGeneratorInstance — caught the context leak above). Test pass after these changes: - Jint.Tests: 3007 passed / 0 failed. - Test262 regression sweep (Async*/Disposable*/awaitUsing/Using/forAwait*/ forStatement/forInStatement/forOfStatement/Modules/Generator): 5219 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lahma
force-pushed
the
fix/2477-async-dispose-suspension
branch
from
May 17, 2026 09:44
dbd33dd to
5964e6e
Compare
SourceTextModule TLA context leak (symmetry with AsyncGenerator fix): - ExecuteAsyncModule now leaves the module's execution context AND sets _tlaAsyncInstance._state = Completed BEFORE dispatching the dispose chain via DisposeResourcesHelper.DisposeAndThen, matching the AsyncGenerator fix in commit 5964e6e. Otherwise the Promise.then callbacks scheduled by the dispose chain run with the module's context still on the stack, and a sync `await` in subsequent code can mis-route via this module's _tlaAsyncInstance. - SettleTla simplified to a static helper that just resolves/rejects the capability based on the final completion type. JintForInForOfStatement: per-iteration dispose state machine: - Previously, iterationEnv?.DisposeResources(result) drove the dispose state machine synchronously via UnwrapIfPromise, susceptible to the same sebastienros#2477 deadlock pattern for `for (await using x of asyncIter())` in an async function. - After body execution, when iterationEnv has dispose resources and we are in an async function (and the body didn't suspend), DriveDispose uses Pattern A — suspends the async function on each pending dispose promise via the same machinery as a JS `await`. - On resume, ResumeFromDispose advances the iteration env's dispose state machine with the awaited result, re-suspends if needed, then applies the same post-dispose handling the main loop applies (update accumulator, propagate Throw/Break/Return/Continue) and either returns the abrupt completion or re-enters BodyEvaluation for the next iteration with the saved iterator and accumulator. - ForOfSuspendData and ForAwaitSuspendData gain DisposeInProgress + DisposeResult fields; ForAwaitSuspendData also gains IterationEnv + OuterEnv to mirror what ForOfSuspendData already carried. - Setting close = false before returning the suspended completion prevents BodyEvaluation's finally block from clearing the suspend data we just stored — same pattern as the body-await suspension. - Sync / no-async-function contexts fall back to the legacy synchronous drive. Tests added (InteropDisposeTests): - ShouldAsyncDisposeInTopLevelAwaitModuleWithGenuinelyAsyncTask: TLA module with Task.Delay-based dispose to lock the SourceTextModule fix in place. - ShouldAsyncDisposeInForOfWithGenuinelyAsyncTask_SingleIteration and ShouldAsyncDisposeInForOfWithGenuinelyAsyncTask: for-of with truly-async dispose, 1 and 3 iterations, LIFO ordering verified. - ShouldAsyncDisposeInForOfPropagatesRejection: dispose-method rejection mid-loop aborts further iterations. Benchmark added (AsyncFunctionExitBenchmark): Three benchmarks measuring async function/generator exit cost — plain async fn exit, async fn with sync `using`, plain async generator exit. ShortRun comparison vs upstream/main: PlainAsyncFunctionExit_1000: 719us -> 682us, 1.99MB -> 2.01MB AsyncFunctionWithSyncUsing_1000: 1163us -> 1222us, 2.5MB -> 2.73MB PlainAsyncGeneratorExit_500: 2104us -> 1987us, 5.8MB -> 5.83MB Zero-alloc hot path (no `using`) preserved within noise — the HasDisposeResources fast path works as designed. Verified: - Jint.Tests net10.0: 3011 / 0 / 5 (passed / failed / skipped) - Jint.Tests net472: 2949 / 0 / 5 - Jint.Tests.Test262: 99,015 / 0 / 131 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 18, 2026
This was referenced Jun 4, 2026
This was referenced Jun 29, 2026
This was referenced Jul 7, 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
DisposeCapability.DisposeResourcesinto a coroutine-style state machine (BeginDispose/ContinueDispose) so the three spec-definedAwait(...)suspension points in Explicit Resource Management (3.d.i, 3.e.ii.1, 4.a) become real Promise hand-offs instead of a single synchronousUnwrapIfPromiseblock.JintBlockStatement(suspend the async function via the same machinery as a JSawait), and Pattern B (a newDisposeResourcesHelper.DisposeAndThenthat chainsPromise.thenso the function/generator/module return promise is settled only when the dispose chain completes). Pattern B is wired intoJintFunctionDefinition.AsyncBlockStart,JintAwaitExpression.AsyncFunctionResume, bothAsyncGeneratorInstanceresume paths, andSourceTextModuletop-level-await execution.Fixes #2477.
Why
The repro in the issue:
hangs and throws
PromiseRejectedException: Timeout of 00:00:10 reachedeven thoughDisposeAsync()is invoked and completes. The cause is a re-entrancy deadlock betweenDisposeCapabilityandEventLoop.RunAvailableContinuations:EvaluateAsync→UnwrapResultAsyncfast-path entersRunAvailableContinuationsand claims_isProcessing.makeDisposablesettle, which resumes the async function inline.JintBlockStatementcallsDisposeResources. TheasyncDisposeClrFunctionwrapsValueTask.CompletedTaskviaConvertAwaitableToPromise;RegisterPromiseWithClrValueenqueues the resolve, so the promise is still pending.DisposeResourcescallsUnwrapIfPromise, which pollsRunAvailableContinuations— that nested call returns early because_isProcessing == 1. The settle never executes; 10s later the polling loop throws.Rather than patch the event-loop re-entry guard, this PR makes every async-context
DisposeResourcescall site suspend properly per spec, so no path inside an event-loop continuation needsUnwrapIfPromiseon a dispose result.Test plan
Jint.Tests.Runtime.InteropDisposeTestscovering: the user's repro viaEvaluateAsync, genuinely-asyncDisposeAsync(Task.Delay), LIFO ordering with multipleawait using, nestedawait usingacross async functions,SuppressedErrorchaining, dispose at async function exit, and dispose after unhandled throw.ShouldAsyncDispose(syncEvaluate + UnwrapIfPromiseentry path) still passes.Statements_awaitUsingpass; the broader async/dispose/for-loop sweep shows 3233 pass with only 4 pre-existing failures (AsyncDisposableStack_prototype_disposeAsync) that exist onmainand are unrelated to this change.🤖 Generated with Claude Code