diff --git a/Jint.Tests/Runtime/PromiseTests.cs b/Jint.Tests/Runtime/PromiseTests.cs index b77425bde4..71be013dca 100644 --- a/Jint.Tests/Runtime/PromiseTests.cs +++ b/Jint.Tests/Runtime/PromiseTests.cs @@ -1,3 +1,4 @@ +using System.Threading.Tasks; using Jint.Native; using Jint.Native.Object; using Jint.Runtime; @@ -619,4 +620,100 @@ public void UnwrapIfPromise_WithCancellationToken_RejectsCorrectly() var ex = Assert.Throws(() => promise.UnwrapIfPromise(cts.Token)); Assert.Equal("error!", ex.RejectedValue.AsString()); } + + [Fact] + public async Task UnwrapIfPromiseAsync_ResolvesCorrectly() + { + Action resolveFunc = null!; + + var engine = new Engine(); + engine.SetValue("f", new Func(() => + { + var (promise, resolve, _) = engine.RegisterPromise(); + resolveFunc = resolve; + return promise; + })); + + var promise = engine.Evaluate("f();"); + + resolveFunc(42); + var result = await promise.UnwrapIfPromiseAsync(); + Assert.Equal(42, result.AsInteger()); + } + + [Fact] + public async Task UnwrapIfPromiseAsync_RejectsCorrectly() + { + Action rejectFunc = null!; + + var engine = new Engine(); + engine.SetValue("f", new Func(() => + { + var (promise, _, reject) = engine.RegisterPromise(); + rejectFunc = reject; + return promise; + })); + + var promise = engine.Evaluate("f();"); + + rejectFunc("error!"); + + var ex = await Assert.ThrowsAsync(async () => await promise.UnwrapIfPromiseAsync()); + Assert.Equal("error!", ex.RejectedValue.AsString()); + } + + [Fact] + public async Task UnwrapIfPromiseAsync_NonPromiseReturnsValue() + { + var engine = new Engine(); + var result = engine.Evaluate("42"); + + var unwrapped = await result.UnwrapIfPromiseAsync(); + Assert.Equal(42, unwrapped.AsInteger()); + } + + [Fact] + public async Task UnwrapIfPromiseAsync_WithCancellationToken_ThrowsOperationCanceledException() + { + var engine = new Engine(); + engine.SetValue("f", new Func(() => + { + var (promise, _, _) = engine.RegisterPromise(); + return promise; + })); + + var promise = engine.Evaluate("f();"); + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); + + await Assert.ThrowsAnyAsync(async () => await promise.UnwrapIfPromiseAsync(cts.Token)); + } + + [Fact] + public async Task UnwrapIfPromiseAsync_WithIOBoundTask_DoesNotBlockCallerThread() + { + var engine = new Engine(); + var ioStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + engine.SetValue("simulateIO", new Func>(async () => + { + ioStarted.TrySetResult(true); + await Task.Delay(100); + return 99; + })); + + var jsPromise = engine.Evaluate("(async () => await simulateIO())()"); + + // Kick off the async unwrap (should not block) + var unwrapTask = jsPromise.UnwrapIfPromiseAsync(); + + // Wait for IO to start + await ioStarted.Task; + + // The unwrap task should still be pending while IO is in flight + Assert.False(unwrapTask.IsCompleted, "UnwrapIfPromiseAsync should not block; task should still be pending during IO"); + + var result = await unwrapTask; + Assert.Equal(99, result.AsInteger()); + } } diff --git a/Jint/Engine.Async.cs b/Jint/Engine.Async.cs index 32f7242c45..f1a2bb7179 100644 --- a/Jint/Engine.Async.cs +++ b/Jint/Engine.Async.cs @@ -84,7 +84,7 @@ public Task InvokeAsync(string propertyName, CancellationToken cancella /// Core async unwrap: if the result is a JsPromise, awaits its settlement /// without blocking any thread. For non-promise values, returns synchronously. /// - private Task UnwrapResultAsync(JsValue result, CancellationToken cancellationToken) + internal Task UnwrapResultAsync(JsValue result, CancellationToken cancellationToken) { if (result is not JsPromise promise) { diff --git a/Jint/JsValueExtensions.cs b/Jint/JsValueExtensions.cs index 0bb13e4a64..5e368b80ce 100644 --- a/Jint/JsValueExtensions.cs +++ b/Jint/JsValueExtensions.cs @@ -2,6 +2,7 @@ using System.Numerics; using System.Runtime.CompilerServices; using System.Threading; +using System.Threading.Tasks; using Jint.Native; using Jint.Native.Function; using Jint.Native.Object; @@ -678,6 +679,28 @@ public static JsValue UnwrapIfPromise(this JsValue value, TimeSpan timeout) public static JsValue UnwrapIfPromise(this JsValue value, CancellationToken cancellationToken) => UnwrapIfPromiseCore(value, Timeout.InfiniteTimeSpan, cancellationToken); + /// + /// Asynchronously unwraps a without blocking the calling thread. + /// If the value is a Promise: + /// 1. If "Fulfilled" returns the value it was fulfilled with + /// 2. If "Rejected" throws with the rejection reason + /// 3. If "Pending" awaits settlement asynchronously + /// Else + /// returns the value intact immediately. + /// + /// value to unwrap + /// cancellation token to observe + /// A task that resolves to the inner value if the value is a Promise, or the value itself otherwise + public static Task UnwrapIfPromiseAsync(this JsValue value, CancellationToken cancellationToken = default) + { + if (value is JsPromise promise) + { + return promise.Engine.UnwrapResultAsync(value, cancellationToken); + } + + return Task.FromResult(value); + } + private static JsValue UnwrapIfPromiseCore(JsValue value, TimeSpan timeout, CancellationToken cancellationToken) { if (value is JsPromise promise) diff --git a/README.md b/README.md index 1959ccffe4..2b4783492e 100644 --- a/README.md +++ b/README.md @@ -449,6 +449,72 @@ var id = ns.Get("result").AsInteger(); Note that you don't need to `EnableModules` if you only use modules created using `Engine.Modules.Add`. +## Asynchronous Execution + +Jint supports non-blocking execution of JavaScript that involves `async`/`await` and Promises. This is important in ASP.NET Core and other environments where blocking a thread while waiting for I/O can cause thread-pool exhaustion. + +### EvaluateAsync / ExecuteAsync / InvokeAsync + +Use `EvaluateAsync` when you want to evaluate JavaScript code that may return a Promise (e.g., an `async` function call). The method awaits Promise settlement without blocking any thread — the calling thread is released back to the pool while I/O is in flight: + +```c# +var engine = new Engine(); + +// Expose an async .NET method to JavaScript +engine.SetValue("fetchData", new Func>(async url => +{ + using var client = new HttpClient(); + return await client.GetStringAsync(url); +})); + +// EvaluateAsync properly awaits the Promise returned by the async IIFE +var result = await engine.EvaluateAsync(""" + (async () => { + const data = await fetchData('https://example.com/api'); + return data; + })() + """); + +Console.WriteLine(result.AsString()); +``` + +`ExecuteAsync` and `InvokeAsync` follow the same pattern: + +```c# +// Execute a script that may produce a Promise and await its completion +await engine.ExecuteAsync("async function init() { ... } init()"); + +// Invoke a named async function and await its result +var value = await engine.InvokeAsync("myAsyncFunction", arg1, arg2); +``` + +By default, async execution respects `Options.Constraints.PromiseTimeout`. You can also pass a `CancellationToken`: + +```c# +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); +var result = await engine.EvaluateAsync("(async () => await fetchData(url))()", cancellationToken: cts.Token); +``` + +### UnwrapIfPromiseAsync + +When you already hold a `JsValue` that may be a Promise — for example returned from `engine.Invoke(...)`, `value.Call(...)`, or a property access — use `UnwrapIfPromiseAsync` to await it without blocking: + +```c# +// Obtain a JsValue that may be a Promise +var jsValue = engine.Invoke("computeAsync", someArg); + +// Await it asynchronously (non-blocking) +var result = await jsValue.UnwrapIfPromiseAsync(); + +// With cancellation support +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); +var result = await jsValue.UnwrapIfPromiseAsync(cts.Token); +``` + +If `jsValue` is not a Promise it is returned immediately. If it is a rejected Promise a `PromiseRejectedException` is thrown. + +The synchronous `UnwrapIfPromise` is still available for scenarios where blocking is acceptable (e.g., CPU-bound scripts with no I/O), but `UnwrapIfPromiseAsync` should be preferred in any `async` call chain. + ## .NET Interoperability - Manipulate CLR objects from JavaScript, including: