diff --git a/src/NodeApi/Runtime/NodeEmbedding.cs b/src/NodeApi/Runtime/NodeEmbedding.cs index 7c9b74d1..5224f9ba 100644 --- a/src/NodeApi/Runtime/NodeEmbedding.cs +++ b/src/NodeApi/Runtime/NodeEmbedding.cs @@ -433,7 +433,19 @@ internal static unsafe napi_value ModuleInitializeCallbackAdapter( nint module_name, napi_value exports) { - using var jsValueScope = new JSValueScope(JSValueScopeType.Root, env, JSRuntime); + // Linked modules are initialized lazily by process._linkedBinding(), so the runtime's + // scope is usually current on this thread. Node.js passes a separate napi_env for each + // module; nesting a root scope for it would fail or, on dispose, leave the thread + // without a current scope. Initialize the module within the runtime's scope instead, + // so that its exports share the runtime context and later callbacks into them work. + // A callback scope cannot be nested in a handle scope, so that is used as is. + using JSValueScope? jsValueScope = JSValueScope.CurrentOrNull?.ScopeType switch + { + null => new JSValueScope(JSValueScopeType.Root, env, JSRuntime), + JSValueScopeType.Handle or JSValueScopeType.Escapable => null, + _ => new JSValueScope(JSValueScopeType.Callback), + }; + napi_env scopeEnv = JSValueScope.Current.UncheckedEnvironmentHandle; try { var callback = (InitializeModuleCallback)GCHandle.FromIntPtr(cb_data).Target!; @@ -448,6 +460,19 @@ internal static unsafe napi_value ModuleInitializeCallbackAdapter( JSError.ThrowError(ex); return napi_value.Null; } + finally + { + // Pending exceptions are tracked per napi_env, and Node.js only reports those of the + // module's env to the caller of process._linkedBinding(). + if (scopeEnv != env && + JSRuntime.IsExceptionPending(scopeEnv, out bool isPending) == napi_status.napi_ok && + isPending && + JSRuntime.GetAndClearLastException(scopeEnv, out napi_value error) == + napi_status.napi_ok) + { + JSRuntime.Throw(env, error); + } + } } #if UNMANAGED_DELEGATES diff --git a/test/NodejsEmbeddingTests.cs b/test/NodejsEmbeddingTests.cs index 1785decc..fafe5ee0 100644 --- a/test/NodejsEmbeddingTests.cs +++ b/test/NodejsEmbeddingTests.cs @@ -4,6 +4,7 @@ #pragma warning disable CA1822 // Mark members as static using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; @@ -288,6 +289,155 @@ public void ThrowErrorFromCallback(string message) }); } + private static NodeEmbeddingThreadRuntime CreateRuntimeWithLinkedModule( + string mainScript = "", + NodeEmbedding.InitializeModuleCallback? onInitialize = null) + { + NodeEmbeddingModuleInfo linkedModule = new() + { + Name = "test-linked", + NodeApiVersion = NodeEmbedding.NodeApiVersion, + OnInitialize = onInitialize ?? ((runtime, moduleName, exports) => + { + exports.SetProperty("moduleName", moduleName); + exports.SetProperty("add", JSValue.CreateFunction( + "add", (args) => (int)args[0] + (int)args[1])); + return exports; + }), + }; + + // Modules are registered on the runtime thread while it is configured, so dirty the + // array pool lazily from there, right before the module name is marshalled. + static IEnumerable WithGarbageInArrayPool( + NodeEmbeddingModuleInfo module) + { + FillArrayPoolWithGarbage(); + yield return module; + } + + return NodejsPlatform.CreateThreadRuntime( + Path.Combine(GetRepoRootDirectory(), "test"), + new NodeEmbeddingRuntimeSettings + { + MainScript = MainScript + mainScript, + Modules = WithGarbageInArrayPool(linkedModule), + }); + } + + private static void AssertJSThreadIsUsable(NodeEmbeddingThreadRuntime nodejs) + { + nodejs.Run(() => + { + Assert.Equal(3, (int)JSValue.RunScript("1 + 2")); + }); + } + + [Fact] + public void LinkedModuleFromScript() + { + using NodeEmbeddingThreadRuntime nodejs = CreateRuntimeWithLinkedModule(); + + nodejs.Run(() => + { + JSValue linked = JSValue.RunScript("process._linkedBinding('test-linked')"); + Assert.Equal("test-linked", (string)linked["moduleName"]); + Assert.Equal(3, (int)linked.CallMethod("add", 1, 2)); + + // The scope that was current before the module was initialized must be restored. + Assert.Equal(3, (int)JSValue.RunScript("1 + 2")); + }); + + AssertJSThreadIsUsable(nodejs); + nodejs.Dispose(); + Assert.Equal(0, nodejs.ExitCode); + } + + [Fact] + public void LinkedModuleFromMainScript() + { + using NodeEmbeddingThreadRuntime nodejs = CreateRuntimeWithLinkedModule( + "globalThis.linked = process._linkedBinding('test-linked');\n"); + + nodejs.Run(() => + { + Assert.Equal(3, (int)JSValue.RunScript("globalThis.linked.add(1, 2)")); + }); + + AssertJSThreadIsUsable(nodejs); + nodejs.Dispose(); + Assert.Equal(0, nodejs.ExitCode); + } + + [Fact] + public async Task LinkedModuleFromESModule() + { + using NodeEmbeddingThreadRuntime nodejs = CreateRuntimeWithLinkedModule(); + + int result = await nodejs.RunAsync(async () => + { + JSValue testModule = await nodejs.ImportAsync( + "./test-linked-module.mjs", null, esModule: true); + return (int)testModule["result"]; + }); + Assert.Equal(3, result); + + AssertJSThreadIsUsable(nodejs); + nodejs.Dispose(); + Assert.Equal(0, nodejs.ExitCode); + } + + [Fact] + public void LinkedModuleFromHandleScope() + { + using NodeEmbeddingThreadRuntime nodejs = CreateRuntimeWithLinkedModule(); + + nodejs.Run(() => + { + using JSValueScope handleScope = new(); + Assert.Equal( + "test-linked", + (string)JSValue.RunScript("process._linkedBinding('test-linked').moduleName")); + }); + + AssertJSThreadIsUsable(nodejs); + nodejs.Dispose(); + Assert.Equal(0, nodejs.ExitCode); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void LinkedModuleInitializationError(bool throwJSError) + { + using NodeEmbeddingThreadRuntime nodejs = CreateRuntimeWithLinkedModule( + onInitialize: (runtime, moduleName, exports) => + { + if (throwJSError) + { + JSError.ThrowError("init failed"); + return exports; + } + + throw new InvalidOperationException("init failed"); + }); + + nodejs.Run(() => + { + // The error must reach the caller of process._linkedBinding(). + JSValue result = JSValue.RunScript(""" + (() => { + try { process._linkedBinding('test-linked'); } catch (e) { return e.message; } + return 'not thrown'; + })() + """); + Assert.Equal("init failed", (string)result); + }); + + AssertJSThreadIsUsable(nodejs); + nodejs.Dispose(); + Assert.Equal(0, nodejs.ExitCode); + } + [Fact] public async Task WorkerIsMainThread() { diff --git a/test/test-linked-module.mjs b/test/test-linked-module.mjs new file mode 100644 index 00000000..0d0e31e2 --- /dev/null +++ b/test/test-linked-module.mjs @@ -0,0 +1,2 @@ +const { add } = process._linkedBinding('test-linked'); +export const result = add(1, 2);