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
56 changes: 46 additions & 10 deletions bindings/otel-thread-ctx.cc
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@

#include "otel-thread-ctx.hh"

#include "defer.hh"

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

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

CtxWrap::CtxWrap(OtelThreadCtxRecord* record, size_t capacity, bool truncated)
: record_(record), capacity_(capacity), truncated_(truncated) {}
: record_(record),
capacity_(capacity),
truncated_(truncated),
encoding_(false) {}

// Copy exactly `expected_bytes` bytes out of a JS Uint8Array (or subclass
// such as Buffer) into `out`. Returns false if the value isn't a
Expand Down Expand Up @@ -289,6 +304,7 @@ bool CtxWrap::EncodeAttrs(Isolate* isolate,
isolate->ThrowError("attributes array length must not exceed 256");
return false;
}

// Reserve a conservative upper bound; reallocations are cheap but
// unnecessary for the typical small attribute set.
out->reserve(out->size() + n * 4);
Expand All @@ -297,7 +313,6 @@ bool CtxWrap::EncodeAttrs(Isolate* isolate,
if (!attrs->Get(context, i).ToLocal(&val_val)) return false;
// null / undefined / array holes mean "no value at this key index".
if (val_val->IsUndefined() || val_val->IsNull()) continue;

Local<String> v;
if (!val_val->ToString(context).ToLocal(&v)) return false;
#if NODE_MAJOR_VERSION >= 24
Expand Down Expand Up @@ -356,9 +371,10 @@ void CtxWrap::New(const FunctionCallbackInfo<Value>& args) {
isolate->ThrowError("ThreadContext must be called with `new`");
return;
}
if (args.Length() != 3) {
if (args.Length() < 2 || args.Length() > 3) {
isolate->ThrowError(
"ThreadContext expects 3 arguments: traceId, spanId, attributes");
"ThreadContext expects 2 or 3 arguments: traceId, spanId, "
"attributes?");
return;
}

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

// Reject reentrant Append on the same wrap. EncodeAttrs' `ToString`
// below can execute user JS, and if that JS calls `appendAttributes`
// on this same ThreadContext, the reentrant call would grow
// attrs_data_size out from under the outer call's `current_used`
// snapshot, causing the outer memcpy to overwrite the reentrant call's
// bytes and the outer attrs_data_size write to shrink the record.
if (self->encoding_) {
isolate->ThrowError(
"reentrant appendAttributes on the same ThreadContext is not allowed");
return;
}
self->encoding_ = true;
defer {
self->encoding_ = false;
};

const size_t current_used = self->record_->attrs_data_size;
std::vector<uint8_t> appended;
bool truncated = false;
Expand Down Expand Up @@ -581,8 +613,6 @@ void ResetDiscoveryStruct(void* /*arg*/) {
}

void StoreAls(const FunctionCallbackInfo<Value>& args) {
static thread_local bool cleanup_registered = false;

Isolate* isolate = args.GetIsolate();
if (!args[0]->IsObject()) {
isolate->ThrowError("First argument must be the AsyncLocalStorage object.");
Expand All @@ -604,15 +634,21 @@ void StoreAls(const FunctionCallbackInfo<Value>& args) {
// addon compiles on the older Node versions the package supports.
otel_thread_ctx_nodejs_v1.cped_slot = nullptr;
#endif
// `undefined_addr == 0` doubles as the "not yet initialized on this
// isolate" flag: it starts at zero (thread-local zero-init), any real
// V8 undefined singleton address is non-zero, and ResetDiscoveryStruct
// clears it back to zero — so a subsequent StoreAls (e.g. isolate
// tear-down then re-init on the same thread) re-registers the cleanup
// hook. Register BEFORE the write so the flag transition is the last
// observable step.
if (otel_thread_ctx_nodejs_v1.undefined_addr == 0) {
node::AddEnvironmentCleanupHook(isolate, ResetDiscoveryStruct, nullptr);
}
// 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.
otel_thread_ctx_nodejs_v1.undefined_addr =
reinterpret_cast<v8::internal::Address>(*v8::Undefined(isolate));
if (!cleanup_registered) {
node::AddEnvironmentCleanupHook(isolate, ResetDiscoveryStruct, nullptr);
cleanup_registered = true;
}
}

// Without a function that explicitly reads the TLS variable, on x86 the
Expand Down
5 changes: 5 additions & 0 deletions bindings/profilers/heap.cc
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ struct HeapProfilerState {
}
if (isolate) {
isolate->AddNearHeapLimitCallback(&NearHeapLimit, nullptr);
// Restore the original heap limit once live old-generation usage falls
// below 90% of the original limit. The threshold controls when V8
// restores the limit, not the restored limit size.
constexpr double kHeapLimitRestoreThreshold = 0.90;
isolate->AutomaticallyRestoreInitialHeapLimit(kHeapLimitRestoreThreshold);
callbackInstalled = true;
}
}
Expand Down
44 changes: 22 additions & 22 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
{
"name": "@datadog/pprof",
"version": "5.16.0",
"version": "5.16.1",
"description": "pprof support for Node.js",
"repository": {
"type": "git",
"url": "git+https://github.com/DataDog/pprof-nodejs.git"
},
"main": "out/src/index.js",
"types": "out/src/index.d.ts",
"gypfile": false,
"scripts": {
"build:asan": "node-gyp configure build --jobs=max --address_sanitizer",
"build:tsan": "node-gyp configure build --jobs=max --thread_sanitizer",
Expand Down Expand Up @@ -42,20 +43,20 @@
},
"devDependencies": {
"@types/mocha": "^10.0.1",
"@types/node": "25.9.2",
"@types/node": "26.1.0",
"@types/semver": "^7.5.8",
"@types/sinon": "^21.0.1",
"@types/tmp": "^0.2.3",
"clang-format": "^1.8.0",
"codecov": "^3.8.3",
"deep-copy": "^1.4.2",
"eslint-plugin-n": "^18.0.1",
"eslint-plugin-n": "^18.2.1",
"gts": "^7.0.0",
"js-green-licenses": "^4.0.0",
"mocha": "^11.7.6",
"nan": "^2.27.0",
"nan": "^2.28.0",
"nyc": "^18.0.0",
"semver": "^7.8.1",
"semver": "^7.8.5",
"sinon": "^22.0.0",
"source-map-support": "^0.5.21",
"tmp": "0.2.7",
Expand Down
85 changes: 85 additions & 0 deletions ts/test/oom-restore-heap-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Copyright 2026 Datadog, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

'use strict';

import * as v8 from 'v8';

import {heap} from '../src/index';

const MB = 1024 * 1024;
const LIMIT_TOLERANCE = 16 * MB;
const CHUNK_SIZE = 4 * MB;
const gc = (global as typeof globalThis & {gc?: () => void}).gc;

function heapLimit() {
return v8.getHeapStatistics().heap_size_limit;
}

function allocateChunk() {
const chunk = new Array<number>(CHUNK_SIZE / 8);
for (let i = 0; i < chunk.length; i++) {
chunk[i] = i + 0.1;
}
return chunk;
}

async function main() {
if (!gc) {
throw new Error('This test must be run with --expose-gc.');
}

heap.start(MB, 64);
try {
heap.monitorOutOfMemory(64 * MB, 1, false);

const initialLimit = heapLimit();
const retained: number[][] = [];

while (
heapLimit() <= initialLimit + LIMIT_TOLERANCE &&
retained.length < 64
) {
retained.push(allocateChunk());
}

const increasedLimit = heapLimit();
if (increasedLimit <= initialLimit + LIMIT_TOLERANCE) {
throw new Error(`Heap limit did not increase from ${initialLimit}.`);
}

retained.length = 0;
for (let i = 0; i < 100; i++) {
gc();
if (heapLimit() <= initialLimit + LIMIT_TOLERANCE) {
return;
}
await new Promise(resolve => setTimeout(resolve, 20));
}

throw new Error(
`Heap limit increased to ${increasedLimit} but did not restore to ` +
`${initialLimit}.`,
);
} finally {
heap.stop();
}
}

main().catch(err => {
console.error(err.stack || err.message);
process.exitCode = 1;
});
Loading
Loading