You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Port of DataDog/pprof-nodejs#388, which fixes this in the vendored copy.
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()
Create a few thousand ThreadContexts and exit normally and it aborts every
time, on a plain release build. Nothing below ~1000 instances reproduces
it — V8 has to still have some left to collect at teardown.
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. Without a replacement this would trade
an abort for a leak of every record still live at exit, since CtxWrap owns
its malloc'd record. Add the equivalent: a thread-local list of live
CtxWraps drained by a single per-isolate cleanup hook, registered from
Init() — module initialisation always runs with a context entered, so
AddEnvironmentCleanupHook is satisfied honestly, and Init() runs exactly
once per isolate, which is the lifetime the hook should match — 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.
The drain also clears the holder's internal field before freeing the
CtxWrap it points at. That slot is exactly what the out-of-process
OTEP-4947 reader walks to reach record_, so leaving it pointing at freed
memory aims a dangling pointer at a consumer we do not control. Being on
the live list means V8 has not collected the holder, so reading the handle
there is safe; the WeakCallback path cannot do this and does not need to,
since there the holder is the object being collected.
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, so offsetof on it is
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.
Taking over the internal-field access means handling the EmbedderDataTypeTag
that Node 26 requires on both the get and the set; the pair is kept together
so they cannot drift. This is new here — the ObjectWrap base was hiding the
version difference.
Verified on Node 22, 24 and 26: 48/48 tests pass on each, and the repro
goes from exit 134 to exit 0 at N=1000, 3000 and 10000. Confirmed the fix
is what does it by rebuilding the same tree with the original addon.cpp,
which still aborts with the CtxWrap::~CtxWrap stack above.
0 commit comments