From e8e5f80918c30d7ca23080b35413d9c55f31fffb Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Fri, 10 Apr 2026 11:20:44 +0200 Subject: [PATCH 1/6] deps: add heap profile sample labels to V8 profiler Add a callback mechanism to V8's SamplingHeapProfiler that allows embedders to attach key-value string labels to allocation samples. At allocation time, SampleObject() captures the ContinuationPreservedEmbedderData (CPED) as a Global on each internal Sample. At profile-read time, BuildSamples() invokes the registered HeapProfileSampleLabelsCallback with each sample's stored CPED, allowing embedders to resolve labels from the async context. This two-phase approach (capture at allocation, resolve at read) avoids running embedder callbacks inside DisallowGarbageCollection scopes and is immune to CPED identity changes caused by unrelated AsyncLocalStorage stores. Changes: - AllocationProfile::Sample gains a `labels` field - New HeapProfileSampleLabelsCallback typedef on HeapProfiler - Internal Sample stores `Global cped` at allocation time - BuildSamples() invokes labels callback with stored CPED - Seven cctests covering callback API, GC behavior, unregistration - features.gypi enables v8_enable_continuation_preserved_embedder_data Signed-off-by: Rudolf Meijering --- deps/v8/include/v8-profiler.h | 72 +++- deps/v8/src/api/api.cc | 8 + deps/v8/src/profiler/heap-profiler.h | 20 + .../v8/src/profiler/sampling-heap-profiler.cc | 49 ++- deps/v8/src/profiler/sampling-heap-profiler.h | 8 +- deps/v8/test/cctest/test-heap-profiler.cc | 346 ++++++++++++++++++ tools/v8_gypfiles/features.gypi | 7 +- 7 files changed, 492 insertions(+), 18 deletions(-) diff --git a/deps/v8/include/v8-profiler.h b/deps/v8/include/v8-profiler.h index 61f427ea47c691..fbde2649945cfb 100644 --- a/deps/v8/include/v8-profiler.h +++ b/deps/v8/include/v8-profiler.h @@ -11,6 +11,11 @@ #include #include +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +#include +#include +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + #include "cppgc/common.h" // NOLINT(build/include_directory) #include "v8-local-handle.h" // NOLINT(build/include_directory) #include "v8-message.h" // NOLINT(build/include_directory) @@ -791,26 +796,39 @@ class V8_EXPORT AllocationProfile { * Represent a single sample recorded for an allocation. */ struct Sample { - /** - * id of the node in the profile tree. - */ + Sample(uint32_t node_id, size_t size, unsigned int count, + uint64_t sample_id) + : node_id(node_id), size(size), count(count), sample_id(sample_id) {} +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + Sample(uint32_t node_id, size_t size, unsigned int count, + uint64_t sample_id, + std::vector> labels) + : node_id(node_id), + size(size), + count(count), + sample_id(sample_id), + labels(std::move(labels)) {} +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + + /** id of the node in the profile tree. */ uint32_t node_id; - - /** - * Size of the sampled allocation object. - */ + /** Size of the sampled allocation object. */ size_t size; - - /** - * The number of objects of such size that were sampled. - */ + /** The number of objects of such size that were sampled. */ unsigned int count; - /** * Unique time-ordered id of the allocation sample. Can be used to track * what samples were added or removed between two snapshots. */ uint64_t sample_id; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + /** + * Embedder-provided labels captured at allocation time via the + * HeapProfileSampleLabelsCallback. Each pair is (key, value). + * Empty if no callback is registered or the callback returned no labels. + */ + std::vector> labels; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS }; /** @@ -1001,6 +1019,24 @@ class V8_EXPORT HeapProfiler { v8::Isolate* isolate, const v8::Local& v8_value, uint16_t class_id, void* data); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + /** + * Callback invoked during sampling heap profiler allocation events to + * retrieve embedder-defined labels for the current execution context. + * + * |data| is the opaque pointer passed to SetHeapProfileSampleLabelsCallback. + * |context| is the ContinuationPreservedEmbedderData (CPED) value, which + * the embedder can use to look up the current async context (e.g., route). + * + * Write labels to out_labels and return true, or return false if no labels + * apply. The caller provides a stack-local vector; returning false avoids + * any heap allocation on the hot path. + */ + using HeapProfileSampleLabelsCallback = bool (*)( + void* data, v8::Local context, + std::vector>* out_labels); +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + /** Returns the number of snapshots taken. */ int GetSnapshotCount(); @@ -1261,6 +1297,18 @@ class V8_EXPORT HeapProfiler { void SetGetDetachednessCallback(GetDetachednessCallback callback, void* data); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + /** + * Registers a callback that the sampling heap profiler invokes on each + * allocation to retrieve embedder-defined string labels. The labels are + * stored on AllocationProfile::Sample::labels. + * + * Pass nullptr to clear the callback. + */ + void SetHeapProfileSampleLabelsCallback( + HeapProfileSampleLabelsCallback callback, void* data = nullptr); +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + /** * Returns whether the heap profiler is currently taking a snapshot. */ diff --git a/deps/v8/src/api/api.cc b/deps/v8/src/api/api.cc index 18d762c6443073..72d770bb513438 100644 --- a/deps/v8/src/api/api.cc +++ b/deps/v8/src/api/api.cc @@ -11829,6 +11829,14 @@ void HeapProfiler::SetGetDetachednessCallback(GetDetachednessCallback callback, data); } +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +void HeapProfiler::SetHeapProfileSampleLabelsCallback( + HeapProfileSampleLabelsCallback callback, void* data) { + reinterpret_cast(this) + ->SetHeapProfileSampleLabelsCallback(callback, data); +} +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + bool HeapProfiler::IsTakingSnapshot() { return reinterpret_cast(this)->IsTakingSnapshot(); } diff --git a/deps/v8/src/profiler/heap-profiler.h b/deps/v8/src/profiler/heap-profiler.h index 82d4db266e7d96..18751b24f4bc2f 100644 --- a/deps/v8/src/profiler/heap-profiler.h +++ b/deps/v8/src/profiler/heap-profiler.h @@ -79,6 +79,21 @@ class HeapProfiler : public HeapObjectAllocationTracker { bool is_sampling_allocations() { return !!sampling_heap_profiler_; } AllocationProfile* GetAllocationProfile(); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + void SetHeapProfileSampleLabelsCallback( + v8::HeapProfiler::HeapProfileSampleLabelsCallback callback, + void* data) { + sample_labels_callback_ = callback; + sample_labels_data_ = data; + } + + v8::HeapProfiler::HeapProfileSampleLabelsCallback + sample_labels_callback() const { + return sample_labels_callback_; + } + void* sample_labels_data() const { return sample_labels_data_; } +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + void StartHeapObjectsTracking(bool track_allocations); void StopHeapObjectsTracking(); AllocationTracker* allocation_tracker() const { @@ -176,6 +191,11 @@ class HeapProfiler : public HeapObjectAllocationTracker { std::pair get_detachedness_callback_; std::unique_ptr native_move_listener_; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + v8::HeapProfiler::HeapProfileSampleLabelsCallback sample_labels_callback_ = + nullptr; + void* sample_labels_data_ = nullptr; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS }; } // namespace internal diff --git a/deps/v8/src/profiler/sampling-heap-profiler.cc b/deps/v8/src/profiler/sampling-heap-profiler.cc index 8133dec033b9fb..639dd7e082de63 100644 --- a/deps/v8/src/profiler/sampling-heap-profiler.cc +++ b/deps/v8/src/profiler/sampling-heap-profiler.cc @@ -15,6 +15,7 @@ #include "src/execution/isolate.h" #include "src/heap/heap-layout-inl.h" #include "src/heap/heap.h" +#include "src/profiler/heap-profiler.h" #include "src/profiler/strings-storage.h" namespace v8 { @@ -96,6 +97,26 @@ void SamplingHeapProfiler::SampleObject(Address soon_object, size_t size) { node->allocations_[size]++; auto sample = std::make_unique(size, node, loc, this, next_sample_id()); + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // If an embedder labels callback is registered, capture the CPED + // (ContinuationPreservedEmbedderData) for later label resolution in + // BuildSamples(). Storing as Global keeps the AsyncContextFrame alive + // and prevents it from being GC'd while the sample exists. + // Global::Reset is safe inside DisallowGC (uses malloc, not V8 heap). + { + HeapProfiler* hp = isolate_->heap()->heap_profiler(); + if (hp->sample_labels_callback()) { + v8::Isolate* v8_isolate = reinterpret_cast(isolate_); + v8::Local context = + v8_isolate->GetContinuationPreservedEmbedderData(); + if (!context.IsEmpty() && !context->IsUndefined()) { + sample->cped.Reset(v8_isolate, context); + } + } + } +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + sample->global.SetWeak(sample.get(), OnWeakCallback, WeakCallbackType::kParameter); samples_.emplace(sample.get(), std::move(sample)); @@ -307,14 +328,34 @@ v8::AllocationProfile* SamplingHeapProfiler::GetAllocationProfile() { } const std::vector -SamplingHeapProfiler::BuildSamples() const { +SamplingHeapProfiler::BuildSamples() { std::vector samples; samples.reserve(samples_.size()); + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + HeapProfiler* hp = heap_->heap_profiler(); + auto callback = hp->sample_labels_callback(); + void* callback_data = hp->sample_labels_data(); + v8::Isolate* v8_isolate = reinterpret_cast(isolate_); +#endif + for (const auto& it : samples_) { const Sample* sample = it.second.get(); - samples.emplace_back(v8::AllocationProfile::Sample{ - sample->owner->id_, sample->size, ScaleSample(sample->size, 1).count, - sample->sample_id}); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + std::vector> labels; + if (callback && !sample->cped.IsEmpty()) { + HandleScope scope(isolate_); + v8::Local cped_local = sample->cped.Get(v8_isolate); + callback(callback_data, cped_local, &labels); + } + samples.emplace_back(sample->owner->id_, sample->size, + ScaleSample(sample->size, 1).count, + sample->sample_id, std::move(labels)); +#else + samples.emplace_back(sample->owner->id_, sample->size, + ScaleSample(sample->size, 1).count, + sample->sample_id); +#endif } return samples; } diff --git a/deps/v8/src/profiler/sampling-heap-profiler.h b/deps/v8/src/profiler/sampling-heap-profiler.h index 6a1010b9993193..bd794c33872e40 100644 --- a/deps/v8/src/profiler/sampling-heap-profiler.h +++ b/deps/v8/src/profiler/sampling-heap-profiler.h @@ -114,6 +114,12 @@ class SamplingHeapProfiler { Global global; SamplingHeapProfiler* const profiler; const uint64_t sample_id; + std::vector> labels; + // ContinuationPreservedEmbedderData captured at allocation time. + // Stored as Global to prevent GC of the AsyncContextFrame while + // the sample exists. Labels are resolved from this at read time + // (in BuildSamples) via the registered labels callback. + Global cped; }; SamplingHeapProfiler(Heap* heap, StringsStorage* names, uint64_t rate, @@ -160,7 +166,7 @@ class SamplingHeapProfiler { void SampleObject(Address soon_object, size_t size); - const std::vector BuildSamples() const; + const std::vector BuildSamples(); AllocationNode* FindOrAddChildNode(AllocationNode* parent, const char* name, int script_id, int start_position); diff --git a/deps/v8/test/cctest/test-heap-profiler.cc b/deps/v8/test/cctest/test-heap-profiler.cc index 4dbb3fc7604344..f634d66dccdfd3 100644 --- a/deps/v8/test/cctest/test-heap-profiler.cc +++ b/deps/v8/test/cctest/test-heap-profiler.cc @@ -4854,3 +4854,349 @@ TEST(HeapSnapshotWithWasmInstance) { #endif // V8_ENABLE_SANDBOX } #endif // V8_ENABLE_WEBASSEMBLY + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + +// --- Tests for HeapProfileSampleLabelsCallback --- + +// Helper: a label callback that writes fixed labels via output parameter. +static bool FixedLabelsCallback( + void* data, v8::Local context, + std::vector>* out_labels) { + auto* labels = + static_cast>*>(data); + *out_labels = *labels; + return true; +} + +// Helper: a label callback that returns false (no labels). +static bool EmptyLabelsCallback( + void* data, v8::Local context, + std::vector>* out_labels) { + return false; +} + +// Helper: a label callback that switches labels based on a flag. +struct MultiLabelState { + bool use_second; + std::vector> first; + std::vector> second; +}; + +static bool MultiLabelsCallback( + void* data, v8::Local context, + std::vector>* out_labels) { + auto* state = static_cast(data); + *out_labels = state->use_second ? state->second : state->first; + return true; +} + +TEST(SamplingHeapProfilerLabelsCallback) { + v8::HandleScope scope(CcTest::isolate()); + LocalContext env; + v8::Isolate* isolate = env->GetIsolate(); + v8::HeapProfiler* heap_profiler = isolate->GetHeapProfiler(); + + i::v8_flags.sampling_heap_profiler_suppress_randomness = true; + + std::vector> labels = { + {"route", "/api/test"}}; + + // Set CPED so the callback gets invoked (non-empty context required). + { + v8::HandleScope inner(isolate); + isolate->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate, "test-context")); + } + + heap_profiler->SetHeapProfileSampleLabelsCallback(FixedLabelsCallback, + &labels); + + heap_profiler->StartSamplingHeapProfiler(256); + + // Allocate enough objects to get samples. + for (int i = 0; i < 8 * 1024; ++i) v8::Object::New(isolate); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + CHECK(profile); + + // Verify at least one sample has the expected labels. + bool found_labeled = false; + for (const auto& sample : profile->GetSamples()) { + if (!sample.labels.empty()) { + CHECK_EQ(sample.labels.size(), 1); + CHECK_EQ(sample.labels[0].first, "route"); + CHECK_EQ(sample.labels[0].second, "/api/test"); + found_labeled = true; + } + } + CHECK(found_labeled); + + heap_profiler->StopSamplingHeapProfiler(); + + // Clear callback. + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); +} + +TEST(SamplingHeapProfilerNoLabelsCallback) { + v8::HandleScope scope(CcTest::isolate()); + LocalContext env; + v8::Isolate* isolate = env->GetIsolate(); + v8::HeapProfiler* heap_profiler = isolate->GetHeapProfiler(); + + i::v8_flags.sampling_heap_profiler_suppress_randomness = true; + + // No callback registered — samples should have empty labels. + heap_profiler->StartSamplingHeapProfiler(256); + + for (int i = 0; i < 8 * 1024; ++i) v8::Object::New(isolate); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + CHECK(profile); + + for (const auto& sample : profile->GetSamples()) { + CHECK(sample.labels.empty()); + } + + heap_profiler->StopSamplingHeapProfiler(); +} + +TEST(SamplingHeapProfilerEmptyLabelsCallback) { + v8::HandleScope scope(CcTest::isolate()); + LocalContext env; + v8::Isolate* isolate = env->GetIsolate(); + v8::HeapProfiler* heap_profiler = isolate->GetHeapProfiler(); + + i::v8_flags.sampling_heap_profiler_suppress_randomness = true; + + // Set CPED so callback is invoked. + isolate->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate, "test-context")); + + // Callback returns empty vector — samples should have empty labels. + heap_profiler->SetHeapProfileSampleLabelsCallback(EmptyLabelsCallback, + nullptr); + + heap_profiler->StartSamplingHeapProfiler(256); + + for (int i = 0; i < 8 * 1024; ++i) v8::Object::New(isolate); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + CHECK(profile); + + for (const auto& sample : profile->GetSamples()) { + CHECK(sample.labels.empty()); + } + + heap_profiler->StopSamplingHeapProfiler(); + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); +} + +TEST(SamplingHeapProfilerMultipleLabels) { + v8::HandleScope scope(CcTest::isolate()); + LocalContext env; + v8::Isolate* isolate = env->GetIsolate(); + v8::HeapProfiler* heap_profiler = isolate->GetHeapProfiler(); + + i::v8_flags.sampling_heap_profiler_suppress_randomness = true; + + // Set CPED so callback is invoked. + isolate->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate, "test-context")); + + MultiLabelState state; + state.use_second = false; + state.first = {{"route", "/api/first"}}; + state.second = {{"route", "/api/second"}}; + + heap_profiler->SetHeapProfileSampleLabelsCallback(MultiLabelsCallback, + &state); + + heap_profiler->StartSamplingHeapProfiler(256); + + // Allocate with first label set. + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate); + + // Switch to second label set. + state.use_second = true; + + // Allocate with second label set. + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + CHECK(profile); + + bool found_first = false; + bool found_second = false; + for (const auto& sample : profile->GetSamples()) { + if (!sample.labels.empty()) { + CHECK_EQ(sample.labels.size(), 1); + CHECK_EQ(sample.labels[0].first, "route"); + if (sample.labels[0].second == "/api/first") found_first = true; + if (sample.labels[0].second == "/api/second") found_second = true; + } + } + CHECK(found_first); + CHECK(found_second); + + heap_profiler->StopSamplingHeapProfiler(); + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); +} + +TEST(SamplingHeapProfilerLabelsWithGCRetain) { + v8::HandleScope scope(CcTest::isolate()); + LocalContext env; + v8::Isolate* isolate = env->GetIsolate(); + v8::HeapProfiler* heap_profiler = isolate->GetHeapProfiler(); + + i::v8_flags.sampling_heap_profiler_suppress_randomness = true; + + // Set CPED so callback is invoked. + isolate->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate, "test-context")); + + std::vector> labels = { + {"route", "/api/gc-test"}}; + heap_profiler->SetHeapProfileSampleLabelsCallback(FixedLabelsCallback, + &labels); + + // Start with GC retain flags — GC'd samples should survive. + heap_profiler->StartSamplingHeapProfiler( + 256, 128, + v8::HeapProfiler::kSamplingIncludeObjectsCollectedByMajorGC | + v8::HeapProfiler::kSamplingIncludeObjectsCollectedByMinorGC); + + // Allocate short-lived objects (no reference retained). + CompileRun( + "for (var i = 0; i < 4096; i++) {" + " new Array(64);" + "}"); + + // Force GC to collect the short-lived objects. + i::heap::InvokeMajorGC(CcTest::heap()); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + CHECK(profile); + + // With GC retain flags, samples for collected objects should still exist + // with their labels intact. + bool found_labeled = false; + for (const auto& sample : profile->GetSamples()) { + if (!sample.labels.empty()) { + CHECK_EQ(sample.labels[0].first, "route"); + CHECK_EQ(sample.labels[0].second, "/api/gc-test"); + found_labeled = true; + } + } + CHECK(found_labeled); + + heap_profiler->StopSamplingHeapProfiler(); + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); +} + +TEST(SamplingHeapProfilerLabelsRemovedByGC) { + v8::HandleScope scope(CcTest::isolate()); + LocalContext env; + v8::Isolate* isolate = env->GetIsolate(); + v8::HeapProfiler* heap_profiler = isolate->GetHeapProfiler(); + + i::v8_flags.sampling_heap_profiler_suppress_randomness = true; + + // Set CPED so callback is invoked. + isolate->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate, "test-context")); + + std::vector> labels = { + {"route", "/api/gc-remove"}}; + heap_profiler->SetHeapProfileSampleLabelsCallback(FixedLabelsCallback, + &labels); + + // Start WITHOUT GC retain flags — GC'd samples should be removed. + heap_profiler->StartSamplingHeapProfiler(256); + + // Allocate short-lived objects (no reference retained). + CompileRun( + "for (var i = 0; i < 4096; i++) {" + " new Array(64);" + "}"); + + // Force GC to collect the short-lived objects. + i::heap::InvokeMajorGC(CcTest::heap()); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + CHECK(profile); + + // Without GC retain flags, most/all short-lived samples should be gone. + // Count remaining labeled samples — should be significantly fewer than + // what was allocated (many were collected by GC). + size_t labeled_count = 0; + for (const auto& sample : profile->GetSamples()) { + if (!sample.labels.empty()) { + labeled_count++; + } + } + // We can't assert zero because some objects may survive GC, but the count + // should be much smaller than the retained case. Just verify the profile + // is valid and doesn't crash. + CHECK(profile->GetRootNode()); + + heap_profiler->StopSamplingHeapProfiler(); + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); +} + +TEST(SamplingHeapProfilerUnregisterCallback) { + v8::HandleScope scope(CcTest::isolate()); + LocalContext env; + v8::Isolate* isolate = env->GetIsolate(); + v8::HeapProfiler* heap_profiler = isolate->GetHeapProfiler(); + + i::v8_flags.sampling_heap_profiler_suppress_randomness = true; + + // Set CPED so callback is invoked. + isolate->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate, "test-context")); + + std::vector> labels = { + {"route", "/api/before-unregister"}}; + heap_profiler->SetHeapProfileSampleLabelsCallback(FixedLabelsCallback, + &labels); + + heap_profiler->StartSamplingHeapProfiler(256); + + // Allocate with callback active. + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate); + + // Unregister callback (pass nullptr). + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); + + // Allocate more — these should have no labels. + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + CHECK(profile); + + // Should have some labeled samples (from before unregister) and some + // unlabeled (from after). Verify at least one labeled exists. + bool found_labeled = false; + bool found_unlabeled = false; + for (const auto& sample : profile->GetSamples()) { + if (!sample.labels.empty()) { + found_labeled = true; + } else { + found_unlabeled = true; + } + } + CHECK(found_labeled); + CHECK(found_unlabeled); + + heap_profiler->StopSamplingHeapProfiler(); +} + +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS diff --git a/tools/v8_gypfiles/features.gypi b/tools/v8_gypfiles/features.gypi index ed9a5a5c487157..36a081385a4338 100644 --- a/tools/v8_gypfiles/features.gypi +++ b/tools/v8_gypfiles/features.gypi @@ -523,7 +523,12 @@ 'defines': ['V8_ENABLE_JAVASCRIPT_PROMISE_HOOKS',], }], ['v8_enable_continuation_preserved_embedder_data==1', { - 'defines': ['V8_ENABLE_CONTINUATION_PRESERVED_EMBEDDER_DATA',], + 'defines': [ + 'V8_ENABLE_CONTINUATION_PRESERVED_EMBEDDER_DATA', + # Enable heap profiler sample labels for per-context memory + # attribution when CPED is available. + 'V8_HEAP_PROFILER_SAMPLE_LABELS', + ], }], ['v8_enable_allocation_folding==1', { 'defines': ['V8_ALLOCATION_FOLDING',], From 21702bbfefa73bb76b41fb753c813351b9bd97f0 Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Fri, 10 Apr 2026 11:20:59 +0200 Subject: [PATCH 2/6] src: add heap profile labels and ProfilingAllocator C++ bindings for V8 heap profile labels with ALS store lookup: - HeapProfileLabelsCallback reads the ALS store directly from the captured CPED (AsyncContextFrame Map) at profile-read time - Uses Map::AsArray() + linear scan for ALS key lookup, safe inside DisallowJavascriptExecution (ArrayBuffer allocator context) - ProfilingArrayBufferAllocator tracks per-label external memory (Buffer/ArrayBuffer) using the same CPED-based label resolution - SetHeapProfileLabelsStore receives the ALS key from JS at init time - GetAllocationProfile returns samples with labels and externalBytes - Cleanup hooks for environment teardown - Node.js cctests for label registration, callback, and cleanup Signed-off-by: Rudolf Meijering --- node.gyp | 12 + src/api/environment.cc | 153 +++++++++- src/node_internals.h | 49 +++ src/node_v8.cc | 278 +++++++++++++++++ src/node_v8.h | 8 + test/cctest/test_heap_profile_labels.cc | 379 ++++++++++++++++++++++++ 6 files changed, 877 insertions(+), 2 deletions(-) create mode 100644 test/cctest/test_heap_profile_labels.cc diff --git a/node.gyp b/node.gyp index b245011181d660..92753e7ce653ed 100644 --- a/node.gyp +++ b/node.gyp @@ -4,6 +4,7 @@ 'v8_trace_maps%': 0, 'v8_enable_pointer_compression%': 0, 'v8_enable_31bit_smis_on_64bit_arch%': 0, + 'v8_enable_continuation_preserved_embedder_data%': 1, 'force_dynamic_crt%': 0, 'node_builtin_modules_path%': '', 'node_core_target_name%': 'node', @@ -937,6 +938,12 @@ 'msvs_disabled_warnings!': [4244], 'conditions': [ + [ 'v8_enable_continuation_preserved_embedder_data==1', { + 'defines': [ + # Enable heap profiler sample labels when CPED is available. + 'V8_HEAP_PROFILER_SAMPLE_LABELS', + ], + }], [ 'openssl_default_cipher_list!=""', { 'defines': [ 'NODE_OPENSSL_DEFAULT_CIPHER_LIST="<(openssl_default_cipher_list)"' @@ -1322,6 +1329,11 @@ 'sources': [ '<@(node_cctest_sources)' ], 'conditions': [ + [ 'v8_enable_continuation_preserved_embedder_data==1', { + 'defines': [ + 'V8_HEAP_PROFILER_SAMPLE_LABELS', + ], + }], [ 'node_shared_gtest=="false"', { 'dependencies': [ 'deps/googletest/googletest.gyp:gtest', diff --git a/src/api/environment.cc b/src/api/environment.cc index 8c14caa9c95f43..b5ae99dcd2d9e6 100644 --- a/src/api/environment.cc +++ b/src/api/environment.cc @@ -15,6 +15,7 @@ #include "node_realm-inl.h" #include "node_shadow_realm.h" #include "node_snapshot_builder.h" +#include "node_v8.h" #include "node_v8_platform-inl.h" #include "node_wasm_web_api.h" #include "uv.h" @@ -191,11 +192,159 @@ void DebuggingArrayBufferAllocator::RegisterPointerInternal(void* data, allocations_[data] = size; } +void* ProfilingArrayBufferAllocator::Allocate(size_t size) { + void* ret = NodeArrayBufferAllocator::Allocate(size); + if (ret != nullptr && enabled_.load(std::memory_order_acquire)) { + LabelPairs labels = FindCurrentLabels(); + if (!labels.empty()) { + std::string key = SerializeLabels(labels); + Mutex::ScopedLock lock(mutex_); + allocations_[ret] = {key, size}; + auto& entry = per_label_bytes_[key]; + if (entry.labels.empty()) entry.labels = std::move(labels); + entry.bytes += static_cast(size); + } + } + return ret; +} + +void* ProfilingArrayBufferAllocator::AllocateUninitialized(size_t size) { + void* ret = NodeArrayBufferAllocator::AllocateUninitialized(size); + if (ret != nullptr && enabled_.load(std::memory_order_acquire)) { + LabelPairs labels = FindCurrentLabels(); + if (!labels.empty()) { + std::string key = SerializeLabels(labels); + Mutex::ScopedLock lock(mutex_); + allocations_[ret] = {key, size}; + auto& entry = per_label_bytes_[key]; + if (entry.labels.empty()) entry.labels = std::move(labels); + entry.bytes += static_cast(size); + } + } + return ret; +} + +void ProfilingArrayBufferAllocator::Free(void* data, size_t size) { + if (enabled_.load(std::memory_order_acquire)) { + Mutex::ScopedLock lock(mutex_); + auto it = allocations_.find(data); + if (it != allocations_.end()) { + auto label_it = per_label_bytes_.find(it->second.first); + if (label_it != per_label_bytes_.end()) { + label_it->second.bytes -= static_cast(it->second.second); + } + allocations_.erase(it); + } + } + NodeArrayBufferAllocator::Free(data, size); +} + +void ProfilingArrayBufferAllocator::Enable( + v8::Isolate* isolate, v8::Global* als_key) { + Mutex::ScopedLock lock(mutex_); + isolate_ = isolate; + als_key_ = als_key; + main_thread_id_ = std::this_thread::get_id(); + enabled_.store(true, std::memory_order_release); +} + +void ProfilingArrayBufferAllocator::Disable() { + enabled_.store(false, std::memory_order_release); + Mutex::ScopedLock lock(mutex_); + allocations_.clear(); + per_label_bytes_.clear(); + isolate_ = nullptr; + als_key_ = nullptr; +} + +std::vector +ProfilingArrayBufferAllocator::GetPerLabelBytes() const { + Mutex::ScopedLock lock(mutex_); + std::vector result; + for (const auto& [key, entry] : per_label_bytes_) { + if (entry.bytes > 0) { + result.push_back(entry); + } + } + return result; +} + +std::string ProfilingArrayBufferAllocator::SerializeLabels( + const LabelPairs& labels) { + std::string key; + for (const auto& [k, v] : labels) { + if (!key.empty()) key += '\0'; + key += k; + key += '\0'; + key += v; + } + return key; +} + +ProfilingArrayBufferAllocator::LabelPairs +ProfilingArrayBufferAllocator::FindCurrentLabels() { + // Skip non-main-thread allocations (SharedArrayBuffer from workers). + if (std::this_thread::get_id() != main_thread_id_) return {}; + if (isolate_ == nullptr || als_key_ == nullptr || als_key_->IsEmpty()) { + return {}; + } + + // Read CPED via public V8 API. This is safe because: + // 1. ArrayBuffer allocator runs in normal JS context, not during GC + // 2. HandleScope is always active during JS execution + v8::Local cped = + isolate_->GetContinuationPreservedEmbedderData(); + if (cped.IsEmpty() || !cped->IsMap()) return {}; + + v8::HandleScope handle_scope(isolate_); + v8::Local context = isolate_->GetCurrentContext(); + v8::Local frame = cped.As(); + v8::Local als_key = als_key_->Get(isolate_); + + // Cannot use Map::Get() here — it calls a JS builtin which is not safe + // in DisallowJavascriptExecution (ArrayBuffer allocator is called from + // BackingStore::Allocate inside the ArrayBuffer constructor). + // Use AsArray() which reads the internal backing store directly without + // calling JS builtins, then iterate entries by identity comparison. + v8::Local entries = frame->AsArray(); + uint32_t entries_len = entries->Length(); + for (uint32_t i = 0; i + 1 < entries_len; i += 2) { + v8::Local entry_key; + if (!entries->Get(context, i).ToLocal(&entry_key)) continue; + if (!entry_key->StrictEquals(als_key)) continue; + + // Found the labels ALS entry — value is the flat array. + v8::Local val; + if (!entries->Get(context, i + 1).ToLocal(&val) || !val->IsArray()) { + return {}; + } + + // Convert flat [key1, val1, key2, val2, ...] array to string pairs. + v8::Local flat = val.As(); + uint32_t len = flat->Length(); + LabelPairs result; + for (uint32_t j = 0; j + 1 < len; j += 2) { + v8::Local k, v; + if (!flat->Get(context, j).ToLocal(&k)) return {}; + if (!flat->Get(context, j + 1).ToLocal(&v)) return {}; + v8::String::Utf8Value key_str(isolate_, k); + v8::String::Utf8Value val_str(isolate_, v); + result.emplace_back(*key_str, *val_str); + } + return result; + } + return {}; +} + std::unique_ptr ArrayBufferAllocator::Create(bool debug) { if (debug || per_process::cli_options->debug_arraybuffer_allocations) return std::make_unique(); - else - return std::make_unique(); + // Always use ProfilingArrayBufferAllocator so that per-label external memory + // tracking is available when the sampling heap profiler is started via + // v8.startSamplingHeapProfiler(). When profiling is disabled (the default) + // the only overhead is a single atomic load (enabled_.load()) on each + // Allocate/Free — no hash-map lookups or CPED reads occur. + return std::make_unique(); } ArrayBufferAllocator* CreateArrayBufferAllocator() { diff --git a/src/node_internals.h b/src/node_internals.h index dbe36868600682..9551a3fa37b47a 100644 --- a/src/node_internals.h +++ b/src/node_internals.h @@ -123,6 +123,8 @@ v8::Maybe InitializePrimordials(v8::Local context, v8::MaybeLocal InitializePrivateSymbols( v8::Local context, IsolateData* isolate_data); +class ProfilingArrayBufferAllocator; // Forward declaration. + class NodeArrayBufferAllocator : public ArrayBufferAllocator { public: void* Allocate(size_t size) override; // Defined in src/node.cc @@ -136,6 +138,9 @@ class NodeArrayBufferAllocator : public ArrayBufferAllocator { } NodeArrayBufferAllocator* GetImpl() final { return this; } + virtual ProfilingArrayBufferAllocator* GetProfilingAllocator() { + return nullptr; + } inline uint64_t total_mem_usage() const { return total_mem_usage_.load(std::memory_order_relaxed); } @@ -164,6 +169,50 @@ class DebuggingArrayBufferAllocator final : public NodeArrayBufferAllocator { std::unordered_map allocations_; }; +// Subclass of NodeArrayBufferAllocator that tracks per-label external memory +// (Buffer/ArrayBuffer backing stores) when heap profiling with labels is active. +// When disabled (default), overhead is a single relaxed atomic load per alloc. +class ProfilingArrayBufferAllocator : public NodeArrayBufferAllocator { + public: + using LabelPairs = std::vector>; + + struct LabeledBytes { + LabelPairs labels; + int64_t bytes = 0; + }; + + void* Allocate(size_t size) override; + void* AllocateUninitialized(size_t size) override; + void Free(void* data, size_t size) override; + ProfilingArrayBufferAllocator* GetProfilingAllocator() override { + return this; + } + + // Called from StartSamplingHeapProfiler/StopSamplingHeapProfiler. + void Enable(v8::Isolate* isolate, v8::Global* als_key); + void Disable(); + + // Returns per-label live external bytes (for getAllocationProfile). + std::vector GetPerLabelBytes() const; + + private: + LabelPairs FindCurrentLabels(); + static std::string SerializeLabels(const LabelPairs& labels); + + std::atomic enabled_{false}; + v8::Isolate* isolate_ = nullptr; + // Borrowed pointer to BindingData::heap_profile_labels_als_key. + v8::Global* als_key_ = nullptr; + + std::thread::id main_thread_id_ = std::this_thread::get_id(); + + mutable Mutex mutex_; + // Maps allocation pointer to {serialized_label_key, size}. + std::unordered_map> allocations_; + // Per-serialized-label-key entry with full labels and live bytes. + std::unordered_map per_label_bytes_; +}; + namespace Buffer { v8::MaybeLocal Copy(Environment* env, const char* data, size_t len); v8::MaybeLocal New(Environment* env, size_t size); diff --git a/src/node_v8.cc b/src/node_v8.cc index 12972f83ea0f61..26e60840b486f7 100644 --- a/src/node_v8.cc +++ b/src/node_v8.cc @@ -25,13 +25,16 @@ #include "env-inl.h" #include "memory_tracker-inl.h" #include "node.h" +#include "node_internals.h" #include "node_external_reference.h" #include "util-inl.h" +#include "v8-container.h" #include "v8-profiler.h" #include "v8.h" namespace node { namespace v8_utils { +using v8::AllocationProfile; using v8::Array; using v8::BigInt; using v8::CFunction; @@ -44,12 +47,14 @@ using v8::FunctionCallbackInfo; using v8::FunctionTemplate; using v8::HandleScope; using v8::HeapCodeStatistics; +using v8::HeapProfiler; using v8::HeapSpaceStatistics; using v8::HeapStatistics; using v8::Integer; using v8::Isolate; using v8::Local; using v8::LocalVector; +using v8::Map; using v8::MaybeLocal; using v8::Number; using v8::Object; @@ -59,6 +64,63 @@ using v8::Uint32; using v8::V8; using v8::Value; +// V8 callback invoked at profile-read time (BuildSamples) for each sample +// that has a stored CPED. Receives the CPED (AsyncContextFrame = JS Map), +// looks up the heap profile labels ALS store value, and converts the +// pre-flattened [key1, val1, key2, val2, ...] array to string pairs. +// Labels are pre-flattened in JS at label-set time because this callback +// runs during BuildSamples() iteration — V8 Object property access +// (GetOwnPropertyNames) allocates and can trigger GC, which removes samples +// via weak callbacks and invalidates the iterator. Dense array access +// (Array::Get on PACKED_ELEMENTS) does NOT allocate V8 objects. +static bool HeapProfileLabelsCallback( + void* data, v8::Local context, + std::vector>* out_labels) { + auto* binding_data = static_cast(data); + if (!binding_data) return false; + if (binding_data->heap_profile_labels_als_key.IsEmpty()) return false; + + // The stored CPED is an AsyncContextFrame (extends Map). + if (context.IsEmpty() || !context->IsMap()) return false; + + Isolate* isolate = binding_data->env()->isolate(); + HandleScope handle_scope(isolate); + Local v8_context = isolate->GetCurrentContext(); + + Local frame = context.As(); + Local als_key = + binding_data->heap_profile_labels_als_key.Get(isolate); + + // Look up the labels ALS store value in the AsyncContextFrame. + Local labels_val; + if (!frame->Get(v8_context, als_key).ToLocal(&labels_val) || + !labels_val->IsArray()) { + return false; + } + + // Convert flat [key1, val1, key2, val2, ...] array to string pairs. + Local flat = labels_val.As(); + uint32_t len = flat->Length(); + for (uint32_t i = 0; i + 1 < len; i += 2) { + Local key_val, val_val; + if (!flat->Get(v8_context, i).ToLocal(&key_val)) return false; + if (!flat->Get(v8_context, i + 1).ToLocal(&val_val)) return false; + String::Utf8Value key(isolate, key_val); + String::Utf8Value val(isolate, val_val); + out_labels->emplace_back(*key, *val); + } + return !out_labels->empty(); +} + +// C++ binding: store the AsyncLocalStorage instance used for heap profile +// labels. The callback uses this as the Map key to look up labels in the +// stored CPED (AsyncContextFrame) at profile-read time. +void SetHeapProfileLabelsStore(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + BindingData* binding_data = Realm::GetBindingData(args); + binding_data->heap_profile_labels_als_key.Reset(isolate, args[0]); +} + #define HEAP_STATISTICS_PROPERTIES(V) \ V(0, total_heap_size, kTotalHeapSizeIndex) \ V(1, total_heap_size_executable, kTotalHeapSizeExecutableIndex) \ @@ -186,6 +248,9 @@ void BindingData::MemoryInfo(MemoryTracker* tracker) const { heap_space_statistics_buffer); tracker->TrackField("heap_code_statistics_buffer", heap_code_statistics_buffer); + tracker->TrackFieldWithSize("heap_profile_labels_als_key", + heap_profile_labels_als_key.IsEmpty() ? 0 : + sizeof(v8::Global)); } void CachedDataVersionTag(const FunctionCallbackInfo& args) { @@ -673,6 +738,171 @@ void GCProfiler::Stop(const FunctionCallbackInfo& args) { } } +void StartSamplingHeapProfiler(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + Local context = isolate->GetCurrentContext(); + HeapProfiler* profiler = isolate->GetHeapProfiler(); + BindingData* binding_data = Realm::GetBindingData(args); + uint64_t interval = 512 * 1024; // Default: 512 KB + if (args.Length() > 0 && args[0]->IsNumber()) { + interval = static_cast(args[0].As()->Value()); + } + int stack_depth = 16; // Default stack depth + if (args.Length() > 1 && args[1]->IsNumber()) { + stack_depth = static_cast(args[1].As()->Value()); + } + profiler->SetHeapProfileSampleLabelsCallback( + HeapProfileLabelsCallback, binding_data); + // By default, GC'd samples are removed from the profile (live-memory mode). + // When includeCollectedObjects is true, retain GC'd samples so allocation + // attribution reflects total allocations (allocation-rate mode). + v8::HeapProfiler::SamplingFlags flags = + static_cast( + v8::HeapProfiler::kSamplingNoFlags); + if (args.Length() > 2 && args[2]->IsObject()) { + Local options = args[2].As(); + Local key = String::NewFromUtf8Literal(isolate, + "includeCollectedObjects"); + Local val; + if (options->Get(context, key).ToLocal(&val) && val->IsTrue()) { + flags = static_cast( + v8::HeapProfiler::kSamplingIncludeObjectsCollectedByMajorGC | + v8::HeapProfiler::kSamplingIncludeObjectsCollectedByMinorGC); + } + } + profiler->StartSamplingHeapProfiler(interval, stack_depth, flags); + + // Enable external memory tracking on the allocator if it supports profiling. + Environment* env = Environment::GetCurrent(args); + auto* node_allocator = env->isolate_data()->node_allocator(); + auto* profiling_allocator = node_allocator != nullptr + ? node_allocator->GetProfilingAllocator() : nullptr; + if (profiling_allocator != nullptr) { + profiling_allocator->Enable( + isolate, &binding_data->heap_profile_labels_als_key); + } +} + +void StopSamplingHeapProfiler(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + HeapProfiler* profiler = isolate->GetHeapProfiler(); + profiler->StopSamplingHeapProfiler(); + profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); + + Environment* env = Environment::GetCurrent(args); + auto* node_allocator = env->isolate_data()->node_allocator(); + auto* profiling_allocator = node_allocator != nullptr + ? node_allocator->GetProfilingAllocator() : nullptr; + if (profiling_allocator != nullptr) { + profiling_allocator->Disable(); + } +} + +void GetAllocationProfile(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + HeapProfiler* profiler = isolate->GetHeapProfiler(); + HandleScope scope(isolate); + Local context = isolate->GetCurrentContext(); + + std::unique_ptr profile(profiler->GetAllocationProfile()); + if (!profile) { + return; // Returns undefined if profiler not started + } + + const std::vector& samples = profile->GetSamples(); + Local js_samples = Array::New(isolate, samples.size()); + + for (size_t i = 0; i < samples.size(); i++) { + const AllocationProfile::Sample& sample = samples[i]; + Local js_sample = Object::New(isolate); + + if (js_sample->Set(context, + FIXED_ONE_BYTE_STRING(isolate, "nodeId"), + Integer::NewFromUnsigned(isolate, sample.node_id)) + .IsNothing()) return; + if (js_sample->Set(context, + FIXED_ONE_BYTE_STRING(isolate, "size"), + Number::New(isolate, static_cast(sample.size))) + .IsNothing()) return; + if (js_sample->Set(context, + FIXED_ONE_BYTE_STRING(isolate, "count"), + Integer::NewFromUnsigned(isolate, sample.count)) + .IsNothing()) return; + if (js_sample->Set(context, + FIXED_ONE_BYTE_STRING(isolate, "sampleId"), + Number::New(isolate, static_cast(sample.sample_id))) + .IsNothing()) return; + + // Always emit labels field (empty {} when no labels captured) + Local js_labels = Object::New(isolate); + for (const auto& label : sample.labels) { + Local key; + if (!String::NewFromUtf8(isolate, label.first.c_str(), + v8::NewStringType::kNormal) + .ToLocal(&key)) return; + Local value; + if (!String::NewFromUtf8(isolate, label.second.c_str(), + v8::NewStringType::kNormal) + .ToLocal(&value)) return; + if (js_labels->Set(context, key, value).IsNothing()) return; + } + if (js_sample->Set(context, + FIXED_ONE_BYTE_STRING(isolate, "labels"), + js_labels).IsNothing()) return; + + if (js_samples->Set(context, i, js_sample).IsNothing()) return; + } + + Local result = Object::New(isolate); + if (result->Set(context, + FIXED_ONE_BYTE_STRING(isolate, "samples"), + js_samples).IsNothing()) return; + + // Include per-label external memory (Buffer/ArrayBuffer) if profiling + // allocator is active. Each entry has { labels: { key: val, ... }, bytes: N } + // matching the labels structure used in heap samples. + Environment* env = Environment::GetCurrent(args); + auto* node_allocator = env->isolate_data()->node_allocator(); + auto* profiling_allocator = node_allocator != nullptr + ? node_allocator->GetProfilingAllocator() : nullptr; + if (profiling_allocator != nullptr) { + auto per_label = profiling_allocator->GetPerLabelBytes(); + if (!per_label.empty()) { + Local js_external = Array::New(isolate, per_label.size()); + for (size_t idx = 0; idx < per_label.size(); idx++) { + const auto& entry = per_label[idx]; + Local js_entry = Object::New(isolate); + Local js_labels = Object::New(isolate); + for (const auto& [lk, lv] : entry.labels) { + Local key; + if (!String::NewFromUtf8(isolate, lk.c_str(), + v8::NewStringType::kNormal) + .ToLocal(&key)) return; + Local value; + if (!String::NewFromUtf8(isolate, lv.c_str(), + v8::NewStringType::kNormal) + .ToLocal(&value)) return; + if (js_labels->Set(context, key, value).IsNothing()) return; + } + if (js_entry->Set(context, + FIXED_ONE_BYTE_STRING(isolate, "labels"), + js_labels).IsNothing()) return; + if (js_entry->Set(context, + FIXED_ONE_BYTE_STRING(isolate, "bytes"), + Number::New(isolate, + static_cast(entry.bytes))) + .IsNothing()) return; + if (js_external->Set(context, idx, js_entry).IsNothing()) return; + } + if (result->Set(context, + FIXED_ONE_BYTE_STRING(isolate, "externalBytes"), + js_external).IsNothing()) return; + } + } + + args.GetReturnValue().Set(result); +} + void Initialize(Local target, Local unused, Local context, @@ -682,6 +912,40 @@ void Initialize(Local target, BindingData* const binding_data = realm->AddBindingData(target); if (binding_data == nullptr) return; + // Clean up heap profiler state on Environment teardown. + // If the profiler was started but not stopped, V8's HeapProfiler holds + // a raw void* to BindingData and the allocator borrows label_map_. + // Clear these before destroying BindingData to prevent dangling pointers. + // + // Capture isolate and allocator pointers at registration time because + // BindingData->env() may not be safe to dereference during cleanup. + { + Isolate* isolate = env->isolate(); + auto* node_allocator = env->isolate_data()->node_allocator(); + auto* profiling_allocator = node_allocator != nullptr + ? node_allocator->GetProfilingAllocator() : nullptr; + struct CleanupData { + BindingData* binding_data; + Isolate* isolate; + ProfilingArrayBufferAllocator* profiling_allocator; + }; + auto* cleanup = new CleanupData{binding_data, isolate, + profiling_allocator}; + env->AddCleanupHook([](void* data) { + auto* ctx = static_cast(data); + + // Clear V8 callback pointer that references the BindingData. + ctx->isolate->GetHeapProfiler()->SetHeapProfileSampleLabelsCallback( + nullptr, nullptr); + + if (ctx->profiling_allocator != nullptr) { + ctx->profiling_allocator->Disable(); + } + + delete ctx; + }, cleanup); + } + SetMethodNoSideEffect( context, target, "cachedDataVersionTag", CachedDataVersionTag); SetMethodNoSideEffect(context, @@ -741,6 +1005,16 @@ void Initialize(Local target, SetMethod(context, target, "startCpuProfile", StartCpuProfile); SetMethod(context, target, "stopCpuProfile", StopCpuProfile); + // Sampling heap profiler with context support + SetMethod(context, target, "startSamplingHeapProfiler", + StartSamplingHeapProfiler); + SetMethod(context, target, "stopSamplingHeapProfiler", + StopSamplingHeapProfiler); + SetMethod(context, target, "getAllocationProfile", + GetAllocationProfile); + SetMethod(context, target, "setHeapProfileLabelsStore", + SetHeapProfileLabelsStore); + // Export symbols used by v8.isStringOneByteRepresentation() SetFastMethodNoSideEffect(context, target, @@ -787,6 +1061,10 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(fast_is_string_one_byte_representation_); registry->Register(StartCpuProfile); registry->Register(StopCpuProfile); + registry->Register(StartSamplingHeapProfiler); + registry->Register(StopSamplingHeapProfiler); + registry->Register(GetAllocationProfile); + registry->Register(SetHeapProfileLabelsStore); } } // namespace v8_utils diff --git a/src/node_v8.h b/src/node_v8.h index 581972b13d4e3c..0eeb9f022f6dfc 100644 --- a/src/node_v8.h +++ b/src/node_v8.h @@ -4,6 +4,7 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS #include +#include #include "aliased_buffer.h" #include "base_object.h" #include "json_utils.h" @@ -17,6 +18,7 @@ class Environment; struct InternalFieldInfoBase; namespace v8_utils { + class BindingData : public SnapshotableObject { public: struct InternalFieldInfo : public node::InternalFieldInfoBase { @@ -35,6 +37,12 @@ class BindingData : public SnapshotableObject { AliasedFloat64Array heap_space_statistics_buffer; AliasedFloat64Array heap_code_statistics_buffer; + // Reference to the JS AsyncLocalStorage instance used by + // withHeapProfileLabels/setHeapProfileLabels. The V8 callback uses this + // as the key to look up label values in the stored CPED (AsyncContextFrame + // Map) at profile-read time. + v8::Global heap_profile_labels_als_key; + void MemoryInfo(MemoryTracker* tracker) const override; SET_SELF_SIZE(BindingData) SET_MEMORY_INFO_NAME(BindingData) diff --git a/test/cctest/test_heap_profile_labels.cc b/test/cctest/test_heap_profile_labels.cc new file mode 100644 index 00000000000000..5eb95310c1391e --- /dev/null +++ b/test/cctest/test_heap_profile_labels.cc @@ -0,0 +1,379 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +// Tests for V8 HeapProfileSampleLabelsCallback API. +// Validates the label callback feature at the V8 public API level. + +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "node_test_fixture.h" +#include "v8-profiler.h" +#include "v8.h" + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + +// Helper: a label callback that writes fixed labels via output parameter. +static bool FixedLabelsCallback( + void* data, v8::Local context, + std::vector>* out_labels) { + auto* labels = + static_cast>*>(data); + *out_labels = *labels; + return true; +} + +// Helper: a label callback that returns false (no labels). +static bool EmptyLabelsCallback( + void* data, v8::Local context, + std::vector>* out_labels) { + return false; +} + +// Helper: a label callback that resolves labels based on the CPED string value. +// Used to verify that different CPED values produce different labels. +struct ContextLabelState { + v8::Isolate* isolate; +}; + +static bool ContextBasedLabelsCallback( + void* data, v8::Local context, + std::vector>* out_labels) { + if (context.IsEmpty() || !context->IsString()) return false; + auto* state = static_cast(data); + v8::String::Utf8Value utf8(state->isolate, context); + std::string cped_str(*utf8, utf8.length()); + if (cped_str == "first") { + out_labels->push_back({"route", "/api/first"}); + } else if (cped_str == "second") { + out_labels->push_back({"route", "/api/second"}); + } + return !out_labels->empty(); +} + +class HeapProfileLabelsTest : public NodeTestFixture {}; + +// Test: register callback, allocate, verify labels on samples. +TEST_F(HeapProfileLabelsTest, CallbackReturnsLabels) { + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + + v8::HeapProfiler* heap_profiler = isolate_->GetHeapProfiler(); + + std::vector> labels = { + {"route", "/api/test"}}; + + // Set CPED so the callback gets invoked (requires non-empty context). + isolate_->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate_, "test-context")); + + heap_profiler->SetHeapProfileSampleLabelsCallback(FixedLabelsCallback, + &labels); + + heap_profiler->StartSamplingHeapProfiler(256); + + // Allocate enough objects to get samples. + for (int i = 0; i < 8 * 1024; ++i) v8::Object::New(isolate_); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + ASSERT_NE(profile, nullptr); + + bool found_labeled = false; + for (const auto& sample : profile->GetSamples()) { + if (!sample.labels.empty()) { + EXPECT_EQ(sample.labels.size(), 1u); + EXPECT_EQ(sample.labels[0].first, "route"); + EXPECT_EQ(sample.labels[0].second, "/api/test"); + found_labeled = true; + } + } + EXPECT_TRUE(found_labeled); + + heap_profiler->StopSamplingHeapProfiler(); + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); +} + +// Test: no callback registered — samples have empty labels. +TEST_F(HeapProfileLabelsTest, NoCallbackEmptyLabels) { + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + + v8::HeapProfiler* heap_profiler = isolate_->GetHeapProfiler(); + + heap_profiler->StartSamplingHeapProfiler(256); + + for (int i = 0; i < 8 * 1024; ++i) v8::Object::New(isolate_); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + ASSERT_NE(profile, nullptr); + + for (const auto& sample : profile->GetSamples()) { + EXPECT_TRUE(sample.labels.empty()); + } + + heap_profiler->StopSamplingHeapProfiler(); +} + +// Test: callback returns empty vector — samples have empty labels. +TEST_F(HeapProfileLabelsTest, EmptyCallbackEmptyLabels) { + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + + v8::HeapProfiler* heap_profiler = isolate_->GetHeapProfiler(); + + // Set CPED so callback is invoked. + isolate_->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate_, "test-context")); + + heap_profiler->SetHeapProfileSampleLabelsCallback(EmptyLabelsCallback, + nullptr); + + heap_profiler->StartSamplingHeapProfiler(256); + + for (int i = 0; i < 8 * 1024; ++i) v8::Object::New(isolate_); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + ASSERT_NE(profile, nullptr); + + for (const auto& sample : profile->GetSamples()) { + EXPECT_TRUE(sample.labels.empty()); + } + + heap_profiler->StopSamplingHeapProfiler(); + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); +} + +// Test: multiple distinct label sets resolved from different CPED values. +// Labels are resolved at read time (BuildSamples) from stored CPED. +TEST_F(HeapProfileLabelsTest, MultipleDistinctLabels) { + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + + v8::HeapProfiler* heap_profiler = isolate_->GetHeapProfiler(); + + ContextLabelState state{isolate_}; + heap_profiler->SetHeapProfileSampleLabelsCallback(ContextBasedLabelsCallback, + &state); + + heap_profiler->StartSamplingHeapProfiler(256); + + // Allocate with first CPED value. + isolate_->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate_, "first")); + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate_); + + // Switch to second CPED value. + isolate_->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate_, "second")); + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate_); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + ASSERT_NE(profile, nullptr); + + bool found_first = false; + bool found_second = false; + for (const auto& sample : profile->GetSamples()) { + if (!sample.labels.empty()) { + EXPECT_EQ(sample.labels.size(), 1u); + EXPECT_EQ(sample.labels[0].first, "route"); + if (sample.labels[0].second == "/api/first") found_first = true; + if (sample.labels[0].second == "/api/second") found_second = true; + } + } + EXPECT_TRUE(found_first); + EXPECT_TRUE(found_second); + + heap_profiler->StopSamplingHeapProfiler(); + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); +} + +// Test: labels survive GC when kSamplingIncludeObjectsCollectedByMajorGC enabled. +TEST_F(HeapProfileLabelsTest, LabelsSurviveGCWithRetainFlags) { + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + + v8::HeapProfiler* heap_profiler = isolate_->GetHeapProfiler(); + + // Set CPED so callback is invoked. + isolate_->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate_, "test-context")); + + std::vector> labels = { + {"route", "/api/gc-test"}}; + heap_profiler->SetHeapProfileSampleLabelsCallback(FixedLabelsCallback, + &labels); + + // Start with GC retain flags — GC'd samples should survive. + heap_profiler->StartSamplingHeapProfiler( + 256, 128, + static_cast( + v8::HeapProfiler::kSamplingIncludeObjectsCollectedByMajorGC | + v8::HeapProfiler::kSamplingIncludeObjectsCollectedByMinorGC)); + + // Allocate short-lived objects via JS (no reference retained). + v8::Local source = + v8::String::NewFromUtf8Literal(isolate_, + "for (var i = 0; i < 4096; i++) { new Array(64); }"); + v8::Local script = + v8::Script::Compile(context, source).ToLocalChecked(); + script->Run(context).ToLocalChecked(); + + // Force GC to collect the short-lived objects. + v8::V8::SetFlagsFromString("--expose-gc"); + isolate_->RequestGarbageCollectionForTesting( + v8::Isolate::kFullGarbageCollection); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + ASSERT_NE(profile, nullptr); + + // With GC retain flags, samples for collected objects should still exist + // with their labels intact. + bool found_labeled = false; + for (const auto& sample : profile->GetSamples()) { + if (!sample.labels.empty()) { + EXPECT_EQ(sample.labels[0].first, "route"); + EXPECT_EQ(sample.labels[0].second, "/api/gc-test"); + found_labeled = true; + } + } + EXPECT_TRUE(found_labeled); + + heap_profiler->StopSamplingHeapProfiler(); + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); +} + +// Test: labels removed with samples when GC flags disabled and objects collected. +TEST_F(HeapProfileLabelsTest, SamplesRemovedByGCWithoutFlags) { + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + + v8::HeapProfiler* heap_profiler = isolate_->GetHeapProfiler(); + + // Set CPED so callback is invoked. + isolate_->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate_, "test-context")); + + std::vector> labels = { + {"route", "/api/gc-remove"}}; + heap_profiler->SetHeapProfileSampleLabelsCallback(FixedLabelsCallback, + &labels); + + // Start WITHOUT GC retain flags — GC'd samples should be removed. + heap_profiler->StartSamplingHeapProfiler(256); + + // Allocate short-lived objects via JS (no reference retained). + v8::Local source = + v8::String::NewFromUtf8Literal(isolate_, + "for (var i = 0; i < 4096; i++) { new Array(64); }"); + v8::Local script = + v8::Script::Compile(context, source).ToLocalChecked(); + script->Run(context).ToLocalChecked(); + + // Force GC to collect the short-lived objects. + v8::V8::SetFlagsFromString("--expose-gc"); + isolate_->RequestGarbageCollectionForTesting( + v8::Isolate::kFullGarbageCollection); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + ASSERT_NE(profile, nullptr); + + // Without GC retain flags, samples for collected objects should be removed. + // The profile should still be valid (no crash). + EXPECT_NE(profile->GetRootNode(), nullptr); + + heap_profiler->StopSamplingHeapProfiler(); + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); +} + +// Test: unregister callback — samples allocated after have no stored CPED. +// In the new architecture, CPED is only captured when a callback is registered +// at allocation time. Labels are resolved at read time. Samples without CPED +// get no labels even when the callback is re-registered for reading. +TEST_F(HeapProfileLabelsTest, UnregisterCallbackStopsLabels) { + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + + v8::HeapProfiler* heap_profiler = isolate_->GetHeapProfiler(); + + // Set CPED so it's available during allocation. + isolate_->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate_, "test-context")); + + std::vector> labels = { + {"route", "/api/before-unregister"}}; + heap_profiler->SetHeapProfileSampleLabelsCallback(FixedLabelsCallback, + &labels); + + heap_profiler->StartSamplingHeapProfiler(256); + + // Allocate with callback active — CPED is captured on these samples. + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate_); + + // Unregister callback — new samples won't have CPED captured. + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); + + // Allocate more — no CPED captured since callback is null at allocation time. + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate_); + + // Re-register callback before reading profile. Labels are resolved at + // read time, so only samples with stored CPED will get labels. + heap_profiler->SetHeapProfileSampleLabelsCallback(FixedLabelsCallback, + &labels); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + ASSERT_NE(profile, nullptr); + + // Should have labeled samples (CPED captured before unregister) and + // unlabeled samples (no CPED captured after unregister). + bool found_labeled = false; + bool found_unlabeled = false; + for (const auto& sample : profile->GetSamples()) { + if (!sample.labels.empty()) { + found_labeled = true; + } else { + found_unlabeled = true; + } + } + EXPECT_TRUE(found_labeled); + EXPECT_TRUE(found_unlabeled); + + heap_profiler->StopSamplingHeapProfiler(); + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); +} + +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS From ad489eda784a633d3c888e1311561b36c89c7617 Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Fri, 10 Apr 2026 11:21:08 +0200 Subject: [PATCH 3/6] lib: add heap profile labels API to v8 module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS API for heap profile label attribution: - withHeapProfileLabels(labels, fn): scoped labels via AsyncLocalStorage — just ALS.run with pre-flattened label array, zero C++ calls - setHeapProfileLabels(labels): enterWith semantics for frameworks where the handler runs after the middleware returns (e.g., Hapi) - startSamplingHeapProfiler with includeCollectedObjects option - stopSamplingHeapProfiler and getAllocationProfile with labels Labels are pre-flattened to [key, val, key, val, ...] arrays at set time for GC safety — the C++ callback runs during BuildSamples() iteration where V8 object allocation could invalidate the iterator. Signed-off-by: Rudolf Meijering --- lib/v8.js | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/lib/v8.js b/lib/v8.js index c0d4074aac21d5..5ae97038580dcf 100644 --- a/lib/v8.js +++ b/lib/v8.js @@ -16,6 +16,7 @@ const { Array, + ArrayPrototypePush, BigInt64Array, BigUint64Array, DataView, @@ -26,7 +27,9 @@ const { Int32Array, Int8Array, JSONParse, + ObjectKeys, ObjectPrototypeToString, + String, SymbolDispose, Uint16Array, Uint32Array, @@ -38,7 +41,10 @@ const { } = primordials; const { Buffer } = require('buffer'); +const { AsyncLocalStorage } = require('async_hooks'); const { + validateFunction, + validateObject, validateString, validateUint32, validateOneOf, @@ -156,6 +162,11 @@ const { heapSpaceStatisticsBuffer, getCppHeapStatistics: _getCppHeapStatistics, detailLevel, + + startSamplingHeapProfiler: _startSamplingHeapProfiler, + stopSamplingHeapProfiler: _stopSamplingHeapProfiler, + getAllocationProfile: _getAllocationProfile, + setHeapProfileLabelsStore: _setHeapProfileLabelsStore, } = binding; const kNumberOfHeapSpaces = kHeapSpaces.length; @@ -494,6 +505,81 @@ class GCProfiler { } } +// --- Heap profile labels API --- +// Internal AsyncLocalStorage for propagating labels through async context. +// Requires --experimental-async-context-frame (Node 22) or Node 24+. +const _heapProfileLabelsALS = new AsyncLocalStorage(); +// Register the ALS instance with C++ so the V8 callback can look up +// label values from stored CPED (AsyncContextFrame Map) at read time. +_setHeapProfileLabelsStore(_heapProfileLabelsALS); + +/** + * Convert a labels object to a flat array [key1, val1, key2, val2, ...]. + * Pre-flattened at label-set time (not per allocation/sample) because the + * C++ callback runs during BuildSamples() iteration where V8 Object property + * access could allocate and trigger GC, invalidating the sample iterator. + * @param {Record} labels + * @returns {string[]} + */ +function labelsToFlat(labels) { + const keys = ObjectKeys(labels); + const flat = []; + for (let i = 0; i < keys.length; i++) { + ArrayPrototypePush(flat, String(keys[i]), String(labels[keys[i]])); + } + return flat; +} + +/** + * Starts the V8 sampling heap profiler. + * @param {number} [sampleInterval] - Average bytes between samples (default 512 KB). + * @param {number} [stackDepth] - Maximum stack depth for samples (default 16). + * @param {object} [options] - Options object. + * @param {boolean} [options.includeCollectedObjects] - If true, retain + * samples for objects collected by GC (allocation-rate mode). + */ +function startSamplingHeapProfiler(sampleInterval, stackDepth, options) { + if (sampleInterval !== undefined) validateUint32(sampleInterval, 'sampleInterval'); + if (stackDepth !== undefined) validateUint32(stackDepth, 'stackDepth'); + if (options !== undefined) validateObject(options, 'options'); + return _startSamplingHeapProfiler(sampleInterval, stackDepth, options); +} + +/** + * Runs `fn` with the given heap profile labels active. Labels propagate + * across `await` boundaries via AsyncLocalStorage. If `fn` returns a + * Promise, labels remain active until the Promise settles. + * + * @param {Record} labels + * @param {Function} fn + * @returns {*} The return value of `fn`. + */ +function withHeapProfileLabels(labels, fn) { + validateObject(labels, 'labels'); + validateFunction(fn, 'fn'); + // Store the flat [key1, val1, key2, val2, ...] array in ALS. + // Conversion happens once at label-set time (not per allocation). + // Must stay pre-flattened because the C++ callback runs during + // BuildSamples iteration — Object property access would allocate + // V8 objects, potentially triggering GC and invalidating the iterator. + const flat = labelsToFlat(labels); + return _heapProfileLabelsALS.run(flat, fn); +} + +/** + * Sets heap profile labels for the current async scope using + * `enterWith` semantics. Labels persist until overwritten or the + * async scope ends. Useful for frameworks (e.g. Hapi) where the + * handler runs after the extension returns. + * + * @param {Record} labels + */ +function setHeapProfileLabels(labels) { + validateObject(labels, 'labels'); + const flat = labelsToFlat(labels); + _heapProfileLabelsALS.enterWith(flat); +} + module.exports = { cachedDataVersionTag, getHeapSnapshot, @@ -518,4 +604,9 @@ module.exports = { GCProfiler, isStringOneByteRepresentation, startCpuProfile, + startSamplingHeapProfiler, + stopSamplingHeapProfiler: _stopSamplingHeapProfiler, + getAllocationProfile: _getAllocationProfile, + withHeapProfileLabels, + setHeapProfileLabels, }; From 34b5ffdc60b7bbb122529777c09cfc456ae7fd3e Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Fri, 10 Apr 2026 11:21:15 +0200 Subject: [PATCH 4/6] test: add heap profile labels tests JS parallel tests: - test-v8-heap-profile-labels: basic labeling, multi-key, JSON round-trip, GC retention/removal, includeCollectedObjects flag, setHeapProfileLabels leak check, CPED identity test (verifies labels survive when another ALS store changes the CPED address) - test-v8-heap-profile-labels-async: await boundary propagation, concurrent contexts, Hapi-style setHeapProfileLabels - test-v8-heap-profile-external: Buffer/ArrayBuffer per-label externalBytes tracking, GC cleanup, unlabeled isolation Signed-off-by: Rudolf Meijering --- .../parallel/test-v8-heap-profile-external.js | 257 +++++++++++++ .../test-v8-heap-profile-labels-async.js | 153 ++++++++ test/parallel/test-v8-heap-profile-labels.js | 345 ++++++++++++++++++ 3 files changed, 755 insertions(+) create mode 100644 test/parallel/test-v8-heap-profile-external.js create mode 100644 test/parallel/test-v8-heap-profile-labels-async.js create mode 100644 test/parallel/test-v8-heap-profile-labels.js diff --git a/test/parallel/test-v8-heap-profile-external.js b/test/parallel/test-v8-heap-profile-external.js new file mode 100644 index 00000000000000..60e3197e113310 --- /dev/null +++ b/test/parallel/test-v8-heap-profile-external.js @@ -0,0 +1,257 @@ +// Flags: --expose-gc +'use strict'; + +require('../common'); +const assert = require('assert'); +const v8 = require('v8'); + +// Helper: find an externalBytes entry whose labels match a predicate. +function findExternal(profile, predicate) { + if (!Array.isArray(profile.externalBytes)) return undefined; + return profile.externalBytes.find(predicate); +} + +// Helper: find an externalBytes entry by a single label key-value pair. +function findByLabel(profile, key, value) { + return findExternal(profile, (e) => e.labels[key] === value); +} + +// Test 1: Buffer.alloc() inside withHeapProfileLabels is attributed to the +// correct label in externalBytes. +{ + v8.startSamplingHeapProfiler(512 * 1024); // 512KB interval (default) + + // Allocate 10MB Buffer inside a labeled context. + const buf = v8.withHeapProfileLabels({ route: '/heavy' }, () => { + const b = Buffer.alloc(10 * 1024 * 1024); + // Keep buf alive. + assert.strictEqual(b.length, 10 * 1024 * 1024); + return b; + }); + + const profile = v8.getAllocationProfile(); + assert.ok(profile, 'profile should exist'); + + assert.ok(Array.isArray(profile.externalBytes), + 'externalBytes should be an array (ProfilingArrayBufferAllocator active)'); + { + const entry = findByLabel(profile, 'route', '/heavy'); + assert.ok(entry, 'Expected entry for route=/heavy in externalBytes'); + assert.ok(entry.bytes > 0, + `Expected /heavy external bytes > 0, got ${entry.bytes}`); + // The 10MB Buffer should show up (allow some tolerance for overhead). + assert.ok(entry.bytes >= 9 * 1024 * 1024, + `Expected /heavy >= 9MB, got ${entry.bytes}`); + } + + // Keep buf alive until after profile is read. + assert.ok(buf.length > 0); + v8.stopSamplingHeapProfiler(); +} + +// Test 2: Buffer.alloc() outside any label context is not tracked. +{ + v8.startSamplingHeapProfiler(512 * 1024); + + // Allocate outside any label context. + const buf = Buffer.alloc(5 * 1024 * 1024); + assert.strictEqual(buf.length, 5 * 1024 * 1024); + + const profile = v8.getAllocationProfile(); + assert.ok(profile, 'profile should exist'); + + // externalBytes should be empty or have zero bytes (no labeled allocations). + if (Array.isArray(profile.externalBytes)) { + const totalLabeled = profile.externalBytes + .reduce((a, e) => a + e.bytes, 0); + assert.strictEqual(totalLabeled, 0, + `Expected 0 labeled external bytes, got ${totalLabeled}`); + } + + v8.stopSamplingHeapProfiler(); +} + +// Test 3: After dropping Buffer references and forcing GC, per-label bytes +// decrease (Free is called). +{ + v8.startSamplingHeapProfiler(512 * 1024); + + let profile; + + v8.withHeapProfileLabels({ route: '/gc-test' }, () => { + // Create a Buffer, then let it be GC'd. + let buf = Buffer.alloc(8 * 1024 * 1024); + assert.strictEqual(buf.length, 8 * 1024 * 1024); + + profile = v8.getAllocationProfile(); + assert.ok(Array.isArray(profile.externalBytes), + 'externalBytes should be an array (ProfilingArrayBufferAllocator active)'); + { + const entry = findByLabel(profile, 'route', '/gc-test'); + if (entry) { + assert.ok(entry.bytes >= 7 * 1024 * 1024, + `Expected /gc-test >= 7MB before GC, got ${entry.bytes}`); + } + } + + // Drop reference and force GC. + buf = null; + }); + + global.gc(); + global.gc(); + + profile = v8.getAllocationProfile(); + // After GC, externalBytes may be absent if all labeled allocations were freed. + { + const entry = Array.isArray(profile.externalBytes) + ? findByLabel(profile, 'route', '/gc-test') : undefined; + const afterGC = entry ? entry.bytes : 0; + // After GC, the buffer should be freed and the count should decrease. + // It may not go to exactly 0 due to other small allocations. + assert.ok(afterGC < 8 * 1024 * 1024, + `Expected /gc-test < 8MB after GC, got ${afterGC}`); + } + + v8.stopSamplingHeapProfiler(); +} + +// Test 4: Multiple labels — allocate Buffers with different labels, verify +// externalBytes shows correct per-label totals. +{ + v8.startSamplingHeapProfiler(512 * 1024); + + const bufs = []; + v8.withHeapProfileLabels({ route: '/api/users' }, () => { + bufs.push(Buffer.alloc(4 * 1024 * 1024)); + }); + + v8.withHeapProfileLabels({ route: '/api/orders' }, () => { + bufs.push(Buffer.alloc(6 * 1024 * 1024)); + }); + + const profile = v8.getAllocationProfile(); + assert.ok(profile, 'profile should exist'); + + assert.ok(Array.isArray(profile.externalBytes), + 'externalBytes should be an array (ProfilingArrayBufferAllocator active)'); + { + const usersEntry = findByLabel(profile, 'route', '/api/users'); + const ordersEntry = findByLabel(profile, 'route', '/api/orders'); + const usersBytes = usersEntry ? usersEntry.bytes : 0; + const ordersBytes = ordersEntry ? ordersEntry.bytes : 0; + assert.ok(usersBytes >= 3 * 1024 * 1024, + `Expected /api/users >= 3MB, got ${usersBytes}`); + assert.ok(ordersBytes >= 5 * 1024 * 1024, + `Expected /api/orders >= 5MB, got ${ordersBytes}`); + // Orders should have more external memory than users. + assert.ok(ordersBytes > usersBytes, + `Expected /api/orders (${ordersBytes}) > /api/users (${usersBytes})`); + } + + // Keep bufs alive. + assert.ok(bufs.length === 2); + v8.stopSamplingHeapProfiler(); +} + +// Test 5: JSON serialization of the profile includes externalBytes. +{ + v8.startSamplingHeapProfiler(512 * 1024); + + const buf = v8.withHeapProfileLabels({ route: '/json-test' }, () => { + return Buffer.alloc(2 * 1024 * 1024); + }); + + const profile = v8.getAllocationProfile(); + const json = JSON.stringify(profile); + const parsed = JSON.parse(json); + + assert.ok(Array.isArray(parsed.samples), 'samples should be an array'); + if (Array.isArray(parsed.externalBytes)) { + const entry = parsed.externalBytes.find( + (e) => e.labels && e.labels.route === '/json-test' + ); + assert.ok(entry, 'Expected /json-test in serialized externalBytes'); + assert.ok(entry.bytes > 0, + `Expected /json-test bytes > 0, got ${entry.bytes}`); + } + + // Keep buf alive. + assert.ok(buf.length > 0); + v8.stopSamplingHeapProfiler(); +} + +// Test 6: Multi-label context — both key-value pairs appear in externalBytes. +{ + v8.startSamplingHeapProfiler(512 * 1024); + + const buf = v8.withHeapProfileLabels( + { route: '/foo', handler: 'bar' }, () => { + return Buffer.alloc(3 * 1024 * 1024); + }); + + const profile = v8.getAllocationProfile(); + assert.ok(profile, 'profile should exist'); + + assert.ok(Array.isArray(profile.externalBytes), + 'externalBytes should be an array (ProfilingArrayBufferAllocator active)'); + { + const entry = findExternal(profile, + (e) => e.labels.route === '/foo' && e.labels.handler === 'bar'); + assert.ok(entry, + 'Expected entry with both route=/foo and handler=bar'); + assert.ok(entry.bytes >= 2 * 1024 * 1024, + `Expected multi-label entry >= 2MB, got ${entry.bytes}`); + // Verify both keys are present. + assert.strictEqual(entry.labels.route, '/foo'); + assert.strictEqual(entry.labels.handler, 'bar'); + } + + // Keep buf alive. + assert.ok(buf.length > 0); + v8.stopSamplingHeapProfiler(); +} + +// Test 7: externalBytes labels match heap sample labels for same context. +{ + v8.startSamplingHeapProfiler(64); // Small interval to increase sample hits + + const buf = v8.withHeapProfileLabels({ route: '/match-test' }, () => { + // Allocate both heap objects and a Buffer in the same label context. + const arr = []; + for (let i = 0; i < 1000; i++) { + arr.push({ data: new Array(100).fill(i) }); + } + const b = Buffer.alloc(5 * 1024 * 1024); + // Keep arr alive. + assert.ok(arr.length > 0); + return b; + }); + + const profile = v8.getAllocationProfile(); + + // Find heap samples with matching labels. + const labeledSamples = profile.samples.filter( + (s) => s.labels && s.labels.route === '/match-test' + ); + + // Find externalBytes entry with matching labels. + assert.ok(Array.isArray(profile.externalBytes), + 'externalBytes should be an array (ProfilingArrayBufferAllocator active)'); + { + const extEntry = findByLabel(profile, 'route', '/match-test'); + if (extEntry && labeledSamples.length > 0) { + // The label keys should match between heap samples and externalBytes. + const sampleLabelKeys = Object.keys(labeledSamples[0].labels).sort(); + const extLabelKeys = Object.keys(extEntry.labels).sort(); + assert.deepStrictEqual(extLabelKeys, sampleLabelKeys, + 'externalBytes label keys should match heap sample label keys'); + } + } + + // Keep buf alive. + assert.ok(buf.length > 0); + v8.stopSamplingHeapProfiler(); +} + +console.log('All external memory tracking tests passed.'); diff --git a/test/parallel/test-v8-heap-profile-labels-async.js b/test/parallel/test-v8-heap-profile-labels-async.js new file mode 100644 index 00000000000000..bd07eb09525ca4 --- /dev/null +++ b/test/parallel/test-v8-heap-profile-labels-async.js @@ -0,0 +1,153 @@ +// Flags: +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const v8 = require('v8'); + +// Test: labels survive await boundaries +async function testAwaitBoundary() { + v8.startSamplingHeapProfiler(64); + + await v8.withHeapProfileLabels({ route: '/async' }, async () => { + // Allocate before await + const before = []; + for (let i = 0; i < 2000; i++) before.push({ pre: i }); + + // Yield to event loop + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Allocate after await — labels should still be active + const after = []; + for (let i = 0; i < 2000; i++) after.push({ post: i }); + }); + + const profile = v8.getAllocationProfile(); + v8.stopSamplingHeapProfiler(); + + const labeled = profile.samples.filter( + (s) => s.labels.route === '/async' + ); + assert.ok( + labeled.length > 0, + 'Labels should survive await boundaries' + ); +} + +// Test: concurrent async contexts with different labels +async function testConcurrentContexts() { + v8.startSamplingHeapProfiler(64); + + const task = async (route, count) => { + await v8.withHeapProfileLabels({ route }, async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + const arr = []; + for (let i = 0; i < count; i++) arr.push({ data: i, route }); + }); + }; + + // Run multiple concurrent labeled tasks + await Promise.all([ + task('/users', 5000), + task('/products', 5000), + task('/orders', 5000), + ]); + + const profile = v8.getAllocationProfile(); + v8.stopSamplingHeapProfiler(); + + const users = profile.samples.filter((s) => s.labels.route === '/users'); + const products = profile.samples.filter( + (s) => s.labels.route === '/products' + ); + const orders = profile.samples.filter((s) => s.labels.route === '/orders'); + + // At least some of the three routes should have samples + const totalLabeled = users.length + products.length + orders.length; + assert.ok( + totalLabeled > 0, + 'Concurrent contexts should produce labeled samples' + ); +} + +// Test: setHeapProfileLabels with async work +async function testSetLabelsAsync() { + v8.startSamplingHeapProfiler(64); + + // Simulate Hapi-style: set labels, then do async work + v8.setHeapProfileLabels({ route: '/hapi-style' }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const arr = []; + for (let i = 0; i < 5000; i++) arr.push({ hapi: i }); + + const profile = v8.getAllocationProfile(); + v8.stopSamplingHeapProfiler(); + + const labeled = profile.samples.filter( + (s) => s.labels.route === '/hapi-style' + ); + assert.ok( + labeled.length > 0, + 'setHeapProfileLabels should work with async code' + ); +} + +// Test: withHeapProfileLabels handles async errors +async function testAsyncError() { + v8.startSamplingHeapProfiler(64); + + await assert.rejects( + () => v8.withHeapProfileLabels({ route: '/error' }, async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + throw new Error('test error'); + }), + { message: 'test error' } + ); + + // Profiler should still work after error + const profile = v8.getAllocationProfile(); + v8.stopSamplingHeapProfiler(); + assert.ok(profile); +} + +// Test: nested withHeapProfileLabels +async function testNestedLabels() { + v8.startSamplingHeapProfiler(64); + + await v8.withHeapProfileLabels({ route: '/outer' }, async () => { + const outer = []; + for (let i = 0; i < 2000; i++) outer.push({ outer: i }); + + await v8.withHeapProfileLabels({ route: '/inner' }, async () => { + const inner = []; + for (let i = 0; i < 2000; i++) inner.push({ inner: i }); + }); + }); + + const profile = v8.getAllocationProfile(); + v8.stopSamplingHeapProfiler(); + + const outerSamples = profile.samples.filter( + (s) => s.labels.route === '/outer' + ); + const innerSamples = profile.samples.filter( + (s) => s.labels.route === '/inner' + ); + + // Both outer and inner should have some samples + assert.ok( + outerSamples.length + innerSamples.length > 0, + 'Nested labels should produce labeled samples' + ); +} + +async function main() { + await testAwaitBoundary(); + await testConcurrentContexts(); + await testSetLabelsAsync(); + await testAsyncError(); + await testNestedLabels(); +} + +main().then(common.mustCall()); diff --git a/test/parallel/test-v8-heap-profile-labels.js b/test/parallel/test-v8-heap-profile-labels.js new file mode 100644 index 00000000000000..f4f16e3351e28a --- /dev/null +++ b/test/parallel/test-v8-heap-profile-labels.js @@ -0,0 +1,345 @@ +// Flags: --expose-gc +'use strict'; +require('../common'); +const assert = require('assert'); +const v8 = require('v8'); + +// Test: API functions are exported +assert.strictEqual(typeof v8.startSamplingHeapProfiler, 'function'); +assert.strictEqual(typeof v8.stopSamplingHeapProfiler, 'function'); +assert.strictEqual(typeof v8.getAllocationProfile, 'function'); +assert.strictEqual(typeof v8.withHeapProfileLabels, 'function'); +assert.strictEqual(typeof v8.setHeapProfileLabels, 'function'); + +// Test: getAllocationProfile returns undefined when profiler not started +assert.strictEqual(v8.getAllocationProfile(), undefined); + +// Test: basic profiling without labels +{ + v8.startSamplingHeapProfiler(64); + const arr = []; + for (let i = 0; i < 1000; i++) arr.push({ x: i }); + const profile = v8.getAllocationProfile(); + v8.stopSamplingHeapProfiler(); + + assert.ok(profile); + assert.ok(Array.isArray(profile.samples)); + assert.ok(profile.samples.length > 0); + + // Every sample should have a labels field (empty object when unlabeled) + for (const sample of profile.samples) { + assert.strictEqual(typeof sample.nodeId, 'number'); + assert.strictEqual(typeof sample.size, 'number'); + assert.strictEqual(typeof sample.count, 'number'); + assert.strictEqual(typeof sample.sampleId, 'number'); + assert.strictEqual(typeof sample.labels, 'object'); + assert.ok(sample.labels !== null); + } +} + +// Test: withHeapProfileLabels captures labels on samples +{ + v8.startSamplingHeapProfiler(64); + + v8.withHeapProfileLabels({ route: '/test' }, () => { + const arr = []; + for (let i = 0; i < 5000; i++) arr.push({ data: i }); + }); + + const profile = v8.getAllocationProfile(); + v8.stopSamplingHeapProfiler(); + + const labeled = profile.samples.filter( + (s) => s.labels.route === '/test' + ); + assert.ok(labeled.length > 0, 'Should have samples labeled with /test'); +} + +// Test: distinct labels are attributed correctly +{ + v8.startSamplingHeapProfiler(64); + + v8.withHeapProfileLabels({ route: '/heavy' }, () => { + const arr = []; + for (let i = 0; i < 10000; i++) arr.push(new Array(100)); + }); + + v8.withHeapProfileLabels({ route: '/light' }, () => { + const arr = []; + for (let i = 0; i < 100; i++) arr.push({ x: i }); + }); + + const profile = v8.getAllocationProfile(); + v8.stopSamplingHeapProfiler(); + + const heavy = profile.samples.filter((s) => s.labels.route === '/heavy'); + const light = profile.samples.filter((s) => s.labels.route === '/light'); + assert.ok(heavy.length > 0, 'Should have /heavy samples'); + // /light may have zero samples due to sampling — that's acceptable + // The key assertion is that /heavy has samples and they are attributed +} + +// Test: multi-key labels +{ + v8.startSamplingHeapProfiler(64); + + v8.withHeapProfileLabels({ route: '/api', method: 'GET' }, () => { + const arr = []; + for (let i = 0; i < 5000; i++) arr.push({ data: i }); + }); + + const profile = v8.getAllocationProfile(); + v8.stopSamplingHeapProfiler(); + + const labeled = profile.samples.filter( + (s) => s.labels.route === '/api' && s.labels.method === 'GET' + ); + assert.ok(labeled.length > 0, 'Should have multi-key labeled samples'); +} + +// Test: JSON.stringify round-trip +{ + v8.startSamplingHeapProfiler(64); + + v8.withHeapProfileLabels({ route: '/json' }, () => { + const arr = []; + for (let i = 0; i < 5000; i++) arr.push({ data: i }); + }); + + const profile = v8.getAllocationProfile(); + v8.stopSamplingHeapProfiler(); + + const json = JSON.stringify(profile); + const parsed = JSON.parse(json); + assert.ok(Array.isArray(parsed.samples)); + const labeled = parsed.samples.filter((s) => s.labels.route === '/json'); + assert.ok(labeled.length > 0, 'Labels survive JSON round-trip'); +} + +// Test: withHeapProfileLabels validates arguments +assert.throws(() => v8.withHeapProfileLabels('bad', () => {}), { + code: 'ERR_INVALID_ARG_TYPE', +}); +assert.throws(() => v8.withHeapProfileLabels({}, 'bad'), { + code: 'ERR_INVALID_ARG_TYPE', +}); + +// Test: setHeapProfileLabels validates arguments +assert.throws(() => v8.setHeapProfileLabels('bad'), { + code: 'ERR_INVALID_ARG_TYPE', +}); + +// Test: repeated start/stop cycles work +{ + for (let cycle = 0; cycle < 3; cycle++) { + v8.startSamplingHeapProfiler(64); + v8.withHeapProfileLabels({ route: `/cycle${cycle}` }, () => { + const arr = []; + for (let i = 0; i < 1000; i++) arr.push({ x: i }); + }); + const profile = v8.getAllocationProfile(); + v8.stopSamplingHeapProfiler(); + assert.ok(profile); + assert.ok(profile.samples.length > 0); + } +} + +// Test: GC'd samples are retained with includeCollectedObjects: true +{ + v8.startSamplingHeapProfiler(64, 16, { includeCollectedObjects: true }); + + v8.withHeapProfileLabels({ route: '/heavy-gc' }, () => { + for (let i = 0; i < 500; i++) { + // Allocate ~100KB arrays that become garbage immediately + new Array(25000).fill(i); + } + }); + + v8.withHeapProfileLabels({ route: '/light-gc' }, () => { + const arr = []; + for (let i = 0; i < 10; i++) arr.push({ x: i }); + }); + + // Force garbage collection to remove unreferenced objects + global.gc(); + + const profile = v8.getAllocationProfile(); + v8.stopSamplingHeapProfiler(); + + const heavy = profile.samples.filter((s) => s.labels.route === '/heavy-gc'); + assert.ok( + heavy.length > 0, + 'GC\'d samples should still be present with includeCollectedObjects: true' + ); + + // Heavy route allocated ~50MB, light route ~trivial — ratio should be high + const heavyBytes = heavy.reduce((sum, s) => sum + s.size * s.count, 0); + const light = profile.samples.filter((s) => s.labels.route === '/light-gc'); + const lightBytes = light.reduce((sum, s) => sum + s.size * s.count, 0); + if (lightBytes > 0) { + const ratio = heavyBytes / lightBytes; + assert.ok( + ratio > 50, + `Heavy/light ratio should be >50 after GC, got ${ratio.toFixed(1)}` + ); + } +} + +// Test: GC'd samples are removed without includeCollectedObjects (default) +{ + v8.startSamplingHeapProfiler(64); + + v8.withHeapProfileLabels({ route: '/gc-default' }, () => { + for (let i = 0; i < 500; i++) { + // Allocate ~100KB arrays that become garbage immediately + new Array(25000).fill(i); + } + }); + + // Force garbage collection — without includeCollectedObjects, samples are + // removed from the profile via V8's OnWeakCallback + global.gc(); + + const profile = v8.getAllocationProfile(); + v8.stopSamplingHeapProfiler(); + + const samples = profile.samples.filter( + (s) => s.labels.route === '/gc-default' + ); + const totalBytes = samples.reduce((sum, s) => sum + s.size * s.count, 0); + // After GC, most or all samples should be gone. The total bytes retained + // should be much less than what was allocated (~50MB). + assert.ok( + totalBytes < 5 * 1024 * 1024, + `Without includeCollectedObjects, GC'd samples should mostly be removed ` + + `(got ${(totalBytes / 1024 / 1024).toFixed(1)}MB)` + ); +} + +// Test: includeCollectedObjects: true retains samples, false does not +{ + // Start WITH includeCollectedObjects + v8.startSamplingHeapProfiler(64, 16, { includeCollectedObjects: true }); + v8.withHeapProfileLabels({ route: '/retained' }, () => { + for (let i = 0; i < 200; i++) new Array(25000).fill(i); + }); + global.gc(); + const withProfile = v8.getAllocationProfile(); + v8.stopSamplingHeapProfiler(); + + const withSamples = withProfile.samples.filter( + (s) => s.labels.route === '/retained' + ); + const withBytes = withSamples.reduce((sum, s) => sum + s.size * s.count, 0); + + // Start WITHOUT includeCollectedObjects + v8.startSamplingHeapProfiler(64); + v8.withHeapProfileLabels({ route: '/removed' }, () => { + for (let i = 0; i < 200; i++) new Array(25000).fill(i); + }); + global.gc(); + const withoutProfile = v8.getAllocationProfile(); + v8.stopSamplingHeapProfiler(); + + const withoutSamples = withoutProfile.samples.filter( + (s) => s.labels.route === '/removed' + ); + const withoutBytes = withoutSamples.reduce( + (sum, s) => sum + s.size * s.count, 0 + ); + + // With includeCollectedObjects should retain significantly more bytes. + // withBytes must be positive to avoid vacuous pass when both are 0. + assert.ok(withBytes > 0, + `includeCollectedObjects should retain samples: withBytes=${withBytes}`); + assert.ok( + withBytes > withoutBytes * 5, + `includeCollectedObjects should retain more samples: ` + + `with=${(withBytes / 1024).toFixed(0)}KB, ` + + `without=${(withoutBytes / 1024).toFixed(0)}KB` + ); +} + +// Test: setHeapProfileLabels doesn't leak entries when called repeatedly +// Each enterWith() creates a new AsyncContextFrame (CPED). Without cleanup, +// old entries accumulate in the label map. The fix calls unregister before +// enterWith so the old CPED entry is removed. +{ + v8.startSamplingHeapProfiler(64); + + // Call setHeapProfileLabels 100 times — only the last label should matter + for (let i = 0; i < 100; i++) { + v8.setHeapProfileLabels({ route: `/iter${i}` }); + } + + // Allocate under the final label + const arr = []; + for (let i = 0; i < 5000; i++) arr.push({ data: i }); + + const profile = v8.getAllocationProfile(); + v8.stopSamplingHeapProfiler(); + + // The final label should be present on samples + const finalLabeled = profile.samples.filter( + (s) => s.labels.route === '/iter99' + ); + assert.ok( + finalLabeled.length > 0, + 'Should have samples labeled with final /iter99' + ); + + // Old labels should NOT appear on the post-loop allocations + // (they may appear on allocations made during the loop itself, which is fine) + // The key check: only /iter99 should appear on samples from the bulk alloc + const allRoutes = new Set( + profile.samples + .filter((s) => s.labels.route) + .map((s) => s.labels.route) + ); + // With the fix, old entries are cleaned up before enterWith. Intermediate + // labels may still appear (allocations during the loop), but the entry + // count should not grow — verified by no crash or OOM under heavy use. + assert.ok(allRoutes.has('/iter99'), 'Final label must be present'); +} + +// Test: labels survive when another ALS store changes the CPED address. +// This is the exact bug Qard (Stephen Belanger) identified: CPED is a shared +// AsyncContextFrame Map. Its identity (address) changes when ANY ALS store +// changes, not just the heap profile labels store. The old address-based lookup +// would fail because the CPED address at allocation time differs from the one +// registered with withHeapProfileLabels. The new CPED-storage approach stores +// the full CPED value on each sample at allocation time and resolves labels +// at profile-read time via Map lookup — immune to address changes. +{ + const { AsyncLocalStorage } = require('async_hooks'); + const otherALS = new AsyncLocalStorage(); + + v8.startSamplingHeapProfiler(64); + + v8.withHeapProfileLabels({ route: '/cped-identity' }, () => { + // Allocate before changing other ALS (CPED address is X) + const before = []; + for (let i = 0; i < 2000; i++) before.push({ pre: i }); + + // Change a DIFFERENT ALS store — this creates a new AsyncContextFrame, + // changing the CPED address to Y. The heap profile labels ALS store is + // still set (it was inherited into the new frame). + otherALS.enterWith({ unrelated: 'data' }); + + // Allocate after the other ALS change (CPED address is now Y, not X) + const after = []; + for (let i = 0; i < 2000; i++) after.push({ post: i }); + }); + + const profile = v8.getAllocationProfile(); + v8.stopSamplingHeapProfiler(); + + const labeled = profile.samples.filter( + (s) => s.labels.route === '/cped-identity' + ); + assert.ok( + labeled.length > 0, + 'Labels must survive when another ALS store changes the CPED address ' + + '(Qard bug fix verification)' + ); +} From 085cf662909d651c1868fa1eb641d3a17a19ec46 Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Fri, 10 Apr 2026 11:21:20 +0200 Subject: [PATCH 5/6] doc: add heap profile labels API documentation Document the new v8 module APIs: - startSamplingHeapProfiler with includeCollectedObjects option - stopSamplingHeapProfiler - getAllocationProfile with samples and externalBytes - withHeapProfileLabels for scoped label attribution - setHeapProfileLabels for enterWith-style frameworks - Limitations section covering what is and isn't measured Signed-off-by: Rudolf Meijering --- doc/api/v8.md | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/doc/api/v8.md b/doc/api/v8.md index 7ee7a748674cae..d4bb576a85e295 100644 --- a/doc/api/v8.md +++ b/doc/api/v8.md @@ -1453,6 +1453,113 @@ added: Returns true if the Node.js instance is run to build a snapshot. +## Heap profile labels + + + +> Stability: 1 - Experimental + +Attach string labels to V8 sampling heap profiler allocation samples. +Combined with [`AsyncLocalStorage`][], labels propagate through `await` +boundaries for per-context memory attribution (e.g., per-HTTP-route). + +### `v8.startSamplingHeapProfiler([sampleInterval[, stackDepth[, options]]])` + + + +* `sampleInterval` {number} Average interval in bytes. **Default:** `524288`. +* `stackDepth` {number} Maximum call stack depth. **Default:** `16`. +* `options` {Object} + * `includeCollectedObjects` {boolean} Retain samples for GC'd objects. + **Default:** `false`. + +Starts the V8 sampling heap profiler. + +### `v8.stopSamplingHeapProfiler()` + + + +Stops the sampling heap profiler and clears all registered label entries. + +### `v8.getAllocationProfile()` + + + +* Returns: {Object | undefined} + +Returns the current allocation profile, or `undefined` if the profiler is +not running. + +```json +{ + "samples": [ + { "nodeId": 1, "size": 128, "count": 4, "sampleId": 42, + "labels": { "route": "/users/:id" } } + ], + "externalBytes": [ + { "labels": { "route": "/users/:id" }, "bytes": 1048576 } + ] +} +``` + +* `samples[].labels` — key-value string pairs from the active label context + at allocation time. Empty object if no labels were active. +* `externalBytes[]` — live `Buffer`/`ArrayBuffer` backing-store bytes per + label context. Complements heap samples which only see the JS wrapper. + +### `v8.withHeapProfileLabels(labels, fn)` + + + +* `labels` {Object} Key-value string pairs (e.g., `{ route: '/users/:id' }`). +* `fn` {Function} May be `async`. +* Returns: {*} Return value of `fn`. + +Runs `fn` with the given labels active. If `fn` returns a promise, labels +remain active until the promise settles. + +```js +v8.startSamplingHeapProfiler(64); + +await v8.withHeapProfileLabels({ route: '/users' }, async () => { + const data = await fetchUsers(); + return processData(data); +}); + +const profile = v8.getAllocationProfile(); +v8.stopSamplingHeapProfiler(); +``` + +### `v8.setHeapProfileLabels(labels)` + + + +* `labels` {Object} Key-value string pairs. + +Sets labels for the current async scope using `enterWith` semantics. +Useful for frameworks where the handler runs after the extension returns. + +Prefer [`v8.withHeapProfileLabels()`][] when possible for automatic cleanup. + +### Limitations — what is measured + +Heap samples cover V8 heap allocations (JS objects, strings, closures). +`externalBytes` covers `Buffer`/`ArrayBuffer` backing stores. + +Not measured: native addon memory, JIT code space, OS-level allocations. + ## Class: `v8.GCProfiler`