Skip to content

Commit 5281a2b

Browse files
committed
Don't derive CtxWrap from node::ObjectWrap
Don't derive CtxWrap from node::ObjectWrap as it has a known bug in interaction with GC when numerous instances (>1000) are created and aborts the process during isolate teardown. This was historically not much of an issue when only few instances were created by add-ons but with the advent of AsyncContextFrame now we can indeed have thousands of objects being created.
1 parent 604a480 commit 5281a2b

3 files changed

Lines changed: 126 additions & 32 deletions

File tree

js/addon.cpp

Lines changed: 124 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,43 @@ 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+
inline void* GetAlignedPointerFromInternalField(Object* object, int index) {
140+
#if NODE_MAJOR_VERSION >= 26
141+
return object->GetAlignedPointerFromInternalField(
142+
index, v8::kEmbedderDataTypeTagDefault);
143+
#else
144+
return object->GetAlignedPointerFromInternalField(index);
145+
#endif
146+
}
147+
148+
inline void SetAlignedPointerInInternalField(Local<Object> object,
149+
int index,
150+
void* value) {
151+
#if NODE_MAJOR_VERSION >= 26
152+
object->SetAlignedPointerInInternalField(
153+
index, value, v8::kEmbedderDataTypeTagDefault);
154+
#else
155+
object->SetAlignedPointerInInternalField(index, value);
156+
#endif
157+
}
158+
139159
// Wraps a heap-allocated OtelThreadCtxRecord. Lifetime is managed by V8 GC:
140160
// when no JS code (or AsyncLocalStorage entry) holds a reference, the record
141161
// is freed.
142162
//
143163
// Layout note for the reader: `record_` is private to C++ but its byte
144164
// position within CtxWrap is part of the reader contract. It is the first
145-
// field after the node::ObjectWrap base subobject. `capacity_` sits after
165+
// field of the class, at offset zero. `capacity_` sits after
146166
// `record_` purely for the writer's own bookkeeping — the reader never
147167
// touches it.
148-
class CtxWrap : public ObjectWrap {
168+
//
169+
// Deliberately not a node::ObjectWrap as it has a known bug in interaction
170+
// with GC when numerous instances are created and can abort the process during
171+
// isolate teardown. Instances live at shutdown are deleted using DrainLiveCtxWraps.
172+
class CtxWrap {
149173
public:
150-
~CtxWrap() override;
174+
~CtxWrap();
151175
static void Init(Local<Object> exports);
152176

153177
CtxWrap(const CtxWrap&) = delete;
@@ -177,7 +201,13 @@ class CtxWrap : public ObjectWrap {
177201

178202
CtxWrap(OtelThreadCtxRecord* record, size_t capacity, bool truncated);
179203

180-
// The three fields are kept in one access section because C++ leaves
204+
// Attach to the holder JSObject: store `this` in internal field 0 and take
205+
// a weak handle on the holder, so V8 deletes us once it collects it.
206+
void Wrap(Local<Object> holder);
207+
static CtxWrap* Unwrap(Local<Object> holder);
208+
static void WeakCallback(const v8::WeakCallbackInfo<CtxWrap>& data);
209+
210+
// The fields are kept in one access section because C++ leaves
181211
// the relative layout of fields in different access controls
182212
// implementation-defined. `record_` must come first — its offset
183213
// within CtxWrap is part of the reader contract (see the
@@ -206,32 +236,95 @@ class CtxWrap : public ObjectWrap {
206236
// attrs_data_size write to shrink the record. We reject the reentrant
207237
// call instead.
208238
bool encoding_;
239+
// Intrusive doubly-linked list of the CtxWraps still alive on this thread,
240+
// threaded through g_live_ctx_wraps. `pprev_` is the address of the pointer
241+
// currently referencing us, so unlinking needs no head/non-head branch;
242+
// `pprev_ == nullptr` is the "already detached" sentinel set by the drain
243+
// hook before it deletes us.
244+
CtxWrap** pprev_;
245+
CtxWrap* next_;
246+
// Weak handle on the holder object; owns this CtxWrap.
247+
v8::Global<v8::Object> handle_;
209248
};
210249

211250
// 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
251+
// JSObject's internal field 0. With no base class it is simply the first
252+
// member, so the offset is zero and the published
253+
// `threadlocal.native_wrap_fields_offset` is computed from this.
254+
static_assert(std::is_standard_layout<CtxWrap>::value,
255+
"CtxWrap must stay standard-layout: the reader contract depends "
256+
"on offsetof(record_) being well-defined");
257+
static_assert(offsetof(CtxWrap, record_) == 0,
258+
"record_ must be the first field of CtxWrap");
259+
260+
// Head of the live-CtxWrap list for this thread. Node pins each isolate to a
261+
// thread, and CtxWraps are only ever constructed and destroyed on their own
262+
// isolate's thread, so a thread-local needs no lock.
263+
thread_local CtxWrap* g_live_ctx_wraps = nullptr;
264+
265+
// Delete every CtxWrap V8 has not collected yet. Registered once per isolate
266+
// from Init() as an environment shutdown hook.
267+
void DrainLiveCtxWraps(void* arg) {
268+
auto* isolate = static_cast<Isolate*>(arg);
269+
v8::HandleScope scope(isolate);
270+
CtxWrap* p = g_live_ctx_wraps;
271+
while (p != nullptr) {
272+
CtxWrap* next = p->next_;
273+
p->pprev_ = nullptr;
274+
p->next_ = nullptr;
275+
// Clear the holder's internal field before freeing what it points at, so
276+
// nothing can reach a dangling CtxWrap through it — including the
277+
// out-of-process reader, which walks exactly this slot. Being on the live
278+
// list means V8 has not collected the holder, so the handle is safe to
279+
// read here; the WeakCallback path cannot do this and does not need to,
280+
// since there the holder is the object being collected.
281+
if (!p->handle_.IsEmpty()) {
282+
SetAlignedPointerInInternalField(p->handle_.Get(isolate), 0, nullptr);
283+
}
284+
delete p;
285+
p = next;
286+
}
287+
g_live_ctx_wraps = nullptr;
288+
}
225289

226290
CtxWrap::~CtxWrap() {
291+
// pprev_ != nullptr means we are still on the live list, i.e. V8 collected
292+
// the holder and we got here from WeakCallback. If it is null the drain hook
293+
// is walking the list and has already detached us.
294+
if (pprev_ != nullptr) {
295+
*pprev_ = next_;
296+
if (next_ != nullptr) next_->pprev_ = pprev_;
297+
}
227298
free(record_);
228299
}
229300

301+
void CtxWrap::WeakCallback(const v8::WeakCallbackInfo<CtxWrap>& data) {
302+
delete data.GetParameter();
303+
}
304+
305+
void CtxWrap::Wrap(Local<Object> holder) {
306+
Isolate* isolate = Isolate::GetCurrent();
307+
SetAlignedPointerInInternalField(holder, 0, this);
308+
handle_.Reset(isolate, holder);
309+
handle_.SetWeak(this, &WeakCallback, v8::WeakCallbackType::kParameter);
310+
next_ = g_live_ctx_wraps;
311+
pprev_ = &g_live_ctx_wraps;
312+
if (next_ != nullptr) next_->pprev_ = &next_;
313+
g_live_ctx_wraps = this;
314+
}
315+
316+
CtxWrap* CtxWrap::Unwrap(Local<Object> holder) {
317+
if (holder->InternalFieldCount() < 1) return nullptr;
318+
return static_cast<CtxWrap*>(GetAlignedPointerFromInternalField(*holder, 0));
319+
}
320+
230321
CtxWrap::CtxWrap(OtelThreadCtxRecord* record, size_t capacity, bool truncated)
231322
: record_(record),
232323
capacity_(capacity),
233324
truncated_(truncated),
234-
encoding_(false) {}
325+
encoding_(false),
326+
pprev_(nullptr),
327+
next_(nullptr) {}
235328

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

419-
CtxWrap* self = ObjectWrap::Unwrap<CtxWrap>(args.This());
512+
CtxWrap* self = CtxWrap::Unwrap(args.This());
420513
if (!self) {
421514
isolate->ThrowError("not a ThreadContext");
422515
return;
@@ -527,7 +620,7 @@ void CtxWrap::AppendAttributes(const FunctionCallbackInfo<Value>& args) {
527620
// still exposing the finished span. Idempotent; safe to call multiple
528621
// times.
529622
void CtxWrap::Invalidate(const FunctionCallbackInfo<Value>& args) {
530-
CtxWrap* self = ObjectWrap::Unwrap<CtxWrap>(args.This());
623+
CtxWrap* self = CtxWrap::Unwrap(args.This());
531624
if (!self) {
532625
args.GetIsolate()->ThrowError("not a ThreadContext");
533626
return;
@@ -541,7 +634,7 @@ void CtxWrap::Invalidate(const FunctionCallbackInfo<Value>& args) {
541634
// CtxWrap::New() if the initial set didn't fit, or by any subsequent
542635
// CtxWrap::AppendAttributes() call.
543636
void CtxWrap::IsTruncated(const FunctionCallbackInfo<Value>& args) {
544-
CtxWrap* self = ObjectWrap::Unwrap<CtxWrap>(args.This());
637+
CtxWrap* self = CtxWrap::Unwrap(args.This());
545638
if (!self) {
546639
args.GetIsolate()->ThrowError("not a ThreadContext");
547640
return;
@@ -554,7 +647,7 @@ void CtxWrap::IsTruncated(const FunctionCallbackInfo<Value>& args) {
554647
// API; intended for tests and out-of-process-reader development.
555648
void CtxWrap::DebugBytes(const FunctionCallbackInfo<Value>& args) {
556649
Isolate* isolate = args.GetIsolate();
557-
CtxWrap* self = ObjectWrap::Unwrap<CtxWrap>(args.This());
650+
CtxWrap* self = CtxWrap::Unwrap(args.This());
558651
if (!self) {
559652
isolate->ThrowError("not a ThreadContext");
560653
return;
@@ -569,6 +662,7 @@ void CtxWrap::DebugBytes(const FunctionCallbackInfo<Value>& args) {
569662
void CtxWrap::Init(Local<Object> exports) {
570663
Isolate* isolate = Isolate::GetCurrent();
571664
Local<Context> context = isolate->GetCurrentContext();
665+
node::AddEnvironmentCleanupHook(isolate, DrainLiveCtxWraps, isolate);
572666

573667
Local<FunctionTemplate> tpl = FunctionTemplate::New(isolate, New);
574668
tpl->SetClassName(String::NewFromUtf8Literal(isolate, "ThreadContext"));
@@ -691,13 +785,13 @@ constexpr int WRAPPED_OBJECT_OFFSET = 0;
691785
#endif
692786
constexpr int TAGGED_SIZE = v8::internal::kApiTaggedSize;
693787

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).
788+
// Given a pointer to a CtxWrap — reached from the JSObject's V8
789+
// wrapped-object slot — add this offset to arrive at `record_`. CtxWrap has
790+
// no base class, so `record_` is its first member and the offset is zero;
791+
// computing it with offsetof keeps the published value correct if the layout
792+
// ever changes.
699793
constexpr int NATIVE_WRAP_FIELDS_OFFSET =
700-
static_cast<int>(sizeof(node::ObjectWrap));
794+
static_cast<int>(offsetof(CtxWrap, record_));
701795

702796
// V8 JSMap layout: kTableOffset within the JSMap object holds a tagged
703797
// 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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ test('getProcessContextAttributes returns the expected shape', () => {
361361
// compression, no sandbox) these are 24 and 8 respectively.
362362
assert.equal(pca['threadlocal.wrapped_object_offset'], 24);
363363
assert.equal(pca['threadlocal.tagged_size'], 8);
364-
assert.equal(pca['threadlocal.native_wrap_fields_offset'], 24);
364+
assert.equal(pca['threadlocal.native_wrap_fields_offset'], 0);
365365
assert.equal(pca['threadlocal.js_map_table_offset'], 0x18);
366366
assert.equal(pca['threadlocal.ordered_hash_map_header_size'], 0x10);
367367
assert.deepEqual(Object.keys(pca).sort(), [

0 commit comments

Comments
 (0)