From 29fb31e08e6825d12ab7ce89b2c541001f6b1e5d Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Mon, 13 Jul 2026 15:37:22 +0300 Subject: [PATCH] Fix top-level await of a .NET Task in a module hanging as "Pending" 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 (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 #2663 Co-Authored-By: Claude Opus 4.8 (1M context) --- Jint.Tests/Runtime/AsyncTests.cs | 54 +++++++++++++++++++++++++++++ Jint/Engine.Modules.cs | 43 ++++------------------- Jint/Engine.cs | 59 ++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 36 deletions(-) diff --git a/Jint.Tests/Runtime/AsyncTests.cs b/Jint.Tests/Runtime/AsyncTests.cs index 45a7d1c43..b1110a747 100644 --- a/Jint.Tests/Runtime/AsyncTests.cs +++ b/Jint.Tests/Runtime/AsyncTests.cs @@ -927,6 +927,60 @@ async function main() { Assert.Equal("success", result); } + [Fact] + public void ShouldResolveTopLevelAwaitOfDelayedTaskInModule() + { + // #2663: top-level await of a .NET Task in a module must block Modules.Import until the + // Task completes on a ThreadPool thread, instead of giving up in a tight microsecond loop + // and throwing "did not return a fulfilled promise: Pending". Success path, so the timeout + // budget must be generous enough not to race a delayed task's continuation under CI load. + Engine engine = new(options => + { + options.ExperimentalFeatures = ExperimentalFeature.TaskInterop; + options.Constraints.PromiseTimeout = GenerousPromiseTimeout; + }); + + engine.SetValue("getAnswer", new Func>(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"); + + Assert.Equal(42, ns.Get("answer").AsInteger()); + } + + [Fact] + public void ShouldPropagateRejectionOfTopLevelAwaitedTaskInModule() + { + // The rejection path of the same #2663 flow: a faulted top-level awaited Task must surface + // as a JavaScript error out of Modules.Import, not hang or report a spurious pending state. + Engine engine = new(options => + { + options.ExperimentalFeatures = ExperimentalFeature.TaskInterop; + options.Constraints.PromiseTimeout = GenerousPromiseTimeout; + }); + + engine.SetValue("boom", new Func>(async () => + { + await Task.Delay(50); + throw new InvalidOperationException("kaboom"); + })); + + engine.Modules.Add("main", """ + const x = await boom(); + export const answer = x; + """); + + Assert.Throws(() => engine.Modules.Import("main")); + } + #if !NETFRAMEWORK [Fact] diff --git a/Jint/Engine.Modules.cs b/Jint/Engine.Modules.cs index d44480363..a24f21b6f 100644 --- a/Jint/Engine.Modules.cs +++ b/Jint/Engine.Modules.cs @@ -211,42 +211,13 @@ private JsValue EvaluateModule(string specifier, Module module) return null; } - // For async modules (TLA), we need to run the event loop to process pending jobs - // which will resolve the module's promise. With complex module graphs and dynamic - // imports, promise handlers may be registered asynchronously, so we need to allow - // multiple iterations even when the queue appears empty. - var emptyQueueIterations = 0; - const int maxEmptyQueueIterations = 10; - - while (promise.State == PromiseState.Pending) - { - _engine.RunAvailableContinuations(); - - // Check if promise settled after processing continuations - if (promise.State != PromiseState.Pending) - { - break; - } - - // If no more jobs to process, this could mean: - // 1. True deadlock - promise will never resolve (error condition) - // 2. Complex module graph where async dependencies are chained and need more time - // We allow several iterations with empty queue before assuming deadlock. - if (_engine._eventLoop.IsEmpty) - { - emptyQueueIterations++; - if (emptyQueueIterations >= maxEmptyQueueIterations) - { - // Queue has been empty for multiple iterations - likely a real issue - break; - } - } - else - { - // Queue has events, reset the counter - emptyQueueIterations = 0; - } - } + // For async modules (top-level await), drive the event loop until the module's top-level + // capability promise settles. A .NET Task awaited at the top level (task interop) completes + // on a ThreadPool thread and enqueues its resolve job only after a delay, so we must poll + // rather than spin — a tight loop finishes in microseconds while the queue is still empty + // and gives up before the Task ever completes (issue #2663). Bounded by PromiseTimeout so a + // genuinely never-settling top-level await cannot block Import forever. + _engine.DrainEventLoopUntilSettled(promise, _engine.Options.Constraints.PromiseTimeout); if (promise.State == PromiseState.Rejected) { diff --git a/Jint/Engine.cs b/Jint/Engine.cs index 059664140..3c96ffe78 100644 --- a/Jint/Engine.cs +++ b/Jint/Engine.cs @@ -748,6 +748,65 @@ internal void RunAvailableContinuations() _eventLoop.RunAvailableContinuations(this); } + /// + /// Synchronously drives the event loop until leaves the pending state + /// or elapses. Between drains it waits on the promise's completion event + /// with a short poll interval so that continuations enqueued from a background thread — a .NET + /// awaited at a module's top level via task interop, or a + /// setTimeout callback — get a chance to settle the promise. + /// + /// + /// A tight spin loop gives up in microseconds and never observes a Task that only completes + /// milliseconds later on a ThreadPool thread (issue #2663). This mirrors the polling in + /// . A non-positive + /// means wait indefinitely. + /// + internal void DrainEventLoopUntilSettled(JsPromise promise, TimeSpan timeout) + { + // Claim this thread as the one draining the loop so background threads (Task completions) + // don't race to execute JavaScript continuations on the engine. Save/restore to support + // nesting (e.g. a re-entrant UnwrapIfPromise already waiting). + var previousWaitingThreadId = _eventLoop._waitingThreadId; + _eventLoop._waitingThreadId = System.Environment.CurrentManagedThreadId; + + try + { + var hasTimeout = timeout > TimeSpan.Zero; + var deadline = hasTimeout ? DateTime.UtcNow + timeout : DateTime.MaxValue; + var pollInterval = TimeSpan.FromMilliseconds(10); + var completedEvent = promise.CompletedEvent; + + while (promise.State == PromiseState.Pending) + { + RunAvailableContinuations(); + + if (promise.State != PromiseState.Pending) + { + break; + } + + if (hasTimeout) + { + var remaining = deadline - DateTime.UtcNow; + if (remaining <= TimeSpan.Zero) + { + break; + } + + completedEvent.Wait(remaining < pollInterval ? remaining : pollInterval); + } + else + { + completedEvent.Wait(pollInterval); + } + } + } + finally + { + _eventLoop._waitingThreadId = previousWaitingThreadId; + } + } + internal void RunBeforeExecuteStatementChecks(StatementOrExpression? statement) { // Avoid allocating the enumerator because we run this loop very often.