diff --git a/bindings/profilers/near-oom.cc b/bindings/profilers/near-oom.cc index e03094e1..07c5fd73 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" @@ -259,6 +260,48 @@ 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, + bool deliver_async_callback) { + // Release a superseded capture before asking v8 to allocate the next one. + state->ResetProfile(); + std::unique_ptr profile{ + isolate->GetHeapProfiler()->GetAllocationProfile()}; + if (!profile) { + fprintf(stderr, + "NearHeapLimit: heap profiler is not enabled, no allocation " + "profile to report\n"); + return; + } + + state->profile = TranslateAllocationProfileToCpp(profile->GetRootNode()); + if (state->allocations) { + state->profile_allocation_stats = + BuildAllocationStatsByNodeId(profile->GetSamples()); + } + if (state->dumpProfileOnStderr) { + dumpAllocationProfile(stderr, state->profile.get()); + } + if (!state->export_command.empty()) { + ExportProfile(*state); + } + + if (state->callback.IsEmpty()) { + state->ResetProfile(); + return; + } + if (state->callbackMode & kInterruptCallback) { + isolate->RequestInterrupt(InterruptCallback, nullptr); + } + if (state->callbackMode & kAsyncCallback) { + if (deliver_async_callback) { + InterruptCallback(isolate, nullptr); + } else { + uv_async_send(state->async); + } + } +} + size_t NearHeapLimit(void* data, size_t current_heap_limit, size_t initial_heap_limit) { @@ -337,41 +380,11 @@ 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(); - } + // kSamplingForceGC needs the extension returned below to already be active. + if (state->allocations) { + uv_async_send(state->async); } 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"); + CaptureProfile(isolate, state, false); } if (!state->isMainThread) { @@ -443,7 +456,7 @@ NAN_METHOD(HeapProfiler::MonitorOutOfMemory) { state->current_heap_extension_count = 0; state->automatic_heap_extension_size.reset(); - state->profile.reset(); + state->ResetProfile(); state->export_command.clear(); state->callback.Reset(); @@ -453,7 +466,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()); } @@ -467,9 +479,11 @@ NAN_METHOD(HeapProfiler::MonitorOutOfMemory) { } } - if (!state->callback.IsEmpty() && (state->callbackMode & kAsyncCallback)) { + if (state->allocations || + (!state->callback.IsEmpty() && (state->callbackMode & kAsyncCallback))) { state->RegisterAsyncCallback(); } + state->InstallNearHeapLimitCallback(); } void InterruptCallback(v8::Isolate* isolate, void* data) { @@ -480,16 +494,28 @@ void InterruptCallback(v8::Isolate* isolate, void* data) { if (!state || !state->profile) { return; } - v8::Local argv[1] = { - dd::TranslateAllocationProfile(state->profile.get())}; + // Own the capture locally before translating: translation and the JS + // callback both allocate in the v8 heap and can re-enter NearHeapLimit, + // which overwrites these fields and would dangle or lose that capture. + auto profile = std::move(state->profile); + auto allocation_stats = std::move(state->profile_allocation_stats); + state->ResetProfile(); + + v8::Local argv[1] = {dd::TranslateAllocationProfile( + profile.get(), allocation_stats ? &*allocation_stats : nullptr)}; 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) { - InterruptCallback(v8::Isolate::GetCurrent(), nullptr); + auto isolate = v8::Isolate::GetCurrent(); + v8::HandleScope scope(isolate); + auto state = PerIsolateData::For(isolate)->GetHeapProfilerState(); + if (state && state->allocations) { + CaptureProfile(isolate, state, true); + return; + } + InterruptCallback(isolate, nullptr); } } // namespace dd diff --git a/bindings/profilers/near-oom.hh b/bindings/profilers/near-oom.hh index ce9008c2..ee0baa92 100644 --- a/bindings/profilers/near-oom.hh +++ b/bindings/profilers/near-oom.hh @@ -100,8 +100,13 @@ struct HeapProfilerState { uv_unref(reinterpret_cast(async)); } - void OnNewProfile() { + void ResetProfile() { profile.reset(); + profile_allocation_stats.reset(); + } + + void OnNewProfile() { + ResetProfile(); // Only (re)install the NearHeapLimit callback when OOM monitoring is // configured. Otherwise a plain start()+profile() flow would silently // register a callback that the user never asked for. @@ -120,6 +125,9 @@ struct HeapProfilerState { uint32_t current_heap_extension_count = 0; uv_async_t* async = nullptr; std::shared_ptr profile; + // Engaged iff |profile| was captured in allocation mode, so delivery reads + // the capture's own mode rather than |allocations|, which may have moved on. + std::optional profile_allocation_stats; std::vector export_command; bool allocations = false; bool dumpProfileOnStderr = false; diff --git a/bindings/translate-heap-profile.cc b/bindings/translate-heap-profile.cc index bd97d549..68c546a7 100644 --- a/bindings/translate-heap-profile.cc +++ b/bindings/translate-heap-profile.cc @@ -42,18 +42,13 @@ class HeapProfileTranslator : ProfileTranslator { public: v8::Local TranslateAllocationProfile( - v8::AllocationProfile::Node* node) { + v8::AllocationProfile::Node* node, + const AllocationProfileNodeStatsMap* allocation_stats) { 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, + Set(children, i, - CreateAllocation(NewNumber(alloc.count), NewNumber(alloc.size))); + TranslateAllocationProfile(node->children[i], allocation_stats)); } return CreateNode(node->name, @@ -62,61 +57,53 @@ class HeapProfileTranslator : ProfileTranslator { NewInteger(node->line_number), NewInteger(node->column_number), children, - allocations); + TranslateAllocations( + node->node_id, node->allocations, allocation_stats)); } v8::Local TranslateAllocationProfile( - v8::AllocationProfile::Node* node, - const AllocationProfileNodeStatsMap* allocation_stats) { - if (!allocation_stats) { - return TranslateAllocationProfile(node); - } - + Node* node, const AllocationProfileNodeStatsMap* allocation_stats) { v8::Local children = NewArray(node->children.size()); for (size_t i = 0; i < node->children.size(); i++) { Set(children, i, - TranslateAllocationProfile(node->children[i], allocation_stats)); + TranslateAllocationProfile(node->children[i].get(), + 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, + return CreateNode(NewString(node->name.c_str()), + NewString(node->script_name.c_str()), NewInteger(node->script_id), NewInteger(node->line_number), NewInteger(node->column_number), children, - allocations); + TranslateAllocations( + node->node_id, node->allocations, allocation_stats)); } - v8::Local TranslateAllocationProfile(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].get())); + private: + v8::Local TranslateAllocations( + uint32_t node_id, + const std::vector& node_allocations, + const AllocationProfileNodeStatsMap* allocation_stats) { + if (allocation_stats) { + auto node_stats = allocation_stats->find(node_id); + return TranslateAllocationStats(isolate, + node_stats == allocation_stats->end() + ? nullptr + : &node_stats->second); } - v8::Local allocations = NewArray(node->allocations.size()); - for (size_t i = 0; i < node->allocations.size(); i++) { - auto alloc = node->allocations[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(NewString(node->name.c_str()), - NewString(node->script_name.c_str()), - NewInteger(node->script_id), - NewInteger(node->line_number), - NewInteger(node->column_number), - children, - allocations); + return allocations; } - private: v8::Local CreateNode(v8::Local name, v8::Local scriptName, v8::Local scriptId, @@ -153,6 +140,7 @@ std::shared_ptr TranslateAllocationProfileToCpp( new_node->line_number = node->line_number; new_node->column_number = node->column_number; new_node->script_id = node->script_id; + new_node->node_id = node->node_id; Nan::Utf8String name(node->name); new_node->name.assign(*name, name.length()); Nan::Utf8String script_name(node->script_name); @@ -170,11 +158,6 @@ std::shared_ptr TranslateAllocationProfileToCpp( return new_node; } -v8::Local TranslateAllocationProfile( - v8::AllocationProfile::Node* node) { - return HeapProfileTranslator().TranslateAllocationProfile(node); -} - v8::Local TranslateAllocationProfile( v8::AllocationProfile::Node* node, const AllocationProfileNodeStatsMap* allocation_stats) { @@ -182,8 +165,10 @@ v8::Local TranslateAllocationProfile( allocation_stats); } -v8::Local TranslateAllocationProfile(Node* node) { - return HeapProfileTranslator().TranslateAllocationProfile(node); +v8::Local TranslateAllocationProfile( + Node* node, const AllocationProfileNodeStatsMap* allocation_stats) { + return HeapProfileTranslator().TranslateAllocationProfile(node, + allocation_stats); } } // namespace dd diff --git a/bindings/translate-heap-profile.hh b/bindings/translate-heap-profile.hh index e62f14f4..6aaa9186 100644 --- a/bindings/translate-heap-profile.hh +++ b/bindings/translate-heap-profile.hh @@ -20,6 +20,7 @@ #include #include +#include #include #include #include @@ -33,18 +34,22 @@ struct Node { int line_number; int column_number; int script_id; + // Joins the retained AllocationProfileNodeStatsMap onto this tree. + uint32_t node_id = 0; std::vector> children; + // v8 only decrements this on GC without the include-collected-objects flags: + // the live set in legacy mode, the cumulative allocated total with them. std::vector allocations; }; std::shared_ptr TranslateAllocationProfileToCpp( v8::AllocationProfile::Node* node); -v8::Local TranslateAllocationProfile(Node* node); v8::Local TranslateAllocationProfile( - v8::AllocationProfile::Node* node); + Node* node, + const AllocationProfileNodeStatsMap* allocation_stats = nullptr); v8::Local TranslateAllocationProfile( v8::AllocationProfile::Node* node, - const AllocationProfileNodeStatsMap* allocation_stats); + const AllocationProfileNodeStatsMap* allocation_stats = nullptr); } // namespace dd 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/oom-allocation-profile.ts b/ts/test/oom-allocation-profile.ts new file mode 100644 index 00000000..5390e196 --- /dev/null +++ b/ts/test/oom-allocation-profile.ts @@ -0,0 +1,100 @@ +/* + * 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 {heap} from '../src/index'; + +const MB = 1024 * 1024; + +function report(message: string) { + fs.writeSync(1, `${message}\n`); +} + +function fail(message: string): never { + report(`FAIL ${message}`); + process.exit(1); +} + +heap.start(256 * 1024, 64, true); +heap.monitorOutOfMemory( + 'auto', + 1, + false, + undefined, + profile => { + const sampleTypes = profile.sampleType.map( + sampleType => profile.stringTable.strings[Number(sampleType.type)], + ); + const expectedTypes = [ + 'inuse_objects', + 'alloc_objects', + 'inuse_space', + 'alloc_space', + ]; + if (sampleTypes.join() !== expectedTypes.join()) { + fail(`sample types were ${sampleTypes.join()}`); + } + + if (profile.sample.length === 0) { + fail('profile had no samples'); + } + if (profile.sample.some(sample => sample.value.length !== 4)) { + fail('a sample did not contain four values'); + } + if ( + !profile.sample.some( + sample => Number(sample.value[1]) > Number(sample.value[0]), + ) + ) { + fail('profile did not retain collected allocations'); + } + if ( + profile.sample.some( + sample => + Number(sample.value[1]) < Number(sample.value[0]) || + Number(sample.value[3]) < Number(sample.value[2]), + ) + ) { + fail('allocated values were smaller than in-use values'); + } + + report('allocationProfileChecked'); + process.exit(0); + }, + heap.CallbackMode.Async, +); + +const retained: number[][] = []; + +function allocateChunk(): number[] { + const chunk = new Array((4 * MB) / 8); + for (let i = 0; i < chunk.length; i++) { + chunk[i] = i + 0.1; + } + return chunk; +} + +function leak() { + // Give one sampled call site both live and collected allocations. + retained.push(allocateChunk()); + allocateChunk(); + setTimeout(leak, 5); +} + +leak(); diff --git a/ts/test/test-heap-profiler.ts b/ts/test/test-heap-profiler.ts index 26bcca70..584452cf 100644 --- a/ts/test/test-heap-profiler.ts +++ b/ts/test/test-heap-profiler.ts @@ -377,8 +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, + heapLimitExtensionSize?: string, + ) { + const args = + heapLimitExtensionSize === undefined ? [] : [heapLimitExtensionSize]; + const proc = fork(path.join(__dirname, script), args, { execArgv: ['--expose-gc', '--max-old-space-size=64'], silent: true, }); @@ -394,7 +399,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 +481,19 @@ describe('OOMMonitoring', () => { ); }); + it('should report allocation stats in the near-OOM profile', async function () { + if (Number(process.versions.node.split('.')[0]) < 26) { + this.skip(); + } + this.timeout(30000); + const {code, output} = await runOomFixture('oom-allocation-profile.js'); + assert.strictEqual(code, 0, `fixture reported a failure\n${output}`); + assert.ok( + output.includes('allocationProfileChecked'), + `the OOM callback did not report a checked profile\n${output}`, + ); + }); + it('should call external process upon OOM', async function () { // this test is very slow on some configs (asan/valgrind) this.timeout(20000);