Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 72 additions & 10 deletions bindings/otel-thread-ctx.cc
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,24 @@
#include <new>
#include <vector>

// 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:
//
Expand All @@ -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+<record slot offset> to detect the absence.
//
// Layout is part of the reader ABI: see the README "Discovery contract"
// section and the static_asserts below.
Expand All @@ -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<Object> 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<Object> 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
};

Expand All @@ -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 {
Expand Down Expand Up @@ -880,8 +906,13 @@ void StoreAls(const FunctionCallbackInfo<Value>& 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::internal::Address>(*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
Expand Down Expand Up @@ -915,10 +946,6 @@ void GetStoredAlsHash(const FunctionCallbackInfo<Value>& 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,
Expand All @@ -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<v8::Context> context) {
#if NODE_MAJOR_VERSION >= 22
v8::HandleScope scope(isolate);
v8::Local<v8::ObjectTemplate> tpl = v8::ObjectTemplate::New(isolate);
tpl->SetInternalFieldCount(1);
v8::Local<v8::Object> 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<const char*>(
v8::internal::ValueHelper::ValueAsAddress(*probe) -
v8::internal::kHeapObjectTag);
void* at_offset;
memcpy(&at_offset, object + kRecordSlotOffset, sizeof(at_offset));
return at_offset == &marker;
#else
// No ContinuationPreservedEmbedderData, so nothing to publish anyway.
return false;
#endif
}

} // namespace

void OtelThreadCtx::Init(Local<Object> exports) {
CtxWrap::Init(exports);
NODE_SET_METHOD(exports, "otelThreadCtxStoreAls", StoreAls);
NODE_SET_METHOD(exports, "otelThreadCtxGetStoredAlsHash", GetStoredAlsHash);

v8::Isolate* isolate = v8::Isolate::GetCurrent();
v8::Local<v8::Context> context = isolate->GetCurrentContext();
exports
->Set(context,
v8::String::NewFromUtf8Literal(
isolate, "otelThreadCtxRecordSlotOffsetHolds"),
v8::Boolean::New(isolate, RecordSlotOffsetHolds(isolate, context)))
.FromJust();
}

} // namespace dd
27 changes: 23 additions & 4 deletions ts/src/otel-thread-ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,15 @@ interface Addon {
threadContext: ThreadContextCtor;
otelThreadCtxStoreAls(als: AsyncLocalStorage<ThreadContext>): 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;

Expand All @@ -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<ThreadContext> | undefined;

function ensureHook(): AsyncLocalStorage<ThreadContext> {
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<ThreadContext>();
Expand Down Expand Up @@ -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');
}
Expand Down
34 changes: 33 additions & 1 deletion ts/test/test-async-context-frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions ts/test/test-otel-thread-ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading