fix(otel-thread-ctx): detect AsyncContextFrame by reading CPED natively - #398
Draft
szegedi wants to merge 1 commit into
Draft
fix(otel-thread-ctx): detect AsyncContextFrame by reading CPED natively#398szegedi wants to merge 1 commit into
szegedi wants to merge 1 commit into
Conversation
#397 replaced the execArgv inference with a feature detection, but the probe was indirect: it overrode `enterWith` on a throwaway AsyncLocalStorage and checked whether `run()` dispatched through it. That `run()` goes through the instance property is unspecified, and anything patching AsyncLocalStorage can break it — including dd-trace-js, which patches async-context machinery. The resulting false negative is the failure #397 set out to fix: `enter()` throwing inside a diagnostic-channel subscriber, in application code. Ask the question directly instead. `cpedMapContains(key, value)` reports whether the isolate's ContinuationPreservedEmbedderData binds a key to a value, so calling it from inside a `run()` with the probe storage and its own store observes the property the addon actually depends on. It is the same slot, and the same "is it a Map" question, that WallProfiler::SetContext asks before storing a context; the key is the one whose identity hash is published as otel_thread_ctx_nodejs_v1.als_identity_hash for the out-of-process reader to look up. Verified empirically that the frame is keyed by the storage instance with the store as value. Checking the key and value rather than just "CPED holds a Map" matters: CPED is a general embedder slot, so a Map another addon left there must not answer for us — that would resurrect the silent false positive, where the writer looks healthy from JS while readers see records nothing updates. Uses the public v8::Map::Get, not the raw OrderedHashMap walk in map-get.hh. Conflating "is ACF on" with "is our layout knowledge correct" would report a V8 layout change as ACF being unavailable; layout has its own coverage. Lives in binding.cc rather than wall.cc so it works on Windows: wall.cc's `#ifndef _WIN32` block is there for SIGPROF and the v8::base::TimeTicks symbol trick, neither of which a CPED read needs. Gated on NODE_MAJOR_VERSION >= 22, returning false below, which is the correct answer there rather than a missing export. Total by construction — no context, slot unset or not a Map, key absent, or a malformed call all yield false, never a throw, because the writer calls this from ensureHook(). Detection routes verified on both Node lines: 24 default-on, 24 off via command line, 24 off via NODE_OPTIONS, 22 off by default, 22 on via command line, 22 on via NODE_OPTIONS. Five new tests pin the key/value discrimination; mutating the helper to a bare IsMap check fails three of them and none of the pre-existing ones. 124 passing on macOS, 175 passing / 2 pending in test:docker.
szegedi
requested review from
IlyasShabi,
nsavoire and
r1viollet
as code owners
August 14, 2026 07:13
szegedi
marked this pull request as draft
August 14, 2026 07:14
Overall package sizeSelf size: 2.53 MB Dependency sizes| name | version | self size | total size | |------|---------|-----------|------------| | pprof-format | 2.3.1 | 504.33 kB | 504.33 kB | | source-map | 0.8.0 | 185.66 kB | 185.66 kB | | node-gyp-build | 4.8.4 | 13.86 kB | 13.86 kB |🤖 This report was automatically generated by heaviest-objects-in-the-universe |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #397, implementing @nsavoire's suggestion from this comment: gather the evidence natively by reading CPED, rather than inferring it from
AsyncLocalStorage#run's behaviour.Of the three shapes I floated in r3776016667, this is the third —
cpedMapContains(key, value). Rationale for that choice below, since it wasn't obvious to me at first either.Why the old probe was worth replacing
#397's detection overrode
enterWithon a throwawayAsyncLocalStorageand checked whetherrun()dispatched through it:That
run()reaches the instance'senterWithproperty is unspecified. Anything patchingAsyncLocalStoragecan break it — dd-trace-js patches async-context machinery itself — and the resulting false negative is precisely the failure #397 existed to fix:ThreadContext#enter()throwing from an inline diagnostic-channel subscriber, surfacing in application code.What replaces it
With ACF active, Node installs an AsyncContextFrame — a JS Map keyed by the storage instance, valued by its store — as the running continuation's CPED. Nothing writes the slot otherwise. I verified the keying empirically rather than assuming it.
This is the same slot and the same "is it a Map" question that
WallProfiler::SetContextalready asks before storing a context, and the key is the one whose identity hash we publish asotel_thread_ctx_nodejs_v1.als_identity_hashfor the out-of-process reader to look up. So a wrong answer here means both consumers are already broken — the detection now depends on exactly what the consumers depend on, and nothing more.Why key+value rather than
cpedIsMapCPED is a general embedder slot. A Map that some other native addon left there must not be able to answer for us — that would readmit the silent false positive #397 killed, where the writer looks healthy from JS while every reader sees records nothing updates.
This is load-bearing, not theoretical: mutating the helper to a bare
IsMapcheck fails three of the five new tests and none of the pre-existing ones. Without them, the weaker version would pass the whole suite.Why not
getCpedReturning the live frame to JS would hand any caller who can require the addon an object whose mutation corrupts async-context propagation process-wide. It also moves the check into JS, where
instanceof Mapis realm-sensitive (the frame is aSafeMapsubclass), so it'd need to beacf?.get?.(probe) === valueto be safe — same answer as this, larger blast radius.Implementation notes
v8::Map::Get, notmap-get.hh. Tempting to reuse the raw OrderedHashMap walk since that's what the reader does, but it would conflate "is ACF on" with "is our layout knowledge still correct", reporting a V8 layout change as ACF being unavailable. Layout has its own coverage.binding.cc, notwall.cc, so it works on Windows.wall.cc's#ifndef _WIN32block is there for SIGPROF and thev8::base::TimeTickssymbol trick; a CPED read needs neither. No JS fallback anywhere.NODE_MAJOR_VERSION >= 22, returning false below — the correct answer there, rather than a missing export the JS side has to reason about.ensureHook()calls this.Verification
All six detection routes, on both Node lines:
--no-async-context-frameNODE_OPTIONS=--no-async-context-frame--experimental-async-context-frameNODE_OPTIONS=--experimental-async-context-frame124 passing on macOS; 175 passing / 2 pending in
npm run test:docker(170 before — the five new tests).Not in this PR
The
>=22.7.0nits from the same review are untouched, so they can be applied independently. Worth noting the direction agrees with them: with the capability observed directly, the detection path carries no version arithmetic at all, and version reasoning survives only inasyncContextFrameHint()'s advisory text — wheremajor < 22should become 22.7.0.