Skip to content
Draft
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
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