Skip to content

Commit

Permalink
Propagate .NET stack to JS for async errors
Browse files Browse the repository at this point in the history
  • Loading branch information
jasongin committed May 9, 2024
1 parent 8b85d6c commit 2b3096b
Show file tree
Hide file tree
Showing 4 changed files with 117 additions and 65 deletions.
94 changes: 51 additions & 43 deletions src/NodeApi/JSError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,7 @@ public JSError(Exception exception)
}
}

var tempError = new JSError(exception.Message);
_message = tempError._message;
_errorRef = tempError._errorRef;
_errorRef = CreateErrorReference(CreateErrorValueForException(exception, out _message));
}

public string Message
Expand Down Expand Up @@ -192,6 +190,49 @@ public readonly JSValue Value
}
}

private static JSValue CreateErrorValueForException(Exception exception, out string message)
{
message = (exception as TargetInvocationException)?.InnerException?.Message
?? exception.Message;

// If the exception is a JSException for an error value, use that error value;
// otherwise construct a new error value from the exception message.
JSValue error = (exception as JSException)?.Error?.Value ??
JSValue.CreateError(code: null, (JSValue)message);

// A no-context scope is used when initializing the host. In that case, do not attempt
// to override the stack property, because if initialization fails the scope may not
// be available for the stack callback.
if (JSValueScope.Current.ScopeType != JSValueScopeType.NoContext)
{
// When running on V8, the `Error.captureStackTrace()` function and `Error.stack`
// property can be used to add the .NET stack info to the JS error stack.
JSValue captureStackTrace = JSValue.Global["Error"]["captureStackTrace"];
if (captureStackTrace.IsFunction())
{
// Capture the stack trace of the .NET exception, which will be combined with
// the JS stack trace when requested.
JSValue dotnetStack = exception.StackTrace?.Replace("\r", string.Empty)
?? string.Empty;

// Capture the current JS stack trace as an object.
// Defer formatting the stack as a string until requested.
JSObject jsStack = new();
captureStackTrace.Call(default, jsStack);

// Override the `stack` property of the JS Error object, and add private
// properties that the overridden property getter uses to construct the stack.
error.DefineProperties(
JSPropertyDescriptor.Accessor(
"stack", GetErrorStack, setter: null, JSPropertyAttributes.DefaultProperty),
JSPropertyDescriptor.ForValue("__dotnetStack", dotnetStack),
JSPropertyDescriptor.ForValue("__jsStack", jsStack));
}
}

return error;
}

public readonly void ThrowError()
{
if (_errorRef is null)
Expand Down Expand Up @@ -230,52 +271,15 @@ public readonly void ThrowError()
public static void ThrowError(Exception exception)
{
// Do not construct a JSError object here, because that would require a runtime context.

string message = (exception as TargetInvocationException)?.InnerException?.Message
?? exception.Message;

// If the exception is a JSException for an error value, throw that error value;
// otherwise construct a new error value from the exception message.
JSValue error = (exception as JSException)?.Error?.Value ??
JSValue.CreateError(code: null, (JSValue)message);

// A no-context scope is used when initializing the host. In that case, do not attempt
// to override the stack property, because if initialization fails the scope may not
// be available for the stack callback.
if (JSValueScope.Current.ScopeType != JSValueScopeType.NoContext)
{
// When running on V8, the `Error.captureStackTrace()` function and `Error.stack`
// property can be used to add the .NET stack info to the JS error stack.
JSValue captureStackTrace = JSValue.Global["Error"]["captureStackTrace"];
if (captureStackTrace.IsFunction())
{
// Capture the stack trace of the .NET exception, which will be combined with
// the JS stack trace when requested.
JSValue dotnetStack = exception.StackTrace?.Replace("\r", string.Empty)
?? string.Empty;

// Capture the current JS stack trace as an object.
// Defer formatting the stack as a string until requested.
JSObject jsStack = new();
captureStackTrace.Call(default, jsStack);

// Override the `stack` property of the JS Error object, and add private
// properties that the overridden property getter uses to construct the stack.
error.DefineProperties(
JSPropertyDescriptor.Accessor(
"stack", GetErrorStack, setter: null, JSPropertyAttributes.DefaultProperty),
JSPropertyDescriptor.ForValue("__dotnetStack", dotnetStack),
JSPropertyDescriptor.ForValue("__jsStack", jsStack));
}
}
JSValue error = CreateErrorValueForException(exception, out string message);

napi_status status = error.Scope.Runtime.Throw(
(napi_env)JSValueScope.Current, (napi_value)error);

if (status != napi_status.napi_ok && status != napi_status.napi_pending_exception)
{
throw new JSException(
$"Failed to throw JS Error. Status: {status}\n{exception.Message}");
$"Failed to throw JS Error. Status: {status}\n{message}");
}
}

Expand Down Expand Up @@ -323,14 +327,18 @@ private static JSValue GetErrorStack(JSCallbackArgs args)
{
jsStack = jsStack.Substring(firstLineEnd + 1);
}
else
{
jsStack = "";
}

// Normalize indentation to 4 spaces, as used by JS. (.NET traces indent with 3 spaces.)
if (jsStack.StartsWith(" at "))
{
dotnetStack = dotnetStack.Replace(" at ", " at ");
}

return $"{name}: {message}\n{dotnetStack}\n{jsStack}";
return $"{name}: {message}\n{dotnetStack}{(jsStack.Length > 0 ? '\n' : "")}{jsStack}";
}

[DoesNotReturn]
Expand Down
48 changes: 26 additions & 22 deletions src/NodeApi/JSPromise.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,19 +96,21 @@ public JSPromise(ResolveRejectCallback callback)
public JSPromise(AsyncResolveCallback callback)
{
_value = JSValue.CreatePromise(out Deferred deferred);
async void AsyncCallback()
ResolveDeferred(callback, deferred);
}

private static async void ResolveDeferred(
AsyncResolveCallback callback, Deferred deferred)
{
using var asyncScope = new JSAsyncScope();
try
{
using var asyncScope = new JSAsyncScope();
try
{
await callback(deferred.Resolve);
}
catch (Exception ex)
{
deferred.Reject(ex);
}
await callback(deferred.Resolve);
}
catch (Exception ex)
{
deferred.Reject(ex);
}
AsyncCallback();
}

/// <summary>
Expand All @@ -124,19 +126,21 @@ async void AsyncCallback()
public JSPromise(AsyncResolveRejectCallback callback)
{
_value = JSValue.CreatePromise(out Deferred deferred);
async void AsyncCallback()
ResolveDeferred(callback, deferred);
}

private static async void ResolveDeferred(
AsyncResolveRejectCallback callback, Deferred deferred)
{
using var asyncScope = new JSAsyncScope();
try
{
using var asyncScope = new JSAsyncScope();
try
{
await callback(deferred.Resolve, deferred.Reject);
}
catch (Exception ex)
{
deferred.Reject(ex);
}
await callback(deferred.Resolve, deferred.Reject);
}
catch (Exception ex)
{
deferred.Reject(ex);
}
AsyncCallback();
}

/// <summary>
Expand Down
7 changes: 7 additions & 0 deletions test/TestCases/napi-dotnet/Errors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

using System;
using System.Threading.Tasks;

namespace Microsoft.JavaScript.NodeApi.TestCases;

Expand All @@ -17,6 +18,12 @@ public static void ThrowJSError(string message, IJSErrors jsErrors)
{
jsErrors.ThrowJSError(message);
}

public static async Task ThrowAsyncDotnetError(string message)
{
await Task.Yield();
throw new Exception(message);
}
}

[JSExport]
Expand Down
33 changes: 33 additions & 0 deletions test/TestCases/napi-dotnet/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function catchDotnetError() {

assert(typeof error.stack === 'string');
console.log(error.stack);
console.log();

const stack = error.stack.split('\n').map((line) => line.trim());

Expand Down Expand Up @@ -56,6 +57,7 @@ function catchJSError() {

assert(typeof error.stack === 'string');
console.log(error.stack);
console.log();

const stack = error.stack.split('\n').map((line) => line.trim());

Expand All @@ -82,3 +84,34 @@ function catchJSError() {
assert(stack[0].startsWith(`at ${catchJSError.name} `));
}
catchJSError();

async function catchAsyncDotnetError() {
let error = undefined;
try {
await Errors.throwAsyncDotnetError('test');
} catch (e) {
error = e;
}

assert(error instanceof Error);
assert.strictEqual(error.message, 'test');

assert(typeof error.stack === 'string');
console.log(error.stack);
console.log();

const stack = error.stack.split('\n').map((line) => line.trim());

// The stack should be prefixed with the error type and message.
const firstLine = stack.shift();
assert.strictEqual(firstLine, 'Error: test');

// The first line of the stack trace should refer to the .NET method that threw.
assert(stack[0].startsWith(`at ${dotnetNamespacePrefix}`));
assert(stack[0].includes('Errors.ThrowAsyncDotnetError('));

// Unfortunately the JS stack trace is not available for errors thrown by a .NET async method.
// That is because the Task to Promise conversion uses Promise APIs which do not preserve
// the JS stack. See https://v8.dev/blog/fast-async#improved-developer-experience
}
catchAsyncDotnetError();

0 comments on commit 2b3096b

Please sign in to comment.