Skip to content

Commit d46220d

Browse files
committed
otel-thread-ctx: address review feedback from #347 (#366)
* Address review feedback from PR #347 Four independently-flagged concerns: - Accept optional third `attributes` arg in ThreadContext constructor. The TS ThreadContextCtor.new type declares `attributes` optional, but the C++ side hard-errored on `args.Length() != 3`. Loosen to accept 2 or 3 args; EncodeAttrs already handled undefined/null attrs_val correctly. - Fold `cleanup_registered` into `undefined_addr`. Uses the field's zero / non-zero state as the 'already-registered' flag: it starts at zero, ResetDiscoveryStruct clears it back to zero (so a re-init on the same thread would re-register), and any real V8 undefined singleton address is non-zero. Removes the separate thread_local static. - Coerce attribute values to strings in a pre-pass in EncodeAttrs before writing to the output buffer. Value->ToString may execute user JS (custom toString methods) which could re-enter into the ThreadContext via appendAttributes and interleave with our writes. Separating the coerce phase from the encode phase keeps the encode phase re-entrancy-free. - Make the 'enterWithContext attaches the record to the current async scope' test callback `async` and `await tcRun(...)`. Previously the callback returned a promise via `void tcRun(...)` and the promise's inner `.then` assertion could fire an unhandled rejection instead of a test failure. * Explicitly guard against reentrant Append calls. Also undo the two-phase encoding loop that was the previous (insufficient) attempt at fixing the reentrancy.
1 parent 6c92834 commit d46220d

2 files changed

Lines changed: 48 additions & 12 deletions

File tree

bindings/otel-thread-ctx.cc

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131

3232
#include "otel-thread-ctx.hh"
3333

34+
#include "defer.hh"
35+
3436
#include <node.h>
3537
#include <node_object_wrap.h>
3638
#include <v8-internal.h>
@@ -225,6 +227,16 @@ class CtxWrap : public ObjectWrap {
225227
// attribute had to be dropped because it would have pushed attrs_data
226228
// past MAX_ATTRS_DATA_SIZE.
227229
bool truncated_;
230+
// Reentrancy guard for Append(). EncodeAttrs calls ToString on each
231+
// attribute value, which can execute user JS (e.g. a custom
232+
// `toString`) that in turn calls `appendAttributes` on the same
233+
// ThreadContext. A reentrant Append would mutate attrs_data_size out
234+
// from under the outer call's `current_used` snapshot, causing the
235+
// outer memcpy to overwrite the reentrant call's bytes and the outer
236+
// attrs_data_size write to shrink the record. We reject the reentrant
237+
// call instead. New() doesn't need the guard because a freshly constructed
238+
// CtxWrap isn't observable to JS until New() returns.
239+
bool encoding_;
228240
};
229241

230242
// Pin the offset of `record_` — the field the reader walks to from the
@@ -247,7 +259,10 @@ CtxWrap::~CtxWrap() {
247259
}
248260

249261
CtxWrap::CtxWrap(OtelThreadCtxRecord* record, size_t capacity, bool truncated)
250-
: record_(record), capacity_(capacity), truncated_(truncated) {}
262+
: record_(record),
263+
capacity_(capacity),
264+
truncated_(truncated),
265+
encoding_(false) {}
251266

252267
// Copy exactly `expected_bytes` bytes out of a JS Uint8Array (or subclass
253268
// such as Buffer) into `out`. Returns false if the value isn't a
@@ -289,6 +304,7 @@ bool CtxWrap::EncodeAttrs(Isolate* isolate,
289304
isolate->ThrowError("attributes array length must not exceed 256");
290305
return false;
291306
}
307+
292308
// Reserve a conservative upper bound; reallocations are cheap but
293309
// unnecessary for the typical small attribute set.
294310
out->reserve(out->size() + n * 4);
@@ -297,7 +313,6 @@ bool CtxWrap::EncodeAttrs(Isolate* isolate,
297313
if (!attrs->Get(context, i).ToLocal(&val_val)) return false;
298314
// null / undefined / array holes mean "no value at this key index".
299315
if (val_val->IsUndefined() || val_val->IsNull()) continue;
300-
301316
Local<String> v;
302317
if (!val_val->ToString(context).ToLocal(&v)) return false;
303318
#if NODE_MAJOR_VERSION >= 24
@@ -356,9 +371,10 @@ void CtxWrap::New(const FunctionCallbackInfo<Value>& args) {
356371
isolate->ThrowError("ThreadContext must be called with `new`");
357372
return;
358373
}
359-
if (args.Length() != 3) {
374+
if (args.Length() < 2 || args.Length() > 3) {
360375
isolate->ThrowError(
361-
"ThreadContext expects 3 arguments: traceId, spanId, attributes");
376+
"ThreadContext expects 2 or 3 arguments: traceId, spanId, "
377+
"attributes?");
362378
return;
363379
}
364380

@@ -438,6 +454,22 @@ void CtxWrap::Append(const FunctionCallbackInfo<Value>& args) {
438454
return;
439455
}
440456

457+
// Reject reentrant Append on the same wrap. EncodeAttrs' `ToString`
458+
// below can execute user JS, and if that JS calls `appendAttributes`
459+
// on this same ThreadContext, the reentrant call would grow
460+
// attrs_data_size out from under the outer call's `current_used`
461+
// snapshot, causing the outer memcpy to overwrite the reentrant call's
462+
// bytes and the outer attrs_data_size write to shrink the record.
463+
if (self->encoding_) {
464+
isolate->ThrowError(
465+
"reentrant appendAttributes on the same ThreadContext is not allowed");
466+
return;
467+
}
468+
self->encoding_ = true;
469+
defer {
470+
self->encoding_ = false;
471+
};
472+
441473
const size_t current_used = self->record_->attrs_data_size;
442474
std::vector<uint8_t> appended;
443475
bool truncated = false;
@@ -581,8 +613,6 @@ void ResetDiscoveryStruct(void* /*arg*/) {
581613
}
582614

583615
void StoreAls(const FunctionCallbackInfo<Value>& args) {
584-
static thread_local bool cleanup_registered = false;
585-
586616
Isolate* isolate = args.GetIsolate();
587617
if (!args[0]->IsObject()) {
588618
isolate->ThrowError("First argument must be the AsyncLocalStorage object.");
@@ -604,15 +634,21 @@ void StoreAls(const FunctionCallbackInfo<Value>& args) {
604634
// addon compiles on the older Node versions the package supports.
605635
otel_thread_ctx_nodejs_v1.cped_slot = nullptr;
606636
#endif
637+
// `undefined_addr == 0` doubles as the "not yet initialized on this
638+
// isolate" flag: it starts at zero (thread-local zero-init), any real
639+
// V8 undefined singleton address is non-zero, and ResetDiscoveryStruct
640+
// clears it back to zero — so a subsequent StoreAls (e.g. isolate
641+
// tear-down then re-init on the same thread) re-registers the cleanup
642+
// hook. Register BEFORE the write so the flag transition is the last
643+
// observable step.
644+
if (otel_thread_ctx_nodejs_v1.undefined_addr == 0) {
645+
node::AddEnvironmentCleanupHook(isolate, ResetDiscoveryStruct, nullptr);
646+
}
607647
// Cache the per-isolate undefined singleton's tagged address. Undefined
608648
// is a read-only-roots heap object, never moves, so a cached numeric
609649
// address is fine — no Global<> tracking needed.
610650
otel_thread_ctx_nodejs_v1.undefined_addr =
611651
reinterpret_cast<v8::internal::Address>(*v8::Undefined(isolate));
612-
if (!cleanup_registered) {
613-
node::AddEnvironmentCleanupHook(isolate, ResetDiscoveryStruct, nullptr);
614-
cleanup_registered = true;
615-
}
616652
}
617653

618654
// Without a function that explicitly reads the TLS variable, on x86 the

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -424,8 +424,8 @@ function captureBytes(opts: {
424424
});
425425

426426
describe('enterWithContext', () => {
427-
it('attaches the record to the current async scope', () => {
428-
void tcRun(
427+
it('attaches the record to the current async scope', async () => {
428+
await tcRun(
429429
() => {
430430
strictAssert.deepEqual(
431431
decodeHeader(_currentRecordBytes()!).spanId,

0 commit comments

Comments
 (0)