Skip to content

Commit 0154ecb

Browse files
authored
fix(otel-thread-ctx): detect AsyncContextFrame by reading CPED natively (#398)
* fix(otel-thread-ctx): detect AsyncContextFrame by reading CPED natively #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. * test: use the real 22.7.0 AsyncContextFrame cutoff
1 parent 9ac10de commit 0154ecb

8 files changed

Lines changed: 171 additions & 32 deletions

bindings/binding.cc

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,40 @@
2929
#include <unistd.h>
3030
#endif
3131

32+
// Whether the isolate's ContinuationPreservedEmbedderData is a JS Map that
33+
// currently binds `key` to `value`.
34+
//
35+
// This exists for AsyncContextFrame feature detection. With ACF active, Node
36+
// implements AsyncLocalStorage#run by installing an AsyncContextFrame — a JS
37+
// Map keyed by the AsyncLocalStorage instance — as the CPED of the running
38+
// continuation. Calling this from inside a run() with the storage and its
39+
// store therefore observes the property this addon actually depends on,
40+
// instead of inferring it from the Node version, process.execArgv, or whether
41+
// run() happens to dispatch through the instance's enterWith.
42+
static NAN_METHOD(CpedMapContains) {
43+
#if NODE_MAJOR_VERSION >= 22
44+
// A malformed call must not accidentally answer true by comparing an absent
45+
// key's undefined against an undefined expected value.
46+
if (info.Length() >= 2) {
47+
auto isolate = info.GetIsolate();
48+
auto cped = isolate->GetContinuationPreservedEmbedderData();
49+
if (!cped.IsEmpty() && cped->IsMap()) {
50+
auto context = isolate->GetCurrentContext();
51+
if (!context.IsEmpty()) {
52+
v8::Local<v8::Value> found;
53+
if (cped.As<v8::Map>()->Get(context, info[0]).ToLocal(&found)) {
54+
info.GetReturnValue().Set(found->StrictEquals(info[1]));
55+
return;
56+
}
57+
}
58+
}
59+
}
60+
#endif
61+
// Either code above didn't reach the innermost if statement, or
62+
// we're compiling for Node.js < 22.
63+
info.GetReturnValue().Set(false);
64+
}
65+
3266
static NAN_METHOD(GetNativeThreadId) {
3367
#ifdef __APPLE__
3468
uint64_t native_id;
@@ -56,4 +90,5 @@ NODE_MODULE_INIT(/* exports, module, context */) {
5690
dd::WallProfiler::Init(exports);
5791
dd::OtelThreadCtx::Init(exports);
5892
Nan::SetMethod(exports, "getNativeThreadId", GetNativeThreadId);
93+
Nan::SetMethod(exports, "cpedMapContains", CpedMapContains);
5994
}

ts/src/async-context-frame.ts

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,23 @@
1515
*/
1616

1717
import {AsyncLocalStorage} from 'node:async_hooks';
18+
import {join} from 'path';
19+
20+
interface Addon {
21+
cpedMapContains(key: unknown, value: unknown): boolean;
22+
}
23+
24+
let addon: Addon | undefined;
25+
26+
// Required lazily so importing this module doesn't force the addon to load;
27+
// memoized by isAsyncContextFrameActive, so this runs at most once per thread.
28+
function bindings(): Addon {
29+
if (!addon) {
30+
const findBinding = require('node-gyp-build');
31+
addon = findBinding(join(__dirname, '..', '..')) as Addon;
32+
}
33+
return addon;
34+
}
1835

1936
let active: boolean | undefined;
2037

@@ -27,9 +44,10 @@ let active: boolean | undefined;
2744
* `process.execArgv`, because the two disagree in both directions and each
2845
* combination is reachable today:
2946
*
30-
* - `NODE_OPTIONS=--experimental-async-context-frame` is accepted on Node 22
31-
* and 23 and turns ACF on without appearing in `execArgv`. Inferring "off"
32-
* there makes callers refuse to run in a process that would have worked.
47+
* - `NODE_OPTIONS=--experimental-async-context-frame` is accepted from Node
48+
* 22.7.0 through 23 and turns ACF on without appearing in `execArgv`.
49+
* Inferring "off" there makes callers refuse to run in a process that would
50+
* have worked.
3351
* - `NODE_OPTIONS=--no-async-context-frame` is accepted on Node 24 and turns
3452
* ACF off without appearing in `execArgv`. Inferring "on" there is the worse
3553
* error: the CPED slot is never written, so a writer that starts anyway keeps
@@ -39,19 +57,34 @@ let active: boolean | undefined;
3957
* main thread's command line either, and tooling sometimes rewrites
4058
* `process.execArgv` outright.
4159
*
42-
* With ACF, `run()` is implemented in terms of `enterWith()`; without it, it
43-
* isn't. Memoized: the answer is fixed for the life of the thread.
60+
* Detected by asking the addon what is in the CPED slot during a `run()`. With
61+
* ACF, Node installs an AsyncContextFrame — a JS Map keyed by the
62+
* `AsyncLocalStorage` instance, valued by its store — as the running
63+
* continuation's CPED; without it, nothing writes the slot. So a probe storage
64+
* whose own store is visible there is direct evidence, and it is evidence about
65+
* the exact slot both consumers read: `WallProfiler::SetContext` requires that
66+
* Map, and the thread-ctx reader looks this very key up by the identity hash
67+
* published as `als_identity_hash`.
68+
*
69+
* Observing whether `run()` delegates to `enterWith()` would be an indirect
70+
* proxy for the same thing: it holds today, but it depends on `run()`
71+
* dispatching through the instance property, which is unspecified and which
72+
* anything patching `AsyncLocalStorage` can break — and the failure would be
73+
* silent and in the dangerous direction.
74+
*
75+
* Memoized: the answer is fixed for the life of the thread.
4476
*/
4577
export function isAsyncContextFrameActive(): boolean {
4678
if (active === undefined) {
47-
const probe = new AsyncLocalStorage<number>();
48-
let delegated = false;
49-
probe.enterWith = () => {
50-
delegated = true;
51-
};
52-
probe.run(0, () => {});
79+
const probe = new AsyncLocalStorage<object>();
80+
// Object identity, so a stray equal-valued binding can't answer for us.
81+
const sentinel = {};
82+
let bound = false;
83+
probe.run(sentinel, () => {
84+
bound = bindings().cpedMapContains(probe, sentinel);
85+
});
5386
probe.disable();
54-
active = delegated;
87+
active = bound;
5588
}
5689
return active;
5790
}
@@ -65,8 +98,10 @@ export function isAsyncContextFrameActive(): boolean {
6598
*/
6699
export function asyncContextFrameHint(): string {
67100
const version = process.versions.node;
68-
const major = Number(version.split('.')[0]);
69-
if (major < 22) {
101+
const [major, minor] = version.split('.').map(Number);
102+
// Hand-rolled rather than semver.satisfies: semver is a devDependency, and
103+
// this module ships.
104+
if (major < 22 || (major === 22 && minor < 7)) {
70105
return `Node ${version} does not support it at all; Node 24 and later enable it by default`;
71106
}
72107
if (major < 24) {

ts/test/test-async-context-frame.ts

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,23 @@
1515
*/
1616

1717
import {strict as assert} from 'assert';
18+
import {AsyncLocalStorage} from 'node:async_hooks';
1819
import {fork} from 'node:child_process';
1920
import {join} from 'node:path';
2021

22+
import {satisfies} from 'semver';
23+
2124
import {isAsyncContextFrameActive} from '../src/async-context-frame';
2225

26+
const addon = require('node-gyp-build')(join(__dirname, '..', '..')) as {
27+
cpedMapContains(key?: unknown, value?: unknown): boolean;
28+
};
29+
2330
const CHILD = join(__dirname, 'async-context-frame-child.js');
2431

2532
const major = Number(process.versions.node.split('.')[0]);
33+
// ACF landed in 22.7.0, so the opt-in routes are gated on that, not on major 22.
34+
const hasAcfSupport = satisfies(process.versions.node, '>=22.7.0');
2635

2736
interface ChildReport {
2837
active: boolean;
@@ -79,7 +88,7 @@ describe('isAsyncContextFrameActive', () => {
7988
});
8089

8190
it('reports it inactive when Node has no support for it', async function () {
82-
if (major >= 22) return this.skip();
91+
if (hasAcfSupport) return this.skip();
8392
const {active} = await probeChild();
8493
assert.equal(active, false);
8594
});
@@ -107,15 +116,83 @@ describe('isAsyncContextFrameActive', () => {
107116
});
108117

109118
it('reports it active when NODE_OPTIONS turns it on', async function () {
110-
// The mirror image, on the other Node line: 22 and 23 accept the flag in
119+
// The mirror image, on the other Node line: 22.7.0 through 23 accept the flag in
111120
// NODE_OPTIONS (24 rejects it outright), again without it reaching execArgv,
112121
// so inferring from execArgv concludes ACF is off when it is on — and the
113122
// caller refuses to run in a process that would have worked.
114-
if (major < 22 || major >= 24) return this.skip();
123+
if (!hasAcfSupport || major >= 24) return this.skip();
115124
const {active, execArgv} = await probeChild({
116125
nodeOptions: '--experimental-async-context-frame',
117126
});
118127
assert.deepEqual(execArgv, []);
119128
assert.equal(active, true);
120129
});
121130
});
131+
132+
// The detection asks whether the running storage is bound to its own store,
133+
// not merely whether the CPED slot holds a Map. These pin that difference:
134+
// without them, weakening the helper to a bare IsMap check would still pass
135+
// every test above.
136+
describe('cpedMapContains', () => {
137+
beforeEach(function () {
138+
// With ACF off nothing writes the slot, so every answer here is false for
139+
// an uninteresting reason. The routes that discriminate on/off are covered
140+
// by the child-process cases above.
141+
if (!isAsyncContextFrameActive()) this.skip();
142+
});
143+
144+
it('finds the running storage bound to its store', () => {
145+
const als = new AsyncLocalStorage<object>();
146+
const store = {};
147+
let found = false;
148+
als.run(store, () => {
149+
found = addon.cpedMapContains(als, store);
150+
});
151+
als.disable();
152+
assert.equal(found, true);
153+
});
154+
155+
it('does not match a foreign key', () => {
156+
// CPED is a general embedder slot. Another native addon storing a Map there
157+
// must not be able to answer for us, which is the false positive an IsMap
158+
// check would admit.
159+
const als = new AsyncLocalStorage<object>();
160+
const store = {};
161+
let found = true;
162+
als.run(store, () => {
163+
found = addon.cpedMapContains(new AsyncLocalStorage<object>(), store);
164+
});
165+
als.disable();
166+
assert.equal(found, false);
167+
});
168+
169+
it('does not match a different value for the right key', () => {
170+
const als = new AsyncLocalStorage<object>();
171+
let found = true;
172+
als.run({}, () => {
173+
found = addon.cpedMapContains(als, {});
174+
});
175+
als.disable();
176+
assert.equal(found, false);
177+
});
178+
179+
it('is false outside any run', () => {
180+
const als = new AsyncLocalStorage<object>();
181+
const store = {};
182+
als.run(store, () => {});
183+
als.disable();
184+
assert.equal(addon.cpedMapContains(als, store), false);
185+
});
186+
187+
it('is false when called without a key and value', () => {
188+
// An absent key reads as undefined; so would a missing expected value, so
189+
// a malformed call must not compare the two and report success.
190+
const als = new AsyncLocalStorage<object>();
191+
let found = true;
192+
als.run({}, () => {
193+
found = addon.cpedMapContains();
194+
});
195+
als.disable();
196+
assert.equal(found, false);
197+
});
198+
});

ts/test/test-get-value-from-map-profiler.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,13 @@
2929
import assert from 'assert';
3030
import {join} from 'path';
3131
import {AsyncLocalStorage} from 'async_hooks';
32-
import {satisfies} from 'semver';
3332

3433
import {isAsyncContextFrameActive} from '../src/async-context-frame';
3534

3635
const findBinding = require('node-gyp-build');
3736
const profiler = findBinding(join(__dirname, '..', '..'));
3837

39-
const useCPED =
40-
isAsyncContextFrameActive() && satisfies(process.versions.node, '>=22.7.0');
38+
const useCPED = isAsyncContextFrameActive();
4139

4240
const supportedPlatform =
4341
process.platform === 'darwin' || process.platform === 'linux';

ts/test/test-otel-thread-ctx.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,9 @@ function tcIsTruncated(): boolean {
6262
}
6363

6464
const isLinux = process.platform === 'linux';
65-
// AsyncContextFrame is the writer's discovery substrate: opt-in on Node 22/23
66-
// (via --experimental-async-context-frame) and on by default from Node 24
65+
// AsyncContextFrame is the writer's discovery substrate: opt-in from Node
66+
// 22.7.0 through 23 (via --experimental-async-context-frame) and on by
67+
// default from Node 24
6768
// (disable-able via --no-async-context-frame). The TS layer refuses to install
6869
// the hook when it isn't active, so the entire describe block is skipped then.
6970
// Asks the same question the source side asks, the same way.

ts/test/test-time-profiler.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,7 @@ import {fork} from 'child_process';
3232

3333
import assert from 'assert';
3434

35-
const useCPED =
36-
isAsyncContextFrameActive() && satisfies(process.versions.node, '>=22.7.0');
35+
const useCPED = isAsyncContextFrameActive();
3736

3837
const collectAsyncId = satisfies(process.versions.node, '>=24.0.0');
3938

ts/test/worker.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,7 @@ const DURATION_MILLIS = 1000;
1212
const intervalMicros = 10000;
1313
const withContexts =
1414
process.platform === 'darwin' || process.platform === 'linux';
15-
const useCPED =
16-
withContexts &&
17-
isAsyncContextFrameActive() &&
18-
satisfies(process.versions.node, '>=22.7.0');
15+
const useCPED = withContexts && isAsyncContextFrameActive();
1916
const collectAsyncId =
2017
withContexts && satisfies(process.versions.node, '>=24.0.0');
2118

ts/test/worker2.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,7 @@ const INTERVAL_MICROS = 10000;
1010
const withContexts =
1111
process.platform === 'darwin' || process.platform === 'linux';
1212

13-
const useCPED =
14-
withContexts &&
15-
isAsyncContextFrameActive() &&
16-
satisfies(process.versions.node, '>=22.7.0');
13+
const useCPED = withContexts && isAsyncContextFrameActive();
1714

1815
const collectAsyncId =
1916
withContexts && satisfies(process.versions.node, '>=24.0.0');

0 commit comments

Comments
 (0)