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
1,246 changes: 1,246 additions & 0 deletions Jint.Tests/Runtime/AsyncTests.cs

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions Jint.Tests/Runtime/DateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ public void CanUseMoment()
var momentJs = EngineTests.GetEmbeddedFile("moment.js");
_engine.Execute(momentJs);

var parsedDate = _engine.Evaluate("moment().format('yyyy')").ToString();
Assert.Equal(DateTime.Now.Year.ToString(),parsedDate);
var parsedDate = _engine.Evaluate("moment().format('YYYY')").ToString();
Assert.Equal(DateTime.Now.Year.ToString(), parsedDate);
}

[Fact]
Expand Down
45 changes: 45 additions & 0 deletions Jint/Engine.Advanced.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Jint.Native;
using Jint.Native.Promise;

namespace Jint;
Expand Down Expand Up @@ -62,5 +63,49 @@ public ManualPromise RegisterPromise()
{
return _engine.RegisterPromise();
}

/// <summary>
/// Event raised when a promise is rejected without a handler (operation = Reject),
/// or when a handler is added to a previously unhandled rejected promise (operation = Handle).
/// This implements the HostPromiseRejectionTracker abstract operation from the TC39 spec.
/// </summary>
/// <remarks>
/// https://tc39.es/ecma262/#sec-hostpromiserejectiontracker
/// </remarks>
public event EventHandler<PromiseRejectionTrackerEventArgs>? PromiseRejectionTracker;

internal void RaisePromiseRejectionTracker(JsPromise promise, PromiseRejectionOperation operation)
{
PromiseRejectionTracker?.Invoke(_engine, new PromiseRejectionTrackerEventArgs(promise, operation));
}
}
}

/// <summary>
/// Event arguments for the PromiseRejectionTracker event.
/// </summary>
public sealed class PromiseRejectionTrackerEventArgs : EventArgs
{
/// <summary>
/// The promise that triggered the rejection tracking.
/// </summary>
public JsValue Promise { get; }

/// <summary>
/// The rejection reason (only meaningful when Operation is Reject).
/// </summary>
public JsValue? Value { get; }

/// <summary>
/// Whether this is a new unhandled rejection ("Reject") or a previously
/// unhandled rejection that now has a handler ("Handle").
/// </summary>
public PromiseRejectionOperation Operation { get; }

internal PromiseRejectionTrackerEventArgs(JsPromise promise, PromiseRejectionOperation operation)
{
Promise = promise;
Value = promise.State == PromiseState.Rejected ? promise.Value : null;
Operation = operation;
}
}
191 changes: 191 additions & 0 deletions Jint/Engine.Async.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
using System.Threading;
using Jint.Native;
using Jint.Native.Promise;
using Jint.Runtime;

namespace Jint;

#pragma warning disable MA0042 // The async methods intentionally call sync variants then wrap the result

public partial class Engine
{
/// <summary>
/// Evaluates JavaScript code asynchronously, properly awaiting any promises.
/// This is the non-blocking alternative to Evaluate() + UnwrapIfPromise().
/// During IO-bound operations (e.g., .NET Tasks awaited from JS), the calling
/// thread is released and zero threads are consumed until work is available.
/// </summary>
/// <param name="code">The JavaScript code to evaluate.</param>
/// <param name="source">Optional source identifier for debugging.</param>
/// <param name="cancellationToken">Cancellation token to observe.</param>
/// <returns>The resolved value if the result is a promise, otherwise the direct result.</returns>
public Task<JsValue> EvaluateAsync(string code, string? source = null, CancellationToken cancellationToken = default)
{
var result = Evaluate(code, source);
return UnwrapResultAsync(result, cancellationToken);
}

/// <summary>
/// Evaluates a prepared script asynchronously, properly awaiting any promises.
/// </summary>
/// <param name="preparedScript">The pre-parsed script to evaluate.</param>
/// <param name="cancellationToken">Cancellation token to observe.</param>
/// <returns>The resolved value if the result is a promise, otherwise the direct result.</returns>
public Task<JsValue> EvaluateAsync(in Prepared<Script> preparedScript, CancellationToken cancellationToken = default)
{
var result = Evaluate(preparedScript);
return UnwrapResultAsync(result, cancellationToken);
}

/// <summary>
/// Executes JavaScript code asynchronously, properly awaiting completion of any promises.
/// This is the non-blocking alternative to Execute() when the code may contain async operations.
/// </summary>
/// <param name="code">The JavaScript code to execute.</param>
/// <param name="source">Optional source identifier for debugging.</param>
/// <param name="cancellationToken">Cancellation token to observe.</param>
/// <returns>The engine instance for chaining, after all async work completes.</returns>
public async Task<Engine> ExecuteAsync(string code, string? source = null, CancellationToken cancellationToken = default)
{
var result = Execute(code, source)._completionValue;
if (result is JsPromise)
{
await UnwrapResultAsync(result, cancellationToken).ConfigureAwait(false);
}

return this;
}

/// <summary>
/// Invokes a JavaScript function asynchronously, properly awaiting any returned promise.
/// </summary>
/// <param name="propertyName">The name of the function to invoke.</param>
/// <param name="arguments">Arguments to pass to the function.</param>
/// <returns>The resolved value if the function returns a promise, otherwise the direct result.</returns>
public Task<JsValue> InvokeAsync(string propertyName, params object?[] arguments)
{
return InvokeAsync(propertyName, CancellationToken.None, arguments);
}

/// <summary>
/// Invokes a JavaScript function asynchronously, properly awaiting any returned promise.
/// </summary>
/// <param name="propertyName">The name of the function to invoke.</param>
/// <param name="cancellationToken">Cancellation token to observe.</param>
/// <param name="arguments">Arguments to pass to the function.</param>
/// <returns>The resolved value if the function returns a promise, otherwise the direct result.</returns>
public Task<JsValue> InvokeAsync(string propertyName, CancellationToken cancellationToken, params object?[] arguments)
{
var result = Invoke(propertyName, arguments);
return UnwrapResultAsync(result, cancellationToken);
}

/// <summary>
/// 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)
{
if (result is not JsPromise promise)
{
return Task.FromResult(result);
}

// Fast path: process any queued microtasks and check if already settled
RunAvailableContinuations();

if (promise.State == PromiseState.Fulfilled)
{
return Task.FromResult(promise.Value);
}

if (promise.State == PromiseState.Rejected)
{
return Task.FromException<JsValue>(new PromiseRejectedException(promise.Value));
}

// Slow path: promise is pending, use truly async waiting.
// No thread is consumed during the wait — the event loop wake signal
// will resume execution when new work arrives (e.g., from Task.ContinueWith).
return AwaitPromiseSettlementAsync(promise, cancellationToken);
}

/// <summary>
/// Truly async promise settlement loop. Releases the thread between event loop
/// processing cycles. When a .NET Task completes (e.g., gRPC IO), its ContinueWith
/// callback enqueues work on the event loop and signals the wake, causing this method
/// to resume on a thread pool thread, process the JS continuation, and either complete
/// or go back to sleep if another await is hit.
/// </summary>
private async Task<JsValue> AwaitPromiseSettlementAsync(JsPromise promise, CancellationToken cancellationToken)
{
var eventLoop = _eventLoop;
var timeout = Options.Constraints.PromiseTimeout;
var hasTimeout = timeout > TimeSpan.Zero;

// Build an effective CancellationToken that respects both user cancellation
// and the PromiseTimeout constraint. This ensures WaitForEventAsync wakes up
// when the timeout expires, even if no events have been enqueued.
CancellationTokenSource? ownedCts = null;
CancellationToken effectiveCt;

if (hasTimeout && cancellationToken.CanBeCanceled)
{
ownedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
ownedCts.CancelAfter(timeout);
effectiveCt = ownedCts.Token;
}
else if (hasTimeout)
{
ownedCts = new CancellationTokenSource(timeout);
effectiveCt = ownedCts.Token;
}
else
{
effectiveCt = cancellationToken;
}

try
{
while (promise.State == PromiseState.Pending)
{
effectiveCt.ThrowIfCancellationRequested();

// Truly async wait — releases the thread back to the pool.
// Zero threads consumed while waiting for IO to complete.
await eventLoop.WaitForEventAsync(effectiveCt).ConfigureAwait(false);

// Woke up — take ownership of the event loop for this processing cycle.
// Setting _waitingThreadId prevents any other thread from processing
// JavaScript continuations while we're running.
var previousWaitingThreadId = eventLoop._waitingThreadId;
eventLoop._waitingThreadId = Environment.CurrentManagedThreadId;
try
{
RunAvailableContinuations();
}
finally
{
eventLoop._waitingThreadId = previousWaitingThreadId;
}
}
}
catch (OperationCanceledException) when (hasTimeout && ownedCts!.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
// The timeout CTS fired, not the user's cancellation token.
// Translate to PromiseRejectedException to match sync API behavior.
throw new PromiseRejectedException($"Timeout of {timeout} reached");
}
finally
{
ownedCts?.Dispose();
}

return promise.State switch
{
PromiseState.Fulfilled => promise.Value,
PromiseState.Rejected => throw new PromiseRejectedException(promise.Value),
_ => throw new InvalidOperationException("Promise is still pending after async loop completed")
};
}
}
9 changes: 9 additions & 0 deletions Jint/Engine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,15 @@ internal void AddToEventLoop(Action continuation)
_eventLoop.Enqueue(continuation);
}

/// <summary>
/// Called by the host when a promise rejection is tracked/untracked.
/// Raises the <see cref="AdvancedOperations.PromiseRejectionTracker"/> event.
/// </summary>
internal void OnPromiseRejectionTracker(JsPromise promise, PromiseRejectionOperation operation)
{
Advanced.RaisePromiseRejectionTracker(promise, operation);
}

internal void AddToKeptObjects(JsValue target)
{
_agent.AddToKeptObjects(target);
Expand Down
49 changes: 45 additions & 4 deletions Jint/Native/JsPromise.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,45 @@ internal sealed class JsPromise : ObjectInstance

// valid only in settled state (Fulfilled or Rejected)
internal JsValue Value { get; private set; } = null!;
internal ManualResetEventSlim CompletedEvent { get; } = new();

/// <summary>
/// Lazily allocated ManualResetEventSlim, only created when someone actually
/// calls UnwrapIfPromise() and needs to wait. This avoids allocating an OS
/// synchronization primitive for every promise (most are never polled).
/// </summary>
private ManualResetEventSlim? _completedEvent;

/// <summary>
/// Gets or creates the completed event for synchronous waiting.
/// Thread-safe via Interlocked.CompareExchange.
/// </summary>
internal ManualResetEventSlim CompletedEvent
{
get
{
if (_completedEvent is not null)
{
return _completedEvent;
}

var newEvent = new ManualResetEventSlim(State != PromiseState.Pending);
var existing = Interlocked.CompareExchange(ref _completedEvent, newEvent, null);
if (existing is not null)
{
// Another thread created it first, dispose ours
newEvent.Dispose();
return existing;
}

return newEvent;
}
}

/// <summary>
/// Whether this promise has been observed by a reject handler.
/// Used by HostPromiseRejectionTracker per TC39 spec.
/// </summary>
internal bool PromiseIsHandled { get; set; }

internal List<PromiseReaction> PromiseRejectReactions = new();
internal List<PromiseReaction> PromiseFulfillReactions = new();
Expand Down Expand Up @@ -128,10 +166,13 @@ private JsValue RejectPromise(JsValue reason)
var reactions = PromiseRejectReactions;
PromiseRejectReactions = new List<PromiseReaction>();
PromiseFulfillReactions.Clear();
CompletedEvent.Set();
_completedEvent?.Set();

// Note that this part is skipped because there is no tracking yet
// 7. If promise.[[PromiseIsHandled]] is false, perform HostPromiseRejectionTracker(promise, "reject").
if (!PromiseIsHandled)
{
_engine._host.HostPromiseRejectionTracker(this, PromiseRejectionOperation.Reject);
}

return PromiseOperations.TriggerPromiseReactions(_engine, reactions, reason);
}
Expand All @@ -148,7 +189,7 @@ private JsValue FulfillPromise(JsValue result)
var reactions = PromiseFulfillReactions;
PromiseFulfillReactions = new List<PromiseReaction>();
PromiseRejectReactions.Clear();
CompletedEvent.Set();
_completedEvent?.Set();

return PromiseOperations.TriggerPromiseReactions(_engine, reactions, result);
}
Expand Down
17 changes: 14 additions & 3 deletions Jint/Native/JsValue.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Contracts;
using System.Numerics;
using System.Reflection;
using System.Runtime.CompilerServices;
using Jint.Native.Generator;
using Jint.Native.Iterator;
Expand Down Expand Up @@ -151,6 +153,12 @@ internal virtual bool TryGetIterator(
return true;
}

/// <summary>
/// Cached reflection lookups for Task interop to avoid repeated GetMethod/GetProperty calls.
/// </summary>
private static readonly ConcurrentDictionary<Type, PropertyInfo?> _taskResultPropertyCache = new();
private static readonly ConcurrentDictionary<Type, MethodInfo?> _valueTaskAsTaskMethodCache = new();

internal static JsValue ConvertAwaitableToPromise(Engine engine, object obj)
{
if (obj is Task task)
Expand All @@ -164,8 +172,9 @@ internal static JsValue ConvertAwaitableToPromise(Engine engine, object obj)
return ConvertTaskToPromise(engine, valueTask.AsTask());
}

// ValueTask<T>
var asTask = obj.GetType().GetMethod(nameof(ValueTask<object>.AsTask));
// ValueTask<T> - use cached reflection lookup
var objType = obj.GetType();
var asTask = _valueTaskAsTaskMethodCache.GetOrAdd(objType, static t => t.GetMethod(nameof(ValueTask<object>.AsTask)));
if (asTask is not null)
{
return ConvertTaskToPromise(engine, (Task) asTask.Invoke(obj, parameters: null)!);
Expand Down Expand Up @@ -200,7 +209,9 @@ internal static JsValue ConvertTaskToPromise(Engine engine, Task task)
return;
}

var result = continuationAction.GetType().GetProperty(nameof(Task<>.Result));
// Use cached reflection lookup for Task<T>.Result property
var taskType = continuationAction.GetType();
var result = _taskResultPropertyCache.GetOrAdd(taskType, static t => t.GetProperty(nameof(Task<object>.Result)));
if (result is not null)
{
resolveClr(result.GetValue(continuationAction));
Expand Down
Loading