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>
1716
1817#include < atomic>
1918#include < memory>
19+ #include < type_traits>
2020#include < vector>
2121
2222extern " 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
7777namespace otel_thread_ctx_nodejs {
78- using node::ObjectWrap;
7978using v8::Array;
8079using v8::Context;
8180using v8::Function;
@@ -136,18 +135,43 @@ constexpr size_t MIN_INITIAL_CAPACITY = 64 - sizeof(OtelThreadCtxRecord);
136135// as best-effort.
137136constexpr 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
226290CtxWrap::~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+
230321CtxWrap::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.
529622void 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.
543636void 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.
555648void 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) {
569662void 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
692786constexpr 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 .
699793constexpr 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
0 commit comments