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
97 changes: 97 additions & 0 deletions Jint.Tests/Runtime/PromiseTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Threading.Tasks;
using Jint.Native;
using Jint.Native.Object;
using Jint.Runtime;
Expand Down Expand Up @@ -619,4 +620,100 @@ public void UnwrapIfPromise_WithCancellationToken_RejectsCorrectly()
var ex = Assert.Throws<PromiseRejectedException>(() => promise.UnwrapIfPromise(cts.Token));
Assert.Equal("error!", ex.RejectedValue.AsString());
}

[Fact]
public async Task UnwrapIfPromiseAsync_ResolvesCorrectly()
{
Action<JsValue> resolveFunc = null!;

var engine = new Engine();
engine.SetValue("f", new Func<JsValue>(() =>
{
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<JsValue> rejectFunc = null!;

var engine = new Engine();
engine.SetValue("f", new Func<JsValue>(() =>
{
var (promise, _, reject) = engine.RegisterPromise();
rejectFunc = reject;
return promise;
}));

var promise = engine.Evaluate("f();");

rejectFunc("error!");

var ex = await Assert.ThrowsAsync<PromiseRejectedException>(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<JsValue>(() =>
{
var (promise, _, _) = engine.RegisterPromise();
return promise;
}));

var promise = engine.Evaluate("f();");

using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));

await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await promise.UnwrapIfPromiseAsync(cts.Token));
}

[Fact]
public async Task UnwrapIfPromiseAsync_WithIOBoundTask_DoesNotBlockCallerThread()
{
var engine = new Engine();
var ioStarted = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);

engine.SetValue("simulateIO", new Func<Task<int>>(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());
}
}
2 changes: 1 addition & 1 deletion Jint/Engine.Async.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public Task<JsValue> 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.
/// </summary>
private Task<JsValue> UnwrapResultAsync(JsValue result, CancellationToken cancellationToken)
internal Task<JsValue> UnwrapResultAsync(JsValue result, CancellationToken cancellationToken)
{
if (result is not JsPromise promise)
{
Expand Down
23 changes: 23 additions & 0 deletions Jint/JsValueExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

/// <summary>
/// Asynchronously unwraps a <see cref="JsPromise"/> 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 <see cref="PromiseRejectedException"/> with the rejection reason
/// 3. If "Pending" awaits settlement asynchronously
/// Else
/// returns the value intact immediately.
/// </summary>
/// <param name="value">value to unwrap</param>
/// <param name="cancellationToken">cancellation token to observe</param>
/// <returns>A task that resolves to the inner value if the value is a Promise, or the value itself otherwise</returns>
public static Task<JsValue> 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)
Expand Down
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Task<string>>(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:
Expand Down
Loading