Skip to content

Commit 122a06a

Browse files
committed
Add ThreadContext.invalidate() to flip record valid byte in place
When an SDK finishes a span, calling clearContext() on the current async-context frame detaches the ThreadContext only from that frame. Sibling and detached-continuation frames that already inherited the reference keep holding the same JS object — and with it the same underlying native record — so an out-of-process reader sampling those threads still sees the finished span's trace / span IDs as active. invalidate() writes 0 to the record's `valid` header byte in place, using the same volatile+atomic_signal_fence protocol the constructor and AppendAttributes() use for header bytes readers may race with. Because every async-context frame holding this ThreadContext reference observes the same shared record buffer, a single invalidate() drops the record out of scope for every such frame at once — readers see valid=0 and MUST ignore the record per OTEP-4947. The method is idempotent, safe under repeated calls, and orthogonal to attrs_data mutation: appendAttributes after invalidate is still observable in the record bytes, but readers honor the valid=0 flag regardless.
1 parent 021fdac commit 122a06a

5 files changed

Lines changed: 82 additions & 0 deletions

File tree

js/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,16 @@ Instance methods:
126126
internal record pointer in place — the JS object stays the same — so no
127127
per-frame divergence.
128128

129+
- **`invalidate()`** — mark this record's `valid` byte as 0 in place.
130+
Every async-context frame that still holds this `ThreadContext`
131+
reference (including those that merely inherited it verbatim from a
132+
parent frame) will subsequently present the same shared record to a
133+
reader, so this one call drops the record out of scope for every such
134+
frame at once. Intended for the span-finish path, where clearing only
135+
the current frame's context via `clearContext()` would leave
136+
sibling and detached-continuation frames still exposing the finished
137+
span. Idempotent.
138+
129139
- **`isTruncated()`** — returns `true` if at any point in this record's
130140
lifetime — either at construction or in a subsequent `appendAttributes`
131141
call — at least one attribute had to be dropped because it would have

js/addon.cpp

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ class CtxWrap : public ObjectWrap {
159159
static void New(const FunctionCallbackInfo<Value>& args);
160160
static void DebugBytes(const FunctionCallbackInfo<Value>& args);
161161
static void AppendAttributes(const FunctionCallbackInfo<Value>& args);
162+
static void Invalidate(const FunctionCallbackInfo<Value>& args);
162163
static void IsTruncated(const FunctionCallbackInfo<Value>& args);
163164

164165
// Encode the JS array at `attrs_val` into `out` as packed (key, len, value)
@@ -516,6 +517,25 @@ void CtxWrap::AppendAttributes(const FunctionCallbackInfo<Value>& args) {
516517
free(old_rec);
517518
}
518519

520+
// Mark this record's `valid` byte as 0 in place. Every async-context
521+
// frame that holds this ThreadContext reference — including those that
522+
// merely inherited it verbatim from a parent frame — will subsequently
523+
// present the same shared record to a reader, so this one write drops
524+
// the record out of scope for every such frame at once. Intended for
525+
// span-finish, where clearing the current frame's context via
526+
// `clearContext()` alone leaves sibling / detached-continuation frames
527+
// still exposing the finished span. Idempotent; safe to call multiple
528+
// times.
529+
void CtxWrap::Invalidate(const FunctionCallbackInfo<Value>& args) {
530+
CtxWrap* self = ObjectWrap::Unwrap<CtxWrap>(args.This());
531+
if (!self) {
532+
args.GetIsolate()->ThrowError("not a ThreadContext");
533+
return;
534+
}
535+
std::atomic_signal_fence(std::memory_order_release);
536+
*reinterpret_cast<volatile uint8_t*>(&self->record_->valid) = 0;
537+
}
538+
519539
// Returns true if any attribute was ever dropped from this wrapper's
520540
// record because it would have pushed attrs_data past the cap — set during
521541
// CtxWrap::New() if the initial set didn't fit, or by any subsequent
@@ -560,6 +580,9 @@ void CtxWrap::Init(Local<Object> exports) {
560580
tpl->PrototypeTemplate()->Set(
561581
String::NewFromUtf8Literal(isolate, "appendAttributes"),
562582
FunctionTemplate::New(isolate, AppendAttributes));
583+
tpl->PrototypeTemplate()->Set(
584+
String::NewFromUtf8Literal(isolate, "invalidate"),
585+
FunctionTemplate::New(isolate, Invalidate));
563586
tpl->PrototypeTemplate()->Set(
564587
String::NewFromUtf8Literal(isolate, "isTruncated"),
565588
FunctionTemplate::New(isolate, IsTruncated));

js/index.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,19 @@ export interface ThreadContext {
7878
*/
7979
appendAttributes(attributes: Array<string | null | undefined> | undefined): void;
8080

81+
/**
82+
* Mark this record's underlying `valid` byte as 0 in place. Every
83+
* async-context frame that still holds this `ThreadContext` reference —
84+
* including those that inherited it verbatim from a parent frame —
85+
* will subsequently present a record with `valid = 0` to a reader, so
86+
* this one call drops the record out of scope for every such frame at
87+
* once. Intended for the span-finish path, where clearing only the
88+
* current frame's context via {@link clearContext} would leave
89+
* sibling / detached-continuation frames still exposing the finished
90+
* span's trace / span IDs. Idempotent.
91+
*/
92+
invalidate(): void;
93+
8194
/**
8295
* True if at any point in this context's lifetime — either at
8396
* construction or in a subsequent {@link appendAttributes} call — at

js/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ if (process.platform === 'linux') {
9595
// AsyncLocalStorage.
9696
class NoopThreadContext {
9797
appendAttributes() {}
98+
invalidate() {}
9899
isTruncated() { return false; }
99100
debugBytes() { return new Uint8Array(0); }
100101
enter() {}

js/test/test.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,41 @@ test('appendAttributes propagates through async continuations', async () => {
560560
});
561561
});
562562

563+
test('invalidate flips the record\'s valid byte to 0 in place', () => {
564+
// The invalidation is visible across every async-context frame that
565+
// holds the same ThreadContext reference — nothing about the async
566+
// scope changes, only the shared record's `valid` header byte.
567+
const ctx = new ThreadContext(TRACE_ID_BYTES, SPAN_ID_BYTES);
568+
ctx.run(() => {
569+
assert.equal(decodeHeader(_currentRecordBytes()).valid, 1);
570+
ctx.invalidate();
571+
assert.equal(decodeHeader(_currentRecordBytes()).valid, 0);
572+
});
573+
});
574+
575+
test('invalidate is idempotent', () => {
576+
const ctx = new ThreadContext(TRACE_ID_BYTES, SPAN_ID_BYTES);
577+
ctx.run(() => {
578+
ctx.invalidate();
579+
ctx.invalidate();
580+
assert.equal(decodeHeader(_currentRecordBytes()).valid, 0);
581+
});
582+
});
583+
584+
test('appendAttributes after invalidate mutates attrs_data but leaves valid=0', () => {
585+
// The addon separates `valid` from the attrs-append path: an
586+
// invalidated record's `attrs_data_size` can still grow, but readers
587+
// MUST honor `valid == 0` and ignore the record anyway.
588+
const ctx = new ThreadContext(TRACE_ID_BYTES, SPAN_ID_BYTES);
589+
ctx.run(() => {
590+
ctx.invalidate();
591+
ctx.appendAttributes([, 'late']);
592+
const hdr = decodeHeader(_currentRecordBytes());
593+
assert.equal(hdr.valid, 0);
594+
assert.equal(hdr.attrsDataSize, 6); // key(1) + len(1) + 'late'(4)
595+
});
596+
});
597+
563598
test('otel_thread_ctx_nodejs_v1 is exported as a TLS dynsym', (t) => {
564599
const addon = path.join(__dirname, '..', 'build', 'Release', 'customlabels.node');
565600
if (!require('node:fs').existsSync(addon)) {

0 commit comments

Comments
 (0)