-
Notifications
You must be signed in to change notification settings - Fork 13
Don't derive CtxWrap from node::ObjectWrap #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
604a480
5281a2b
1cbf223
9d59621
ed55190
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,6 @@ | |
| // finally the record it owns. | ||
|
|
||
| #include <node.h> | ||
| #include <node_object_wrap.h> | ||
| #include <v8-internal.h> | ||
|
|
||
| #include <stddef.h> | ||
|
|
@@ -17,6 +16,7 @@ | |
|
|
||
| #include <atomic> | ||
| #include <memory> | ||
| #include <type_traits> | ||
| #include <vector> | ||
|
|
||
| extern "C" { | ||
|
|
@@ -75,7 +75,6 @@ static_assert(offsetof(otel_thread_ctx_nodejs_v1_t, undefined_addr) == | |
| "undefined_addr must follow als_identity_hash + padding"); | ||
|
|
||
| namespace otel_thread_ctx_nodejs { | ||
| using node::ObjectWrap; | ||
| using v8::Array; | ||
| using v8::Context; | ||
| using v8::Function; | ||
|
|
@@ -136,18 +135,43 @@ constexpr size_t MIN_INITIAL_CAPACITY = 64 - sizeof(OtelThreadCtxRecord); | |
| // as best-effort. | ||
| constexpr size_t MAX_ATTRS_DATA_SIZE = 640 - sizeof(OtelThreadCtxRecord); | ||
|
|
||
| // Read and write the embedder pointer stored in an object's internal field. | ||
| inline void* GetAlignedPointerFromInternalField(Object* object, int index) { | ||
| #if NODE_MAJOR_VERSION >= 26 | ||
| return object->GetAlignedPointerFromInternalField( | ||
| index, v8::kEmbedderDataTypeTagDefault); | ||
| #else | ||
| return object->GetAlignedPointerFromInternalField(index); | ||
| #endif | ||
| } | ||
|
|
||
| inline void SetAlignedPointerInInternalField(Local<Object> object, | ||
| int index, | ||
| void* value) { | ||
| #if NODE_MAJOR_VERSION >= 26 | ||
| object->SetAlignedPointerInInternalField( | ||
| index, value, v8::kEmbedderDataTypeTagDefault); | ||
| #else | ||
| object->SetAlignedPointerInInternalField(index, value); | ||
| #endif | ||
| } | ||
|
|
||
| // Wraps a heap-allocated OtelThreadCtxRecord. Lifetime is managed by V8 GC: | ||
| // when no JS code (or AsyncLocalStorage entry) holds a reference, the record | ||
| // is freed. | ||
| // | ||
| // Layout note for the reader: `record_` is private to C++ but its byte | ||
| // position within CtxWrap is part of the reader contract. It is the first | ||
| // field after the node::ObjectWrap base subobject. `capacity_` sits after | ||
| // field of the class, at offset zero. `capacity_` sits after | ||
| // `record_` purely for the writer's own bookkeeping — the reader never | ||
| // touches it. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: Should we maybe mention "Use
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can. This offset will actually go away if I implement that follow-up that eliminates one level of indirection. I'll touch up the comment for now regardless. |
||
| class CtxWrap : public ObjectWrap { | ||
| // | ||
| // Deliberately not a node::ObjectWrap as it has a known bug in interaction | ||
| // with GC when numerous instances are created and can abort the process during | ||
| // isolate teardown. Instances live at shutdown are deleted using DrainLiveCtxWraps. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we link to the Node issue? |
||
| class CtxWrap { | ||
| public: | ||
| ~CtxWrap() override; | ||
| ~CtxWrap(); | ||
| static void Init(Local<Object> exports); | ||
|
|
||
| CtxWrap(const CtxWrap&) = delete; | ||
|
|
@@ -177,7 +201,13 @@ class CtxWrap : public ObjectWrap { | |
|
|
||
| CtxWrap(OtelThreadCtxRecord* record, size_t capacity, bool truncated); | ||
|
|
||
| // The three fields are kept in one access section because C++ leaves | ||
| // Attach to the holder JSObject: store `this` in internal field 0 and take | ||
| // a weak handle on the holder, so V8 deletes us once it collects it. | ||
| void Wrap(Local<Object> holder); | ||
| static CtxWrap* Unwrap(Local<Object> holder); | ||
| static void WeakCallback(const v8::WeakCallbackInfo<CtxWrap>& data); | ||
|
|
||
| // The fields are kept in one access section because C++ leaves | ||
| // the relative layout of fields in different access controls | ||
| // implementation-defined. `record_` must come first — its offset | ||
| // within CtxWrap is part of the reader contract (see the | ||
|
|
@@ -206,32 +236,95 @@ class CtxWrap : public ObjectWrap { | |
| // attrs_data_size write to shrink the record. We reject the reentrant | ||
| // call instead. | ||
| bool encoding_; | ||
| // Intrusive doubly-linked list of the CtxWraps still alive on this thread, | ||
| // threaded through g_live_ctx_wraps. `pprev_` is the address of the pointer | ||
| // currently referencing us, so unlinking needs no head/non-head branch; | ||
| // `pprev_ == nullptr` is the "already detached" sentinel set by the drain | ||
| // hook before it deletes us. | ||
| CtxWrap** pprev_; | ||
| CtxWrap* next_; | ||
| // Weak handle on the holder object; owns this CtxWrap. | ||
| v8::Global<v8::Object> handle_; | ||
| }; | ||
|
|
||
| // Pin the offset of `record_` — the field the reader walks to from the | ||
| // JSObject's internal field 0. We document it as "the first field after | ||
| // the node::ObjectWrap base subobject", so equality with | ||
| // sizeof(node::ObjectWrap) is the invariant. `offsetof` on a non- | ||
| // standard-layout type (CtxWrap has private fields and inherits from | ||
| // ObjectWrap) is conditionally supported per the standard but accepted | ||
| // by every compiler this addon targets; suppress -Winvalid-offsetof so | ||
| // the static_assert compiles cleanly under strict warning flags. | ||
| #pragma GCC diagnostic push | ||
| #pragma GCC diagnostic ignored "-Winvalid-offsetof" | ||
| static_assert(offsetof(CtxWrap, record_) == sizeof(node::ObjectWrap), | ||
| "record_ must be the first field after the ObjectWrap base " | ||
| "subobject"); | ||
| #pragma GCC diagnostic pop | ||
| // JSObject's internal field 0. With no base class it is simply the first | ||
| // member, so the offset is zero and the published | ||
| // `threadlocal.native_wrap_fields_offset` is computed from this. | ||
| static_assert(std::is_standard_layout<CtxWrap>::value, | ||
| "CtxWrap must stay standard-layout: the reader contract depends " | ||
| "on offsetof(record_) being well-defined"); | ||
| static_assert(offsetof(CtxWrap, record_) == 0, | ||
| "record_ must be the first field of CtxWrap"); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: I suggest moving this together with NATIVE_WRAP_FIELDS_OFFSET and avoid all the repeating of details in comments. IMHO it's a bit redundant to have "0" and "zero" in both code and comments + all these things need to be changed together so it's maybe nice to have them next to each other. |
||
|
|
||
| // Head of the live-CtxWrap list for this thread. Node pins each isolate to a | ||
| // thread, and CtxWraps are only ever constructed and destroyed on their own | ||
| // isolate's thread, so a thread-local needs no lock. | ||
| thread_local CtxWrap* g_live_ctx_wraps = nullptr; | ||
|
|
||
| // Delete every CtxWrap V8 has not collected yet. Registered once per isolate | ||
| // from Init() as an environment shutdown hook. | ||
| void DrainLiveCtxWraps(void* arg) { | ||
| auto* isolate = static_cast<Isolate*>(arg); | ||
| v8::HandleScope scope(isolate); | ||
| CtxWrap* p = g_live_ctx_wraps; | ||
| while (p != nullptr) { | ||
| CtxWrap* next = p->next_; | ||
| p->pprev_ = nullptr; | ||
| p->next_ = nullptr; | ||
| // Clear the holder's internal field before freeing what it points at, so | ||
| // nothing can reach a dangling CtxWrap through it — including the | ||
| // out-of-process reader, which walks exactly this slot. Being on the live | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wait, is this true -- will the out-of-process reader need to walk the linked list? I thought this change was only related to resource cleanup?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No it doesn't need to walk this linked list, it's a purely internal construct. The reader walks this slot for exactly one object during any particular lookup. I should replace the word "walks" with "reads"
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, I guess it reads only whatever's the first one, rather than walking the list?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the out-of-process reader never sees this list. The reader will start from a thread local, find the async context map in the CPED, find the value in the map that the writer sets (which is a JS object), and then read that object's internal field slot to access the CtxWrap object. So the reader traverses this slot for the object that is the value for the currently active async context. The list on the other hand primarily exists for freeing the memory of native wrappers at runtime destruction – it contains all the live native wrappers. As it's freeing them it also zeroes out the internal field slot of the JS objects wrapping them, as that slot points to the native objects being freed. That's all this code does. I'll reword the comment.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Your description matches what I expected coming into the PR -- I guess the comment was what confused me. The latest version still mentions the "out-of-process" reader in relation to maintaining this list, which seems to not match what you describe above -- perhaps consider removing the comment?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay, I'll make another attempt at explaining. I think the comment is now right. Maybe let's visualize this in two dimensions:
On teardown, An external reader OTOH reads vertically in the attached picture. It starts from As an aside: I put in more So:
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AAAAAAAAAAAH I totally get it now. Thanks for going to the trouble to throughly explain, it's crystal clear now. I'd read through this PR and earlier PRs and was aware of the "vertical" traversal and then this PR added the "horizontal" and I wasn't connecting the dots on how both traversals were... (ahem) connected. |
||
| // list means V8 has not collected the holder, so the handle is safe to | ||
| // read here; the WeakCallback path cannot do this and does not need to, | ||
| // since there the holder is the object being collected. | ||
| if (!p->handle_.IsEmpty()) { | ||
| SetAlignedPointerInInternalField(p->handle_.Get(isolate), 0, nullptr); | ||
| } | ||
| delete p; | ||
| p = next; | ||
| } | ||
| g_live_ctx_wraps = nullptr; | ||
| } | ||
|
|
||
| CtxWrap::~CtxWrap() { | ||
| // pprev_ != nullptr means we are still on the live list, i.e. V8 collected | ||
| // the holder and we got here from WeakCallback. If it is null the drain hook | ||
| // is walking the list and has already detached us. | ||
| if (pprev_ != nullptr) { | ||
| *pprev_ = next_; | ||
| if (next_ != nullptr) next_->pprev_ = pprev_; | ||
| } | ||
| free(record_); | ||
| } | ||
|
|
||
| void CtxWrap::WeakCallback(const v8::WeakCallbackInfo<CtxWrap>& data) { | ||
| delete data.GetParameter(); | ||
| } | ||
|
|
||
| void CtxWrap::Wrap(Local<Object> holder) { | ||
| Isolate* isolate = Isolate::GetCurrent(); | ||
| SetAlignedPointerInInternalField(holder, 0, this); | ||
| handle_.Reset(isolate, holder); | ||
| handle_.SetWeak(this, &WeakCallback, v8::WeakCallbackType::kParameter); | ||
| next_ = g_live_ctx_wraps; | ||
| pprev_ = &g_live_ctx_wraps; | ||
| if (next_ != nullptr) next_->pprev_ = &next_; | ||
| g_live_ctx_wraps = this; | ||
| } | ||
|
|
||
| CtxWrap* CtxWrap::Unwrap(Local<Object> holder) { | ||
| if (holder->InternalFieldCount() < 1) return nullptr; | ||
| return static_cast<CtxWrap*>(GetAlignedPointerFromInternalField(*holder, 0)); | ||
| } | ||
|
|
||
| CtxWrap::CtxWrap(OtelThreadCtxRecord* record, size_t capacity, bool truncated) | ||
| : record_(record), | ||
| capacity_(capacity), | ||
| truncated_(truncated), | ||
| encoding_(false) {} | ||
| encoding_(false), | ||
| pprev_(nullptr), | ||
| next_(nullptr) {} | ||
|
|
||
| // Copy exactly `expected_bytes` bytes out of a JS Uint8Array (or subclass such | ||
| // 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) { | |
| Isolate* isolate = args.GetIsolate(); | ||
| Local<Context> context = isolate->GetCurrentContext(); | ||
|
|
||
| CtxWrap* self = ObjectWrap::Unwrap<CtxWrap>(args.This()); | ||
| CtxWrap* self = CtxWrap::Unwrap(args.This()); | ||
| if (!self) { | ||
| isolate->ThrowError("not a ThreadContext"); | ||
| return; | ||
|
|
@@ -527,7 +620,7 @@ void CtxWrap::AppendAttributes(const FunctionCallbackInfo<Value>& args) { | |
| // still exposing the finished span. Idempotent; safe to call multiple | ||
| // times. | ||
| void CtxWrap::Invalidate(const FunctionCallbackInfo<Value>& args) { | ||
| CtxWrap* self = ObjectWrap::Unwrap<CtxWrap>(args.This()); | ||
| CtxWrap* self = CtxWrap::Unwrap(args.This()); | ||
| if (!self) { | ||
| args.GetIsolate()->ThrowError("not a ThreadContext"); | ||
| return; | ||
|
|
@@ -541,7 +634,7 @@ void CtxWrap::Invalidate(const FunctionCallbackInfo<Value>& args) { | |
| // CtxWrap::New() if the initial set didn't fit, or by any subsequent | ||
| // CtxWrap::AppendAttributes() call. | ||
| void CtxWrap::IsTruncated(const FunctionCallbackInfo<Value>& args) { | ||
| CtxWrap* self = ObjectWrap::Unwrap<CtxWrap>(args.This()); | ||
| CtxWrap* self = CtxWrap::Unwrap(args.This()); | ||
| if (!self) { | ||
| args.GetIsolate()->ThrowError("not a ThreadContext"); | ||
| return; | ||
|
|
@@ -554,7 +647,7 @@ void CtxWrap::IsTruncated(const FunctionCallbackInfo<Value>& args) { | |
| // API; intended for tests and out-of-process-reader development. | ||
| void CtxWrap::DebugBytes(const FunctionCallbackInfo<Value>& args) { | ||
| Isolate* isolate = args.GetIsolate(); | ||
| CtxWrap* self = ObjectWrap::Unwrap<CtxWrap>(args.This()); | ||
| CtxWrap* self = CtxWrap::Unwrap(args.This()); | ||
| if (!self) { | ||
| isolate->ThrowError("not a ThreadContext"); | ||
| return; | ||
|
|
@@ -569,6 +662,7 @@ void CtxWrap::DebugBytes(const FunctionCallbackInfo<Value>& args) { | |
| void CtxWrap::Init(Local<Object> exports) { | ||
| Isolate* isolate = Isolate::GetCurrent(); | ||
| Local<Context> context = isolate->GetCurrentContext(); | ||
| node::AddEnvironmentCleanupHook(isolate, DrainLiveCtxWraps, isolate); | ||
|
|
||
| Local<FunctionTemplate> tpl = FunctionTemplate::New(isolate, New); | ||
| tpl->SetClassName(String::NewFromUtf8Literal(isolate, "ThreadContext")); | ||
|
|
@@ -691,13 +785,13 @@ constexpr int WRAPPED_OBJECT_OFFSET = 0; | |
| #endif | ||
| constexpr int TAGGED_SIZE = v8::internal::kApiTaggedSize; | ||
|
|
||
| // sizeof(node::ObjectWrap). Given a pointer to a CtxWrap — or any other | ||
| // ObjectWrap-derived C++ object attached to a JSObject via the V8 | ||
| // wrapped-object slot — add this offset to reach the derived class's own | ||
| // fields. For CtxWrap, that's `record_` (see the static_assert on its | ||
| // offset above). | ||
| // Given a pointer to a CtxWrap — reached from the JSObject's V8 | ||
| // wrapped-object slot — add this offset to arrive at `record_`. CtxWrap has | ||
| // no base class, so `record_` is its first member and the offset is zero; | ||
| // computing it with offsetof keeps the published value correct if the layout | ||
| // ever changes. | ||
| constexpr int NATIVE_WRAP_FIELDS_OFFSET = | ||
| static_cast<int>(sizeof(node::ObjectWrap)); | ||
| static_cast<int>(offsetof(CtxWrap, record_)); | ||
|
|
||
| // V8 JSMap layout: kTableOffset within the JSMap object holds a tagged | ||
| // pointer to the backing OrderedHashMap table. Not exposed in V8's | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| 'use strict'; | ||
|
|
||
| // Spawned by the "contexts collected during isolate teardown" test. Runs in | ||
| // its own process because the failure mode is a SIGABRT, which would take the | ||
| // whole test run down with it. | ||
| // | ||
| // When CtxWrap derived from node::ObjectWrap, a CtxWrap collected during | ||
| // isolate teardown ran ~ObjectWrap -> RemoveEnvironmentCleanupHook, which | ||
| // CHECKs that an Environment is current. It is not, during teardown, so: | ||
| // | ||
| // Assertion failed: (env) != nullptr | ||
| // 3: otel_thread_ctx_nodejs::CtxWrap::~CtxWrap() | ||
| // | ||
| // It needs enough instances (~1000) that V8 still has some left to collect | ||
| // at teardown. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It may be just me, but this reads a bit confusing. Looking at the high-level, this is a regression test for the previous implementation detail where But the description gets a bit too much into detail and at least for me the above bit -- which IMHO is the only important part here -- is not clear. (In the context of the PR it's obvious what this does, but if I were looking at just this file without prior context I'm not sure I'd understand what's going on/what's being tested here) Also, the whole harness introduced in
I think is not very valuable anymore? E.g. we could keep it around as a previous commit in the branch, but going forward the issue is expected to be fixed forever, so why pay the cost of a spawn and additional complexity for a test that's never expected to ever fail ever again?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's kinda typical for me, I like putting reproducers into issues as a first commit, so a reviewer can check out the repo at that commit and see evidence that the issue is real, and then they can check out the next commit, and see that it fixes the issue. It's true that this should not occur again, though. I'm happy to remove it or… how about this: I'll add a commit that reverts this commit after the fix commit, so the reproducer stays here in the PR branch, but vanishes from the squashed commit when we merge.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, to be clear, my comment was more along the lines of:
|
||
|
|
||
| const { ThreadContext } = require('..'); | ||
|
|
||
| const N = Number(process.argv[2] || 3000); | ||
|
|
||
| function id(n, len) { | ||
| const b = Buffer.alloc(len); | ||
| b.writeUInt32BE(n >>> 0, 0); | ||
| return b; | ||
| } | ||
|
|
||
| const retained = []; | ||
|
|
||
| for (let i = 0; i < N; i++) { | ||
| const ctx = new ThreadContext(id(i, 16), id(i, 8), ['k', String(i)]); | ||
| if (i % 4 === 0) { | ||
| // Still strongly reachable at exit. | ||
| retained.push(ctx); | ||
| } else { | ||
| // Reachable only through the async context frame, so collectable | ||
| // whenever V8 decides — including during teardown. | ||
| ctx.enter(); | ||
| } | ||
| } | ||
|
|
||
| if (retained.length > 0) { | ||
| retained[0].enter(); | ||
| } | ||
| globalThis.__retained = retained; | ||
|
|
||
| // Exit through the normal path so the Environment is torn down and the | ||
| // isolate disposed; that is where the weak callbacks in question fire. | ||
| console.log(`created ${N}, retained ${retained.length}`); | ||

Uh oh!
There was an error while loading. Please reload this page.