Skip to content

Commit f21ae4b

Browse files
committed
Don't derive CtxWrap from node::ObjectWrap
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.
1 parent 0da50ea commit f21ae4b

3 files changed

Lines changed: 166 additions & 32 deletions

File tree

js/addon.cpp

Lines changed: 162 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
// finally the record it owns.
88

99
#include <node.h>
10-
#include <node_object_wrap.h>
1110
#include <v8-internal.h>
1211

1312
#include <stddef.h>
@@ -17,6 +16,7 @@
1716

1817
#include <atomic>
1918
#include <memory>
19+
#include <type_traits>
2020
#include <vector>
2121

2222
extern "C" {
@@ -75,7 +75,6 @@ static_assert(offsetof(otel_thread_ctx_nodejs_v1_t, undefined_addr) ==
7575
"undefined_addr must follow als_identity_hash + padding");
7676

7777
namespace otel_thread_ctx_nodejs {
78-
using node::ObjectWrap;
7978
using v8::Array;
8079
using v8::Context;
8180
using v8::Function;
@@ -136,18 +135,63 @@ constexpr size_t MIN_INITIAL_CAPACITY = 64 - sizeof(OtelThreadCtxRecord);
136135
// as best-effort.
137136
constexpr size_t MAX_ATTRS_DATA_SIZE = 640 - sizeof(OtelThreadCtxRecord);
138137

138+
// Read and write the embedder pointer stored in an object's internal field.
139+
// Node 26 requires an EmbedderDataTypeTag on both ends; keeping the pair
140+
// together means the getter and the setter cannot drift apart.
141+
inline void* GetAlignedPointerFromInternalField(Object* object, int index) {
142+
#if NODE_MAJOR_VERSION >= 26
143+
return object->GetAlignedPointerFromInternalField(
144+
index, v8::kEmbedderDataTypeTagDefault);
145+
#else
146+
return object->GetAlignedPointerFromInternalField(index);
147+
#endif
148+
}
149+
150+
inline void SetAlignedPointerInInternalField(Local<Object> object,
151+
int index,
152+
void* value) {
153+
#if NODE_MAJOR_VERSION >= 26
154+
object->SetAlignedPointerInInternalField(
155+
index, value, v8::kEmbedderDataTypeTagDefault);
156+
#else
157+
object->SetAlignedPointerInInternalField(index, value);
158+
#endif
159+
}
160+
139161
// Wraps a heap-allocated OtelThreadCtxRecord. Lifetime is managed by V8 GC:
140162
// when no JS code (or AsyncLocalStorage entry) holds a reference, the record
141163
// is freed.
142164
//
143165
// Layout note for the reader: `record_` is private to C++ but its byte
144166
// position within CtxWrap is part of the reader contract. It is the first
145-
// field after the node::ObjectWrap base subobject. `capacity_` sits after
167+
// field of the class, at offset zero. `capacity_` sits after
146168
// `record_` purely for the writer's own bookkeeping — the reader never
147169
// touches it.
148-
class CtxWrap : public ObjectWrap {
170+
//
171+
// Deliberately not a node::ObjectWrap. That base registers a per-instance
172+
// environment cleanup hook in its constructor and calls
173+
// RemoveEnvironmentCleanupHook from its destructor, which CHECKs that an
174+
// Environment is current:
175+
//
176+
// node[107]: void node::RemoveEnvironmentCleanupHook(...) hooks.cc:142
177+
// Assertion failed: (env) != nullptr
178+
// 3: otel_thread_ctx_nodejs::CtxWrap::~CtxWrap()
179+
//
180+
// A CtxWrap is owned by a weak V8 handle, so V8 chooses when it dies, and
181+
// weak callbacks run during isolate teardown with no context entered —
182+
// Environment::GetCurrent(isolate) returns null on `!isolate->InContext()`
183+
// alone — so the CHECK fires and aborts. Reproducible by creating a few
184+
// thousand ThreadContexts and exiting normally.
185+
//
186+
// The CHECK guards something real, so this must not be worked around by
187+
// skipping the removal: the Environment may well still be alive, and leaving
188+
// a hook behind whose arg is a freed pointer turns an abort into a
189+
// use-after-free at CleanupQueue::Drain. The fix is to never register the
190+
// per-instance hook, and to provide the teardown deletion it was giving us
191+
// (see g_live_ctx_wraps below).
192+
class CtxWrap {
149193
public:
150-
~CtxWrap() override;
194+
~CtxWrap();
151195
static void Init(Local<Object> exports);
152196

153197
CtxWrap(const CtxWrap&) = delete;
@@ -177,7 +221,13 @@ class CtxWrap : public ObjectWrap {
177221

178222
CtxWrap(OtelThreadCtxRecord* record, size_t capacity, bool truncated);
179223

180-
// The three fields are kept in one access section because C++ leaves
224+
// Attach to the holder JSObject: store `this` in internal field 0 and take
225+
// a weak handle on the holder, so V8 deletes us once it collects it.
226+
void Wrap(Local<Object> holder);
227+
static CtxWrap* Unwrap(Local<Object> holder);
228+
static void WeakCallback(const v8::WeakCallbackInfo<CtxWrap>& data);
229+
230+
// The fields are kept in one access section because C++ leaves
181231
// the relative layout of fields in different access controls
182232
// implementation-defined. `record_` must come first — its offset
183233
// within CtxWrap is part of the reader contract (see the
@@ -206,32 +256,109 @@ class CtxWrap : public ObjectWrap {
206256
// attrs_data_size write to shrink the record. We reject the reentrant
207257
// call instead.
208258
bool encoding_;
259+
// Intrusive doubly-linked list of the CtxWraps still alive on this thread,
260+
// threaded through g_live_ctx_wraps. `pprev_` is the address of the pointer
261+
// currently referencing us, so unlinking needs no head/non-head branch;
262+
// `pprev_ == nullptr` is the "already detached" sentinel set by the drain
263+
// hook before it deletes us.
264+
CtxWrap** pprev_;
265+
CtxWrap* next_;
266+
// Weak handle on the holder object; owns this CtxWrap.
267+
v8::Global<v8::Object> handle_;
209268
};
210269

211270
// Pin the offset of `record_` — the field the reader walks to from the
212-
// JSObject's internal field 0. We document it as "the first field after
213-
// the node::ObjectWrap base subobject", so equality with
214-
// sizeof(node::ObjectWrap) is the invariant. `offsetof` on a non-
215-
// standard-layout type (CtxWrap has private fields and inherits from
216-
// ObjectWrap) is conditionally supported per the standard but accepted
217-
// by every compiler this addon targets; suppress -Winvalid-offsetof so
218-
// the static_assert compiles cleanly under strict warning flags.
219-
#pragma GCC diagnostic push
220-
#pragma GCC diagnostic ignored "-Winvalid-offsetof"
221-
static_assert(offsetof(CtxWrap, record_) == sizeof(node::ObjectWrap),
222-
"record_ must be the first field after the ObjectWrap base "
223-
"subobject");
224-
#pragma GCC diagnostic pop
271+
// JSObject's internal field 0. With no base class it is simply the first
272+
// member, so the offset is zero and the published
273+
// `threadlocal.native_wrap_fields_offset` is computed from this rather than
274+
// from sizeof() of a foreign type we do not control.
275+
//
276+
// Dropping the ObjectWrap base also made CtxWrap standard-layout — no base
277+
// subobject, no virtuals, every data member in one access section — so
278+
// offsetof is unconditionally valid here and needs no -Winvalid-offsetof
279+
// suppression, unlike when it inherited.
280+
static_assert(std::is_standard_layout<CtxWrap>::value,
281+
"CtxWrap must stay standard-layout: the reader contract depends "
282+
"on offsetof(record_) being well-defined");
283+
static_assert(offsetof(CtxWrap, record_) == 0,
284+
"record_ must be the first field of CtxWrap");
285+
286+
// Head of the live-CtxWrap list for this thread. Node pins each isolate to a
287+
// thread, and CtxWraps are only ever constructed and destroyed on their own
288+
// isolate's thread, so a thread-local needs no lock.
289+
// `otel_thread_ctx_nodejs_v1` above is thread-local for the same reason.
290+
thread_local CtxWrap* g_live_ctx_wraps = nullptr;
291+
292+
// Delete every CtxWrap V8 has not collected yet. This is the teardown deletion
293+
// that node::ObjectWrap's per-instance cleanup hook used to provide; without it
294+
// the records would simply leak at exit. Registered once per isolate from
295+
// Init(), which runs at module initialisation with a context entered, so
296+
// AddEnvironmentCleanupHook's own CHECK is satisfied, and never removed — it
297+
// fires exactly once, at teardown, while the Environment is still alive.
298+
void DrainLiveCtxWraps(void* arg) {
299+
auto* isolate = static_cast<Isolate*>(arg);
300+
v8::HandleScope scope(isolate);
301+
CtxWrap* p = g_live_ctx_wraps;
302+
while (p != nullptr) {
303+
CtxWrap* next = p->next_;
304+
p->pprev_ = nullptr;
305+
p->next_ = nullptr;
306+
// Clear the holder's internal field before freeing what it points at, so
307+
// nothing can reach a dangling CtxWrap through it — including the
308+
// out-of-process reader, which walks exactly this slot. Being on the live
309+
// list means V8 has not collected the holder, so the handle is safe to
310+
// read here; the WeakCallback path cannot do this and does not need to,
311+
// since there the holder is the object being collected.
312+
if (!p->handle_.IsEmpty()) {
313+
SetAlignedPointerInInternalField(p->handle_.Get(isolate), 0, nullptr);
314+
}
315+
delete p;
316+
p = next;
317+
}
318+
g_live_ctx_wraps = nullptr;
319+
}
225320

226321
CtxWrap::~CtxWrap() {
322+
// pprev_ != nullptr means we are still on the live list, i.e. V8 collected
323+
// the holder and we got here from WeakCallback. If it is null the drain hook
324+
// is walking the list and has already detached us.
325+
if (pprev_ != nullptr) {
326+
*pprev_ = next_;
327+
if (next_ != nullptr) next_->pprev_ = pprev_;
328+
}
329+
// ~Global releases handle_. Arriving from WeakCallback, V8 requires the
330+
// callback to reset the handle; arriving from the drain hook, resetting
331+
// cancels a callback that would otherwise fire later.
227332
free(record_);
228333
}
229334

335+
void CtxWrap::WeakCallback(const v8::WeakCallbackInfo<CtxWrap>& data) {
336+
delete data.GetParameter();
337+
}
338+
339+
void CtxWrap::Wrap(Local<Object> holder) {
340+
Isolate* isolate = Isolate::GetCurrent();
341+
SetAlignedPointerInInternalField(holder, 0, this);
342+
handle_.Reset(isolate, holder);
343+
handle_.SetWeak(this, &WeakCallback, v8::WeakCallbackType::kParameter);
344+
next_ = g_live_ctx_wraps;
345+
pprev_ = &g_live_ctx_wraps;
346+
if (next_ != nullptr) next_->pprev_ = &next_;
347+
g_live_ctx_wraps = this;
348+
}
349+
350+
CtxWrap* CtxWrap::Unwrap(Local<Object> holder) {
351+
if (holder->InternalFieldCount() < 1) return nullptr;
352+
return static_cast<CtxWrap*>(GetAlignedPointerFromInternalField(*holder, 0));
353+
}
354+
230355
CtxWrap::CtxWrap(OtelThreadCtxRecord* record, size_t capacity, bool truncated)
231356
: record_(record),
232357
capacity_(capacity),
233358
truncated_(truncated),
234-
encoding_(false) {}
359+
encoding_(false),
360+
pprev_(nullptr),
361+
next_(nullptr) {}
235362

236363
// Copy exactly `expected_bytes` bytes out of a JS Uint8Array (or subclass such
237364
// as Buffer) into `out`. Returns false if the value isn't a Uint8Array or its
@@ -416,7 +543,7 @@ void CtxWrap::AppendAttributes(const FunctionCallbackInfo<Value>& args) {
416543
Isolate* isolate = args.GetIsolate();
417544
Local<Context> context = isolate->GetCurrentContext();
418545

419-
CtxWrap* self = ObjectWrap::Unwrap<CtxWrap>(args.This());
546+
CtxWrap* self = CtxWrap::Unwrap(args.This());
420547
if (!self) {
421548
isolate->ThrowError("not a ThreadContext");
422549
return;
@@ -527,7 +654,7 @@ void CtxWrap::AppendAttributes(const FunctionCallbackInfo<Value>& args) {
527654
// still exposing the finished span. Idempotent; safe to call multiple
528655
// times.
529656
void CtxWrap::Invalidate(const FunctionCallbackInfo<Value>& args) {
530-
CtxWrap* self = ObjectWrap::Unwrap<CtxWrap>(args.This());
657+
CtxWrap* self = CtxWrap::Unwrap(args.This());
531658
if (!self) {
532659
args.GetIsolate()->ThrowError("not a ThreadContext");
533660
return;
@@ -541,7 +668,7 @@ void CtxWrap::Invalidate(const FunctionCallbackInfo<Value>& args) {
541668
// CtxWrap::New() if the initial set didn't fit, or by any subsequent
542669
// CtxWrap::AppendAttributes() call.
543670
void CtxWrap::IsTruncated(const FunctionCallbackInfo<Value>& args) {
544-
CtxWrap* self = ObjectWrap::Unwrap<CtxWrap>(args.This());
671+
CtxWrap* self = CtxWrap::Unwrap(args.This());
545672
if (!self) {
546673
args.GetIsolate()->ThrowError("not a ThreadContext");
547674
return;
@@ -554,7 +681,7 @@ void CtxWrap::IsTruncated(const FunctionCallbackInfo<Value>& args) {
554681
// API; intended for tests and out-of-process-reader development.
555682
void CtxWrap::DebugBytes(const FunctionCallbackInfo<Value>& args) {
556683
Isolate* isolate = args.GetIsolate();
557-
CtxWrap* self = ObjectWrap::Unwrap<CtxWrap>(args.This());
684+
CtxWrap* self = CtxWrap::Unwrap(args.This());
558685
if (!self) {
559686
isolate->ThrowError("not a ThreadContext");
560687
return;
@@ -569,6 +696,11 @@ void CtxWrap::DebugBytes(const FunctionCallbackInfo<Value>& args) {
569696
void CtxWrap::Init(Local<Object> exports) {
570697
Isolate* isolate = Isolate::GetCurrent();
571698
Local<Context> context = isolate->GetCurrentContext();
699+
// One hook per isolate, registered here rather than lazily on first Wrap():
700+
// module initialisation always runs with a context entered, so
701+
// AddEnvironmentCleanupHook's CHECK is satisfied, and Init() runs exactly
702+
// once per isolate, which is what the hook's lifetime should match.
703+
node::AddEnvironmentCleanupHook(isolate, DrainLiveCtxWraps, isolate);
572704

573705
Local<FunctionTemplate> tpl = FunctionTemplate::New(isolate, New);
574706
tpl->SetClassName(String::NewFromUtf8Literal(isolate, "ThreadContext"));
@@ -691,13 +823,13 @@ constexpr int WRAPPED_OBJECT_OFFSET = 0;
691823
#endif
692824
constexpr int TAGGED_SIZE = v8::internal::kApiTaggedSize;
693825

694-
// sizeof(node::ObjectWrap). Given a pointer to a CtxWrap — or any other
695-
// ObjectWrap-derived C++ object attached to a JSObject via the V8
696-
// wrapped-object slot — add this offset to reach the derived class's own
697-
// fields. For CtxWrap, that's `record_` (see the static_assert on its
698-
// offset above).
826+
// Given a pointer to a CtxWrap — reached from the JSObject's V8
827+
// wrapped-object slot — add this offset to arrive at `record_`. CtxWrap has
828+
// no base class, so `record_` is its first member and the offset is zero;
829+
// computing it with offsetof keeps the published value correct if the layout
830+
// ever changes again.
699831
constexpr int NATIVE_WRAP_FIELDS_OFFSET =
700-
static_cast<int>(sizeof(node::ObjectWrap));
832+
static_cast<int>(offsetof(CtxWrap, record_));
701833

702834
// V8 JSMap layout: kTableOffset within the JSMap object holds a tagged
703835
// pointer to the backing OrderedHashMap table. Not exposed in V8's

js/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const SCHEMA_VERSION = 'nodejs_v1_dev';
99
// see consistent values.
1010
let WRAPPED_OBJECT_OFFSET = 24;
1111
let TAGGED_SIZE = 8;
12-
let NATIVE_WRAP_FIELDS_OFFSET = 24;
12+
let NATIVE_WRAP_FIELDS_OFFSET = 0;
1313
let JS_MAP_TABLE_OFFSET = 0x18;
1414
let ORDERED_HASH_MAP_HEADER_SIZE = 0x10;
1515

js/test/test.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,9 @@ test('getProcessContextAttributes returns the expected shape', () => {
359359
// compression, no sandbox) these are 24 and 8 respectively.
360360
assert.equal(pca['threadlocal.wrapped_object_offset'], 24);
361361
assert.equal(pca['threadlocal.tagged_size'], 8);
362-
assert.equal(pca['threadlocal.native_wrap_fields_offset'], 24);
362+
// Zero since CtxWrap dropped its node::ObjectWrap base: `record_` is
363+
// now the first member, so no base subobject to skip past.
364+
assert.equal(pca['threadlocal.native_wrap_fields_offset'], 0);
363365
assert.equal(pca['threadlocal.js_map_table_offset'], 0x18);
364366
assert.equal(pca['threadlocal.ordered_hash_map_header_size'], 0x10);
365367
assert.deepEqual(Object.keys(pca).sort(), [

0 commit comments

Comments
 (0)