Skip to content

Commit 6168214

Browse files
authored
Handle scope validation tests (#180)
1 parent b6396b9 commit 6168214

20 files changed

Lines changed: 1477 additions & 361 deletions

src/NodeApi.DotNetHost/ManagedHost.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,14 +393,15 @@ public JSValue LoadModule(JSCallbackArgs args)
393393
}
394394
}
395395

396+
JSValueScope scope = JSValueScope.Current;
396397
JSValue exports = JSValue.CreateObject();
397398

398399
var result = (napi_value?)initializeMethod.Invoke(
399-
null, new object[] { (napi_env)JSValueScope.Current, (napi_value)exports });
400+
null, new object[] { (napi_env)scope, (napi_value)exports });
400401

401402
if (result != null && result.Value != default)
402403
{
403-
exports = new JSValue(result.Value);
404+
exports = new JSValue(result.Value, scope);
404405
}
405406

406407
if (exports.IsObject())

src/NodeApi.Generator/ModuleGenerator.cs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,10 @@ private SourceBuilder GenerateModuleInitializer(
282282
s += $"public static class {ModuleInitializerClassName}";
283283
s += "{";
284284

285+
// The module scope is not disposed after a successful initialization. It becomes
286+
// the parent of callback scopes, allowing the JS runtime instance to be inherited.
287+
s += "private static JSValueScope _moduleScope;";
288+
285289
// The unmanaged entrypoint is used only when the AOT-compiled module is loaded.
286290
s += "#if !NETFRAMEWORK";
287291
s += $"[UnmanagedCallersOnly(EntryPoint = \"{ModuleRegisterFunctionName}\")]";
@@ -293,11 +297,11 @@ private SourceBuilder GenerateModuleInitializer(
293297
// The main initialization entrypoint is called by the `ManagedHost`, and by the unmanaged entrypoint.
294298
s += $"public static napi_value {ModuleInitializeMethodName}(napi_env env, napi_value exports)";
295299
s += "{";
296-
s += "var scope = new JSValueScope(JSValueScopeType.Module, env);";
300+
s += "_moduleScope = new JSValueScope(JSValueScopeType.Module, env, runtime: default);";
297301
s += "try";
298302
s += "{";
299-
s += "JSRuntimeContext context = scope.RuntimeContext;";
300-
s += "JSValue exportsValue = new(exports, scope);";
303+
s += "JSRuntimeContext context = _moduleScope.RuntimeContext;";
304+
s += "JSValue exportsValue = new(exports, _moduleScope);";
301305
s++;
302306

303307
if (moduleInitializer is IMethodSymbol moduleInitializerMethod)
@@ -327,15 +331,12 @@ private SourceBuilder GenerateModuleInitializer(
327331
s += "return (napi_value)exportsValue;";
328332
}
329333

330-
// The module scope is not disposed before a successful return. It becomes the parent
331-
// of callback scopes, allowing the JS runtime instance to be inherited.
332-
333334
s += "}";
334335
s += "catch (System.Exception ex)";
335336
s += "{";
336337
s += "System.Console.Error.WriteLine($\"Failed to export module: {ex}\");";
337338
s += "JSError.ThrowError(ex);";
338-
s += "scope.Dispose();";
339+
s += "_moduleScope.Dispose();";
339340
s += "return exports;";
340341
s += "}";
341342
s += "}";

src/NodeApi/DotNetHost/NativeHost.cs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ internal unsafe partial class NativeHost : IDisposable
2323
private static readonly string s_managedHostTypeName =
2424
typeof(NativeHost).Namespace + ".ManagedHost";
2525

26+
private static JSRuntime? s_jsRuntime;
2627
private string? _targetFramework;
2728
private string? _managedHostPath;
2829
private ICLRRuntimeHost* _runtimeHost;
2930
private hostfxr_handle _hostContextHandle;
31+
private readonly JSValueScope _hostScope;
3032
private JSReference? _exports;
3133

3234
public static bool IsTracingEnabled { get; } =
@@ -48,15 +50,18 @@ public static napi_value InitializeModule(napi_env env, napi_value exports)
4850
{
4951
Trace($"> NativeHost.InitializeModule({env.Handle:X8}, {exports.Handle:X8})");
5052

51-
JSRuntime runtime = new NodejsRuntime();
52-
using JSValueScope scope = new(JSValueScopeType.NoContext, env, runtime);
53+
s_jsRuntime ??= new NodejsRuntime();
54+
55+
// The native host JSValueScope is not disposed after a successful initialization. It
56+
// becomes the parent of callback scopes, allowing the JS runtime instance to be inherited.
57+
JSValueScope hostScope = new(JSValueScopeType.NoContext, env, s_jsRuntime);
5358
try
5459
{
55-
NativeHost host = new();
60+
NativeHost host = new(hostScope);
5661

5762
// Do not use JSModuleBuilder here because it relies on having a current context.
5863
// But the context will be set by the managed host.
59-
new JSValue(exports, scope).DefineProperties(
64+
new JSValue(exports, hostScope).DefineProperties(
6065
// The package index.js will invoke the initialize method with the path to
6166
// the managed host assembly.
6267
JSPropertyDescriptor.Function("initialize", host.InitializeManagedHost));
@@ -65,16 +70,18 @@ public static napi_value InitializeModule(napi_env env, napi_value exports)
6570
{
6671
string message = $"Failed to load CLR native host module: {ex}";
6772
Trace(message);
68-
runtime.Throw(env, (napi_value)JSValue.CreateError(null, (JSValue)message));
73+
s_jsRuntime.Throw(env, (napi_value)JSValue.CreateError(null, (JSValue)message));
74+
hostScope.Dispose();
6975
}
7076

7177
Trace("< NativeHost.InitializeModule()");
7278

7379
return exports;
7480
}
7581

76-
public NativeHost()
82+
private NativeHost(JSValueScope hostScope)
7783
{
84+
_hostScope = hostScope;
7885
}
7986

8087
/// <summary>

src/NodeApi/Interop/JSRuntimeContext.cs

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using System.IO;
99
using System.Runtime.InteropServices;
1010
using System.Threading;
11+
using Microsoft.JavaScript.NodeApi.Runtime;
1112
using static Microsoft.JavaScript.NodeApi.Interop.JSCollectionProxies;
1213
using static Microsoft.JavaScript.NodeApi.JSNativeApi;
1314
using static Microsoft.JavaScript.NodeApi.Runtime.JSRuntime;
@@ -103,28 +104,53 @@ public sealed class JSRuntimeContext : IDisposable
103104

104105
private readonly ConcurrentDictionary<Type, JSProxy.Handler> _collectionProxyHandlerMap = new();
105106

106-
public bool IsDisposed { get; private set; }
107+
internal napi_env EnvironmentHandle
108+
{
109+
get
110+
{
111+
if (IsDisposed)
112+
{
113+
throw new ObjectDisposedException(nameof(JSRuntimeContext));
114+
}
115+
116+
return _env;
117+
}
118+
}
119+
120+
public static explicit operator napi_env(JSRuntimeContext context)
121+
{
122+
if (context is null) throw new ArgumentNullException(nameof(context));
123+
return context.EnvironmentHandle;
124+
}
107125

108-
public static explicit operator napi_env(JSRuntimeContext context) => context?._env ??
109-
throw new ArgumentNullException(nameof(context));
110126
public static explicit operator JSRuntimeContext(napi_env env)
111127
=> GetInstanceData(env) as JSRuntimeContext
112128
?? throw new InvalidCastException("Context is not found in napi_env instance data.");
113129

130+
public bool IsDisposed { get; private set; }
131+
114132
/// <summary>
115133
/// Gets the current runtime context.
116134
/// </summary>
117135
/// <exception cref="InvalidOperationException">No runtime context was set for the current
118136
/// thread.</exception>
119137
public static JSRuntimeContext Current => JSValueScope.Current.RuntimeContext;
120138

139+
public JSRuntime Runtime { get; }
140+
121141
public JSSynchronizationContext SynchronizationContext { get; }
122142

123-
public JSRuntimeContext(napi_env env)
143+
internal JSRuntimeContext(
144+
napi_env env,
145+
JSRuntime runtime,
146+
JSSynchronizationContext? synchronizationContext = null)
124147
{
148+
if (env.IsNull) throw new ArgumentNullException(nameof(env));
149+
125150
_env = env;
151+
Runtime = runtime;
126152
SetInstanceData(env, this);
127-
SynchronizationContext = JSSynchronizationContext.Create();
153+
SynchronizationContext = synchronizationContext ?? JSSynchronizationContext.Create();
128154
}
129155

130156
/// <summary>
@@ -692,22 +718,4 @@ internal void FreeGCHandle(GCHandle handle)
692718

693719
handle.Free();
694720
}
695-
696-
/// <summary>
697-
/// Frees a GC handle previously allocated via <see cref="AllocGCHandle(object)" />
698-
/// and tracked on the runtime context obtained from environment instance data.
699-
/// </summary>
700-
/// <exception cref="InvalidOperationException">The handle was not previously allocated
701-
/// by <see cref="AllocGCHandle(object)" />, or was already freed.</exception>
702-
internal static void FreeGCHandle(GCHandle handle, napi_env env)
703-
{
704-
if (GetInstanceData(env) is JSRuntimeContext runtimeContext)
705-
{
706-
runtimeContext.FreeGCHandle(handle);
707-
}
708-
else
709-
{
710-
handle.Free();
711-
}
712-
}
713721
}

src/NodeApi/Interop/JSSynchronizationContext.cs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@
77

88
namespace Microsoft.JavaScript.NodeApi.Interop;
99

10+
/// <summary>
11+
/// Manages the synchronization context for a JavaScript environment, allowing callbacks and
12+
/// asynchronous continuations to be invoked on the JavaScript thread that runs the environment.
13+
/// </summary>
14+
/// <remarks>
15+
/// All JavaScript values are bound to the thread that runs the JS environment and can only be
16+
/// accessed from the same thread. Attempts to access a JavaScript value from a different thread
17+
/// will throw <see cref="JSInvalidThreadAccessException" />.
18+
/// <para/>
19+
/// Use of <see cref="Task.ConfigureAwait(bool)"/> with <c>continueOnCapturedContext:false</c>
20+
/// can prevent execution from returning to the JS thread, though it isn't necessarily a problem
21+
/// as long as there is a top-level continuation that uses <c>continueOnCapturedContext:true</c>
22+
/// (the default) to return to the JS thread.
23+
/// <para/>
24+
/// Code that makes explicit use of .NET threads or thread pools may need to capture the
25+
/// <see cref="JSSynchronizationContext.Current" /> context (before switching off the JS thread)
26+
/// and hold it for later use to call back to JS via <see cref="JSSynchronizationContext.Post"/>,
27+
/// <see cref="JSSynchronizationContext.Run"/>, or <see cref="JSSynchronizationContext.RunAsync"/>.
28+
/// </remarks>
1029
public abstract class JSSynchronizationContext : SynchronizationContext, IDisposable
1130
{
1231
public bool IsDisposed { get; private set; }
@@ -224,7 +243,7 @@ public Task<T> RunAsync<T>(Func<Task<T>> asyncAction)
224243
}
225244
}
226245

227-
public sealed class JSTsfnSynchronizationContext : JSSynchronizationContext
246+
internal sealed class JSTsfnSynchronizationContext : JSSynchronizationContext
228247
{
229248
private readonly JSThreadSafeFunction _tsfn;
230249

@@ -233,7 +252,7 @@ public JSTsfnSynchronizationContext()
233252
_tsfn = new JSThreadSafeFunction(
234253
maxQueueSize: 0,
235254
initialThreadCount: 1,
236-
asyncResourceName: (JSValue)"SynchronizationContext");
255+
asyncResourceName: (JSValue)nameof(JSSynchronizationContext));
237256

238257
// Unref TSFN to indicate that this TSFN is not preventing Node.JS shutdown.
239258
_tsfn.Unref();
@@ -295,7 +314,7 @@ public override void Send(SendOrPostCallback callback, object? state)
295314
}
296315
}
297316

298-
public sealed class JSDispatcherSynchronizationContext : JSSynchronizationContext
317+
internal sealed class JSDispatcherSynchronizationContext : JSSynchronizationContext
299318
{
300319
private readonly JSDispatcherQueue _queue;
301320

src/NodeApi/Interop/JSThreadSafeFunction.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ private static unsafe void CustomCallJS(napi_env env, napi_value jsCallback, nin
235235

236236
try
237237
{
238-
using JSValueScope scope = new(JSValueScopeType.Callback, env);
238+
using JSValueScope scope = new(JSValueScopeType.Callback, env, runtime: null);
239239

240240
object? callbackData = null;
241241
if (data != default)
@@ -267,7 +267,7 @@ private static unsafe void DefaultCallJS(napi_env env, napi_value jsCallback, ni
267267

268268
try
269269
{
270-
using JSValueScope scope = new(JSValueScopeType.Callback, env);
270+
using JSValueScope scope = new(JSValueScopeType.Callback, env, runtime: null);
271271

272272
if (data != default)
273273
{
@@ -299,6 +299,9 @@ private static unsafe void DefaultCallJS(napi_env env, napi_value jsCallback, ni
299299
}
300300
catch (Exception ex)
301301
{
302+
#if DEBUG
303+
Console.Error.WriteLine(ex);
304+
#endif
302305
JSError.Fatal(ex.Message);
303306
}
304307
}

src/NodeApi/JSException.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ namespace Microsoft.JavaScript.NodeApi;
77

88
/// <summary>
99
/// An exception that was caused by an error thrown by JavaScript code or
10-
/// interactions with the JavaScript engine.
10+
/// interactions with JavaScript objects.
1111
/// </summary>
1212
public class JSException : Exception
1313
{
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.Threading;
6+
using Microsoft.JavaScript.NodeApi.Interop;
7+
8+
namespace Microsoft.JavaScript.NodeApi;
9+
10+
/// <summary>
11+
/// An exception that was caused by an attempt to access a JavaScript value without any
12+
/// <see cref="JSValueScope" /> established on the current thread, or from a thread associated
13+
/// with a different environment / root scope.
14+
/// </summary>
15+
/// <remarks>
16+
/// All JavaScript values are created within a scope that is bound to the thread that runs the
17+
/// JS environment. They can only be accessed from the same thread and only as long as the scope
18+
/// is still valid (not disposed).
19+
/// </remarks>
20+
/// <seealso cref="JSSynchronizationContext"/>
21+
public class JSInvalidThreadAccessException : InvalidOperationException
22+
{
23+
/// <summary>
24+
/// Creates a new instance of <see cref="JSInvalidThreadAccessException" /> with a
25+
/// current scope and message.
26+
/// </summary>
27+
public JSInvalidThreadAccessException(
28+
JSValueScope? currentScope,
29+
string? message = null)
30+
: this(currentScope, targetScope: null, message)
31+
{
32+
}
33+
34+
/// <summary>
35+
/// Creates a new instance of <see cref="JSInvalidThreadAccessException" /> with current
36+
/// and target scopes and a message.
37+
/// </summary>
38+
public JSInvalidThreadAccessException(
39+
JSValueScope? currentScope,
40+
JSValueScope? targetScope,
41+
string? message = null)
42+
: base(message ?? GetMessage(currentScope, targetScope))
43+
{
44+
CurrentScope = currentScope;
45+
TargetScope = targetScope;
46+
}
47+
48+
/// <summary>
49+
/// Gets the scope associated with the current thread (<see cref="JSValueScope.Current" />)
50+
/// when the exception was thrown, or null if there was no scope for the thread.
51+
/// </summary>
52+
public JSValueScope? CurrentScope { get; }
53+
54+
/// <summary>
55+
/// Gets the scope of the value (<see cref="JSValue.Scope" />) that was being accessed when
56+
/// the exception was thrown, or null if a static operation was attempted.
57+
/// </summary>
58+
public JSValueScope? TargetScope { get; }
59+
60+
private static string GetMessage(JSValueScope? currentScope, JSValueScope? targetScope)
61+
{
62+
int threadId = Environment.CurrentManagedThreadId;
63+
string? threadName = Thread.CurrentThread.Name;
64+
string threadDescription = string.IsNullOrEmpty(threadName) ?
65+
$"#{threadId}" : $"#{threadId} \"{threadName}\"";
66+
67+
if (targetScope == null)
68+
{
69+
// If the target scope is null, then this was an attempt to access either a static
70+
// operation or a JS reference (which has an environment but no scope).
71+
if (currentScope != null)
72+
{
73+
// In that case if the current scope is NOT null this exception
74+
// shouldn't be thrown.
75+
throw new ArgumentException("Current scope must be null if target scope is null.");
76+
}
77+
78+
return $"There is no active JS value scope.\nCurrent thread: {threadDescription}. " +
79+
$"Consider using the synchronization context to switch to the JS thread.";
80+
}
81+
82+
return "The JS value scope cannot be accessed from the current thread.\n" +
83+
$"The scope of type {targetScope.ScopeType} was created on thread" +
84+
$"#{targetScope.ThreadId} and is being accessed from {threadDescription}. " +
85+
$"Consider using the synchronization context to switch to the JS thread.";
86+
}
87+
}

src/NodeApi/JSProxy.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ public JSProxy(
7272
/// <exception cref="InvalidOperationException">The proxy is not revocable.</exception>
7373
public void Revoke()
7474
{
75-
if (!_revoke.Handle.HasValue)
75+
if (_revoke == default)
7676
{
7777
throw new InvalidOperationException("Proxy is not revokable.");
7878
}

0 commit comments

Comments
 (0)