diff --git a/bindings/allocation-profile.cc b/bindings/allocation-profile.cc index 5ca03727..e357f0e4 100644 --- a/bindings/allocation-profile.cc +++ b/bindings/allocation-profile.cc @@ -23,7 +23,6 @@ using namespace v8; namespace dd { -namespace { Local CreateAllocationObject(Isolate* isolate, const AllocationProfileNodeStats& stats) { Local alloc_obj = Object::New(isolate); @@ -41,7 +40,6 @@ Local CreateAllocationObject(Isolate* isolate, Number::New(isolate, static_cast(stats.alloc_space_bytes))); return alloc_obj; } -} // namespace AllocationProfileNodeStatsMap BuildAllocationStatsByNodeId( const std::vector& samples) { diff --git a/bindings/allocation-profile.hh b/bindings/allocation-profile.hh index 130a4421..7861b704 100644 --- a/bindings/allocation-profile.hh +++ b/bindings/allocation-profile.hh @@ -45,4 +45,7 @@ v8::Local TranslateAllocationStats( v8::Isolate* isolate, const AllocationProfileSizeStatsMap* allocation_stats); +v8::Local CreateAllocationObject( + v8::Isolate* isolate, const AllocationProfileNodeStats& stats); + } // namespace dd diff --git a/bindings/profilers/near-oom.cc b/bindings/profilers/near-oom.cc index e03094e1..69b664fe 100644 --- a/bindings/profilers/near-oom.cc +++ b/bindings/profilers/near-oom.cc @@ -16,6 +16,7 @@ #include "near-oom.hh" +#include "allocation-profile.hh" #include "defer.hh" #include "heap.hh" #include "per-isolate-data.hh" @@ -162,7 +163,7 @@ static int CreateTempFile(uv_loop_t& loop, std::string& filepath) { nullptr); uv_fs_req_cleanup(&fs_req); if (fd >= 0) { - return r; + return fd; } if (fd != UV_EEXIST) { fprintf(stderr, "Failed to create temp file: %s\n", uv_strerror(fd)); @@ -193,6 +194,10 @@ static void ExportProfile(HeapProfilerState& state) { return; } FILE* file = fdopen(fd, "w"); + if (!file) { + fprintf(stderr, "Failed to open temp file: %s\n", strerror(errno)); + return; + } dumpAllocationProfileAsJSON(file, state.profile.get()); fclose(file); std::vector args; @@ -259,6 +264,47 @@ static size_t ExtendedHeapLimit(size_t current_heap_limit, size_t extension) { : current_heap_limit + extension; } +static void CaptureProfile(v8::Isolate* isolate, + const std::shared_ptr& state) { + // Drop the superseded capture before v8 allocates the next one. + state->profile.reset(); + std::unique_ptr profile{ + isolate->GetHeapProfiler()->GetAllocationProfile()}; + if (!profile) { + fprintf(stderr, + "NearHeapLimit: heap profiler is not enabled, no allocation " + "profile to report\n"); + return; + } + + // Only the JS callback reads stats; dump and export use Node::allocations. + const bool with_stats = state->allocations && !state->callback.IsEmpty(); + AllocationProfileNodeStatsMap allocation_stats; + if (with_stats) { + allocation_stats = BuildAllocationStatsByNodeId(profile->GetSamples()); + } + state->profile = TranslateAllocationProfileToCpp( + profile->GetRootNode(), with_stats ? &allocation_stats : nullptr); + + if (state->dumpProfileOnStderr) { + dumpAllocationProfile(stderr, state->profile.get()); + } + if (!state->export_command.empty()) { + ExportProfile(*state); + } + + if (state->callback.IsEmpty()) { + state->profile.reset(); + return; + } + if (state->callbackMode & kInterruptCallback) { + isolate->RequestInterrupt(InterruptCallback, nullptr); + } + if (state->callbackMode & kAsyncCallback) { + uv_async_send(state->async); + } +} + size_t NearHeapLimit(void* data, size_t current_heap_limit, size_t initial_heap_limit) { @@ -337,42 +383,8 @@ size_t NearHeapLimit(void* data, stats.object_count()); } } - // GetAllocationProfile returns null when V8's sampling heap profiler isn't - // running, and that can happen while this callback is still installed: - // HeapProfilerCleanupHook stops V8's sampler without touching our state, so - // between that hook and the isolate actually going away we stay registered - // with nothing to sample. The heap-limit bookkeeping below still has to run, - // so skip only the profile-dependent work. - std::unique_ptr profile{ - isolate->GetHeapProfiler()->GetAllocationProfile()}; - if (profile) { - state->profile = TranslateAllocationProfileToCpp(profile->GetRootNode()); - if (state->dumpProfileOnStderr) { - dumpAllocationProfile(stderr, state->profile.get()); - } - - if (!state->export_command.empty()) { - ExportProfile(*state); - } - - if (!state->callback.IsEmpty()) { - if (state->callbackMode & kInterruptCallback) { - isolate->RequestInterrupt(InterruptCallback, nullptr); - } - if (state->callbackMode & kAsyncCallback) { - uv_async_send(state->async); - } - } else { - state->profile.reset(); - } - } else { - // Drop any profile retained from an earlier invocation: it is stale, and - // nothing below is going to consume or replace it. - state->profile.reset(); - fprintf(stderr, - "NearHeapLimit: heap profiler is not enabled, no allocation " - "profile to report\n"); - } + // Capture now; the event loop may never run. insideCallback guards re-entry. + CaptureProfile(isolate, state); if (!state->isMainThread) { // In worker thread, OOM is not fatal to the whole process and will only @@ -441,6 +453,9 @@ NAN_METHOD(HeapProfiler::MonitorOutOfMemory) { state = std::make_shared(isolate); } + // Uninstall while reconfiguring: a hit here would see a half-applied config. + state->UninstallNearHeapLimitCallback(); + state->current_heap_extension_count = 0; state->automatic_heap_extension_size.reset(); state->profile.reset(); @@ -453,7 +468,6 @@ NAN_METHOD(HeapProfiler::MonitorOutOfMemory) { state->callbackMode = info[5].As()->Value(); state->isMainThread = info[6].As()->Value(); state->automatic_heap_extension = info[7].As()->Value(); - state->InstallNearHeapLimitCallback(); if (!info[4]->IsNullOrUndefined() && state->callbackMode != kNoCallback) { state->callback.Reset(Nan::To(info[4]).ToLocalChecked()); } @@ -470,6 +484,7 @@ NAN_METHOD(HeapProfiler::MonitorOutOfMemory) { if (!state->callback.IsEmpty() && (state->callbackMode & kAsyncCallback)) { state->RegisterAsyncCallback(); } + state->InstallNearHeapLimitCallback(); } void InterruptCallback(v8::Isolate* isolate, void* data) { @@ -480,12 +495,13 @@ void InterruptCallback(v8::Isolate* isolate, void* data) { if (!state || !state->profile) { return; } + // Own it first: translating and the callback can re-enter NearHeapLimit. + auto profile = std::move(state->profile); + v8::Local argv[1] = { - dd::TranslateAllocationProfile(state->profile.get())}; + dd::TranslateAllocationProfile(profile.get())}; Nan::AsyncResource resource("NearHeapLimit"); state->callback.Call(1, argv, &resource); - // Release the retained native profile once the callback has been invoked. - state->profile.reset(); } void AsyncCallback(uv_async_t* handle) { diff --git a/bindings/translate-heap-profile.cc b/bindings/translate-heap-profile.cc index ad4ed0e0..eabf1fe9 100644 --- a/bindings/translate-heap-profile.cc +++ b/bindings/translate-heap-profile.cc @@ -21,6 +21,12 @@ namespace dd { namespace { +const AllocationProfileSizeStatsMap* FindNodeStats( + const AllocationProfileNodeStatsMap& allocation_stats, uint32_t node_id) { + auto node_stats = allocation_stats.find(node_id); + return node_stats == allocation_stats.end() ? nullptr : &node_stats->second; +} + class HeapProfileTranslator : ProfileTranslator { #define NODE_FIELDS \ X(name) \ @@ -41,37 +47,9 @@ class HeapProfileTranslator : ProfileTranslator { #undef X public: - v8::Local TranslateAllocationProfile( - v8::AllocationProfile::Node* node) { - v8::Local children = NewArray(node->children.size()); - for (size_t i = 0; i < node->children.size(); i++) { - Set(children, i, TranslateAllocationProfile(node->children[i])); - } - - v8::Local allocations = NewArray(node->allocations.size()); - for (size_t i = 0; i < node->allocations.size(); i++) { - auto alloc = node->allocations[i]; - Set(allocations, - i, - CreateAllocation(NewNumber(alloc.count), NewNumber(alloc.size))); - } - - return CreateNode(node->name, - node->script_name, - NewInteger(node->script_id), - NewInteger(node->line_number), - NewInteger(node->column_number), - children, - allocations); - } - v8::Local TranslateAllocationProfile( v8::AllocationProfile::Node* node, const AllocationProfileNodeStatsMap* allocation_stats) { - if (!allocation_stats) { - return TranslateAllocationProfile(node); - } - v8::Local children = NewArray(node->children.size()); for (size_t i = 0; i < node->children.size(); i++) { Set(children, @@ -79,18 +57,17 @@ class HeapProfileTranslator : ProfileTranslator { TranslateAllocationProfile(node->children[i], allocation_stats)); } - auto node_stats = allocation_stats->find(node->node_id); - v8::Local allocations = TranslateAllocationStats( - isolate, - node_stats == allocation_stats->end() ? nullptr : &node_stats->second); - - return CreateNode(node->name, - node->script_name, - NewInteger(node->script_id), - NewInteger(node->line_number), - NewInteger(node->column_number), - children, - allocations); + return CreateNode( + node->name, + node->script_name, + NewInteger(node->script_id), + NewInteger(node->line_number), + NewInteger(node->column_number), + children, + allocation_stats + ? TranslateAllocationStats( + isolate, FindNodeStats(*allocation_stats, node->node_id)) + : TranslateAllocations(node->allocations)); } v8::Local TranslateAllocationProfile(Node* node) { @@ -101,10 +78,12 @@ class HeapProfileTranslator : ProfileTranslator { v8::Local allocations = NewArray(node->allocations.size()); for (size_t i = 0; i < node->allocations.size(); i++) { - auto alloc = node->allocations[i]; + const auto& alloc = node->allocations[i]; Set(allocations, i, - CreateAllocation(NewNumber(alloc.count), NewNumber(alloc.size))); + node->has_allocation_stats ? CreateAllocationStats(alloc) + : CreateAllocation(NewNumber(alloc.count), + NewNumber(alloc.size))); } return CreateNode(NewString(node->name.c_str()), @@ -117,6 +96,18 @@ class HeapProfileTranslator : ProfileTranslator { } private: + v8::Local TranslateAllocations( + const std::vector& node_allocations) { + v8::Local allocations = NewArray(node_allocations.size()); + for (size_t i = 0; i < node_allocations.size(); i++) { + auto alloc = node_allocations[i]; + Set(allocations, + i, + CreateAllocation(NewNumber(alloc.count), NewNumber(alloc.size))); + } + return allocations; + } + v8::Local CreateNode(v8::Local name, v8::Local scriptName, v8::Local scriptId, @@ -142,13 +133,24 @@ class HeapProfileTranslator : ProfileTranslator { return js_alloc; } + // Shares the object shape with the non-detached path in allocation-profile. + v8::Local CreateAllocationStats(const Node::Allocation& alloc) { + AllocationProfileNodeStats stats; + stats.inuse_objects = alloc.inuse_objects; + stats.alloc_objects = alloc.alloc_objects; + stats.inuse_space_bytes = alloc.inuse_objects * alloc.size; + stats.alloc_space_bytes = alloc.alloc_objects * alloc.size; + return CreateAllocationObject(isolate, stats); + } + public: explicit HeapProfileTranslator() {} }; } // namespace std::shared_ptr TranslateAllocationProfileToCpp( - v8::AllocationProfile::Node* node) { + v8::AllocationProfile::Node* node, + const AllocationProfileNodeStatsMap* allocation_stats) { auto new_node = std::make_shared(); new_node->line_number = node->line_number; new_node->column_number = node->column_number; @@ -160,12 +162,30 @@ std::shared_ptr TranslateAllocationProfileToCpp( new_node->children.reserve(node->children.size()); for (auto& child : node->children) { - new_node->children.push_back(TranslateAllocationProfileToCpp(child)); + new_node->children.push_back( + TranslateAllocationProfileToCpp(child, allocation_stats)); } + // Join now: the samples these node_ids key into are freed on return. + new_node->has_allocation_stats = allocation_stats != nullptr; + const auto* stats = allocation_stats + ? FindNodeStats(*allocation_stats, node->node_id) + : nullptr; new_node->allocations.reserve(node->allocations.size()); - for (auto& allocation : node->allocations) { - new_node->allocations.push_back(allocation); + for (const auto& alloc : node->allocations) { + Node::Allocation out; + out.size = alloc.size; + out.count = alloc.count; + out.inuse_objects = alloc.count; + out.alloc_objects = alloc.count; + if (stats) { + auto size_stats = stats->find(alloc.size); + if (size_stats != stats->end()) { + out.inuse_objects = size_stats->second.inuse_objects; + out.alloc_objects = size_stats->second.alloc_objects; + } + } + new_node->allocations.push_back(out); } return new_node; } diff --git a/bindings/translate-heap-profile.hh b/bindings/translate-heap-profile.hh index 502c9195..648bfc05 100644 --- a/bindings/translate-heap-profile.hh +++ b/bindings/translate-heap-profile.hh @@ -27,7 +27,16 @@ namespace dd { struct Node { - using Allocation = v8::AllocationProfile::Allocation; + struct Allocation { + size_t size = 0; + // v8's per-size count, which the stderr dump and the export render. + uint32_t count = 0; + // Rebuilt from the profile's samples, and equal to count outside + // allocation mode, where v8 reports no live/allocated split. + uint64_t inuse_objects = 0; + uint64_t alloc_objects = 0; + }; + std::string name; std::string script_name; int line_number; @@ -35,10 +44,13 @@ struct Node { int script_id; std::vector> children; std::vector allocations; + // Set on every node iff the profile was captured in allocation mode. + bool has_allocation_stats = false; }; std::shared_ptr TranslateAllocationProfileToCpp( - v8::AllocationProfile::Node* node); + v8::AllocationProfile::Node* node, + const AllocationProfileNodeStatsMap* allocation_stats); v8::Local TranslateAllocationProfile(Node* node); v8::Local TranslateAllocationProfile( diff --git a/ts/src/heap-profiler-bindings.ts b/ts/src/heap-profiler-bindings.ts index cc5d0d4e..f78d0dcd 100644 --- a/ts/src/heap-profiler-bindings.ts +++ b/ts/src/heap-profiler-bindings.ts @@ -54,7 +54,9 @@ export function mapAllocationProfile( return profiler.heapProfiler.mapAllocationProfile(callback); } -export type NearHeapLimitCallback = (profile: AllocationProfileNode) => void; +export type NearHeapLimitCallback = ( + profile: AllocationProfileNode | AllocationProfileNodeWithStats, +) => void; export function monitorOutOfMemory( heapLimitExtensionSize: number, diff --git a/ts/src/heap-profiler.ts b/ts/src/heap-profiler.ts index e520d45e..7bf36125 100644 --- a/ts/src/heap-profiler.ts +++ b/ts/src/heap-profiler.ts @@ -298,7 +298,9 @@ export function monitorOutOfMemory( } let newCallback; if (typeof callback !== 'undefined') { - newCallback = (profile: AllocationProfileNode) => { + newCallback = ( + profile: AllocationProfileNode | AllocationProfileNodeWithStats, + ) => { callback(convertProfile(profile)); }; } diff --git a/ts/test/check_profile.ts b/ts/test/check_profile.ts index 801bb8ef..c75cb391 100644 --- a/ts/test/check_profile.ts +++ b/ts/test/check_profile.ts @@ -1,7 +1,19 @@ import fs from 'fs'; -if (fs.existsSync(process.argv[1])) { - fs.writeFileSync('oom_check.log', 'ok'); -} else { - fs.writeFileSync('oom_check.log', 'ko'); +// monitorOutOfMemory appends the profile path after the export command. +function check(): string { + const profile = fs.readFileSync(process.argv[2], 'utf8'); + JSON.parse(profile); + const counts = [...profile.matchAll(/"count":(\d+)/g)]; + return counts.some(match => Number(match[1]) > 0) + ? 'ok' + : 'ko: exported profile carried no allocations'; } + +let result: string; +try { + result = check(); +} catch (err) { + result = `ko: ${err}`; +} +fs.writeFileSync('oom_check.log', result); diff --git a/ts/test/oom-allocation-profile-sync.ts b/ts/test/oom-allocation-profile-sync.ts new file mode 100644 index 00000000..252ae53e --- /dev/null +++ b/ts/test/oom-allocation-profile-sync.ts @@ -0,0 +1,29 @@ +/* + * 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 {heap} from '../src/index'; +import {SAMPLE_INTERVAL_BYTES, leak} from './oom-profile-check'; + +// Never yields, so a deferred capture would dump nothing. No callback on +// purpose: delivering one needs heap room this process no longer has. +heap.start(SAMPLE_INTERVAL_BYTES, 64, true); +heap.monitorOutOfMemory('auto', 1, true); + +for (;;) { + leak(); +} diff --git a/ts/test/oom-inuse-profile.ts b/ts/test/oom-inuse-profile.ts new file mode 100644 index 00000000..d5045cda --- /dev/null +++ b/ts/test/oom-inuse-profile.ts @@ -0,0 +1,40 @@ +/* + * 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 {heap} from '../src/index'; +import { + SAMPLE_INTERVAL_BYTES, + checkInuseProfile, + leak, +} from './oom-profile-check'; + +// Same shape as the allocation fixtures, without allocation mode: covers the +// branch that renders v8's per-size counts instead of the sample stats. +heap.start(SAMPLE_INTERVAL_BYTES, 64); +heap.monitorOutOfMemory( + 'auto', + 1, + true, + undefined, + checkInuseProfile, + heap.CallbackMode.Interrupt, +); + +for (;;) { + leak(); +} diff --git a/ts/test/oom-profile-check.ts b/ts/test/oom-profile-check.ts new file mode 100644 index 00000000..6ad47c09 --- /dev/null +++ b/ts/test/oom-profile-check.ts @@ -0,0 +1,75 @@ +/* + * 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 fs from 'fs'; + +import {Profile} from 'pprof-format'; + +// Small on purpose: multi-megabyte chunks make the heap all-live, so the +// capture's forced GC frees nothing and v8 aborts on ineffective mark-compacts. +const CHUNK_BYTES = 256 * 1024; +export const SAMPLE_INTERVAL_BYTES = 64 * 1024; + +function expect(ok: boolean, message: string) { + if (!ok) { + fs.writeSync(1, `FAIL ${message}\n`); + process.exit(1); + } +} + +function done(marker: string): never { + fs.writeSync(1, `${marker}\n`); + process.exit(0); +} + +function expectTypes(profile: Profile, expected: string) { + const actual = profile.sampleType + .map(sampleType => profile.stringTable.strings[Number(sampleType.type)]) + .join(); + expect(actual === expected, `sample types were ${actual}`); +} + +function values(profile: Profile): number[][] { + return profile.sample.map(sample => sample.value.map(Number)); +} + +export function checkInuseProfile(profile: Profile) { + expectTypes(profile, 'inuse_objects,inuse_space'); + // The leak samples many objects per call site; "(external)" is always one. + expect( + values(profile).some(([inuseObjects]) => inuseObjects > 1), + 'no sample accounted for the leak', + ); + done('inuseProfileChecked'); +} + +const retained: number[][] = []; + +function allocateChunk(): number[] { + const chunk = new Array(CHUNK_BYTES / 8); + for (let i = 0; i < chunk.length; i++) { + chunk[i] = i + 0.1; + } + return chunk; +} + +export function leak() { + // Give one sampled call site both live and collected allocations. + retained.push(allocateChunk()); + allocateChunk(); +} diff --git a/ts/test/test-heap-profiler.ts b/ts/test/test-heap-profiler.ts index 26bcca70..cacc9fe4 100644 --- a/ts/test/test-heap-profiler.ts +++ b/ts/test/test-heap-profiler.ts @@ -377,10 +377,13 @@ describe('foreign heap sampler', () => { }); describe('OOMMonitoring', () => { - async function runOomFixture(script: string, heapLimitExtensionSize: string) { - const proc = fork(path.join(__dirname, script), [heapLimitExtensionSize], { + async function runOomFixture(script: string, ...args: string[]) { + const proc = fork(path.join(__dirname, script), args, { execArgv: ['--expose-gc', '--max-old-space-size=64'], silent: true, + // These fixtures end on process.exit() while holding the leak, so under + // asan LeakSanitizer would report it all and fail the child. ASAN stays on. + env: {...process.env, LSAN_OPTIONS: 'detect_leaks=0'}, }); let output = ''; @@ -394,7 +397,8 @@ describe('OOMMonitoring', () => { return new Promise<{code: number | null; output: string}>( (resolve, reject) => { proc.on('error', reject); - proc.on('exit', code => { + // 'close', not 'exit': stdio is only drained by then. + proc.on('close', code => { resolve({code, output}); }); }, @@ -475,6 +479,47 @@ describe('OOMMonitoring', () => { ); }); + // A dumped folded stack: " ", with a nonzero count. + const FOLDED_STACK_LINE = /allocateChunk:\d+ [1-9]\d* \d+/; + + async function assertOomProfileReported(script: string, marker: string) { + const {code, output} = await runOomFixture(script); + assert.strictEqual(code, 0, `fixture reported a failure\n${output}`); + assert.ok( + output.includes(marker), + `the OOM callback did not report a checked profile\n${output}`, + ); + assert.match( + output, + FOLDED_STACK_LINE, + `the folded stack dump carried no allocations\n${output}`, + ); + } + + // Regression guard: no yielding, so only an inline capture can dump. The + // fixture has no callback and is expected to die, so only the dump is read. + it('should capture the near-OOM profile without yielding', async function () { + if (Number(process.versions.node.split('.')[0]) < 26) { + this.skip(); + } + this.timeout(30000); + const {output} = await runOomFixture('oom-allocation-profile-sync.js'); + assert.match( + output, + FOLDED_STACK_LINE, + `the folded stack dump carried no allocations\n${output}`, + ); + }); + + // Not version-gated: this translation branch must hold on every version. + it('should report in-use stats in the near-OOM profile', async function () { + this.timeout(30000); + await assertOomProfileReported( + 'oom-inuse-profile.js', + 'inuseProfileChecked', + ); + }); + it('should call external process upon OOM', async function () { // this test is very slow on some configs (asan/valgrind) this.timeout(20000); @@ -495,7 +540,8 @@ describe('OOMMonitoring', () => { } }); }); - assert.equal(fs.readFileSync(checkFilePath), 'ok'); + const checkResult = fs.readFileSync(checkFilePath, 'utf8'); fs.unlinkSync(checkFilePath); + assert.strictEqual(checkResult, 'ok', `export check said: ${checkResult}`); }); });