diff --git a/bindings/otel-thread-ctx.cc b/bindings/otel-thread-ctx.cc index c2ec2669..5c43eb93 100644 --- a/bindings/otel-thread-ctx.cc +++ b/bindings/otel-thread-ctx.cc @@ -48,6 +48,24 @@ #include #include +// Byte offset, within an object created from an API template such as +// ThreadContext's, of the pointer stored in internal field 0. Different +// in some Node.js versions. +#if NODE_MAJOR_VERSION >= 23 +constexpr int kRecordSlotOffset = + v8::internal::Internals::kJSAPIObjectWithEmbedderSlotsHeaderSize + + v8::internal::Internals::kEmbedderDataSlotExternalPointerOffset; +#elif NODE_MAJOR_VERSION >= 22 +constexpr int kRecordSlotOffset = + v8::internal::Internals::kJSObjectHeaderSize + + v8::internal::Internals::kEmbedderDataSlotExternalPointerOffset; +#else +// not used +constexpr int kRecordSlotOffset = 0; +#endif +static_assert(kRecordSlotOffset >= 0 && kRecordSlotOffset <= UINT8_MAX, + "record_slot_offset must fit its uint8 field"); + // Single thread-local read from outside the process via TLSDESC. It // identifies, for the current V8 isolate's thread: // @@ -60,13 +78,16 @@ // AsyncContextFrame map (`als_handle`), // - that instance's JS identity hash (`als_identity_hash`), so the // reader can restrict the lookup to a single hash bucket. +// - the byte offset of internal field 0 within the wrapper JSObject the +// frame maps our key to (`record_slot_offset`), which holds the record +// pointer. // - the (per-isolate) tagged address of the `undefined` singleton // (`undefined_addr`). After looking up the value for our ALS key in // the ACF map, the reader can compare against this to skip the // JSObject / internal-field-0 dereference when no ThreadContext is // currently attached; without it, a reader walking through undefined // would have to rely on structural validation of the bytes at -// undefined+js_object_record_offset to detect the absence. +// undefined+ to detect the absence. // // Layout is part of the reader ABI: see the README "Discovery contract" // section and the static_asserts below. @@ -75,9 +96,11 @@ using v8::Global; using v8::Object; struct otel_thread_ctx_nodejs_v1_t { - v8::internal::Address* cped_slot; // offset 0 - Global als_handle; // offset sizeof(void*); 1 V8 ptr - int als_identity_hash; // offset 2 * sizeof(void*); 4 + 4 pad + v8::internal::Address* cped_slot; // offset 0 + Global als_handle; // offset sizeof(void*); 1 V8 ptr + int als_identity_hash; // offset 2 * sizeof(void*) + uint8_t record_slot_offset = kRecordSlotOffset; // 2 * sizeof(void*) + 4 + uint8_t reserved[3] = {}; // 2 * sizeof(void*) + 5 v8::internal::Address undefined_addr; // offset 3 * sizeof(void*); tagged }; @@ -100,9 +123,12 @@ static_assert(offsetof(otel_thread_ctx_nodejs_v1_t, als_handle) == static_assert(offsetof(otel_thread_ctx_nodejs_v1_t, als_identity_hash) == 2 * sizeof(void*), "als_identity_hash must immediately follow als_handle"); +static_assert(offsetof(otel_thread_ctx_nodejs_v1_t, record_slot_offset) == + 2 * sizeof(void*) + 4, + "record_slot_offset must immediately follow als_identity_hash"); static_assert(offsetof(otel_thread_ctx_nodejs_v1_t, undefined_addr) == 3 * sizeof(void*), - "undefined_addr must follow als_identity_hash + padding"); + "undefined_addr must follow record_slot_offset + reserved"); namespace dd { namespace { @@ -880,8 +906,13 @@ void StoreAls(const FunctionCallbackInfo& args) { // Cache the per-isolate undefined singleton's tagged address. Undefined // is a read-only-roots heap object, never moves, so a cached numeric // address is fine — no Global<> tracking needed. +#if NODE_MAJOR_VERSION >= 22 otel_thread_ctx_nodejs_v1.undefined_addr = - reinterpret_cast(*v8::Undefined(isolate)); + v8::internal::ValueHelper::ValueAsAddress(*v8::Undefined(isolate)); +#else + // Unreachable from JS; nonzero for the cleanup-hook bookkeeping. + otel_thread_ctx_nodejs_v1.undefined_addr = 1; +#endif // Write `cped_slot` last with signal fence + volatile. It is what a reader // tests before it dereferences anything, so publishing it after every other @@ -915,10 +946,6 @@ void GetStoredAlsHash(const FunctionCallbackInfo& args) { // OrderedHashMap header kFixedArrayHeaderSize, because // size (0x10) OrderedHashTable derives from FixedArray // (deps/v8/src/objects/ordered-hash-table.h) -// record slot offset (0x18) kJSObjectHeaderSize plus -// kEmbedderDataSlotExternalPointerOffset, -// which is 0 without the sandbox: internal -// field 0 then holds the raw record pointer static_assert(v8::internal::kApiTaggedSize == 8, "nodejs_v1 assumes a V8 built without pointer compression"); static_assert(v8::internal::Internals::kJSObjectHeaderSize == 0x18, @@ -934,12 +961,47 @@ static_assert(kEmbedderDataSlotExternalPtrOffset == 0, "nodejs_v1 assumes a V8 built without the sandbox"); #endif +// Whether internal field 0 of an object created from an API template really +// sits at kRecordSlotOffset. Set the field on a probe object and read it back +// at the offset. +bool RecordSlotOffsetHolds(v8::Isolate* isolate, + v8::Local context) { +#if NODE_MAJOR_VERSION >= 22 + v8::HandleScope scope(isolate); + v8::Local tpl = v8::ObjectTemplate::New(isolate); + tpl->SetInternalFieldCount(1); + v8::Local probe; + if (!tpl->NewInstance(context).ToLocal(&probe)) return false; + // Any aligned address will do; this one is ours and can't collide. + static int marker; + SetAlignedPointerInInternalField(probe, 0, &marker); + const char* object = reinterpret_cast( + v8::internal::ValueHelper::ValueAsAddress(*probe) - + v8::internal::kHeapObjectTag); + void* at_offset; + memcpy(&at_offset, object + kRecordSlotOffset, sizeof(at_offset)); + return at_offset == ▮ +#else + // No ContinuationPreservedEmbedderData, so nothing to publish anyway. + return false; +#endif +} + } // namespace void OtelThreadCtx::Init(Local exports) { CtxWrap::Init(exports); NODE_SET_METHOD(exports, "otelThreadCtxStoreAls", StoreAls); NODE_SET_METHOD(exports, "otelThreadCtxGetStoredAlsHash", GetStoredAlsHash); + + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + v8::Local context = isolate->GetCurrentContext(); + exports + ->Set(context, + v8::String::NewFromUtf8Literal( + isolate, "otelThreadCtxRecordSlotOffsetHolds"), + v8::Boolean::New(isolate, RecordSlotOffsetHolds(isolate, context))) + .FromJust(); } } // namespace dd diff --git a/ts/src/otel-thread-ctx.ts b/ts/src/otel-thread-ctx.ts index 8df1ae5d..5ea2d725 100644 --- a/ts/src/otel-thread-ctx.ts +++ b/ts/src/otel-thread-ctx.ts @@ -138,10 +138,15 @@ interface Addon { threadContext: ThreadContextCtor; otelThreadCtxStoreAls(als: AsyncLocalStorage): void; otelThreadCtxGetStoredAlsHash(): number; + otelThreadCtxRecordSlotOffsetHolds: boolean; } const SCHEMA_VERSION = 'nodejs_v1_dev'; +// Why this process can't honor the schema, if it can't. Only meaningful on +// Linux, the one platform the reader contract covers. +let whyUnpublishable: () => string | undefined = () => undefined; + /** {@inheritDoc ThreadContextCtor} */ export let ThreadContext: ThreadContextCtor; @@ -162,20 +167,29 @@ export let clearContext: () => void; export let _currentRecordBytes: () => Uint8Array | undefined = () => undefined; if (process.platform === 'linux') { - // eslint-disable-next-line @typescript-eslint/no-require-imports const findBinding = require('node-gyp-build'); const addon: Addon = findBinding(join(__dirname, '..', '..')); ThreadContext = addon.threadContext; + whyUnpublishable = () => { + if (!isAsyncContextFrameActive()) { + return `async_context_frame support is unavailable: ${asyncContextFrameHint()}`; + } + if (!addon.otelThreadCtxRecordSlotOffsetHolds) { + return 'V8 does not place internal fields where the addon was built to expect them'; + } + return undefined; + }; + let als: AsyncLocalStorage | undefined; function ensureHook(): AsyncLocalStorage { if (als) return als; - if (!isAsyncContextFrameActive()) { + const reason = whyUnpublishable(); + if (reason) { throw new Error( - 'otel thread-ctx writer requires async_context_frame support, which is ' + - `unavailable: ${asyncContextFrameHint()}.`, + `otel thread-ctx writer can't publish on this Node: ${reason}.`, ); } als = new AsyncLocalStorage(); @@ -256,6 +270,11 @@ if (process.platform === 'linux') { export function getProcessContextAttributes( keys: string[], ): ProcessContextAttributes { + // A reader would find nothing or mis-walk, so don't declare the schema. + const reason = whyUnpublishable(); + if (reason) { + throw new Error(`can't declare ${SCHEMA_VERSION} on this Node: ${reason}.`); + } if (!Array.isArray(keys)) { throw new TypeError('keys must be an array of attribute names'); } diff --git a/ts/test/test-async-context-frame.ts b/ts/test/test-async-context-frame.ts index 54a0e970..f2cb7ebc 100644 --- a/ts/test/test-async-context-frame.ts +++ b/ts/test/test-async-context-frame.ts @@ -16,7 +16,7 @@ import {strict as assert} from 'assert'; import {AsyncLocalStorage} from 'node:async_hooks'; -import {fork} from 'node:child_process'; +import {fork, spawnSync} from 'node:child_process'; import {join} from 'node:path'; import {satisfies} from 'semver'; @@ -129,6 +129,38 @@ describe('isAsyncContextFrameActive', () => { }); }); +describe('getProcessContextAttributes', () => { + it('refuses to declare the schema without AsyncContextFrame', function () { + // The reader contract, and so this refusal, is Linux-only. + if (process.platform !== 'linux') return this.skip(); + // Nothing would ever write the CPED slot, so a reader told the schema is + // in use would find nothing. + const off = major >= 24 ? ['--no-async-context-frame'] : []; + const lib = JSON.stringify(join(__dirname, '..', 'src', 'otel-thread-ctx')); + const r = spawnSync( + process.execPath, + [ + ...off, + '-e', + `try { + require(${lib}).getProcessContextAttributes([]); + console.log('declared'); + } catch (e) { + console.log(e.message); + }`, + ], + {encoding: 'utf8', env: {...process.env, NODE_OPTIONS: ''}}, + ); + // Not the exit status: under the sanitizer jobs LeakSanitizer fails the + // child for leaks in Node's own `-e` startup path. + assert.match( + r.stdout, + /can't declare .* async_context_frame support is unavailable/, + r.stderr, + ); + }); +}); + // The detection asks whether the running storage is bound to its own store, // not merely whether the CPED slot holds a Map. These pin that difference: // without them, weakening the helper to a bare IsMap check would still pass diff --git a/ts/test/test-otel-thread-ctx.ts b/ts/test/test-otel-thread-ctx.ts index e7d0afeb..61538c0b 100644 --- a/ts/test/test-otel-thread-ctx.ts +++ b/ts/test/test-otel-thread-ctx.ts @@ -965,6 +965,18 @@ function captureBytes(opts: { }); describe('discovery contract', () => { + it('places internal field 0 at the record slot offset it publishes', () => { + // The offset differs between V8 versions, so the addon checks at load + // time that it matches where V8 actually puts the field. + const addon = require('node-gyp-build')( + join(__dirname, '..', '..'), + ) as { + otelThreadCtxRecordSlotOffsetHolds: boolean; + }; + strictAssert.equal(addon.otelThreadCtxRecordSlotOffsetHolds, true); + assert.doesNotThrow(() => getProcessContextAttributes([])); + }); + it('exports otel_thread_ctx_nodejs_v1 as a TLS dynsym', function () { const addon = join( __dirname,