Improve object safety - #500
Conversation
There was a problem hiding this comment.
Pull request overview
Refactors Node-API object lifetimes around environment-owned runtime contexts and explicit value-scope factories.
Changes:
- Reworks scope, reference, module-holder, and runtime-context ownership.
- Updates hosts, embedding adapters, source generation, and Hermes integration.
- Expands lifetime and worker-teardown testing; updates documentation and dependencies.
Reviewed changes
Copilot reviewed 30 out of 30 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
test/TestCases/napi-dotnet/worker_teardown_stress.js |
Adds repeated worker teardown coverage. |
test/TestBuilder.cs |
Hardens SDK selection for test builds. |
test/MockJSRuntime.cs |
Mocks escapable-handle behavior. |
test/JSValueScopeTests.cs |
Tests the new scope model. |
test/JSReferenceTests.cs |
Updates reference lifetime tests. |
test/GCTests.cs |
Uses runtime-scope factories. |
src/NodeApi/Runtime/TracingJSRuntime.cs |
Migrates traced callbacks to runtime scopes. |
src/NodeApi/Runtime/NodeEmbeddingNodeApiScope.cs |
Creates an embedding runtime context. |
src/NodeApi/Runtime/NodeEmbedding.cs |
Updates embedding callback scopes. |
src/NodeApi/NodeApi.csproj |
Grants internal access to host and tests. |
src/NodeApi/JSValueScope.cs |
Introduces factory-based scope construction. |
src/NodeApi/JSValue.cs |
Removes no-context callback paths. |
src/NodeApi/JSReference.cs |
Makes references context-owned. |
src/NodeApi/JSPropertyDescriptor.cs |
Captures module holders. |
src/NodeApi/JSError.cs |
Adapts error handling to new scopes. |
src/NodeApi/Interop/JSThreadSafeFunction.cs |
Uses context-resolving callback scopes. |
src/NodeApi/Interop/JSSynchronizationContext.cs |
Adds an inline host synchronization context. |
src/NodeApi/Interop/JSRuntimeContext.cs |
Adds environment registration and annotations. |
src/NodeApi/Interop/JSModuleContext.cs |
Removes the former module context. |
src/NodeApi/Interop/JSModuleBuilderOfT.cs |
Stores module instances in holders. |
src/NodeApi/Interop/JSCallbackDescriptor.cs |
Carries module holders through callbacks. |
src/NodeApi/DotNetHost/NativeHost.cs |
Adds managed-host teardown registration. |
src/NodeApi.Generator/ModuleGenerator.cs |
Generates separate AOT and hosted entry paths. |
src/NodeApi.DotNetHost/ManagedHostRegistration.cs |
Defines the host teardown handshake. |
src/NodeApi.DotNetHost/ManagedHost.cs |
Registers and disposes managed contexts. |
src/NodeApi.DotNetHost/JSMarshaller.cs |
Resolves module instances from scopes. |
examples/hermes-engine/HermesRuntime.cs |
Migrates Hermes to scope factories. |
docs/features/js-value-scopes.md |
Documents the new factory API. |
Directory.Packages.props |
Updates Nullability.Source. |
bench/Benchmarks.cs |
Updates benchmark scope creation. |
Suppressed comments (3)
src/NodeApi/Interop/JSRuntimeContext.cs:184
FromEnvuses the runtime from the most recently constructed context process-wide. BecauseCreatepublicly accepts a runtime per context, creating env A with runtime A and then env B with a stateful runtime B makesFromEnv(envA)callruntimeB.GetInstanceData(envA)and potentially return B's context. Store/resolve the runtime per environment, or require the caller's runtime explicitly instead of using this global.
public static unsafe JSRuntimeContext? FromEnv(napi_env env)
{
JSRuntime? runtime = s_instanceDataRuntime;
if (runtime is null)
{
return null;
}
runtime.GetInstanceData(env, out nint instanceData).ThrowIfFailed();
src/NodeApi/Interop/JSRuntimeContext.cs:221
- Lazy creation makes disposal unsafe for a context that never opened a scope.
Dispose()accesses this property, so it attemptsJSSynchronizationContext.Create()while noJSValueScopeis current (including instance-data finalization), throws, and skips the remaining context/annotation cleanup. Disposal should only dispose an already-created_synchronizationContext, without constructing one.
/// <summary>
/// Gets the synchronization context that marshals callbacks and continuations to the JS thread.
/// A default one is created on first access, which happens while a scope for this context is
/// current, because creating it requires the current scope's runtime and environment.
/// </summary>
public JSSynchronizationContext SynchronizationContext
=> _synchronizationContext ??= JSSynchronizationContext.Create();
src/NodeApi/Interop/JSRuntimeContext.cs:278
- This silently overwrites an occupied slot instead of enforcing the promised one-context-per-env invariant. The embedding adapters now construct contexts repeatedly for the same lifecycle/env, so earlier contexts remain rooted while
FromEnvsuddenly resolves the last one; separate AOT addons are worse because the overwritten slot may contain a GCHandle owned by another CLR heap. Reuse/reject an existing registration and provide storage that cannot cross-dereference another runtime's handle.
((nint*)instanceData)[s_instanceDataSlot] = ContextHandle;
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| try | ||
| { | ||
| // A no-context reference (for example one created from the native host scope) can | ||
| // only be deleted on the JS thread. CurrentOrNull is thread-static, so on the real | ||
| // GC finalizer thread it is null and this delete is skipped; the napi_ref is then | ||
| // reclaimed when the JS environment is destroyed. The guarded delete still runs if | ||
| // Dispose(disposing: false) is ever invoked on the owning JS thread. A no-context | ||
| // scope has no synchronization context, so the finalizer cannot marshal the delete | ||
| // to the JS thread; doing so would require an env-scoped cleanup queue in the | ||
| // native host (tracked as a follow-up). | ||
| JSValueScope? scope = JSValueScope.CurrentOrNull; | ||
| if (scope != null && scope.UncheckedEnvironmentHandle == _env) | ||
| { | ||
| scope.Runtime.DeleteReference(_env, _handle); | ||
| } | ||
| } | ||
| else | ||
| { | ||
| // Post the delete to the JS thread. The synchronization context is a safe no-op | ||
| // once it has been disposed (that is, after the worker has been torn down). | ||
| _context.SynchronizationContext?.Post( | ||
| _context.SynchronizationContext.Post( |
There was a problem hiding this comment.
Tracked as a separate follow-up (the JSTsfnSynchronizationContext post-then-release race, #497), intentionally out of scope for this PR. Removing the no-context path did not dissolve it: it needs a dedicated design for gating in-flight TSFN calls against release (a napi acquire/release refcount around each post is the obvious lever, but leaves a narrow post-count-zero/finalize window). Leaving this thread open to track it.
…down - JSValueScope: validate a supplied env against the resolved context on the inherited path; a nested runtime scope inherits the parent's module holder. - TracingJSRuntime: apply the descriptor's module holder to the callback scope (matching InvokeCallback) so module members work under NODE_API_TRACE_RUNTIME. - JSRuntimeContext.Dispose: dispose an already-created sync context only, never construct one during environment finalization. - ManagedHost: register as a per-env disposable annotation so its full Dispose (unsubscribing the process-wide resolve handlers) runs at environment teardown. - NativeHost: close the per-env CLR host at environment teardown; correct the process-level comments on both hosts.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
src/NodeApi/Interop/JSRuntimeContext.cs:278
- This unconditionally replaces an existing context for the same runtime/environment. The five changed Node embedding callback adapters each construct a new context, so earlier contexts (including their synchronization contexts and references) remain live while the environment finalizer disposes only the last one. Reuse the context already registered for the environment, and reject duplicate registration in the factory as a safeguard.
((nint*)instanceData)[s_instanceDataSlot] = ContextHandle;
src/NodeApi/Interop/JSRuntimeContext.cs:904
ContextHandleis intentionally never freed, so this dictionary otherwise keeps every disposedManagedHost/NativeHostannotation strongly reachable forever. Repeated worker creation therefore accumulates disposed hosts and their load-context object graphs. Clear the owning annotations after all values have been disposed.
if (_disposableAnnotations != null)
{
foreach (IDisposable annotation in _disposableAnnotations.Values)
{
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
src/NodeApi/Interop/JSRuntimeContext.cs:123
- The fixed two-slot layout is not actually “one slot per runtime.” Two NativeAOT modules loaded into the same
napi_envrun in separate managed runtimes, but both useModuleContextSlot; the later module overwrites the first module’s opaqueGCHandle, and the first module’s instance-data finalizer then attempts to interpret a handle owned by the other GC heap. This can leak or crash during teardown. The instance-data representation needs per-runtime ownership that does not place multiple runtimes’ handles in the same fixed slot.
// Env instance-data layout: one GCHandle slot per runtime sharing the napi_env. Slot 0 is the
// module context (managed host / AOT module / embedding); slot 1 is the native host context.
// A runtime reads and writes only its own slot, so it never dereferences the other runtime's
// GCHandle (which belongs to a separate GC heap).
private const int ModuleContextSlot = 0;
src/NodeApi/Interop/JSRuntimeContext.cs:136
- This strong
GCHandleis intentionally never freed, so every environment permanently roots itsJSRuntimeContextand everything it still references. The worker stress path creates both native- and managed-host contexts per iteration, making repeated worker teardown a guaranteed process-lifetime managed-memory leak. Keep only teardown-safe finalize-hint state alive as long as necessary, and free the context handle after the environment’s dependent finalizers can no longer use it.
// A GCHandle rooting this context, used both as its env instance-data slot value and as the
// finalize hint for pooled GC handles. It is intentionally never freed: pooled-handle
// finalizers dereference it during env teardown, after this context is already disposed.
src/NodeApi/Interop/JSRuntimeContext.cs:180
FromEnvuses whichever runtime was registered most recently process-wide. After creating contexts with differentJSRuntimeinstances, resolving the earlier environment callsGetInstanceDataon the later runtime; stateful implementations (including the per-instanceMockJSRuntime) therefore return the wrong context or fail. Track the runtime/context per environment instead of storing one global runtime.
public static unsafe JSRuntimeContext? FromEnv(napi_env env)
{
JSRuntime? runtime = s_instanceDataRuntime;
if (runtime is null)
{
src/NodeApi/Interop/JSRuntimeContext.cs:877
- Replacing an owning annotation of the same type drops the previous instance without disposing it or returning it to the caller. Because this API promises that the context owns these values, the previous resource is leaked permanently. Define replacement semantics and either dispose the replaced value or reject duplicate registration.
public void SetDisposableAnnotation<T>(T value) where T : class, IDisposable
{
if (value is null) throw new ArgumentNullException(nameof(value));
(_disposableAnnotations ??= new())[typeof(T)] = value;
- Wrapped-object and action finalizers resolve the context from napi_env (FromEnv) instead of a GCHandle finalize hint, so the context's rooting handle no longer needs to stay rooted. - At teardown the context clears its instance-data slot and frees its rooting GCHandle so it can be collected; the small instance-data block is intentionally kept so a late finalizer's FromEnv resolves no context rather than reading freed memory. - Clarify that the FromEnv runtime static is safe: JSRuntime is a stateless dispatch v-table.
The embedding runtime callbacks and Node-API scopes constructed a new JSRuntimeContext for the env on every invocation, leaking a context and overwriting the env instance-data slot each time. They now resolve the env's registered context (FromEnv) and create one only if absent, so there is a single context per env, disposed by the instance-data finalizer at teardown.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
src/NodeApi.DotNetHost/ManagedHost.cs:213
- In hosted mode this context does not own the instance-data finalizer, but the native host receives the managed teardown callback only after
ManagedHostconstruction succeeds and fillsregistration. If initialization throws before that point, the catch reports the JS error but leaves this context's rootingGCHandleand synchronization context registered forever because the native host has no callback with which to dispose it. Close the scope and dispose the context on the failed-initialization path.
bool hosted = registration != null;
JSRuntimeContext context = new(env, runtime);
using JSValueScope scope = JSValueScope.CreateRuntimeScope(env, context);
src/NodeApi/Interop/JSModuleBuilderOfT.cs:37
- This drops the previous ownership behavior of
JSModuleContext: whenmoduleimplementsIDisposable, it is no longer disposed at module/context teardown. Generated module classes such astest/TestCases/napi-dotnet/ModuleClass.cs:17rely on that contract. Register disposable module instances with the runtime context (without collapsing multiple modules onto one annotation key) so teardown still invokesDispose().
// Write through the holder the descriptors captured, so callbacks bound before the module
// instance existed observe it.
JSValueScope.Current.ModuleHolder!.Value = module;
exports.DefineProperties(Properties.ToArray());
src/NodeApi/Interop/JSRuntimeContext.cs:280
- This assignment silently replaces an existing context in the slot without disposing it or freeing its
ContextHandle, violating the one-context-per-env invariant. The default embedding path already triggers this:RuntimeLoadingCallbackAdapterregisters one context, thenNodeEmbeddingNodeApiScoperegisters another for the same environment, permanently rooting the first context and its GC handles. Resolve/reuse the registered context in embedding callbacks/scopes, and reject or safely handle duplicate registration.
((nint*)instanceData)[s_instanceDataSlot] = ContextHandle;
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated 1 comment.
Suppressed comments (6)
Previously missed (2) — in code that hasn't changed since the last review.
src/NodeApi/JSValueScope.cs:175
- With no parent and the default null
env, this callsFromEnv(default). Once any context has initialized the static runtime, that invokesnapi_get_instance_datawith a null environment instead of rejecting the invalid factory call. Validate that an env was supplied before attempting environment lookup.
// Inherit the parent scope's context, else recover it from the env instance data.
context ??= _parentScope?.RuntimeContext
?? JSRuntimeContext.FromEnv(env)
?? throw new InvalidOperationException(
"A runtime context could not be resolved for the scope.");
src/NodeApi.DotNetHost/ManagedHost.cs:213
- The context is registered and rooted before initialization enters the
try, but the native host receives the registration handle only near the end of the successful path. If initialization throws earlier, the catch returns with no handshake handle, so environment teardown cannot dispose this managed context; its instance-data GCHandle, synchronization context, and any installed resolve handlers remain rooted. Dispose the failed scope and context after reporting the JS error.
bool hosted = registration != null;
JSRuntimeContext context = new(env, runtime);
using JSValueScope scope = JSValueScope.CreateRuntimeScope(env, context);
src/NodeApi/JSReference.cs:365
- This concurrent-disposal assumption is unsafe.
JSTsfnSynchronizationContext.PostchecksIsDisposedand then calls_tsfn.NonBlockingCall, whileDisposecan release the TSFN between those operations. Since reference finalizers now always post here during environment teardown, that race can call a released native TSFN. Gate in-flight calls and close the gate before releasing the TSFN.
// The guard above handles an already-disposed context; if it is disposed concurrently after
// that check, the posted delete is still a safe no-op (the napi_ref went with the env).
src/NodeApi/DotNetHost/NativeHost.cs:501
- On .NET Framework, the managed teardown notification uses
_runtimeHost->ExecuteInDefaultAppDomain. This callback closes and nulls_runtimeHostwithout notifying the managed host, so the later environment finalizer skips its notification and leaks_addonGCHandleplus the managed context. Run the full idempotentDispose()path so notification occurs before the CLR host is closed.
exports.DefineProperties(new JSPropertyDescriptor(
"dispose", (_) => { CloseRuntimeHost(); return default; }));
src/NodeApi.Generator/ModuleGenerator.cs:309
- This hosted-module scope inherits the
ModuleHolderfrom the currentManagedHost.LoadModulecallback. Consequently all dynamically loaded generated modules share oneStrongBox; loading a second module overwrites the instance observed by callbacks from the first module. Create a fresh module holder at each module-initialization boundary while continuing to share the runtime context.
s += "using var moduleScope = JSValueScope.CreateRuntimeScope(env);";
s += $"return {ModuleExportsMethodName}(moduleScope, exports);";
src/NodeApi/Interop/JSRuntimeContext.cs:880
- Replacing an owning annotation of the same type drops the previous
IDisposablewithout disposing it, even though this API transfers disposal responsibility to the context. Either reject duplicate keys or dispose the previous value when replacing it so owned resources are not leaked.
public void SetDisposableAnnotation<T>(T value) where T : class, IDisposable
{
if (value is null) throw new ArgumentNullException(nameof(value));
(_disposableAnnotations ??= new())[typeof(T)] = value;
JSModuleAttribute documents that a module class implementing IDisposable is disposed when the module is unloaded. Register the module instance as a disposable annotation on its runtime context so it is disposed at environment teardown, restoring that contract.
Add docs/concepts/runtime-model.md covering the napi_env-per-module relationship, the node::Environment vs napi_env vs isolate/worker distinction (environment cleanup hook vs per-napi_env instance-data finalizer), instance-data slot ownership, the three JSValueScope types, and the rules for holding napi_value/napi_ref safely. Add AGENTS.md with thin CLAUDE.md and .github/copilot-instructions.md pointers, and surface the concepts docs in the site navigation.
SetDisposableAnnotation now throws ObjectDisposedException if called after the context is disposed (the value would otherwise never be disposed), and disposes any same-type annotation it displaces so an owned annotation is never silently leaked.
A generated module's hosted entry point opened a runtime scope that inherited the managed host's module holder, so loading a second module overwrote the first module's instance and later callbacks from the first module resolved the wrong instance. Add JSValueScope.CreateModuleScope, which references the surrounding context but starts a fresh module holder, and use it from the generated module entry points.
The embedding adapters resolve the env's context via FromEnv, which reads instance data through the process-wide static runtime. When a different runtime last registered (for example a mock in unit tests), that read can return another env's block, so FromEnv returned a context whose env did not match and the scope constructor threw, crashing the host. FromEnv now returns a context only when its environment handle matches the requested env.
Fix a regression where IDisposable module instances loaded into one managed host disposed each other: ExportModule inferred T=IDisposable and registered every module (and, on the module-less path, the context itself) under one type-keyed annotation, so loading a second module displaced and disposed the first mid-load. Module instances now register in an append-many list on the context (AddModuleDisposable), each disposed once at teardown; the context is never registered as its own module disposable. Adds a regression test that loads two IDisposable modules through ExportModule. Also: JSValueScope.Dispose fetches the env only for handle/escapable scopes so disposing a runtime scope after its context is torn down does not throw; document the intentional per-env instance-data block retention at its allocation; move the rooting-GCHandle doc onto ContextHandle.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated 4 comments.
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
src/NodeApi/DotNetHost/NativeHost.cs:501
- On .NET Framework this releases
_runtimeHost, but environment teardown later requires that same pointer to callOnEnvironmentFinalize. After an explicit JSdispose(), the condition inNotifyManagedHostEnvironmentFinalizeis false, so the managed registration GCHandle and context are never released. Use the full disposal path so the managed host is notified before the runtime-host pointer is cleared.
// 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.
exports.DefineProperties(new JSPropertyDescriptor(
"dispose", (_) => { CloseRuntimeHost(); return default; }));
src/NodeApi/Interop/JSRuntimeContext.cs:291
- This unconditionally overwrites an occupied slot. A second
JSRuntimeContext.Createfor the same environment leaves the first context rooted by a GCHandle that is no longer reachable through instance data, so it is never disposed. Reject an already-populated slot (and free the newly allocated handle on registration failure) to enforce the stated one-context-per-env invariant.
((nint*)instanceData)[s_instanceDataSlot] = ContextHandle;
src/NodeApi/JSReference.cs:374
- The context can be disposed after the
IsDisposedcheck but before this post.JSTsfnSynchronizationContext.Postitself also checks then callsNonBlockingCall, whileDisposecan concurrently release the TSFN, producing a native use-after-release. A try/catch cannot protect that race; gate in-flight TSFN calls against release before using this path for cross-thread reference cleanup.
if (disposing)
{
// Delete the reference on the JS thread (inline if already there).
_context.SynchronizationContext.Post(
() => runtime.DeleteReference(env, handle).ThrowIfFailed(), allowSync: true);
TryCreateRuntimeScope now inherits the current scope's context only when its environment matches the callback's env, so a synchronous callback for another environment resolves that env's context from the instance data instead of hitting the env-mismatch check. It also guards the fallible resolution (FromEnv and scope construction) so no setup exception can escape the UnmanagedCallersOnly callback boundary and terminate the process; an unresolvable or disposed context still makes the callback a no-op.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/NodeApi/Interop/JSRuntimeContext.cs:1023
- These cleanup calls are now guaranteed not to release any native references:
IsDisposedis set at line 1006, andJSReference.Disposeimmediately returns when its owning context is disposed. This matters outside env finalization—for example, the exported hostdispose()tears down the managed context while thenapi_envremains alive—so every mappednapi_refis retained until eventual env shutdown. The disposal API needs to distinguish live-env explicit teardown (delete references before closing the synchronization context) from env-finalizer teardown (where Node reclaims them and no Node-API call is allowed).
try { DisposeReferences(_objectMap.Select((entry) => entry.Value)); }
catch (Exception ex) { firstFailure ??= ExceptionDispatchInfo.Capture(ex); }
…access on a disposed context The seven UnmanagedCallersOnly callback boundaries (JSValue.InvokeCallback, the four tracing callbacks, and the five embedding adapters) declared the runtime scope with a using before the try, so a scope-disposal exception -- for example the LIFO order check when callback code left a nested scope open -- ran after the catch and could cross the native boundary. Each now disposes the scope inside an outer try, while callback errors are still reported as a JS exception from an inner catch that runs with the scope current. JSReference.ThrowIfDisposed now also fails when the owning context is disposed, so Handle, GetValue, and TryGetValue cannot read or invoke a napi_ref whose environment was already torn down.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated 3 comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/NodeApi/JSReference.cs:136
- After the owning context is disposed, this still returns its already-created (and disposed) synchronization context.
Run/Run<T>then callSend, which simply returns when disposed, so the documented action is never invoked andRun<T>silently returnsdefaultinstead of throwingObjectDisposedException. Check the reference/context state before exposing the synchronization context so these operations reach the same disposed guard asGetValue.
public JSSynchronizationContext? SynchronizationContext => _context.SynchronizationContext;
…wning thread Wrap the napi_finalize and cleanup-hook UnmanagedCallersOnly callbacks so a managed exception can no longer escape across the native boundary during teardown, where no JS error can be reported. A new FreeFinalizerGCHandle helper frees each finalizer's GC handle exactly once -- tracked when the owning context is still usable, untracked otherwise -- without throwing. Reject disposing a JSRuntimeContext from a thread other than the one that created it, since teardown calls thread-affine napi (the sync context's RemoveEnvCleanupHook). The instance-data finalizer and the JS dispose functions, the only expected callers, both run on that thread.
At env teardown Node runs finalizers in no defined order, so a napi_ref may already be finalized and freed; deleting it again would crash. On an explicit dispose() the env is still alive, so the few undeleted references are reclaimed when the env is torn down. The previous comment claimed the env is always torn down, which is not true on the explicit-dispose path.
The JS dispose() hook and the managed environment-finalize notification are native calls dispatched through Node-API, which can be nested inside open value scopes. Disposing the runtime context then would leave those scopes to close their napi handle scopes on a disposed context as they unwind -- an unbalanced close that Node-API rejects. Flag the innermost open scope instead; the request moves outward as scopes close, so the outermost scope disposes the context once none remain open (or immediately when no scope is open).
There was a problem hiding this comment.
🟡 Changes recommended
Deferred teardown can dispose the wrong context, and tracing can silently swallow callback failures.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/NodeApi/Runtime/TracingJSRuntime.cs:402
- The outer catch silently converts preprocessing/postprocessing failures into
undefinedwhen tracing is enabled.TraceCallbackonly catches exceptions fromdescriptor.Callback;GetDataAndLength, descriptor conversion, argumentFormat, and returnFormatall run outside that catch. Report those failures as JS exceptions while the scope is current, as the non-tracing callback path does, while retaining the outer catch for scope-disposal failures.
- Files reviewed: 35/35 changed files
- Comments generated: 1
- Review effort level: Balanced
The deferred-disposal request recorded only a boolean on the scope, so with a scope for one context nested under a scope for another (which TryCreateRuntimeScope allows across environments) the request propagated into the parent and disposed the parent's context instead of the requested one, leaving the requested context live. Record the target context with the request and stop propagating once the parent is null or belongs to a different context, so the requested context is disposed when its own outermost scope closes.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved registration rollback, unmanaged-boundary, and scope-disposal issues can break context resolution or leak native runtimes.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
docs/concepts/runtime-model.md:117
- This says the native reference is released when its context is disposed, but the implementation intentionally marks the context disposed before visiting references, causing
JSReference.Dispose()to skipnapi_delete_reference; on explicit context disposal, Node retains the reference until later environment teardown. Document that context disposal invalidates the managed reference and that the nativenapi_refis reclaimed by environment teardown.
- Files reviewed: 35/35 changed files
- Comments generated: 4
- Review effort level: Balanced
Publish the process-wide runtime FromEnv uses only after instance-data registration succeeds, so a failed GetInstanceData/SetInstanceData or a rejected duplicate slot can't repoint FromEnv at a runtime that never registered a context. Guard the managed host's failed-init context disposal, since JSRuntimeContext.Dispose rethrows its first cleanup error, which must not escape the UnmanagedCallersOnly entry point. Mark NodeEmbeddingNodeApiScope and HermesRuntime disposed only after their scope and native closes succeed, so an out-of-order close (the value scope's LIFO/thread check) stays retryable instead of leaking the native scope or runtime.
There was a problem hiding this comment.
🟡 Changes recommended
Deferred teardown can dispose a context while an earlier scope for that context remains open under alternating context nesting.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 35/35 changed files
- Comments generated: 1
- Review effort level: Balanced
| if (_parentScope is null || _parentScope.RuntimeContext != runtimeContext) | ||
| { | ||
| runtimeContext.Dispose(); | ||
| } | ||
| else | ||
| { | ||
| _parentScope._runtimeContextToDisposeOnClose = runtimeContext; | ||
| } |
Type of change
JSValueScopeis now constructed through staticfactory methods, and
JSValueScopeTypeis internal.Why
Node-API values (
napi_value) and references (napi_ref) have strict, environment-scopedlifetimes, but the previous design spread responsibility for those lifetimes across several
overlapping concepts — five
JSValueScopetypes, a separateJSModuleContext, and a"no-context"
JSReferencepath. That made two simple invariants hard to guarantee:napi_valueis valid exactly while itsJSValueScopeis open, andJSReferenceis owned by the oneJSRuntimeContextbound to itsnapi_env.This change makes those invariants structural. It builds on the recent worker-teardown crash
fixes (#487, #492) and removes the need for the separate no-context follow-up (#495) by
eliminating that path entirely.
What
A
napi_valueis valid exactly while itsJSValueScopeis open. Value validity flowsentirely through the owning scope, so using a value after its scope closes fails predictably
instead of depending on scope-type-specific handling.
JSValueis correspondingly simpler.A
JSReferenceis owned by theJSRuntimeContextof itsnapi_env. Reference cleanupis always posted to the owning JS thread through that context, and the finalizer never
touches JS state off-thread, so it stays crash-safe during environment teardown.
Exactly one
JSRuntimeContextpernapi_env, disposed when the env is finalized. Thecontext is stored in and resolved from the env's instance data (
FromEnv, which returns acontext only when its environment handle matches the requested env), and a native/managed
host handshake disposes it deterministically when the environment's instance data is
finalized — without calling back into JavaScript, since the environment is going away. The
context is bound to its environment's JS thread (entering its scope from another thread throws),
and the env's instance-data block is reclaimed once the last context on that env is finalized.
Removed the "no-context" concept. Every scope and reference is backed by a runtime
context, which removes a class of teardown edge cases (and makes the no-context reference
leak targeted by Fix no-context JSReference leak and TSFN post/release race (follow-ups to #492) #495 moot).
Simplified
JSValueScope. ReplacedJSModuleContextwith a lightweightStrongBox<object?>module holder; reducedJSValueScopeTypeto three internal values(
RuntimeContext,Handle,Escapable); the public surface is now static factories —CreateRuntimeScope/CreateHandleScope/CreateEscapableScope/CreateModuleScope—plus
JSRuntimeContext.Create.Each loaded module resolves its own instance, and disposes cleanly. A module boundary
(
CreateModuleScope) starts a fresh module holder, so when one host loads several modules alater module no longer displaces an earlier module's instance. An
IDisposable[JSModule]class is disposed exactly once at environment teardown.
Host, embedding, and generator updated to match. The native and managed hosts and the
embedding adapters create or resolve the context explicitly, and the generated module entry
points split into an AOT path that creates the context and a dynamic path that resolves it.
Documentation. Added
docs/concepts/runtime-model.md— the environment / context /lifetime model this change relies on (one
napi_envper loaded module, the instance-datafinalizer vs. environment cleanup hook, the two-slot instance-data layout, the three scope
types, and the rules for holding
napi_value/napi_ref) — plus anAGENTS.md(and thinCLAUDE.md/.github/copilot-instructions.md) pointing contributors and agents to it.Tests. Rewrote the scope and reference unit tests for the new model and added coverage
for value escaping, context-from-env resolution, the context factory, synchronization-context
install/restore, the module holder, off-thread disposal, disposing several
IDisposablemodules that share one context, identity-based deduplication of module disposables, and
rejecting a runtime scope entered from another thread. Added a worker-teardown stress test that
repeatedly loads and tears down the host to exercise the per-environment init/teardown path.
Build hygiene. Bumped
Nullability.Source(2.1.0→2.3.0) to clear adotnet formatwarning on the package's vendored source file.
Testing
Built and packed in Release, then ran the full test suite — managed unit tests plus the AOT,
hosted-CLR, embedding, and worker-teardown-stress cases — on all target frameworks. All green.
Release notes
Should this change be included in the release notes: yes — Object-lifetime safety: a
napi_value's lifetime is governed by itsJSValueScopeand aJSReference's lifetime by theJSRuntimeContextof itsnapi_env;JSValueScopeis now created via static factory methods(
CreateRuntimeScope/CreateHandleScope/CreateEscapableScope) andJSValueScopeTypeisinternal (breaking, pre-1.0).