Skip to content

Fix async test reporting and resolve async test262 failures#2359

Merged
lahma merged 21 commits into
sebastienros:mainfrom
lahma:analyze-async
Mar 24, 2026
Merged

Fix async test reporting and resolve async test262 failures#2359
lahma merged 21 commits into
sebastienros:mainfrom
lahma:analyze-async

Conversation

@lahma

@lahma lahma commented Mar 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

Resolves #2354 — Jint's Test262 harness was not properly implementing the async testing protocol (Test262:AsyncTestComplete / Test262:AsyncTestFailure markers), masking many async test failures as passing.

This PR:

  • Implements proper async test validation with $DONE marker detection and event loop draining
  • Fixes the underlying async/await engine issues that were hidden by the missing validation
  • +148 newly passing test262 tests (95,852 → 95,999), with 0 regressions

Root Cause

The test262 harness had no mechanism to:

  1. Capture Test262:AsyncTestComplete / Test262:AsyncTestFailure markers printed by $DONE via doneprintHandle.js
  2. Drain the engine's event loop (microtask queue) after script execution to allow async operations to complete
  3. Validate that async tests actually completed rather than silently hanging

Once proper async test validation was added, ~58 previously-passing tests were revealed as actually failing. All of these were then fixed through engine changes.

Fixes Applied

Async Test Infrastructure (Test262Test.cs)

  • Added WaitForAsyncTestCompletion() that drains the event loop, polls for $DONE markers, and validates completion
  • Added setTimeout support via engine.AddToEventLoop() for tests that use timing
  • 30-second timeout for async test completion

Async Generator Fixes (AsyncGeneratorInstance.cs, JintYieldExpression.cs)

  • yield* delegation return/throw: Proper forwarding through delegation chains with spec-compliant Await suspension
  • Return value awaiting: AsyncGeneratorAwaitReturn with cached PromiseResolve to avoid double thenable access
  • return; vs return expr;: Bare return; skips Await per spec 13.10.1; return expr; goes through AsyncGeneratorAwaitReturn
  • yield* return tick ordering: Two-phase Await in delegation return path per AsyncGeneratorUnwrapYieldResumption
  • Body disposal timing: DisposeResources called at correct point before leaving execution context

Promise/Await Fixes

  • AsyncGeneratorYield: Use PromiseResolve instead of CreateResolvedPromise to avoid extra thenable job tick when yielding Promise values (fixes for-await-of interleaving)
  • For-await-of: Added spec-required PromiseResolve(%Promise%, nextResult) call per Await step 1 (makes Promise.constructor lookups observable)
  • Await suspension in async generators: Proper suspension/resumption with _awaitSuspended flag and _completedAwaits cache

Array.fromAsync Fixes (ArrayConstructor.cs)

  • Don't await async iterator values when no mapfn (spec step 3.j.ii.8)
  • Use ulong for length overflow check (was truncating at 2^32)
  • Wrap SetLength in try-catch to reject promise on setter errors / readonly length

Resource Management

  • await-using implicit Await tick (DisposeCapability.cs, JintBlockStatement.cs): await using _ = null now properly introduces a microtask suspension per spec Dispose step 3.a
  • Async function disposal timing: Deferred DisposeResources for async functions

Module System

  • Evaluation ordering (CyclicModule.cs): Moved asyncEvalOrder counter to agent level per spec IncrementModuleAsyncEvaluationCount — was incorrectly local to each Evaluate() call

Dynamic Import Fixes

  • import.meta prototype (JintMetaPropertyExpression.cs, SourceTextModule.cs): import.meta now uses OrdinaryObjectCreate(null) per spec instead of Object.Construct(), which gave it Object.prototype — caused import(import.meta) to reject with string instead of TypeError
  • MemberExpression await suspension (JintMemberExpression.cs): Added IsSuspended() check in GetValue fast path to prevent double evaluation of await expressions in chained member access like (await x).value.x — was causing statement list _index to not save, leading to re-execution of side-effectful statements on async function resume

Atomics.waitAsync Fixes

  • Agent test support (Test262Test.cs, Test262AgentManager.cs): Multi-agent test infrastructure for Atomics.waitAsync tests with proper SharedArrayBuffer sharing
  • For-of let+await hang (JintForInForOfStatement.cs): Fixed hang when for (let x of ...) combined with await inside async generators

Test plan

  • dotnet test Jint.Tests.Test262 — 95,999 passed, 0 failed, 0 regressions
  • dotnet test Jint.Tests — all passing on both net472 and net10.0
  • All async exclusions removed except Atomics.waitAsync agent tests (require true multi-threading)

🤖 Generated with Claude Code

lahma and others added 19 commits March 24, 2026 22:08
Improve test262 runner to properly detect async test failures via
$DONE/doneprintHandle.js markers, then fix the root causes surfaced:

- AsyncFromSyncIterator.Next(): forward value argument to sync iterator
  using cached [[NextMethod]] per spec (was passing Arguments.Empty)
- Await: remove extra microtask tick by eliminating unnecessary
  resultCapability and AddToEventLoop wrapper in promise reaction handlers
- yield* delegation: cache nextMethod once before loop per spec, and
  always Await inner iterator results (handles thenables, not just JsPromise)
- AsyncFromSyncIterator: wrap GetMethod/done/value access in try-catch
  to reject promise on error; reject with TypeError for non-object results

Remaining async failures excluded by category for incremental fixing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Resume execution with Return/Throw completion types instead of bypassing
the generator body, fixing three interconnected issue categories:

- AsyncGeneratorResumeNext: for Return completions on SuspendedYield,
  resume execution with _resumeCompletionType=Return instead of calling
  AsyncGeneratorAwaitReturn directly. This allows try/finally blocks to
  execute and yield* delegation to forward return to inner iterators.
- ContinueAsyncYieldDelegate: override _delegationResumeType with
  _resumeCompletionType when user called .throw()/.return() during
  yield* delegation, so the completion flows to inner iterator.
- AwaitAndYieldDelegation: capture delegationResumeType and propagate
  Return completion when inner iterator returns done=true, enabling
  proper cleanup through try/finally in the outer generator.

Resolves 132 additional test262 failures (190 total with prior commit).
Remaining 244 failures excluded by category for incremental fixing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wrap IteratorComplete and IteratorValue calls in AwaitAndYieldDelegation's
onFulfilled handler with try-catch to properly reject the promise when
the result object's done or value getters throw. Previously, these
exceptions would propagate uncaught, breaking the generator state.

Resolves 48 additional test262 failures (238 total across all commits).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Close async iterators when mapfn throws, mapped value rejects, or
  CreateDataPropertyOrThrow fails (IfAbruptCloseAsyncIterator per spec)
- Fix onRejected handlers to pass args.At(0) instead of full args array
- Add RangeError check for array-like length > 2^32-1

Resolves 12 additional Array.fromAsync test262 failures (250 total).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Clear _returnRequested for async generators (not just sync) before
executing finally blocks in JintTryStatement. Without this, a yield
inside finally would be treated as a return, overwriting the pending
return value and preventing proper completion after finally.

This enables the PendingCompletionType/Value mechanism to correctly
restore the original Return completion after the finally block yields
and completes.

Resolves 8 additional test262 failures (258 total across all commits).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per spec, when an async generator returns, the return value must be
Awaited via PromiseResolve to unwrap promise values:

- SuspendedStart + Return: use AsyncGeneratorAwaitReturn instead of
  directly resolving (unwraps promise return values)
- Completed + Return: same treatment
- ResumeExecution: when body returns with Return completion, await
  the value via AsyncGeneratorAwaitReturn before completing

This ensures `.return(promise)` unwraps the promise value, and
`.return(brokenPromise)` properly rejects when PromiseResolve fails.

Resolves 6 additional test262 failures (264 total across all commits).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- AsyncFromSyncIterator throw: set closeOnRejection to true per spec
- yield* delegation: propagate IteratorValue errors to generator body
  as throw completions instead of rejecting the promise directly
- Async generator return at yield: try PromiseResolve at yield point
  so broken promise errors reach the body's try/catch
- AsyncGeneratorAwaitReturn: catch PromiseResolve failures and reject
- AsyncDisposableStack.disposeAsync: return rejected promise on invalid
  receiver instead of throwing synchronously
- Promise.any: set iteratorRecord.[[Done]] to true before propagating
  IteratorStep/IteratorValue errors to prevent unnecessary iterator close

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Destructuring: generalize yield/return handling to work with both sync
  and async generators via ISuspendable interface (was sync-only)
- For-await-of: save current iterator value when yield suspends inside
  destructuring, replay it on resume instead of re-calling next()
- For-await-of: preserve IsResuming flag when resuming from yield in
  destructuring so the yield expression can detect the resume
- AsyncGeneratorInstance: use undefined for normal completion value per
  spec (was incorrectly using the body's last expression value)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- ScriptFunction: skip DisposeResources for async functions (disposal
  must be deferred until the async body truly completes)
- AsyncBlockStart: call DisposeResources after body completes, before
  resolving/rejecting the async function's promise
- AsyncFunctionResume: same disposal at completion, skip when suspended
- Fixes using/await-using disposal timing in async functions: resources
  are no longer disposed while the function is suspended at an await
- Tick ordering and deferred-await-in-async-generator tests remain
  excluded (require full async await suspension architecture)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Array.fromAsync: catch TypeErrorException and RangeErrorException
  (thrown by TypeConverter without Engine context) and reject the
  promise instead of letting them escape as synchronous throws
- Un-exclude expression-yield-newline (already passes)
- Un-exclude asyncitems-arraylike-length-accessor-throws (now passes)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously, `await` inside async generator bodies fell through to the
blocking UnwrapIfPromise path because JintAwaitExpression only checked
for AsyncFunctionInstance (null for async generators). This caused
deadlocks on deferred promises and wrong microtask tick ordering.

- AsyncGeneratorInstance: add _awaitSuspended, _resumeWithThrow, and
  _completedAwaits fields; update IsSuspended to check _awaitSuspended
- JintAwaitExpression: add async generator resume/cache/suspend paths
  with SuspendForAwaitInAsyncGenerator method using promise handlers
  that resume via AsyncGeneratorContinueForAwait
- ResumeExecution/ResumeAfterDelegation: detect _awaitSuspended and
  return early (await handler resumes later)
- AsyncGeneratorYield: clear _completedAwaits on yield
- All loop statements: clear _completedAwaits for async generators
  at iteration boundaries (matching async function pattern)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- ResumeExecution: call DisposeResources on the lexical environment
  before leaving the execution context when the body completes
- ResumeAfterDelegation: same disposal at completion
- Both catch blocks: dispose resources before rejecting
- Fixes using/await-using in async generators: resources are now
  properly disposed when the generator body completes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… and un-exclude passing tests

Array.fromAsync: don't await async iterator values when no mapfn (spec 3.j.ii.8),
use ulong for length overflow check, wrap SetLength in try-catch to reject promise.
Async generators: distinguish return; (no await) from return expr; (await per 13.10.1),
cache PromiseResolve from yield return path to avoid double thenable "then" access.
Un-exclude 14 previously over-excluded tests that were already passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
For-await-of: add PromiseResolve call per spec Await step 1 (13.7.5.13)
to make Promise.constructor lookups observable. Fixes 6 ticks-with tests.

AsyncGeneratorYield: use PromiseResolve instead of CreateResolvedPromise
to avoid creating an extra thenable job when yielding Promise values.
This fixes microtask interleaving between for-await-of and Promise.then.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per spec Dispose step 3.a, even when method is undefined (null/undefined
resource), async-dispose must Await(result). This introduces a microtask
tick that suspends the async function.

DisposeCapability signals NeedsAsyncTick when it encounters an async-dispose
resource with no method. JintBlockStatement detects this after block exit
and creates a resolved promise to suspend the async function, ensuring
statements after the block run in a new microtask.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per spec AsyncGeneratorUnwrapYieldResumption step 2, the resumption value
must be Awaited before yield* delegation continues. Additionally, when
GetMethod("return") returns undefined, spec step 25.iii.1 requires a
second Await on the received value.

Split the yield* Return branch into async phases:
Phase A: Await(resumptionValue) — makes "then" getter observable before "return"
Phase B: GetMethod("return") lookup, then second Await if return is undefined

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per spec IncrementModuleAsyncEvaluationCount, the [[ModuleAsyncEvaluationCount]]
is a field of the Agent Record, not local to each Evaluate() call. When dynamic
import() triggers a new Evaluate() call, the counter must persist to maintain
correct relative ordering between pending async modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…sues

- Remove dead Agent.ModuleAsyncEvaluationCount (duplicate of Engine field)
- Extract ExecutionContext.ClearCompletedAwaitsIfNotResuming() replacing 4 copy-pasted blocks
- Extract AsyncGeneratorInstance.AbortDelegation()/ClearDelegationIterator() replacing ~13 inline blocks
- Fix bare catch to catch(Exception) in ArrayConstructor.AsyncIteratorClose
- Use CommonProperties.Return instead of string literals in JintYieldExpression
- Revert CreateResolvedPromise to private (no external callers)
- Clear _completedAwaits on generator completion to release references

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…c generator yield

Two fixes:

1. import.meta now uses OrdinaryObjectCreate(null) per spec instead of
   Object.Construct(), which gave it Object.prototype. This caused
   import(import.meta) to reject with a string instead of TypeError.

2. JintMemberExpression.GetValue fast path now checks IsSuspended() after
   evaluating the object expression. Without this, an await suspension
   inside a chained expression like (await x).value.x would fall through
   to the slow path, re-evaluating the await and preventing the statement
   list _index from being saved correctly. This caused side-effectful
   statements (like generator .next() calls) to re-execute on every async
   function resume, corrupting generator state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two fixes to enable all 60 previously excluded Atomics.waitAsync tests:

1. Agent worker event loop draining: worker threads now drain the event
   loop after engine.Execute() until agent.leaving() is called, allowing
   async continuations from await Atomics.waitAsync() to be processed.

2. For-of loop environment corruption on async resume: when a for-of body
   contains let declarations and await, the saved execution context
   captured a block-scoped environment. On resume, BodyEvaluation captured
   this as oldEnv instead of the function scope, corrupting the environment
   chain for subsequent iterations. Fixed by saving/restoring oldEnv in
   ForOfSuspendData.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@lahma
lahma enabled auto-merge (squash) March 24, 2026 21:37
@lahma
lahma merged commit f089071 into sebastienros:main Mar 24, 2026
4 checks passed
@lahma
lahma deleted the analyze-async branch March 24, 2026 21:39
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.

Jint.Tests.Test262 doesn't properly check async tests

1 participant