Skip to content
Draft
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d554115
Improve object safety
vmoroz Aug 27, 2026
c1e47c0
Address Copilot review: scope/context correctness + per-env host tear…
vmoroz Aug 27, 2026
3a0345b
Fix formatting
vmoroz Aug 27, 2026
e1ed27e
Free runtime context root at teardown
vmoroz Aug 27, 2026
2454f05
Reuse one JSRuntimeContext per env in embedding adapters
vmoroz Aug 27, 2026
d217d0d
Dispose IDisposable module instance at environment teardown
vmoroz Aug 28, 2026
85e2611
Document the runtime model and add agent instructions
vmoroz Aug 28, 2026
047ed0b
Harden SetDisposableAnnotation against post-dispose and replacement
vmoroz Aug 28, 2026
a4e6e42
Give each loaded module its own module holder
vmoroz Aug 28, 2026
3b84450
Return a context from FromEnv only when it matches the env
vmoroz Aug 28, 2026
65ec52e
Address pre-PR code review: shared-context module disposal
vmoroz Aug 28, 2026
ba16dce
Free the env instance-data block at teardown; fix module-disposable d…
vmoroz Aug 28, 2026
e5114f4
Pin JSRuntimeContext to its creation thread
vmoroz Aug 28, 2026
5dc04b6
Harden runtime-context construction and scope entry
vmoroz Aug 28, 2026
7ab932b
Run full host disposal from the JS dispose() hook
vmoroz Aug 28, 2026
372ce30
Guard lazy sync-context creation; report a stackless init error
vmoroz Aug 28, 2026
1ca52e2
Dispose exports reference on host dispose; clarify finalizer doc
vmoroz Aug 28, 2026
31e752d
Trim NativeHost.Dispose comments
vmoroz Aug 28, 2026
43db3ae
Contain managed-host scope creation in the failure path; harden stres…
vmoroz Aug 29, 2026
54e0f60
Guard native-host init at the boundary; safe slot-clear order; net472…
vmoroz Aug 29, 2026
a6edc79
Document native-host context finalizer ownership in the init catch
vmoroz Aug 30, 2026
ca4d8fb
Guard managed-host and generated-AOT entry points at the boundary
vmoroz Aug 30, 2026
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
7 changes: 7 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -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`.
59 changes: 59 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.10.91" />
<PackageVersion Include="Nullability.Source" Version="2.1.0" />
<PackageVersion Include="Nullability.Source" Version="2.3.0" />
<PackageVersion Include="System.Memory" Version="4.5.5" />
<PackageVersion Include="System.Reflection.Emit" Version="4.7.0" />
<PackageVersion Include="System.Reflection.MetadataLoadContext" Version="6.0.0" />
Expand Down
2 changes: 1 addition & 1 deletion bench/Benchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 7 additions & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
138 changes: 138 additions & 0 deletions docs/concepts/runtime-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# 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 run during teardown — no JS allowed

A finalizer (for a wrapped .NET object, an external, or a reference) may run while the environment is
being torn down, where **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.
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.
9 changes: 4 additions & 5 deletions docs/features/js-value-scopes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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())
Expand Down
5 changes: 3 additions & 2 deletions examples/hermes-engine/HermesRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -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;
Expand Down
18 changes: 9 additions & 9 deletions src/NodeApi.DotNetHost/JSMarshaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -1878,22 +1878,22 @@ private IEnumerable<Expression> BuildThisArgumentExpressions(

if (type.GetCustomAttributes<JSModuleAttribute>().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(
Expand Down
Loading
Loading