Skip to content
Open
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
27 changes: 26 additions & 1 deletion src/NodeApi/Runtime/NodeEmbedding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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!;
Expand All @@ -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
Expand Down
150 changes: 150 additions & 0 deletions test/NodejsEmbeddingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<NodeEmbeddingModuleInfo> 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()
{
Expand Down
2 changes: 2 additions & 0 deletions test/test-linked-module.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const { add } = process._linkedBinding('test-linked');
export const result = add(1, 2);