From d4a546f20e30b9b1cfba750cb2fa686b19bed784 Mon Sep 17 00:00:00 2001 From: Ilyas Shabi Date: Thu, 19 Feb 2026 14:20:16 +0100 Subject: [PATCH 01/16] Improve heap profile memory usage by lazily loading js objects (#260) Improve heap profile memory usage by lazily loading js objects --- binding.gyp | 4 +- bindings/allocation-profile-node.cc | 132 ++++++++++++++++++++++++++++ bindings/allocation-profile-node.hh | 44 ++++++++++ bindings/binding.cc | 2 + bindings/per-isolate-data.cc | 4 + bindings/per-isolate-data.hh | 2 + bindings/profilers/heap.cc | 109 +++++++---------------- bindings/profilers/heap.hh | 4 + bindings/translate-heap-profile.cc | 51 +++++++++++ bindings/translate-heap-profile.hh | 19 ++++ ts/src/heap-profiler-bindings.ts | 6 ++ ts/src/heap-profiler.ts | 46 +++++++++- ts/src/index.ts | 1 + ts/src/profile-serializer.ts | 13 +-- ts/src/v8-types.ts | 1 + ts/test/heap-memory-worker.ts | 112 +++++++++++++++++++++++ ts/test/test-heap-profiler-v2.ts | 130 +++++++++++++++++++++++++++ 17 files changed, 593 insertions(+), 87 deletions(-) create mode 100644 bindings/allocation-profile-node.cc create mode 100644 bindings/allocation-profile-node.hh create mode 100644 ts/test/heap-memory-worker.ts create mode 100644 ts/test/test-heap-profiler-v2.ts diff --git a/binding.gyp b/binding.gyp index 71b0f215..b8af5ca4 100644 --- a/binding.gyp +++ b/binding.gyp @@ -18,7 +18,8 @@ "bindings/thread-cpu-clock.cc", "bindings/translate-heap-profile.cc", "bindings/translate-time-profile.cc", - "bindings/binding.cc" + "bindings/binding.cc", + "bindings/allocation-profile-node.cc" ], "include_dirs": [ "bindings", @@ -42,6 +43,7 @@ "bindings/translate-heap-profile.cc", "bindings/translate-time-profile.cc", "bindings/test/binding.cc", + "bindings/allocation-profile-node.cc" ], "include_dirs": [ "bindings", diff --git a/bindings/allocation-profile-node.cc b/bindings/allocation-profile-node.cc new file mode 100644 index 00000000..f9434362 --- /dev/null +++ b/bindings/allocation-profile-node.cc @@ -0,0 +1,132 @@ +/* + * 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. + */ + +#include "allocation-profile-node.hh" +#include "per-isolate-data.hh" + +using namespace v8; + +namespace dd { + +template +void AllocationProfileNodeView::mapAllocationProfileNode( + const Nan::PropertyCallbackInfo& info, F&& mapper) { + auto* node = static_cast( + Nan::GetInternalFieldPointer(info.Holder(), 0)); + info.GetReturnValue().Set(mapper(node)); +} + +NAN_MODULE_INIT(AllocationProfileNodeView::Init) { + Local tpl = Nan::New(); + tpl->SetClassName(Nan::New("AllocationProfileNode").ToLocalChecked()); + tpl->InstanceTemplate()->SetInternalFieldCount(1); + + auto inst = tpl->InstanceTemplate(); + Nan::SetAccessor(inst, Nan::New("name").ToLocalChecked(), GetName); + Nan::SetAccessor( + inst, Nan::New("scriptName").ToLocalChecked(), GetScriptName); + Nan::SetAccessor(inst, Nan::New("scriptId").ToLocalChecked(), GetScriptId); + Nan::SetAccessor( + inst, Nan::New("lineNumber").ToLocalChecked(), GetLineNumber); + Nan::SetAccessor( + inst, Nan::New("columnNumber").ToLocalChecked(), GetColumnNumber); + Nan::SetAccessor( + inst, Nan::New("allocations").ToLocalChecked(), GetAllocations); + Nan::SetAccessor(inst, Nan::New("children").ToLocalChecked(), GetChildren); + + PerIsolateData::For(Isolate::GetCurrent()) + ->AllocationNodeConstructor() + .Reset(Nan::GetFunction(tpl).ToLocalChecked()); +} + +Local AllocationProfileNodeView::New(AllocationProfile::Node* node) { + auto* isolate = Isolate::GetCurrent(); + + Local constructor = + Nan::New(PerIsolateData::For(isolate)->AllocationNodeConstructor()); + + Local obj = Nan::NewInstance(constructor).ToLocalChecked(); + + Nan::SetInternalFieldPointer(obj, 0, node); + + return obj; +} + +NAN_GETTER(AllocationProfileNodeView::GetName) { + mapAllocationProfileNode( + info, [](AllocationProfile::Node* node) { return node->name; }); +} + +NAN_GETTER(AllocationProfileNodeView::GetScriptName) { + mapAllocationProfileNode( + info, [](AllocationProfile::Node* node) { return node->script_name; }); +} + +NAN_GETTER(AllocationProfileNodeView::GetScriptId) { + mapAllocationProfileNode( + info, [](AllocationProfile::Node* node) { return node->script_id; }); +} + +NAN_GETTER(AllocationProfileNodeView::GetLineNumber) { + mapAllocationProfileNode( + info, [](AllocationProfile::Node* node) { return node->line_number; }); +} + +NAN_GETTER(AllocationProfileNodeView::GetColumnNumber) { + mapAllocationProfileNode( + info, [](AllocationProfile::Node* node) { return node->column_number; }); +} + +NAN_GETTER(AllocationProfileNodeView::GetAllocations) { + mapAllocationProfileNode(info, [](AllocationProfile::Node* node) { + auto* isolate = Isolate::GetCurrent(); + auto context = isolate->GetCurrentContext(); + + const auto& allocations = node->allocations; + Local arr = Array::New(isolate, allocations.size()); + auto sizeBytes = String::NewFromUtf8Literal(isolate, "sizeBytes"); + auto count = String::NewFromUtf8Literal(isolate, "count"); + + for (size_t i = 0; i < allocations.size(); i++) { + const auto& alloc = allocations[i]; + Local alloc_obj = Object::New(isolate); + Nan::Set(alloc_obj, + sizeBytes, + Number::New(isolate, static_cast(alloc.size))); + Nan::Set(alloc_obj, + count, + Number::New(isolate, static_cast(alloc.count))); + arr->Set(context, i, alloc_obj).Check(); + } + return arr; + }); +} + +NAN_GETTER(AllocationProfileNodeView::GetChildren) { + mapAllocationProfileNode(info, [](AllocationProfile::Node* node) { + auto* isolate = Isolate::GetCurrent(); + auto context = isolate->GetCurrentContext(); + + const auto& children = node->children; + Local arr = Array::New(isolate, children.size()); + for (size_t i = 0; i < children.size(); i++) { + arr->Set(context, i, AllocationProfileNodeView::New(children[i])).Check(); + } + return arr; + }); +} + +} // namespace dd diff --git a/bindings/allocation-profile-node.hh b/bindings/allocation-profile-node.hh new file mode 100644 index 00000000..bb9cec8d --- /dev/null +++ b/bindings/allocation-profile-node.hh @@ -0,0 +1,44 @@ +/* + * 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. + */ + +#pragma once + +#include +#include + +namespace dd { + +class AllocationProfileNodeView { + public: + static NAN_MODULE_INIT(Init); + + static v8::Local New(v8::AllocationProfile::Node* node); + + private: + template + static void mapAllocationProfileNode( + const Nan::PropertyCallbackInfo& info, F&& mapper); + + static NAN_GETTER(GetName); + static NAN_GETTER(GetScriptName); + static NAN_GETTER(GetScriptId); + static NAN_GETTER(GetLineNumber); + static NAN_GETTER(GetColumnNumber); + static NAN_GETTER(GetAllocations); + static NAN_GETTER(GetChildren); +}; + +} // namespace dd diff --git a/bindings/binding.cc b/bindings/binding.cc index 57640194..67f2b802 100644 --- a/bindings/binding.cc +++ b/bindings/binding.cc @@ -18,6 +18,7 @@ #include #include +#include "allocation-profile-node.hh" #include "profilers/heap.hh" #include "profilers/wall.hh" @@ -47,6 +48,7 @@ NODE_MODULE_INIT(/* exports, module, context */) { #pragma GCC diagnostic pop #endif + dd::AllocationProfileNodeView::Init(exports); dd::HeapProfiler::Init(exports); dd::WallProfiler::Init(exports); Nan::SetMethod(exports, "getNativeThreadId", GetNativeThreadId); diff --git a/bindings/per-isolate-data.cc b/bindings/per-isolate-data.cc index 6dfbd6d3..424f9c47 100644 --- a/bindings/per-isolate-data.cc +++ b/bindings/per-isolate-data.cc @@ -52,6 +52,10 @@ Nan::Global& PerIsolateData::WallProfilerConstructor() { return wall_profiler_constructor; } +Nan::Global& PerIsolateData::AllocationNodeConstructor() { + return allocation_node_constructor; +} + std::shared_ptr& PerIsolateData::GetHeapProfilerState() { return heap_profiler_state; } diff --git a/bindings/per-isolate-data.hh b/bindings/per-isolate-data.hh index f555c5e8..dba9d52a 100644 --- a/bindings/per-isolate-data.hh +++ b/bindings/per-isolate-data.hh @@ -28,6 +28,7 @@ struct HeapProfilerState; class PerIsolateData { private: Nan::Global wall_profiler_constructor; + Nan::Global allocation_node_constructor; std::shared_ptr heap_profiler_state; PerIsolateData() {} @@ -36,6 +37,7 @@ class PerIsolateData { static PerIsolateData* For(v8::Isolate* isolate); Nan::Global& WallProfilerConstructor(); + Nan::Global& AllocationNodeConstructor(); std::shared_ptr& GetHeapProfilerState(); }; diff --git a/bindings/profilers/heap.cc b/bindings/profilers/heap.cc index a577b7fb..a5e3c435 100644 --- a/bindings/profilers/heap.cc +++ b/bindings/profilers/heap.cc @@ -28,6 +28,7 @@ #include #include +#include "allocation-profile-node.hh" namespace dd { @@ -55,17 +56,6 @@ static size_t NearHeapLimit(void* data, static void InterruptCallback(v8::Isolate* isolate, void* data); static void AsyncCallback(uv_async_t* handle); -struct Node { - using Allocation = v8::AllocationProfile::Allocation; - std::string name; - std::string script_name; - int line_number; - int column_number; - int script_id; - std::vector> children; - std::vector allocations; -}; - enum CallbackMode { kNoCallback = 0, kAsyncCallback = 1, @@ -139,73 +129,6 @@ struct HeapProfilerState { bool insideCallback = false; }; -std::shared_ptr TranslateAllocationProfileToCpp( - v8::AllocationProfile::Node* node) { - auto new_node = std::make_shared(); - new_node->line_number = node->line_number; - new_node->column_number = node->column_number; - new_node->script_id = node->script_id; - Nan::Utf8String name(node->name); - new_node->name.assign(*name, name.length()); - Nan::Utf8String script_name(node->script_name); - new_node->script_name.assign(*script_name, script_name.length()); - - new_node->children.reserve(node->children.size()); - for (auto& child : node->children) { - new_node->children.push_back(TranslateAllocationProfileToCpp(child)); - } - - new_node->allocations.reserve(node->allocations.size()); - for (auto& allocation : node->allocations) { - new_node->allocations.push_back(allocation); - } - return new_node; -} - -v8::Local TranslateAllocationProfile(Node* node) { - v8::Local js_node = Nan::New(); - - Nan::Set(js_node, - Nan::New("name").ToLocalChecked(), - Nan::New(node->name).ToLocalChecked()); - Nan::Set(js_node, - Nan::New("scriptName").ToLocalChecked(), - Nan::New(node->script_name).ToLocalChecked()); - Nan::Set(js_node, - Nan::New("scriptId").ToLocalChecked(), - Nan::New(node->script_id)); - Nan::Set(js_node, - Nan::New("lineNumber").ToLocalChecked(), - Nan::New(node->line_number)); - Nan::Set(js_node, - Nan::New("columnNumber").ToLocalChecked(), - Nan::New(node->column_number)); - - v8::Local children = Nan::New(node->children.size()); - for (size_t i = 0; i < node->children.size(); i++) { - Nan::Set(children, i, TranslateAllocationProfile(node->children[i].get())); - } - Nan::Set( - js_node, Nan::New("children").ToLocalChecked(), children); - v8::Local allocations = - Nan::New(node->allocations.size()); - for (size_t i = 0; i < node->allocations.size(); i++) { - v8::AllocationProfile::Allocation alloc = node->allocations[i]; - v8::Local js_alloc = Nan::New(); - Nan::Set(js_alloc, - Nan::New("sizeBytes").ToLocalChecked(), - Nan::New(alloc.size)); - Nan::Set(js_alloc, - Nan::New("count").ToLocalChecked(), - Nan::New(alloc.count)); - Nan::Set(allocations, i, js_alloc); - } - Nan::Set(js_node, - Nan::New("allocations").ToLocalChecked(), - allocations); - return js_node; -} - static void dumpAllocationProfile(FILE* file, Node* node, std::string& cur_stack) { @@ -582,6 +505,35 @@ NAN_METHOD(HeapProfiler::GetAllocationProfile) { info.GetReturnValue().Set(TranslateAllocationProfile(root)); } +// mapAllocationProfile(callback): callback result +NAN_METHOD(HeapProfiler::MapAllocationProfile) { + if (info.Length() < 1 || !info[0]->IsFunction()) { + return Nan::ThrowTypeError("mapAllocationProfile requires a callback"); + } + auto isolate = info.GetIsolate(); + auto callback = info[0].As(); + + std::unique_ptr profile( + isolate->GetHeapProfiler()->GetAllocationProfile()); + + if (!profile) { + return Nan::ThrowError("Heap profiler is not enabled."); + } + + auto state = PerIsolateData::For(isolate)->GetHeapProfilerState(); + if (state) { + state->OnNewProfile(); + } + + auto root = AllocationProfileNodeView::New(profile->GetRootNode()); + v8::Local argv[] = {root}; + auto result = + Nan::Call(callback, isolate->GetCurrentContext()->Global(), 1, argv); + if (!result.IsEmpty()) { + info.GetReturnValue().Set(result.ToLocalChecked()); + } +} + NAN_METHOD(HeapProfiler::MonitorOutOfMemory) { if (info.Length() != 7) { return Nan::ThrowTypeError("MonitorOOMCondition must have 7 arguments."); @@ -645,6 +597,7 @@ NAN_MODULE_INIT(HeapProfiler::Init) { Nan::SetMethod( heapProfiler, "stopSamplingHeapProfiler", StopSamplingHeapProfiler); Nan::SetMethod(heapProfiler, "getAllocationProfile", GetAllocationProfile); + Nan::SetMethod(heapProfiler, "mapAllocationProfile", MapAllocationProfile); Nan::SetMethod(heapProfiler, "monitorOutOfMemory", MonitorOutOfMemory); Nan::Set(target, Nan::New("heapProfiler").ToLocalChecked(), diff --git a/bindings/profilers/heap.hh b/bindings/profilers/heap.hh index 5badc46c..aef0ef7e 100644 --- a/bindings/profilers/heap.hh +++ b/bindings/profilers/heap.hh @@ -34,6 +34,10 @@ class HeapProfiler { // getAllocationProfile(): AllocationProfileNode static NAN_METHOD(GetAllocationProfile); + // Signature: + // mapAllocationProfile(callback): callback result + static NAN_METHOD(MapAllocationProfile); + static NAN_METHOD(MonitorOutOfMemory); static NAN_MODULE_INIT(Init); diff --git a/bindings/translate-heap-profile.cc b/bindings/translate-heap-profile.cc index a4331e09..41f5e129 100644 --- a/bindings/translate-heap-profile.cc +++ b/bindings/translate-heap-profile.cc @@ -15,6 +15,7 @@ */ #include "translate-heap-profile.hh" +#include #include "profile-translator.hh" namespace dd { @@ -64,6 +65,29 @@ class HeapProfileTranslator : ProfileTranslator { allocations); } + 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())); + } + + 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); + } + private: v8::Local CreateNode(v8::Local name, v8::Local scriptName, @@ -95,9 +119,36 @@ class HeapProfileTranslator : ProfileTranslator { }; } // namespace +std::shared_ptr TranslateAllocationProfileToCpp( + v8::AllocationProfile::Node* node) { + auto new_node = std::make_shared(); + new_node->line_number = node->line_number; + new_node->column_number = node->column_number; + new_node->script_id = node->script_id; + Nan::Utf8String name(node->name); + new_node->name.assign(*name, name.length()); + Nan::Utf8String script_name(node->script_name); + new_node->script_name.assign(*script_name, script_name.length()); + + new_node->children.reserve(node->children.size()); + for (auto& child : node->children) { + new_node->children.push_back(TranslateAllocationProfileToCpp(child)); + } + + new_node->allocations.reserve(node->allocations.size()); + for (auto& allocation : node->allocations) { + new_node->allocations.push_back(allocation); + } + return new_node; +} + v8::Local TranslateAllocationProfile( v8::AllocationProfile::Node* node) { return HeapProfileTranslator().TranslateAllocationProfile(node); } +v8::Local TranslateAllocationProfile(Node* node) { + return HeapProfileTranslator().TranslateAllocationProfile(node); +} + } // namespace dd diff --git a/bindings/translate-heap-profile.hh b/bindings/translate-heap-profile.hh index dc5c7aa6..e913dd66 100644 --- a/bindings/translate-heap-profile.hh +++ b/bindings/translate-heap-profile.hh @@ -17,9 +17,28 @@ #pragma once #include +#include +#include +#include +#include namespace dd { +struct Node { + using Allocation = v8::AllocationProfile::Allocation; + std::string name; + std::string script_name; + int line_number; + int column_number; + int script_id; + std::vector> children; + std::vector allocations; +}; + +std::shared_ptr TranslateAllocationProfileToCpp( + v8::AllocationProfile::Node* node); + +v8::Local TranslateAllocationProfile(Node* node); v8::Local TranslateAllocationProfile( v8::AllocationProfile::Node* node); diff --git a/ts/src/heap-profiler-bindings.ts b/ts/src/heap-profiler-bindings.ts index 77522577..f5baa8e8 100644 --- a/ts/src/heap-profiler-bindings.ts +++ b/ts/src/heap-profiler-bindings.ts @@ -41,6 +41,12 @@ export function getAllocationProfile(): AllocationProfileNode { return profiler.heapProfiler.getAllocationProfile(); } +export function mapAllocationProfile( + callback: (root: AllocationProfileNode) => T +): T { + return profiler.heapProfiler.mapAllocationProfile(callback); +} + export type NearHeapLimitCallback = (profile: AllocationProfileNode) => void; export function monitorOutOfMemory( diff --git a/ts/src/heap-profiler.ts b/ts/src/heap-profiler.ts index b6c64d0f..afe08e75 100644 --- a/ts/src/heap-profiler.ts +++ b/ts/src/heap-profiler.ts @@ -18,6 +18,7 @@ import {Profile} from 'pprof-format'; import { getAllocationProfile, + mapAllocationProfile, startSamplingHeapProfiler, stopSamplingHeapProfiler, monitorOutOfMemory as monitorOutOfMemoryImported, @@ -47,6 +48,26 @@ export function v8Profile(): AllocationProfileNode { return getAllocationProfile(); } +/** + * Collects a heap profile when heapProfiler is enabled. Otherwise throws + * an error. + * Map the heap profiler to a converted profile using callback function. + * + * WARNING: Nodes in the tree are only valid during the callback. Do not store + * references to them. The memory is freed when the callback returns. + * + * @param callback - function to convert the heap profiler to a converted profile + * @returns converted profile + */ +export function v8ProfileV2( + callback: (root: AllocationProfileNode) => T +): T { + if (!enabled) { + throw new Error('Heap profiler is not enabled.'); + } + return mapAllocationProfile(callback); +} + /** * Collects a profile and returns it serialized in pprof format. * Throws if heap profiler is not enabled. @@ -79,6 +100,7 @@ export function convertProfile( // TODO: remove any once type definition is updated to include external. // eslint-disable-next-line @typescript-eslint/no-explicit-any const {external}: {external: number} = process.memoryUsage() as any; + let root: AllocationProfileNode; if (external > 0) { const externalNode: AllocationProfileNode = { name: '(external)', @@ -86,10 +108,12 @@ export function convertProfile( children: [], allocations: [{sizeBytes: external, count: 1}], }; - rootNode.children.push(externalNode); + root = {...rootNode, children: [...rootNode.children, externalNode]}; + } else { + root = rootNode; } return serializeHeapProfile( - rootNode, + root, startTimeNanos, heapIntervalBytes, ignoreSamplePath, @@ -98,6 +122,24 @@ export function convertProfile( ); } +/** + * Collects a profile and returns it serialized in pprof format using lazy V2 API. + * Throws if heap profiler is not enabled. + * + * @param ignoreSamplePath + * @param sourceMapper + * @param generateLabels + */ +export function profileV2( + ignoreSamplePath?: string, + sourceMapper?: SourceMapper, + generateLabels?: GenerateAllocationLabelsFunction +): Profile { + return v8ProfileV2(root => { + return convertProfile(root, ignoreSamplePath, sourceMapper, generateLabels); + }); +} + /** * Starts heap profiling. If heap profiling has already been started with * the same parameters, this is a noop. If heap profiler has already been diff --git a/ts/src/index.ts b/ts/src/index.ts index 42454629..be2a4170 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -48,6 +48,7 @@ export const heap = { start: heapProfiler.start, stop: heapProfiler.stop, profile: heapProfiler.profile, + profileV2: heapProfiler.profileV2, convertProfile: heapProfiler.convertProfile, v8Profile: heapProfiler.v8Profile, monitorOutOfMemory: heapProfiler.monitorOutOfMemory, diff --git a/ts/src/profile-serializer.ts b/ts/src/profile-serializer.ts index 802bc968..f966bb69 100644 --- a/ts/src/profile-serializer.ts +++ b/ts/src/profile-serializer.ts @@ -110,15 +110,15 @@ function serialize( const node = entry.node; // mjs files have a `file://` prefix in the scriptName -> remove it - if (node.scriptName.startsWith('file://')) { - node.scriptName = node.scriptName.slice(7); - } + const scriptName = node.scriptName.startsWith('file://') + ? node.scriptName.slice(7) + : node.scriptName; - if (ignoreSamplesPath && node.scriptName.indexOf(ignoreSamplesPath) > -1) { + if (ignoreSamplesPath && scriptName.indexOf(ignoreSamplesPath) > -1) { continue; } const stack = entry.stack; - const location = getLocation(node, sourceMapper); + const location = getLocation(node, scriptName, sourceMapper); stack.unshift(location.id as number); appendToSamples(entry, samples); for (const child of node.children as T[]) { @@ -133,10 +133,11 @@ function serialize( function getLocation( node: ProfileNode, + scriptName: string, sourceMapper?: SourceMapper ): Location { let profLoc: SourceLocation = { - file: node.scriptName || '', + file: scriptName || '', line: node.lineNumber, column: node.columnNumber, name: node.name, diff --git a/ts/src/v8-types.ts b/ts/src/v8-types.ts index 39178b19..1becf7d2 100644 --- a/ts/src/v8-types.ts +++ b/ts/src/v8-types.ts @@ -51,6 +51,7 @@ export interface TimeProfileNode extends ProfileNode { export interface AllocationProfileNode extends ProfileNode { allocations: Allocation[]; + children: AllocationProfileNode[]; } export interface Allocation { diff --git a/ts/test/heap-memory-worker.ts b/ts/test/heap-memory-worker.ts new file mode 100644 index 00000000..fa7038c5 --- /dev/null +++ b/ts/test/heap-memory-worker.ts @@ -0,0 +1,112 @@ +import * as heapProfiler from '../src/heap-profiler'; +import * as v8HeapProfiler from '../src/heap-profiler-bindings'; +import {AllocationProfileNode} from '../src/v8-types'; + +const gc = (global as NodeJS.Global & {gc?: () => void}).gc; +if (!gc) { + throw new Error('Run with --expose-gc flag'); +} + +const keepAlive: object[] = []; + +// Create many unique functions via new Function() to produce a large profile tree. +function createAllocatorFunctions(count: number): Function[] { + const fns: Function[] = []; + for (let i = 0; i < count; i++) { + const fn = new Function( + 'keepAlive', + ` + for (let j = 0; j < 100; j++) { + keepAlive.push({ + id${i}: j, + data${i}: new Array(64).fill('${'x'.repeat(16)}'), + }); + } + ` + ); + fns.push(() => fn(keepAlive)); + } + return fns; +} + +function createDeepChain(depth: number): Function[] { + const fns: Function[] = []; + for (let i = depth - 1; i >= 0; i--) { + const next = i < depth - 1 ? fns[fns.length - 1] : null; + const fn = new Function( + 'keepAlive', + 'next', + ` + for (let j = 0; j < 50; j++) { + keepAlive.push({ arr${i}: new Array(32).fill(j) }); + } + if (next) next(keepAlive, null); + ` + ) as (arr: object[], next: unknown) => void; + fns.push((arr: object[]) => fn(arr, next)); + } + return fns; +} + +function generateAllocations(): void { + const wideFns = createAllocatorFunctions(5000); + for (const fn of wideFns) { + fn(); + } + + for (let chain = 0; chain < 200; chain++) { + const deepFns = createDeepChain(50); + deepFns[deepFns.length - 1](keepAlive); + } +} + +function traverseTree(root: AllocationProfileNode): void { + const stack: AllocationProfileNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.children) { + for (const child of node.children) { + stack.push(child); + } + } + } +} + +function measureV1(): {initial: number; afterTraversal: number} { + gc!(); + gc!(); + const baseline = process.memoryUsage().heapUsed; + + const profile = v8HeapProfiler.getAllocationProfile(); + const initial = process.memoryUsage().heapUsed - baseline; + traverseTree(profile); + const afterTraversal = process.memoryUsage().heapUsed - baseline; + + return {initial, afterTraversal}; +} + +function measureV2(): {initial: number; afterTraversal: number} { + gc!(); + gc!(); + const baseline = process.memoryUsage().heapUsed; + + return v8HeapProfiler.mapAllocationProfile(root => { + const initial = process.memoryUsage().heapUsed - baseline; + traverseTree(root); + const afterTraversal = process.memoryUsage().heapUsed - baseline; + return {initial, afterTraversal}; + }); +} + +process.on('message', (version: 'v1' | 'v2') => { + heapProfiler.start(128, 128); + generateAllocations(); + + const {initial, afterTraversal} = + version === 'v1' ? measureV1() : measureV2(); + + heapProfiler.stop(); + keepAlive.length = 0; + + process.send!({initial, afterTraversal}); +}); diff --git a/ts/test/test-heap-profiler-v2.ts b/ts/test/test-heap-profiler-v2.ts new file mode 100644 index 00000000..d514c241 --- /dev/null +++ b/ts/test/test-heap-profiler-v2.ts @@ -0,0 +1,130 @@ +import {strict as assert} from 'assert'; +import {fork} from 'child_process'; +import * as heapProfiler from '../src/heap-profiler'; +import * as v8HeapProfiler from '../src/heap-profiler-bindings'; + +function generateAllocations(): object[] { + const allocations: object[] = []; + for (let i = 0; i < 1000; i++) { + allocations.push({data: new Array(100).fill(i)}); + } + return allocations; +} + +describe('HeapProfiler V2 API', () => { + let keepAlive: object[] = []; + + before(() => { + heapProfiler.start(512, 64); + keepAlive = generateAllocations(); + }); + + after(() => { + heapProfiler.stop(); + keepAlive.length = 0; + }); + + describe('v8ProfileV2', () => { + it('should return AllocationProfileNode', () => { + heapProfiler.v8ProfileV2(root => { + assert.equal(typeof root.name, 'string'); + assert.equal(typeof root.scriptName, 'string'); + assert.equal(typeof root.scriptId, 'number'); + assert.equal(typeof root.lineNumber, 'number'); + assert.equal(typeof root.columnNumber, 'number'); + assert.ok(Array.isArray(root.allocations)); + + assert.ok(Array.isArray(root.children)); + assert.equal(typeof root.children.length, 'number'); + + if (root.children.length > 0) { + const child = root.children[0]; + assert.equal(typeof child.name, 'string'); + assert.ok(Array.isArray(child.children)); + assert.ok(Array.isArray(child.allocations)); + } + }); + }); + + it('should throw error when profiler not started', () => { + heapProfiler.stop(); + assert.throws( + () => { + heapProfiler.v8ProfileV2(() => {}); + }, + (err: Error) => { + return err.message === 'Heap profiler is not enabled.'; + } + ); + heapProfiler.start(512, 64); + }); + }); + + describe('mapAllocationProfile', () => { + it('should return AllocationProfileNode directly', () => { + v8HeapProfiler.mapAllocationProfile(root => { + assert.equal(typeof root.name, 'string'); + assert.equal(typeof root.scriptName, 'string'); + assert.ok(Array.isArray(root.children)); + assert.ok(Array.isArray(root.allocations)); + }); + }); + }); + + describe('profileV2', () => { + it('should produce valid pprof Profile', () => { + const profile = heapProfiler.profileV2(); + + assert.ok(profile.sampleType); + assert.ok(profile.sample); + assert.ok(profile.location); + assert.ok(profile.function); + assert.ok(profile.stringTable); + }); + }); + + describe('Memory comparison', () => { + interface MemoryResult { + initial: number; + afterTraversal: number; + } + + function measureMemoryInWorker( + version: 'v1' | 'v2' + ): Promise { + return new Promise((resolve, reject) => { + const child = fork('./out/test/heap-memory-worker.js', [], { + execArgv: ['--expose-gc'], + }); + + child.on('message', (result: MemoryResult) => { + resolve(result); + child.kill(); + }); + + child.on('error', reject); + child.send(version); + }); + } + + it('mapAllocationProfile should use less initial memory than getAllocationProfile', async () => { + const v1MemoryUsage = await measureMemoryInWorker('v1'); + const v2MemoryUsage = await measureMemoryInWorker('v2'); + + console.log( + ` V1 initial: ${v1MemoryUsage.initial}, afterTraversal: ${v1MemoryUsage.afterTraversal} + | V2 initial: ${v2MemoryUsage.initial}, afterTraversal: ${v2MemoryUsage.afterTraversal}` + ); + + assert.ok( + v2MemoryUsage.initial < v1MemoryUsage.initial, + `V2 initial should be less: V1=${v1MemoryUsage.initial}, V2=${v2MemoryUsage.initial}` + ); + + assert.ok( + v2MemoryUsage.afterTraversal < v1MemoryUsage.afterTraversal, + `V2 afterTraversal should be less: V1=${v1MemoryUsage.afterTraversal}, V2=${v2MemoryUsage.afterTraversal}` + ); + }).timeout(100_000); + }); +}); From 8f7c55fd510a711ec23dc59af4dc7f7291906545 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Fri, 20 Feb 2026 12:09:32 +0100 Subject: [PATCH 02/16] Store sampling context in the AsyncContextFrame directly (#255) * Read V8 map data directly, for signal safety * Allow passing in an ACF key object externally to the profiler. * time-profiler.ts uses it to pass an AsyncLocalStorage (ALS) as the key. --- binding.gyp | 1 + bindings/map-get.cc | 390 ++++++++++++++++++++ bindings/map-get.hh | 25 ++ bindings/profilers/wall.cc | 278 +++++--------- bindings/profilers/wall.hh | 23 +- doc/sample_context_in_cped.md | 239 ++++++------ ts/src/time-profiler.ts | 11 +- ts/test/cped-freelist-regression-child.ts | 125 ------- ts/test/test-get-value-from-map-profiler.ts | 195 ++++++++++ ts/test/test-time-profiler.ts | 18 - ts/test/worker.ts | 3 - 11 files changed, 837 insertions(+), 471 deletions(-) create mode 100644 bindings/map-get.cc create mode 100644 bindings/map-get.hh delete mode 100644 ts/test/cped-freelist-regression-child.ts create mode 100644 ts/test/test-get-value-from-map-profiler.ts diff --git a/binding.gyp b/binding.gyp index b8af5ca4..2594f898 100644 --- a/binding.gyp +++ b/binding.gyp @@ -19,6 +19,7 @@ "bindings/translate-heap-profile.cc", "bindings/translate-time-profile.cc", "bindings/binding.cc", + "bindings/map-get.cc", "bindings/allocation-profile-node.cc" ], "include_dirs": [ diff --git a/bindings/map-get.cc b/bindings/map-get.cc new file mode 100644 index 00000000..7d3c3f50 --- /dev/null +++ b/bindings/map-get.cc @@ -0,0 +1,390 @@ +/** + * Copyright 2025 Datadog. All Rights Reserved. + * + * 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. + */ + +#include "map-get.hh" + +// Find a value in JavaScript map by directly reading the underlying V8 hash +// map. +// +// V8 uses TWO internal hash map representations: +// 1. SmallOrderedHashMap: For small maps (capacity 4-254) +// - Metadata stored as uint8_t bytes +// - Entry size: 2 (key, value) +// - Chain table separate from entries +// +// 2. OrderedHashMap: For larger maps (capacity >254) +// - Metadata stored as Smis in FixedArray +// - Entry size: 3 (key, value, chain) +// - Chain stored inline with entries +// +// This code handles both types by detecting the table format at runtime. +// Practical testing shows that at least the AsyncContextFrame maps use the +// large map format even for small cardinality maps, but just in case we handle +// both. + +#include + +namespace dd { + +using Address = uintptr_t; + +#ifndef _WIN32 +// ============================================================================ +// Constants from V8 internals +// ============================================================================ + +// Heap object tagging +constexpr int kHeapObjectTag = 1; + +// OrderedHashMap/SmallOrderedHashMap shared constants +constexpr int kNotFound = -1; +constexpr int kLoadFactor = 2; + +// ============================================================================ +// Helper Functions (needed by struct methods) +// ============================================================================ + +inline Address UntagPointer(Address tagged) { + return tagged - kHeapObjectTag; +} + +// IsSmi and SmiToInt implementations are valid only on 64-bit platforms, the +// only ones we support. +static_assert(sizeof(void*) == 8, "Only 64-bit platforms supported"); + +inline bool IsSmi(Address value) { + // More rigorous than (value & 1) but valid on 64-bit platforms only. + return (value & 0xFFFFFFFF) == 0; +} + +inline int SmiToInt(Address smi) { + return static_cast(static_cast(smi) >> 32); +} + +// ============================================================================ +// V8 Hashtable Structure Definitions +// ============================================================================ + +// HeapObject layout - base for all V8 heap objects +// From v8/src/objects/heap-object.h +struct HeapObjectLayout { + Address classMap_; // Tagged pointer to the class map +}; + +// JavaScript Map object +struct JSMapLayout { + HeapObjectLayout header_; // Map is a HeapObject + Address properties_or_hash_; // not used by us + Address elements_; // not used by us + // Tagged pointer to a [Small]OrderedHashMapLayout + Address table_; +}; + +// V8 FixedArray: length_ is a Smi, followed by that many element slots +struct FixedArrayLayout { + HeapObjectLayout header_; // FixedArray is a HeapObject + Address length_; + Address elements_[0]; +}; + +// NOTE: both OrderedHashMap and SmallOrderedHashMap have compatible method +// definitions so FindEntryByHash and FindValueByHash can be defined as +// templated function working on both. + +// OrderedHashMap layout (for large maps, capacity >254) +// From v8/src/objects/ordered-hash-table.h +struct OrderedHashMapLayout { + FixedArrayLayout fixedArray_; // OrderedHashMap is a FixedArray + // The first 3 address slots in the FixedArray that is a Hashtable are the + // number of elements, deleted elements, and buckets. Each one is a Smi. + Address number_of_elements_; + Address number_of_deleted_elements_; + Address number_of_buckets_; + // First number_of_buckets_ entries in head_and_data_table_ is the head table: + // each entry is an index of the first entry (head of the linked list of + // entries) in the data table for that bucket. This is followed by the data + // table. Each data table entry uses three (kEntrySize == 3) tagged pointer + // slots: + // [0]: key (Tagged Object) + // [1]: value (Tagged Object) + // [2]: chain (Smi - next entry index or -1) + // All indices (both to the head of the list and to the next entry are + // expressed in number of entries from the start of the data table, so to + // convert it into a head_and_data_table_ you need to add number_of_buckets_ + // (length of the head table) and then 3 * index. + Address head_and_data_table_[0]; // Variable: [head_table][data_table] + + // Constants for entry structure + static constexpr int kEntrySize = 3; + static constexpr int kKeyOffset = 0; + static constexpr int kValueOffset = 1; + static constexpr int kChainOffset = 2; + static constexpr int kNotFoundValue = kNotFound; + + // Get number of buckets (converts Smi to int) + int NumberOfBuckets() const { return SmiToInt(number_of_buckets_); } + + int GetEntryCount() const { + return SmiToInt(number_of_elements_) + + SmiToInt(number_of_deleted_elements_); + } + + // Get first entry index for a bucket + int GetFirstEntry(int bucket) const { + Address entry_smi = head_and_data_table_[bucket]; + return IsSmi(entry_smi) ? SmiToInt(entry_smi) : kNotFound; + } + + // Convert entry index to head_and_data_table_ index for the entry's key + int EntryToIndex(int entry) const { + return NumberOfBuckets() + (entry * kEntrySize); + } + + // Get key at entry index + Address GetKey(int entry) const { + int index = EntryToIndex(entry); + return head_and_data_table_[index + kKeyOffset]; + } + + // Get value at entry index + Address GetValue(int entry) const { + int index = EntryToIndex(entry); + return head_and_data_table_[index + kValueOffset]; + } + + // Get next entry in chain + int GetNextChainEntry(int entry) const { + int index = EntryToIndex(entry); + Address chain_smi = head_and_data_table_[index + kChainOffset]; + return IsSmi(chain_smi) ? SmiToInt(chain_smi) : kNotFound; + } +}; + +// SmallOrderedHashMap layout (for small maps, capacity 4-254) +// Memory layout (stores metadata as uint8_t, not Smis): +// [0]: map pointer (HeapObject) +// [kHeaderSize + 0]: number_of_elements (uint8) +// [kHeaderSize + 1]: number_of_deleted_elements (uint8) +// [kHeaderSize + 2]: number_of_buckets (uint8) +// [kHeaderSize + 3...]: padding (5 bytes on 64-bit, 1 byte on 32-bit) +// [DataTableStartOffset...]: data table (key-value pairs as Tagged) +// [...]: hash table (uint8 bucket indices) +// [...]: chain table (uint8 next entry indices) +// +// Each entry is 2 Tagged elements (kEntrySize = 2): +// [0]: key (Tagged Object) +// [1]: value (Tagged Object) +// +// From v8/src/objects/ordered-hash-table.h +struct SmallOrderedHashMapLayout { + HeapObjectLayout header_; + uint8_t number_of_elements_; + uint8_t number_of_deleted_elements_; + uint8_t number_of_buckets_; + uint8_t padding_[5]; // 5 bytes on 64-bit + // Variable length: + // - Address data_table_[capacity * kEntrySize] // Keys and values + // - uint8_t hash_table_[number_of_buckets_] // Bucket -> first entry + // - uint8_t chain_table_[capacity] // Entry -> next entry + Address data_table_[0]; + + // Constants for entry structure + static constexpr int kEntrySize = 2; + static constexpr int kKeyOffset = 0; + static constexpr int kValueOffset = 1; + static constexpr int kNotFoundValue = 255; + + // Get capacity from number of buckets + int Capacity() const { return number_of_buckets_ * kLoadFactor; } + + int NumberOfBuckets() const { return number_of_buckets_; } + + int GetEntryCount() const { + return number_of_elements_ + number_of_deleted_elements_; + } + + const uint8_t* GetHashTable() const { + return reinterpret_cast(data_table_ + + Capacity() * kEntrySize); + } + + const uint8_t* GetChainTable() const { + return GetHashTable() + number_of_buckets_; + } + + // Get key at entry index + Address GetKey(int entry) const { + return data_table_[entry * kEntrySize + kKeyOffset]; + } + + // Get value at entry index + Address GetValue(int entry) const { + return data_table_[entry * kEntrySize + kValueOffset]; + } + + // Get first entry in bucket + uint8_t GetFirstEntry(int bucket) const { + const uint8_t* hash_table = GetHashTable(); + return hash_table[bucket]; + } + + // Get next entry in chain + uint8_t GetNextChainEntry(int entry) const { + const uint8_t* chain_table = GetChainTable(); + return chain_table[entry]; + } +}; + +// ============================================================================ +// Templated Hash Table Lookup +// ============================================================================ + +// Find an entry by a key and its hash in any hash table layout +// Template parameter LayoutT should be either OrderedHashMapLayout or +// SmallOrderedHashMapLayout +template +int FindEntryByHash(const LayoutT* layout, int hash, Address key_to_find) { + const int entry_count = layout->GetEntryCount(); + const int bucket = hash & (layout->NumberOfBuckets() - 1); + int entry = layout->GetFirstEntry(bucket); + + // Paranoid: by never traversing more than the total number of entries in the + // map we guarantee this terminates in bound time even if for some unforeseen + // reason the chain is cyclical. Also, every entry value must be between + // [0, GetEntryCount). + for (int max_entries_left = entry_count; + entry != LayoutT::kNotFoundValue && entry >= 0 && entry < entry_count && + max_entries_left > 0; + max_entries_left--) { + Address key_at_entry = layout->GetKey(entry); + if (key_at_entry == key_to_find) { + return entry; + } + entry = layout->GetNextChainEntry(entry); + } + + return kNotFound; +} + +// Find an entry by a key and its hash in any hash table layout, and return its +// value or the zero address if it is not found. +// Template parameter LayoutT should be either OrderedHashMapLayout or +// SmallOrderedHashMapLayout +template +Address FindValueByHash(const LayoutT* layout, int hash, Address key_to_find) { + auto entry = FindEntryByHash(layout, hash, key_to_find); + return entry == kNotFound ? 0 : layout->GetValue(entry); +} + +static bool IsSmallOrderedHashMap(Address table_untagged) { + const SmallOrderedHashMapLayout* potential_small = + reinterpret_cast(table_untagged); + + // Read the header as one 64-bit value for validation + uint64_t smallHeader = + *reinterpret_cast(&potential_small->number_of_elements_); + + static_assert(__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__, + "Little-endian required"); + // Small map will have some bits in bytes 0-2 be nonzero, and all bits in + // bytes 3-7 zero. That effectively limits the value range of smallHeader to + // [0x1-0xFFFFFF]. + if (smallHeader == 0 || smallHeader >= 0x1000000) return false; + + auto num_elements = potential_small->number_of_elements_; + auto num_deleted = potential_small->number_of_deleted_elements_; + auto num_buckets = potential_small->number_of_buckets_; + + // num_buckets must be between 2 and 127 + if (num_buckets < 2 || num_buckets > 127) return false; + + // num_buckets must be a power of 2 + if ((num_buckets & (num_buckets - 1)) != 0) return false; + + auto capacity = num_buckets * kLoadFactor; + // Sum of elements and deleted elements can't exceed capacity + return num_elements + num_deleted <= capacity; +} + +static bool IsOrderedHashMap(Address table_untagged) { + const OrderedHashMapLayout* layout = + reinterpret_cast(table_untagged); + + // Let's validate its invariants! + + // Its length must be a Smi. + if (!IsSmi(layout->fixedArray_.length_)) return false; + auto length = SmiToInt(layout->fixedArray_.length_); + + // Must have at least 3 elements for number_of_* fields. + if (length < 3) return false; + + // All of them must be Smis + if (!IsSmi(layout->number_of_buckets_) || + !IsSmi(layout->number_of_deleted_elements_) || + !IsSmi(layout->number_of_elements_)) + return false; + auto num_buckets = SmiToInt(layout->number_of_buckets_); + auto num_deleted = SmiToInt(layout->number_of_deleted_elements_); + auto num_elements = SmiToInt(layout->number_of_elements_); + + // num_buckets must be a power of 2 + if (num_buckets <= 0 || (num_buckets & (num_buckets - 1)) != 0) return false; + auto capacity = num_buckets * kLoadFactor; + + // number of elements and number of deleted elements can't be negative, and + // they can't add up to more than the capacity. + if (num_elements < 0 || num_deleted < 0 || + num_elements + num_deleted > capacity) + return false; + + // The length of the array must be enough to store the whole map. + return length >= + 3 + num_buckets + OrderedHashMapLayout::kEntrySize * capacity; +} + +// ============================================================================ +// Main entry point +// ============================================================================ + +// Lookup value in a Map given the hash and key pointer. If the key is not found +// in the map (or the lookup can not be performed) returns a zero Address (which +// is essentially a zero Smi value.) +Address GetValueFromMap(Address map_addr, int hash, Address key) { + const JSMapLayout* map_untagged = + reinterpret_cast(UntagPointer(map_addr)); + Address table_untagged = UntagPointer(map_untagged->table_); + + if (IsSmallOrderedHashMap(table_untagged)) { + const SmallOrderedHashMapLayout* layout = + reinterpret_cast(table_untagged); + return FindValueByHash(layout, hash, key); + } else if (IsOrderedHashMap(table_untagged)) { + const OrderedHashMapLayout* layout = + reinterpret_cast(table_untagged); + return FindValueByHash(layout, hash, key); + } + return 0; // We couldn't determine the kind of the map, just return zero. +} + +#else // _WIN32 + +Address GetValueFromMap(Address map_addr, int hash, Address key) { + return 0; +} + +#endif // _WIN32 +} // namespace dd diff --git a/bindings/map-get.hh b/bindings/map-get.hh new file mode 100644 index 00000000..bb20e0d6 --- /dev/null +++ b/bindings/map-get.hh @@ -0,0 +1,25 @@ +/** + * Copyright 2025 Datadog. All Rights Reserved. + * + * 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. + */ + +#pragma once + +#include + +using Address = uintptr_t; + +namespace dd { +Address GetValueFromMap(Address map_addr, int hash, Address key); +} // namespace dd diff --git a/bindings/profilers/wall.cc b/bindings/profilers/wall.cc index 1a028a88..98a8d2dd 100644 --- a/bindings/profilers/wall.cc +++ b/bindings/profilers/wall.cc @@ -26,7 +26,7 @@ #include #include -#include "defer.hh" +#include "map-get.hh" #include "per-isolate-data.hh" #include "translate-time-profile.hh" #include "wall.hh" @@ -612,123 +612,6 @@ void GCEpilogueCallback(Isolate* isolate, static_cast(data)->OnGCEnd(); } -#if DD_WALL_USE_CPED -// Implementation of method calls on the CPED proxy that invoke the method on -// the proxied object. "data" is a two-element array where element 0 is the -// Symbol used to find the proxied object in the proxy and element 1 is either -// the method name or a cached method. -void CpedProxyMethodCallback(const FunctionCallbackInfo& info) { - auto isolate = info.GetIsolate(); - auto context = isolate->GetCurrentContext(); - auto cpedProxy = info.This(); - auto data = info.Data().As(); - auto symbol = data->Get(context, 0).ToLocalChecked().As(); - auto propertyName = data->Get(context, 1).ToLocalChecked(); - auto proxied = cpedProxy->Get(context, symbol).ToLocalChecked(); - Local method; - if (propertyName->IsFunction()) { - // It was already cached as a method, so we can use it directly - method = propertyName.As(); - } else { - method = proxied.As() - ->Get(context, propertyName) - .ToLocalChecked() - .As(); - // replace the property name with the method once resolved so later - // invocations are faster - data->Set(context, 1, method).Check(); - } - MaybeLocal retval; - auto arglen = info.Length(); - switch (arglen) { - case 0: - retval = method->Call(context, proxied, 0, nullptr); - break; - case 1: { - auto arg = info[0]; - retval = method->Call(context, proxied, 1, &arg); - break; - } - case 2: { - Local args[] = {info[0], info[1]}; - retval = method->Call(context, proxied, 2, args); - break; - } - default: { - // No Map methods take more than 2 arguments, so this path should never - // get invoked. We still implement it for completeness sake. - auto args = new Local[arglen]; - for (int i = 0; i < arglen; ++i) { - args[i] = info[i]; - } - retval = method->Call(context, proxied, arglen, args); - delete[] args; - } - } - info.GetReturnValue().Set(retval.ToLocalChecked()); -} - -// Implementation of property getters on the CPED proxy that get the property on -// the proxied object. "data" the Symbol used to find the proxied object in the -// proxy. -void CpedProxyPropertyGetterCallback(Local property, - const PropertyCallbackInfo& info) { - auto isolate = info.GetIsolate(); - auto context = isolate->GetCurrentContext(); - auto cpedProxy = info.This(); - auto symbol = info.Data().As(); - auto proxied = cpedProxy->Get(context, symbol).ToLocalChecked(); - auto value = proxied.As()->Get(context, property).ToLocalChecked(); - info.GetReturnValue().Set(value); -} - -// Sets up all the proxy methods and properties for the CPED proxy prototype -void SetupCpedProxyProtoMethods(Isolate* isolate, - Local cpedProxyProto, - Local cpedProxySymbol) { - auto context = isolate->GetCurrentContext(); - - auto addProxyProtoMethod = [&](Local methodName) { - auto data = Array::New(isolate, 2); - data->Set(context, Number::New(isolate, 0), cpedProxySymbol).Check(); - data->Set(context, Number::New(isolate, 1), methodName).Check(); - cpedProxyProto - ->Set(context, - methodName, - Function::New(context, &CpedProxyMethodCallback, data) - .ToLocalChecked()) - .Check(); - }; - - // Map methods + AsyncContextFrame.disable method - static constexpr const char* methodNames[] = {"clear", - "delete", - "entries", - "forEach", - "get", - "has", - "keys", - "set", - "values", - "disable"}; - - for (const char* methodName : methodNames) { - addProxyProtoMethod( - String::NewFromUtf8(isolate, methodName).ToLocalChecked()); - } - addProxyProtoMethod(Symbol::GetIterator(isolate)); - - // Map.size property - cpedProxyProto - ->SetNativeDataProperty(context, - String::NewFromUtf8Literal(isolate, "size"), - &CpedProxyPropertyGetterCallback, - nullptr, - cpedProxySymbol) - .Check(); -} -#endif // DD_WALL_USE_CPED - WallProfiler::WallProfiler(std::chrono::microseconds samplingPeriod, std::chrono::microseconds duration, bool includeLines, @@ -737,9 +620,8 @@ WallProfiler::WallProfiler(std::chrono::microseconds samplingPeriod, bool collectCpuTime, bool collectAsyncId, bool isMainThread, - bool useCPED) + Local cpedKey) : samplingPeriod_(samplingPeriod), - useCPED_(useCPED), includeLines_(includeLines), withContexts_(withContexts), isMainThread_(isMainThread) { @@ -751,9 +633,9 @@ WallProfiler::WallProfiler(std::chrono::microseconds samplingPeriod, collectCpuTime_ = collectCpuTime && withContexts; collectAsyncId_ = collectAsyncId && withContexts; #if DD_WALL_USE_CPED - useCPED_ = useCPED && withContexts; + bool useCPED = withContexts && cpedKey->IsObject(); #else - useCPED_ = false; + constexpr bool useCPED = false; #endif if (withContexts_) { @@ -774,30 +656,16 @@ WallProfiler::WallProfiler(std::chrono::microseconds samplingPeriod, jsArray_ = v8::Global(isolate, jsArray); std::fill(fields_, fields_ + kFieldCount, 0); -#if DD_WALL_USE_CPED - if (useCPED_) { - // Used to create CPED proxy objects that will have one internal field to + if (useCPED) { + // Used to create Map value objects that will have one internal field to // store the sample context pointer. - auto cpedObjTpl = ObjectTemplate::New(isolate); - cpedObjTpl->SetInternalFieldCount(1); - cpedProxyTemplate_.Reset(isolate, cpedObjTpl); - // Symbol used for the property name that stores the proxied object in the - // CPED proxy object. - Local cpedProxySymbol = - Symbol::New(isolate, - String::NewFromUtf8Literal( - isolate, "WallProfiler::CPEDProxy::ProxiedObject")); - cpedProxySymbol_.Reset(isolate, cpedProxySymbol); - // Prototype for the CPED proxy object that will have methods and property - // getters that invoke the corresponding methods and properties on the - // proxied object. The set of methods & properties is chosen with the - // assumption that the proxied object is a Node.js AsyncContextFrame. - Local cpedProxyProto = Object::New(isolate); - cpedProxyProto_.Reset(isolate, cpedProxyProto); - - SetupCpedProxyProtoMethods(isolate, cpedProxyProto, cpedProxySymbol); - } -#endif // DD_WALL_USE_CPED + auto wrapObjectTemplate = ObjectTemplate::New(isolate); + wrapObjectTemplate->SetInternalFieldCount(1); + wrapObjectTemplate_.Reset(isolate, wrapObjectTemplate); + auto cpedKeyObj = cpedKey.As(); + cpedKey_.Reset(isolate, cpedKeyObj); + cpedKeyHash_ = cpedKeyObj->GetIdentityHash(); + } } WallProfiler::~WallProfiler() { @@ -818,7 +686,7 @@ void WallProfiler::Dispose(Isolate* isolate, bool removeFromMap) { g_profilers.RemoveProfiler(isolate, this); } - if (collectAsyncId_ || useCPED_) { + if (collectAsyncId_ || useCPED()) { isolate->RemoveGCPrologueCallback(&GCPrologueCallback, this); isolate->RemoveGCEpilogueCallback(&GCEpilogueCallback, this); } @@ -829,8 +697,7 @@ void WallProfiler::Dispose(Isolate* isolate, bool removeFromMap) { } #define DD_WALL_PROFILER_GET_BOOLEAN_CONFIG(name) \ - auto name##Value = \ - Nan::Get(arg, Nan::New(#name).ToLocalChecked()); \ + auto name##Value = getArg(#name); \ if (name##Value.IsEmpty() || !name##Value.ToLocalChecked()->IsBoolean()) { \ return Nan::ThrowTypeError(#name " must be a boolean."); \ } \ @@ -843,8 +710,14 @@ NAN_METHOD(WallProfiler::New) { if (info.IsConstructCall()) { auto arg = info[0].As(); - auto intervalMicrosValue = - Nan::Get(arg, Nan::New("intervalMicros").ToLocalChecked()); + auto isolate = info.GetIsolate(); + auto context = isolate->GetCurrentContext(); + auto getArg = [&](const char* name) { + return arg->Get(context, + String::NewFromUtf8(isolate, name).ToLocalChecked()); + }; + + auto intervalMicrosValue = getArg("intervalMicros"); if (intervalMicrosValue.IsEmpty() || !intervalMicrosValue.ToLocalChecked()->IsNumber()) { return Nan::ThrowTypeError("intervalMicros must be a number."); @@ -857,8 +730,7 @@ NAN_METHOD(WallProfiler::New) { return Nan::ThrowTypeError("Sample rate must be positive."); } - auto durationMillisValue = - Nan::Get(arg, Nan::New("durationMillis").ToLocalChecked()); + auto durationMillisValue = getArg("durationMillis"); if (durationMillisValue.IsEmpty() || !durationMillisValue.ToLocalChecked()->IsNumber()) { return Nan::ThrowTypeError("durationMillis must be a number."); @@ -882,6 +754,13 @@ NAN_METHOD(WallProfiler::New) { DD_WALL_PROFILER_GET_BOOLEAN_CONFIG(isMainThread); DD_WALL_PROFILER_GET_BOOLEAN_CONFIG(useCPED); + auto cpedKey = getArg("CPEDKey").ToLocalChecked(); + if (cpedKey->IsObject() && !useCPED) { + return Nan::ThrowTypeError("useCPED is false but CPEDKey is specified"); + } + if (useCPED && cpedKey->IsUndefined()) { + cpedKey = Object::New(isolate); + } #if !DD_WALL_USE_CPED if (useCPED) { return Nan::ThrowTypeError( @@ -933,7 +812,7 @@ NAN_METHOD(WallProfiler::New) { collectCpuTime, collectAsyncId, isMainThread, - useCPED); + cpedKey); obj->Wrap(info.This()); info.GetReturnValue().Set(info.This()); } else { @@ -974,7 +853,7 @@ Result WallProfiler::StartImpl() { // Register GC callbacks for async ID and CPED context tracking before // starting profiling auto isolate = Isolate::GetCurrent(); - if (collectAsyncId_ || useCPED_) { + if (collectAsyncId_ || useCPED()) { isolate->AddGCPrologueCallback(&GCPrologueCallback, this); isolate->AddGCEpilogueCallback(&GCEpilogueCallback, this); } @@ -1273,39 +1152,42 @@ void WallProfiler::SetCurrentContextPtr(Isolate* isolate, Local value) { void WallProfiler::SetContext(Isolate* isolate, Local value) { #if DD_WALL_USE_CPED - if (!useCPED_) { + if (!useCPED()) { SetCurrentContextPtr(isolate, value); return; } auto cped = isolate->GetContinuationPreservedEmbedderData(); // No Node AsyncContextFrame in this continuation yet - if (!cped->IsObject()) return; - - auto cpedObj = cped.As(); - PersistentContextPtr* contextPtr; - SignalGuard m(setInProgress_); - auto proxyProto = cpedProxyProto_.Get(isolate); - if (!proxyProto->StrictEquals(cpedObj->GetPrototype())) { - auto v8Ctx = isolate->GetCurrentContext(); - // This should always be called from a V8 context, but check just in case. - if (v8Ctx.IsEmpty()) return; - // Create a new CPED object with an internal field for the context pointer - auto proxyObj = - cpedProxyTemplate_.Get(isolate)->NewInstance(v8Ctx).ToLocalChecked(); - // Set up the proxy object to hold the proxied object and have the prototype - // that proxies AsyncContextFrame methods and properties - proxyObj->SetPrototype(v8Ctx, proxyProto).Check(); - proxyObj->Set(v8Ctx, cpedProxySymbol_.Get(isolate), cpedObj).Check(); - // Set up the context pointer in the internal field - contextPtr = new PersistentContextPtr(&liveContextPtrs_, proxyObj); - liveContextPtrs_.insert(contextPtr); - // Set the proxy object as the continuation preserved embedder data - isolate->SetContinuationPreservedEmbedderData(proxyObj); + if (!cped->IsMap()) return; + + auto v8Ctx = isolate->GetCurrentContext(); + // This should always be called from a V8 context, but check just in case. + if (v8Ctx.IsEmpty()) return; + + auto cpedMap = cped.As(); + auto localKey = cpedKey_.Get(isolate); + + // Always replace the PersistentContextPtr in the map even if it is present, + // we want the PersistentContextPtr in a parent map to not be mutated. + if (value->IsUndefined()) { + // The absence of a sample context will be interpreted as undefined in + // GetContextPtr so if value is undefined, just delete the key. + SignalGuard m(setInProgress_); + cpedMap->Delete(v8Ctx, localKey).Check(); } else { - contextPtr = PersistentContextPtr::Unwrap(cpedObj); + auto wrap = + wrapObjectTemplate_.Get(isolate)->NewInstance(v8Ctx).ToLocalChecked(); + // for easy access from JS when cpedKey is an ALS, it can do + // als.getStore()?.[0]; + wrap->Set(v8Ctx, 0, value).Check(); + auto contextPtr = new PersistentContextPtr(&liveContextPtrs_, wrap); + liveContextPtrs_.insert(contextPtr); + contextPtr->Set(isolate, value); + + SignalGuard m(setInProgress_); + cpedMap->Set(v8Ctx, localKey, wrap).ToLocalChecked(); } - contextPtr->Set(isolate, value); #else SetCurrentContextPtr(isolate, value); #endif @@ -1320,7 +1202,7 @@ ContextPtr WallProfiler::GetContextPtrSignalSafe(Isolate* isolate) { return ContextPtr(); } - if (useCPED_) { + if (useCPED()) { auto curGcCount = gcCount.load(std::memory_order_relaxed); std::atomic_signal_fence(std::memory_order_acquire); if (curGcCount > 0) { @@ -1333,27 +1215,34 @@ ContextPtr WallProfiler::GetContextPtrSignalSafe(Isolate* isolate) { ContextPtr WallProfiler::GetContextPtr(Isolate* isolate) { #if DD_WALL_USE_CPED - if (!useCPED_) { + if (!useCPED()) { return curContext_; } if (!isolate->IsInUse()) { - // Must not try to create a handle scope if isolate is not in use. return ContextPtr(); } - auto addr = reinterpret_cast( + auto cpedAddrPtr = reinterpret_cast( reinterpret_cast(isolate) + internal::Internals::kContinuationPreservedEmbedderDataOffset); - - if (internal::Internals::HasHeapObjectTag(*addr)) { - auto cped = reinterpret_cast(addr); - if (cped->IsObject()) { - auto cpedObj = static_cast(cped); - if (cpedObj->InternalFieldCount() > 0) { - return static_cast( - cpedObj->GetAlignedPointerFromInternalField(0)) - ->Get(); + auto cpedAddr = *cpedAddrPtr; + if (internal::Internals::HasHeapObjectTag(cpedAddr)) { + auto cpedValuePtr = reinterpret_cast(cpedAddrPtr); + if (cpedValuePtr->IsMap()) { + Address keyAddr = **(reinterpret_cast(&cpedKey_)); + + Address wrapAddr = GetValueFromMap(cpedAddr, cpedKeyHash_, keyAddr); + if (internal::Internals::HasHeapObjectTag(wrapAddr)) { + auto wrapValue = reinterpret_cast(&wrapAddr); + if (wrapValue->IsObject()) { + auto wrapObj = reinterpret_cast(wrapValue); + if (wrapObj->InternalFieldCount() > 0) { + return static_cast( + wrapObj->GetAlignedPointerFromInternalField(0)) + ->Get(); + } + } } } } @@ -1468,7 +1357,7 @@ void WallProfiler::OnGCStart(v8::Isolate* isolate) { if (collectAsyncId_) { gcAsyncId = GetAsyncIdNoGC(isolate); } - if (useCPED_) { + if (useCPED()) { gcContext_ = GetContextPtrSignalSafe(isolate); } } @@ -1478,10 +1367,9 @@ void WallProfiler::OnGCStart(v8::Isolate* isolate) { void WallProfiler::OnGCEnd() { auto oldCount = gcCount.fetch_sub(1, std::memory_order_relaxed); - if (oldCount != 1 || !useCPED_) { + if (oldCount != 1 || !useCPED()) { return; } - // Not strictly necessary, as we'll reset it to something else on next GC, // but why retain it longer than needed? gcContext_.reset(); diff --git a/bindings/profilers/wall.hh b/bindings/profilers/wall.hh index 0d044d43..9b4ed58a 100644 --- a/bindings/profilers/wall.hh +++ b/bindings/profilers/wall.hh @@ -49,15 +49,13 @@ class WallProfiler : public Nan::ObjectWrap { std::chrono::microseconds samplingPeriod_{0}; v8::CpuProfiler* cpuProfiler_ = nullptr; - bool useCPED_ = false; // If we aren't using the CPED, we use a single context ptr stored here. ContextPtr curContext_; - // Otherwise we'll use an internal field in objects stored in CPED. We must - // construct objects with an internal field count of 1 and a specially - // constructed prototype. - v8::Global cpedProxyTemplate_; - v8::Global cpedProxyProto_; - v8::Global cpedProxySymbol_; + // Otherwise we'll use an object as a key to store the context in + // AsyncContextFrame maps. + v8::Global cpedKey_; + int cpedKeyHash_ = 0; + v8::Global wrapObjectTemplate_; // We track live context pointers in a set to avoid memory leaks. They will // be deleted when the profiler is disposed. @@ -120,6 +118,8 @@ class WallProfiler : public Nan::ObjectWrap { void SetCurrentContextPtr(v8::Isolate* isolate, v8::Local context); + inline bool useCPED() { return !cpedKey_.IsEmpty(); } + public: /** * @param samplingPeriodMicros sampling interval, in microseconds @@ -127,10 +127,9 @@ class WallProfiler : public Nan::ObjectWrap { * parameter is informative; it is up to the caller to call the Stop method * every period. The parameter is used to preallocate data structures that * should not be reallocated in async signal safe code. - * @param useCPED whether to use the V8 ContinuationPreservedEmbedderData to - * store the current sampling context. It can be used if AsyncLocalStorage - * uses the AsyncContextFrame implementation (experimental in Node 23, default - * in Node 24.) + * @param cpedKey if an object, then the profiler should use the + * AsyncLocalFrame stored in the V8 ContinuationPreservedEmbedderData to store + * the current sampling context. */ explicit WallProfiler(std::chrono::microseconds samplingPeriod, std::chrono::microseconds duration, @@ -140,7 +139,7 @@ class WallProfiler : public Nan::ObjectWrap { bool collectCpuTime, bool collectAsyncId, bool isMainThread, - bool useCPED); + v8::Local cpedKey); v8::Local GetContext(v8::Isolate*); void SetContext(v8::Isolate*, v8::Local); diff --git a/doc/sample_context_in_cped.md b/doc/sample_context_in_cped.md index f4dd8a4b..5d76616f 100644 --- a/doc/sample_context_in_cped.md +++ b/doc/sample_context_in_cped.md @@ -9,12 +9,9 @@ typical piece of data sample context stores is the tracing span ID, so whenever it changes, the sample context needs to be updated. ## How is the Sample Context stored and updated? -Before Node 23, the sample context would be stored in a +Before Node 22.7, the sample context would be stored in a `std::shared_ptr>` field on the C++ `WallProfiler` -instance. (In fact, due to the need for ensuring atomic updates and shared -pointers not being effectively updateable atomically it's actually a pair of -fields with an atomic pointer-to-shared-pointer switching between them, but I -digress.) Due to it being a single piece of instance state, it had to be updated +instance. Due to it being a single piece of instance state, it had to be updated every time the active span changed, possibly on every invocation of `AsyncLocalStorage.enterWith` and `.run`, but even more importantly on every async context change, and for that we needed to register a "before" callback @@ -39,8 +36,8 @@ per-continuation basis and the engine takes care to return the right one when `Isolate::GetContinuationPreservedEmbedderData()` method is invoked. We will refer to continuation-preserved embedder data as "CPED" from now on. -Starting with Node.js 23, CPED is used to implement data storage behind Node.js -`AsyncLocalStorage` API. This dovetails nicely with our needs as all the +Starting with Node.js 22.7, CPED is used to implement data storage behind +Node.js `AsyncLocalStorage` API. This dovetails nicely with our needs as all the span-related data we set on the sample context is normally managed in an async local storage (ALS) by the tracer. An application can create any number of ALSes, and each ALS manages a single value per async context. This value is @@ -50,139 +47,149 @@ one per async context) and "store", which is a value of a storage within a particular async context. The new implementation for storing ALS stores introduces an internal Node.js -class named `AsyncContextFrame` (ACF) which is a map that uses ALSes as keys, -and their stores as the map values, essentially providing a mapping from an ALS -to its store in the current async context. (This implementation is very similar -to how e.g. Java implements `ThreadLocal`, which is a close analogue to ALS in -Node.js.) ACF instances are then stored in CPED. +class named `AsyncContextFrame` (ACF) which is a subclass of JavaScript Map +class that uses ALSes as keys and their stores as the map values, essentially +providing a mapping from an ALS to its store in the current async context. (This +implementation is very similar to how e.g. Java implements `ThreadLocal`, which +is a close analogue to ALS in Node.js.) ACF instances are then stored in CPED. -## Storing the Sample Context in CPED, take one +## Storing the Sample Context in CPED Node.js – as the embedder of V8 – commandeers the CPED to store instances of ACF in it. This means that our profiler can't directly store our sample context in the CPED, because then we'd overwrite the ACF reference already in there and -break Node.js. Our first attempt at solving this was to –- since ACF is "just" -an ordinary JavaScript object -- to define a new property on it, and store our -sample context in it! JavaScript properties can have strings, numbers, or -symbols as their keys, with symbols being the recommended practice to define -properties that are hidden from unrelated code as symbols are private to their -creator and only compare equal to themselves. Thus we created a private symbol in -the profiler instance for our property key, and our logic for storing the sample -context thus becomes: +break Node.js. Fortunately, since ACF is "just" an ordinary JavaScript Map, +we can store our sample context in it as a key-value pair! When a new ACF is +created (normally, through `AsyncLocalStorage.enterWith`), all key-value pairs +are copied into the new map, so our sample context is nicely propagated. +Our logic for storing the sample context thus becomes: * get the CPED from the V8 isolate -* if it is not an object, do nothing (we can't set the sample context) -* otherwise set the sample context as a value in the object with our property - key. - -Unfortunately, this approach is not signal safe. When we want to read the value -in the signal handler, it now needs to retrieve the CPED, which creates a V8 -`Local`, and then it needs to read a property on it, which creates -another `Local`. It also needs to retrieve the current context, and a `Local` -for the symbol used as a key – four `Local`s in total. V8 tracks the object -addresses pointed to by locals so that GC doesn't touch them. It tracks them in -a series of arrays, and if the current array fills up, it needs to allocate a -new one. As we know, allocation is unsafe in a signal handler, hence our -problem. We were thinking of a solution where we check if there is at least 4 -slots free in the current array, but then our profiler's operation would be at -mercy of V8 internal state. - -## Storing the Sample Context in CPED, take two - -Next we thought of replacing the `AsyncContextFrame` object in CPED with one we -created with an internal field – we can store and retrieve an arbitrary `void *` -in it with `{Get|Set}AlignedPointerInInternalField` methods. The initial idea -was to leverage JavaScript's property of being a prototype-based language and -set the original CPED object as the prototype of our replacement, so that all -its methods would keep being invoked. This unfortunately didn't work because -the `AsyncContextFrame` is a `Map` and our replacement object doesn't have the -internal structure of V8's implementation of a map. The final solution turned -out to be the one where we store the original ACF as a property in our -replacement object (now effectively, a proxy to the ACF), and define all the -`Map` methods and properties on the proxy so that they are invoked on the ACF. -Even though the proxy does not pass an `instanceof Map` check, it is duck-typed -as a map. We even encapsulated this behavior in a special prototype object, so -the operations to set the context are: -* retrieve the ACF from CPED -* create a new object (the proxy) with one internal field -* set the ACF as a special property in the proxy -* set the prototype of the proxy to our prototype that defines all the proxied -methods and properties to forward through the proxy-referenced ACF. -* store our sample context in the internal field of the proxy -* set the proxy object as the CPED. - -Now, a keen eyed reader will notice that in the signal handler we still need to -call `Isolate::GetContinuationPreservedEmbedderData` which still creates a -`Local`. That would be true, except that we can import the `v8-internals.h` -header and directly read the address of the object by reading into the isolate -at the offset `kContinuationPreservedEmbedderDataOffset` declared in it. +* if it is not a Map, do nothing (we can't set the sample context) +* otherwise set the sample context as a value in the map with our key. + +It's worth noting that our key is just an ordinary empty JavaScript object +created internally by the profiler. We could've also passed it an externally +created `AsyncLocalStorage` instance, thus preserving the invariant that all +keys in an ACF are ALS instances, but this doesn't seem necessary. + +We use a mutex implemented as an atomic boolean to guard our writes to the map. +The JavaScript code for AsyncContextFrame/AsyncLocalStorage treats the maps as +immutable. Whenever a new AsyncLocalStorage is added to the map, or even its +store value changes, the AsyncContextFrame map is copied into a new instance, +the change effected there, and the CPED reference in the isolate updated to the +new map. This means that for uncoordinated changes in JavaScript, we thankfully +require no guard. We only need to ensure we're guarding our own writes to the +map, which are the only in-place mutation of it. (Even we could've performed a +copy, but it feels excessive.) + +Internally, we hold on to the sample context value with a shared pointer to a +V8 `Global`: +``` +using ContextPtr = std::shared_ptr>; +``` +The values we store in ACF need to be JavaScript values. We use Node.js +`WrapObject` class for this purpose – it allows defining C++ classes that have +a JavaScript "mirror" object, carry a pointer to their C++ object in an internal +field, and when the JS object is garbage collected, the C++ object is destroyed. +Our `WrapObject` subclass in named `PersistentContextPtr` (PCP) because it has +only one field – the above introduced `ContextPtr`, and it is "persistent" +because its lifecycle is bound to that of its representative JavaScript object. + +So the more detailed algorithm for setting a sample context is: +* get the CPED from the V8 isolate +* if it is not a Map, do nothing (we can't set the sample context) +* if sample context is undefined, delete the key (if it exists) from the map +* if sample context is a different value, create a new `PersistentContextPtr` + wrapped in a JS object, and set the JS object as the value with the key in the + map. The chain of data now looks something like this: ``` v8::Isolate (from Isolate::GetCurrent()) +-> current continuation (internally managed by V8) - +-- our proxy object - +-- node::AsyncContextFrame (in proxy's private property, for forwarding method calls) - +-- prototype: declares functions and properties that forward to the AsyncContextFrame - +-- dd:PersistentContextPtr* (in proxy's internal field) - +-> std::shared_ptr> (in PersistentContextPtr's context field) - +-> v8::Global (in shared_ptr) - +-> v8::Value (the actual sample context object) - + +-> node::AsyncContextFrame (in continuation's CPED field) + +-> Object (the PersistentContextPtr wrapper, associated with our key) + +-> dd::PersistentContextPtr (pointed in Object's internal field) + +-> ContextPtr (in `context` field) + +-> v8::Global (in shared_ptr) + +-> v8::Value (the actual sample context object) ``` -The last 3 steps are the same as when CPED is not being used, except `context` -is directly represented in the `WallProfiler`, so then it looks like this: +The last 3-4 steps were the same in the previous code version as well, except +there we used a field directly in the `WallProfiler`: ``` dd::WallProfiler - +-> std::shared_ptr> (in either WallProfiler::ptr1 or ptr2) + +-> ContextPtr (in `curContext_` field) +-> v8::Global (in shared_ptr) +-> v8::Value (the actual sample context object) ``` - -### Memory allocations and garbage collection -We need to allocate a `PersistentContextPtr` (PCP) instance for every proxy we -create. The PCP has two concerns: it both has a shared pointer to the V8 global -that carries the sample context, and it also has a V8 weak reference to the -proxy object it is encapsulated within. This allows us to detect (since weak -references allow for GC callbacks) when the proxy object gets garbage collected, -and at that time the PCP itself can be either deleted or reused. We have an -optimization where we don't delete PCPs -- the assumption is that the number of -live ACFs (and thus proxies, and thus PCPs) will be constant for a server -application under load, so instead of doing a high amount of small new/delete -operations that can fragment the native heap, we keep the ones we'd delete in a -dequeue instead and reuse them. +The difference between the two diagrams shows how we moved the ContextPtr from +being a single instance state of `WallProfiler` to being an element in ACF maps. + +## Looking up values in a signal handler +The signal handler unfortunately can't directly call any V8 APIs, so in order to +traverse the chain of data above, it relies on some pointer arithmetic and +structure definition. Every `Global` and `Local` have one field, and `Address*`. +Thus, to dereference the actual memory location of a JS object represented by a +global reference `ref`, we use `**(Address**)(&ref)`. These +addresses are _tagged_, meaning their LSB is set to 1, and need to be masked to +obtain the actual memory address. We can safely get the current Isolate pointer, +but then we need to interpret as an address the memory location at an internal +offset where it keeps the current CPED. If it's a JS Map, then we need to +retrieve from it a pointer to its `OrderedHashMap`, and then know its memory +layout to find the right hash bucket and traverse the linked list until we find +a key-value pair where the key address is our key object's current address (this +can be moved around by the GC, so that's why our Global is an `Address*`, for +a sufficient number of indirections to keep up with the moves.) The algorithm +for executing an equivalent of a `Map.get()` with knowledge of the V8 object +memory layouts is encapsulated in `map-get.cc`. We define C++ structs that +describe V8 internal `JSMap`, `FixedArray`, `OrderedHashMap` and +`SmallOrderedHashMap` structures, treat the memory pointed to by those pointers +as if they were these data structures (because they are), and read from them. If +in the future V8 changes these structures, we wil also need to adapt. +Unfortunately V8 doesn't export definitions of these data structures in a +publicly accessible header. ## Odds and ends And that's mostly it! There are few more small odds and ends to make it work -safely. We still need to guard reading the value in the signal handler while -it's being written. We guard by introducing an atomic boolean and proper signal -fencing. - -The signal handler code also needs to be prevented from trying to access the -data while GC is in progress. For this reason, we register GC prologue and -epilogue callbacks with the V8 isolate so we can know when GCs are ongoing and -the signal handler will refrain from reading the CPED field during them. We'll -however grab the current sample context from the CPED and store it in a profiler -instance field in the GC prologue and use it for any samples taken during GC. +safely. As we mentioned above, we're preventing the signal handler from reading +if we're just writing the value using an atomic boolean. We also register GC +prologue and epilogue callbacks with the V8 isolate so we can know when GCs are +ongoing and the signal handler will also refrain from touching memory while a GC +runs. We'll however grab the current sample context from the CPED +and store it in a profiler instance field in the GC prologue and use it for any +samples taken during GC. + +Speaking of GC, we can now have an unbounded number of PersistentContextPtr +objects – one for each live ACF. Each PCP is allocated on the C++ heap, and +needs to be deleted eventually. The profiler tracks every PCP it creates in an +internal set of live PCPs and deletes them all when it itself gets disposed. +This is combined with `WrapObject` having GC finalization callback for every +PCP. When V8 collects a PCP wrapper its finalization callback will delete the +PCP. ## Changes in dd-trace-js For completeness, we'll describe the changes in dd-trace-js here as well. The main change is that with Node 24, we no longer require async hooks. The -instrumentation points for `AsyncLocalStorage.enterWith` and -`AsyncLocalStorage.run` remain in place – they are the only ones that are needed -now. +instrumentation point for `AsyncLocalStorage.enterWith` is the only one +remaining (`AsyncLocalStorage.run` is implemented in terms of `enterWith`.) +We can further optimize and _not_ set the sample context object if we see it's +the same as the current one (because `enterWith` was run without setting a new +span as the current span.) There are some small performance optimizations that no longer apply with the new approach, though. For one, with the old approach we did some data conversions -(span IDs to bigint, a tag array to endpoint string) in a sample when a sample -was captured. With the new approach, we do these conversions for all sample -contexts during profile serialization. Doing them after each sample capture -amortized their cost, possibly reducing the latency induced at serialization -time. With the old approach we also called `SetContext` only once per sampling – -we'd install a sample context to be used for the next sample, and then kept -updating a `ref` field in it with a reference to the actual data from pure -JavaScript code. Since we no longer have a single sample context (but one per -continuation) we can not do this anymore, and we need to call `SetContext` on -every ACF change. The cost of this (basically, going into a native call from -JavaScript) are still well offset by not having to use async hooks and do work -on every async context change. We could arguably simplify the code by removing -those small optimizations. +(span IDs to string, a tag array to endpoint string) in a sample context when a +sample was captured. With the new approach, we do these conversions for all +sample contexts during profile serialization. Doing them after each sample +capture amortized their cost possibly minimally reducing the latency induced at +serialization time. With the old approach we also called `SetContext` only once +per sampling – we'd install a sample context to be used for the next sample, and +then kept updating a `ref` field in it with a reference to the actual data. +Since we no longer have a single sample context (but rather one per +continuation) we can not do this anymore, and we need to call `SetContext` +either every time `enterWith` runs, or only when we notice that the relevant +span data changed. +The cost of this (basically, going into a native call from JavaScript) are still +well offset by not having to use async hooks and do work on every async context +change. We could arguably even simplify the code by removing those small +optimizations. diff --git a/ts/src/time-profiler.ts b/ts/src/time-profiler.ts index acd242e1..0aabb53e 100644 --- a/ts/src/time-profiler.ts +++ b/ts/src/time-profiler.ts @@ -29,7 +29,7 @@ import { } from './time-profiler-bindings'; import {GenerateTimeLabelsFunction, TimeProfilerMetrics} from './v8-types'; import {isMainThread} from 'worker_threads'; - +import {AsyncLocalStorage} from 'async_hooks'; const {kSampleCount} = profilerConstants; const DEFAULT_INTERVAL_MICROS: Microseconds = 1000; @@ -39,6 +39,7 @@ type Microseconds = number; type Milliseconds = number; let gProfiler: InstanceType | undefined; +let gStore: AsyncLocalStorage | undefined; let gSourceMapper: SourceMapper | undefined; let gIntervalMicros: Microseconds; let gV8ProfilerStuckEventLoopDetected = 0; @@ -95,12 +96,14 @@ export function start(options: TimeProfilerOptions = {}) { throw new Error('Wall profiler is already started'); } - gProfiler = new TimeProfiler({...options, isMainThread}); + const store = options.useCPED === true ? new AsyncLocalStorage() : undefined; + gProfiler = new TimeProfiler({...options, CPEDKey: store, isMainThread}); gSourceMapper = options.sourceMapper; gIntervalMicros = options.intervalMicros!; gV8ProfilerStuckEventLoopDetected = 0; gProfiler.start(); + gStore = store; // If contexts are enabled without using CPED, set an initial empty context if (options.withContexts && !options.useCPED) { @@ -144,6 +147,10 @@ export function stop( gProfiler.dispose(); gProfiler = undefined; gSourceMapper = undefined; + if (gStore !== undefined) { + gStore.disable(); + gStore = undefined; + } } return serializedProfile; } diff --git a/ts/test/cped-freelist-regression-child.ts b/ts/test/cped-freelist-regression-child.ts deleted file mode 100644 index 988a7a8d..00000000 --- a/ts/test/cped-freelist-regression-child.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Child process entrypoint for CPED freelist trimming regression test. - * - * This file is intentionally not named `test-*.ts` so mocha won't execute it - * directly. It is executed as a standalone Node.js script from the test suite. - */ - -import assert from 'assert'; -import {AsyncLocalStorage} from 'async_hooks'; -import {satisfies} from 'semver'; - -// Require from the built output to match how tests run in CI (out/test/*). -// eslint-disable-next-line @typescript-eslint/no-var-requires -const {time} = require('../src'); - -function isUseCPEDEnabled(): boolean { - return ( - (satisfies(process.versions.node, '>=24.0.0') && - !process.execArgv.includes('--no-async-context-frame')) || - (satisfies(process.versions.node, '>=22.7.0') && - process.execArgv.includes('--experimental-async-context-frame')) - ); -} - -async function main() { - if (process.platform !== 'darwin' && process.platform !== 'linux') { - return; // unsupported in this repo's time profiler tests - } - - // This regression targets the CPED path. - const useCPED = isUseCPEDEnabled(); - if (!useCPED) return; - - const gc = global.gc; - if (typeof gc !== 'function') { - throw new Error('expected --expose-gc'); - } - const runGc = gc as () => void; - - // Ensure an async context frame exists to hold the profiler context. - new AsyncLocalStorage().enterWith(1); - - time.start({ - intervalMicros: 1000, - durationMillis: 10_000, - withContexts: true, - lineNumbers: false, - useCPED: true, - }); - - const als = new AsyncLocalStorage(); - - const waveSize = 20_000; - const maxWaves = 6; - const minDelta = 5_000; - const minTotalBeforeGc = 40_000; - const debug = process.env.DEBUG_CPED_TEST === '1'; - const log = (...args: unknown[]) => { - if (debug) { - // eslint-disable-next-line no-console - console.error(...args); - } - }; - - async function gcAndYield(times = 3) { - for (let i = 0; i < times; i++) { - runGc(); - await new Promise(resolve => setImmediate(resolve)); - } - } - - async function runWave(count: number): Promise { - const tasks: Array> = []; - for (let i = 0; i < count; i++) { - const value = i; - tasks.push( - als.run(value, async () => { - await new Promise(resolve => setTimeout(resolve, 0)); - time.setContext({v: value}); - }) - ); - } - await Promise.all(tasks); - } - - const baseline = time.getMetrics().totalAsyncContextCount; - let totalBeforeGc = baseline; - let wavesRun = 0; - while (wavesRun < maxWaves && totalBeforeGc < minTotalBeforeGc) { - await runWave(waveSize); - totalBeforeGc = time.getMetrics().totalAsyncContextCount; - wavesRun++; - log('wave', wavesRun, 'totalBeforeGc', totalBeforeGc); - } - const metricsBeforeGc = time.getMetrics(); - log('baseline', baseline, 'metricsBeforeGc', metricsBeforeGc); - assert( - totalBeforeGc - baseline >= minDelta, - `test did not create enough async contexts (baseline=${baseline}, total=${totalBeforeGc})` - ); - assert( - totalBeforeGc >= minTotalBeforeGc, - `test did not reach target async context count (total=${totalBeforeGc})` - ); - - await gcAndYield(6); - const metricsAfterGc = time.getMetrics(); - const totalAfterGc = metricsAfterGc.totalAsyncContextCount; - log('metricsAfterGc', metricsAfterGc); - const maxAllowed = Math.floor(totalBeforeGc * 0.75); - assert( - totalAfterGc <= maxAllowed, - `expected trimming; before=${totalBeforeGc}, after=${totalAfterGc}, max=${maxAllowed}` - ); - - time.stop(false); -} - -main().catch(err => { - // Ensure the child exits non-zero on failure. - // eslint-disable-next-line no-console - console.error(err); - // eslint-disable-next-line no-process-exit - process.exit(1); -}); diff --git a/ts/test/test-get-value-from-map-profiler.ts b/ts/test/test-get-value-from-map-profiler.ts new file mode 100644 index 00000000..329f3254 --- /dev/null +++ b/ts/test/test-get-value-from-map-profiler.ts @@ -0,0 +1,195 @@ +/** + * Copyright 2025 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. + */ + +/** + * Tests for GetValueFromMap through the TimeProfiler API. + * + * These tests verify that GetValueFromMap works correctly in its actual usage context: + * - The profiler creates a key object (AsyncLocalStorage or internal key) + * - Contexts are stored in the CPED map using that key + * - GetValueFromMap retrieves contexts using the same key during signal handling + * + * This is the real-world usage pattern, and these tests confirm the structure + * layout and key address extraction work correctly on Node 24.x / V8 13.6. + */ + +import assert from 'assert'; +import {join} from 'path'; +import {AsyncLocalStorage} from 'async_hooks'; +import {satisfies} from 'semver'; + +const findBinding = require('node-gyp-build'); +const profiler = findBinding(join(__dirname, '..', '..')); + +const useCPED = + (satisfies(process.versions.node, '>=24.0.0') && + !process.execArgv.includes('--no-async-context-frame')) || + (satisfies(process.versions.node, '>=22.7.0') && + process.execArgv.includes('--experimental-async-context-frame')); + +const supportedPlatform = + process.platform === 'darwin' || process.platform === 'linux'; + +if (useCPED && supportedPlatform) { + describe('GetValueFromMap (through TimeProfiler)', () => { + describe('basic context storage and retrieval', () => { + it('should store and retrieve a simple object context', () => { + const als = new AsyncLocalStorage(); + const profiler = createProfiler(als); + + als.enterWith([]); + + const context = {label: 'test-context'}; + profiler.context = context; + + const retrieved = profiler.context; + assert.strictEqual( + retrieved, + context, + 'Should retrieve the same object' + ); + + profiler.dispose(); + }); + + it('should store and retrieve context with multiple properties', () => { + const als = new AsyncLocalStorage(); + const profiler = createProfiler(als); + + als.enterWith([]); + + const context = { + spanId: '1234567890', + traceId: 'abcdef123456', + operation: 'test-operation', + resource: '/api/endpoint', + tags: {environment: 'test', version: '1.0'}, + }; + + profiler.context = context; + const retrieved = profiler.context; + + assert.deepStrictEqual(retrieved, context); + assert.strictEqual(retrieved.spanId, context.spanId); + assert.strictEqual(retrieved.traceId, context.traceId); + assert.deepStrictEqual(retrieved.tags, context.tags); + + profiler.dispose(); + }); + + it('should handle context updates', () => { + const als = new AsyncLocalStorage(); + const profiler = createProfiler(als); + + als.enterWith([]); + + const context1 = {label: 'first'}; + profiler.context = context1; + assert.strictEqual(profiler.context, context1); + + const context2 = {label: 'second'}; + profiler.context = context2; + assert.strictEqual(profiler.context, context2); + + const context3 = {label: 'third', extra: 'data'}; + profiler.context = context3; + assert.strictEqual(profiler.context, context3); + + profiler.dispose(); + }); + + it('should return undefined for undefined context', () => { + const als = new AsyncLocalStorage(); + const profiler = createProfiler(als); + + als.enterWith([]); + + profiler.context = undefined; + const retrieved = profiler.context; + + assert.strictEqual(retrieved, undefined); + + profiler.dispose(); + }); + }); + + describe('multiple context frames', () => { + it('should isolate contexts in different async frames', () => { + const als = new AsyncLocalStorage(); + const profiler = createProfiler(als); + + const context1 = {frame: 'frame1'}; + const context2 = {frame: 'frame2'}; + + // Frame 1 + als.run([], () => { + profiler.context = context1; + assert.deepStrictEqual(profiler.context, context1); + }); + + // Frame 2 + als.run([], () => { + assert.strictEqual(profiler.context, undefined); + profiler.context = context2; + assert.deepStrictEqual(profiler.context, context2); + }); + + // Outside frames + assert.strictEqual(profiler.context, undefined); + + profiler.dispose(); + }); + + it('should handle nested async frames', () => { + const als = new AsyncLocalStorage(); + const profiler = createProfiler(als); + + const outerContext = {level: 'outer'}; + const innerContext = {level: 'inner'}; + + als.run([], () => { + profiler.context = outerContext; + assert.deepStrictEqual(profiler.context, outerContext); + + als.run([], () => { + profiler.context = innerContext; + assert.deepStrictEqual(profiler.context, innerContext); + }); + + // Back to outer context frame + assert.deepStrictEqual(profiler.context, outerContext); + }); + + profiler.dispose(); + }); + }); + }); +} + +function createProfiler(als: AsyncLocalStorage) { + return new profiler.TimeProfiler({ + intervalMicros: 10000, + durationMillis: 500, + withContexts: true, + useCPED: true, + CPEDKey: als, + lineNumbers: false, + workaroundV8Bug: false, + collectCpuTime: false, + collectAsyncId: true, + isMainThread: true, + }); +} diff --git a/ts/test/test-time-profiler.ts b/ts/test/test-time-profiler.ts index d3b7924f..8082245f 100644 --- a/ts/test/test-time-profiler.ts +++ b/ts/test/test-time-profiler.ts @@ -22,7 +22,6 @@ import {hrtime} from 'process'; import {Label, Profile} from 'pprof-format'; import {AssertionError} from 'assert'; import {GenerateTimeLabelsArgs, LabelSet} from '../src/v8-types'; -import {AsyncLocalStorage} from 'async_hooks'; import {satisfies} from 'semver'; import {setTimeout as setTimeoutPromise} from 'timers/promises'; @@ -54,10 +53,6 @@ describe('Time Profiler', () => { this.skip(); } const startTime = BigInt(Date.now()) * 1000n; - if (useCPED) { - // Ensure an async context frame is created to hold the profiler context. - new AsyncLocalStorage().enterWith(1); - } time.start({ intervalMicros: 20 * 1_000, durationMillis: PROFILE_OPTIONS.durationMillis, @@ -112,10 +107,6 @@ describe('Time Profiler', () => { this.timeout(3000); const intervalNanos = PROFILE_OPTIONS.intervalMicros * 1_000; - if (useCPED) { - // Ensure an async context frame is created to hold the profiler context. - new AsyncLocalStorage().enterWith(1); - } time.start({ intervalMicros: PROFILE_OPTIONS.intervalMicros, durationMillis: PROFILE_OPTIONS.durationMillis, @@ -139,10 +130,6 @@ describe('Time Profiler', () => { for (let i = 0; i < repeats; ++i) { loop(); enableEndPoint = i % 2 === 0; - const metrics = time.getMetrics(); - const expectedAsyncContextCount = useCPED ? 1 : 0; - assert(metrics.totalAsyncContextCount === expectedAsyncContextCount); - assert(metrics.usedAsyncContextCount === expectedAsyncContextCount); validateProfile( time.stop( i < repeats - 1, @@ -554,11 +541,6 @@ describe('Time Profiler', () => { } this.timeout(3000); - if (useCPED) { - // Ensure an async context frame is created to hold the profiler context. - new AsyncLocalStorage().enterWith(1); - } - // Set up some contexts with labels that we'll mark as low cardinality const lowCardLabel = 'service_name'; const highCardLabel = 'trace_id'; diff --git a/ts/test/worker.ts b/ts/test/worker.ts index bcc7d147..5bc7dca9 100644 --- a/ts/test/worker.ts +++ b/ts/test/worker.ts @@ -6,7 +6,6 @@ import {getAndVerifyPresence, getAndVerifyString} from './profiles-for-tests'; import {satisfies} from 'semver'; import assert from 'assert'; -import {AsyncLocalStorage} from 'async_hooks'; const DURATION_MILLIS = 1000; const intervalMicros = 10000; @@ -62,7 +61,6 @@ function getCpuUsage() { } async function main(durationMs: number) { - if (useCPED) new AsyncLocalStorage().enterWith(1); time.start({ durationMillis: durationMs * 3, intervalMicros, @@ -107,7 +105,6 @@ async function main(durationMs: number) { } async function worker(durationMs: number) { - if (useCPED) new AsyncLocalStorage().enterWith(1); time.start({ durationMillis: durationMs, intervalMicros, From 60656da3f31fb1df66e206e5f2cec09dd5478323 Mon Sep 17 00:00:00 2001 From: Ilyas Shabi Date: Fri, 20 Feb 2026 14:25:43 +0100 Subject: [PATCH 03/16] Switch heap profiling to use lazy allocation profile method by default (#281) use MapAllocationProfile by default to get heap profiler --- README.md | 8 +- bindings/profilers/heap.cc | 18 ---- bindings/profilers/heap.hh | 4 - bindings/translate-heap-profile.cc | 29 ------- bindings/translate-heap-profile.hh | 3 - ts/src/heap-profiler-bindings.ts | 4 - ts/src/heap-profiler.ts | 43 +--------- ts/src/index.ts | 1 - ts/test/heap-memory-worker.ts | 112 ------------------------- ts/test/test-heap-profiler-v2.ts | 130 ----------------------------- ts/test/test-heap-profiler.ts | 44 ++++++++-- 11 files changed, 44 insertions(+), 352 deletions(-) delete mode 100644 ts/test/heap-memory-worker.ts delete mode 100644 ts/test/test-heap-profiler-v2.ts diff --git a/README.md b/README.md index a3799c34..675eddea 100644 --- a/README.md +++ b/README.md @@ -97,10 +97,14 @@ Install [`pprof`][npm-url] with `npm` or add to your `package.json`. pprof -http=: heap.pb.gz ``` - * Collecting a heap profile with V8 allocation profile format: + * Collecting a heap profile with V8 allocation profile format: ```javascript - const profile = await pprof.heap.v8Profile(); + const profile = pprof.heap.v8Profile(pprof.heap.convertProfile); ``` + `v8Profile` accepts a callback and returns its result. Allocation nodes + are only valid during the callback, so copy/transform what you need + before returning. `heap.convertProfile` performs that conversion during + the callback, and `heap.profile()` uses it under the hood. [build-image]: https://github.com/Datadog/pprof-nodejs/actions/workflows/build.yml/badge.svg?branch=main [build-url]: https://github.com/Datadog/pprof-nodejs/actions/workflows/build.yml diff --git a/bindings/profilers/heap.cc b/bindings/profilers/heap.cc index a5e3c435..ff7f2d01 100644 --- a/bindings/profilers/heap.cc +++ b/bindings/profilers/heap.cc @@ -488,23 +488,6 @@ NAN_METHOD(HeapProfiler::StopSamplingHeapProfiler) { } } -// Signature: -// getAllocationProfile(): AllocationProfileNode -NAN_METHOD(HeapProfiler::GetAllocationProfile) { - auto isolate = info.GetIsolate(); - std::unique_ptr profile( - isolate->GetHeapProfiler()->GetAllocationProfile()); - if (!profile) { - return Nan::ThrowError("Heap profiler is not enabled."); - } - v8::AllocationProfile::Node* root = profile->GetRootNode(); - auto state = PerIsolateData::For(isolate)->GetHeapProfilerState(); - if (state) { - state->OnNewProfile(); - } - info.GetReturnValue().Set(TranslateAllocationProfile(root)); -} - // mapAllocationProfile(callback): callback result NAN_METHOD(HeapProfiler::MapAllocationProfile) { if (info.Length() < 1 || !info[0]->IsFunction()) { @@ -596,7 +579,6 @@ NAN_MODULE_INIT(HeapProfiler::Init) { heapProfiler, "startSamplingHeapProfiler", StartSamplingHeapProfiler); Nan::SetMethod( heapProfiler, "stopSamplingHeapProfiler", StopSamplingHeapProfiler); - Nan::SetMethod(heapProfiler, "getAllocationProfile", GetAllocationProfile); Nan::SetMethod(heapProfiler, "mapAllocationProfile", MapAllocationProfile); Nan::SetMethod(heapProfiler, "monitorOutOfMemory", MonitorOutOfMemory); Nan::Set(target, diff --git a/bindings/profilers/heap.hh b/bindings/profilers/heap.hh index aef0ef7e..d87e1cac 100644 --- a/bindings/profilers/heap.hh +++ b/bindings/profilers/heap.hh @@ -30,10 +30,6 @@ class HeapProfiler { // stopSamplingHeapProfiler() static NAN_METHOD(StopSamplingHeapProfiler); - // Signature: - // getAllocationProfile(): AllocationProfileNode - static NAN_METHOD(GetAllocationProfile); - // Signature: // mapAllocationProfile(callback): callback result static NAN_METHOD(MapAllocationProfile); diff --git a/bindings/translate-heap-profile.cc b/bindings/translate-heap-profile.cc index 41f5e129..0e795dd3 100644 --- a/bindings/translate-heap-profile.cc +++ b/bindings/translate-heap-profile.cc @@ -41,30 +41,6 @@ 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(Node* node) { v8::Local children = NewArray(node->children.size()); for (size_t i = 0; i < node->children.size(); i++) { @@ -142,11 +118,6 @@ std::shared_ptr TranslateAllocationProfileToCpp( return new_node; } -v8::Local TranslateAllocationProfile( - v8::AllocationProfile::Node* node) { - return HeapProfileTranslator().TranslateAllocationProfile(node); -} - v8::Local TranslateAllocationProfile(Node* node) { return HeapProfileTranslator().TranslateAllocationProfile(node); } diff --git a/bindings/translate-heap-profile.hh b/bindings/translate-heap-profile.hh index e913dd66..22b644b7 100644 --- a/bindings/translate-heap-profile.hh +++ b/bindings/translate-heap-profile.hh @@ -39,7 +39,4 @@ std::shared_ptr TranslateAllocationProfileToCpp( v8::AllocationProfile::Node* node); v8::Local TranslateAllocationProfile(Node* node); -v8::Local TranslateAllocationProfile( - v8::AllocationProfile::Node* node); - } // namespace dd diff --git a/ts/src/heap-profiler-bindings.ts b/ts/src/heap-profiler-bindings.ts index f5baa8e8..e6fa0b05 100644 --- a/ts/src/heap-profiler-bindings.ts +++ b/ts/src/heap-profiler-bindings.ts @@ -37,10 +37,6 @@ export function stopSamplingHeapProfiler() { profiler.heapProfiler.stopSamplingHeapProfiler(); } -export function getAllocationProfile(): AllocationProfileNode { - return profiler.heapProfiler.getAllocationProfile(); -} - export function mapAllocationProfile( callback: (root: AllocationProfileNode) => T ): T { diff --git a/ts/src/heap-profiler.ts b/ts/src/heap-profiler.ts index afe08e75..550121e6 100644 --- a/ts/src/heap-profiler.ts +++ b/ts/src/heap-profiler.ts @@ -17,7 +17,6 @@ import {Profile} from 'pprof-format'; import { - getAllocationProfile, mapAllocationProfile, startSamplingHeapProfiler, stopSamplingHeapProfiler, @@ -34,20 +33,6 @@ import {isMainThread} from 'worker_threads'; let enabled = false; let heapIntervalBytes = 0; let heapStackDepth = 0; - -/* - * Collects a heap profile when heapProfiler is enabled. Otherwise throws - * an error. - * - * Data is returned in V8 allocation profile format. - */ -export function v8Profile(): AllocationProfileNode { - if (!enabled) { - throw new Error('Heap profiler is not enabled.'); - } - return getAllocationProfile(); -} - /** * Collects a heap profile when heapProfiler is enabled. Otherwise throws * an error. @@ -59,35 +44,13 @@ export function v8Profile(): AllocationProfileNode { * @param callback - function to convert the heap profiler to a converted profile * @returns converted profile */ -export function v8ProfileV2( - callback: (root: AllocationProfileNode) => T -): T { +export function v8Profile(callback: (root: AllocationProfileNode) => T): T { if (!enabled) { throw new Error('Heap profiler is not enabled.'); } return mapAllocationProfile(callback); } -/** - * Collects a profile and returns it serialized in pprof format. - * Throws if heap profiler is not enabled. - * - * @param ignoreSamplePath - * @param sourceMapper - */ -export function profile( - ignoreSamplePath?: string, - sourceMapper?: SourceMapper, - generateLabels?: GenerateAllocationLabelsFunction -): Profile { - return convertProfile( - v8Profile(), - ignoreSamplePath, - sourceMapper, - generateLabels - ); -} - export function convertProfile( rootNode: AllocationProfileNode, ignoreSamplePath?: string, @@ -130,12 +93,12 @@ export function convertProfile( * @param sourceMapper * @param generateLabels */ -export function profileV2( +export function profile( ignoreSamplePath?: string, sourceMapper?: SourceMapper, generateLabels?: GenerateAllocationLabelsFunction ): Profile { - return v8ProfileV2(root => { + return v8Profile(root => { return convertProfile(root, ignoreSamplePath, sourceMapper, generateLabels); }); } diff --git a/ts/src/index.ts b/ts/src/index.ts index be2a4170..42454629 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -48,7 +48,6 @@ export const heap = { start: heapProfiler.start, stop: heapProfiler.stop, profile: heapProfiler.profile, - profileV2: heapProfiler.profileV2, convertProfile: heapProfiler.convertProfile, v8Profile: heapProfiler.v8Profile, monitorOutOfMemory: heapProfiler.monitorOutOfMemory, diff --git a/ts/test/heap-memory-worker.ts b/ts/test/heap-memory-worker.ts deleted file mode 100644 index fa7038c5..00000000 --- a/ts/test/heap-memory-worker.ts +++ /dev/null @@ -1,112 +0,0 @@ -import * as heapProfiler from '../src/heap-profiler'; -import * as v8HeapProfiler from '../src/heap-profiler-bindings'; -import {AllocationProfileNode} from '../src/v8-types'; - -const gc = (global as NodeJS.Global & {gc?: () => void}).gc; -if (!gc) { - throw new Error('Run with --expose-gc flag'); -} - -const keepAlive: object[] = []; - -// Create many unique functions via new Function() to produce a large profile tree. -function createAllocatorFunctions(count: number): Function[] { - const fns: Function[] = []; - for (let i = 0; i < count; i++) { - const fn = new Function( - 'keepAlive', - ` - for (let j = 0; j < 100; j++) { - keepAlive.push({ - id${i}: j, - data${i}: new Array(64).fill('${'x'.repeat(16)}'), - }); - } - ` - ); - fns.push(() => fn(keepAlive)); - } - return fns; -} - -function createDeepChain(depth: number): Function[] { - const fns: Function[] = []; - for (let i = depth - 1; i >= 0; i--) { - const next = i < depth - 1 ? fns[fns.length - 1] : null; - const fn = new Function( - 'keepAlive', - 'next', - ` - for (let j = 0; j < 50; j++) { - keepAlive.push({ arr${i}: new Array(32).fill(j) }); - } - if (next) next(keepAlive, null); - ` - ) as (arr: object[], next: unknown) => void; - fns.push((arr: object[]) => fn(arr, next)); - } - return fns; -} - -function generateAllocations(): void { - const wideFns = createAllocatorFunctions(5000); - for (const fn of wideFns) { - fn(); - } - - for (let chain = 0; chain < 200; chain++) { - const deepFns = createDeepChain(50); - deepFns[deepFns.length - 1](keepAlive); - } -} - -function traverseTree(root: AllocationProfileNode): void { - const stack: AllocationProfileNode[] = [root]; - while (stack.length > 0) { - const node = stack.pop()!; - if (node.children) { - for (const child of node.children) { - stack.push(child); - } - } - } -} - -function measureV1(): {initial: number; afterTraversal: number} { - gc!(); - gc!(); - const baseline = process.memoryUsage().heapUsed; - - const profile = v8HeapProfiler.getAllocationProfile(); - const initial = process.memoryUsage().heapUsed - baseline; - traverseTree(profile); - const afterTraversal = process.memoryUsage().heapUsed - baseline; - - return {initial, afterTraversal}; -} - -function measureV2(): {initial: number; afterTraversal: number} { - gc!(); - gc!(); - const baseline = process.memoryUsage().heapUsed; - - return v8HeapProfiler.mapAllocationProfile(root => { - const initial = process.memoryUsage().heapUsed - baseline; - traverseTree(root); - const afterTraversal = process.memoryUsage().heapUsed - baseline; - return {initial, afterTraversal}; - }); -} - -process.on('message', (version: 'v1' | 'v2') => { - heapProfiler.start(128, 128); - generateAllocations(); - - const {initial, afterTraversal} = - version === 'v1' ? measureV1() : measureV2(); - - heapProfiler.stop(); - keepAlive.length = 0; - - process.send!({initial, afterTraversal}); -}); diff --git a/ts/test/test-heap-profiler-v2.ts b/ts/test/test-heap-profiler-v2.ts deleted file mode 100644 index d514c241..00000000 --- a/ts/test/test-heap-profiler-v2.ts +++ /dev/null @@ -1,130 +0,0 @@ -import {strict as assert} from 'assert'; -import {fork} from 'child_process'; -import * as heapProfiler from '../src/heap-profiler'; -import * as v8HeapProfiler from '../src/heap-profiler-bindings'; - -function generateAllocations(): object[] { - const allocations: object[] = []; - for (let i = 0; i < 1000; i++) { - allocations.push({data: new Array(100).fill(i)}); - } - return allocations; -} - -describe('HeapProfiler V2 API', () => { - let keepAlive: object[] = []; - - before(() => { - heapProfiler.start(512, 64); - keepAlive = generateAllocations(); - }); - - after(() => { - heapProfiler.stop(); - keepAlive.length = 0; - }); - - describe('v8ProfileV2', () => { - it('should return AllocationProfileNode', () => { - heapProfiler.v8ProfileV2(root => { - assert.equal(typeof root.name, 'string'); - assert.equal(typeof root.scriptName, 'string'); - assert.equal(typeof root.scriptId, 'number'); - assert.equal(typeof root.lineNumber, 'number'); - assert.equal(typeof root.columnNumber, 'number'); - assert.ok(Array.isArray(root.allocations)); - - assert.ok(Array.isArray(root.children)); - assert.equal(typeof root.children.length, 'number'); - - if (root.children.length > 0) { - const child = root.children[0]; - assert.equal(typeof child.name, 'string'); - assert.ok(Array.isArray(child.children)); - assert.ok(Array.isArray(child.allocations)); - } - }); - }); - - it('should throw error when profiler not started', () => { - heapProfiler.stop(); - assert.throws( - () => { - heapProfiler.v8ProfileV2(() => {}); - }, - (err: Error) => { - return err.message === 'Heap profiler is not enabled.'; - } - ); - heapProfiler.start(512, 64); - }); - }); - - describe('mapAllocationProfile', () => { - it('should return AllocationProfileNode directly', () => { - v8HeapProfiler.mapAllocationProfile(root => { - assert.equal(typeof root.name, 'string'); - assert.equal(typeof root.scriptName, 'string'); - assert.ok(Array.isArray(root.children)); - assert.ok(Array.isArray(root.allocations)); - }); - }); - }); - - describe('profileV2', () => { - it('should produce valid pprof Profile', () => { - const profile = heapProfiler.profileV2(); - - assert.ok(profile.sampleType); - assert.ok(profile.sample); - assert.ok(profile.location); - assert.ok(profile.function); - assert.ok(profile.stringTable); - }); - }); - - describe('Memory comparison', () => { - interface MemoryResult { - initial: number; - afterTraversal: number; - } - - function measureMemoryInWorker( - version: 'v1' | 'v2' - ): Promise { - return new Promise((resolve, reject) => { - const child = fork('./out/test/heap-memory-worker.js', [], { - execArgv: ['--expose-gc'], - }); - - child.on('message', (result: MemoryResult) => { - resolve(result); - child.kill(); - }); - - child.on('error', reject); - child.send(version); - }); - } - - it('mapAllocationProfile should use less initial memory than getAllocationProfile', async () => { - const v1MemoryUsage = await measureMemoryInWorker('v1'); - const v2MemoryUsage = await measureMemoryInWorker('v2'); - - console.log( - ` V1 initial: ${v1MemoryUsage.initial}, afterTraversal: ${v1MemoryUsage.afterTraversal} - | V2 initial: ${v2MemoryUsage.initial}, afterTraversal: ${v2MemoryUsage.afterTraversal}` - ); - - assert.ok( - v2MemoryUsage.initial < v1MemoryUsage.initial, - `V2 initial should be less: V1=${v1MemoryUsage.initial}, V2=${v2MemoryUsage.initial}` - ); - - assert.ok( - v2MemoryUsage.afterTraversal < v1MemoryUsage.afterTraversal, - `V2 afterTraversal should be less: V1=${v1MemoryUsage.afterTraversal}, V2=${v2MemoryUsage.afterTraversal}` - ); - }).timeout(100_000); - }); -}); diff --git a/ts/test/test-heap-profiler.ts b/ts/test/test-heap-profiler.ts index 178ca283..6ffe259d 100644 --- a/ts/test/test-heap-profiler.ts +++ b/ts/test/test-heap-profiler.ts @@ -35,10 +35,30 @@ import { const copy = require('deep-copy'); const assert = require('assert'); +function mapToGetterNode(node: AllocationProfileNode): AllocationProfileNode { + const children = (node.children || []).map(mapToGetterNode); + const allocations = node.allocations || []; + const result = {}; + + Object.defineProperties(result, { + name: {get: () => node.name}, + scriptName: {get: () => node.scriptName}, + scriptId: {get: () => node.scriptId}, + lineNumber: {get: () => node.lineNumber}, + columnNumber: {get: () => node.columnNumber}, + allocations: {get: () => allocations}, + children: {get: () => children}, + }); + return result as AllocationProfileNode; +} + describe('HeapProfiler', () => { let startStub: sinon.SinonStub<[number, number], void>; let stopStub: sinon.SinonStub<[], void>; - let profileStub: sinon.SinonStub<[], AllocationProfileNode>; + let profileStub: sinon.SinonStub< + [(root: AllocationProfileNode) => unknown], + unknown + >; let dateStub: sinon.SinonStub<[], number>; let memoryUsageStub: sinon.SinonStub<[], NodeJS.MemoryUsage>; beforeEach(() => { @@ -58,8 +78,8 @@ describe('HeapProfiler', () => { describe('profile', () => { it('should return a profile equal to the expected profile when external memory is allocated', async () => { profileStub = sinon - .stub(v8HeapProfiler, 'getAllocationProfile') - .returns(copy(v8HeapProfile)); + .stub(v8HeapProfiler, 'mapAllocationProfile') + .callsFake(callback => callback(mapToGetterNode(copy(v8HeapProfile)))); memoryUsageStub = sinon.stub(process, 'memoryUsage').returns({ external: 1024, rss: 2048, @@ -76,8 +96,10 @@ describe('HeapProfiler', () => { it('should return a profile equal to the expected profile when including all samples', async () => { profileStub = sinon - .stub(v8HeapProfiler, 'getAllocationProfile') - .returns(copy(v8HeapWithPathProfile)); + .stub(v8HeapProfiler, 'mapAllocationProfile') + .callsFake(callback => + callback(mapToGetterNode(copy(v8HeapWithPathProfile))) + ); memoryUsageStub = sinon.stub(process, 'memoryUsage').returns({ external: 0, rss: 2048, @@ -94,8 +116,10 @@ describe('HeapProfiler', () => { it('should return a profile equal to the expected profile when excluding profiler samples', async () => { profileStub = sinon - .stub(v8HeapProfiler, 'getAllocationProfile') - .returns(copy(v8HeapWithPathProfile)); + .stub(v8HeapProfiler, 'mapAllocationProfile') + .callsFake(callback => + callback(mapToGetterNode(copy(v8HeapWithPathProfile))) + ); memoryUsageStub = sinon.stub(process, 'memoryUsage').returns({ external: 0, rss: 2048, @@ -112,8 +136,10 @@ describe('HeapProfiler', () => { it('should return a profile equal to the expected profile when adding labels', async () => { profileStub = sinon - .stub(v8HeapProfiler, 'getAllocationProfile') - .returns(copy(v8HeapWithPathProfile)); + .stub(v8HeapProfiler, 'mapAllocationProfile') + .callsFake(callback => + callback(mapToGetterNode(copy(v8HeapWithPathProfile))) + ); memoryUsageStub = sinon.stub(process, 'memoryUsage').returns({ external: 0, rss: 2048, From 28850584c908cd00f617c7853cfbebef97fbee74 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Fri, 20 Feb 2026 15:47:15 +0100 Subject: [PATCH 04/16] Add timeProfiler.runWithContext (#275) --- bindings/profilers/wall.cc | 39 +++- bindings/profilers/wall.hh | 5 + ts/src/index.ts | 1 + ts/src/time-profiler.ts | 13 ++ ts/test/test-get-value-from-map-profiler.ts | 21 ++ ts/test/test-time-profiler.ts | 246 +++++++++++++++++++- 6 files changed, 312 insertions(+), 13 deletions(-) diff --git a/bindings/profilers/wall.cc b/bindings/profilers/wall.cc index 98a8d2dd..f5e6cc7f 100644 --- a/bindings/profilers/wall.cc +++ b/bindings/profilers/wall.cc @@ -1089,6 +1089,7 @@ NAN_MODULE_INIT(WallProfiler::Init) { Nan::SetPrototypeMethod(tpl, "v8ProfilerStuckEventLoopDetected", V8ProfilerStuckEventLoopDetected); + Nan::SetPrototypeMethod(tpl, "createContextHolder", CreateContextHolder); Nan::SetAccessor(tpl->InstanceTemplate(), Nan::New("state").ToLocalChecked(), @@ -1176,23 +1177,29 @@ void WallProfiler::SetContext(Isolate* isolate, Local value) { SignalGuard m(setInProgress_); cpedMap->Delete(v8Ctx, localKey).Check(); } else { - auto wrap = - wrapObjectTemplate_.Get(isolate)->NewInstance(v8Ctx).ToLocalChecked(); - // for easy access from JS when cpedKey is an ALS, it can do - // als.getStore()?.[0]; - wrap->Set(v8Ctx, 0, value).Check(); - auto contextPtr = new PersistentContextPtr(&liveContextPtrs_, wrap); - liveContextPtrs_.insert(contextPtr); - contextPtr->Set(isolate, value); - + auto contextHolder = CreateContextHolder(isolate, v8Ctx, value); SignalGuard m(setInProgress_); - cpedMap->Set(v8Ctx, localKey, wrap).ToLocalChecked(); + cpedMap->Set(v8Ctx, localKey, contextHolder).ToLocalChecked(); } #else SetCurrentContextPtr(isolate, value); #endif } +Local WallProfiler::CreateContextHolder(Isolate* isolate, + Local v8Ctx, + Local value) { + auto wrap = + wrapObjectTemplate_.Get(isolate)->NewInstance(v8Ctx).ToLocalChecked(); + // for easy access from JS when cpedKey is an ALS, it can do + // als.getStore()?.[0]; + wrap->Set(v8Ctx, 0, value).Check(); + auto contextPtr = new PersistentContextPtr(&liveContextPtrs_, wrap); + liveContextPtrs_.insert(contextPtr); + contextPtr->Set(isolate, value); + return wrap; +} + ContextPtr WallProfiler::GetContextPtrSignalSafe(Isolate* isolate) { auto isSetInProgress = setInProgress_.load(std::memory_order_relaxed); std::atomic_signal_fence(std::memory_order_acquire); @@ -1279,6 +1286,18 @@ NAN_SETTER(WallProfiler::SetContext) { profiler->SetContext(info.GetIsolate(), value); } +NAN_METHOD(WallProfiler::CreateContextHolder) { + auto profiler = Nan::ObjectWrap::Unwrap(info.This()); + if (!profiler->useCPED()) { + return Nan::ThrowTypeError( + "CreateContextHolder can only be used with CPED"); + } + auto isolate = info.GetIsolate(); + auto contextHolder = profiler->CreateContextHolder( + isolate, isolate->GetCurrentContext(), info[0]); + info.GetReturnValue().Set(contextHolder); +} + NAN_GETTER(WallProfiler::SharedArrayGetter) { auto profiler = Nan::ObjectWrap::Unwrap(info.This()); info.GetReturnValue().Set(profiler->jsArray_.Get(v8::Isolate::GetCurrent())); diff --git a/bindings/profilers/wall.hh b/bindings/profilers/wall.hh index 9b4ed58a..7e01f354 100644 --- a/bindings/profilers/wall.hh +++ b/bindings/profilers/wall.hh @@ -143,6 +143,10 @@ class WallProfiler : public Nan::ObjectWrap { v8::Local GetContext(v8::Isolate*); void SetContext(v8::Isolate*, v8::Local); + v8::Local CreateContextHolder(v8::Isolate*, + v8::Local, + v8::Local); + void PushContext(int64_t time_from, int64_t time_to, int64_t cpu_time, @@ -186,6 +190,7 @@ class WallProfiler : public Nan::ObjectWrap { static NAN_MODULE_INIT(Init); static NAN_GETTER(GetContext); static NAN_SETTER(SetContext); + static NAN_METHOD(CreateContextHolder); static NAN_GETTER(SharedArrayGetter); static NAN_GETTER(GetMetrics); }; diff --git a/ts/src/index.ts b/ts/src/index.ts index 42454629..92a7ec5c 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -36,6 +36,7 @@ export const time = { stop: timeProfiler.stop, getContext: timeProfiler.getContext, setContext: timeProfiler.setContext, + runWithContext: timeProfiler.runWithContext, isStarted: timeProfiler.isStarted, v8ProfilerStuckEventLoopDetected: timeProfiler.v8ProfilerStuckEventLoopDetected, diff --git a/ts/src/time-profiler.ts b/ts/src/time-profiler.ts index 0aabb53e..631a89dc 100644 --- a/ts/src/time-profiler.ts +++ b/ts/src/time-profiler.ts @@ -169,6 +169,19 @@ export function setContext(context?: object) { gProfiler.context = context; } +export function runWithContext( + context: object, + f: (...args: TArgs) => R, + ...args: TArgs +): R { + if (!gProfiler) { + throw new Error('Wall profiler is not started'); + } else if (!gStore) { + throw new Error('Can only use runWithContext with AsyncContextFrame'); + } + return gStore.run(gProfiler.createContextHolder(context), f, ...args); +} + export function getContext() { if (!gProfiler) { throw new Error('Wall profiler is not started'); diff --git a/ts/test/test-get-value-from-map-profiler.ts b/ts/test/test-get-value-from-map-profiler.ts index 329f3254..5e03bf00 100644 --- a/ts/test/test-get-value-from-map-profiler.ts +++ b/ts/test/test-get-value-from-map-profiler.ts @@ -124,6 +124,27 @@ if (useCPED && supportedPlatform) { profiler.dispose(); }); + + it('should work with createContextHolder pattern', () => { + // This tests the pattern used by runWithContext where + // createContextHolder creates a wrap object that's stored in CPED map + + const als = new AsyncLocalStorage(); + const profiler = createProfiler(als); + + const context = {label: 'wrapped-context', id: 999}; + + // Using als.run mimics what runWithContext does internally + als.run(profiler.createContextHolder(context), () => { + const retrieved = profiler.context; + + // The wrap object stores context at index 0 + assert.ok(retrieved !== null && typeof retrieved === 'object'); + assert.deepStrictEqual(retrieved, context); + }); + + profiler.dispose(); + }); }); describe('multiple context frames', () => { diff --git a/ts/test/test-time-profiler.ts b/ts/test/test-time-profiler.ts index 8082245f..6727d77c 100644 --- a/ts/test/test-time-profiler.ts +++ b/ts/test/test-time-profiler.ts @@ -35,6 +35,10 @@ const useCPED = const collectAsyncId = satisfies(process.versions.node, '>=24.0.0'); +const unsupportedPlatform = + process.platform !== 'darwin' && process.platform !== 'linux'; +const shouldSkipCPEDTests = !useCPED || unsupportedPlatform; + const PROFILE_OPTIONS = { durationMillis: 500, intervalMicros: 1000, @@ -49,7 +53,7 @@ describe('Time Profiler', () => { }); it('should update state', function shouldUpdateState() { - if (process.platform !== 'darwin' && process.platform !== 'linux') { + if (unsupportedPlatform) { this.skip(); } const startTime = BigInt(Date.now()) * 1000n; @@ -101,7 +105,7 @@ describe('Time Profiler', () => { }); it('should have labels', function shouldHaveLabels() { - if (process.platform !== 'darwin' && process.platform !== 'linux') { + if (unsupportedPlatform) { this.skip(); } this.timeout(3000); @@ -536,7 +540,7 @@ describe('Time Profiler', () => { describe('lowCardinalityLabels', () => { it('should handle lowCardinalityLabels parameter in stop function', async function testLowCardinalityLabels() { - if (process.platform !== 'darwin' && process.platform !== 'linux') { + if (unsupportedPlatform) { this.skip(); } this.timeout(3000); @@ -726,4 +730,240 @@ describe('Time Profiler', () => { assert.ok(threadId > 0); }); }); + + describe('runWithContext', () => { + it('should throw when profiler is not started', () => { + assert.throws(() => { + time.runWithContext({label: 'test'}, () => {}); + }, /Wall profiler is not started/); + }); + + it('should throw when useCPED is not enabled', function testNoCPED() { + if (unsupportedPlatform) { + this.skip(); + } + + time.start({ + intervalMicros: PROFILE_OPTIONS.intervalMicros, + durationMillis: PROFILE_OPTIONS.durationMillis, + withContexts: true, + useCPED: false, + }); + + try { + assert.throws(() => { + time.runWithContext({label: 'test'}, () => {}); + }, /Can only use runWithContext with AsyncContextFrame/); + } finally { + time.stop(); + } + }); + + it('should run function with context when useCPED is enabled', function testRunWithContext() { + if (shouldSkipCPEDTests) { + this.skip(); + } + + time.start({ + intervalMicros: PROFILE_OPTIONS.intervalMicros, + durationMillis: PROFILE_OPTIONS.durationMillis, + withContexts: true, + useCPED: true, + }); + + try { + const testContext = {label: 'test-value', id: '123'}; + let contextInsideFunction; + + time.runWithContext(testContext, () => { + contextInsideFunction = time.getContext(); + }); + + assert.deepEqual( + contextInsideFunction, + testContext, + 'Context should be accessible within function' + ); + } finally { + time.stop(); + } + }); + + it('should pass arguments to function correctly', function testArguments() { + if (shouldSkipCPEDTests) { + this.skip(); + } + + time.start({ + intervalMicros: PROFILE_OPTIONS.intervalMicros, + durationMillis: PROFILE_OPTIONS.durationMillis, + withContexts: true, + useCPED: true, + }); + + try { + const testContext = {label: 'test'}; + const result = time.runWithContext( + testContext, + (a: number, b: string, c: boolean) => { + return {a, b, c}; + }, + 42, + 'hello', + true + ); + + assert.deepEqual( + result, + {a: 42, b: 'hello', c: true}, + 'Arguments should be passed correctly' + ); + } finally { + time.stop(); + } + }); + + it('should return function result', function testReturnValue() { + if (shouldSkipCPEDTests) { + this.skip(); + } + + time.start({ + intervalMicros: PROFILE_OPTIONS.intervalMicros, + durationMillis: PROFILE_OPTIONS.durationMillis, + withContexts: true, + useCPED: true, + }); + + try { + const testContext = {label: 'test'}; + const result = time.runWithContext(testContext, () => { + return 'test-result'; + }); + + assert.strictEqual( + result, + 'test-result', + 'Function result should be returned' + ); + } finally { + time.stop(); + } + }); + + it('should handle nested runWithContext calls', function testNestedCalls() { + if (shouldSkipCPEDTests) { + this.skip(); + } + + time.start({ + intervalMicros: PROFILE_OPTIONS.intervalMicros, + durationMillis: PROFILE_OPTIONS.durationMillis, + withContexts: true, + useCPED: true, + }); + + try { + const outerContext = {label: 'outer'}; + const innerContext = {label: 'inner'}; + const results: string[] = []; + + time.runWithContext(outerContext, () => { + const ctx1 = time.getContext(); + results.push((ctx1 as any).label); + + time.runWithContext(innerContext, () => { + const ctx2 = time.getContext(); + results.push((ctx2 as any).label); + }); + + const ctx3 = time.getContext(); + results.push((ctx3 as any).label); + }); + + assert.deepEqual( + results, + ['outer', 'inner', 'outer'], + 'Nested contexts should be properly isolated and restored' + ); + } finally { + time.stop(); + } + }); + + it('should isolate context from outside runWithContext', function testContextIsolation() { + if (shouldSkipCPEDTests) { + this.skip(); + } + + time.start({ + intervalMicros: PROFILE_OPTIONS.intervalMicros, + durationMillis: PROFILE_OPTIONS.durationMillis, + withContexts: true, + useCPED: true, + }); + + try { + const runWithContextContext = {label: 'inside'}; + let contextInside; + + time.runWithContext(runWithContextContext, () => { + contextInside = time.getContext(); + }); + + // Context outside runWithContext should be undefined since we're using CPED + const contextOutside = time.getContext(); + + assert.deepEqual( + contextInside, + runWithContextContext, + 'Context inside should match' + ); + assert.strictEqual( + contextOutside, + undefined, + 'Context outside should be undefined with CPED' + ); + } finally { + time.stop(); + } + }); + + it('should work with async functions', async function testAsyncFunction() { + if (shouldSkipCPEDTests) { + this.skip(); + } + + time.start({ + intervalMicros: PROFILE_OPTIONS.intervalMicros, + durationMillis: PROFILE_OPTIONS.durationMillis, + withContexts: true, + useCPED: true, + }); + + try { + const testContext = {label: 'async-test'}; + + const result = await time.runWithContext(testContext, async () => { + const ctx1 = time.getContext(); + await setTimeoutPromise(10); + const ctx2 = time.getContext(); + return {ctx1, ctx2}; + }); + + assert.deepEqual( + result.ctx1, + testContext, + 'Context should be available before await' + ); + assert.deepEqual( + result.ctx2, + testContext, + 'Context should be preserved after await' + ); + } finally { + time.stop(); + } + }); + }); }); From 45c34a5a843964d69f2a18f5d3a761d6ad53f8ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:44:11 +0100 Subject: [PATCH 05/16] build(deps-dev): bump gts from 4.0.1 to 7.0.0 (#276) * build(deps-dev): bump gts from 4.0.1 to 7.0.0 Bumps [gts](https://github.com/google/gts) from 4.0.1 to 7.0.0. - [Release notes](https://github.com/google/gts/releases) - [Changelog](https://github.com/google/gts/blob/main/CHANGELOG.md) - [Commits](https://github.com/google/gts/compare/v4.0.1...v7.0.0) --- updated-dependencies: - dependency-name: gts dependency-version: 7.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * fix(lint): migrate ESLint config to flat format for gts 7.x / ESLint v9 - Add eslint.config.js using gts flat config export - Delete legacy .eslintrc.json and .eslintignore - Fix .prettierrc.js formatting (prettier v3 style) - Fix @typescript-eslint/no-explicit-any errors (now error in ts-eslint v8 recommended) - Fix @typescript-eslint/no-unused-vars and @typescript-eslint/no-floating-promises - Update eslint-disable comment to use n/ prefix instead of node/ - Exclude benchmark/, scripts/, system-test/ from root config (have own configs) - Auto-fix prettier trailing comma issues across source files (prettier v3) Co-Authored-By: Claude Sonnet 4.6 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Attila Szegedi Co-authored-by: Claude Sonnet 4.6 --- .eslintrc.js | 16 - .prettierrc.js | 6 +- eslint.config.js | 19 + package-lock.json | 1738 ++++++++----------- package.json | 2 +- ts/src/heap-profiler-bindings.ts | 12 +- ts/src/heap-profiler.ts | 16 +- ts/src/profile-serializer.ts | 26 +- ts/src/sourcemapper/sourcemapper.ts | 28 +- ts/src/time-profiler.ts | 8 +- ts/test/oom.ts | 1 - ts/test/profiles-for-tests.ts | 16 +- ts/test/test-get-value-from-map-profiler.ts | 4 +- ts/test/test-heap-profiler.ts | 20 +- ts/test/test-profile-serializer.ts | 18 +- ts/test/test-time-profiler.ts | 86 +- ts/test/test-worker-threads.ts | 5 +- ts/test/worker.ts | 47 +- ts/test/worker2.ts | 2 +- 19 files changed, 873 insertions(+), 1197 deletions(-) delete mode 100644 .eslintrc.js create mode 100644 eslint.config.js diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index e699d5b2..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,16 +0,0 @@ -'use strict'; - -module.exports = { - extends: ['./node_modules/gts'], - ignorePatterns: [ - '**/node_modules', - '**/coverage', - 'build/**', - 'proto/**', - 'out/**', - 'benchmark/**', - 'scripts/**', - 'system-test/**', - 'test.ts', - ], -}; diff --git a/.prettierrc.js b/.prettierrc.js index ffd5a93c..33e412df 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -13,6 +13,6 @@ // limitations under the License. module.exports = { - endOfLine:"auto", - ...require('gts/.prettierrc.json') -} + endOfLine: 'auto', + ...require('gts/.prettierrc.json'), +}; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..7432de02 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,19 @@ +'use strict'; +const gts = require('./node_modules/gts'); + +module.exports = [ + { + ignores: [ + '**/node_modules', + '**/coverage', + 'build/**', + 'proto/**', + 'out/**', + 'benchmark/**', + 'scripts/**', + 'system-test/**', + 'test.ts', + ], + }, + ...gts, +]; diff --git a/package-lock.json b/package-lock.json index c703f294..b0282fee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "codecov": "^3.8.3", "deep-copy": "^1.4.2", "eslint-plugin-n": "^17.24.0", - "gts": "^4.0.1", + "gts": "^7.0.0", "js-green-licenses": "^4.0.0", "mocha": "^11.7.5", "nan": "^2.23.1", @@ -343,34 +343,75 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -397,29 +438,64 @@ } }, "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" }, "engines": { - "node": ">=10.10.0" + "node": ">=18.18.0" } }, "node_modules/@humanwhocodes/module-importer": { @@ -436,13 +512,19 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "BSD-3-Clause" + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, "node_modules/@isaacs/cliui": { "version": "8.0.2", @@ -690,44 +772,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -739,6 +783,19 @@ "node": ">=14" } }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -829,6 +886,13 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", @@ -926,122 +990,159 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", - "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", + "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/type-utils": "5.62.0", - "@typescript-eslint/utils": "5.62.0", - "debug": "^4.3.4", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "natural-compare-lite": "^1.4.0", - "semver": "^7.3.7", - "tsutils": "^3.21.0" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/type-utils": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "@typescript-eslint/parser": "^8.57.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", - "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", + "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", + "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.0", + "@typescript-eslint/types": "^8.57.0", + "debug": "^4.4.3" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", - "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", + "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0" + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", + "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", - "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", + "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "5.62.0", - "@typescript-eslint/utils": "5.62.0", - "debug": "^4.3.4", - "tsutils": "^3.21.0" + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", - "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", + "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", "dev": true, "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -1049,89 +1150,131 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", - "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", + "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" + "@typescript-eslint/project-service": "8.57.0", + "@typescript-eslint/tsconfig-utils": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/utils": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", - "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz", + "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@types/json-schema": "^7.0.9", - "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", - "eslint-scope": "^5.1.1", - "semver": "^7.3.7" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", - "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", + "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "5.62.0", - "eslint-visitor-keys": "^3.3.0" + "@typescript-eslint/types": "8.57.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "ISC" + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -1179,9 +1322,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1284,7 +1427,6 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/argv/-/argv-0.0.2.tgz", "integrity": "sha512-dEamhpPEwRUBpLNHeuCm/v+g0anFByHahxodVO/BbAarHVBBg2MccCwf9K+o1Pof+2btdnkJelYVUWjW/VrATw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "dev": true, "engines": { "node": ">=0.6.10" @@ -1300,16 +1442,6 @@ "node": ">=0.10.0" } }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/arrify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", @@ -1358,19 +1490,6 @@ "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browser-stdout": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", @@ -1674,7 +1793,6 @@ "version": "3.8.3", "resolved": "https://registry.npmjs.org/codecov/-/codecov-3.8.3.tgz", "integrity": "sha512-Y8Hw+V3HgR7V71xWH2vQ9lyS358CbGCldWlJFR0JirqoGtOoas3R3/OclRTvgUYFK29mmJICDPauVKmpqbwhOA==", - "deprecated": "https://about.codecov.io/blog/codecov-uploader-deprecation-plan/", "dev": true, "license": "MIT", "dependencies": { @@ -1748,9 +1866,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -1894,32 +2012,6 @@ "node": ">=0.3.1" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2006,63 +2098,66 @@ } }, "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", + "cross-spawn": "^7.0.6", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-compat-utils": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-compat-utils": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", @@ -2079,36 +2174,19 @@ } }, "node_modules/eslint-config-prettier": { - "version": "8.10.2", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.2.tgz", - "integrity": "sha512-/IGJ6+Dka158JnP5n5YFMOszjDWrXggGz1LaK/guZq9vZTmniaKlHcsscvkAhn9y4U+BU3JuUdYvtAMcv30y4A==", + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", "bin": { "eslint-config-prettier": "bin/cli.js" }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-plugin-es": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz", - "integrity": "sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-utils": "^2.0.0", - "regexpp": "^3.0.0" - }, - "engines": { - "node": ">=8.10.0" - }, "funding": { - "url": "https://github.com/sponsors/mysticatea" + "url": "https://opencollective.com/eslint-config-prettier" }, "peerDependencies": { - "eslint": ">=4.19.1" + "eslint": ">=7.0.0" } }, "node_modules/eslint-plugin-es-x": { @@ -2173,97 +2251,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-plugin-node": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz", - "integrity": "sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-plugin-es": "^3.0.0", - "eslint-utils": "^2.0.0", - "ignore": "^5.1.1", - "minimatch": "^3.0.4", - "resolve": "^1.10.1", - "semver": "^6.1.0" - }, - "engines": { - "node": ">=8.10.0" - }, - "peerDependencies": { - "eslint": ">=5.16.0" - } - }, - "node_modules/eslint-plugin-node/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/eslint-plugin-prettier": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.5.tgz", - "integrity": "sha512-9Ni+xgemM2IWLq6aXEpP2+V/V30GeA/46Ar629vcMqVPodFFWC9skHu/D1phvuqtS8bJCFnNf01/qcmqYEwNfg==", + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", "dev": true, "license": "MIT", "dependencies": { - "prettier-linter-helpers": "^1.0.0" + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" }, "engines": { - "node": ">=12.0.0" + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" }, "peerDependencies": { - "eslint": ">=7.28.0", - "prettier": ">=2.0.0" + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" }, "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, "eslint-config-prettier": { "optional": true } } }, "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/eslint-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", - "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^1.1.0" + "estraverse": "^5.2.0" }, "engines": { - "node": ">=6" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/mysticatea" - } - }, - "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=4" + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { @@ -2279,66 +2312,45 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/eslint/node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, + "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/eslint/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, + "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -2371,16 +2383,6 @@ "node": ">=0.10" } }, - "node_modules/esquery/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -2394,7 +2396,7 @@ "node": ">=4.0" } }, - "node_modules/esrecurse/node_modules/estraverse": { + "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", @@ -2404,16 +2406,6 @@ "node": ">=4.0" } }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2497,36 +2489,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2558,14 +2520,22 @@ "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/figures": { @@ -2595,29 +2565,16 @@ } }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { - "to-regex-range": "^5.0.1" + "flat-cache": "^4.0.0" }, "engines": { - "node": ">=8" + "node": ">=16.0.0" } }, "node_modules/find-cache-dir": { @@ -2666,24 +2623,23 @@ } }, "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "keyv": "^4.5.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16" } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, @@ -2815,7 +2771,6 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "license": "ISC", "dependencies": { @@ -2847,37 +2802,13 @@ } }, "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -2923,44 +2854,38 @@ "dev": true, "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/gts": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/gts/-/gts-4.0.1.tgz", - "integrity": "sha512-BeFQLqra3HH5/MHuog+uOpn/h6qVMLeBB0WZ4bgal7CbqbvfaCxCYA7vlfaMAANhgw1Ko2HFZA4iuOJqOBW5lg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/gts/-/gts-7.0.0.tgz", + "integrity": "sha512-5Sb73zpklh7GyYRd14QUwa6+4n99b7JqECc+bS2fRYlSK40SXu7Po3//q4jBAP/CQwR+VbUPiOLxJf5zpp4Ajw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/eslint-plugin": "^5.0.0", - "@typescript-eslint/parser": "^5.0.0", - "chalk": "^4.1.0", - "eslint": "^8.0.0", - "eslint-config-prettier": "^8.0.0", - "eslint-plugin-node": "^11.1.0", - "eslint-plugin-prettier": "^4.0.0", + "@eslint/js": "^9.37.0", + "@typescript-eslint/eslint-plugin": "^8.46.1", + "@typescript-eslint/parser": "^8.46.1", + "chalk": "^4.1.2", + "eslint": "^9.37.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-n": "^17.23.1", + "eslint-plugin-prettier": "^5.5.4", "execa": "^5.0.0", "inquirer": "^7.3.3", "json5": "^2.1.3", "meow": "^9.0.0", "ncp": "^2.0.0", - "prettier": "~2.7.0", - "rimraf": "^3.0.2", - "write-file-atomic": "^4.0.0" + "prettier": "^3.6.2", + "typescript-eslint": "^8.46.1", + "write-file-atomic": "^6.0.0" }, "bin": { "gts": "build/src/cli.js" }, "engines": { - "node": ">=12" + "node": ">=18" }, "peerDependencies": { - "typescript": ">=3" + "typescript": ">=5" } }, "node_modules/hard-rejection": { @@ -3200,7 +3125,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, "license": "ISC", "dependencies": { @@ -3303,16 +3227,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -3441,154 +3355,50 @@ "node": "20 || >=22" } }, - "node_modules/istanbul-lib-processinfo/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=10" } }, - "node_modules/istanbul-lib-processinfo/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" + "semver": "^7.5.3" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/istanbul-lib-processinfo/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "BSD-3-Clause", "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" }, "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/istanbul-lib-processinfo/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/istanbul-lib-processinfo/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/istanbul-lib-processinfo/node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/istanbul-lib-processinfo/node_modules/rimraf": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", - "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "glob": "^13.0.3", - "package-json-from-dist": "^1.0.1" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report/node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" + "node": ">=10" } }, "node_modules/istanbul-lib-source-maps/node_modules/source-map": { @@ -3933,30 +3743,6 @@ "dev": true, "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -3988,9 +3774,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4110,7 +3896,6 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -4224,13 +4009,6 @@ "dev": true, "license": "MIT" }, - "node_modules/natural-compare-lite": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", - "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", - "dev": true, - "license": "MIT" - }, "node_modules/ncp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", @@ -4584,26 +4362,6 @@ "node": ">=8" } }, - "node_modules/nyc/node_modules/rimraf": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", - "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "glob": "^13.0.3", - "package-json-from-dist": "^1.0.1" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/nyc/node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -4727,23 +4485,7 @@ "node": ">=8" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate/node_modules/p-limit": { + "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", @@ -4759,12 +4501,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-locate/node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, "engines": { "node": ">=10" }, @@ -4930,16 +4675,6 @@ "dev": true, "license": "ISC" }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4948,13 +4683,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -5046,25 +4781,25 @@ } }, "node_modules/prettier": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz", - "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { - "prettier": "bin-prettier.js" + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=10.13.0" + "node": ">=14" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" } }, "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", "dev": true, "license": "MIT", "dependencies": { @@ -5108,27 +4843,6 @@ "node": ">=6" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/quick-lru": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", @@ -5343,19 +5057,6 @@ "node": ">=8" } }, - "node_modules/regexpp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - } - }, "node_modules/registry-auth-token": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.2.2.tgz", @@ -5487,68 +5188,119 @@ "node": ">=8" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", "dev": true, - "license": "ISC", - "peer": true, + "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^7.1.3" + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" }, "bin": { - "rimraf": "bin.js" + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "node_modules/rimraf/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.12.0" + "node": "18 || 20 || >=22" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], "license": "MIT", "dependencies": { - "queue-microtask": "^1.2.2" - } + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } }, "node_modules/rxjs": { "version": "6.6.7", @@ -5679,16 +5431,6 @@ "node": ">=0.3.1" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -5738,110 +5480,6 @@ "node": ">=8" } }, - "node_modules/spawn-wrap/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/spawn-wrap/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/spawn-wrap/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/spawn-wrap/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/spawn-wrap/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/spawn-wrap/node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/spawn-wrap/node_modules/rimraf": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", - "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "glob": "^13.0.3", - "package-json-from-dist": "^1.0.1" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/spdx-compare": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/spdx-compare/-/spdx-compare-1.0.0.tgz", @@ -6063,6 +5701,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -6193,13 +5847,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -6207,27 +5854,31 @@ "dev": true, "license": "MIT" }, - "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, "engines": { - "node": ">=14.14" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "dev": true, "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, "engines": { - "node": ">=8.0" + "node": ">=14.14" } }, "node_modules/tr46": { @@ -6247,6 +5898,19 @@ "node": ">=8" } }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-declaration-location": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/ts-declaration-location/-/ts-declaration-location-1.0.7.tgz", @@ -6270,19 +5934,6 @@ "typescript": ">=4.0.0" } }, - "node_modules/ts-declaration-location/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", @@ -6290,22 +5941,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^1.8.1" - }, - "engines": { - "node": ">= 6" - }, - "peerDependencies": { - "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6329,19 +5964,6 @@ "node": ">=4" } }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -6366,6 +5988,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz", + "integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.0", + "@typescript-eslint/parser": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -6565,17 +6211,30 @@ "license": "ISC" }, "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz", + "integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==", "dev": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" + "signal-exit": "^4.0.1" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/y18n": { @@ -6685,6 +6344,19 @@ "engines": { "node": ">=12" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index ff64a8ab..a88d11f8 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "codecov": "^3.8.3", "deep-copy": "^1.4.2", "eslint-plugin-n": "^17.24.0", - "gts": "^4.0.1", + "gts": "^7.0.0", "js-green-licenses": "^4.0.0", "mocha": "^11.7.5", "nan": "^2.23.1", diff --git a/ts/src/heap-profiler-bindings.ts b/ts/src/heap-profiler-bindings.ts index e6fa0b05..7d625d50 100644 --- a/ts/src/heap-profiler-bindings.ts +++ b/ts/src/heap-profiler-bindings.ts @@ -25,11 +25,11 @@ const profiler = findBinding(path.join(__dirname, '..', '..')); export function startSamplingHeapProfiler( heapIntervalBytes: number, - heapStackDepth: number + heapStackDepth: number, ) { profiler.heapProfiler.startSamplingHeapProfiler( heapIntervalBytes, - heapStackDepth + heapStackDepth, ); } @@ -38,7 +38,7 @@ export function stopSamplingHeapProfiler() { } export function mapAllocationProfile( - callback: (root: AllocationProfileNode) => T + callback: (root: AllocationProfileNode) => T, ): T { return profiler.heapProfiler.mapAllocationProfile(callback); } @@ -49,10 +49,10 @@ export function monitorOutOfMemory( heapLimitExtensionSize: number, maxHeapLimitExtensionCount: number, dumpHeapProfileOnSdterr: boolean, - exportCommand: Array | undefined, + exportCommand: Array | undefined, callback: NearHeapLimitCallback | undefined, callbackMode: number, - isMainThread: boolean + isMainThread: boolean, ) { profiler.heapProfiler.monitorOutOfMemory( heapLimitExtensionSize, @@ -61,6 +61,6 @@ export function monitorOutOfMemory( exportCommand, callback, callbackMode, - isMainThread + isMainThread, ); } diff --git a/ts/src/heap-profiler.ts b/ts/src/heap-profiler.ts index 550121e6..f34591ae 100644 --- a/ts/src/heap-profiler.ts +++ b/ts/src/heap-profiler.ts @@ -55,7 +55,7 @@ export function convertProfile( rootNode: AllocationProfileNode, ignoreSamplePath?: string, sourceMapper?: SourceMapper, - generateLabels?: GenerateAllocationLabelsFunction + generateLabels?: GenerateAllocationLabelsFunction, ): Profile { const startTimeNanos = Date.now() * 1000 * 1000; // Add node for external memory usage. @@ -81,7 +81,7 @@ export function convertProfile( heapIntervalBytes, ignoreSamplePath, sourceMapper, - generateLabels + generateLabels, ); } @@ -96,7 +96,7 @@ export function convertProfile( export function profile( ignoreSamplePath?: string, sourceMapper?: SourceMapper, - generateLabels?: GenerateAllocationLabelsFunction + generateLabels?: GenerateAllocationLabelsFunction, ): Profile { return v8Profile(root => { return convertProfile(root, ignoreSamplePath, sourceMapper, generateLabels); @@ -114,7 +114,7 @@ export function profile( export function start(intervalBytes: number, stackDepth: number) { if (enabled) { throw new Error( - `Heap profiler is already started with intervalBytes ${heapIntervalBytes} and stackDepth ${stackDepth}` + `Heap profiler is already started with intervalBytes ${heapIntervalBytes} and stackDepth ${stackDepth}`, ); } heapIntervalBytes = intervalBytes; @@ -170,13 +170,13 @@ export function monitorOutOfMemory( heapLimitExtensionSize: number, maxHeapLimitExtensionCount: number, dumpHeapProfileOnSdterr: boolean, - exportCommand?: Array, + exportCommand?: Array, callback?: NearHeapLimitCallback, - callbackMode?: number + callbackMode?: number, ) { if (!enabled) { throw new Error( - 'Heap profiler must already be started to call monitorOutOfMemory' + 'Heap profiler must already be started to call monitorOutOfMemory', ); } let newCallback; @@ -192,6 +192,6 @@ export function monitorOutOfMemory( exportCommand || [], newCallback, typeof callbackMode !== 'undefined' ? callbackMode : CallbackMode.Async, - isMainThread + isMainThread, ); } diff --git a/ts/src/profile-serializer.ts b/ts/src/profile-serializer.ts index f966bb69..7566beef 100644 --- a/ts/src/profile-serializer.ts +++ b/ts/src/profile-serializer.ts @@ -54,7 +54,7 @@ type Stack = number[]; */ type AppendEntryToSamples = ( entry: Entry, - samples: Sample[] + samples: Sample[], ) => void; /** @@ -66,7 +66,7 @@ interface Entry { } function isGeneratedLocation( - location: SourceLocation + location: SourceLocation, ): location is GeneratedLocation { return ( location.column !== undefined && @@ -93,7 +93,7 @@ function serialize( appendToSamples: AppendEntryToSamples, stringTable: StringTable, ignoreSamplesPath?: string, - sourceMapper?: SourceMapper + sourceMapper?: SourceMapper, ) { const samples: Sample[] = []; const locations: Location[] = []; @@ -134,7 +134,7 @@ function serialize( function getLocation( node: ProfileNode, scriptName: string, - sourceMapper?: SourceMapper + sourceMapper?: SourceMapper, ): Location { let profLoc: SourceLocation = { file: scriptName || '', @@ -264,7 +264,7 @@ function computeTotalHitCount(root: TimeProfileNode): number { root.hitCount + (root.children as TimeProfileNode[]).reduce( (sum, node) => sum + computeTotalHitCount(node), - 0 + 0, ) ); } @@ -357,7 +357,7 @@ export function serializeTimeProfile( sourceMapper?: SourceMapper, recomputeSamplingInterval = false, generateLabels?: GenerateTimeLabelsFunction, - lowCardinalityLabels: string[] = [] + lowCardinalityLabels: string[] = [], ): Profile { // If requested, recompute sampling interval from profile duration and total number of hits, // since profile duration should be #hits x interval. @@ -371,9 +371,9 @@ export function serializeTimeProfile( intervalMicros = Math.min( Math.max( Math.floor((prof.endTime - prof.startTime) / totalHitCount), - intervalMicros + intervalMicros, ), - 2 * intervalMicros + 2 * intervalMicros, ); } } @@ -406,7 +406,7 @@ export function serializeTimeProfile( const appendTimeEntryToSamples: AppendEntryToSamples = ( entry: Entry, - samples: Sample[] + samples: Sample[], ) => { let unlabelledHits = entry.node.hitCount; let unlabelledCpuTime = 0; @@ -414,7 +414,7 @@ export function serializeTimeProfile( for (const context of entry.node.contexts || []) { const labels = generateLabels ? generateLabels({node: entry.node, context}) - : context.context ?? {}; + : (context.context ?? {}); const labelsArr = buildLabels(labels, stringTable); if (labelsArr.length > 0) { // Only assign wall time if there are hits, some special nodes such as `(Non-JS threads)` @@ -479,7 +479,7 @@ export function serializeTimeProfile( appendTimeEntryToSamples, stringTable, undefined, - sourceMapper + sourceMapper, ); return new Profile(profile); @@ -525,7 +525,7 @@ export function serializeHeapProfile( intervalBytes: number, ignoreSamplesPath?: string, sourceMapper?: SourceMapper, - generateLabels?: GenerateAllocationLabelsFunction + generateLabels?: GenerateAllocationLabelsFunction, ): Profile { const appendHeapEntryToSamples: AppendEntryToSamples< AllocationProfileNode @@ -563,7 +563,7 @@ export function serializeHeapProfile( appendHeapEntryToSamples, stringTable, ignoreSamplesPath, - sourceMapper + sourceMapper, ); return new Profile(profile); diff --git a/ts/src/sourcemapper/sourcemapper.ts b/ts/src/sourcemapper/sourcemapper.ts index 7df1cc62..efca3a76 100644 --- a/ts/src/sourcemapper/sourcemapper.ts +++ b/ts/src/sourcemapper/sourcemapper.ts @@ -83,7 +83,7 @@ export interface SourceLocation { async function processSourceMap( infoMap: Map, mapPath: string, - debug: boolean + debug: boolean, ): Promise { // this handles the case when the path is undefined, null, or // the empty string @@ -108,7 +108,7 @@ async function processSourceMap( // type is expected to be of `RawSourceMap` but the existing // working code uses a string.) consumer = (await new sourceMap.SourceMapConsumer( - contents as {} as sourceMap.RawSourceMap + contents as {} as sourceMap.RawSourceMap, )) as {} as sourceMap.RawSourceMap; } catch (e) { throw error( @@ -116,7 +116,7 @@ async function processSourceMap( 'sourceMap file ' + mapPath + ': ' + - e + e, ); } @@ -157,7 +157,7 @@ async function processSourceMap( logger.debug(`Loaded source map for ${generatedPath} => ${mapPath}`); } return; - } catch (err) { + } catch { if (debug) { logger.debug(`Generated path ${generatedPath} does not exist`); } @@ -174,11 +174,11 @@ export class SourceMapper { static async create( searchDirs: string[], - debug = false + debug = false, ): Promise { if (debug) { logger.debug( - `Looking for source map files in dirs: [${searchDirs.join(', ')}]` + `Looking for source map files in dirs: [${searchDirs.join(', ')}]`, ); } const mapFiles: string[] = []; @@ -272,7 +272,7 @@ export class SourceMapper { if (entry === null) { if (this.debug) { logger.debug( - `Source map lookup failed: no map found for ${location.file} (normalized: ${inputPath})` + `Source map lookup failed: no map found for ${location.file} (normalized: ${inputPath})`, ); } return location; @@ -291,7 +291,7 @@ export class SourceMapper { if (pos.source === null) { if (this.debug) { logger.debug( - `Source map lookup failed for ${location.name}(${location.file}:${location.line}:${location.column})` + `Source map lookup failed for ${location.name}(${location.file}:${location.line}:${location.column})`, ); } return location; @@ -306,7 +306,7 @@ export class SourceMapper { if (this.debug) { logger.debug( - `Source map lookup succeeded for ${location.name}(${location.file}:${location.line}:${location.column}) => ${loc.name}(${loc.file}:${loc.line}:${loc.column})` + `Source map lookup succeeded for ${location.name}(${location.file}:${location.line}:${location.column}) => ${loc.name}(${loc.file}:${loc.line}:${loc.column})`, ); } return loc; @@ -315,18 +315,18 @@ export class SourceMapper { async function createFromMapFiles( mapFiles: string[], - debug: boolean + debug: boolean, ): Promise { const limit = createLimiter(CONCURRENCY); const mapper = new SourceMapper(debug); const promises: Array> = mapFiles.map(mapPath => - limit(() => processSourceMap(mapper.infoMap, mapPath, debug)) + limit(() => processSourceMap(mapper.infoMap, mapPath, debug)), ); try { await Promise.all(promises); } catch (err) { throw error( - 'An error occurred while processing the source map files' + err + 'An error occurred while processing the source map files' + err, ); } return mapper; @@ -349,7 +349,7 @@ async function* walk( // eslint-disable-next-line @typescript-eslint/no-unused-vars fileFilter = (filename: string) => true, // eslint-disable-next-line @typescript-eslint/no-unused-vars - directoryFilter = (root: string, dirname: string) => true + directoryFilter = (root: string, dirname: string) => true, ): AsyncIterable { async function* walkRecursive(dir: string): AsyncIterable { try { @@ -381,7 +381,7 @@ async function getMapFiles(baseDir: string): Promise { baseDir, filename => /\.[cm]?js\.map$/.test(filename), (root, dirname) => - root !== '/proc' && dirname !== '.git' && dirname !== 'node_modules' + root !== '/proc' && dirname !== '.git' && dirname !== 'node_modules', )) { mapFiles.push(path.relative(baseDir, entry)); } diff --git a/ts/src/time-profiler.ts b/ts/src/time-profiler.ts index 631a89dc..42d2c1a8 100644 --- a/ts/src/time-profiler.ts +++ b/ts/src/time-profiler.ts @@ -39,7 +39,7 @@ type Microseconds = number; type Milliseconds = number; let gProfiler: InstanceType | undefined; -let gStore: AsyncLocalStorage | undefined; +let gStore: AsyncLocalStorage | undefined; let gSourceMapper: SourceMapper | undefined; let gIntervalMicros: Microseconds; let gV8ProfilerStuckEventLoopDetected = 0; @@ -114,7 +114,7 @@ export function start(options: TimeProfilerOptions = {}) { export function stop( restart = false, generateLabels?: GenerateTimeLabelsFunction, - lowCardinalityLabels?: string[] + lowCardinalityLabels?: string[], ) { if (!gProfiler) { throw new Error('Wall profiler is not started'); @@ -141,7 +141,7 @@ export function stop( gSourceMapper, true, generateLabels, - lowCardinalityLabels + lowCardinalityLabels, ); if (!restart) { gProfiler.dispose(); @@ -169,7 +169,7 @@ export function setContext(context?: object) { gProfiler.context = context; } -export function runWithContext( +export function runWithContext( context: object, f: (...args: TArgs) => R, ...args: TArgs diff --git a/ts/test/oom.ts b/ts/test/oom.ts index f5a25cac..d028afa7 100644 --- a/ts/test/oom.ts +++ b/ts/test/oom.ts @@ -1,6 +1,5 @@ 'use strict'; -/* eslint-disable no-console */ import {Worker, isMainThread, threadId} from 'worker_threads'; import {heap} from '../src/index'; import path from 'path'; diff --git a/ts/test/profiles-for-tests.ts b/ts/test/profiles-for-tests.ts index eb2df90f..3e1ea7c1 100644 --- a/ts/test/profiles-for-tests.ts +++ b/ts/test/profiles-for-tests.ts @@ -196,7 +196,7 @@ export const timeProfile = new Profile({ // decodedTimeProfile const encodedTimeProfile = timeProfile.encode(); export const decodedTimeProfile = Object.freeze( - Profile.decode(encodedTimeProfile) + Profile.decode(encodedTimeProfile), ); const heapLeaf1 = { @@ -350,7 +350,7 @@ export const heapProfile = new Profile({ // decodedHeapProfile const encodedHeapProfile = heapProfile.encode(); export const decodedHeapProfile = Object.freeze( - Profile.decode(encodedHeapProfile) + Profile.decode(encodedHeapProfile), ); const heapLinesWithExternal = [ @@ -462,7 +462,7 @@ export const heapProfileWithExternal = new Profile({ // decodedHeapProfile const encodedHeapProfileWithExternal = heapProfile.encode(); export const decodedHeapProfileWithExternal = Object.freeze( - Profile.decode(encodedHeapProfileWithExternal) + Profile.decode(encodedHeapProfileWithExternal), ); const anonymousHeapNode = { @@ -827,7 +827,7 @@ export const heapProfileIncludePathWithLabels = new Profile({ // decodedHeapProfile const encodedHeapProfileIncludePath = heapProfileIncludePath.encode(); export const decodedHeapProfileIncludePath = Object.freeze( - Profile.decode(encodedHeapProfileIncludePath) + Profile.decode(encodedHeapProfileIncludePath), ); const heapExcludePathFunctions = [ @@ -888,7 +888,7 @@ export const heapProfileExcludePath = new Profile({ // decodedHeapProfile const encodedHeapProfileExcludePath = heapProfileExcludePath.encode(); export const decodedHeapProfileExcludePath = Object.freeze( - Profile.decode(encodedHeapProfileExcludePath) + Profile.decode(encodedHeapProfileExcludePath), ); export const mapDirPath = ((name: string) => { @@ -1239,7 +1239,7 @@ export function getAndVerifyPresence( // eslint-disable-next-line @typescript-eslint/no-explicit-any list: any[], id: number, - zeroIndex = false + zeroIndex = false, ) { assert.strictEqual(typeof id, 'number', 'has id'); const index = id - (zeroIndex ? 0 : 1); @@ -1251,13 +1251,13 @@ export function getAndVerifyString( stringTable: StringTable, // eslint-disable-next-line @typescript-eslint/no-explicit-any source: any, - field: string + field: string, ) { assert.ok(hasOwnProperty.call(source, field), 'has id field'); const str = getAndVerifyPresence( stringTable.strings, source[field] as number, - true + true, ); assert.strictEqual(typeof str, 'string', 'is a string'); return str; diff --git a/ts/test/test-get-value-from-map-profiler.ts b/ts/test/test-get-value-from-map-profiler.ts index 5e03bf00..432dac5c 100644 --- a/ts/test/test-get-value-from-map-profiler.ts +++ b/ts/test/test-get-value-from-map-profiler.ts @@ -59,7 +59,7 @@ if (useCPED && supportedPlatform) { assert.strictEqual( retrieved, context, - 'Should retrieve the same object' + 'Should retrieve the same object', ); profiler.dispose(); @@ -200,7 +200,7 @@ if (useCPED && supportedPlatform) { }); } -function createProfiler(als: AsyncLocalStorage) { +function createProfiler(als: AsyncLocalStorage) { return new profiler.TimeProfiler({ intervalMicros: 10000, durationMillis: 500, diff --git a/ts/test/test-heap-profiler.ts b/ts/test/test-heap-profiler.ts index 6ffe259d..14f67d04 100644 --- a/ts/test/test-heap-profiler.ts +++ b/ts/test/test-heap-profiler.ts @@ -98,7 +98,7 @@ describe('HeapProfiler', () => { profileStub = sinon .stub(v8HeapProfiler, 'mapAllocationProfile') .callsFake(callback => - callback(mapToGetterNode(copy(v8HeapWithPathProfile))) + callback(mapToGetterNode(copy(v8HeapWithPathProfile))), ); memoryUsageStub = sinon.stub(process, 'memoryUsage').returns({ external: 0, @@ -118,7 +118,7 @@ describe('HeapProfiler', () => { profileStub = sinon .stub(v8HeapProfiler, 'mapAllocationProfile') .callsFake(callback => - callback(mapToGetterNode(copy(v8HeapWithPathProfile))) + callback(mapToGetterNode(copy(v8HeapWithPathProfile))), ); memoryUsageStub = sinon.stub(process, 'memoryUsage').returns({ external: 0, @@ -138,7 +138,7 @@ describe('HeapProfiler', () => { profileStub = sinon .stub(v8HeapProfiler, 'mapAllocationProfile') .callsFake(callback => - callback(mapToGetterNode(copy(v8HeapWithPathProfile))) + callback(mapToGetterNode(copy(v8HeapWithPathProfile))), ); memoryUsageStub = sinon.stub(process, 'memoryUsage').returns({ external: 0, @@ -164,7 +164,7 @@ describe('HeapProfiler', () => { }, (err: Error) => { return err.message === 'Heap profiler is not enabled.'; - } + }, ); }); @@ -179,7 +179,7 @@ describe('HeapProfiler', () => { }, (err: Error) => { return err.message === 'Heap profiler is not enabled.'; - } + }, ); }); }); @@ -191,7 +191,7 @@ describe('HeapProfiler', () => { heapProfiler.start(intervalBytes1, stackDepth1); assert.ok( startStub.calledWith(intervalBytes1, stackDepth1), - 'expected startSamplingHeapProfiler to be called' + 'expected startSamplingHeapProfiler to be called', ); }); it('should throw error when enabled and started with different parameters', () => { @@ -200,7 +200,7 @@ describe('HeapProfiler', () => { heapProfiler.start(intervalBytes1, stackDepth1); assert.ok( startStub.calledWith(intervalBytes1, stackDepth1), - 'expected startSamplingHeapProfiler to be called' + 'expected startSamplingHeapProfiler to be called', ); startStub.resetHistory(); const intervalBytes2 = 1024 * 128; @@ -211,12 +211,12 @@ describe('HeapProfiler', () => { assert.strictEqual( (e as Error).message, 'Heap profiler is already started with intervalBytes 524288 and' + - ' stackDepth 64' + ' stackDepth 64', ); } assert.ok( !startStub.called, - 'expected startSamplingHeapProfiler not to be called second time' + 'expected startSamplingHeapProfiler not to be called second time', ); }); }); @@ -231,7 +231,7 @@ describe('HeapProfiler', () => { heapProfiler.stop(); assert.ok( stopStub.called, - 'expected stopSamplingHeapProfiler to be called' + 'expected stopSamplingHeapProfiler to be called', ); }); }); diff --git a/ts/test/test-profile-serializer.ts b/ts/test/test-profile-serializer.ts index c93619e2..a9d0cc0d 100644 --- a/ts/test/test-profile-serializer.ts +++ b/ts/test/test-profile-serializer.ts @@ -42,18 +42,18 @@ import { const assert = require('assert'); -function getNonJSThreadsSample(profile: Profile): Number[] | null { +function getNonJSThreadsSample(profile: Profile): number[] | null { for (const sample of profile.sample!) { const locationId = sample.locationId[0]; const location = getAndVerifyPresence( profile.location!, - locationId as number + locationId as number, ); const functionId = location.line![0].functionId; const fn = getAndVerifyPresence(profile.function!, functionId as number); const fn_name = profile.stringTable.strings[fn.name as number]; if (fn_name === NON_JS_THREADS_FUNCTION_NAME) { - return sample.value as Number[]; + return sample.value as number[]; } } @@ -101,7 +101,7 @@ describe('profile-serializer', () => { false, () => { return {foo: 'bar'}; - } + }, ); assert.equal(getNonJSThreadsSample(timeProfileOutWithLabels), null); }); @@ -131,7 +131,7 @@ describe('profile-serializer', () => { false, () => { return {foo: 'bar'}; - } + }, ); assert.equal(getNonJSThreadsSample(timeProfileOutWithLabels), null); }); @@ -165,7 +165,7 @@ describe('profile-serializer', () => { false, () => { return {foo: 'bar'}; - } + }, ); const valuesWithLabels = getNonJSThreadsSample(timeProfileOutWithLabels); assert.notEqual(valuesWithLabels, null); @@ -196,7 +196,7 @@ describe('profile-serializer', () => { const heapProfileOut = serializeHeapProfile( v8AnonymousFunctionHeapProfile, 0, - 512 * 1024 + 512 * 1024, ); assert.deepEqual(heapProfileOut, anonymousFunctionHeapProfile); }); @@ -216,7 +216,7 @@ describe('profile-serializer', () => { 0, 512 * 1024, undefined, - sourceMapper + sourceMapper, ); assert.deepEqual(heapProfileOut, heapSourceProfile); }); @@ -227,7 +227,7 @@ describe('profile-serializer', () => { const timeProfileOut = serializeTimeProfile( v8TimeGeneratedProfile, 1000, - sourceMapper + sourceMapper, ); assert.deepEqual(timeProfileOut, timeSourceProfile); }); diff --git a/ts/test/test-time-profiler.ts b/ts/test/test-time-profiler.ts index 6727d77c..559247f1 100644 --- a/ts/test/test-time-profiler.ts +++ b/ts/test/test-time-profiler.ts @@ -93,7 +93,7 @@ describe('Time Profiler', () => { assert.deepEqual( context!.context, initialContext, - 'Unexpected context' + 'Unexpected context', ); assert.ok(context!.timestamp >= startTime); @@ -137,8 +137,8 @@ describe('Time Profiler', () => { validateProfile( time.stop( i < repeats - 1, - enableEndPoint || collectAsyncId ? generateLabels : undefined - ) + enableEndPoint || collectAsyncId ? generateLabels : undefined, + ), ); } @@ -226,7 +226,7 @@ describe('Time Profiler', () => { hrtimeBigIntIdx, asyncIdLabelIdx, ] = ['loop', 'fn0', 'fn1', 'fn2', 'hrtimeBigInt', asyncIdLabel].map(x => - stringTable.dedup(x) + stringTable.dedup(x), ); function getString(n: number | bigint): string { @@ -281,7 +281,7 @@ describe('Time Profiler', () => { const labels = sample.label; if (collectAsyncId) { const idx = labels.findIndex( - label => label.key === asyncIdLabelIdx + label => label.key === asyncIdLabelIdx, ); if (idx !== -1) { // Remove async ID label so it doesn't confuse the assertions on @@ -295,7 +295,7 @@ describe('Time Profiler', () => { if (enableEndPoint) { assert( labels.length < 4, - 'loop can have at most two labels and one endpoint' + 'loop can have at most two labels and one endpoint', ); labels.forEach(label => { assert( @@ -303,7 +303,7 @@ describe('Time Profiler', () => { labelIs(label, 'label', 'value1') || labelIs(label, endPointLabel, endPoint) || labelIs(label, rootSpanIdLabel, rootSpanId), - 'loop can be observed with value0 or value1 or root span id or endpoint' + 'loop can be observed with value0 or value1 or root span id or endpoint', ); }); } else { @@ -313,7 +313,7 @@ describe('Time Profiler', () => { labelIs(label, 'label', 'value0') || labelIs(label, 'label', 'value1') || labelIs(label, rootSpanIdLabel, rootSpanId), - 'loop can be observed with value0 or value1 or root span id' + 'loop can be observed with value0 or value1 or root span id', ); }); } @@ -323,8 +323,8 @@ describe('Time Profiler', () => { assert( labels.length < 2, `fn0 can have at most one label, instead got: ${labels.map( - labelStr - )}` + labelStr, + )}`, ); labels.forEach(label => { if (labelIs(label, 'label', 'value0')) { @@ -342,7 +342,7 @@ describe('Time Profiler', () => { if (enableEndPoint) { assert( labels.length === 3, - 'fn1 must be observed with a label, a root span id and an endpoint' + 'fn1 must be observed with a label, a root span id and an endpoint', ); const labelMap = getLabels(labels); assert.deepEqual(labelMap, { @@ -352,13 +352,13 @@ describe('Time Profiler', () => { } else { assert( labels.length === 2, - 'fn1 must be observed with a label' + 'fn1 must be observed with a label', ); labels.forEach(label => { assert( labelIs(label, 'label', 'value1') || labelIs(label, rootSpanIdLabel, rootSpanId), - 'Only value1 can be observed with fn1' + 'Only value1 can be observed with fn1', ); }); } @@ -368,7 +368,7 @@ describe('Time Profiler', () => { assert( labels.length === 0, 'fn2 must be observed with no labels. Observed instead with ' + - labelStr(labels[0]) + labelStr(labels[0]), ); fn2ObservedWithoutLabels = true; break; @@ -381,7 +381,7 @@ describe('Time Profiler', () => { assert(fn1ObservedWithLabel1, 'fn1 was not observed with value1'); assert( fn2ObservedWithoutLabels, - 'fn2 was not observed without a label' + 'fn2 was not observed without a label', ); assert(!collectAsyncId || observedAsyncId, 'Async ID was not observed'); } @@ -445,7 +445,7 @@ describe('Time Profiler', () => { before(() => { sinonStubs.push( - sinon.stub(v8TimeProfiler, 'TimeProfiler').returns(timeProfilerStub) + sinon.stub(v8TimeProfiler, 'TimeProfiler').returns(timeProfilerStub), ); sinonStubs.push(sinon.stub(Date, 'now').returns(0)); }); @@ -458,7 +458,7 @@ describe('Time Profiler', () => { it('should profile during duration and finish profiling after duration', async () => { let isProfiling = true; - time.profile(PROFILE_OPTIONS).then(() => { + void time.profile(PROFILE_OPTIONS).then(() => { isProfiling = false; }); await setTimeoutPromise(2 * PROFILE_OPTIONS.durationMillis); @@ -479,7 +479,7 @@ describe('Time Profiler', () => { assert.equal( time.v8ProfilerStuckEventLoopDetected(), 0, - 'v8 bug detected' + 'v8 bug detected', ); sinon.assert.notCalled(timeProfilerStub.start); @@ -507,7 +507,7 @@ describe('Time Profiler', () => { before(() => { sinonStubs.push( - sinon.stub(v8TimeProfiler, 'TimeProfiler').returns(timeProfilerStub) + sinon.stub(v8TimeProfiler, 'TimeProfiler').returns(timeProfilerStub), ); sinonStubs.push(sinon.stub(Date, 'now').returns(0)); }); @@ -527,7 +527,7 @@ describe('Time Profiler', () => { assert.equal( time.v8ProfilerStuckEventLoopDetected(), 2, - 'v8 bug not detected' + 'v8 bug not detected', ); timeProfilerStub.start.resetHistory(); timeProfilerStub.stop.resetHistory(); @@ -641,7 +641,7 @@ describe('Time Profiler', () => { assert(foundLowCardLabel, 'Should find low cardinality label in samples'); assert( foundHighCardLabel, - 'Should find high cardinality label in samples' + 'Should find high cardinality label in samples', ); // Verify that the lowCardinalityLabels parameter is working correctly @@ -662,17 +662,17 @@ describe('Time Profiler', () => { labelsByValue.size === 2, `Expected exactly 2 distinct low cardinality label values, found ${ labelsByValue.size - }. Values: ${Array.from(labelsByValue.keys()).join(', ')}` + }. Values: ${Array.from(labelsByValue.keys()).join(', ')}`, ); // Verify we found both expected values assert( labelsByValue.has('web-service'), - 'Should find web-service labels' + 'Should find web-service labels', ); assert( labelsByValue.has('api-service'), - 'Should find api-service labels' + 'Should find api-service labels', ); // Verify that the lowCardinalityLabels parameter was properly used @@ -680,7 +680,7 @@ describe('Time Profiler', () => { labelsByValue.forEach((labels, value) => { assert( labels.length > 0, - `Should have at least one label with value '${value}'` + `Should have at least one label with value '${value}'`, ); // Check that all labels have the same key (service_name) @@ -688,7 +688,7 @@ describe('Time Profiler', () => { const keyStr = profile.stringTable.strings[Number(label.key)]; assert( keyStr === lowCardLabel, - `Expected label key to be '${lowCardLabel}', got '${keyStr}'` + `Expected label key to be '${lowCardLabel}', got '${keyStr}'`, ); }); }); @@ -697,17 +697,17 @@ describe('Time Profiler', () => { // This verifies that the lowCardinalityLabels parameter is properly handled const allUniqueValues = new Set( lowCardinalityLabels.map( - label => profile.stringTable.strings[Number(label.str)] - ) + label => profile.stringTable.strings[Number(label.str)], + ), ); assert( allUniqueValues.size === 2, - `Expected exactly 2 unique low cardinality label values across all samples, found ${allUniqueValues.size}` + `Expected exactly 2 unique low cardinality label values across all samples, found ${allUniqueValues.size}`, ); assert( allUniqueValues.has('web-service') && allUniqueValues.has('api-service'), - 'Should find both web-service and api-service values in the low cardinality labels' + 'Should find both web-service and api-service values in the low cardinality labels', ); // Verify that low cardinality labels with the same value are the same object @@ -717,7 +717,7 @@ describe('Time Profiler', () => { assert( uniqueObjects.size === 1, `All labels with value '${value}' should be the same object, found ${uniqueObjects.size} different objects. ` + - 'The lowCardinalityLabels parameter should enable deduplication of Label objects with identical key/value pairs.' + 'The lowCardinalityLabels parameter should enable deduplication of Label objects with identical key/value pairs.', ); }); }); @@ -782,7 +782,7 @@ describe('Time Profiler', () => { assert.deepEqual( contextInsideFunction, testContext, - 'Context should be accessible within function' + 'Context should be accessible within function', ); } finally { time.stop(); @@ -810,13 +810,13 @@ describe('Time Profiler', () => { }, 42, 'hello', - true + true, ); assert.deepEqual( result, {a: 42, b: 'hello', c: true}, - 'Arguments should be passed correctly' + 'Arguments should be passed correctly', ); } finally { time.stop(); @@ -844,7 +844,7 @@ describe('Time Profiler', () => { assert.strictEqual( result, 'test-result', - 'Function result should be returned' + 'Function result should be returned', ); } finally { time.stop(); @@ -870,21 +870,21 @@ describe('Time Profiler', () => { time.runWithContext(outerContext, () => { const ctx1 = time.getContext(); - results.push((ctx1 as any).label); + results.push((ctx1 as Record).label); time.runWithContext(innerContext, () => { const ctx2 = time.getContext(); - results.push((ctx2 as any).label); + results.push((ctx2 as Record).label); }); const ctx3 = time.getContext(); - results.push((ctx3 as any).label); + results.push((ctx3 as Record).label); }); assert.deepEqual( results, ['outer', 'inner', 'outer'], - 'Nested contexts should be properly isolated and restored' + 'Nested contexts should be properly isolated and restored', ); } finally { time.stop(); @@ -917,12 +917,12 @@ describe('Time Profiler', () => { assert.deepEqual( contextInside, runWithContextContext, - 'Context inside should match' + 'Context inside should match', ); assert.strictEqual( contextOutside, undefined, - 'Context outside should be undefined with CPED' + 'Context outside should be undefined with CPED', ); } finally { time.stop(); @@ -954,12 +954,12 @@ describe('Time Profiler', () => { assert.deepEqual( result.ctx1, testContext, - 'Context should be available before await' + 'Context should be available before await', ); assert.deepEqual( result.ctx2, testContext, - 'Context should be preserved after await' + 'Context should be preserved after await', ); } finally { time.stop(); diff --git a/ts/test/test-worker-threads.ts b/ts/test/test-worker-threads.ts index d659da02..93d8e04b 100644 --- a/ts/test/test-worker-threads.ts +++ b/ts/test/test-worker-threads.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line node/no-unsupported-features/node-builtins import {execFile} from 'child_process'; import {promisify} from 'util'; import {Worker} from 'worker_threads'; @@ -24,7 +23,7 @@ describe('Worker Threads', () => { worker.postMessage('hello'); worker.on('message', () => { - worker.terminate(); + void worker.terminate(); }); workers.push( @@ -36,7 +35,7 @@ describe('Worker Threads', () => { reject(new Error('Worker exited with code 0')); } }); - }) + }), ); } await Promise.all(workers); diff --git a/ts/test/worker.ts b/ts/test/worker.ts index 5bc7dca9..5b4240af 100644 --- a/ts/test/worker.ts +++ b/ts/test/worker.ts @@ -26,19 +26,22 @@ function createWorker(durationMs: number): Promise { new Worker(__filename, {workerData: {durationMs}}) .on('exit', exitCode => { if (exitCode !== 0) reject(); - setTimeout(() => { - // Run a second worker after the first one exited to test for proper - // cleanup after first worker. This used to segfault. - new Worker(__filename, {workerData: {durationMs}}) - .on('exit', exitCode => { - if (exitCode !== 0) reject(); - resolve(profiles); - }) - .on('error', reject) - .on('message', profile => { - profiles.push(profile); - }); - }, Math.floor(Math.random() * durationMs)); + setTimeout( + () => { + // Run a second worker after the first one exited to test for proper + // cleanup after first worker. This used to segfault. + new Worker(__filename, {workerData: {durationMs}}) + .on('exit', exitCode => { + if (exitCode !== 0) reject(); + resolve(profiles); + }) + .on('error', reject) + .on('message', profile => { + profiles.push(profile); + }); + }, + Math.floor(Math.random() * durationMs), + ); }) .on('error', reject) .on('message', profile => { @@ -125,9 +128,9 @@ async function worker(durationMs: number) { } if (isMainThread) { - main(DURATION_MILLIS); + void main(DURATION_MILLIS); } else { - worker(workerData.durationMs); + void worker(workerData.durationMs); } function valueName(profile: Profile, vt: ValueType) { @@ -148,7 +151,7 @@ function getCpuTime(profile: Profile) { const locationId = sample.locationId[0]; const location = getAndVerifyPresence( profile.location!, - locationId as number + locationId as number, ); const functionId = location.line![0].functionId; const fn = getAndVerifyPresence(profile.function!, functionId as number); @@ -169,7 +172,7 @@ function checkCpuTime( profile: Profile, processCpuTimeMicros: number, workerProfiles: Profile[] = [], - maxRelativeError = 0.1 + maxRelativeError = 0.1, ) { let workersJsCpuTime = 0; let workersNonJsCpuTime = 0; @@ -187,7 +190,7 @@ function checkCpuTime( assert.strictEqual( workersNonJsCpuTime, 0, - 'worker non-JS CPU time should be null' + 'worker non-JS CPU time should be null', ); const totalCpuTimeMicros = @@ -203,7 +206,7 @@ function checkCpuTime( }\nnon-JS cpu time: ${mainNonJsCpuTime / 1000000}ms\nerror: ${err}`; assert.ok( err <= maxRelativeError, - `total profile CPU time should be close to process cpu time:\n${msg}` + `total profile CPU time should be close to process cpu time:\n${msg}`, ); } @@ -218,7 +221,7 @@ function checkProfile(profile: Profile) { assert.strictEqual(typeof profile.period, 'number'); assert.strictEqual( valueName(profile, profile.periodType!), - 'wall/nanoseconds' + 'wall/nanoseconds', ); assert.ok(profile.sample.length > 0, 'No samples'); @@ -233,13 +236,13 @@ function checkProfile(profile: Profile) { for (const locationId of sample.locationId!) { const location = getAndVerifyPresence( profile.location!, - locationId as number + locationId as number, ); for (const {functionId, line} of location.line!) { const fn = getAndVerifyPresence( profile.function!, - functionId as number + functionId as number, ); getAndVerifyString(profile.stringTable!, fn, 'name'); diff --git a/ts/test/worker2.ts b/ts/test/worker2.ts index eaacd07c..2a1e4b13 100644 --- a/ts/test/worker2.ts +++ b/ts/test/worker2.ts @@ -29,7 +29,7 @@ time.start({ }); parentPort?.on('message', () => { - delay(50).then(() => { + void delay(50).then(() => { parentPort?.postMessage('hello'); }); }); From 3c290b3156927553aafe86737dbe3eab680f5a51 Mon Sep 17 00:00:00 2001 From: Ilyas Shabi Date: Tue, 10 Mar 2026 13:50:18 +0100 Subject: [PATCH 06/16] Ignore tsconfig auto generated build info file (#290) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f75c1f40..d65c4ed9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ node_modules system-test/busybench/package-lock.json system-test/busybench-js/package-lock.json prebuilds +tsconfig.tsbuildinfo From 725c750232fb55680638e3d8ea38d24dd44e8639 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Mon, 15 Dec 2025 18:43:37 +0800 Subject: [PATCH 07/16] Fix source mapping of zero-column locations (#248) When lineNumbers is enabled, the column is always zero. If only one occurence of a call occurs on one line then that is correctly selected. However, in cases where the same function is called multiple times in the same line it will be unable to differentiate them and would use the unmapped value. This now makes it select the first call in the line as a best-guess for the match, and in Node.js v25 will use the new column field in LineTick to select the correct column where possible. --- bindings/translate-time-profile.cc | 6 + ts/src/sourcemapper/sourcemapper.ts | 10 +- ts/test/test-profile-serializer.ts | 187 +++++++++++++++++++++++++++- 3 files changed, 201 insertions(+), 2 deletions(-) diff --git a/bindings/translate-time-profile.cc b/bindings/translate-time-profile.cc index 6495d3f0..0d85976a 100644 --- a/bindings/translate-time-profile.cc +++ b/bindings/translate-time-profile.cc @@ -15,6 +15,7 @@ */ #include "translate-time-profile.hh" +#include #include "profile-translator.hh" namespace dd { @@ -99,7 +100,12 @@ class TimeProfileTranslator : ProfileTranslator { node->GetScriptResourceName(), scriptId, NewInteger(entry.line), +// V8 14+ (Node.js 25+) added column field to LineTick struct +#if V8_MAJOR_VERSION >= 14 + NewInteger(entry.column), +#else zero, +#endif NewInteger(entry.hit_count), emptyArray, emptyArray)); diff --git a/ts/src/sourcemapper/sourcemapper.ts b/ts/src/sourcemapper/sourcemapper.ts index efca3a76..4e94446d 100644 --- a/ts/src/sourcemapper/sourcemapper.ts +++ b/ts/src/sourcemapper/sourcemapper.ts @@ -287,7 +287,15 @@ export class SourceMapper { const consumer: sourceMap.SourceMapConsumer = entry.mapConsumer as {} as sourceMap.SourceMapConsumer; - const pos = consumer.originalPositionFor(generatedPos); + // When column is 0, we don't have real column info (e.g., from V8's LineTick + // which only provides line numbers). Use LEAST_UPPER_BOUND to find the first + // mapping on this line instead of failing because there's nothing at column 0. + const bias = + generatedPos.column === 0 + ? sourceMap.SourceMapConsumer.LEAST_UPPER_BOUND + : sourceMap.SourceMapConsumer.GREATEST_LOWER_BOUND; + + const pos = consumer.originalPositionFor({...generatedPos, bias}); if (pos.source === null) { if (this.debug) { logger.debug( diff --git a/ts/test/test-profile-serializer.ts b/ts/test/test-profile-serializer.ts index a9d0cc0d..c4461cdd 100644 --- a/ts/test/test-profile-serializer.ts +++ b/ts/test/test-profile-serializer.ts @@ -23,10 +23,11 @@ import { } from '../src/profile-serializer'; import {SourceMapper} from '../src/sourcemapper/sourcemapper'; import {Label, Profile} from 'pprof-format'; -import {TimeProfile} from '../src/v8-types'; +import {TimeProfile, TimeProfileNode} from '../src/v8-types'; import { anonymousFunctionHeapProfile, getAndVerifyPresence, + getAndVerifyString, heapProfile, heapSourceProfile, labelEncodingProfile, @@ -237,4 +238,188 @@ describe('profile-serializer', () => { tmp.setGracefulCleanup(); }); }); + + describe('source map with column 0 (LineTick simulation)', () => { + // This tests the LEAST_UPPER_BOUND fallback for when V8's LineTick + // doesn't provide column information (column=0) + let sourceMapper: SourceMapper; + let testMapDir: string; + + // Line in source.ts that the first call maps to (column 10) + const FIRST_CALL_SOURCE_LINE = 100; + // Line in source.ts that the second call maps to (column 25) + const SECOND_CALL_SOURCE_LINE = 200; + + before(async () => { + // Create a source map simulating: return fib(n-1) + fib(n-2) + // Same function called twice on the same line at different columns + testMapDir = tmp.dirSync().name; + const {SourceMapGenerator} = await import('source-map'); + const fs = await import('fs'); + const path = await import('path'); + + const mapGen = new SourceMapGenerator({file: 'generated.js'}); + + // First fib() call at column 10 -> maps to source line 100 + mapGen.addMapping({ + source: path.join(testMapDir, 'source.ts'), + name: 'fib', + generated: {line: 5, column: 10}, + original: {line: FIRST_CALL_SOURCE_LINE, column: 0}, + }); + + // Second fib() call at column 25 -> maps to source line 200 + mapGen.addMapping({ + source: path.join(testMapDir, 'source.ts'), + name: 'fib', + generated: {line: 5, column: 25}, + original: {line: SECOND_CALL_SOURCE_LINE, column: 0}, + }); + + fs.writeFileSync( + path.join(testMapDir, 'generated.js.map'), + mapGen.toString(), + ); + fs.writeFileSync(path.join(testMapDir, 'generated.js'), ''); + + sourceMapper = await SourceMapper.create([testMapDir]); + }); + + it('should map column 0 to first mapping on line (LEAST_UPPER_BOUND fallback)', () => { + const path = require('path'); + // Simulate LineTick entry with column=0 (no column info from V8 < 14) + // This is the fallback behavior when LineTick.column is not available + const childNode: TimeProfileNode = { + name: 'fib', + scriptName: path.join(testMapDir, 'generated.js'), + scriptId: 1, + lineNumber: 5, + columnNumber: 0, // LineTick has no column in V8 < 14 + hitCount: 1, + children: [], + }; + const v8Profile: TimeProfile = { + startTime: 0, + endTime: 1000000, + topDownRoot: { + name: '(root)', + scriptName: 'root', + scriptId: 0, + lineNumber: 0, + columnNumber: 0, + hitCount: 0, + children: [childNode], + }, + }; + + const profile = serializeTimeProfile(v8Profile, 1000, sourceMapper); + + assert.strictEqual(profile.location!.length, 1); + const loc = profile.location![0]; + const line = loc.line![0]; + const func = getAndVerifyPresence( + profile.function!, + line.functionId as number, + ); + const filename = getAndVerifyString( + profile.stringTable, + func, + 'filename', + ); + + // Should be mapped to source.ts + assert.ok( + filename.includes('source.ts'), + `Expected source.ts but got ${filename}`, + ); + // With column 0 and LEAST_UPPER_BOUND, should map to FIRST mapping (line 100) + assert.strictEqual( + line.line, + FIRST_CALL_SOURCE_LINE, + 'Column 0 should use LEAST_UPPER_BOUND to find first mapping on line', + ); + }); + + it('should map to second call when column points to it (V8 14+ with LineTick.column)', () => { + const path = require('path'); + // Simulate V8 14+ behavior where LineTick has actual column data + // Column 26 is after the second mapping at column 25 + const childNode: TimeProfileNode = { + name: 'fib', + scriptName: path.join(testMapDir, 'generated.js'), + scriptId: 1, + lineNumber: 5, + columnNumber: 26, // V8 14+ provides actual column from LineTick + hitCount: 1, + children: [], + }; + const v8Profile: TimeProfile = { + startTime: 0, + endTime: 1000000, + topDownRoot: { + name: '(root)', + scriptName: 'root', + scriptId: 0, + lineNumber: 0, + columnNumber: 0, + hitCount: 0, + children: [childNode], + }, + }; + + const profile = serializeTimeProfile(v8Profile, 1000, sourceMapper); + + assert.strictEqual(profile.location!.length, 1); + const loc = profile.location![0]; + const line = loc.line![0]; + + // Column 26 with GREATEST_LOWER_BOUND should map to second call (line 200) + assert.strictEqual( + line.line, + SECOND_CALL_SOURCE_LINE, + 'Column 26 should use GREATEST_LOWER_BOUND to find mapping at column 25', + ); + }); + + it('should map to first call when column points to it (V8 14+ with LineTick.column)', () => { + const path = require('path'); + // Simulate V8 14+ behavior where LineTick has actual column data + // Column 11 is after the first mapping at column 10 but before second at 25 + const childNode: TimeProfileNode = { + name: 'fib', + scriptName: path.join(testMapDir, 'generated.js'), + scriptId: 1, + lineNumber: 5, + columnNumber: 11, // V8 14+ provides actual column from LineTick + hitCount: 1, + children: [], + }; + const v8Profile: TimeProfile = { + startTime: 0, + endTime: 1000000, + topDownRoot: { + name: '(root)', + scriptName: 'root', + scriptId: 0, + lineNumber: 0, + columnNumber: 0, + hitCount: 0, + children: [childNode], + }, + }; + + const profile = serializeTimeProfile(v8Profile, 1000, sourceMapper); + + assert.strictEqual(profile.location!.length, 1); + const loc = profile.location![0]; + const line = loc.line![0]; + + // Column 11 with GREATEST_LOWER_BOUND should map to first call (line 100) + assert.strictEqual( + line.line, + FIRST_CALL_SOURCE_LINE, + 'Column 11 should use GREATEST_LOWER_BOUND to find mapping at column 10', + ); + }); + }); }); From 5db3031b6669760da2dfba4a0c1bfeaa9c0179d3 Mon Sep 17 00:00:00 2001 From: Ilyas Shabi Date: Tue, 10 Mar 2026 18:31:36 +0100 Subject: [PATCH 08/16] Reduce time profiler memory usage with lazy profile tree (#287) Reduce time profiler memory usage with lazy profile tree --- bindings/binding.cc | 2 + bindings/per-isolate-data.cc | 4 + bindings/per-isolate-data.hh | 2 + bindings/profilers/wall.cc | 89 ++++++-- bindings/profilers/wall.hh | 6 + bindings/translate-time-profile.cc | 327 +++++++++++++++++++++++++++++ bindings/translate-time-profile.hh | 40 ++++ ts/src/index.ts | 1 + ts/src/profile-serializer.ts | 5 +- ts/src/time-profiler.ts | 101 +++++++-- ts/src/v8-types.ts | 2 + ts/test/test-time-profiler.ts | 185 ++++++++++++++++ ts/test/time-memory-worker.ts | 153 ++++++++++++++ 13 files changed, 883 insertions(+), 34 deletions(-) create mode 100644 ts/test/time-memory-worker.ts diff --git a/bindings/binding.cc b/bindings/binding.cc index 67f2b802..acbf99a0 100644 --- a/bindings/binding.cc +++ b/bindings/binding.cc @@ -21,6 +21,7 @@ #include "allocation-profile-node.hh" #include "profilers/heap.hh" #include "profilers/wall.hh" +#include "translate-time-profile.hh" #ifdef __linux__ #include @@ -49,6 +50,7 @@ NODE_MODULE_INIT(/* exports, module, context */) { #endif dd::AllocationProfileNodeView::Init(exports); + dd::TimeProfileNodeView::Init(exports); dd::HeapProfiler::Init(exports); dd::WallProfiler::Init(exports); Nan::SetMethod(exports, "getNativeThreadId", GetNativeThreadId); diff --git a/bindings/per-isolate-data.cc b/bindings/per-isolate-data.cc index 424f9c47..5898fce2 100644 --- a/bindings/per-isolate-data.cc +++ b/bindings/per-isolate-data.cc @@ -56,6 +56,10 @@ Nan::Global& PerIsolateData::AllocationNodeConstructor() { return allocation_node_constructor; } +Nan::Global& PerIsolateData::TimeProfileNodeConstructor() { + return time_profile_node_constructor; +} + std::shared_ptr& PerIsolateData::GetHeapProfilerState() { return heap_profiler_state; } diff --git a/bindings/per-isolate-data.hh b/bindings/per-isolate-data.hh index dba9d52a..618aa8a9 100644 --- a/bindings/per-isolate-data.hh +++ b/bindings/per-isolate-data.hh @@ -29,6 +29,7 @@ class PerIsolateData { private: Nan::Global wall_profiler_constructor; Nan::Global allocation_node_constructor; + Nan::Global time_profile_node_constructor; std::shared_ptr heap_profiler_state; PerIsolateData() {} @@ -38,6 +39,7 @@ class PerIsolateData { Nan::Global& WallProfilerConstructor(); Nan::Global& AllocationNodeConstructor(); + Nan::Global& TimeProfileNodeConstructor(); std::shared_ptr& GetHeapProfilerState(); }; diff --git a/bindings/profilers/wall.cc b/bindings/profilers/wall.cc index f5e6cc7f..0f21b56a 100644 --- a/bindings/profilers/wall.cc +++ b/bindings/profilers/wall.cc @@ -942,6 +942,34 @@ NAN_METHOD(WallProfiler::Stop) { info.GetReturnValue().Set(profile); } +// stopAndCollect(restart, callback): callback result +NAN_METHOD(WallProfiler::StopAndCollect) { + if (info.Length() != 2) { + return Nan::ThrowTypeError("stopAndCollect must have two arguments."); + } + if (!info[0]->IsBoolean()) { + return Nan::ThrowTypeError("Restart must be a boolean."); + } + if (!info[1]->IsFunction()) { + return Nan::ThrowTypeError("stopAndCollect requires a callback."); + } + + bool restart = info[0].As()->Value(); + auto callback = info[1].As(); + + WallProfiler* wallProfiler = + Nan::ObjectWrap::Unwrap(info.This()); + + v8::Local result; + auto err = wallProfiler->StopAndCollectImpl(restart, callback, result); + if (!err.success) { + return Nan::ThrowTypeError(err.msg.c_str()); + } + if (!result.IsEmpty()) { + info.GetReturnValue().Set(result); + } +} + bool WallProfiler::waitForSignal(uint64_t targetCallCount) { auto currentCallCount = noCollectCallCount_.load(std::memory_order_relaxed); std::atomic_signal_fence(std::memory_order_acquire); @@ -968,7 +996,8 @@ bool WallProfiler::waitForSignal(uint64_t targetCallCount) { return res >= targetCallCount; } -Result WallProfiler::StopImpl(bool restart, v8::Local& profile) { +template +Result WallProfiler::StopCore(bool restart, ProfileBuilder&& buildProfile) { if (!started_) { return Result{"Stop called on not started profiler."}; } @@ -1030,9 +1059,12 @@ Result WallProfiler::StopImpl(bool restart, v8::Local& profile) { std::memory_order_relaxed); } - if (withContexts_) { - int64_t nonJSThreadsCpuTime{}; + ContextsByNode contextsByNode; + ContextsByNode* contextsByNodePtr = nullptr; + int64_t nonJSThreadsCpuTime = 0; + bool hasCpuTime = false; + if (withContexts_) { if (isMainThread_ && collectCpuTime_) { // account for non-JS threads CPU only in main thread // CPU time of non-JS threads is the difference between process CPU time @@ -1044,18 +1076,14 @@ Result WallProfiler::StopImpl(bool restart, v8::Local& profile) { std::max(processCpu - totalWorkerCpu, ProcessCpuClock::duration{}) .count(); } - auto contextsByNode = + contextsByNode = GetContextsByNode(v8_profile, contexts, startThreadCpuTime); + contextsByNodePtr = &contextsByNode; + hasCpuTime = collectCpuTime_; + } - profile = TranslateTimeProfile(v8_profile, - includeLines_, - &contextsByNode, - collectCpuTime_, - nonJSThreadsCpuTime); + buildProfile(v8_profile, hasCpuTime, nonJSThreadsCpuTime, contextsByNodePtr); - } else { - profile = TranslateTimeProfile(v8_profile, includeLines_); - } v8_profile->Delete(); if (!restart) { @@ -1072,6 +1100,42 @@ Result WallProfiler::StopImpl(bool restart, v8::Local& profile) { return {}; } +Result WallProfiler::StopImpl(bool restart, v8::Local& profile) { + return StopCore(restart, + [&](const v8::CpuProfile* v8_profile, + bool hasCpuTime, + int64_t nonJSThreadsCpuTime, + ContextsByNode* contextsByNodePtr) { + profile = TranslateTimeProfile(v8_profile, + includeLines_, + contextsByNodePtr, + hasCpuTime, + nonJSThreadsCpuTime); + }); +} + +Result WallProfiler::StopAndCollectImpl(bool restart, + v8::Local callback, + v8::Local& result) { + return StopCore( + restart, + [&](const v8::CpuProfile* v8_profile, + bool hasCpuTime, + int64_t nonJSThreadsCpuTime, + ContextsByNode* contextsByNodePtr) { + auto* isolate = Isolate::GetCurrent(); + TimeProfileViewState state{includeLines_, contextsByNodePtr, {}}; + auto profile_view = BuildTimeProfileView( + v8_profile, hasCpuTime, nonJSThreadsCpuTime, state); + v8::Local argv[] = {profile_view}; + auto cb_result = Nan::Call( + callback, isolate->GetCurrentContext()->Global(), 1, argv); + if (!cb_result.IsEmpty()) { + result = cb_result.ToLocalChecked(); + } + }); +} + NAN_MODULE_INIT(WallProfiler::Init) { Local tpl = Nan::New(New); Local className = Nan::New("TimeProfiler").ToLocalChecked(); @@ -1085,6 +1149,7 @@ NAN_MODULE_INIT(WallProfiler::Init) { Nan::SetPrototypeMethod(tpl, "start", Start); Nan::SetPrototypeMethod(tpl, "stop", Stop); + Nan::SetPrototypeMethod(tpl, "stopAndCollect", StopAndCollect); Nan::SetPrototypeMethod(tpl, "dispose", Dispose); Nan::SetPrototypeMethod(tpl, "v8ProfilerStuckEventLoopDetected", diff --git a/bindings/profilers/wall.hh b/bindings/profilers/wall.hh index 7e01f354..a68af759 100644 --- a/bindings/profilers/wall.hh +++ b/bindings/profilers/wall.hh @@ -155,7 +155,12 @@ class WallProfiler : public Nan::ObjectWrap { Result StartImpl(); v8::ProfilerId StartInternal(); + template + Result StopCore(bool restart, ProfileBuilder&& buildProfile); Result StopImpl(bool restart, v8::Local& profile); + Result StopAndCollectImpl(bool restart, + v8::Local callback, + v8::Local& result); CollectionMode collectionMode() { auto res = collectionMode_.load(std::memory_order_relaxed); @@ -185,6 +190,7 @@ class WallProfiler : public Nan::ObjectWrap { static NAN_METHOD(New); static NAN_METHOD(Start); static NAN_METHOD(Stop); + static NAN_METHOD(StopAndCollect); static NAN_METHOD(V8ProfilerStuckEventLoopDetected); static NAN_METHOD(Dispose); static NAN_MODULE_INIT(Init); diff --git a/bindings/translate-time-profile.cc b/bindings/translate-time-profile.cc index 0d85976a..4a915a6f 100644 --- a/bindings/translate-time-profile.cc +++ b/bindings/translate-time-profile.cc @@ -16,11 +16,267 @@ #include "translate-time-profile.hh" #include +#include "per-isolate-data.hh" #include "profile-translator.hh" namespace dd { namespace { + +TimeProfileNodeInfo* AllocNode(TimeProfileViewState* state, + const v8::CpuProfileNode* node, + const v8::CpuProfileNode* metadata_node, + int line_number, + int column_number, + int hit_count, + bool is_line_root = false) { + auto info = std::make_unique(); + info->node = node; + info->metadata_node = metadata_node; + info->line_number = line_number; + info->column_number = column_number; + info->hit_count = hit_count; + info->is_line_root = is_line_root; + info->state = state; + auto* raw = info.get(); + state->owned_nodes.push_back(std::move(info)); + return raw; +} + +// Line-info mode: for a given V8 node, append its line ticks (leaves with +// hit_count > 0) and child calls (intermediate nodes with hit_count 0). +void AppendLineChildren(TimeProfileViewState* state, + const v8::CpuProfileNode* node, + std::vector& out) { + unsigned int hitLineCount = node->GetHitLineCount(); + unsigned int hitCount = node->GetHitCount(); + + if (hitLineCount > 0) { + std::vector entries(hitLineCount); + node->GetLineTicks(&entries[0], hitLineCount); + for (const auto& entry : entries) { + int column = 0; +#if V8_MAJOR_VERSION >= 14 + column = entry.column; +#endif + out.push_back( + AllocNode(state, node, node, entry.line, column, entry.hit_count)); + } + } else if (hitCount > 0) { + out.push_back(AllocNode(state, + node, + node, + node->GetLineNumber(), + node->GetColumnNumber(), + hitCount)); + } + + int32_t count = node->GetChildrenCount(); + for (int32_t i = 0; i < count; i++) { + auto* child = node->GetChild(i); + out.push_back(AllocNode(state, + child, + node, + child->GetLineNumber(), + child->GetColumnNumber(), + 0)); + } +} + +int ResolveHitCount(const v8::CpuProfileNode* node, ContextsByNode* cbn) { + if (!cbn) return node->GetHitCount(); + auto it = cbn->find(node); + return it != cbn->end() ? it->second.hitcount : 0; +} + +int ComputeTotalHitCount(const v8::CpuProfileNode* node, ContextsByNode* cbn) { + int total = ResolveHitCount(node, cbn); + int32_t count = node->GetChildrenCount(); + for (int32_t i = 0; i < count; i++) { + total += ComputeTotalHitCount(node->GetChild(i), cbn); + } + return total; +} + +// WrapNode: create a JS wrapper for a profile node. +// Normal mode stores CpuProfileNode* directly. +// line-info mode stores owned info. +v8::Local WrapNode(const v8::CpuProfileNode* node, + TimeProfileViewState* state) { + auto* isolate = v8::Isolate::GetCurrent(); + auto ctor = + Nan::New(PerIsolateData::For(isolate)->TimeProfileNodeConstructor()); + auto obj = Nan::NewInstance(ctor).ToLocalChecked(); + Nan::SetInternalFieldPointer(obj, 0, const_cast(node)); + Nan::SetInternalFieldPointer(obj, 1, state); + return obj; +} + +v8::Local WrapNode(TimeProfileNodeInfo* info) { + auto* isolate = v8::Isolate::GetCurrent(); + auto ctor = + Nan::New(PerIsolateData::For(isolate)->TimeProfileNodeConstructor()); + auto obj = Nan::NewInstance(ctor).ToLocalChecked(); + Nan::SetInternalFieldPointer(obj, 0, info); + Nan::SetInternalFieldPointer(obj, 1, info->state); + return obj; +} + +// Extracts the two internal fields from a JS wrapper object. +// field 0 represents the node data (depends on mode, see below). +// field 1 TimeProfileViewState* (shared state, always present). +// +// Line-info: field 0 is a TimeProfileNodeInfo* holding synthetic +// line/column/hitCount values. Normal: field 0 is a CpuProfileNode* pointing +// directly to V8. +struct NodeFields { + void* ptr; + TimeProfileViewState* state; + + bool is_line_info() const { return state->include_line_info; } + + TimeProfileNodeInfo* as_info() const { + return static_cast(ptr); + } + + const v8::CpuProfileNode* as_node() const { + return static_cast(ptr); + } +}; + +NodeFields GetFields(const Nan::PropertyCallbackInfo& info) { + return {Nan::GetInternalFieldPointer(info.Holder(), 0), + static_cast( + Nan::GetInternalFieldPointer(info.Holder(), 1))}; +} + +NAN_GETTER(GetName) { + auto fields = GetFields(info); + if (fields.is_line_info()) { + info.GetReturnValue().Set( + fields.as_info()->metadata_node->GetFunctionName()); + } else { + info.GetReturnValue().Set(fields.as_node()->GetFunctionName()); + } +} + +NAN_GETTER(GetScriptName) { + auto fields = GetFields(info); + if (fields.is_line_info()) { + info.GetReturnValue().Set( + fields.as_info()->metadata_node->GetScriptResourceName()); + } else { + info.GetReturnValue().Set(fields.as_node()->GetScriptResourceName()); + } +} + +NAN_GETTER(GetScriptId) { + auto fields = GetFields(info); + if (fields.is_line_info()) { + info.GetReturnValue().Set( + Nan::New(fields.as_info()->metadata_node->GetScriptId())); + } else { + info.GetReturnValue().Set( + Nan::New(fields.as_node()->GetScriptId())); + } +} + +NAN_GETTER(GetLineNumber) { + auto fields = GetFields(info); + if (fields.is_line_info()) { + info.GetReturnValue().Set( + Nan::New(fields.as_info()->line_number)); + } else { + info.GetReturnValue().Set( + Nan::New(fields.as_node()->GetLineNumber())); + } +} + +NAN_GETTER(GetColumnNumber) { + auto fields = GetFields(info); + if (fields.is_line_info()) { + info.GetReturnValue().Set( + Nan::New(fields.as_info()->column_number)); + } else { + info.GetReturnValue().Set( + Nan::New(fields.as_node()->GetColumnNumber())); + } +} + +NAN_GETTER(GetHitCount) { + auto fields = GetFields(info); + if (fields.is_line_info()) { + info.GetReturnValue().Set( + Nan::New(fields.as_info()->hit_count)); + } else { + info.GetReturnValue().Set(Nan::New( + ResolveHitCount(fields.as_node(), fields.state->contexts_by_node))); + } +} + +NAN_GETTER(GetContexts) { + auto fields = GetFields(info); + auto* isolate = v8::Isolate::GetCurrent(); + // Line-info nodes and nodes without context tracking return empty contexts. + if (fields.is_line_info() || !fields.state->contexts_by_node) { + info.GetReturnValue().Set(v8::Array::New(isolate, 0)); + return; + } + auto it = fields.state->contexts_by_node->find(fields.as_node()); + if (it != fields.state->contexts_by_node->end()) { + info.GetReturnValue().Set(it->second.contexts); + } else { + info.GetReturnValue().Set(v8::Array::New(isolate, 0)); + } +} + +NAN_GETTER(GetChildren) { + auto fields = GetFields(info); + auto* isolate = info.GetIsolate(); + auto ctx = isolate->GetCurrentContext(); + + if (fields.is_line_info()) { + std::vector children; + + // Root in line-info mode is flattened from each direct child to preserve + // eager v1 top-level shape. + if (fields.as_info()->is_line_root) { + int32_t count = fields.as_info()->node->GetChildrenCount(); + for (int32_t i = 0; i < count; i++) { + AppendLineChildren( + fields.state, fields.as_info()->node->GetChild(i), children); + } + auto arr = v8::Array::New(isolate, children.size()); + for (size_t i = 0; i < children.size(); i++) { + arr->Set(ctx, i, WrapNode(children[i])).Check(); + } + info.GetReturnValue().Set(arr); + return; + } + + // In line-info mode, leaf nodes (hitCount > 0) have no children. + if (fields.as_info()->hit_count > 0) { + info.GetReturnValue().Set(v8::Array::New(isolate, 0)); + return; + } + + AppendLineChildren(fields.state, fields.as_info()->node, children); + auto arr = v8::Array::New(isolate, children.size()); + for (size_t i = 0; i < children.size(); i++) { + arr->Set(ctx, i, WrapNode(children[i])).Check(); + } + info.GetReturnValue().Set(arr); + } else { + int32_t count = fields.as_node()->GetChildrenCount(); + auto arr = v8::Array::New(isolate, count); + for (int32_t i = 0; i < count; i++) { + arr->Set(ctx, i, WrapNode(fields.as_node()->GetChild(i), fields.state)) + .Check(); + } + info.GetReturnValue().Set(arr); + } +} + class TimeProfileTranslator : ProfileTranslator { private: ContextsByNode* contextsByNode; @@ -236,6 +492,77 @@ class TimeProfileTranslator : ProfileTranslator { }; } // namespace +NAN_MODULE_INIT(TimeProfileNodeView::Init) { + v8::Local tpl = Nan::New(); + tpl->SetClassName(Nan::New("TimeProfileNode").ToLocalChecked()); + tpl->InstanceTemplate()->SetInternalFieldCount(2); + + auto inst = tpl->InstanceTemplate(); + Nan::SetAccessor(inst, Nan::New("name").ToLocalChecked(), GetName); + Nan::SetAccessor( + inst, Nan::New("scriptName").ToLocalChecked(), GetScriptName); + Nan::SetAccessor(inst, Nan::New("scriptId").ToLocalChecked(), GetScriptId); + Nan::SetAccessor( + inst, Nan::New("lineNumber").ToLocalChecked(), GetLineNumber); + Nan::SetAccessor( + inst, Nan::New("columnNumber").ToLocalChecked(), GetColumnNumber); + Nan::SetAccessor(inst, Nan::New("hitCount").ToLocalChecked(), GetHitCount); + Nan::SetAccessor(inst, Nan::New("children").ToLocalChecked(), GetChildren); + Nan::SetAccessor(inst, Nan::New("contexts").ToLocalChecked(), GetContexts); + + PerIsolateData::For(v8::Isolate::GetCurrent()) + ->TimeProfileNodeConstructor() + .Reset(Nan::GetFunction(tpl).ToLocalChecked()); +} + +// Builds a lazy JS profile view. +// For non-line-info, wrappers store raw CpuProfileNode pointers. +// For line-info, owned TimeProfileNodeInfo structs are used for synthetic +// nodes. +v8::Local BuildTimeProfileView(const v8::CpuProfile* profile, + bool has_cpu_time, + int64_t non_js_threads_cpu_time, + TimeProfileViewState& state) { + auto* isolate = v8::Isolate::GetCurrent(); + v8::Local js_profile = v8::Object::New(isolate); + + auto* root_node = profile->GetTopDownRoot(); + + if (state.include_line_info) { + auto* root_info = AllocNode(&state, + root_node, + root_node, + root_node->GetLineNumber(), + root_node->GetColumnNumber(), + 0, + true); + auto root = WrapNode(root_info); + + Nan::Set(js_profile, Nan::New("topDownRoot").ToLocalChecked(), root); + } else { + Nan::Set(js_profile, + Nan::New("topDownRoot").ToLocalChecked(), + WrapNode(root_node, &state)); + } + Nan::Set(js_profile, + Nan::New("startTime").ToLocalChecked(), + Nan::New(profile->GetStartTime())); + Nan::Set(js_profile, + Nan::New("endTime").ToLocalChecked(), + Nan::New(profile->GetEndTime())); + Nan::Set(js_profile, + Nan::New("hasCpuTime").ToLocalChecked(), + Nan::New(has_cpu_time)); + Nan::Set(js_profile, + Nan::New("nonJSThreadsCpuTime").ToLocalChecked(), + Nan::New(non_js_threads_cpu_time)); + Nan::Set(js_profile, + Nan::New("totalHitCount").ToLocalChecked(), + Nan::New( + ComputeTotalHitCount(root_node, state.contexts_by_node))); + return js_profile; +} + v8::Local TranslateTimeProfile(const v8::CpuProfile* profile, bool includeLineInfo, ContextsByNode* contextsByNode, diff --git a/bindings/translate-time-profile.hh b/bindings/translate-time-profile.hh index c83115d5..0f6494cd 100644 --- a/bindings/translate-time-profile.hh +++ b/bindings/translate-time-profile.hh @@ -16,11 +16,51 @@ #pragma once +#include #include +#include +#include +#include #include "contexts.hh" namespace dd { +struct TimeProfileNodeInfo; + +// Shared state for the lazy profile view. +// In line-info mode, owned_nodes keeps synthetic TimeProfileNodeInfo objects +// alive for as long as JS wrappers may reference them. +// In normal mode, owned_nodes stays empty - wrappers point directly to V8 +// nodes. +struct TimeProfileViewState { + bool include_line_info; + ContextsByNode* contexts_by_node; + std::vector> owned_nodes; +}; + +// Line-info mode only: stored in internal field 0 of JS wrappers. +// Needed because line-info nodes have line/column/hitCount values +// and a metadata_node that differs from the traversal node. +struct TimeProfileNodeInfo { + const v8::CpuProfileNode* node; + const v8::CpuProfileNode* metadata_node; + int line_number; + int column_number; + int hit_count; + bool is_line_root; + TimeProfileViewState* state; +}; + +class TimeProfileNodeView { + public: + static NAN_MODULE_INIT(Init); +}; + +v8::Local BuildTimeProfileView(const v8::CpuProfile* profile, + bool has_cpu_time, + int64_t non_js_threads_cpu_time, + TimeProfileViewState& state); + v8::Local TranslateTimeProfile( const v8::CpuProfile* profile, bool includeLineInfo, diff --git a/ts/src/index.ts b/ts/src/index.ts index 92a7ec5c..81cda61d 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -34,6 +34,7 @@ export const time = { profile: timeProfiler.profile, start: timeProfiler.start, stop: timeProfiler.stop, + profileV2: timeProfiler.profileV2, getContext: timeProfiler.getContext, setContext: timeProfiler.setContext, runWithContext: timeProfiler.runWithContext, diff --git a/ts/src/profile-serializer.ts b/ts/src/profile-serializer.ts index 7566beef..7b3f0ec9 100644 --- a/ts/src/profile-serializer.ts +++ b/ts/src/profile-serializer.ts @@ -259,7 +259,7 @@ function createAllocationValueType(table: StringTable): ValueType { }); } -function computeTotalHitCount(root: TimeProfileNode): number { +export function computeTotalHitCount(root: TimeProfileNode): number { return ( root.hitCount + (root.children as TimeProfileNode[]).reduce( @@ -366,7 +366,8 @@ export function serializeTimeProfile( // For very short durations, computation becomes meaningless (eg. if there is only one hit), // therefore keep intervalMicros as a lower bound and 2 * intervalMicros as upper bound. if (recomputeSamplingInterval) { - const totalHitCount = computeTotalHitCount(prof.topDownRoot); + const totalHitCount = + prof.totalHitCount ?? computeTotalHitCount(prof.topDownRoot); if (totalHitCount > 0) { intervalMicros = Math.min( Math.max( diff --git a/ts/src/time-profiler.ts b/ts/src/time-profiler.ts index 42d2c1a8..ed2fb4df 100644 --- a/ts/src/time-profiler.ts +++ b/ts/src/time-profiler.ts @@ -27,7 +27,11 @@ import { getNativeThreadId, constants as profilerConstants, } from './time-profiler-bindings'; -import {GenerateTimeLabelsFunction, TimeProfilerMetrics} from './v8-types'; +import { + GenerateTimeLabelsFunction, + TimeProfile, + TimeProfilerMetrics, +} from './v8-types'; import {isMainThread} from 'worker_threads'; import {AsyncLocalStorage} from 'async_hooks'; const {kSampleCount} = profilerConstants; @@ -38,12 +42,45 @@ const DEFAULT_DURATION_MILLIS: Milliseconds = 60000; type Microseconds = number; type Milliseconds = number; -let gProfiler: InstanceType | undefined; +type NativeTimeProfiler = InstanceType & { + stopAndCollect?: ( + restart: boolean, + callback: (profile: TimeProfile) => T, + ) => T; +}; + +let gProfiler: NativeTimeProfiler | undefined; let gStore: AsyncLocalStorage | undefined; let gSourceMapper: SourceMapper | undefined; let gIntervalMicros: Microseconds; let gV8ProfilerStuckEventLoopDetected = 0; +function handleStopRestart() { + if (!gProfiler) { + return; + } + gV8ProfilerStuckEventLoopDetected = + gProfiler.v8ProfilerStuckEventLoopDetected(); + // Workaround for v8 bug, where profiler event processor thread is stuck in + // a loop eating 100% CPU, leading to empty profiles. + // Fully stop and restart the profiler to reset the profile to a valid state. + if (gV8ProfilerStuckEventLoopDetected > 0) { + gProfiler.stop(false); + gProfiler.start(); + } +} + +function handleStopNoRestart() { + gV8ProfilerStuckEventLoopDetected = 0; + gProfiler?.dispose(); + gProfiler = undefined; + gSourceMapper = undefined; + if (gStore !== undefined) { + gStore.disable(); + gStore = undefined; + } +} + /** Make sure to stop profiler before node shuts down, otherwise profiling * signal might cause a crash if it occurs during shutdown */ process.once('exit', () => { @@ -89,6 +126,13 @@ export async function profile(options: TimeProfilerOptions = {}) { return stop(); } +export async function profileV2(options: TimeProfilerOptions = {}) { + options = {...DEFAULT_OPTIONS, ...options}; + start(options); + await setTimeout(options.durationMillis!); + return stopV2(); +} + // Temporarily retained for backwards compatibility with older tracer export function start(options: TimeProfilerOptions = {}) { options = {...DEFAULT_OPTIONS, ...options}; @@ -122,17 +166,9 @@ export function stop( const profile = gProfiler.stop(restart); if (restart) { - gV8ProfilerStuckEventLoopDetected = - gProfiler.v8ProfilerStuckEventLoopDetected(); - // Workaround for v8 bug, where profiler event processor thread is stuck in - // a loop eating 100% CPU, leading to empty profiles. - // Fully stop and restart the profiler to reset the profile to a valid state. - if (gV8ProfilerStuckEventLoopDetected > 0) { - gProfiler.stop(false); - gProfiler.start(); - } + handleStopRestart(); } else { - gV8ProfilerStuckEventLoopDetected = 0; + handleStopNoRestart(); } const serializedProfile = serializeTimeProfile( @@ -143,14 +179,39 @@ export function stop( generateLabels, lowCardinalityLabels, ); - if (!restart) { - gProfiler.dispose(); - gProfiler = undefined; - gSourceMapper = undefined; - if (gStore !== undefined) { - gStore.disable(); - gStore = undefined; - } + return serializedProfile; +} + +/** + * Same as stop() but uses the lazy callback path: serialization happens inside + * a native callback while the V8 profile is still alive. + * This reduces memory overhead. + */ +export function stopV2( + restart = false, + generateLabels?: GenerateTimeLabelsFunction, + lowCardinalityLabels?: string[], +) { + if (!gProfiler) { + throw new Error('Wall profiler is not started'); + } + + const serializedProfile = gProfiler.stopAndCollect( + restart, + (profile: TimeProfile) => + serializeTimeProfile( + profile, + gIntervalMicros, + gSourceMapper, + true, + generateLabels, + lowCardinalityLabels, + ), + ); + if (restart) { + handleStopRestart(); + } else { + handleStopNoRestart(); } return serializedProfile; } diff --git a/ts/src/v8-types.ts b/ts/src/v8-types.ts index 1becf7d2..87fbc333 100644 --- a/ts/src/v8-types.ts +++ b/ts/src/v8-types.ts @@ -25,6 +25,8 @@ export interface TimeProfile { hasCpuTime?: boolean; /** CPU time of non-JS threads, only reported for the main worker thread */ nonJSThreadsCpuTime?: number; + /** Computed in C++ to avoid a full JS tree traversal. */ + totalHitCount?: number; } export interface ProfileNode { diff --git a/ts/test/test-time-profiler.ts b/ts/test/test-time-profiler.ts index 559247f1..87c8e4a5 100644 --- a/ts/test/test-time-profiler.ts +++ b/ts/test/test-time-profiler.ts @@ -16,6 +16,7 @@ import * as sinon from 'sinon'; import {time, getNativeThreadId} from '../src'; +import {profileV2, stopV2} from '../src/time-profiler'; import * as v8TimeProfiler from '../src/time-profiler-bindings'; import {timeProfile, v8TimeProfile} from './profiles-for-tests'; import {hrtime} from 'process'; @@ -24,6 +25,7 @@ import {AssertionError} from 'assert'; import {GenerateTimeLabelsArgs, LabelSet} from '../src/v8-types'; import {satisfies} from 'semver'; import {setTimeout as setTimeoutPromise} from 'timers/promises'; +import {fork} from 'child_process'; import assert from 'assert'; @@ -495,6 +497,145 @@ describe('Time Profiler', () => { }); }); + describe('profileV2', () => { + it('should exclude program and idle time', async () => { + const profile = await time.profileV2(PROFILE_OPTIONS); + assert.ok(profile.stringTable); + assert.equal(profile.stringTable.strings!.indexOf('(program)'), -1); + }); + + it('should preserve line-number root children metadata in lazy view', function () { + if (unsupportedPlatform) { + this.skip(); + } + + function hotPath() { + const end = hrtime.bigint() + 2_000_000n; + while (hrtime.bigint() < end); + } + + const profiler = new v8TimeProfiler.TimeProfiler({ + intervalMicros: 100, + durationMillis: 200, + lineNumbers: true, + withContexts: false, + workaroundV8Bug: false, + collectCpuTime: false, + collectAsyncId: false, + useCPED: false, + isMainThread: true, + }); + + profiler.start(); + try { + const deadline = Date.now() + 200; + while (Date.now() < deadline) { + hotPath(); + } + + let sawRootChildren = false; + let sawChildWithNonRootMetadata = false; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + profiler.stopAndCollect(false, (profile: any) => { + const root = profile.topDownRoot as { + name: string; + scriptName: string; + scriptId: number; + children: Array<{ + name: string; + scriptName: string; + scriptId: number; + }>; + }; + const children = root.children; + + sawRootChildren = children.length > 0; + sawChildWithNonRootMetadata = children.some( + child => + child.name !== root.name || + child.scriptName !== root.scriptName || + child.scriptId !== root.scriptId, + ); + return undefined; + }); + + assert(sawRootChildren, 'Expected root to have children'); + assert( + sawChildWithNonRootMetadata, + 'Line-number lazy root children should not collapse to root metadata', + ); + } finally { + profiler.dispose(); + } + }); + }); + + describe('profileV2 (w/ stubs)', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sinonStubs: Array> = []; + const timeProfilerStub = { + start: sinon.stub(), + // stopAndCollect invokes the callback synchronously with the raw profile, + // mirroring what the native binding does. + stopAndCollect: sinon + .stub() + .callsFake( + (_restart: boolean, cb: (p: typeof v8TimeProfile) => unknown) => + cb(v8TimeProfile), + ), + dispose: sinon.stub(), + v8ProfilerStuckEventLoopDetected: sinon.stub().returns(0), + }; + + before(() => { + sinonStubs.push( + sinon.stub(v8TimeProfiler, 'TimeProfiler').returns(timeProfilerStub), + ); + sinonStubs.push(sinon.stub(Date, 'now').returns(0)); + }); + + after(() => { + sinonStubs.forEach(stub => stub.restore()); + }); + + it('should profile during duration and finish profiling after duration', async () => { + let isProfiling = true; + void profileV2(PROFILE_OPTIONS).then(() => { + isProfiling = false; + }); + await setTimeoutPromise(2 * PROFILE_OPTIONS.durationMillis); + assert.strictEqual(false, isProfiling, 'profiler is still running'); + }); + + it('should return a profile equal to the expected profile', async () => { + const profile = await profileV2(PROFILE_OPTIONS); + assert.deepEqual(timeProfile, profile); + }); + + it('should be able to restart when stopping', async () => { + time.start({intervalMicros: PROFILE_OPTIONS.intervalMicros}); + timeProfilerStub.start.resetHistory(); + timeProfilerStub.stopAndCollect.resetHistory(); + + assert.deepEqual(timeProfile, stopV2(true)); + assert.equal( + time.v8ProfilerStuckEventLoopDetected(), + 0, + 'v8 bug detected', + ); + sinon.assert.notCalled(timeProfilerStub.start); + sinon.assert.calledOnce(timeProfilerStub.stopAndCollect); + + timeProfilerStub.start.resetHistory(); + timeProfilerStub.stopAndCollect.resetHistory(); + + assert.deepEqual(timeProfile, stopV2()); + sinon.assert.notCalled(timeProfilerStub.start); + sinon.assert.calledOnce(timeProfilerStub.stopAndCollect); + }); + }); + describe('v8BugWorkaround (w/ stubs)', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const sinonStubs: Array> = []; @@ -723,6 +864,50 @@ describe('Time Profiler', () => { }); }); + describe('Memory comparison', () => { + interface WorkerMemoryResult { + initial: number; + afterTraversal: number; + afterHitCount: number; + } + + function measureMemoryInWorker( + version: 'v1' | 'v2', + ): Promise { + return new Promise((resolve, reject) => { + const child = fork('./out/test/time-memory-worker.js', [], { + execArgv: ['--expose-gc'], + }); + + child.on('message', (result: WorkerMemoryResult) => { + resolve(result); + child.kill(); + }); + + child.on('error', reject); + child.send(version); + }); + } + + it('stopAndCollect should use less memory than stop when profile is large', async function () { + if (unsupportedPlatform) { + this.skip(); + } + + const v1 = await measureMemoryInWorker('v1'); + const v2 = await measureMemoryInWorker('v2'); + + console.log('v1 : ', v1.initial, v1.afterTraversal, v1.afterHitCount); + console.log('v2 : ', v2.initial, v2.afterTraversal, v2.afterHitCount); + + // V2 creates almost nothing upfront — lazy wrappers vs full eager tree. + assert.ok( + v2.initial < v1.initial, + `V2 initial should be less: V1=${v1.initial}, V2=${v2.initial}`, + ); + }).timeout(120_000); + }); + describe('getNativeThreadId', () => { it('should return a number', () => { const threadId = getNativeThreadId(); diff --git a/ts/test/time-memory-worker.ts b/ts/test/time-memory-worker.ts new file mode 100644 index 00000000..06de395f --- /dev/null +++ b/ts/test/time-memory-worker.ts @@ -0,0 +1,153 @@ +import {TimeProfiler} from '../src/time-profiler-bindings'; +import {ProfileNode, TimeProfile, TimeProfileNode} from '../src/v8-types'; +import {computeTotalHitCount} from '../src/profile-serializer'; + +const gc = (global as NodeJS.Global & {gc?: () => void}).gc; +if (!gc) { + throw new Error('Run with --expose-gc flag'); +} + +const SCRIPT_PADDING = 'a'.repeat(250); + +function createUniqueFunctions(count: number): Array<() => void> { + const fns: Array<() => void> = []; + for (let i = 0; i < count; i++) { + const fn = new Function( + `//# sourceURL=wide_fn_${i}_${SCRIPT_PADDING}.js\n` + + `var x${i}=0,e${i}=Date.now()+1;while(Date.now() void; + fns.push(fn); + } + return fns; +} + +function createDeepCallChain(chainId: number, depth: number): () => void { + let innermost: (() => void) | null = null; + for (let i = depth - 1; i >= 0; i--) { + const next = innermost; + innermost = new Function( + 'next', + `//# sourceURL=chain_${chainId}_d${i}_${SCRIPT_PADDING}.js\n` + + 'var c=0,e=Date.now()+1;while(Date.now() void; + } + return innermost!; +} + +const CHAIN_STRIDE = 30; + +function generateCpuWork( + wideFns: Array<() => void>, + deepChains: Array<() => void>, + durationMs: number, +): void { + const deadline = Date.now() + durationMs; + let i = 0; + while (Date.now() < deadline) { + wideFns[i % wideFns.length](); + if (i % CHAIN_STRIDE === 0) { + deepChains[(i / CHAIN_STRIDE) % deepChains.length](); + } + i++; + } +} + +const WIDE_FN_COUNT = 5000; +const CHAIN_COUNT = 100; +const CHAIN_DEPTH = 60; + +const PROFILER_OPTIONS = { + intervalMicros: 50, + durationMillis: 20_000, + lineNumbers: true, + withContexts: false, + workaroundV8Bug: false, + collectCpuTime: false, + collectAsyncId: false, + useCPED: false, + isMainThread: true, +}; + +function buildWorkload() { + const wideFns = createUniqueFunctions(WIDE_FN_COUNT); + const deepChains: Array<() => void> = []; + for (let c = 0; c < CHAIN_COUNT; c++) { + deepChains.push(createDeepCallChain(c, CHAIN_DEPTH)); + } + return {wideFns, deepChains}; +} + +function traverseTree(root: TimeProfileNode): void { + const stack: ProfileNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + for (const child of node.children) { + stack.push(child); + } + } +} + +interface MemoryResult { + initial: number; + afterTraversal: number; + afterHitCount: number; +} + +function measureV1(): MemoryResult { + const {wideFns, deepChains} = buildWorkload(); + const profiler = new TimeProfiler(PROFILER_OPTIONS); + profiler.start(); + generateCpuWork(wideFns, deepChains, PROFILER_OPTIONS.durationMillis); + + gc!(); + const baseline = process.memoryUsage().heapUsed; + + const profile: TimeProfile = profiler.stop(false); + const initial = process.memoryUsage().heapUsed - baseline; + + traverseTree(profile.topDownRoot); + const afterTraversal = process.memoryUsage().heapUsed - baseline; + + // V1: computeTotalHitCount triggers children getters on every node, + // creating JS wrapper objects for a second full tree traversal. + computeTotalHitCount(profile.topDownRoot); + const afterHitCount = process.memoryUsage().heapUsed - baseline; + + profiler.dispose(); + return {initial, afterTraversal, afterHitCount}; +} + +function measureV2(): MemoryResult { + const {wideFns, deepChains} = buildWorkload(); + const profiler = new TimeProfiler(PROFILER_OPTIONS); + profiler.start(); + generateCpuWork(wideFns, deepChains, PROFILER_OPTIONS.durationMillis); + + gc!(); + const baseline = process.memoryUsage().heapUsed; + + const result = profiler.stopAndCollect( + false, + (profile: TimeProfile): MemoryResult => { + const initial = process.memoryUsage().heapUsed - baseline; + + traverseTree(profile.topDownRoot); + const afterTraversal = process.memoryUsage().heapUsed - baseline; + + // V2: totalHitCount is pre-computed in C++ — just a property read, + // no JS tree traversal, no wrapper objects created. + void profile.totalHitCount; + const afterHitCount = process.memoryUsage().heapUsed - baseline; + + return {initial, afterTraversal, afterHitCount}; + }, + ); + + profiler.dispose(); + return result; +} + +process.on('message', (version: 'v1' | 'v2') => { + const result = version === 'v1' ? measureV1() : measureV2(); + process.send!(result); +}); From 376c0d3a7a4d321fcb03fae2e427aa02b2f84cb4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:11:03 +0100 Subject: [PATCH 09/16] build(deps-dev): bump sinon from 21.0.1 to 21.0.2 (#296) Bumps [sinon](https://github.com/sinonjs/sinon) from 21.0.1 to 21.0.2. - [Release notes](https://github.com/sinonjs/sinon/releases) - [Changelog](https://github.com/sinonjs/sinon/blob/main/docs/changelog.md) - [Commits](https://github.com/sinonjs/sinon/compare/v21.0.1...v21.0.2) --- updated-dependencies: - dependency-name: sinon dependency-version: 21.0.2 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 26 +++++++++++++------------- package.json | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index b0282fee..2d983069 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "nan": "^2.23.1", "nyc": "^18.0.0", "semver": "^7.7.4", - "sinon": "^21.0.1", + "sinon": "^21.0.2", "source-map-support": "^0.5.21", "tmp": "0.2.5", "typescript": "^5.9.3" @@ -820,9 +820,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.0.tgz", - "integrity": "sha512-cqfapCxwTGsrR80FEgOoPsTonoefMBY7dnUEbQ+GRcved0jvkJLzvX6F4WtN+HBqbPX/SiFsIRUp+IrCW/2I2w==", + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.1.tgz", + "integrity": "sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -830,9 +830,9 @@ } }, "node_modules/@sinonjs/samsam": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", - "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-9.0.2.tgz", + "integrity": "sha512-H/JSxa4GNKZuuU41E3b8Y3tbSEx8y4uq4UH1C56ONQac16HblReJomIvv3Ud7ANQHQmkeSowY49Ij972e/pGxQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5404,16 +5404,16 @@ "license": "ISC" }, "node_modules/sinon": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.1.tgz", - "integrity": "sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw==", + "version": "21.0.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.2.tgz", + "integrity": "sha512-VHV4UaoxIe5jrMd89Y9duI76T5g3Lp+ET+ctLhLDaZtSznDPah1KKpRElbdBV4RwqWSw2vadFiVs9Del7MbVeQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^15.1.0", - "@sinonjs/samsam": "^8.0.3", - "diff": "^8.0.2", + "@sinonjs/fake-timers": "^15.1.1", + "@sinonjs/samsam": "^9.0.2", + "diff": "^8.0.3", "supports-color": "^7.2.0" }, "funding": { diff --git a/package.json b/package.json index a88d11f8..6fdd821e 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "nan": "^2.23.1", "nyc": "^18.0.0", "semver": "^7.7.4", - "sinon": "^21.0.1", + "sinon": "^21.0.2", "source-map-support": "^0.5.21", "tmp": "0.2.5", "typescript": "^5.9.3" From 62e037333cff1d7df602b2f9cd12ad644507046a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:16:35 +0000 Subject: [PATCH 10/16] build(deps-dev): bump @types/node from 25.3.3 to 25.4.0 (#297) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.3.3 to 25.4.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 25.4.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Attila Szegedi --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2d983069..fea3b846 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ }, "devDependencies": { "@types/mocha": "^10.0.1", - "@types/node": "25.3.3", + "@types/node": "25.4.0", "@types/semver": "^7.5.8", "@types/sinon": "^21.0.0", "@types/tmp": "^0.2.3", @@ -932,9 +932,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", - "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "version": "25.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz", + "integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 6fdd821e..9d0b9d08 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ }, "devDependencies": { "@types/mocha": "^10.0.1", - "@types/node": "25.3.3", + "@types/node": "25.4.0", "@types/semver": "^7.5.8", "@types/sinon": "^21.0.0", "@types/tmp": "^0.2.3", From 310d59de4dd3014e799b98bb77470d2fb6cc4be7 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 12 Mar 2026 16:07:41 +0100 Subject: [PATCH 11/16] Rewrite SourceMapper to scan directories with sourceMappingURL support (#292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rewrite SourceMapper to use eager directory scan with sourceMappingURL support Replace `SourceMapper.create(searchDirs[])` with a two-part API: - `new SourceMapper()` constructs an empty mapper synchronously - `await sm.loadDirectory(dir)` populates it asynchronously - SourceMapper.create() is kept as backwards-compatible delegate This separates construction from loading, allowing callers to fire off the async scan without blocking profiler initialization. In production the scan is fire-and-forget (completes well before the first profile is taken); in tests it is awaited directly. The directory scan now uses a two-phase approach per JS file: Phase 1 (higher priority): reads each .js/.cjs/.mjs file and checks for a `sourceMappingURL` annotation (per TC39 ECMA-426). Inline `data:application/json;base64,` URLs are decoded in-memory; external file URLs are loaded from disk if the file exists. The implementation first attempts to read only a 4k tail of the file, and if it is not sufficient falls back to reading the entire file. Phase 2 (fallback): processes .map files found in the directory using the original logic (file property → naming convention). Skips any JS file that Phase 1 already resolved. processSourceMap is refactored to parse just the `file` JSON property before creating the SourceMapConsumer, so we can bail out early (skipping consumer creation) if the JS file was already loaded in Phase 1. Co-Authored-By: Claude Sonnet 4.6 --- ts/src/sourcemapper/sourcemapper.ts | 351 +++++++++++++++++++++------- ts/test/test-profile-serializer.ts | 3 +- ts/test/test-sourcemapper.ts | 274 ++++++++++++++++++++++ 3 files changed, 537 insertions(+), 91 deletions(-) create mode 100644 ts/test/test-sourcemapper.ts diff --git a/ts/src/sourcemapper/sourcemapper.ts b/ts/src/sourcemapper/sourcemapper.ts index 4e94446d..91342a57 100644 --- a/ts/src/sourcemapper/sourcemapper.ts +++ b/ts/src/sourcemapper/sourcemapper.ts @@ -49,6 +49,96 @@ function createLimiter(concurrency: number) { } const MAP_EXT = '.map'; +// Per TC39 ECMA-426 §11.1.2.1 JavaScriptExtractSourceMapURL (without parsing): +// https://tc39.es/ecma426/#sec-linking-inline +// +// Split on these line terminators (ECMA-262 LineTerminatorSequence): +const LINE_SPLIT_RE = /\r\n|\n|\r|\u2028|\u2029/; +// Bytes to read from the end of a JS file when scanning for the annotation. +// The annotation must be on the last non-empty line, which is always short +// for external URLs. If no line terminator appears in the tail we fall back +// to a full file read (handles very large inline data: maps). +export const ANNOTATION_TAIL_BYTES = 4 * 1024; +// Quote code points that invalidate the annotation (U+0022, U+0027, U+0060): +const QUOTE_CHARS_RE = /["'`]/; +// MatchSourceMapURL pattern applied to the comment text that follows "//": +const MATCH_SOURCE_MAP_URL_RE = /^[@#]\s*sourceMappingURL=(\S*?)\s*$/; + +/** + * Extracts a sourceMappingURL from JS source per ECMA-426 §11.1.2.1 + * (without-parsing variant). + * + * Scans lines from the end, skipping empty/whitespace-only lines. + * Returns null as soon as the first non-empty line is found that does not + * carry a valid annotation — the URL must be on the last non-empty line. + */ +export function extractSourceMappingURL(content: string): string | undefined { + const lines = content.split(LINE_SPLIT_RE); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (line.trim() === '') continue; // skip empty / whitespace-only lines + + // This is the last non-empty line; it must carry the annotation or we stop. + const commentStart = line.indexOf('//'); + if (commentStart === -1) return undefined; + + const comment = line.slice(commentStart + 2); + if (QUOTE_CHARS_RE.test(comment)) return undefined; + + const match = MATCH_SOURCE_MAP_URL_RE.exec(comment); + return match ? match[1] || undefined : undefined; + } + return undefined; +} + +/** + * Reads the sourceMappingURL from a JS file efficiently by only reading a + * small tail of the file. + * + * The annotation must be on the last non-empty line (ECMA-426), so as long as + * the tail contains at least one line terminator the last line is fully + * captured. If no line terminator appears in the tail the entire tail is part + * of one very long inline data: line; we fall back to a full file read in + * that case. + */ +export async function readSourceMappingURL( + filePath: string, +): Promise { + const fd = await fs.promises.open(filePath, 'r'); + try { + const {size} = await fd.stat(); + if (size === 0) return undefined; + + const tailSize = Math.min(ANNOTATION_TAIL_BYTES, size); + const buf = Buffer.allocUnsafe(tailSize); + await fd.read(buf, 0, tailSize, size - tailSize); + const tail = buf.toString('utf8'); + + // The last non-empty line is fully captured in the tail if and only if a + // line terminator that precedes it also falls within the tail — i.e. the + // last non-empty segment is not the very first element of the split result. + // + // Counter-example: a large inline map followed by trailing empty lines. + // The tail might be "\n\n", which contains line terminators + // but whose last non-empty content ("") is the first + // segment — it extends before the window. Checking LINE_TERM_RE alone + // would incorrectly accept this tail. + const lines = tail.split(LINE_SPLIT_RE); + let lastNonEmptyIdx = lines.length - 1; + while (lastNonEmptyIdx > 0 && lines[lastNonEmptyIdx].trim() === '') { + lastNonEmptyIdx--; + } + if (tailSize === size || lastNonEmptyIdx > 0) { + return extractSourceMappingURL(tail); + } + + const fullContent = await readFile(filePath, 'utf8'); + return extractSourceMappingURL(fullContent); + } finally { + await fd.close(); + } +} + function error(msg: string) { logger.debug(`Error: ${msg}`); return new Error(msg); @@ -99,27 +189,6 @@ async function processSourceMap( throw error('Could not read source map file ' + mapPath + ': ' + e); } - let consumer: sourceMap.RawSourceMap; - try { - // TODO: Determine how to reconsile the type conflict where `consumer` - // is constructed as a SourceMapConsumer but is used as a - // RawSourceMap. - // TODO: Resolve the cast of `contents as any` (This is needed because the - // type is expected to be of `RawSourceMap` but the existing - // working code uses a string.) - consumer = (await new sourceMap.SourceMapConsumer( - contents as {} as sourceMap.RawSourceMap, - )) as {} as sourceMap.RawSourceMap; - } catch (e) { - throw error( - 'An error occurred while reading the ' + - 'sourceMap file ' + - mapPath + - ': ' + - e, - ); - } - /* If the source map file defines a "file" attribute, use it as * the output file where the path is relative to the directory * containing the map file. Otherwise, use the name of the output @@ -137,9 +206,22 @@ async function processSourceMap( * source map file. */ const dir = path.dirname(mapPath); - const generatedPathCandidates = []; - if (consumer.file) { - generatedPathCandidates.push(path.resolve(dir, consumer.file)); + + // Parse JSON once: extract the `file` property for early-exit checks and + // reuse the parsed object when constructing SourceMapConsumer (avoids a + // second parse inside the library). + let parsedMap: sourceMap.RawSourceMap | undefined; + let rawFile: string | undefined; + try { + parsedMap = JSON.parse(contents) as sourceMap.RawSourceMap; + rawFile = parsedMap.file; + } catch { + // Will fail again below when creating SourceMapConsumer; let that throw. + } + + const generatedPathCandidates: string[] = []; + if (rawFile) { + generatedPathCandidates.push(path.resolve(dir, rawFile)); } const samePath = path.resolve(dir, path.basename(mapPath, MAP_EXT)); if ( @@ -149,22 +231,57 @@ async function processSourceMap( generatedPathCandidates.push(samePath); } - for (const generatedPath of generatedPathCandidates) { - try { - await fs.promises.access(generatedPath, fs.constants.F_OK); - infoMap.set(generatedPath, {mapFileDir: dir, mapConsumer: consumer}); + // Find the first candidate that exists and hasn't been loaded already. + let targetPath: string | undefined; + for (const candidate of generatedPathCandidates) { + if (infoMap.has(candidate)) { + // Already loaded via sourceMappingURL annotation; skip this map file. if (debug) { - logger.debug(`Loaded source map for ${generatedPath} => ${mapPath}`); + logger.debug( + `Skipping ${mapPath}: ${candidate} already loaded via sourceMappingURL`, + ); } return; + } + try { + await fs.promises.access(candidate, fs.constants.F_OK); + targetPath = candidate; + break; } catch { if (debug) { - logger.debug(`Generated path ${generatedPath} does not exist`); + logger.debug(`Generated path ${candidate} does not exist`); } } } + + if (!targetPath) { + if (debug) { + logger.debug(`Unable to find generated file for ${mapPath}`); + } + return; + } + + let consumer: sourceMap.RawSourceMap; + try { + // TODO: Determine how to reconsile the type conflict where `consumer` + // is constructed as a SourceMapConsumer but is used as a + // RawSourceMap. + consumer = (await new sourceMap.SourceMapConsumer( + (parsedMap ?? contents) as {} as sourceMap.RawSourceMap, + )) as {} as sourceMap.RawSourceMap; + } catch (e) { + throw error( + 'An error occurred while reading the ' + + 'sourceMap file ' + + mapPath + + ': ' + + e, + ); + } + + infoMap.set(targetPath, {mapFileDir: dir, mapConsumer: consumer}); if (debug) { - logger.debug(`Unable to find generated file for ${mapPath}`); + logger.debug(`Loaded source map for ${targetPath} => ${mapPath}`); } } @@ -176,41 +293,129 @@ export class SourceMapper { searchDirs: string[], debug = false, ): Promise { - if (debug) { - logger.debug( - `Looking for source map files in dirs: [${searchDirs.join(', ')}]`, - ); - } - const mapFiles: string[] = []; + const mapper = new SourceMapper(debug); for (const dir of searchDirs) { - try { - const mf = await getMapFiles(dir); - mf.forEach(mapFile => { - mapFiles.push(path.resolve(dir, mapFile)); - }); - } catch (e) { - throw error(`failed to get source maps from ${dir}: ${e}`); - } - } - if (debug) { - logger.debug(`Found source map files: [${mapFiles.join(', ')}]`); + await mapper.loadDirectory(dir); } - return createFromMapFiles(mapFiles, debug); + return mapper; } - /** - * @param {Array.} sourceMapPaths An array of paths to .map source map - * files that should be processed. The paths should be relative to the - * current process's current working directory - * @param {Logger} logger A logger that reports errors that occurred while - * processing the given source map files - * @constructor - */ constructor(debug = false) { this.infoMap = new Map(); this.debug = debug; } + /** + * Scans `searchDir` recursively for JS files and source map files, loading + * source maps for all JS files found. + * + * Priority for each JS file: + * 1. A map pointed to by a `sourceMappingURL` annotation in the JS file + * (inline `data:` URL or external file path, only if the file exists). + * 2. A `.map` file found in the directory scan that claims to belong to + * that JS file (via its `file` property or naming convention). + * + * Safe to call multiple times; already-loaded files are skipped. + */ + async loadDirectory(searchDir: string): Promise { + // Resolve to absolute so all paths in infoMap are consistent regardless of + // whether the caller passed a relative or absolute directory. + searchDir = path.resolve(searchDir); + + if (this.debug) { + logger.debug(`Loading source maps from directory: ${searchDir}`); + } + + const jsFiles: string[] = []; + const mapFiles: string[] = []; + + for await (const entry of walk( + searchDir, + filename => + /\.[cm]?js$/.test(filename) || /\.[cm]?js\.map$/.test(filename), + (root, dirname) => + root !== '/proc' && dirname !== '.git' && dirname !== 'node_modules', + )) { + if (entry.endsWith(MAP_EXT)) { + mapFiles.push(entry); + } else { + jsFiles.push(entry); + } + } + + if (this.debug) { + logger.debug( + `Found ${jsFiles.length} JS files and ${mapFiles.length} map files in ${searchDir}`, + ); + } + + const limit = createLimiter(CONCURRENCY); + + // Phase 1: Check sourceMappingURL annotations in JS files (higher priority). + await Promise.all( + jsFiles.map(jsPath => + limit(async () => { + if (this.infoMap.has(jsPath)) return; + + let url: string | undefined; + try { + url = await readSourceMappingURL(jsPath); + } catch { + return; + } + if (!url) return; + + const INLINE_PREFIX = 'data:application/json;base64,'; + if (url.startsWith(INLINE_PREFIX)) { + const mapContent = Buffer.from( + url.slice(INLINE_PREFIX.length), + 'base64', + ).toString(); + await this.loadMapContent(jsPath, mapContent, path.dirname(jsPath)); + } else { + const mapPath = path.resolve(path.dirname(jsPath), url); + try { + const mapContent = await readFile(mapPath, 'utf8'); + await this.loadMapContent( + jsPath, + mapContent, + path.dirname(mapPath), + ); + } catch { + // Map file doesn't exist or is unreadable; fall through to Phase 2. + } + } + }), + ), + ); + + // Phase 2: Process .map files for any JS files not yet resolved. + await Promise.all( + mapFiles.map(mapPath => + limit(() => processSourceMap(this.infoMap, mapPath, this.debug)), + ), + ); + } + + private async loadMapContent( + jsPath: string, + mapContent: string, + mapDir: string, + ): Promise { + try { + const parsedMap = JSON.parse(mapContent) as sourceMap.RawSourceMap; + const consumer = (await new sourceMap.SourceMapConsumer( + parsedMap, + )) as {} as sourceMap.RawSourceMap; + this.infoMap.set(jsPath, {mapFileDir: mapDir, mapConsumer: consumer}); + if (this.debug) { + logger.debug(`Loaded source map for ${jsPath} via sourceMappingURL`); + } + } catch (e) { + logger.debug(`Failed to parse source map for ${jsPath}: ${e}`); + } + } + /** * Used to get the information about the transpiled file from a given input * source file provided there isn't any ambiguity with associating the input @@ -321,25 +526,6 @@ export class SourceMapper { } } -async function createFromMapFiles( - mapFiles: string[], - debug: boolean, -): Promise { - const limit = createLimiter(CONCURRENCY); - const mapper = new SourceMapper(debug); - const promises: Array> = mapFiles.map(mapPath => - limit(() => processSourceMap(mapper.infoMap, mapPath, debug)), - ); - try { - await Promise.all(promises); - } catch (err) { - throw error( - 'An error occurred while processing the source map files' + err, - ); - } - return mapper; -} - function isErrnoException(e: unknown): e is NodeJS.ErrnoException { return e instanceof Error && 'code' in e; } @@ -382,16 +568,3 @@ async function* walk( yield* walkRecursive(dir); } - -async function getMapFiles(baseDir: string): Promise { - const mapFiles: string[] = []; - for await (const entry of walk( - baseDir, - filename => /\.[cm]?js\.map$/.test(filename), - (root, dirname) => - root !== '/proc' && dirname !== '.git' && dirname !== 'node_modules', - )) { - mapFiles.push(path.relative(baseDir, entry)); - } - return mapFiles; -} diff --git a/ts/test/test-profile-serializer.ts b/ts/test/test-profile-serializer.ts index c4461cdd..8f87b0f3 100644 --- a/ts/test/test-profile-serializer.ts +++ b/ts/test/test-profile-serializer.ts @@ -206,8 +206,7 @@ describe('profile-serializer', () => { describe('source map specified', () => { let sourceMapper: SourceMapper; before(async () => { - const sourceMapFiles = [mapDirPath]; - sourceMapper = await SourceMapper.create(sourceMapFiles); + sourceMapper = await SourceMapper.create([mapDirPath]); }); describe('serializeHeapProfile', () => { diff --git a/ts/test/test-sourcemapper.ts b/ts/test/test-sourcemapper.ts new file mode 100644 index 00000000..350df364 --- /dev/null +++ b/ts/test/test-sourcemapper.ts @@ -0,0 +1,274 @@ +/** + * Copyright 2017 Google Inc. All Rights Reserved. + * + * 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. + */ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as tmp from 'tmp'; + +import { + ANNOTATION_TAIL_BYTES, + SourceMapper, + extractSourceMappingURL, + readSourceMappingURL, +} from '../src/sourcemapper/sourcemapper'; + +describe('extractSourceMappingURL', () => { + it('returns URL from a standard annotation', () => { + assert.strictEqual( + extractSourceMappingURL('//# sourceMappingURL=foo.js.map\n'), + 'foo.js.map', + ); + }); + + it('accepts legacy //@ prefix', () => { + assert.strictEqual( + extractSourceMappingURL('//@ sourceMappingURL=foo.js.map\n'), + 'foo.js.map', + ); + }); + + it('skips trailing empty and whitespace-only lines', () => { + assert.strictEqual( + extractSourceMappingURL('//# sourceMappingURL=foo.js.map\n\n \n'), + 'foo.js.map', + ); + }); + + it('allows leading whitespace before //', () => { + assert.strictEqual( + extractSourceMappingURL(' //# sourceMappingURL=foo.js.map\n'), + 'foo.js.map', + ); + }); + + it('returns undefined when last non-empty line has no // comment', () => { + assert.strictEqual(extractSourceMappingURL('const x = 1;\n'), undefined); + }); + + it('returns undefined when // comment does not match annotation pattern', () => { + assert.strictEqual( + extractSourceMappingURL('// some other comment\n'), + undefined, + ); + }); + + it('returns undefined (early exit) when last non-empty line is not an annotation, even if earlier lines are', () => { + // The annotation must be on the last non-empty line; earlier ones are ignored. + assert.strictEqual( + extractSourceMappingURL( + '//# sourceMappingURL=foo.js.map\nconst x = 1;\n', + ), + undefined, + ); + }); + + it('returns undefined when comment contains a double-quote', () => { + assert.strictEqual( + extractSourceMappingURL('//# sourceMappingURL="foo.js.map"\n'), + undefined, + ); + }); + + it('returns undefined when comment contains a single-quote', () => { + assert.strictEqual( + extractSourceMappingURL("//# sourceMappingURL='foo.js.map'\n"), + undefined, + ); + }); + + it('returns undefined when comment contains a backtick', () => { + assert.strictEqual( + extractSourceMappingURL('//# sourceMappingURL=`foo.js.map`\n'), + undefined, + ); + }); + + it('returns undefined for empty content', () => { + assert.strictEqual(extractSourceMappingURL(''), undefined); + }); + + it('returns undefined for whitespace-only content', () => { + assert.strictEqual(extractSourceMappingURL(' \n\n \n'), undefined); + }); + + it('handles all line terminator variants', () => { + assert.strictEqual( + extractSourceMappingURL('x\r//# sourceMappingURL=a.map'), + 'a.map', + ); + assert.strictEqual( + extractSourceMappingURL('x\r\n//# sourceMappingURL=b.map'), + 'b.map', + ); + assert.strictEqual( + extractSourceMappingURL('x\u2028//# sourceMappingURL=c.map'), + 'c.map', + ); + assert.strictEqual( + extractSourceMappingURL('x\u2029//# sourceMappingURL=d.map'), + 'd.map', + ); + }); + + it('returns a data: URL for inline source maps', () => { + const map = Buffer.from('{"mappings":""}').toString('base64'); + const url = `data:application/json;base64,${map}`; + assert.strictEqual( + extractSourceMappingURL(`//# sourceMappingURL=${url}\n`), + url, + ); + }); +}); + +describe('readSourceMappingURL', () => { + let tmpDir: string; + + before(() => { + tmp.setGracefulCleanup(); + tmpDir = tmp.dirSync().name; + }); + + function write(name: string, content: string): string { + const p = path.join(tmpDir, name); + fs.writeFileSync(p, content, 'utf8'); + return p; + } + + // Build a fake base64 payload larger than ANNOTATION_TAIL_BYTES to force + // the "last non-empty line extends before the tail window" scenario. + const LARGE_BASE64 = 'A'.repeat(ANNOTATION_TAIL_BYTES + 128); + const LARGE_ANNOTATION = `//# sourceMappingURL=data:application/json;base64,${LARGE_BASE64}`; + + it('reads external URL from a small file (fits entirely in tail)', async () => { + const p = write('ext-small.js', '//# sourceMappingURL=ext-small.js.map\n'); + assert.strictEqual(await readSourceMappingURL(p), 'ext-small.js.map'); + }); + + it('reads inline data: URL from a small file (fits entirely in tail)', async () => { + const map = Buffer.from('{"mappings":""}').toString('base64'); + const url = `data:application/json;base64,${map}`; + const p = write('inline-small.js', `//# sourceMappingURL=${url}\n`); + assert.strictEqual(await readSourceMappingURL(p), url); + }); + + it('returns undefined for a small file with no annotation', async () => { + const p = write('no-annotation.js', 'const x = 1;\n'); + assert.strictEqual(await readSourceMappingURL(p), undefined); + }); + + it('reads external URL from a large file (last line short, captured in tail)', async () => { + // Pad the file so the total size exceeds ANNOTATION_TAIL_BYTES, but keep + // the annotation line itself short so it fits within the tail. + const padding = '//' + ' '.repeat(ANNOTATION_TAIL_BYTES) + '\n'; + const p = write( + 'ext-large.js', + padding + '//# sourceMappingURL=ext-large.js.map\n', + ); + assert.strictEqual(await readSourceMappingURL(p), 'ext-large.js.map'); + }); + + it('reads large inline data: URL — no trailing newline (full-file fallback)', async () => { + // The annotation line is longer than ANNOTATION_TAIL_BYTES with no + // trailing newline, so the tail contains no line terminator → fallback. + const p = write('inline-large-no-nl.js', LARGE_ANNOTATION); + assert.strictEqual( + await readSourceMappingURL(p), + `data:application/json;base64,${LARGE_BASE64}`, + ); + }); + + it('reads large inline data: URL — single trailing newline (full-file fallback)', async () => { + // tail = "\n" → lastNonEmptyIdx === 0 → fallback. + const p = write('inline-large-one-nl.js', LARGE_ANNOTATION + '\n'); + assert.strictEqual( + await readSourceMappingURL(p), + `data:application/json;base64,${LARGE_BASE64}`, + ); + }); + + it('reads large inline data: URL — multiple trailing empty lines (full-file fallback)', async () => { + // The bug case: tail = "\n\n" has line terminators but + // lastNonEmptyIdx === 0, so we must not use the tail alone. + const p = write('inline-large-multi-nl.js', LARGE_ANNOTATION + '\n\n\n'); + assert.strictEqual( + await readSourceMappingURL(p), + `data:application/json;base64,${LARGE_BASE64}`, + ); + }); + + it('returns undefined for a large file with no annotation', async () => { + const padding = 'x'.repeat(ANNOTATION_TAIL_BYTES + 1) + '\n'; + const p = write('large-no-annotation.js', padding + 'const x = 1;\n'); + assert.strictEqual(await readSourceMappingURL(p), undefined); + }); + + it('returns undefined for an empty file', async () => { + const p = write('empty.js', ''); + assert.strictEqual(await readSourceMappingURL(p), undefined); + }); +}); + +describe('SourceMapper.loadDirectory', () => { + let tmpDir: string; + + before(() => { + tmp.setGracefulCleanup(); + tmpDir = tmp.dirSync().name; + }); + + function write(name: string, content: string): string { + const p = path.join(tmpDir, name); + fs.writeFileSync(p, content, 'utf8'); + return p; + } + + // A minimal valid source map for test.js -> test.ts + const MAP_CONTENT = JSON.stringify({ + version: 3, + file: 'test.js', + sources: ['test.ts'], + names: [], + mappings: 'AAAA', + }); + + it('falls back to .map file when sourceMappingURL points to a non-existent file', async () => { + // The annotation references a file that doesn't exist; Phase 2 should + // find and load the conventional test.js.map instead. + write('test.js', '//# sourceMappingURL=nonexistent.js.map\n'); + write('test.js.map', MAP_CONTENT); + + const sm = new SourceMapper(); + await sm.loadDirectory(tmpDir); + + assert.ok( + sm.hasMappingInfo(path.join(tmpDir, 'test.js')), + 'expected mapping to be loaded via .map file fallback', + ); + }); + + it('loads no mapping when sourceMappingURL points to a non-existent file and there is no .map fallback', async () => { + write('orphan.js', '//# sourceMappingURL=nonexistent.js.map\n'); + // No orphan.js.map written — nothing to fall back to. + + const sm = new SourceMapper(); + await sm.loadDirectory(tmpDir); + + assert.ok( + !sm.hasMappingInfo(path.join(tmpDir, 'orphan.js')), + 'expected no mapping to be loaded', + ); + }); +}); From 414cb9c66a3ed18183087aae52b660f5fb102d98 Mon Sep 17 00:00:00 2001 From: Ilyas Shabi Date: Tue, 17 Mar 2026 20:34:49 +0100 Subject: [PATCH 12/16] use dd-octo-sts for tag creation (#300) use dd-octo-sts for tag creation --- .github/chainguard/release.sts.yaml | 12 ++++++++++++ .github/workflows/release.yml | 11 ++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 .github/chainguard/release.sts.yaml diff --git a/.github/chainguard/release.sts.yaml b/.github/chainguard/release.sts.yaml new file mode 100644 index 00000000..0f98e301 --- /dev/null +++ b/.github/chainguard/release.sts.yaml @@ -0,0 +1,12 @@ +issuer: https://token.actions.githubusercontent.com + +subject: repo:DataDog/pprof-nodejs:environment:npm + +claim_pattern: + event_name: push + job_workflow_ref: DataDog/pprof-nodejs/\.github/workflows/release\.yml@refs/heads/v[0-9]+\.x + ref: refs/heads/v[0-9]+\.x + repository: DataDog/pprof-nodejs + +permissions: + contents: write diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d204889..dd4eae83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,10 +20,15 @@ jobs: runs-on: ubuntu-latest environment: npm permissions: - id-token: write # Required for OIDC + id-token: write # Required for OIDC contents: write steps: - - uses: actions/checkout@v2 + - uses: DataDog/dd-octo-sts-action@acaa02eee7e3bb0839e4272dacb37b8f3b58ba80 # v1.0.3 + id: octo-sts + with: + scope: DataDog/pprof-nodejs + policy: self.github.release.push-tags + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/download-artifact@v4 - uses: actions/setup-node@v3 with: @@ -37,4 +42,4 @@ jobs: echo "json=$content" >> $GITHUB_OUTPUT - run: | git tag v${{ fromJson(steps.pkg.outputs.json).version }} - git push origin v${{ fromJson(steps.pkg.outputs.json).version }} + git push https://x-access-token:${{ steps.octo-sts.outputs.token }}@github.com/${{ github.repository }}.git v${{ fromJson(steps.pkg.outputs.json).version }} From e6cd20085759bbef68fad4a3f50f0ca425a82565 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:35:16 +0100 Subject: [PATCH 13/16] build(deps-dev): bump sinon from 21.0.2 to 21.0.3 (#302) Bumps [sinon](https://github.com/sinonjs/sinon) from 21.0.2 to 21.0.3. - [Release notes](https://github.com/sinonjs/sinon/releases) - [Changelog](https://github.com/sinonjs/sinon/blob/main/docs/changelog.md) - [Commits](https://github.com/sinonjs/sinon/compare/v21.0.2...v21.0.3) --- updated-dependencies: - dependency-name: sinon dependency-version: 21.0.3 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index fea3b846..e5ad8c3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "nan": "^2.23.1", "nyc": "^18.0.0", "semver": "^7.7.4", - "sinon": "^21.0.2", + "sinon": "^21.0.3", "source-map-support": "^0.5.21", "tmp": "0.2.5", "typescript": "^5.9.3" @@ -830,9 +830,9 @@ } }, "node_modules/@sinonjs/samsam": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-9.0.2.tgz", - "integrity": "sha512-H/JSxa4GNKZuuU41E3b8Y3tbSEx8y4uq4UH1C56ONQac16HblReJomIvv3Ud7ANQHQmkeSowY49Ij972e/pGxQ==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-9.0.3.tgz", + "integrity": "sha512-ZgYY7Dc2RW+OUdnZ1DEHg00lhRt+9BjymPKHog4PRFzr1U3MbK57+djmscWyKxzO1qfunHqs4N45WWyKIFKpiQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5404,15 +5404,15 @@ "license": "ISC" }, "node_modules/sinon": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.2.tgz", - "integrity": "sha512-VHV4UaoxIe5jrMd89Y9duI76T5g3Lp+ET+ctLhLDaZtSznDPah1KKpRElbdBV4RwqWSw2vadFiVs9Del7MbVeQ==", + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.3.tgz", + "integrity": "sha512-0x8TQFr8EjADhSME01u1ZK31yv2+bd6Z5NrBCHVM+n4qL1wFqbxftmeyi3bwlr49FbbzRfrqSFOpyHCOh/YmYA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", "@sinonjs/fake-timers": "^15.1.1", - "@sinonjs/samsam": "^9.0.2", + "@sinonjs/samsam": "^9.0.3", "diff": "^8.0.3", "supports-color": "^7.2.0" }, diff --git a/package.json b/package.json index 9d0b9d08..a5cc63df 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "nan": "^2.23.1", "nyc": "^18.0.0", "semver": "^7.7.4", - "sinon": "^21.0.2", + "sinon": "^21.0.3", "source-map-support": "^0.5.21", "tmp": "0.2.5", "typescript": "^5.9.3" From d941fd4d4de6226230a859c3cb2c18911d2644ac Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Wed, 18 Mar 2026 11:24:46 +0100 Subject: [PATCH 14/16] Report missing source maps in profile comments (#298) feat: track declared-but-missing source maps and report them in profile comments When a JS file has a sourceMappingURL annotation but no map file is found after both phases of directory scanning, SourceMapper tracks it as declared-missing. mappingInfo() for such files returns a SourceLocation with missingMapFile: true, and serialize() emits a single dd:has-missing-map-files token into profile.comment when at least one such file is encountered. Inline data: URL annotations in all valid forms (including data:application/json;charset=utf-8;base64,...) are correctly handled and never produce false missing-map signals. Co-authored-by: Claude Sonnet 4.6 --- ts/src/profile-serializer.ts | 9 ++ ts/src/sourcemapper/sourcemapper.ts | 46 +++++++++-- ts/test/test-profile-serializer.ts | 124 ++++++++++++++++++++++++++++ ts/test/test-sourcemapper.ts | 84 +++++++++++++++++++ 4 files changed, 256 insertions(+), 7 deletions(-) diff --git a/ts/src/profile-serializer.ts b/ts/src/profile-serializer.ts index 7b3f0ec9..c104ddf7 100644 --- a/ts/src/profile-serializer.ts +++ b/ts/src/profile-serializer.ts @@ -101,6 +101,8 @@ function serialize( const functionIdMap = new Map(); const locationIdMap = new Map(); + let hasMissingMapFiles = false; + const entries: Array> = (root.children as T[]).map((n: T) => ({ node: n, stack: [], @@ -131,6 +133,10 @@ function serialize( profile.function = functions; profile.stringTable = stringTable; + if (hasMissingMapFiles) { + profile.comment = [stringTable.dedup('dd:has-missing-map-files')]; + } + function getLocation( node: ProfileNode, scriptName: string, @@ -146,6 +152,9 @@ function serialize( if (profLoc.line) { if (sourceMapper && isGeneratedLocation(profLoc)) { profLoc = sourceMapper.mappingInfo(profLoc); + if (profLoc.missingMapFile) { + hasMissingMapFiles = true; + } } } const keyStr = `${node.scriptId}:${profLoc.line}:${profLoc.column}:${profLoc.name}`; diff --git a/ts/src/sourcemapper/sourcemapper.ts b/ts/src/sourcemapper/sourcemapper.ts index 91342a57..becba4d2 100644 --- a/ts/src/sourcemapper/sourcemapper.ts +++ b/ts/src/sourcemapper/sourcemapper.ts @@ -161,6 +161,8 @@ export interface SourceLocation { name?: string; line?: number; column?: number; + /** True when the file declares a sourceMappingURL but the map could not be found. */ + missingMapFile?: boolean; } /** @@ -287,6 +289,8 @@ async function processSourceMap( export class SourceMapper { infoMap: Map; + /** JS files that declared a sourceMappingURL but no map was ultimately found. */ + private declaredMissingMap = new Set(); debug: boolean; static async create( @@ -351,6 +355,9 @@ export class SourceMapper { const limit = createLimiter(CONCURRENCY); + // JS files that declared a sourceMappingURL but Phase 1 couldn't load the map. + const annotatedNotLoaded = new Set(); + // Phase 1: Check sourceMappingURL annotations in JS files (higher priority). await Promise.all( jsFiles.map(jsPath => @@ -365,13 +372,26 @@ export class SourceMapper { } if (!url) return; - const INLINE_PREFIX = 'data:application/json;base64,'; - if (url.startsWith(INLINE_PREFIX)) { - const mapContent = Buffer.from( - url.slice(INLINE_PREFIX.length), - 'base64', - ).toString(); - await this.loadMapContent(jsPath, mapContent, path.dirname(jsPath)); + if (url.startsWith('data:')) { + // Inline source map data URL. Handles both: + // data:application/json;base64, + // data:application/json;charset=utf-8;base64, + // data:application/json, (and other non-base64 forms) + const commaIdx = url.indexOf(','); + if (commaIdx !== -1) { + const meta = url.slice(0, commaIdx); + const data = url.slice(commaIdx + 1); + const mapContent = meta.endsWith(';base64') + ? Buffer.from(data, 'base64').toString() + : decodeURIComponent(data); + await this.loadMapContent( + jsPath, + mapContent, + path.dirname(jsPath), + ); + } + // If the data URL is malformed (no comma), skip silently — not a + // missing map file, just an unreadable inline annotation. } else { const mapPath = path.resolve(path.dirname(jsPath), url); try { @@ -383,6 +403,7 @@ export class SourceMapper { ); } catch { // Map file doesn't exist or is unreadable; fall through to Phase 2. + annotatedNotLoaded.add(jsPath); } } }), @@ -395,6 +416,14 @@ export class SourceMapper { limit(() => processSourceMap(this.infoMap, mapPath, this.debug)), ), ); + + // Any file whose annotation pointed to a missing map and that still has no + // entry after Phase 2 is tracked as "declared but missing". + for (const jsPath of annotatedNotLoaded) { + if (!this.infoMap.has(jsPath)) { + this.declaredMissingMap.add(jsPath); + } + } } private async loadMapContent( @@ -480,6 +509,9 @@ export class SourceMapper { `Source map lookup failed: no map found for ${location.file} (normalized: ${inputPath})`, ); } + if (this.declaredMissingMap.has(inputPath)) { + return {...location, missingMapFile: true}; + } return location; } diff --git a/ts/test/test-profile-serializer.ts b/ts/test/test-profile-serializer.ts index 8f87b0f3..7450e118 100644 --- a/ts/test/test-profile-serializer.ts +++ b/ts/test/test-profile-serializer.ts @@ -238,6 +238,130 @@ describe('profile-serializer', () => { }); }); + describe('missing source map file reporting', () => { + let sourceMapper: SourceMapper; + let missingJsPath: string; + + before(async () => { + const fs = await import('fs'); + const path = await import('path'); + const testDir = tmp.dirSync().name; + missingJsPath = path.join(testDir, 'missing-map.js'); + // JS file that declares a sourceMappingURL but the map file doesn't exist. + fs.writeFileSync( + missingJsPath, + '//# sourceMappingURL=nonexistent.js.map\n', + ); + sourceMapper = await SourceMapper.create([testDir]); + }); + + function makeSingleNodeTimeProfile(scriptName: string) { + return { + startTime: 0, + endTime: 1000000, + topDownRoot: { + name: '(root)', + scriptName: 'root', + scriptId: 0, + lineNumber: 0, + columnNumber: 0, + hitCount: 0, + children: [ + { + name: 'foo', + scriptName, + scriptId: 1, + lineNumber: 1, + columnNumber: 1, + hitCount: 1, + children: [], + }, + ], + }, + }; + } + + function assertHasMissingMapToken(profile: Profile) { + const st = profile.stringTable; + const tokenId = st.dedup('dd:has-missing-map-files'); + const comments = profile.comment as number[]; + assert.ok( + Array.isArray(comments) && comments.includes(tokenId), + 'expected dd:has-missing-map-files token in profile comments', + ); + } + + it('serializeTimeProfile emits missing-map token when a map is declared but absent', () => { + const profile = serializeTimeProfile( + makeSingleNodeTimeProfile(missingJsPath), + 1000, + sourceMapper, + ); + assertHasMissingMapToken(profile); + }); + + it('serializeHeapProfile emits missing-map token when a map is declared but absent', () => { + // serialize() iterates root.children, so the node with allocations must + // be a child of the root passed to serializeHeapProfile. + const heapNode = { + name: '(root)', + scriptName: '', + scriptId: 0, + lineNumber: 0, + columnNumber: 0, + allocations: [], + children: [ + { + name: 'foo', + scriptName: missingJsPath, + scriptId: 1, + lineNumber: 1, + columnNumber: 1, + allocations: [{sizeBytes: 100, count: 1}], + children: [], + }, + ], + }; + const profile = serializeHeapProfile( + heapNode, + 0, + 512 * 1024, + undefined, + sourceMapper, + ); + assertHasMissingMapToken(profile); + }); + + it('does not emit missing-map token when no source mapper is used', () => { + const profile = serializeTimeProfile( + makeSingleNodeTimeProfile(missingJsPath), + 1000, + ); + assert.ok( + !profile.comment || profile.comment.length === 0, + 'expected no comments when no source mapper is provided', + ); + }); + + it('does not emit missing-map token when all maps are found', () => { + const { + mapDirPath, + v8TimeGeneratedProfile, + } = require('./profiles-for-tests'); + return SourceMapper.create([mapDirPath]).then(sm => { + const profile = serializeTimeProfile(v8TimeGeneratedProfile, 1000, sm); + assert.ok( + !profile.comment || profile.comment.length === 0, + 'expected no comments when all maps are resolved', + ); + }); + }); + + after(() => { + tmp.setGracefulCleanup(); + }); + }); + describe('source map with column 0 (LineTick simulation)', () => { // This tests the LEAST_UPPER_BOUND fallback for when V8's LineTick // doesn't provide column information (column=0) diff --git a/ts/test/test-sourcemapper.ts b/ts/test/test-sourcemapper.ts index 350df364..d1d6801d 100644 --- a/ts/test/test-sourcemapper.ts +++ b/ts/test/test-sourcemapper.ts @@ -271,4 +271,88 @@ describe('SourceMapper.loadDirectory', () => { 'expected no mapping to be loaded', ); }); + + it('sets missingMapFile=true when sourceMappingURL declares a missing map', async () => { + write('declared-missing.js', '//# sourceMappingURL=nonexistent.js.map\n'); + // No declared-missing.js.map written — nothing to fall back to. + + const sm = new SourceMapper(); + await sm.loadDirectory(tmpDir); + + const jsPath = path.join(tmpDir, 'declared-missing.js'); + const loc = sm.mappingInfo({file: jsPath, line: 1, column: 0, name: 'foo'}); + assert.strictEqual( + loc.missingMapFile, + true, + 'expected missingMapFile to be true for a file with a declared but missing map', + ); + }); + + it('does not set missingMapFile when file has no sourceMappingURL', async () => { + write('plain.js', 'console.log("hello");\n'); + + const sm = new SourceMapper(); + await sm.loadDirectory(tmpDir); + + const jsPath = path.join(tmpDir, 'plain.js'); + const loc = sm.mappingInfo({file: jsPath, line: 1, column: 0, name: 'foo'}); + assert.ok( + !loc.missingMapFile, + 'expected missingMapFile to be falsy for a file with no sourceMappingURL', + ); + }); + + it('does not set missingMapFile for an inline data: URL with charset parameter', async () => { + // data:application/json;charset=utf-8;base64,... is a valid inline form but + // does not match the old exact INLINE_PREFIX. It must not be treated as a + // file path and must not produce a false missingMapFile signal. + const mapJson = JSON.stringify({ + version: 3, + file: 'charset.js', + sources: ['charset.ts'], + names: [], + mappings: 'AAAA', + }); + const b64 = Buffer.from(mapJson).toString('base64'); + const url = `data:application/json;charset=utf-8;base64,${b64}`; + write('charset.js', `//# sourceMappingURL=${url}\n`); + + const sm = new SourceMapper(); + await sm.loadDirectory(tmpDir); + + const jsPath = path.join(tmpDir, 'charset.js'); + assert.ok( + sm.hasMappingInfo(jsPath), + 'expected mapping to be loaded from charset data: URL', + ); + assert.ok( + !sm.mappingInfo({file: jsPath, line: 1, column: 0, name: 'f'}) + .missingMapFile, + 'expected missingMapFile to be falsy for an inline charset data: URL', + ); + }); + + it('does not set missingMapFile when map was found via .map fallback', async () => { + // JS with annotation pointing to nonexistent path, but a .map file exists + // alongside it (Phase 2 fallback). + const {SourceMapGenerator} = await import('source-map'); + const gen = new SourceMapGenerator({file: 'fallback.js'}); + gen.addMapping({ + source: path.join(tmpDir, 'source.ts'), + generated: {line: 1, column: 0}, + original: {line: 10, column: 0}, + }); + write('fallback.js', '//# sourceMappingURL=nowhere.js.map\n'); + write('fallback.js.map', gen.toString()); + + const sm = new SourceMapper(); + await sm.loadDirectory(tmpDir); + + const jsPath = path.join(tmpDir, 'fallback.js'); + const loc = sm.mappingInfo({file: jsPath, line: 1, column: 0, name: 'foo'}); + assert.ok( + !loc.missingMapFile, + 'expected missingMapFile to be falsy when map was found via Phase 2 fallback', + ); + }); }); From 0aec490a8e0555165e6cd57026b2156c8f198ba4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:29:40 +0000 Subject: [PATCH 15/16] build(deps-dev): bump @types/node from 25.4.0 to 25.5.0 (#303) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.4.0 to 25.5.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 25.5.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index e5ad8c3b..f84193e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ }, "devDependencies": { "@types/mocha": "^10.0.1", - "@types/node": "25.4.0", + "@types/node": "25.5.0", "@types/semver": "^7.5.8", "@types/sinon": "^21.0.0", "@types/tmp": "^0.2.3", @@ -932,9 +932,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.4.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz", - "integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index a5cc63df..40ac4260 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ }, "devDependencies": { "@types/mocha": "^10.0.1", - "@types/node": "25.4.0", + "@types/node": "25.5.0", "@types/semver": "^7.5.8", "@types/sinon": "^21.0.0", "@types/tmp": "^0.2.3", From b37c00653ed2d6a35c0c48df5afc3ce7711f763f Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Tue, 10 Mar 2026 15:25:42 +0100 Subject: [PATCH 16/16] v5.14.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f84193e0..43ba69d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@datadog/pprof", - "version": "5.13.5", + "version": "5.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@datadog/pprof", - "version": "5.13.5", + "version": "5.14.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 40ac4260..193b03eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@datadog/pprof", - "version": "5.13.5", + "version": "5.14.0", "description": "pprof support for Node.js", "repository": { "type": "git",