Skip to content

fix(otel-thread-ctx): detect AsyncContextFrame by reading CPED natively - #398

Draft
szegedi wants to merge 1 commit into
mainfrom
szegedi/acf-native-detection
Draft

fix(otel-thread-ctx): detect AsyncContextFrame by reading CPED natively#398
szegedi wants to merge 1 commit into
mainfrom
szegedi/acf-native-detection

Conversation

@szegedi

@szegedi szegedi commented Aug 14, 2026

Copy link
Copy Markdown

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 enterWith on a throwaway AsyncLocalStorage and checked whether run() dispatched through it:

probe.enterWith = () => { delegated = true; };
probe.run(0, () => {});

That run() reaches the instance's enterWith property is unspecified. Anything patching AsyncLocalStorage can 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

const probe = new AsyncLocalStorage();
const sentinel = {};
let bound = false;
probe.run(sentinel, () => { bound = addon.cpedMapContains(probe, sentinel); });
probe.disable();
active = bound;

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::SetContext already asks before storing a context, and the key is the one whose identity hash we publish as otel_thread_ctx_nodejs_v1.als_identity_hash for 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 cpedIsMap

CPED 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 IsMap check 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 getCped

Returning 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 Map is realm-sensitive (the frame is a SafeMap subclass), so it'd need to be acf?.get?.(probe) === value to be safe — same answer as this, larger blast radius.

Implementation notes

  • Public v8::Map::Get, not map-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.
  • In binding.cc, not wall.cc, so it works on Windows. wall.cc's #ifndef _WIN32 block is there for SIGPROF and the v8::base::TimeTicks symbol 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.
  • Total by construction. No context entered, slot unset or not a Map, key absent, or a malformed call all yield false, never a throw. ensureHook() calls this.

Verification

All six detection routes, on both Node lines:

Node route detected
24.18.0 default active
24.18.0 --no-async-context-frame inactive
24.18.0 NODE_OPTIONS=--no-async-context-frame inactive
22.23.2 default inactive
22.23.2 --experimental-async-context-frame active
22.23.2 NODE_OPTIONS=--experimental-async-context-frame active

124 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.0 nits 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 in asyncContextFrameHint()'s advisory text — where major < 22 should become 22.7.0.

#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
szegedi marked this pull request as draft August 14, 2026 07:14
@github-actions

Copy link
Copy Markdown

Overall package size

Self size: 2.53 MB
Deduped: 3.24 MB
No deduping: 3.24 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

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.

1 participant