Skip to content

fix(wall): don't derive PersistentContextPtr from node::ObjectWrap - #385

Merged
szegedi merged 2 commits into
mainfrom
szegedi/fix-pcp-teardown-cleanup-hook
Aug 7, 2026
Merged

fix(wall): don't derive PersistentContextPtr from node::ObjectWrap#385
szegedi merged 2 commits into
mainfrom
szegedi/fix-pcp-teardown-cleanup-hook

Conversation

@szegedi

@szegedi szegedi commented Aug 6, 2026

Copy link
Copy Markdown

The abort

node::ObjectWrap registers a per-instance environment cleanup hook in its constructor, and its destructor calls RemoveEnvironmentCleanupHook, which CHECKs that the Environment is still alive:

node[650]: void node::RemoveEnvironmentCleanupHook(v8::Isolate*, CleanupHook, void*) at ../src/api/hooks.cc:142
Assertion failed: (env) != nullptr
 3: node::RemoveEnvironmentCleanupHook(...)
 4: node::ObjectWrap::RemoveCleanupHook()
 5: node::ObjectWrap::~ObjectWrap()
 6: dd::PersistentContextPtr::~PersistentContextPtr()
 8: node::ObjectWrap::WeakCallback(...)

A PersistentContextPtr is owned by a weak V8 handle, so V8 decides when it dies — and V8 runs weak callbacks during isolate teardown, after the Environment is gone. The CHECK then aborts the process (SIGABRT, exit 134).

The fix

The wrapper only ever needed two things from the base class: the internal-field pointer that GetContextPtrSignalSafe reads, and a weak handle to hang the object's lifetime on. Neither requires a cleanup hook — ~WallProfiler already walks the live list and deletes any PCP V8 hasn't collected, which is what keeps LSAN quiet at exit.

So hold the weak Persistent directly and drop the base class. ~PersistentContextPtr resets the handle, which cancels the weak callback when ~WallProfiler is the one deleting, and is a no-op when we arrived from the callback itself.

Verification

Reproducible on unmodified main — ASAN perturbs GC timing enough to make it deterministic. It surfaces as an abort during teardown right after the Time Profiler tests, so mocha never prints its summary:

main this branch
npm run test:js-asan exit 134, aborted exit 0, 158 passing
LSAN leak reports 0
npm test exit 0, 158 passing

The zero leak count matters: it confirms ~WallProfiler really is sufficient and the per-instance cleanup hooks were not load-bearing.

Found while CI was red on #384; it is unrelated to that PR's heap-profiler change, which is why it is split out here.

Jira: PROF-15673

node::ObjectWrap registers a per-instance environment cleanup hook in
its constructor and calls RemoveEnvironmentCleanupHook from its
destructor. That teardown path CHECKs the Environment is still alive:

    node[650]: void node::RemoveEnvironmentCleanupHook(
        v8::Isolate*, CleanupHook, void*) at ../src/api/hooks.cc:142
    Assertion failed: (env) != nullptr
     3: node::RemoveEnvironmentCleanupHook(...)
     4: node::ObjectWrap::RemoveCleanupHook()
     5: node::ObjectWrap::~ObjectWrap()
     6: dd::PersistentContextPtr::~PersistentContextPtr()
     8: node::ObjectWrap::WeakCallback(...)

A PCP is owned by a weak V8 handle, so V8 decides when it dies — and V8
runs weak callbacks during isolate teardown, after the Environment is
gone. The CHECK then aborts the process with SIGABRT.

The wrapper only ever needed two things from the base class: the
internal-field pointer that GetContextPtrSignalSafe reads, and a weak
handle to hang the object's lifetime on. Neither needs a cleanup hook —
~WallProfiler already walks the live list and deletes any PCP V8 has not
collected, which is what keeps LSAN quiet at exit. So hold the weak
Persistent directly and drop the base class.

~PersistentContextPtr resets the handle, which cancels the weak callback
when ~WallProfiler is the one doing the deleting and is a no-op when we
arrived from the callback itself.

Reproduced on main under ASAN (which perturbs GC timing enough to make
it deterministic) as an abort during teardown after the Time Profiler
tests; the full ASAN suite goes from exit 134 to 158 passing with no
leaks reported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Overall package size

Self size: 2.44 MB
Deduped: 3.15 MB
No deduping: 3.15 MB

Dependency sizes | name | version | self size | total size | |------|---------|-----------|------------| | pprof-format | 2.3.0 | 503.97 kB | 503.97 kB | | source-map | 0.8.0 | 185.66 kB | 185.66 kB | | node-gyp-build | 4.8.4 | 13.86 kB | 13.86 kB |

🤖 This report was automatically generated by heaviest-objects-in-the-universe

@datadog-datadog-prod-us1-2

datadog-datadog-prod-us1-2 Bot commented Aug 6, 2026

Copy link
Copy Markdown

Pipelines  Tests

⚠️ Warnings

🚦 2 Pipeline jobs failed

Pull Request Labels | label   View in Datadog   GitHub Actions

🔄 Retry job. This looks flaky and may succeed on retry. Service Unavailable while trying to resolve action download info.

DataDog/apm-reliability/pprof-nodejs | benchmarks-pr-comment   View in Datadog   GitLab

ℹ️ Info

🔄 Datadog auto-retried 1 job - 0 passed on retry View in Datadog

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 5bf199a | Docs | Datadog PR Page | Give us feedback!

Node 26 requires an EmbedderDataTypeTag on
Object::SetAlignedPointerInInternalField:

    error: no matching function for call to
    'v8::Object::SetAlignedPointerInInternalField(int, dd::PersistentContextPtr*)'
    note: candidate: 'void v8::Object::SetAlignedPointerInInternalField(
        int, void*, v8::EmbedderDataTypeTag)'
    note:   candidate expects 3 arguments, 2 provided

node::ObjectWrap::Wrap hid this: its header handles the tag internally,
so taking over the store exposed the version difference. Add the setter
counterpart to the existing GetAlignedPointerFromInternalField helper and
use it, so both ends of the internal-field access agree on
kEmbedderDataTypeTagDefault.

Verified on Node 20, 24 and 26 (the last is where AsyncContextFrame is on
by default, so it actually exercises the PCP path): builds clean, 158
passing, ASAN exit 0 with no leaks or aborts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@szegedi szegedi added the semver-patch Bug or security fixes, mainly label Aug 6, 2026
@nsavoire

nsavoire commented Aug 6, 2026

Copy link
Copy Markdown

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: 5bf199a301

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@szegedi
szegedi enabled auto-merge (squash) August 7, 2026 06:59
szegedi added a commit that referenced this pull request Aug 7, 2026
Addresses review feedback on #384.

Check GetAllocationProfile for null before dereferencing it. It returns
null when V8's sampling heap profiler isn't running, and that is reachable
with this callback still installed: HeapProfilerCleanupHook stops V8's
sampler without touching our state, so between that hook running and the
isolate going away we stay registered with nothing to sample. The
heap-limit bookkeeping still has to happen in that case, so only the
profile-dependent work is skipped.

Also remove the null-state check this branch had added to NearHeapLimit.
Its justification was simply wrong: it claimed StopSamplingHeapProfiler
could not uninstall the callback, but resetting the state shared_ptr
destroys HeapProfilerState, whose destructor calls
UninstallNearHeapLimitCallback. The callback cannot fire after the state
is gone, so the check was dead code resting on a false premise.

The one hole in that argument was ordering inside ~HeapProfilerState: it
called V8's StopSamplingHeapProfiler before uninstalling, and by then the
shared_ptr in PerIsolateData is already empty, so a GC in that window
would have reached NearHeapLimit with no state. Fixed at the source by
uninstalling first, which is where the invariant belongs.

Node 20 ASAN: exit 0, 99 passing, no leaks, both OOM tests green. Node 24
still aborts on the pre-existing ~PersistentContextPtr teardown CHECK
(#385), unrelated to this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@szegedi
szegedi merged commit f66e514 into main Aug 7, 2026
198 of 280 checks passed
@szegedi
szegedi deleted the szegedi/fix-pcp-teardown-cleanup-hook branch August 7, 2026 07:50
return object->GetAlignedPointerFromInternalField(index);
#endif
// Cancels the weak callback when we're deleted by ~WallProfiler rather than
// by V8; a no-op when we got here from WeakCallback itself. The holder

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: "a no-op when we got here from WeakCallback itself" is misleading -> v8 docs state that the callback is supposed to reset the handle.


// Weak handle on the holder object. Owns this PCP: when V8 collects the
// holder, WeakCallback deletes us.
v8::Persistent<v8::Object> handle_;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: not sure if this is relevant but v8 docs warn about using Persistent and suggest using Global instead:

CAVEAT: Persistent objects do not have proper destruction behavior by default and as such will leak the object without explicit clear. Consider using v8::Global instead which has proper destruction and move semantics.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

dang, I actually knew this. Thanks, I'll look into it. It might be okay here as we're specifically only using this as a weak handle so it doesn't leak the object IMO.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Okay, so I think we are actually okay here as we're explicitly used Persistent here anyway for very long time before 7bf5cc6 (the class name is kind of a clue :-) ).

Still, we could idiomatically switch to Global since we're using that everywhere else anyway, and then we don't need to call handle_.Reset() anymore in the PersistentContextPtr destructor.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

okay, switched to Global in #387

szegedi added a commit that referenced this pull request Aug 7, 2026
Review follow-up on #385.

v8::Persistent has no destruction behaviour: the handle leaks unless every
path clears it by hand. v8::Global releases it in its own destructor, and
is what every other handle in this file already uses — ContextPtr,
cpedKey_, wrapObjectTemplate_, jsArray_. The Persistent introduced in #385
was the odd one out.

Nothing was leaking in practice, since ~PersistentContextPtr always reset
the handle explicitly, but relying on that is exactly the footgun the V8
docs warn about. Switching to Global makes the release structural, so the
explicit Reset goes away with it.

Historically the manual handle was justified: before #261 removed instance
reuse, PersistentContextPtr recycled itself through a freelist and needed
ClearWeak/Reset to unregister and re-register the same object. With reuse
gone a handle lives exactly as long as its PCP, so there is nothing left
for Persistent's manual semantics to buy.

Also correct the destructor comment. It claimed the reset was "a no-op
when we got here from WeakCallback itself", which is backwards: V8
requires a weak callback to reset the handle, so that path is precisely
where the release is load-bearing.

Verified on Node 20, 24 and 26 — the last is where AsyncContextFrame is on
by default and PCPs are actually created. 163 passing, ASAN exit 0 with no
leaks and no aborts on 20 and 24.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Would using nan::ObjectWrap instead of node::ObjectWrap have solved the issue ?
Can the same issue occur for CtxWrap ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I kinda don't want to rely too much on nan, I honestly feel a bit more comfortable handling this on my own. I'll look into CtxWrap. My gut feeling is that it's okay because we aren't doing live instance tracking like WallProfiler does but I'll unleash Claude on it to confirm. FWIW it feels dodgy to me that node::ObjectWrap would be a public API if it had this dangerous of an edge case.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

huh, interesting. I can reproduce this with CtxWrap too on current main 😢:

node[339]: void node::RemoveEnvironmentCleanupHook(v8::Isolate*, CleanupHook, void*) at ../src/api/hooks.cc:142
Assertion failed: (env) != nullptr
 2: node::RemoveEnvironmentCleanupHook(...)
 3: [/src/build/Release/dd_pprof.node]     ← ~CtxWrap → ~ObjectWrap → RemoveCleanupHook
 4-8: [node]                               ← GC / weak callback

This happens deterministically by creating 3000 ThreadContexts, some in ALS, some abandoned.
There's 3 GC contexts in which the path can be invoked:

  • collected during ordinary GC: a context is entered; it's fine
  • still alive at env teardown: deleted by ObjectWrap's own cleanup hook, while the env is alive; also fine
  • the problem is the third: objects that become garbage late and in bulk, so V8 only collects them during the teardown GC, after the Environment is gone. That needs enough garbage to provoke that GC at all hence it appears at around 3000 objects on my machine.

I was wondering why this doesn't come out for thousands of other addons that use ObjectWrap, but I guess most of them wrap a handful of long-lived objects and never enter this situation where we create thousands and thousands of objects.

Both PCP and CtxWrap have exactly this unusual profile: many short-lived instances whose lifetime is tied to async contexts, retained in a CPED maps until the very end of execution.

Now I have been digging into this deeper, basically why does node::ObjectWrap need that CHECK at all, and it's because it registers per-instance hooks to guarantee that the C++ object is deleted at env teardown even if V8 never collects it. With PCP we replaced that guarantee with ~WallProfiler's list walk over liveContextPtrHead_.

We don't have anything similar for CtxWrap at the moment, but we do have a PerIsolateData environment cleanup hook:

per_isolate_data_.emplace(...);
node::AddEnvironmentCleanupHook(isolate,
    [](void* data) { per_isolate_data_.erase(static_cast<Isolate*>(data)); },
    isolate);

where we could add this teardown, but then CtxWrap class also needs to become an intrusive linked list; I think I'm fine with that. Some things would actually become better :-) like, if we remove ObjectWrap as ancestor, its virtual destructor goes away, so instances no longer have a vptr and we can get rid of the -Winvalid-offsetof pragma. Even better, NATIVE_WRAP_FIELDS_OFFSET changes from sizeof(node::ObjectWrap) which is a foreign type we don't control to offsetof(CtxWrap, record_), which becomes 0 and stays correct through any future layout change.

I'd say let's merge #387 and then I'll work on this separately.

szegedi added a commit that referenced this pull request Aug 7, 2026
Review follow-up on #385.

v8::Persistent has no destruction behaviour: the handle leaks unless every
path clears it by hand. v8::Global releases it in its own destructor, and
is what every other handle in this file already uses — ContextPtr,
cpedKey_, wrapObjectTemplate_, jsArray_. The Persistent introduced in #385
was the odd one out.

Nothing was leaking in practice, since ~PersistentContextPtr always reset
the handle explicitly, but relying on that is exactly the footgun the V8
docs warn about. Switching to Global makes the release structural, so the
explicit Reset goes away with it.

Historically the manual handle was justified: before #261 removed instance
reuse, PersistentContextPtr recycled itself through a freelist and needed
ClearWeak/Reset to unregister and re-register the same object. With reuse
gone a handle lives exactly as long as its PCP, so there is nothing left
for Persistent's manual semantics to buy.

Verified on Node 20, 24 and 26 — the last is where AsyncContextFrame is on
by default and PCPs are actually created. 163 passing, ASAN exit 0 with no
leaks and no aborts on 20 and 24.
szegedi added a commit that referenced this pull request Aug 7, 2026
CtxWrap has the same defect #385 fixed in the wall profiler's
PersistentContextPtr. node::ObjectWrap registers a per-instance
environment cleanup hook in its constructor and calls
RemoveEnvironmentCleanupHook from its destructor, which CHECKs that an
Environment is current. A CtxWrap is owned by a weak V8 handle, so V8
picks the moment it dies, and weak callbacks run during isolate teardown
with no context entered:

    Assertion failed: (env) != nullptr
     2: node::RemoveEnvironmentCleanupHook(...)
     3: otel_thread_ctx_nodejs::CtxWrap::~CtxWrap()

This one is not subtle: create a few thousand ThreadContexts and exit
normally and it aborts every time, on a plain release build. No ASAN
needed, unlike the PCP case. Nothing below ~1000 instances reproduces it
— V8 has to still have some left to collect at teardown.

Note the CHECK guards something real, so it must not be worked around by
skipping the removal. Environment::GetCurrent(isolate) returns null on
`!isolate->InContext()` alone, so the Environment may well still be
alive; leaving a hook behind whose arg is a freed pointer would turn the
abort into a use-after-free when CleanupQueue::Drain later calls it. The
fix is to not register the per-instance hook at all.

Dropping the base loses what that hook provided: deletion at teardown
even when V8 never collects the object. PCP could rely on ~WallProfiler
walking its live list; CtxWrap has no such owner and owns a malloc'd
record, so without a replacement this would trade an abort for a leak.
Add the equivalent: a thread-local list of live CtxWraps drained by a
single per-isolate cleanup hook, registered from Wrap() — inside a JS
constructor call, where a context is entered, so AddEnvironmentCleanupHook
is satisfied honestly — and never removed, since it fires once at
teardown while the Environment is alive. One hook per isolate instead of
one per instance, with removal timing we control rather than V8.

With no base class, `record_` becomes CtxWrap's first member, so the
published threadlocal.native_wrap_fields_offset goes from 24 to 0 and is
now computed with offsetof rather than sizeof() of a foreign type. That
is a reader-contract change, made now because no readers exist yet.

Losing the base also makes CtxWrap standard-layout — no base subobject,
no virtuals, all data members in one access section — so offsetof on it
is now unconditionally valid and the two -Winvalid-offsetof suppressions
the inheriting version needed are gone. A static_assert on
is_standard_layout keeps it that way, since the reader contract depends
on offsetof(record_) being well-defined.

The two internal-field accessors move to a new internal-field.hh: Node 26
requires an EmbedderDataTypeTag on both the get and the set, and having
the pair in one place stops them drifting when only one is exercised on
the version you build against. wall.cc keeps its own copies for now to
avoid conflicting with in-flight work there; folding those in is a
follow-up.

Verified on Node 20, 24 and 26, with both clang and gcc. New regression
test fails with signal=SIGABRT against the pre-fix binding and passes
after; ASAN exit 0 with zero leaks on 20 and 24, which is the check that
the drain hook really does replace what ObjectWrap was doing.
szegedi added a commit that referenced this pull request Aug 7, 2026
Addresses review feedback on #384.

Check GetAllocationProfile for null before dereferencing it. It returns
null when V8's sampling heap profiler isn't running, and that is reachable
with this callback still installed: HeapProfilerCleanupHook stops V8's
sampler without touching our state, so between that hook running and the
isolate going away we stay registered with nothing to sample. The
heap-limit bookkeeping still has to happen in that case, so only the
profile-dependent work is skipped.

Also remove the null-state check this branch had added to NearHeapLimit.
Its justification was simply wrong: it claimed StopSamplingHeapProfiler
could not uninstall the callback, but resetting the state shared_ptr
destroys HeapProfilerState, whose destructor calls
UninstallNearHeapLimitCallback. The callback cannot fire after the state
is gone, so the check was dead code resting on a false premise.

The one hole in that argument was ordering inside ~HeapProfilerState: it
called V8's StopSamplingHeapProfiler before uninstalling, and by then the
shared_ptr in PerIsolateData is already empty, so a GC in that window
would have reached NearHeapLimit with no state. Fixed at the source by
uninstalling first, which is where the invariant belongs.

Node 20 ASAN: exit 0, 99 passing, no leaks, both OOM tests green. Node 24
still aborts on the pre-existing ~PersistentContextPtr teardown CHECK
(#385), unrelated to this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
szegedi added a commit that referenced this pull request Aug 7, 2026
CtxWrap has the same defect #385 fixed in the wall profiler's
PersistentContextPtr. node::ObjectWrap registers a per-instance
environment cleanup hook in its constructor and calls
RemoveEnvironmentCleanupHook from its destructor, which CHECKs that an
Environment is current. A CtxWrap is owned by a weak V8 handle, so V8
picks the moment it dies, and weak callbacks run during isolate teardown
with no context entered:

    Assertion failed: (env) != nullptr
     2: node::RemoveEnvironmentCleanupHook(...)
     3: otel_thread_ctx_nodejs::CtxWrap::~CtxWrap()

This one is not subtle: create a few thousand ThreadContexts and exit
normally and it aborts every time, on a plain release build. No ASAN
needed, unlike the PCP case. Nothing below ~1000 instances reproduces it
— V8 has to still have some left to collect at teardown.

Note the CHECK guards something real, so it must not be worked around by
skipping the removal. Environment::GetCurrent(isolate) returns null on
`!isolate->InContext()` alone, so the Environment may well still be
alive; leaving a hook behind whose arg is a freed pointer would turn the
abort into a use-after-free when CleanupQueue::Drain later calls it. The
fix is to not register the per-instance hook at all.

Dropping the base loses what that hook provided: deletion at teardown
even when V8 never collects the object. PCP could rely on ~WallProfiler
walking its live list; CtxWrap has no such owner and owns a malloc'd
record, so without a replacement this would trade an abort for a leak.
Add the equivalent: a thread-local list of live CtxWraps drained by a
single per-isolate cleanup hook, registered from Wrap() — inside a JS
constructor call, where a context is entered, so AddEnvironmentCleanupHook
is satisfied honestly — and never removed, since it fires once at
teardown while the Environment is alive. One hook per isolate instead of
one per instance, with removal timing we control rather than V8.

With no base class, `record_` becomes CtxWrap's first member, so the
published threadlocal.native_wrap_fields_offset goes from 24 to 0 and is
now computed with offsetof rather than sizeof() of a foreign type. That
is a reader-contract change, made now because no readers exist yet.

Losing the base also makes CtxWrap standard-layout — no base subobject,
no virtuals, all data members in one access section — so offsetof on it
is now unconditionally valid and the two -Winvalid-offsetof suppressions
the inheriting version needed are gone. A static_assert on
is_standard_layout keeps it that way, since the reader contract depends
on offsetof(record_) being well-defined.

The two internal-field accessors move to a new internal-field.hh: Node 26
requires an EmbedderDataTypeTag on both the get and the set, and having
the pair in one place stops them drifting when only one is exercised on
the version you build against. wall.cc keeps its own copies for now to
avoid conflicting with in-flight work there; folding those in is a
follow-up.

Verified on Node 20, 24 and 26, with both clang and gcc. New regression
test fails with signal=SIGABRT against the pre-fix binding and passes
after; ASAN exit 0 with zero leaks on 20 and 24, which is the check that
the drain hook really does replace what ObjectWrap was doing.
szegedi added a commit that referenced this pull request Aug 7, 2026
CtxWrap has the same defect #385 fixed in the wall profiler's
PersistentContextPtr. node::ObjectWrap registers a per-instance
environment cleanup hook in its constructor and calls
RemoveEnvironmentCleanupHook from its destructor, which CHECKs that an
Environment is current. A CtxWrap is owned by a weak V8 handle, so V8
picks the moment it dies, and weak callbacks run during isolate teardown
with no context entered:

    Assertion failed: (env) != nullptr
     2: node::RemoveEnvironmentCleanupHook(...)
     3: otel_thread_ctx_nodejs::CtxWrap::~CtxWrap()

This one is not subtle: create a few thousand ThreadContexts and exit
normally and it aborts every time, on a plain release build. No ASAN
needed, unlike the PCP case. Nothing below ~1000 instances reproduces it
— V8 has to still have some left to collect at teardown.

Note the CHECK guards something real, so it must not be worked around by
skipping the removal. Environment::GetCurrent(isolate) returns null on
`!isolate->InContext()` alone, so the Environment may well still be
alive; leaving a hook behind whose arg is a freed pointer would turn the
abort into a use-after-free when CleanupQueue::Drain later calls it. The
fix is to not register the per-instance hook at all.

Dropping the base loses what that hook provided: deletion at teardown
even when V8 never collects the object. PCP could rely on ~WallProfiler
walking its live list; CtxWrap has no such owner and owns a malloc'd
record, so without a replacement this would trade an abort for a leak.
Add the equivalent: a thread-local list of live CtxWraps drained by a
single per-isolate cleanup hook, registered from Wrap() — inside a JS
constructor call, where a context is entered, so AddEnvironmentCleanupHook
is satisfied honestly — and never removed, since it fires once at
teardown while the Environment is alive. One hook per isolate instead of
one per instance, with removal timing we control rather than V8.

With no base class, `record_` becomes CtxWrap's first member, so the
published threadlocal.native_wrap_fields_offset goes from 24 to 0 and is
now computed with offsetof rather than sizeof() of a foreign type. That
is a reader-contract change, made now because no readers exist yet.

Losing the base also makes CtxWrap standard-layout — no base subobject,
no virtuals, all data members in one access section — so offsetof on it
is now unconditionally valid and the two -Winvalid-offsetof suppressions
the inheriting version needed are gone. A static_assert on
is_standard_layout keeps it that way, since the reader contract depends
on offsetof(record_) being well-defined.

The two internal-field accessors move to a new internal-field.hh: Node 26
requires an EmbedderDataTypeTag on both the get and the set, and having
the pair in one place stops them drifting when only one is exercised on
the version you build against. wall.cc keeps its own copies for now to
avoid conflicting with in-flight work there; folding those in is a
follow-up.

Verified on Node 20, 24 and 26, with both clang and gcc. New regression
test fails with signal=SIGABRT against the pre-fix binding and passes
after; ASAN exit 0 with zero leaks on 20 and 24, which is the check that
the drain hook really does replace what ObjectWrap was doing.
szegedi added a commit that referenced this pull request Aug 7, 2026
* fix(heap): don't assume a per-isolate state exists

GetAllocationProfile and MapAllocationProfile dereference the
per-isolate HeapProfilerState after only checking that V8 returned a
profile:

    auto& state = PerIsolateData::For(isolate)->GetHeapProfilerState();
    std::unique_ptr<v8::AllocationProfile> profile(
        isolate->GetHeapProfiler()->GetAllocationProfile());
    if (!profile) {
      return Nan::ThrowError("Heap profiler is not enabled.");
    }
    const bool allocations = state->allocations;   // <- state may be null

A non-null profile only proves V8's sampling heap profiler is running.
It does not prove we started it: anything else in the process can enable
it out of band — the inspector's HeapProfiler.startSampling, DevTools, a
second agent — and only our own StartSamplingHeapProfiler creates the
state. In that case the guard passes and we dereference an empty
shared_ptr, which segfaults.

Both call sites were null-checked until 5.15.0, when MonitorOutOfMemory
switched from unconditionally replacing the state to reusing an existing
one. That made "state already exists" the normal case and the checks
were dropped along the way — MapAllocationProfile still null-checks
`state` one line above the unguarded OnNewProfile() call.

Restore the checks, keeping the pre-5.15.0 behaviour of serving the
profile without allocation stats rather than throwing: V8's profiler
really is enabled, so "Heap profiler is not enabled." would be wrong.

Fix two pre-existing instances of the same assumption while here, both
reachable because StopSamplingHeapProfiler() resets the state:

  - NearHeapLimit ran `state->insideCallback` unguarded. The state that
    recorded the callback's installation is the one that was dropped, so
    nothing could uninstall it. Remove the callback and leave the heap
    limit alone so V8 does its normal OOM handling.
  - InterruptCallback is requested from NearHeapLimit but runs later, so
    the state can disappear in between.

The regression test forks a child process, since the failure mode is a
SIGSEGV that would otherwise take the whole mocha run down with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(heap): keep the forked child out of LeakSanitizer's reach

Under the asan CI job the forked child inherits LD_PRELOAD=libasan and
LSAN_OPTIONS, so LeakSanitizer runs when it exits. The child ends via
process.exit(), which skips V8 heap teardown, so every live object is
reported as leaked and the child exits non-zero — failing the test for a
reason unrelated to what it checks. Seen on asan (20):

    1) foreign heap sampler
         should not crash when V8 heap sampling was enabled outside of
         pprof:
       Error: heap-foreign-sampler exited with code=1 signal=null

Pass LSAN_OPTIONS=detect_leaks=0 to the child. ASAN itself stays active,
so a real memory error in the code under test is still caught; only the
exit-time leak sweep is suppressed, and only for this child.

Two things made this harder to diagnose than it should have been, both
fixed here:

  - The failure message came through empty because the promise settled
    on 'exit', which can fire before the piped stdio has drained. Settle
    on 'close' instead, so the captured output is complete.
  - Drop the retained allocation from 200k objects to 20k and keep it
    function-scoped rather than parking it on globalThis. The profile
    only needs a non-empty sample set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(heap): guard NearHeapLimit's profile, drop its bogus state check

Addresses review feedback on #384.

Check GetAllocationProfile for null before dereferencing it. It returns
null when V8's sampling heap profiler isn't running, and that is reachable
with this callback still installed: HeapProfilerCleanupHook stops V8's
sampler without touching our state, so between that hook running and the
isolate going away we stay registered with nothing to sample. The
heap-limit bookkeeping still has to happen in that case, so only the
profile-dependent work is skipped.

Also remove the null-state check this branch had added to NearHeapLimit.
Its justification was simply wrong: it claimed StopSamplingHeapProfiler
could not uninstall the callback, but resetting the state shared_ptr
destroys HeapProfilerState, whose destructor calls
UninstallNearHeapLimitCallback. The callback cannot fire after the state
is gone, so the check was dead code resting on a false premise.

The one hole in that argument was ordering inside ~HeapProfilerState: it
called V8's StopSamplingHeapProfiler before uninstalling, and by then the
shared_ptr in PerIsolateData is already empty, so a GC in that window
would have reached NearHeapLimit with no state. Fixed at the source by
uninstalling first, which is where the invariant belongs.

Node 20 ASAN: exit 0, 99 passing, no leaks, both OOM tests green. Node 24
still aborts on the pre-existing ~PersistentContextPtr teardown CHECK
(#385), unrelated to this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(heap): uninstall the near-heap-limit callback before dropping the state

Two review nits from #384.

StopSamplingHeapProfiler relied on ~HeapProfilerState to uninstall the
near-heap-limit callback, but reset() only destroys the state when it holds
the last reference — and it need not. Both NearHeapLimit and
InterruptCallback take a shared_ptr copy for the duration of the call, so a
stop() reached from inside one of them (the near-heap-limit JS callback
calling heapProfiler.stop(), say) leaves the state alive, the destructor
unrun, and the callback still registered with V8 while the per-isolate slot
is already empty. The next near-heap-limit GC would then enter
NearHeapLimit with no state at all — exactly the crash this branch is
about. Uninstall explicitly instead; it is idempotent, clearing
callbackInstalled.

Also keep clearing state->profile when GetAllocationProfile returns null.
Any profile retained from an earlier invocation is stale at that point and
nothing below is going to consume or replace it.

* fix(heap): re-add NearHeapLimit's null-state guard, with a real reason

The version of this check removed earlier on this branch rested on a false
premise — that StopSamplingHeapProfiler could not uninstall the callback —
and deserved to go. There is a genuine reason for it, though, which only
became apparent from the shared_ptr-copy problem in the previous commit.

StopSamplingHeapProfiler now uninstalls before dropping the state, so that
path is covered. The other destruction path is not: a shared_ptr copy taken
by an in-flight NearHeapLimit or InterruptCallback can outlive the
per-isolate slot. If the OOM JS callback calls process.exit(),
PerIsolateData is erased while InterruptCallback still holds a reference,
~HeapProfilerState never runs, and the callback stays registered with an
empty slot behind it. A teardown GC reaching the heap limit then enters
NearHeapLimit with no state and dereferences null.

Decline and let V8 do its normal OOM handling. Deliberately no
RemoveNearHeapLimitCallback: the state that tracked the installation is
already unreachable, so callbackInstalled cannot be cleared, and the only
way to reach this is a process on its way out.

Kept as its own commit because it partially reverses a change made earlier
on this branch, and because it is defence in depth rather than a fix for
anything reproducible — the trigger needs process.exit() from inside the OOM
callback plus a teardown GC that hits the limit, which I could not turn into
a non-flaky test. The branch it adds is therefore uncovered.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

semver-patch Bug or security fixes, mainly

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants