Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions Jint.Tests/Runtime/AsyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,60 @@ async function main() {
Assert.Equal("success", result);
}

[Fact]
public void ShouldResolveTopLevelAwaitOfDelayedTaskInModule()
{
// #2663: top-level await of a .NET Task<T> 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<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");

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<Task<int>>(async () =>
{
await Task.Delay(50);
throw new InvalidOperationException("kaboom");
}));

engine.Modules.Add("main", """
const x = await boom();
export const answer = x;
""");

Assert.Throws<JavaScriptException>(() => engine.Modules.Import("main"));
}

#if !NETFRAMEWORK

[Fact]
Expand Down
43 changes: 7 additions & 36 deletions Jint/Engine.Modules.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
59 changes: 59 additions & 0 deletions Jint/Engine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,65 @@ internal void RunAvailableContinuations()
_eventLoop.RunAvailableContinuations(this);
}

/// <summary>
/// Synchronously drives the event loop until <paramref name="promise"/> leaves the pending state
/// or <paramref name="timeout"/> 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
/// <see cref="System.Threading.Tasks.Task"/> awaited at a module's top level via task interop, or a
/// <c>setTimeout</c> callback — get a chance to settle the promise.
/// </summary>
/// <remarks>
/// 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
/// <see cref="JsValueExtensions.UnwrapIfPromise(Native.JsValue, TimeSpan)"/>. A non-positive
/// <paramref name="timeout"/> means wait indefinitely.
/// </remarks>
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.
Expand Down
Loading