Skip to content

Improve object safety - #500

Draft
Vladimir Morozov (vmoroz) wants to merge 11 commits into
microsoft:mainfrom
vmoroz:PR/improve-object-safety
Draft

Improve object safety#500
Vladimir Morozov (vmoroz) wants to merge 11 commits into
microsoft:mainfrom
vmoroz:PR/improve-object-safety

Conversation

@vmoroz

Copy link
Copy Markdown
Member

Type of change

  • Refactor / object-lifetime hardening
  • ⚠️ Breaking API change (pre-1.0): JSValueScope is now constructed through static
    factory methods, and JSValueScopeType is internal.

Why

Node-API values (napi_value) and references (napi_ref) have strict, environment-scoped
lifetimes, but the previous design spread responsibility for those lifetimes across several
overlapping concepts — five JSValueScope types, a separate JSModuleContext, and a
"no-context" JSReference path. That made two simple invariants hard to guarantee:

  • a napi_value is valid exactly while its JSValueScope is open, and
  • a JSReference is owned by the one JSRuntimeContext bound to its napi_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_value is valid exactly while its JSValueScope is open. Value validity flows
    entirely through the owning scope, so using a value after its scope closes fails predictably
    instead of depending on scope-type-specific handling. JSValue is correspondingly simpler.

  • A JSReference is owned by the JSRuntimeContext of its napi_env. Reference cleanup
    is 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 JSRuntimeContext per napi_env, disposed when the env is finalized. The
    context is stored in and resolved from the env's instance data, 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.

  • 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. Replaced JSModuleContext with a lightweight
    StrongBox<object?> module holder that nested scopes inherit; reduced JSValueScopeType to
    three internal values (RuntimeContext, Handle, Escapable); the public surface is now
    static factories — CreateRuntimeScope / CreateHandleScope / CreateEscapableScope — plus
    JSRuntimeContext.Create.

  • 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.

  • 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, and off-thread disposal. 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.02.3.0) to clear a dotnet format
    warning 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 its JSValueScope and a JSReference's
lifetime by the JSRuntimeContext of its napi_env; JSValueScope is now created via static factory
methods (CreateRuntimeScope / CreateHandleScope / CreateEscapableScope) and
JSValueScopeType is internal (breaking, pre-1.0).

Copilot AI balanced review requested due to automatic review settings August 27, 2026 19:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • FromEnv uses the runtime from the most recently constructed context process-wide. Because Create publicly accepts a runtime per context, creating env A with runtime A and then env B with a stateful runtime B makes FromEnv(envA) call runtimeB.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 attempts JSSynchronizationContext.Create() while no JSValueScope is 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 FromEnv suddenly 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.

Comment thread src/NodeApi/Interop/JSRuntimeContext.cs Outdated
Comment thread src/NodeApi/JSValueScope.cs Outdated
Comment thread src/NodeApi/JSValueScope.cs Outdated
Comment thread src/NodeApi/Runtime/TracingJSRuntime.cs
Comment on lines +383 to +385
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(
Comment thread src/NodeApi/Interop/JSModuleBuilderOfT.cs
Comment thread src/NodeApi.DotNetHost/ManagedHost.cs
Comment thread src/NodeApi/DotNetHost/NativeHost.cs
…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.
Copilot AI review requested due to automatic review settings August 27, 2026 21:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • ContextHandle is intentionally never freed, so this dictionary otherwise keeps every disposed ManagedHost/NativeHost annotation 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)
            {

Comment thread src/NodeApi/Interop/JSRuntimeContext.cs Outdated
Comment thread src/NodeApi/Interop/JSRuntimeContext.cs Outdated
Copilot AI review requested due to automatic review settings August 27, 2026 23:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_env run in separate managed runtimes, but both use ModuleContextSlot; the later module overwrites the first module’s opaque GCHandle, 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 GCHandle is intentionally never freed, so every environment permanently roots its JSRuntimeContext and 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

  • FromEnv uses whichever runtime was registered most recently process-wide. After creating contexts with different JSRuntime instances, resolving the earlier environment calls GetInstanceData on the later runtime; stateful implementations (including the per-instance MockJSRuntime) 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;

Comment thread src/NodeApi/Interop/JSRuntimeContext.cs
- 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.
Copilot AI review requested due to automatic review settings August 27, 2026 23:51
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ManagedHost construction succeeds and fills registration. If initialization throws before that point, the catch reports the JS error but leaves this context's rooting GCHandle and 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: when module implements IDisposable, it is no longer disposed at module/context teardown. Generated module classes such as test/TestCases/napi-dotnet/ModuleClass.cs:17 rely on that contract. Register disposable module instances with the runtime context (without collapsing multiple modules onto one annotation key) so teardown still invokes Dispose().
        // 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: RuntimeLoadingCallbackAdapter registers one context, then NodeEmbeddingNodeApiScope registers 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;

Comment thread src/NodeApi.Generator/ModuleGenerator.cs Outdated
Copilot AI review requested due to automatic review settings August 28, 2026 00:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 calls FromEnv(default). Once any context has initialized the static runtime, that invokes napi_get_instance_data with 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.Post checks IsDisposed and then calls _tsfn.NonBlockingCall, while Dispose can 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 _runtimeHost without notifying the managed host, so the later environment finalizer skips its notification and leaks _addonGCHandle plus the managed context. Run the full idempotent Dispose() 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 ModuleHolder from the current ManagedHost.LoadModule callback. Consequently all dynamically loaded generated modules share one StrongBox; 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 IDisposable without 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;

Comment thread src/NodeApi/Interop/JSRuntimeContext.cs
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.
Copilot AI review requested due to automatic review settings August 28, 2026 04:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 call OnEnvironmentFinalize. After an explicit JS dispose(), the condition in NotifyManagedHostEnvironmentFinalize is 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.Create for 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 IsDisposed check but before this post. JSTsfnSynchronizationContext.Post itself also checks then calls NonBlockingCall, while Dispose can 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);

Comment on lines +915 to +918
if (!_moduleDisposables.Contains(disposable))
{
_moduleDisposables.Add(disposable);
}
Comment on lines +275 to +278
// Retained for the env's lifetime, never freed here or by the finalizer: a late
// wrapped-object finalizer may still read a cleared slot via FromEnv after teardown
// (see FinalizeInstanceData), so freeing this block would risk a use-after-free.
instanceData = Marshal.AllocHGlobal(IntPtr.Size * InstanceDataSlotCount);
Comment on lines +200 to +202
_env = context.UncheckedEnvironmentHandle;
ThreadId = Environment.CurrentManagedThreadId;
Runtime = context.Runtime;
Comment on lines +207 to +213
// 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);
using JSValueScope scope = JSValueScope.CreateRuntimeScope(env, context);
@vmoroz
Vladimir Morozov (vmoroz) marked this pull request as draft August 28, 2026 16:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants