Add async .NET API for JavaScript async/await without blocking threads#2274
Conversation
|
What model did that? |
|
Opus 4.6 via Claude Code |
|
Opus has contributed more value in 1 month than all of us combined in the lifetime of the project |
|
@desty2k did you have an idea in mind before prompting Claude or did you just let it cook options |
|
Haha, yes, I have to admit Claude is great. I was a little afraid to post this PR because not everyone likes AI. Still, if Claude didn't miss anything, these changes will be useful to everyone |
|
I asked Claude to analyze PRs with different approaches to implementing async support and recommend something that would be simple and wouldn't require rewriting the entire code base from scratch. Then it just implemented it |
|
● Validation Results — Consensus Across 3 Models Finding 1: CancellationTokenRegistration Leak in EventLoop.cs:95 ┌──────────┬───────────────────────┐ Unanimous: Real Issue. All three models confirm the CancellationTokenRegistration is never disposed. The nuance from Opus 4.6: it only matters when the user passes a long-lived CancellationToken without a timeout, because the timeout path creates an owned CTS Recommended fix: Capture and dispose the registration when the TCS completes: var ctr = cancellationToken.Register(static state => ((TaskCompletionSource) state!).TrySetCanceled(), tcs); |
lahma
left a comment
There was a problem hiding this comment.
Great addition, ideal with small API exposure and us humans can even grok it 😆 Did a small tweak based on review consensus.
…ests - EventLoop: Replace volatile bool with Interlocked.CompareExchange to fix TOCTOU race condition in RunAvailableContinuations() - JsPromise: Make ManualResetEventSlim lazy-allocated (only when polled), add PromiseIsHandled flag per TC39 spec - Task interop: Cache reflection lookups for Task<T>.Result and ValueTask<T>.AsTask() in ConcurrentDictionary - HostPromiseRejectionTracker: Implement per TC39 spec with Reject/Handle operations, expose PromiseRejectionTracker event on Engine.Advanced - Engine.Async.cs: Add EvaluateAsync, ExecuteAsync, InvokeAsync returning Task<JsValue>/Task<Engine> with CancellationToken and timeout support - Add 31 new tests: Promise.allSettled, Promise.any, async generators, for-await-of, thenable protocol, rejection tracking, async API, stress - Fix DateTests.CanUseMoment: use Moment.js YYYY (calendar year) format Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…e TCS edge case Replace the Task.Run polling loop in Engine.Async.cs with a truly async wake signal (TaskCompletionSource in EventLoop). During IO-bound .NET Task awaits (e.g. gRPC), zero threads are consumed — the event loop wake signal resumes execution when work arrives. Fix an edge case in WaitForEventAsync where a cancelled/timed-out TCS left in _eventAvailable could cause a brief busy-loop on subsequent async operations. The fix detects stale (completed) TCS entries and atomically replaces them. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…gation Tests sequential EvaluateAsync on same engine (stale TCS), mixed sync/async paths (_waitingThreadId contamination), engine recovery after timeout and cancellation, uncaught CLR task failures, synchronous throws, void Task, Prepared<Script> overload, and fast-path for already-resolved promises. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dispose the CancellationTokenRegistration when the TaskCompletionSource completes normally, preventing memory growth when a long-lived CancellationToken is used across many EvaluateAsync calls without a timeout.
2b1b7e2 to
3c13dae
Compare
|
@desty2k this is great, thank you! |
Hey, in my project I use Jint to run scripts that call async .NET methods (gRPC). With multiple engines running concurrently, the thread pool gets exhausted pretty quickly since each UnwrapIfPromise blocks a thread for the entire duration of the call.
I looked at #2049 but that approach adds async methods throughout the entire interpreter, so every expression, statement, callable gets a ValueTask variant, and the sync path ends up calling through them via .Preserve().GetAwaiter().GetResult(). This PR goes the other way: the interpreter is completely untouched, the async part is just a wake signal on the event loop that releases the thread during IO.
Details below.
How it works
The interpreter remains fully synchronous. The async API works by replacing the
ManualResetEventSlimpolling loop (which blocks a thread during IO) with aTaskCompletionSource-based wake signal in the event loop:EvaluateAsynccalls the same synchronousEvaluateinternallyawait, the engine's event loop callsWaitForEventAsyncwhich returns aTask— releasing the thread back to the poolEnqueuefires the wake signalZero threads are held during IO. Zero async state machines in the interpreter hot path.
Why this doesn't kill performance
Addressing the concern from #2049 discussion ("Async state machinery will add a lot of strain for performance"):
ValueTask-returning async methods to everyJintExpression,JintStatement, andICallable— then rewrites the sync path to call through them via.Preserve().GetAwaiter().GetResult()— this PR doesn't touch the interpreter at all. The sync path is the same code as before, with zeroValueTaskwrapping.EvaluateAsync("1+2")benchmarks at ~0.6μs/op vsEvaluate("1+2")at ~1.5μs/op because it avoids theUnwrapIfPromisespin-wait.ExperimentalFeature.TaskInterop. Engines that don't use it have zero overhead.Benchmark results (50 concurrent engines, Task.Delay IO)
1+2, no IO)Changes
Engine.Async.cs—EvaluateAsync,ExecuteAsync,InvokeAsyncentry pointsEngine.Advanced.cs—PromiseRejectionTrackereventEventLoop.cs—TaskCompletionSource-based wake signal replacingManualResetEventSlimpolling, with stale TCS detectionJsPromise.cs— LazyManualResetEventSlimallocationJsValue.cs—UnwrapIfPromisewith configurable timeoutHost.cs—HostPromiseRejectionTrackerper specPromiseOperations.cs— Reflection caching forTask<T>.ResultaccessAsyncTests.cs— 40 tests covering concurrent IO, error propagation, rejection tracking, cancellation, timeout, engine reuse after timeout/cancellation, mixed sync/async paths, and state recovery edge casesTest plan