diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 00000000..51cbb056
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,7 @@
+# GitHub Copilot instructions
+
+See [AGENTS.md](../AGENTS.md) for how to work in this repository, including the runtime model that
+underlies environments, teardown, threading, and object lifetime, plus the build/format/test steps.
+
+Key reminder: run `dotnet format --severity info --verbosity detailed` after code changes (PR builds
+fail on formatting violations), and run `dotnet pack` before `dotnet test`.
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..37240b15
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,59 @@
+# Repository guide for AI agents
+
+This file orients automated coding agents (and new contributors) working in this repository. It is
+intentionally short; it points at the authoritative docs rather than duplicating them.
+
+`node-api-dotnet` provides high-performance, in-process interop between .NET and JavaScript, built on
+[Node-API](https://nodejs.org/api/n-api.html). It ships a runtime library, a native + managed host,
+a C# source generator, and a TypeScript type-definitions generator.
+
+## Read this first: the runtime model
+
+Most recurring misunderstandings in this codebase come from the JavaScript environment / .NET
+context lifetime model. **Read [docs/concepts/runtime-model.md](docs/concepts/runtime-model.md)
+before reasoning about environments, teardown, threading, or object lifetime.** The facts that are
+most often gotten wrong:
+
+- **Node.js creates one `napi_env` per loaded native module.** A Native AOT module is `1 env : 1`
+ `JSRuntimeContext`. A managed module runs a native host and a managed host that **share one env**
+ (two instance-data slots) — that is the *only* case where two contexts share an env. Two
+ independently compiled AOT addons are two separate modules and therefore get **two different
+ envs**; they never share one, so their per-environment state cannot collide.
+- **`node::Environment` is not `napi_env`.** There is one `node::Environment` per V8 isolate / worker
+ thread, and **zero or more `napi_env` per `node::Environment`** (one per native module). An
+ environment cleanup hook is associated with the `node::Environment`; the **instance-data finalizer
+ is per `napi_env`.** Per-context teardown keys off the instance-data finalizer, not the cleanup
+ hook.
+- **Finalizers run during environment teardown, where calling into JavaScript is forbidden.** Resolve
+ the context with `JSRuntimeContext.FromEnv(env)`, never by dereferencing a finalize hint that may be
+ freed, and assume no ordering between wrapped-object finalizers and the instance-data finalizer.
+- **`napi_value` / `JSValue` are valid only within their `JSValueScope` and only on the JS thread.**
+ To keep a value beyond its scope, hold a `JSReference` (`napi_ref`). There are three scope types —
+ runtime-context, handle, and escapable — and a module boundary starts a fresh module holder so each
+ loaded module resolves its own module instance.
+
+## Build, format, and test
+
+Full details are in [README-DEV.md](README-DEV.md). The essentials:
+
+```bash
+dotnet build
+dotnet format --severity info --verbosity detailed # PR builds FAIL if formatting is non-compliant
+dotnet pack # required before tests (the generator is consumed as a local package)
+dotnet test
+```
+
+- **Run `dotnet format` after code changes and before tests** — formatting is a CI gate.
+- **`dotnet pack` is required before `dotnet test`**, and again after any change to the source
+ generator, because tests consume the generator through the locally built NuGet package. Use
+ `-c Release` for release-configuration testing.
+- Most test cases run twice: once in hosted CLR mode and once in Native AOT mode. Test cases are
+ derived from the `.js` files under `test/TestCases`.
+
+## Conventions
+
+- Follow the existing code style enforced by `.editorconfig` (American English in code, comments, and
+ docs).
+- See [docs/contributing.md](docs/contributing.md) for contribution guidelines, and
+ [docs/NodeApi-Layers.md](docs/NodeApi-Layers.md) for how the assemblies and namespaces are layered.
+- Keep code comments minimal: add one only to explain a non-obvious "why" that the code cannot show.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..aaca4204
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,7 @@
+# Guidance for Claude
+
+See [AGENTS.md](AGENTS.md) for how to work in this repository, including the runtime model that
+underlies environments, teardown, threading, and object lifetime, plus the build/format/test steps.
+
+Key reminder: run `dotnet format --severity info --verbosity detailed` after code changes (PR builds
+fail on formatting violations), and run `dotnet pack` before `dotnet test`.
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 973b56fb..7c5e7614 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,7 +11,7 @@
-
+
diff --git a/bench/Benchmarks.cs b/bench/Benchmarks.cs
index c3461ab5..937ff8f6 100644
--- a/bench/Benchmarks.cs
+++ b/bench/Benchmarks.cs
@@ -144,7 +144,7 @@ protected void Setup()
_reference = new JSReference(_jsFunction);
}
- private static JSValueScope NewJSScope() => new(JSValueScopeType.Callback);
+ private static JSValueScope NewJSScope() => JSValueScope.CreateRuntimeScope();
// Benchmarks in the base class run in both CLR and AOT environments.
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 3f87ab30..fdf9a0c5 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -17,6 +17,13 @@ export default defineConfig({
sidebar: [
{ text: 'Overview', link: '/overview' },
+ {
+ text: 'Concepts',
+ items: [
+ { text: 'Runtime model', link: '/concepts/runtime-model' },
+ { text: 'Project layers', link: '/NodeApi-Layers' },
+ ]
+ },
{
text: 'Get Started',
items: [
diff --git a/docs/concepts/runtime-model.md b/docs/concepts/runtime-model.md
new file mode 100644
index 00000000..569e815e
--- /dev/null
+++ b/docs/concepts/runtime-model.md
@@ -0,0 +1,141 @@
+# Runtime model: environments, lifetimes, and threads
+
+This page describes the foundational runtime model that the rest of the library is built on:
+how JavaScript environments map to .NET runtime contexts, how those contexts are torn down, and
+the rules for safely holding JavaScript values. The per-feature pages
+([JS value scopes](../features/js-value-scopes), [JS references](../features/js-references),
+[JS threading & async](../features/js-threading-async),
+[Node worker threads](../features/node-workers)) assume the model described here.
+
+If you are extending this library or reviewing a change to it, read this first — several parts of
+the design only make sense once the environment/module relationship is clear.
+
+## Environments and module instances
+
+**Node.js creates a unique `napi_env` for each native module it loads.** When a module is
+registered, `napi_module_register_by_symbol` (in Node's `src/node_api.cc`) calls `NodeApiEnv::New`,
+which mints a fresh `napi_env` for that specific module. So the mapping is per-module, not
+per-process and not per-isolate.
+
+That gives three deployment shapes:
+
+| Shape | `napi_env` : `JSRuntimeContext` | Notes |
+| --- | --- | --- |
+| **Native AOT module** | 1 : 1 | The `.node` file *is* the module, so Node makes one env and the module owns one context. |
+| **Managed module** (`.node` native host + managed host) | 1 : 2 | The native host and the managed host run in **separate .NET runtimes** but share the **same** env. Each registers its own context. |
+| **Embedding** (a .NET app hosting `libnode`) | 1 : 1 per env | The .NET app creates and owns each environment's context. |
+
+The managed-module case is the only one where two contexts share a single `napi_env`. The native
+host (`NativeHost`, AOT-compiled into the `.node`) initializes first and hands the same env to the
+managed host (`ManagedHost`, loaded into the default .NET runtime); both create a `JSRuntimeContext`
+for that one env. This is deliberate and bounded — there are never more than these two.
+
+**A consequence worth stating explicitly:** two independently compiled AOT addons are two separate
+native modules, so Node gives them **two different `napi_env` instances**. They never share one
+environment, and their per-environment state never collides. The same is true for an AOT addon
+loaded alongside the managed host: different modules, different envs.
+
+## `node::Environment` vs `napi_env` vs isolate/worker
+
+These three are easy to conflate, but they nest at different granularities:
+
+- **`node::Environment`** — one per V8 isolate, i.e. one per Node.js **worker thread** (the main
+ thread is a worker too). It owns the event loop and the environment-cleanup hook list.
+- **`napi_env`** — **zero or more per `node::Environment`**, one for each native module loaded into
+ that worker. Node-API objects, references, and instance data all belong to a specific `napi_env`.
+- **isolate/worker thread** — the JS execution thread. All JS values and value scopes have affinity
+ to it.
+
+Two teardown callbacks live at these different levels, and the difference matters:
+
+- An **environment cleanup hook** (`napi_add_env_cleanup_hook`, backed by
+ `node::AddEnvironmentCleanupHook`) is associated with the **`node::Environment`**. It fires once
+ when the whole worker shuts down.
+- The **instance-data finalizer** (registered with `napi_set_instance_data`) is associated with a
+ **single `napi_env`**. It fires when that module's environment is torn down.
+
+Because a `JSRuntimeContext` is scoped to one `napi_env`, this library keys per-context teardown off
+the **instance-data finalizer**, not the environment cleanup hook. Using the cleanup hook would be
+both too coarse (one worker may host several envs) and wrongly timed for per-module lifetime.
+
+## Instance-data ownership (`JSRuntimeContext`)
+
+Each context roots itself with a `GCHandle` stored in its env's instance-data block. Because the
+managed-module case puts two contexts (in two separate .NET runtimes/GC heaps) on one env, the block
+has **two slots**:
+
+- **slot 0** — the module context: managed host, AOT module, or embedding.
+- **slot 1** — the native host context.
+
+There are exactly two slots because the native-host + managed-host pair is the only case where two
+contexts share an env. A runtime **reads and writes only its own slot**, so it never dereferences a
+`GCHandle` that belongs to the other runtime's GC heap (which would be undefined behavior).
+
+`JSRuntimeContext.FromEnv(napi_env)` resolves the calling runtime's context from its slot. This is
+how callback dispatch and finalizers recover the context when no scope is yet current on the thread.
+
+At environment teardown the instance-data finalizer disposes the owning context, which **clears its
+slot and frees the rooting `GCHandle`**. Disposing a host context cascades synchronously to the
+other slot's context, so once every context on the env is gone the finalizer **frees the block**.
+Freeing it there is no less safe than keeping it: a finalizer that called `FromEnv` after the
+instance-data finalizer would already be reading Node's own freed finalizer record (Node does not
+null its instance-data pointer), so retaining the block never protected that case. The block is not
+nulled out via `napi_set_instance_data` — that would delete the finalizer record Node is running and
+then double-free it.
+
+## JavaScript value scopes
+
+Every `JSValue` belongs to a [`JSValueScope`](../features/js-value-scopes). There are three scope
+types, each created by a static factory:
+
+- **Runtime-context scope** — `JSValueScope.CreateRuntimeScope(env, context)`. References a
+ `JSRuntimeContext` and marks a call/context boundary. It opens no napi handle scope. This is the
+ scope opened at a module entry point or a callback into .NET.
+- **Handle scope** — `JSValueScope.CreateHandleScope()`. A nested napi handle scope; JS values
+ created within it are released when it is disposed, unless held by a `JSReference`. Use it to
+ bound the lifetime of values created in a loop.
+- **Escapable scope** — `JSValueScope.CreateEscapableScope()`. Like a handle scope, but one value
+ may be promoted to the parent scope with `Escape`, so it survives the inner scope's disposal.
+
+A **module boundary** is a runtime-context scope that starts a *fresh module holder* while reusing
+the surrounding context, so each loaded module resolves its own module instance via
+`JSValueScope.Current.Module`. This matters when a single managed host loads several generated
+modules: without a fresh holder per module, the most recently loaded module's instance would be the
+one every module's callbacks resolve.
+
+## Lifetime of `napi_value` and `napi_ref` (`JSValue` / `JSReference`)
+
+- A `napi_value` (wrapped by [`JSValue`](../features/js-value-scopes)) is valid **only within its
+ scope**. Using it after the scope closes throws `JSValueScopeClosedException`. Values passed to a
+ .NET callback belong to that call's scope and become invalid when it returns.
+- JS values and scopes have **thread affinity**: they may be accessed only from the JS thread that
+ owns the environment. Access from another thread throws `JSInvalidThreadAccessException`. To marshal
+ work back to the JS thread, use the context's synchronization context (see
+ [JS threading & async](../features/js-threading-async)).
+- To keep a value **beyond its scope**, create a [`JSReference`](../features/js-references) (a
+ `napi_ref`). A strong reference keeps the value alive; a weak one lets it be collected and resolves
+ to nothing afterward. A `JSReference` is itself owned by a context and released with it.
+
+### Finalizers and teardown — no JS once the context is disposed
+
+A finalizer (for a wrapped .NET object, an external, or a reference) may run during normal GC while
+the environment is still alive, or while the environment is being torn down. **Once the context is
+disposed at environment teardown, calling into JavaScript is forbidden.** Finalizer code in this
+library follows two rules:
+
+1. **Resolve the context from the env**, via `JSRuntimeContext.FromEnv(env)` — never by dereferencing
+ a finalize hint that may already be freed. If `FromEnv` returns no live context (the slot was
+ cleared at teardown), the finalizer only frees its own native handle and does no JS work. While the
+ context is still live, a finalizer action may run — for example `JSValue.CallFinalizeAction` opens a
+ runtime scope to invoke the user action — so this rule is what keeps teardown itself JS-free.
+2. **Never assume ordering** among the env's finalizers. Node drains wrapped-object finalizers in no
+ guaranteed order, so a finalizer must tolerate the context's slot already being cleared (rule 1).
+ The instance-data finalizer frees the block only after every context on the env is disposed.
+
+## See also
+
+- [Project layers](../NodeApi-Layers) — how the assemblies and namespaces are organized.
+- [JS value scopes](../features/js-value-scopes), [JS references](../features/js-references) —
+ the day-to-day API surface built on this model.
+- [JS threading & async](../features/js-threading-async),
+ [Node worker threads](../features/node-workers) — the threading rules in practice.
diff --git a/docs/features/js-value-scopes.md b/docs/features/js-value-scopes.md
index de332411..0a25fc59 100644
--- a/docs/features/js-value-scopes.md
+++ b/docs/features/js-value-scopes.md
@@ -7,9 +7,8 @@ A value is only valid within its scope; if the scope is closed (disposed), then
access or use the value will throw
[`JSValueScopeClosedException`](../reference/dotnet/Microsoft.JavaScript.NodeApi/JSValueScopeClosedException).
-Values received by a .NET method that is a JS callback are associated with a `Callback`
-[scope type](../reference/dotnet/Microsoft.JavaScript.NodeApi/JSValueScopeType). When the method
-returns, the callback scope is closed and any values in that scope become invalid.
+Values received by a .NET method that is a JS callback belong to the current scope for that call.
+When the method returns, that scope is closed and any values in it become invalid.
## Nesting and escaping scopes
@@ -23,7 +22,7 @@ JSFunction jsFunction = …
foreach (string item in array)
{
- using (var nestedScope = new JSValueScope())
+ using (var nestedScope = JSValueScope.CreateHandleScope())
{
// Passing a .NET string to JS requires converting it to JSValue.
// The conversion is implicit; the explicit cast is for illustration.
@@ -44,7 +43,7 @@ public JSValue EscapableScopeExample(JSCallbackArgs args)
foreach (string item in array)
{
- using (var escapableScope = new JSValueScope(JSValueScopeType.Escapable))
+ using (var escapableScope = JSValueScope.CreateEscapableScope())
{
JSValue result = jsFunction.Call(thisArg: default, (JSValue)item);
if (!result.IsUndefined())
diff --git a/examples/hermes-engine/HermesRuntime.cs b/examples/hermes-engine/HermesRuntime.cs
index 1e862c99..37df0ede 100644
--- a/examples/hermes-engine/HermesRuntime.cs
+++ b/examples/hermes-engine/HermesRuntime.cs
@@ -30,7 +30,8 @@ private HermesRuntime(JSDispatcherQueue dispatcherQueue)
JSRuntime runtime = HermesApi.Load("hermes.dll");
using HermesConfig tempConfig = new();
hermes_create_runtime((hermes_config)tempConfig, out _runtime).ThrowIfFailed();
- _rootScope = new JSValueScope(JSValueScopeType.Root, (napi_env)this, runtime);
+ JSRuntimeContext context = JSRuntimeContext.Create((napi_env)this, runtime);
+ _rootScope = JSValueScope.CreateRuntimeScope((napi_env)this, context);
CreatePolyfills();
}
@@ -98,7 +99,7 @@ public static explicit operator napi_env(HermesRuntime value)
private void CreatePolyfills()
{
VerifyElseThrow(JSDispatcherQueue.GetForCurrentThread() == _dispatcherQueue);
- using var scope = new JSValueScope();
+ using var scope = JSValueScope.CreateHandleScope();
// Add global
JSValue global = JSValue.Global;
diff --git a/src/NodeApi.DotNetHost/JSMarshaller.cs b/src/NodeApi.DotNetHost/JSMarshaller.cs
index 25c164b2..8f8e00b8 100644
--- a/src/NodeApi.DotNetHost/JSMarshaller.cs
+++ b/src/NodeApi.DotNetHost/JSMarshaller.cs
@@ -89,9 +89,9 @@ public JSMarshaller()
typeof(JSRuntimeContext).GetStaticProperty(nameof(JSRuntimeContext.Current))
?? throw new NotImplementedException("JSRuntimeContext.Current");
- private static readonly PropertyInfo s_moduleContext =
- typeof(JSModuleContext).GetStaticProperty(nameof(JSModuleContext.Current))
- ?? throw new NotImplementedException("JSModuleContext.Current");
+ private static readonly PropertyInfo s_currentScope =
+ typeof(JSValueScope).GetStaticProperty(nameof(JSValueScope.Current))
+ ?? throw new NotImplementedException("JSValueScope.Current");
private static readonly PropertyInfo s_valueItem =
typeof(JSValue).GetIndexer(typeof(string))
@@ -1878,22 +1878,22 @@ private IEnumerable BuildThisArgumentExpressions(
if (type.GetCustomAttributes().Any())
{
- // For a method on a module class, the .NET object is stored in the module context.
+ // For a method on a module class, the .NET object is the current module instance.
// `ThisArg` is ignored for module-level methods.
/*
- * ObjectType? __this = JSRuntimeContext.Current.Module as ObjectType;
+ * ObjectType? __this = JSValueScope.Current.Module as ObjectType;
* if (__this == null) return JSValue.Undefined;
*/
- PropertyInfo moduleProperty = typeof(JSModuleContext).GetProperty(
- nameof(JSModuleContext.Module))
- ?? throw new NotImplementedException("JSModuleContext.Module");
+ PropertyInfo moduleProperty = typeof(JSValueScope).GetProperty(
+ nameof(JSValueScope.Module))
+ ?? throw new NotImplementedException("JSValueScope.Module");
yield return Expression.Assign(
thisVariable,
Expression.TypeAs(
Expression.Property(
- Expression.Property(null, s_moduleContext),
+ Expression.Property(null, s_currentScope),
moduleProperty),
type));
yield return Expression.IfThen(
diff --git a/src/NodeApi.DotNetHost/ManagedHost.cs b/src/NodeApi.DotNetHost/ManagedHost.cs
index 402befe6..e8f22aeb 100644
--- a/src/NodeApi.DotNetHost/ManagedHost.cs
+++ b/src/NodeApi.DotNetHost/ManagedHost.cs
@@ -34,13 +34,15 @@ public sealed class ManagedHost : JSEventEmitter, IDisposable
#if !(NETFRAMEWORK || NETSTANDARD)
///
- /// Each instance of a managed host uses a separate assembly load context.
- /// That way, static data is not shared across multiple host instances.
+ /// Each instance of a managed host uses a separate assembly load context, so static data is not
+ /// shared across host instances. It is not collectible: JSInterfaceMarshaller emits interface
+ /// adapter types with Reflection.Emit, which a collectible load context does not support, so the
+ /// context cannot be unloaded at teardown (only its resolve handlers are unsubscribed).
///
private readonly AssemblyLoadContext _loadContext = new(name: default);
#endif
- private JSValueScope? _rootScope;
+ private JSRuntimeContext? _context;
///
/// Component that dynamically exports types from loaded assemblies.
@@ -80,17 +82,6 @@ public sealed class ManagedHost : JSEventEmitter, IDisposable
/// JS object on which the managed host APIs will be exported.
public ManagedHost(JSObject exports)
{
-#if NETFRAMEWORK || NETSTANDARD
- AppDomain.CurrentDomain.AssemblyResolve += OnResolvingAssembly;
-#else
- _loadContext.Resolving += OnResolvingAssembly;
-
- // It shouldn't be necessary to handle resolve events in the default load context.
- // But TypeBuilder (used by JSInterfaceMarshaller) seems to require it when a nuget
- // package referenced type is replaced with a system type, as with IAsyncEnumerable.
- AssemblyLoadContext.Default.Resolving += OnResolvingAssembly;
-#endif
-
JSValue addListener(JSCallbackArgs args)
{
AddListener(eventName: (string)args[0], listener: args[1]);
@@ -143,6 +134,20 @@ JSValue removeListener(JSCallbackArgs args)
{
_exportedAssembliesByName.Add(typeof(Console).Assembly.GetName().Name!);
}
+
+ // Subscribe the process-wide resolve handlers last, after all fallible construction: a
+ // constructor that throws is never registered for disposal, so leaving them subscribed
+ // would root the failed host.
+#if NETFRAMEWORK || NETSTANDARD
+ AppDomain.CurrentDomain.AssemblyResolve += OnResolvingAssembly;
+#else
+ _loadContext.Resolving += OnResolvingAssembly;
+
+ // It shouldn't be necessary to handle resolve events in the default load context.
+ // But TypeBuilder (used by JSInterfaceMarshaller) seems to require it when a nuget
+ // package referenced type is replaced with a system type, as with IAsyncEnumerable.
+ AssemblyLoadContext.Default.Resolving += OnResolvingAssembly;
+#endif
}
public static bool IsTracingEnabled { get; } =
@@ -177,10 +182,14 @@ public static unsafe int InitializeModule(string argument)
napi_env env = new((nint)ulong.Parse(args[0], NumberStyles.HexNumber));
napi_value exports = new((nint)ulong.Parse(args[1], NumberStyles.HexNumber));
napi_value* pResult = (napi_value*)(nint)ulong.Parse(args[2], NumberStyles.HexNumber);
+ ManagedHostRegistration* registration = args.Length > 3 ?
+ (ManagedHostRegistration*)(nint)ulong.Parse(args[3], NumberStyles.HexNumber) : null;
#else
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
- public static napi_value InitializeModule(napi_env env, napi_value exports)
+ public static unsafe napi_value InitializeModule(
+ napi_env env, napi_value exports, nint registrationPtr)
{
+ ManagedHostRegistration* registration = (ManagedHostRegistration*)registrationPtr;
Trace($"> ManagedHost.InitializeModule({env.Handle:X8})");
Trace($" .NET Runtime version: {Environment.Version}");
#endif
@@ -198,10 +207,19 @@ public static napi_value InitializeModule(napi_env env, napi_value exports)
runtime = new TracingJSRuntime(runtime, trace);
}
- JSValueScope scope = new(JSValueScopeType.Root, env, runtime);
+ // The managed host registers its context in the environment instance-data block (at the
+ // module slot). When hosted, the native host owns that block and its finalizer signals
+ // environment teardown, so the managed context is a non-owner: it writes its own slot but
+ // does not claim the finalizer, and is disposed via the registration notification below.
+ bool hosted = registration != null;
+ JSRuntimeContext context = new(env, runtime);
try
{
+ // CreateRuntimeScope lazily builds the sync context and can throw; keep it in the try so
+ // a failure disposes the context instead of leaking it and escaping this entry point.
+ using JSValueScope scope = JSValueScope.CreateRuntimeScope(env, context);
+
JSObject exportsObject = (JSObject)new JSValue(exports, scope);
// Save the require() and import() functions that were passed in by the init script.
@@ -219,15 +237,40 @@ public static napi_value InitializeModule(napi_env env, napi_value exports)
ManagedHost host = new(exportsObject)
{
- _rootScope = scope
+ _context = context
};
+ // Dispose the host with its environment: as a disposable annotation on the context, the
+ // host's full Dispose (which unsubscribes the process-wide resolve handlers) runs when
+ // the context is disposed at environment teardown. Mirrors the native host.
+ context.SetDisposableAnnotation(host);
+
+ if (hosted)
+ {
+ // Root the managed host for the environment lifetime and give the native host a
+ // native callback to invoke at teardown (never a JS call -- see OnEnvironmentFinalize).
+ registration->AddonGCHandle = (nint)GCHandle.Alloc(host);
+#if !(NETFRAMEWORK || NETSTANDARD)
+ registration->OnEnvFinalize =
+ (nint)(delegate* unmanaged[Cdecl])&OnEnvironmentFinalize;
+#endif
+ }
+
Trace("< ManagedHost.InitializeModule()");
}
catch (Exception ex)
{
Trace($"Failed to load CLR managed host module: {ex}");
- JSError.ThrowError(ex);
+ try
+ {
+ // Throw via the runtime directly: scope creation may have failed, and the disposed
+ // context below would make a scope-bound JSError's lazy stack getter unusable.
+ runtime.ThrowError(env, code: null, ex.ToString());
+ }
+ finally
+ {
+ context.Dispose();
+ }
}
#if NETFRAMEWORK || NETSTANDARD
@@ -238,6 +281,63 @@ public static napi_value InitializeModule(napi_env env, napi_value exports)
#endif
}
+#if !(NETFRAMEWORK || NETSTANDARD)
+ ///
+ /// Called natively by the native host when the environment is being torn down. Runs during
+ /// environment finalization where calling into JavaScript is forbidden, so it touches only
+ /// managed state.
+ ///
+ [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
+ private static void OnEnvironmentFinalize(nint addon) => OnEnvironmentFinalizeCore(addon);
+#else
+ ///
+ /// Called by the native host (through the default AppDomain) when the environment is being
+ /// torn down. Runs during environment finalization where calling into JavaScript is forbidden,
+ /// so it touches only managed state.
+ ///
+ public static int OnEnvironmentFinalize(string argument)
+ {
+ OnEnvironmentFinalizeCore((nint)ulong.Parse(argument, NumberStyles.HexNumber));
+ return 0;
+ }
+#endif
+
+ private static void OnEnvironmentFinalizeCore(nint addon)
+ {
+ if (addon == default)
+ {
+ return;
+ }
+
+ GCHandle handle = GCHandle.FromIntPtr(addon);
+ try
+ {
+ (handle.Target as ManagedHost)?.DisposeOnEnvironmentFinalize();
+ }
+ catch (Exception ex)
+ {
+ Trace($"Failed to dispose managed host on environment finalize: {ex}");
+ }
+ finally
+ {
+ handle.Free();
+ }
+ }
+
+ ///
+ /// Disposes the managed host in response to environment teardown. No JavaScript may be called
+ /// here; disposing the context marks it disposed (so any late cross-thread post becomes a
+ /// no-op), disposes the host (a disposable annotation on the context) so its process-wide
+ /// resolve handlers are unsubscribed, and frees the context's GC handles. The context's
+ /// references are reclaimed by Node as the environment is torn down.
+ ///
+ private void DisposeOnEnvironmentFinalize()
+ {
+ JSRuntimeContext? context = _context;
+ _context = null;
+ context?.Dispose();
+ }
+
///
/// Resolve references to Node API and other assemblies that loaded assemblies depend on.
///
@@ -588,19 +688,32 @@ private JSValue RunWorker(JSCallbackArgs args)
}
}
+ private bool _isDisposed;
+
protected override void Dispose(bool disposing)
{
+ if (_isDisposed) return;
+ _isDisposed = true;
+
if (disposing)
{
- _rootScope?.Dispose();
- _rootScope = null;
+ // The context disposes this host (a disposable annotation) at teardown, so the
+ // re-entrant context dispose here is a guarded no-op. Unsubscribe the process-wide
+ // resolve handlers so a torn-down environment's host is not left rooted by them.
+ _context?.Dispose();
+ _context = null;
#if NETFRAMEWORK || NETSTANDARD
AppDomain.CurrentDomain.AssemblyResolve -= OnResolvingAssembly;
#else
AssemblyLoadContext.Default.Resolving -= OnResolvingAssembly;
_loadContext.Resolving -= OnResolvingAssembly;
- _loadContext.Unload();
+
+ // A non-collectible load context cannot be unloaded; only unload one created collectible.
+ if (_loadContext.IsCollectible)
+ {
+ _loadContext.Unload();
+ }
#endif
}
diff --git a/src/NodeApi.DotNetHost/ManagedHostRegistration.cs b/src/NodeApi.DotNetHost/ManagedHostRegistration.cs
new file mode 100644
index 00000000..00869630
--- /dev/null
+++ b/src/NodeApi.DotNetHost/ManagedHostRegistration.cs
@@ -0,0 +1,35 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Runtime.InteropServices;
+
+namespace Microsoft.JavaScript.NodeApi.DotNetHost;
+
+///
+/// Native handshake structure the managed host fills in at initialization, so the native host can
+/// keep the managed host alive for the environment lifetime and notify it when the environment is
+/// torn down.
+///
+///
+/// The layout must exactly match the native host's own copy of this structure (in the NodeApi
+/// assembly). Both are two pointer-sized fields, passed by pointer across the native/managed
+/// boundary. The native host and managed host run in separate .NET runtimes, so the structure is
+/// defined independently in each and only its binary layout is shared.
+///
+[StructLayout(LayoutKind.Sequential)]
+internal struct ManagedHostRegistration
+{
+ ///
+ /// A strong to the managed host, allocated and freed only by managed
+ /// code. The native host treats it as an opaque pointer.
+ ///
+ public nint AddonGCHandle;
+
+ ///
+ /// A native callback pointer (delegate* unmanaged<nint, void>) the native host
+ /// invokes at environment teardown, or default when the native host uses another channel
+ /// (the .NET Framework host invokes the finalize method through the default AppDomain instead).
+ ///
+ public nint OnEnvFinalize;
+}
diff --git a/src/NodeApi.Generator/ModuleGenerator.cs b/src/NodeApi.Generator/ModuleGenerator.cs
index d4c3bd87..2ecf5c68 100644
--- a/src/NodeApi.Generator/ModuleGenerator.cs
+++ b/src/NodeApi.Generator/ModuleGenerator.cs
@@ -24,6 +24,7 @@ public class ModuleGenerator : SourceGenerator, ISourceGenerator
{
private const string ModuleInitializerClassName = "Module";
private const string ModuleInitializeMethodName = "Initialize";
+ private const string ModuleExportsMethodName = "InitializeExports";
private const string ModuleRegisterFunctionName = "napi_register_module_v1";
private readonly JSMarshaller _marshaller = new()
@@ -287,26 +288,36 @@ private SourceBuilder GenerateModuleInitializer(
s += $"public static class {ModuleInitializerClassName}";
s += "{";
- // The module scope is not disposed after a successful initialization. It becomes
- // the parent of callback scopes, allowing the JS runtime instance to be inherited.
- s += "private static JSValueScope _moduleScope;";
-
- // The unmanaged entrypoint is used only when the AOT-compiled module is loaded.
+ // The unmanaged entrypoint is used only when the AOT-compiled module is loaded. As the
+ // root it creates the runtime context; there is no host to resolve it from.
s += "#if !NETFRAMEWORK";
s += $"[UnmanagedCallersOnly(EntryPoint = \"{ModuleRegisterFunctionName}\")]";
s += $"public static napi_value _{ModuleInitializeMethodName}(napi_env env, napi_value exports)";
- s += $"{s.Indent}=> {ModuleInitializeMethodName}(env, exports);";
+ s += "{";
+ s += "JSRuntimeContext context = JSRuntimeContext.Create(env);";
+ s += "using var moduleScope = JSValueScope.CreateModuleScope(env, context);";
+ s += $"return {ModuleExportsMethodName}(moduleScope, exports);";
+ s += "}";
s += "#endif";
s++;
- // The main initialization entrypoint is called by the `ManagedHost`, and by the unmanaged entrypoint.
+ // The main initialization entrypoint is called by the `ManagedHost` that loaded this
+ // module; the scope resolves the runtime context from that host.
s += $"public static napi_value {ModuleInitializeMethodName}(napi_env env, napi_value exports)";
s += "{";
- s += "_moduleScope = new JSValueScope(JSValueScopeType.Module, env, runtime: default);";
+ s += "using var moduleScope = JSValueScope.CreateModuleScope(env);";
+ s += $"return {ModuleExportsMethodName}(moduleScope, exports);";
+ s += "}";
+ s++;
+
+ // The shared body builds the exports within the module scope opened by an entrypoint
+ // above; the scope stays alive through the catch so it can build the JS error.
+ s += $"private static napi_value {ModuleExportsMethodName}(JSValueScope moduleScope, napi_value exports)";
+ s += "{";
s += "try";
s += "{";
- s += "JSRuntimeContext context = _moduleScope.RuntimeContext;";
- s += "JSValue exportsValue = new(exports, _moduleScope);";
+ s += "JSRuntimeContext context = moduleScope.RuntimeContext;";
+ s += "JSValue exportsValue = new(exports, moduleScope);";
s++;
if (moduleInitializer is IMethodSymbol moduleInitializerMethod)
@@ -340,7 +351,6 @@ private SourceBuilder GenerateModuleInitializer(
s += "{";
s += "System.Console.Error.WriteLine($\"Failed to export module: {ex}\");";
s += "JSError.ThrowError(ex);";
- s += "_moduleScope.Dispose();";
s += "return exports;";
s += "}";
s += "}";
diff --git a/src/NodeApi/DotNetHost/NativeHost.cs b/src/NodeApi/DotNetHost/NativeHost.cs
index e925cb19..15a3d43b 100644
--- a/src/NodeApi/DotNetHost/NativeHost.cs
+++ b/src/NodeApi/DotNetHost/NativeHost.cs
@@ -7,6 +7,7 @@
using System.IO;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
+using Microsoft.JavaScript.NodeApi.Interop;
using Microsoft.JavaScript.NodeApi.Runtime;
using static Microsoft.JavaScript.NodeApi.DotNetHost.HostFxr;
using static Microsoft.JavaScript.NodeApi.DotNetHost.MSCorEE;
@@ -28,9 +29,14 @@ internal unsafe partial class NativeHost : IDisposable
private string? _managedHostPath;
private ICLRRuntimeHost* _runtimeHost;
private hostfxr_handle _hostContextHandle;
- private readonly JSValueScope _hostScope;
private JSReference? _exports;
+ // Filled in by the managed host during initialization via the registration struct: a GCHandle
+ // (owned by the managed runtime) that roots the managed host, and a native callback the native
+ // host invokes at environment teardown. Both are default until a managed host is initialized.
+ private nint _addonGCHandle;
+ private nint _onEnvFinalize;
+
public static bool IsTracingEnabled { get; } =
Environment.GetEnvironmentVariable("NODE_API_TRACE_HOST") == "1";
@@ -194,15 +200,21 @@ public static napi_value InitializeModule(napi_env env, napi_value exports)
s_jsRuntime ??= new NodejsRuntime();
- // The native host JSValueScope is not disposed after a successful initialization. It
- // becomes the parent of callback scopes, allowing the JS runtime instance to be inherited.
- JSValueScope hostScope = new(JSValueScopeType.NoContext, env, s_jsRuntime);
+ // The native host's context occupies the host instance-data slot, so the initialize()/
+ // dispose() callbacks (dispatched later with no parent scope) recover it via FromEnv.
+ JSRuntimeContext.UseHostContextSlot();
+
+ // The host owns its context (inline, non-TSFN sync context); the transient scope only
+ // references it and is opened before the try so the catch can still build a JSValue error.
+ // The context outlives the scope -- rooted by its instance-data slot, disposed by that
+ // slot's finalizer (which disposes the NativeHost).
+ JSRuntimeContext context = new(env, s_jsRuntime, new JSInlineSynchronizationContext());
+ using JSValueScope hostScope = JSValueScope.CreateRuntimeScope(env, context);
try
{
- NativeHost host = new(hostScope);
+ NativeHost host = new();
+ context.SetDisposableAnnotation(host);
- // Do not use JSModuleBuilder here because it relies on having a current context.
- // But the context will be set by the managed host.
new JSValue(exports, hostScope).DefineProperties(
// The package index.js will invoke the initialize method with the path to
// the managed host assembly.
@@ -213,7 +225,6 @@ public static napi_value InitializeModule(napi_env env, napi_value exports)
string message = $"Failed to load CLR native host module: {ex}";
Trace(message);
s_jsRuntime.Throw(env, (napi_value)JSValue.CreateError(null, (JSValue)message));
- hostScope.Dispose();
}
Trace("< NativeHost.InitializeModule()");
@@ -221,9 +232,37 @@ public static napi_value InitializeModule(napi_env env, napi_value exports)
return exports;
}
- private NativeHost(JSValueScope hostScope)
+ [StructLayout(LayoutKind.Sequential)]
+ private struct ManagedHostRegistration
+ {
+ public nint AddonGCHandle;
+ public nint OnEnvFinalize;
+ }
+
+ private void NotifyManagedHostEnvironmentFinalize()
{
- _hostScope = hostScope;
+ if (_onEnvFinalize != default)
+ {
+ // hostfxr (.NET 5+): the managed host provided a native callback pointer.
+ ((delegate* unmanaged[Cdecl])_onEnvFinalize)(_addonGCHandle);
+ }
+ else if (_runtimeHost is not null && _addonGCHandle != default && _managedHostPath is not null)
+ {
+ // .NET Framework: invoke the managed finalize through the default AppDomain. This is a
+ // native call into the (still-loaded) CLR, never a JavaScript call.
+ try
+ {
+ _runtimeHost->ExecuteInDefaultAppDomain(
+ _managedHostPath,
+ s_managedHostTypeName,
+ "OnEnvironmentFinalize",
+ ((ulong)_addonGCHandle).ToString("X8"));
+ }
+ catch (Exception ex)
+ {
+ Trace("Failed to notify managed host on environment finalize: " + ex);
+ }
+ }
}
///
@@ -350,8 +389,10 @@ private JSValue InitializeFrameworkHost(
napi_value exports = (napi_value)exportsValue;
// The method to be executed must take a single string argument and return a uint.
- // So, encode the parameters and retval pointer in the argument string.
- string argument = $"{(ulong)env.Handle:X8},{(ulong)exports.Handle:X8},{(ulong)&exports:X8}";
+ // So, encode the parameters, retval pointer, and registration pointer in the argument.
+ ManagedHostRegistration registration = default;
+ string argument = $"{(ulong)env.Handle:X8},{(ulong)exports.Handle:X8}," +
+ $"{(ulong)&exports:X8},{(ulong)®istration:X8}";
Trace($" Calling {s_managedHostTypeName}.{nameof(InitializeModule)}({argument})");
_runtimeHost->ExecuteInDefaultAppDomain(
@@ -360,6 +401,9 @@ private JSValue InitializeFrameworkHost(
nameof(InitializeModule),
argument);
+ _addonGCHandle = registration.AddonGCHandle;
+ _onEnvFinalize = registration.OnEnvFinalize;
+
exportsValue = exports;
return exportsValue;
}
@@ -446,23 +490,28 @@ private JSValue InitializeDotNetHost(
Trace(" Invoking managed host method: " + nameof(InitializeModule));
- // Invoke the managed host initialize method.
- // (It will define some properties on the exports object passed in.)
- napi_register_module_v1 initializeModule =
- Marshal.GetDelegateForFunctionPointer(
- initializeModulePointer);
-
// Create an "exports" object for the managed host module initialization.
var exports = JSValue.CreateObject();
exports.SetProperty("require", require);
exports.SetProperty("import", import);
- // Define a dispose method implemented by the native host that closes the CLR context.
- // The managed host proxy will pass through dispose calls to this callback.
+ // The dispose method runs the full idempotent host disposal -- notifying the managed host
+ // before closing the runtime-host channel -- so on .NET Framework (which notifies managed
+ // code only through that channel) the managed registration is released, not stranded.
exports.DefineProperties(new JSPropertyDescriptor(
"dispose", (_) => { Dispose(); return default; }));
- exports = initializeModule((napi_env)exports.Scope, (napi_value)exports);
+ // Invoke the managed host initialize method. It defines properties on the exports object
+ // and fills in the registration so the native host can keep the managed host alive and
+ // notify it when the environment is torn down.
+ ManagedHostRegistration registration = default;
+ var initializeModule =
+ (delegate* unmanaged[Cdecl])
+ initializeModulePointer;
+ exports = initializeModule((napi_env)exports.Scope, (napi_value)exports, (nint)(®istration));
+
+ _addonGCHandle = registration.AddonGCHandle;
+ _onEnvFinalize = registration.OnEnvFinalize;
return exports;
}
@@ -498,6 +547,25 @@ private hostfxr_handle InitializeManagedRuntime(
public void Dispose()
{
+ // Called at env teardown (disposable annotation on the host context) and by the JS dispose() hook.
+ NotifyManagedHostEnvironmentFinalize();
+ CloseRuntimeHost();
+ _addonGCHandle = default;
+ _onEnvFinalize = default;
+
+ // JSReference.Dispose no-ops once its context is disposed, so this frees the napi_ref only on
+ // an explicit dispose() (env alive), never during env-teardown finalization.
+ _exports?.Dispose();
+ _exports = null;
+ }
+
+ private void CloseRuntimeHost()
+ {
+ // Closes this environment's CLR host: the hostfxr context handle (.NET 5+) or the
+ // ICLRRuntimeHost COM reference (.NET Framework). Each environment initializes its own, so
+ // this is per-environment teardown (the underlying shared CLR is not unloaded). Invoked at
+ // environment teardown and by the optional JS dispose() hook; idempotent.
+
// Close the CLR host context handle, if it's still open.
if (_hostContextHandle != default)
{
diff --git a/src/NodeApi/Interop/JSCallbackDescriptor.cs b/src/NodeApi/Interop/JSCallbackDescriptor.cs
index 682f0752..e865acf5 100644
--- a/src/NodeApi/Interop/JSCallbackDescriptor.cs
+++ b/src/NodeApi/Interop/JSCallbackDescriptor.cs
@@ -3,6 +3,7 @@
using System;
using System.Diagnostics;
+using System.Runtime.CompilerServices;
namespace Microsoft.JavaScript.NodeApi.Interop;
@@ -15,10 +16,10 @@ namespace Microsoft.JavaScript.NodeApi.Interop;
public readonly struct JSCallbackDescriptor
{
///
- /// Saves the module context under which the callback was defined, so that multiple .NET
+ /// Saves the module instance holder under which the callback was defined, so that multiple .NET
/// modules in the same process can register callbacks for module-level functions.
///
- internal JSModuleContext? ModuleContext { get; }
+ internal StrongBox