Skip to content

Commit ba16dce

Browse files
committed
Free the env instance-data block at teardown; fix module-disposable dedup and failed managed-init leak
- FinalizeInstanceData frees the per-env instance-data block once the last context on the env is disposed, instead of retaining it for the process lifetime; a host context's disposal cascades synchronously to the other slot, and the block is not nulled via napi_set_instance_data (that would double-free Node's finalizer record). Updates runtime-model.md. - AddModuleDisposable deduplicates by reference identity rather than Equals, so equal-but-distinct IDisposable module instances are each disposed once. Adds a regression test. - ManagedHost disposes its JSRuntimeContext on the failed-initialization path so its instance-data GCHandle, synchronization context, and resolve handlers are released when no teardown handshake was established.
1 parent 65ec52e commit ba16dce

4 files changed

Lines changed: 85 additions & 19 deletions

File tree

‎docs/concepts/runtime-model.md‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,13 @@ contexts share an env. A runtime **reads and writes only its own slot**, so it n
7575
how callback dispatch and finalizers recover the context when no scope is yet current on the thread.
7676

7777
At environment teardown the instance-data finalizer disposes the owning context, which **clears its
78-
slot and frees the rooting `GCHandle`**. The instance-data block itself is intentionally *not*
79-
freed: a wrapped-object finalizer may still run afterward and call `FromEnv`, and reading a freed
80-
block would be a use-after-free — whereas reading a **cleared slot** simply resolves to "no context."
78+
slot and frees the rooting `GCHandle`**. Disposing a host context cascades synchronously to the
79+
other slot's context, so once every context on the env is gone the finalizer **frees the block**.
80+
Freeing it there is no less safe than keeping it: a finalizer that called `FromEnv` after the
81+
instance-data finalizer would already be reading Node's own freed finalizer record (Node does not
82+
null its instance-data pointer), so retaining the block never protected that case. The block is not
83+
nulled out via `napi_set_instance_data` — that would delete the finalizer record Node is running and
84+
then double-free it.
8185

8286
## JavaScript value scopes
8387

@@ -121,9 +125,9 @@ follows two rules:
121125
1. **Resolve the context from the env**, via `JSRuntimeContext.FromEnv(env)` — never by dereferencing
122126
a finalize hint that may already be freed. If `FromEnv` returns no live context (the slot was
123127
cleared at teardown), the finalizer only frees its own native handle and does no JS work.
124-
2. **Never assume ordering** between an individual wrapped-object finalizer and the instance-data
125-
finalizer. Node drains its pending finalizers without a guaranteed order relative to instance-data
126-
finalization, which is exactly why the context block is retained after its slot is cleared.
128+
2. **Never assume ordering** among the env's finalizers. Node drains wrapped-object finalizers in no
129+
guaranteed order, so a finalizer must tolerate the context's slot already being cleared (rule 1).
130+
The instance-data finalizer frees the block only after every context on the env is disposed.
127131

128132
## See also
129133

‎src/NodeApi.DotNetHost/ManagedHost.cs‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,17 @@ public static unsafe napi_value InitializeModule(
255255
catch (Exception ex)
256256
{
257257
Trace($"Failed to load CLR managed host module: {ex}");
258-
JSError.ThrowError(ex);
258+
try
259+
{
260+
JSError.ThrowError(ex);
261+
}
262+
finally
263+
{
264+
// Failed init: nothing else disposes this context (when hosted, the native host
265+
// never received a teardown callback), so dispose it here to release its slot
266+
// GCHandle, synchronization context, and resolve handlers.
267+
context.Dispose();
268+
}
259269
}
260270

261271
#if NETFRAMEWORK || NETSTANDARD

‎src/NodeApi/Interop/JSRuntimeContext.cs‎

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -272,9 +272,8 @@ private unsafe void RegisterInstanceData(napi_env env, JSRuntime runtime)
272272
runtime.GetInstanceData(env, out nint instanceData).ThrowIfFailed();
273273
if (instanceData == default)
274274
{
275-
// Retained for the env's lifetime, never freed here or by the finalizer: a late
276-
// wrapped-object finalizer may still read a cleared slot via FromEnv after teardown
277-
// (see FinalizeInstanceData), so freeing this block would risk a use-after-free.
275+
// One block per env, freed by FinalizeInstanceData when the last context on the env is
276+
// disposed at teardown.
278277
instanceData = Marshal.AllocHGlobal(IntPtr.Size * InstanceDataSlotCount);
279278
for (int i = 0; i < InstanceDataSlotCount; i++)
280279
{
@@ -306,9 +305,7 @@ private static unsafe void FinalizeInstanceData(napi_env env, nint data, nint hi
306305
// Runs during env teardown, where calling into JS is forbidden. Dispose the owning
307306
// runtime's context (which clears its slot and frees its rooting GCHandle). Only this
308307
// runtime's slot is read, never the other runtime's (whose GCHandle belongs to a separate
309-
// GC heap). The block is intentionally not freed: a wrapped-object finalizer may still run
310-
// after this and resolve the context via FromEnv, which reads this block; a freed block
311-
// would be a use-after-free, whereas a cleared slot safely resolves to no context.
308+
// GC heap).
312309
nint slotHandle = ((nint*)data)[s_instanceDataSlot];
313310
if (slotHandle != default)
314311
{
@@ -321,6 +318,20 @@ private static unsafe void FinalizeInstanceData(napi_env env, nint data, nint hi
321318
// A finalizer must never throw; teardown continues regardless.
322319
}
323320
}
321+
322+
// Free the shared block once the last context on the env is gone (all slots cleared);
323+
// disposing a host context cascades synchronously to the other slot. Do not null it out
324+
// via napi_set_instance_data: that deletes this very TrackedFinalizer, which Node then
325+
// deletes again (double free).
326+
for (int i = 0; i < InstanceDataSlotCount; i++)
327+
{
328+
if (((nint*)data)[i] != default)
329+
{
330+
return;
331+
}
332+
}
333+
334+
Marshal.FreeHGlobal(data);
324335
}
325336

326337
/// <summary>
@@ -912,10 +923,18 @@ internal void AddModuleDisposable(IDisposable disposable)
912923
{
913924
if (disposable is null) throw new ArgumentNullException(nameof(disposable));
914925
_moduleDisposables ??= new();
915-
if (!_moduleDisposables.Contains(disposable))
926+
927+
// Dedupe by identity, not Equals: a module class may override equality, but each distinct
928+
// instance must be disposed once.
929+
foreach (IDisposable existing in _moduleDisposables)
916930
{
917-
_moduleDisposables.Add(disposable);
931+
if (ReferenceEquals(existing, disposable))
932+
{
933+
return;
934+
}
918935
}
936+
937+
_moduleDisposables.Add(disposable);
919938
}
920939

921940
public void Dispose()
@@ -969,10 +988,9 @@ public void Dispose()
969988
}
970989
}
971990

972-
// Remove this context's root so it can be collected: clear its instance-data slot (a late
973-
// wrapped-object finalizer then resolves no context via FromEnv and frees only its own
974-
// handle) and free the rooting GCHandle. The block is left allocated (see
975-
// FinalizeInstanceData) so a late FromEnv reads a cleared slot rather than freed memory.
991+
// Remove this context's root so it can be collected: clear its instance-data slot (a
992+
// concurrent FromEnv then resolves no context) and free the rooting GCHandle. The shared
993+
// block itself is freed by FinalizeInstanceData once every context on the env is gone.
976994
if (ContextHandle != default)
977995
{
978996
Runtime.GetInstanceData(UncheckedEnvironmentHandle, out nint instanceData);

‎test/JSValueScopeTests.cs‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,40 @@ public void DisposableModulesShareContextAndAreDisposedOnceAtTeardown()
146146
Assert.Equal(1, moduleB.DisposeCount);
147147
}
148148

149+
private sealed class EqualDisposable : IDisposable
150+
{
151+
public int DisposeCount { get; private set; }
152+
153+
public void Dispose() => DisposeCount++;
154+
155+
// All instances compare equal, to prove module disposables dedupe by identity, not Equals.
156+
public override bool Equals(object? obj) => obj is EqualDisposable;
157+
158+
public override int GetHashCode() => 0;
159+
}
160+
161+
[Fact]
162+
public void ModuleDisposablesAreDedupedByIdentityNotEquality()
163+
{
164+
var moduleA = new EqualDisposable();
165+
var moduleB = new EqualDisposable();
166+
Assert.True(moduleA.Equals(moduleB));
167+
168+
napi_env env = new(Environment.CurrentManagedThreadId);
169+
var context = new JSRuntimeContext(
170+
env, _mockRuntime, new MockJSRuntime.SynchronizationContext());
171+
172+
context.AddModuleDisposable(moduleA);
173+
context.AddModuleDisposable(moduleB);
174+
context.AddModuleDisposable(moduleA); // Re-adding the same instance is a no-op.
175+
176+
context.Dispose();
177+
178+
// Both distinct instances are disposed once, despite comparing equal.
179+
Assert.Equal(1, moduleA.DisposeCount);
180+
Assert.Equal(1, moduleB.DisposeCount);
181+
}
182+
149183
[Fact]
150184
public void AccessValueFromClosedScope()
151185
{

0 commit comments

Comments
 (0)