Skip to content

Add async .NET API for JavaScript async/await without blocking threads#2274

Merged
lahma merged 4 commits into
sebastienros:mainfrom
desty2k:feature/async-refactor
Feb 8, 2026
Merged

Add async .NET API for JavaScript async/await without blocking threads#2274
lahma merged 4 commits into
sebastienros:mainfrom
desty2k:feature/async-refactor

Conversation

@desty2k

@desty2k desty2k commented Feb 7, 2026

Copy link
Copy Markdown
Contributor

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 ManualResetEventSlim polling loop (which blocks a thread during IO) with a TaskCompletionSource-based wake signal in the event loop:

  1. EvaluateAsync calls the same synchronous Evaluate internally
  2. When JS hits await, the engine's event loop calls WaitForEventAsync which returns a Task — releasing the thread back to the pool
  3. When the awaited .NET Task completes, Enqueue fires the wake signal
  4. A thread pool thread picks up the continuation and resumes JS execution

Zero 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"):

  • No async state machines in the interpreter. Unlike Engine InvokeAsync is implemented for task/valuetask structure #2049 which adds ValueTask-returning async methods to every JintExpression, JintStatement, and ICallable — 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 zero ValueTask wrapping.
  • No code duplication. Engine InvokeAsync is implemented for task/valuetask structure #2049 touches 31 files across the interpreter core. This PR changes 11 files, ~460 lines of runtime code (the rest is tests).
  • Pure JS overhead is lower, not higher. EvaluateAsync("1+2") benchmarks at ~0.6μs/op vs Evaluate("1+2") at ~1.5μs/op because it avoids the UnwrapIfPromise spin-wait.
  • Opt-in. Requires ExperimentalFeature.TaskInterop. Engines that don't use it have zero overhead.

Benchmark results (50 concurrent engines, Task.Delay IO)

Metric Sync Async
Peak threads (50 concurrent, 200ms IO) ~51 ~2
Thread scaling at 100 concurrency 100 threads 2 threads
Throughput (generous thread pool) 901 ops/s 976 ops/s
Throughput (constrained to 16 threads) 1.1 ops/s, 35 errors 943 ops/s, 0 errors
Pure JS overhead (1+2, no IO) 1.50 μs/op 0.60 μs/op

Changes

  • Engine.Async.csEvaluateAsync, ExecuteAsync, InvokeAsync entry points
  • Engine.Advanced.csPromiseRejectionTracker event
  • EventLoop.csTaskCompletionSource-based wake signal replacing ManualResetEventSlim polling, with stale TCS detection
  • JsPromise.cs — Lazy ManualResetEventSlim allocation
  • JsValue.csUnwrapIfPromise with configurable timeout
  • Host.csHostPromiseRejectionTracker per spec
  • PromiseOperations.cs — Reflection caching for Task<T>.Result access
  • AsyncTests.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 cases

Test plan

  • All existing tests pass (2717 passed, 0 failed)
  • 40 new async-specific tests including edge cases for engine reuse, stale TCS recovery, mixed sync/async execution, and error propagation
  • Rebased on latest main (no conflicts)
  • Benchmarked thread consumption, throughput, overhead, and starvation resilience

@sebastienros

Copy link
Copy Markdown
Owner

What model did that?

@desty2k

desty2k commented Feb 7, 2026

Copy link
Copy Markdown
Contributor Author

Opus 4.6 via Claude Code

@sebastienros

Copy link
Copy Markdown
Owner

Opus has contributed more value in 1 month than all of us combined in the lifetime of the project

@sebastienros

Copy link
Copy Markdown
Owner

@desty2k did you have an idea in mind before prompting Claude or did you just let it cook options

@desty2k

desty2k commented Feb 7, 2026

Copy link
Copy Markdown
Contributor Author

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

@desty2k

desty2k commented Feb 7, 2026

Copy link
Copy Markdown
Contributor Author

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

@lahma

lahma commented Feb 8, 2026

Copy link
Copy Markdown
Collaborator

● Validation Results — Consensus Across 3 Models

Finding 1: CancellationTokenRegistration Leak in EventLoop.cs:95

┌──────────┬───────────────────────┐
│ Model │ Verdict │
├──────────┼───────────────────────┤
│ Opus 4.6 │ ✅ Real Issue (Minor) │
├──────────┼───────────────────────┤
│ Sonnet │ ✅ Real Issue │
├──────────┼───────────────────────┤
│ Codex │ ✅ Real Issue │
└──────────┴───────────────────────┘

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
that gets disposed in the finally block (cleaning up registrations). But in server scenarios with a shared app-lifetime token, registrations accumulate across the while loop iterations in AwaitPromiseSettlementAsync.

Recommended fix: Capture and dispose the registration when the TCS completes:

var ctr = cancellationToken.Register(static state => ((TaskCompletionSource) state!).TrySetCanceled(), tcs);
tcs.Task.ContinueWith(static (_, state) => ((CancellationTokenRegistration)state!).Dispose(),
ctr, TaskScheduler.Default);

@lahma lahma left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great addition, ideal with small API exposure and us humans can even grok it 😆 Did a small tweak based on review consensus.

desty2k and others added 4 commits February 8, 2026 16:38
…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.
@lahma
lahma force-pushed the feature/async-refactor branch from 2b1b7e2 to 3c13dae Compare February 8, 2026 14:38
@lahma
lahma merged commit cf0f7e7 into sebastienros:main Feb 8, 2026
4 checks passed
@lofcz

lofcz commented Feb 14, 2026

Copy link
Copy Markdown
Contributor

@desty2k this is great, thank you!

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.

4 participants