Skip to content

Don't derive CtxWrap from node::ObjectWrap - #20

Open
szegedi wants to merge 2 commits into
polarsignals:otel-thread-ctx-wipfrom
szegedi:szegedi/ctxwrap-teardown-fix
Open

Don't derive CtxWrap from node::ObjectWrap#20
szegedi wants to merge 2 commits into
polarsignals:otel-thread-ctx-wipfrom
szegedi:szegedi/ctxwrap-teardown-fix

Conversation

@szegedi

@szegedi szegedi commented Aug 11, 2026

Copy link
Copy Markdown

This PR rewrites CtxWrap to not inherit 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.

Here's a list of existing Node.js issues and PRs regarding this problem for reference:

The problem is that Node.js committers will likely fix this for 26.3 and maybe backport to 24 but 22, 23, and 25 remain vulnerable, so our fix needs to be a permanent one and not just a workaround. Our solution here is to add an intrusive linked list to the CtxWrap type so we can have a linked list of all live instances rooted in a thread local, and a teardown function invoked from a single environment cleanup hook that walks the list and destroys the instances.

Our change is, I believe, materially better than the Node.js ObjectWrap fix as it buys us record_ at offset 0 and one cleanup hook per isolate rather than one per instance, independently of any Node fix.

BTW, this does affect CtxWrap on the main branch as well, although it's not in my scope to fix that.

@szegedi
szegedi force-pushed the szegedi/ctxwrap-teardown-fix branch from 774421e to 4677f54 Compare August 11, 2026 11:34
@szegedi szegedi mentioned this pull request Aug 11, 2026
@szegedi
szegedi marked this pull request as ready for review August 11, 2026 11:39
@umanwizard

Copy link
Copy Markdown
Collaborator

Same comment as #21 -- please write prose intended for humans (commit messages, PR messages, code comments) by hand.

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.
@szegedi
szegedi force-pushed the szegedi/ctxwrap-teardown-fix branch from 4677f54 to 5281a2b Compare August 12, 2026 16:27
@szegedi

szegedi commented Aug 12, 2026

Copy link
Copy Markdown
Author

@umanwizard rewrote PR description and commit messages by hand here as well. (Also significantly reduced the amount of in-code comments.)

@ivoanjo ivoanjo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've given it a pass! It does look reasonable, but it's probably worth having a second set of eyes on this since my C++/Node-fu is not amazing.

Comment thread js/test/teardown-child.js
Comment on lines +3 to +15
// 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 CtxWrap extended from ObjectWrap -- this test would crash prior to the other changes in the PR, and now it doesn't.

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

Runs in its own process because the failure mode is a SIGABRT, which would take the whole test run down with it.

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?

Comment thread js/addon.cpp
Comment on lines +139 to +148
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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Should these be static inline?

Comment thread js/addon.cpp
Comment on lines 163 to 167
// 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Should we maybe mention "Use native_wrap_fields_offset, don't assume" instead of hardcoding here the value as a comment?

Comment thread js/addon.cpp
Comment on lines +251 to +258
// 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");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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.

Comment thread js/addon.cpp
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants