Skip to content

Fix top-level await of a .NET Task in a module failing with "Pending"#2665

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/2663-tla-task-module
Jul 13, 2026
Merged

Fix top-level await of a .NET Task in a module failing with "Pending"#2665
lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/2663-tla-task-module

Conversation

@lahma

@lahma lahma commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

Fixes #2663.

When a module uses top-level await (TLA) to await a .NET Task<T> returned from a registered method (with ExperimentalFeature.TaskInterop), engine.Modules.Import() throws:

InvalidOperationException: Error while evaluating module: Module evaluation did not return a fulfilled promise: Pending

var engine = new Engine(o => o.ExperimentalFeatures = ExperimentalFeature.TaskInterop);
engine.SetValue("getAnswer", new Func<Task<int>>(async () => { await Task.Delay(100); return 42; }));
engine.Modules.Add("main", "const x = await getAnswer(); export const answer = x;");
var ns = engine.Modules.Import("main"); // throws

Root cause

EvaluateModule drains the event loop until the module's top-level capability promise settles, but it did so with a tight loop (max 10 iterations, no wait) that completes in microseconds. A Task<T> awaited at the top level completes on a ThreadPool thread and enqueues its resolve job only milliseconds later, so the queue is empty on every iteration; the loop hits its empty-queue limit and gives up while the promise is still Pending, then throws.

UnwrapIfPromise (used by, e.g., the await using disposal path) does not have this problem because it polls on the promise's completion event with a short interval, giving the background Task time to complete.

Fix

Replace the spin loop with a poll that mirrors UnwrapIfPromise: drain continuations, then wait on the promise's completion event with a 10 ms interval, bounded by Constraints.PromiseTimeout. The draining thread is claimed via _waitingThreadId so background Task completions don't race to run JavaScript on the engine. The shared logic lives in a new Engine.DrainEventLoopUntilSettled helper.

Modules whose top-level await settles purely through microtasks (the entire Test262 corpus) still settle on the first drain call, so there is no added latency for them — confirmed by the timings below.

Tests

  • New ShouldResolveTopLevelAwaitOfDelayedTaskInModule — the exact repro from the issue (asserts answer === 42).
  • New ShouldPropagateRejectionOfTopLevelAwaitedTaskInModule — a faulted top-level-awaited Task surfaces as a JavaScriptException out of Import rather than a spurious pending state.

Both failed against main (...did not return a fulfilled promise: Pending) and pass with the fix, on net10.0 and net472.

Regression coverage (all green):

  • Full Jint.Tests suite: 3539 (net10.0) / 3476 (net472)
  • Test262 top-level-await: 273 passed in 186 ms
  • Test262 dynamic-import: 1906 passed in 619 ms

Modules.Import evaluates a top-level-await module by draining the event
loop until the module's capability promise settles. The drain used a tight
loop (max 10 iterations, no wait) that completes in microseconds: when the
top-level await awaits a .NET Task<T> (task interop), the Task resolves on a
ThreadPool thread milliseconds later, so the queue is empty on every
iteration and the loop gives up and throws:

  InvalidOperationException: Module evaluation did not return a fulfilled
  promise: Pending

Replace the spin with a poll that waits on the promise's completion event
with a short interval (mirroring UnwrapIfPromise), bounded by
Constraints.PromiseTimeout, and claims the waiting thread so background Task
completions don't race to run JavaScript on the engine. Purely
microtask-settled modules (the Test262 case) still settle on the first
drain, so there is no added latency for them.

Fixes sebastienros#2663

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

Modules.Import fails with "Pending" when top-level await awaits a .NET Task

1 participant