diff --git a/Jint.Tests/Runtime/AsyncTests.cs b/Jint.Tests/Runtime/AsyncTests.cs index 830a5b968..fd70b9cf4 100644 --- a/Jint.Tests/Runtime/AsyncTests.cs +++ b/Jint.Tests/Runtime/AsyncTests.cs @@ -2209,6 +2209,33 @@ public void AwaitInsideForOfLoopShouldPreserveOneShotIteratorDestructuringWithAs Assert.Equal("7", result.AsString()); } +#if !NETFRAMEWORK + [Fact] + public async Task EventLoopShouldSignalAllConcurrentWaiters() + { + // Regression: a second concurrent caller of WaitForEventAsync used to receive + // Task.CompletedTask immediately because the loop only tracked a single + // outstanding TCS. Its outer loop would then spin (or, if a single Enqueue + // arrived, only the first waiter would be woken). With the multi-waiter fix, + // both waiters register and both are signaled by a single Enqueue. + var loop = new EventLoop(); + + var waiter1 = loop.WaitForEventAsync(CancellationToken.None); + var waiter2 = loop.WaitForEventAsync(CancellationToken.None); + + Assert.False(waiter1.IsCompleted, "waiter1 should be pending until Enqueue"); + Assert.False(waiter2.IsCompleted, "waiter2 should be pending until Enqueue"); + + loop.Enqueue(static () => { }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await Task.WhenAll(waiter1, waiter2).WaitAsync(cts.Token); + + Assert.True(waiter1.IsCompletedSuccessfully); + Assert.True(waiter2.IsCompletedSuccessfully); + } +#endif + class TestAsyncClass { private readonly ConcurrentBag _values = new(); diff --git a/Jint/Runtime/EventLoop.cs b/Jint/Runtime/EventLoop.cs index e2ea9f6e6..b7fdfca45 100644 --- a/Jint/Runtime/EventLoop.cs +++ b/Jint/Runtime/EventLoop.cs @@ -24,11 +24,16 @@ internal sealed record EventLoop internal volatile int _waitingThreadId = -1; /// - /// Async wake signal used by to release the thread - /// while waiting for events. Set by when new work arrives. - /// Uses TaskCompletionSource<bool> for compatibility with netstandard2.0/net462. + /// Async wake signals registered by callers of . + /// Each call appends its own TCS so that can wake every + /// outstanding waiter — supporting concurrent awaiters on a single engine + /// (e.g. a caller that awaits two engine-internal promises in parallel + /// via Task.WhenAll). Replaces an earlier single-field design that + /// silently dropped the second waiter and could spin until the first one + /// happened to resume. /// - private volatile TaskCompletionSource? _eventAvailable; + private readonly Lock _waitersLock = new(); + private List>? _waiters; public bool IsEmpty => _events.IsEmpty; @@ -36,17 +41,33 @@ public void Enqueue(Action continuation) { _events.Enqueue(continuation); - // Wake any async waiter. Atomically steal the TCS and signal it. - // This ensures the async loop in WaitForEventAsync wakes up promptly - // when new work is enqueued (e.g., from a Task.ContinueWith callback). - Interlocked.Exchange(ref _eventAvailable, null)?.TrySetResult(true); + // Wake every registered async waiter. Each one re-checks its own promise + // state on resume, so spurious wakes loop harmlessly back to WaitForEventAsync. + List>? toSignal = null; + lock (_waitersLock) + { + if (_waiters is { Count: > 0 }) + { + toSignal = _waiters; + _waiters = null; + } + } + + if (toSignal is not null) + { + for (var i = 0; i < toSignal.Count; i++) + { + toSignal[i].TrySetResult(true); + } + } } /// /// Waits asynchronously for events to be enqueued, releasing the current thread. /// Used by the async API path (EvaluateAsync/ExecuteAsync/InvokeAsync) to avoid /// blocking a thread during IO-bound operations. During the wait, zero threads - /// are consumed. + /// are consumed. Multiple concurrent waiters are supported — each registers its + /// own TCS and is signaled on every . /// /// Token to observe for cancellation. /// A task that completes when events are available or cancellation is requested. @@ -60,33 +81,20 @@ public Task WaitForEventAsync(CancellationToken cancellationToken) var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - // Atomically register our TCS. If another waiter is already registered, - // check whether it's stale (completed/cancelled from a prior timeout). - var previous = Interlocked.CompareExchange(ref _eventAvailable, tcs, null); - if (previous is not null) + lock (_waitersLock) { - if (!previous.Task.IsCompleted) - { - // Active waiter exists — events may already be pending. - return Task.CompletedTask; - } - - // Previous TCS is stale (cancelled/completed). Try to replace it with ours. - if (Interlocked.CompareExchange(ref _eventAvailable, tcs, previous) != previous) - { - // Enqueue cleared it concurrently — events are likely available. - return Task.CompletedTask; - } - - // Successfully replaced stale TCS, fall through to double-check. + (_waiters ??= new List>()).Add(tcs); } // Double-check after registration to close the race window where an event - // was enqueued between our IsEmpty check and the CompareExchange. + // was enqueued between our IsEmpty check and the list insertion. Self-signal + // rather than removing from the list — Enqueue's broadcast TrySetResult on + // an already-completed TCS is a no-op, so leaving the entry costs nothing + // beyond a single GC root until the next Enqueue clears the list. if (!_events.IsEmpty) { - Interlocked.Exchange(ref _eventAvailable, null); - return Task.CompletedTask; + tcs.TrySetResult(true); + return tcs.Task; } if (cancellationToken.CanBeCanceled)