Skip to content

Fix #2477: spec-compliant async suspension for await using dispose#2478

Merged
lahma merged 4 commits into
sebastienros:mainfrom
lahma:fix/2477-async-dispose-suspension
May 17, 2026
Merged

Fix #2477: spec-compliant async suspension for await using dispose#2478
lahma merged 4 commits into
sebastienros:mainfrom
lahma:fix/2477-async-dispose-suspension

Conversation

@lahma

@lahma lahma commented May 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Refactor DisposeCapability.DisposeResources into a coroutine-style state machine (BeginDispose / ContinueDispose) so the three spec-defined Await(...) suspension points in Explicit Resource Management (3.d.i, 3.e.ii.1, 4.a) become real Promise hand-offs instead of a single synchronous UnwrapIfPromise block.
  • Drive the state machine from two integration patterns: Pattern A in JintBlockStatement (suspend the async function via the same machinery as a JS await), and Pattern B (a new DisposeResourcesHelper.DisposeAndThen that chains Promise.then so the function/generator/module return promise is settled only when the dispose chain completes). Pattern B is wired into JintFunctionDefinition.AsyncBlockStart, JintAwaitExpression.AsyncFunctionResume, both AsyncGeneratorInstance resume paths, and SourceTextModule top-level-await execution.

Fixes #2477.

Why

The repro in the issue:

var engine = new Engine(o => o.ExperimentalFeatures = ExperimentalFeature.TaskInterop);
engine.SetValue(\"makeDisposable\", new Func<Task<MyDisposable>>(() =>
    Task.FromResult(new MyDisposable())));
await engine.EvaluateAsync(\"\"\"
    (async () => {
        await using d = await makeDisposable();
    })()
    \"\"\");

hangs and throws PromiseRejectedException: Timeout of 00:00:10 reached even though DisposeAsync() is invoked and completes. The cause is a re-entrancy deadlock between DisposeCapability and EventLoop.RunAvailableContinuations:

  1. EvaluateAsyncUnwrapResultAsync fast-path enters RunAvailableContinuations and claims _isProcessing.
  2. Draining queues runs the makeDisposable settle, which resumes the async function inline.
  3. Body finishes; JintBlockStatement calls DisposeResources. The asyncDispose ClrFunction wraps ValueTask.CompletedTask via ConvertAwaitableToPromise; RegisterPromiseWithClrValue enqueues the resolve, so the promise is still pending.
  4. DisposeResources calls UnwrapIfPromise, which polls RunAvailableContinuations — 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 DisposeResources call site suspend properly per spec, so no path inside an event-loop continuation needs UnwrapIfPromise on a dispose result.

Test plan

  • New Jint.Tests.Runtime.InteropDisposeTests covering: the user's repro via EvaluateAsync, genuinely-async DisposeAsync (Task.Delay), LIFO ordering with multiple await using, nested await using across async functions, SuppressedError chaining, dispose at async function exit, and dispose after unhandled throw.
  • Existing ShouldAsyncDispose (sync Evaluate + UnwrapIfPromise entry path) still passes.
  • Full Jint.Tests suite: 3004 passed / 0 failed.
  • Test262: 184/184 Statements_awaitUsing pass; the broader async/dispose/for-loop sweep shows 3233 pass with only 4 pre-existing failures (AsyncDisposableStack_prototype_disposeAsync) that exist on main and are unrelated to this change.

🤖 Generated with Claude Code

lahma and others added 3 commits May 17, 2026 12:43
…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
lahma force-pushed the fix/2477-async-dispose-suspension branch from dbd33dd to 5964e6e Compare May 17, 2026 09:44
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>
@lahma
lahma merged commit afbe598 into sebastienros:main May 17, 2026
4 checks passed
@lahma
lahma deleted the fix/2477-async-dispose-suspension branch May 17, 2026 16:40
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.

await using with IAsyncDisposable causes EvaluateAsync to timeout

1 participant