From 2febdb40d6c8c250115649e359d972b34f399eda Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Wed, 15 Apr 2026 16:15:58 +0100 Subject: [PATCH 01/13] deps: add heap profile sample labels to V8 profiler Add V8 API for attaching embedder-defined labels to sampling heap profiler samples. Labels propagate through async context via CPED and are resolved at profile-read time. Signed-off-by: Rudolf Meijering --- deps/v8/include/v8-profiler.h | 95 ++++- deps/v8/src/api/api.cc | 13 + deps/v8/src/profiler/heap-profiler.h | 40 ++ .../v8/src/profiler/sampling-heap-profiler.cc | 131 ++++++- deps/v8/src/profiler/sampling-heap-profiler.h | 15 + deps/v8/test/cctest/test-heap-profiler.cc | 352 ++++++++++++++++++ tools/v8_gypfiles/features.gypi | 7 +- 7 files changed, 637 insertions(+), 16 deletions(-) diff --git a/deps/v8/include/v8-profiler.h b/deps/v8/include/v8-profiler.h index 61f427ea47c691..5fb3072aa157b0 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,42 @@ class V8_EXPORT AllocationProfile { * Represent a single sample recorded for an allocation. */ struct Sample { - /** - * id of the node in the profile tree. - */ +#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)) {} +#else + 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) {} +#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 resolved by the HeapProfileSampleLabelsCallback + * during GetAllocationProfile(). The ALS value is captured at allocation + * time; labels are parsed from it when the profile is read. Each pair is + * (key, value). Empty if no callback is registered or the callback + * returned no labels for this sample. + */ + std::vector> labels; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS }; /** @@ -1001,6 +1022,31 @@ 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 GetAllocationProfile() to resolve labels from + * the ALS value captured at allocation time. + * + * |data| is the opaque pointer passed to SetHeapProfileSampleLabelsCallback. + * |context| is the ALS value (e.g. a flat [key, val, ...] array) that was + * extracted from the ContinuationPreservedEmbedderData (CPED) Map at + * allocation time using the ALS key set via SetHeapProfileSampleLabelsKey. + * The extraction uses OrderedHashMap::FindEntry (zero-allocation, GC-safe). + * The callback parses this value directly to produce labels — no Map lookup + * is needed at callback time. + * + * Only invoked for samples that had an ALS value at allocation time (i.e. + * the ALS key was found in the CPED Map). + * + * 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 +1307,31 @@ class V8_EXPORT HeapProfiler { void SetGetDetachednessCallback(GetDetachednessCallback callback, void* data); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + /** + * Registers a callback invoked during GetAllocationProfile() to resolve + * embedder-defined labels from the ALS value extracted at allocation time. + * The labels are stored on AllocationProfile::Sample::labels. + * + * Pass nullptr to clear the callback. + */ + void SetHeapProfileSampleLabelsCallback( + HeapProfileSampleLabelsCallback callback, void* data = nullptr); + + /** + * Sets the AsyncLocalStorage (ALS) key used to extract the label value + * from the ContinuationPreservedEmbedderData (CPED) Map at allocation + * time. When a heap sample is taken, SampleObject uses this key with + * OrderedHashMap::FindEntry to look up the corresponding ALS value and + * store it on the sample. The stored value is later passed to the + * HeapProfileSampleLabelsCallback as the |context| parameter. + * + * Must be called before starting the sampling profiler. + * Pass an empty handle to clear. + */ + void SetHeapProfileSampleLabelsKey(Local key); +#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..6ed0ae73e85869 100644 --- a/deps/v8/src/api/api.cc +++ b/deps/v8/src/api/api.cc @@ -11829,6 +11829,19 @@ 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); +} + +void HeapProfiler::SetHeapProfileSampleLabelsKey(Local key) { + reinterpret_cast(this) + ->SetHeapProfileSampleLabelsKey(key); +} +#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..dda2a7d6c9ac9e 100644 --- a/deps/v8/src/profiler/heap-profiler.h +++ b/deps/v8/src/profiler/heap-profiler.h @@ -79,6 +79,34 @@ 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; + } + + void SetHeapProfileSampleLabelsKey(v8::Local key) { + if (key.IsEmpty()) { + sample_labels_als_key_.Reset(); + } else { + sample_labels_als_key_.Reset( + reinterpret_cast(isolate()), key); + } + } + + v8::HeapProfiler::HeapProfileSampleLabelsCallback + sample_labels_callback() const { + return sample_labels_callback_; + } + void* sample_labels_data() const { return sample_labels_data_; } + + const v8::Global& sample_labels_als_key() const { + return sample_labels_als_key_; + } +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + void StartHeapObjectsTracking(bool track_allocations); void StopHeapObjectsTracking(); AllocationTracker* allocation_tracker() const { @@ -176,6 +204,18 @@ class HeapProfiler : public HeapObjectAllocationTracker { std::pair get_detachedness_callback_; std::unique_ptr native_move_listener_; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // All three fields are written only from the main thread via + // SetHeapProfileSampleLabelsCallback() / SetHeapProfileSampleLabelsKey() + // and read during GetAllocationProfile() or SampleObject() (also main + // thread). No cross-thread access, so no synchronization is required. + v8::HeapProfiler::HeapProfileSampleLabelsCallback sample_labels_callback_ = + nullptr; + void* sample_labels_data_ = nullptr; + // The ALS key used at allocation time to extract the label value from + // the CPED Map via OrderedHashMap::FindEntry. + v8::Global sample_labels_als_key_; +#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..f438900f2edd16 100644 --- a/deps/v8/src/profiler/sampling-heap-profiler.cc +++ b/deps/v8/src/profiler/sampling-heap-profiler.cc @@ -15,6 +15,9 @@ #include "src/execution/isolate.h" #include "src/heap/heap-layout-inl.h" #include "src/heap/heap.h" +#include "src/objects/js-collection-inl.h" +#include "src/objects/ordered-hash-table.h" +#include "src/profiler/heap-profiler.h" #include "src/profiler/strings-storage.h" namespace v8 { @@ -96,6 +99,47 @@ 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 + // Extract the ALS value from the CPED Map at allocation time. + // The CPED (ContinuationPreservedEmbedderData) is a JSMap containing + // ALL ALS stores. We look up only our ALS key via + // OrderedHashMap::FindEntry (zero-allocation, GC-safe) and store just + // the resulting value (the flat label array). This avoids retaining + // the entire CPED for the lifetime of each sample. + // Global::Reset is safe inside DisallowGC (uses malloc, not V8 heap). + { + HeapProfiler* hp = isolate_->heap()->heap_profiler(); + if (hp->sample_labels_callback()) { + const v8::Global& als_key_global = + hp->sample_labels_als_key(); + if (!als_key_global.IsEmpty()) { + v8::Isolate* v8_isolate = reinterpret_cast(isolate_); + v8::Local context = + v8_isolate->GetContinuationPreservedEmbedderData(); + if (!context.IsEmpty() && !context->IsUndefined()) { + Tagged cped_obj = *Utils::OpenDirectHandle(*context); + if (IsJSMap(cped_obj)) { + Tagged js_map = Cast(cped_obj); + Tagged table = + Cast(js_map->table()); + v8::Local als_key_local = + als_key_global.Get(v8_isolate); + Tagged key_obj = *Utils::OpenDirectHandle(*als_key_local); + InternalIndex entry = table->FindEntry(isolate_, key_obj); + if (entry.is_found()) { + Tagged value = table->ValueAt(entry); + v8::Local value_local = + Utils::ToLocal(direct_handle(value, isolate_)); + sample->label_value.Reset(v8_isolate, value_local); + } + } + } + } + } + } +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + sample->global.SetWeak(sample.get(), OnWeakCallback, WeakCallbackType::kParameter); samples_.emplace(sample.get(), std::move(sample)); @@ -299,22 +343,103 @@ v8::AllocationProfile* SamplingHeapProfiler::GetAllocationProfile() { scripts[script->id()] = handle(script, isolate_); } } + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // Pre-resolve labels while GC is still allowed. + // The labels callback may allocate on the V8 heap. These MUST NOT run + // during BuildSamples() iteration — GC would fire weak callbacks that + // erase from samples_, causing iterator invalidation / UAF. + // + // Two-phase approach: + // Phase 1: snapshot sample IDs and ALS values (Global::Get + Global + // ctor use malloc, not V8 heap — no GC risk during iteration). + // Phase 2: invoke the callback for each snapshot entry. GC may fire + // here but we iterate our snapshot vector, not samples_. + ResolvedLabelsMap resolved_labels; + { + HeapProfiler* hp = heap_->heap_profiler(); + auto callback = hp->sample_labels_callback(); + void* callback_data = hp->sample_labels_data(); + if (callback) { + v8::Isolate* v8_isolate = reinterpret_cast(isolate_); + + // Phase 1: snapshot ALS values keyed by sample_id. + // Global ctor (GlobalizeReference) and Global::Get use global handle + // storage (malloc), not the V8 heap — safe under DisallowGarbageCollection. + // Locals from Get() are immediately consumed by Global ctor, so a single + // HandleScope outside the loop suffices (no per-iteration scope churn). + struct LabelValueSnapshot { + uint64_t sample_id; + v8::Global label_value; + }; + std::vector snapshot; + snapshot.reserve(samples_.size()); + { + HandleScope handle_scope(isolate_); + DisallowGarbageCollection no_gc; + for (const auto& [ptr, sample] : samples_) { + if (!sample->label_value.IsEmpty()) { + snapshot.push_back( + {sample->sample_id, + v8::Global( + v8_isolate, sample->label_value.Get(v8_isolate))}); + } + } + } + + // Phase 2: resolve labels via callback (GC allowed). + // The callback receives the ALS value (flat label array) directly — + // the CPED Map lookup was already done at allocation time. + for (auto& entry : snapshot) { + HandleScope scope(isolate_); + v8::Local value_local = entry.label_value.Get(v8_isolate); + std::vector> labels; + if (callback(callback_data, value_local, &labels) && !labels.empty()) { + resolved_labels[entry.sample_id] = std::move(labels); + } + } + } + } +#endif + auto profile = new v8::internal::AllocationProfile(); TranslateAllocationNode(profile, &profile_root_, scripts); + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + profile->samples_ = BuildSamples(std::move(resolved_labels)); +#else profile->samples_ = BuildSamples(); +#endif return profile; } +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +const std::vector +SamplingHeapProfiler::BuildSamples(ResolvedLabelsMap resolved_labels) const { +#else const std::vector SamplingHeapProfiler::BuildSamples() const { +#endif std::vector samples; samples.reserve(samples_.size()); + 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; + auto label_it = resolved_labels.find(sample->sample_id); + if (label_it != resolved_labels.end()) { + labels = std::move(label_it->second); + } + 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..d54f2b9bd2a42c 100644 --- a/deps/v8/src/profiler/sampling-heap-profiler.h +++ b/deps/v8/src/profiler/sampling-heap-profiler.h @@ -114,6 +114,14 @@ class SamplingHeapProfiler { Global global; SamplingHeapProfiler* const profiler; const uint64_t sample_id; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // ALS value extracted from the CPED Map at allocation time via + // OrderedHashMap::FindEntry. Storing only the ALS value (the flat + // label array) instead of the full CPED avoids retaining all ALS + // stores for the lifetime of the sample. Labels are resolved from + // this at read time (in GetAllocationProfile) via the callback. + Global label_value; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS }; SamplingHeapProfiler(Heap* heap, StringsStorage* names, uint64_t rate, @@ -160,7 +168,14 @@ class SamplingHeapProfiler { void SampleObject(Address soon_object, size_t size); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + using ResolvedLabelsMap = std::unordered_map< + uint64_t, std::vector>>; + const std::vector BuildSamples( + ResolvedLabelsMap resolved_labels) const; +#else const std::vector BuildSamples() const; +#endif 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..7187993aaf5070 100644 --- a/deps/v8/test/cctest/test-heap-profiler.cc +++ b/deps/v8/test/cctest/test-heap-profiler.cc @@ -4854,3 +4854,355 @@ 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 returns different labels based on CPED value. +struct MultiLabelState { + std::string first_cped; + std::vector> first; + std::string second_cped; + std::vector> second; +}; + +static bool MultiLabelsCallback( + void* data, v8::Local context, + std::vector>* out_labels) { + auto* state = static_cast(data); + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + v8::String::Utf8Value cped_str(isolate, context); + std::string cped(*cped_str, cped_str.length()); + if (cped == state->first_cped) { + *out_labels = state->first; + } else if (cped == state->second_cped) { + *out_labels = state->second; + } + 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; + + MultiLabelState state; + state.first_cped = "context-first"; + state.first = {{"route", "/api/first"}}; + state.second_cped = "context-second"; + state.second = {{"route", "/api/second"}}; + + heap_profiler->SetHeapProfileSampleLabelsCallback(MultiLabelsCallback, + &state); + + heap_profiler->StartSamplingHeapProfiler(256); + + // Set first CPED and allocate. + isolate->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate, "context-first")); + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate); + + // Set second CPED and allocate. + isolate->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate, "context-second")); + 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 a393276ad934e1bfc79683b1bb4e88c4ddf5d63a Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Wed, 15 Apr 2026 16:18:43 +0100 Subject: [PATCH 02/13] src: add heap profile labels bindings Add Node.js C++ bindings that wire up the V8 sample labels API. Handles callback registration, ALS key setup, and cleanup on worker termination. Signed-off-by: Rudolf Meijering --- node.gyp | 14 +++ src/node_v8.cc | 281 +++++++++++++++++++++++++++++++++++++++++++++++++ src/node_v8.h | 35 ++++++ 3 files changed, 330 insertions(+) diff --git a/node.gyp b/node.gyp index b245011181d660..66285d32499142 100644 --- a/node.gyp +++ b/node.gyp @@ -4,6 +4,9 @@ 'v8_trace_maps%': 0, 'v8_enable_pointer_compression%': 0, 'v8_enable_31bit_smis_on_64bit_arch%': 0, + # Matches V8 default (BUILD.gn); must be declared here for GYP conditions + # that gate V8_HEAP_PROFILER_SAMPLE_LABELS below. + 'v8_enable_continuation_preserved_embedder_data%': 1, 'force_dynamic_crt%': 0, 'node_builtin_modules_path%': '', 'node_core_target_name%': 'node', @@ -937,6 +940,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 +1331,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/node_v8.cc b/src/node_v8.cc index 12972f83ea0f61..2b39b4f52a576e 100644 --- a/src/node_v8.cc +++ b/src/node_v8.cc @@ -27,11 +27,16 @@ #include "node.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 { + +// V8's default sampling interval for the sampling heap profiler (512 KB). +static constexpr uint64_t kDefaultSamplingInterval = 512 * 1024; +using v8::AllocationProfile; using v8::Array; using v8::BigInt; using v8::CFunction; @@ -44,12 +49,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 +66,63 @@ using v8::Uint32; using v8::V8; using v8::Value; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +// 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. +// +// GC safety: this callback is invoked from GetAllocationProfile() BEFORE +// BuildSamples() iteration, in a context where GC is allowed. The caller +// iterates a snapshot of ALS values (not the live samples_ map), so +// GC-triggered weak callbacks that erase from samples_ cannot cause +// iterator invalidation. Array::Get() may allocate on the V8 heap — that +// is safe in this pre-resolution phase. +// +// The |context| parameter is the ALS value (flat [key1, val1, key2, val2, ...] +// array) extracted by V8 at allocation time via OrderedHashMap::FindEntry. +// If no ALS value was found for a sample, context will be empty or undefined. +static bool HeapProfileLabelsCallback( + void* data, v8::Local context, + std::vector>* out_labels) { + auto* binding_data = static_cast(data); + if (!binding_data) return false; + + // context is the ALS value (flat label array) — no Map lookup needed. + if (context.IsEmpty() || !context->IsArray()) return false; + + Isolate* isolate = binding_data->env()->isolate(); + HandleScope handle_scope(isolate); + Local v8_context = isolate->GetCurrentContext(); + + // Convert flat [key1, val1, key2, val2, ...] array to string pairs. + Local flat = context.As(); + uint32_t len = flat->Length(); + std::vector> result; + for (uint32_t j = 0; j + 1 < len; j += 2) { + Local k, v; + if (!flat->Get(v8_context, j).ToLocal(&k)) return false; + if (!flat->Get(v8_context, j + 1).ToLocal(&v)) return false; + String::Utf8Value key_str(isolate, k); + String::Utf8Value val_str(isolate, v); + if (*key_str == nullptr || *val_str == nullptr) continue; + result.emplace_back(*key_str, *val_str); + } + + *out_labels = std::move(result); + return !out_labels->empty(); +} + +// C++ binding: store the AsyncLocalStorage instance used for heap profile +// labels. V8 uses this key at allocation time to extract the ALS value from +// the CPED (AsyncContextFrame) via OrderedHashMap::FindEntry. +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]); +} +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + #define HEAP_STATISTICS_PROPERTIES(V) \ V(0, total_heap_size, kTotalHeapSizeIndex) \ V(1, total_heap_size_executable, kTotalHeapSizeExecutableIndex) \ @@ -103,6 +167,9 @@ static const size_t kHeapCodeStatisticsPropertiesCount = HEAP_CODE_STATISTICS_PROPERTIES(V); #undef V +// Forward declaration for the env cleanup hook (used by ~BindingData). +static void CleanupHeapProfiling(void* data); + BindingData::BindingData(Realm* realm, Local obj, InternalFieldInfo* info) @@ -144,6 +211,25 @@ BindingData::BindingData(Realm* realm, heap_code_statistics_buffer.MakeWeak(); } +BindingData::~BindingData() { + // BindingData is destroyed during Realm::RunCleanup() (via + // binding_data_store_.reset()), which runs BEFORE the environment cleanup + // queue is drained. At this point the Isolate and Environment are + // guaranteed to still be alive (Realm::RunCleanup runs inside + // Environment::RunCleanup, before the Environment is deleted and long + // before the Isolate is disposed). + // + // If profiling was active, DoCleanup() stops the sampler, clears V8's + // callback pointer into this (about-to-be-destroyed) BindingData, and + // disables the allocator tracker — preventing use-after-free. + if (heap_profiling_cleanup_ != nullptr) { + heap_profiling_cleanup_->DoCleanup(); + env()->RemoveCleanupHook(CleanupHeapProfiling, heap_profiling_cleanup_); + delete heap_profiling_cleanup_; + heap_profiling_cleanup_ = nullptr; + } +} + bool BindingData::PrepareForSerialization(Local context, v8::SnapshotCreator* creator) { DCHECK_NULL(internal_field_info_); @@ -186,6 +272,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 +762,180 @@ void GCProfiler::Stop(const FunctionCallbackInfo& args) { } } +// Data captured when heap profiling starts, used by the cleanup hook to +// safely tear down profiler state if the Environment is destroyed while +// profiling is still active (e.g. worker thread termination). +// +// Raw pointers are safe here because Environment cleanup hooks are +// guaranteed to run before the Isolate is disposed: FreeEnvironment() +// calls env->RunCleanup() (which drains the cleanup queue) while still +// inside Isolate::Scope, and the ArrayBufferAllocator that owns +// ProfilingArrayBufferAllocator outlives the Isolate. +// +// We intentionally do NOT store a BindingData* here — Realm::RunCleanup() +// destroys BindingData (via binding_data_store_.reset()) before the +// environment cleanup queue is drained, so any BindingData* would be +// dangling by the time this hook fires. +struct HeapProfilingCleanup { + Isolate* isolate; + bool cleaned_up = false; + + // Idempotent: stops the sampling profiler and clears the labels callback. + // Safe to call multiple times — only the first call has effect. + void DoCleanup() { + if (cleaned_up) return; + cleaned_up = true; + + HeapProfiler* profiler = isolate->GetHeapProfiler(); + profiler->StopSamplingHeapProfiler(); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); + profiler->SetHeapProfileSampleLabelsKey(Local()); +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + isolate = nullptr; + } +}; + +static void CleanupHeapProfiling(void* data) { + auto* ctx = static_cast(data); + ctx->DoCleanup(); + delete ctx; +} + +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 = kDefaultSamplingInterval; + 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()); + } +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + profiler->SetHeapProfileSampleLabelsCallback( + HeapProfileLabelsCallback, binding_data); + if (!binding_data->heap_profile_labels_als_key.IsEmpty()) { + profiler->SetHeapProfileSampleLabelsKey( + binding_data->heap_profile_labels_als_key.Get(isolate)); + } +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + // 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); + + // Register a cleanup hook so that if the Environment is torn down while + // profiling is active (e.g. a worker thread is terminated), V8's callback + // pointer into the now-destroyed BindingData is cleared. Remove any prior + // hook first (handles repeated Start calls without an intervening Stop). + Environment* env = Environment::GetCurrent(args); + if (binding_data->heap_profiling_cleanup_ != nullptr) { + binding_data->heap_profiling_cleanup_->DoCleanup(); + env->RemoveCleanupHook( + CleanupHeapProfiling, binding_data->heap_profiling_cleanup_); + delete binding_data->heap_profiling_cleanup_; + } + auto* cleanup = new HeapProfilingCleanup{isolate}; + env->AddCleanupHook(CleanupHeapProfiling, cleanup); + binding_data->heap_profiling_cleanup_ = cleanup; +} + +void StopSamplingHeapProfiler(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + BindingData* binding_data = Realm::GetBindingData(args); + + if (binding_data->heap_profiling_cleanup_ != nullptr) { + binding_data->heap_profiling_cleanup_->DoCleanup(); + env->RemoveCleanupHook( + CleanupHeapProfiling, binding_data->heap_profiling_cleanup_); + delete binding_data->heap_profiling_cleanup_; + binding_data->heap_profiling_cleanup_ = nullptr; + } +} + +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; + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // 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; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + + 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; + + args.GetReturnValue().Set(result); +} + void Initialize(Local target, Local unused, Local context, @@ -741,6 +1004,18 @@ 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); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + SetMethod(context, target, "setHeapProfileLabelsStore", + SetHeapProfileLabelsStore); +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + // Export symbols used by v8.isStringOneByteRepresentation() SetFastMethodNoSideEffect(context, target, @@ -787,6 +1062,12 @@ 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); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + registry->Register(SetHeapProfileLabelsStore); +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS } } // namespace v8_utils diff --git a/src/node_v8.h b/src/node_v8.h index 581972b13d4e3c..364763bf5386e8 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,9 @@ class Environment; struct InternalFieldInfoBase; namespace v8_utils { + +struct HeapProfilingCleanup; + class BindingData : public SnapshotableObject { public: struct InternalFieldInfo : public node::InternalFieldInfoBase { @@ -27,6 +31,7 @@ class BindingData : public SnapshotableObject { BindingData(Realm* realm, v8::Local obj, InternalFieldInfo* info = nullptr); + ~BindingData() override; SERIALIZABLE_OBJECT_METHODS() SET_BINDING_ID(v8_binding_data) @@ -35,6 +40,36 @@ 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; + + // Typed pointer to HeapProfilingCleanup state registered with + // AddCleanupHook when profiling is active. + // + // Lifetime contract — three paths may trigger cleanup, all on the + // main thread: + // + // 1. StopSamplingHeapProfiler (explicit user call): + // Calls DoCleanup(), removes the env cleanup hook, deletes + // the struct, and nulls this pointer. + // + // 2. ~BindingData (Realm teardown — runs BEFORE env cleanup hooks): + // Calls DoCleanup(), removes the env cleanup hook, deletes + // the struct, and nulls this pointer. + // + // 3. CleanupHeapProfiling (env cleanup hook): + // Fires only if neither (1) nor (2) removed the hook first. + // Calls DoCleanup() and deletes the struct. + // + // DoCleanup() is idempotent (guarded by cleaned_up flag), so + // multiple paths calling it is safe. The first path that runs + // removes the hook and deletes the struct; subsequent paths see + // nullptr and skip. + HeapProfilingCleanup* heap_profiling_cleanup_ = nullptr; + void MemoryInfo(MemoryTracker* tracker) const override; SET_SELF_SIZE(BindingData) SET_MEMORY_INFO_NAME(BindingData) From a8ac88ee7a4293a44a7efa56dd71df2e33656392 Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Wed, 15 Apr 2026 16:19:06 +0100 Subject: [PATCH 03/13] lib: add heap profile labels API to v8 module Add JS API for labeling heap profiler samples via AsyncLocalStorage. Labels are pre-flattened at set time to avoid V8 property access during resolution. Signed-off-by: Rudolf Meijering --- lib/v8.js | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/lib/v8.js b/lib/v8.js index c0d4074aac21d5..90f6e30268469c 100644 --- a/lib/v8.js +++ b/lib/v8.js @@ -26,7 +26,9 @@ const { Int32Array, Int8Array, JSONParse, + ObjectKeys, ObjectPrototypeToString, + ReflectGet, SymbolDispose, Uint16Array, Uint32Array, @@ -39,6 +41,8 @@ const { const { Buffer } = require('buffer'); const { + validateFunction, + validateObject, validateString, validateUint32, validateOneOf, @@ -156,6 +160,11 @@ const { heapSpaceStatisticsBuffer, getCppHeapStatistics: _getCppHeapStatistics, detailLevel, + + startSamplingHeapProfiler: _startSamplingHeapProfiler, + stopSamplingHeapProfiler: _stopSamplingHeapProfiler, + getAllocationProfile: _getAllocationProfile, + setHeapProfileLabelsStore: _setHeapProfileLabelsStore, } = binding; const kNumberOfHeapSpaces = kHeapSpaces.length; @@ -494,6 +503,109 @@ class GCProfiler { } } +// --- Heap profile labels API --- +// Internal AsyncLocalStorage for propagating labels through async context. +// Requires --experimental-async-context-frame (Node 22) or Node 24+. +// Lazily initialized on first use so processes that never use heap profiling +// pay zero cost (no ALS instance, no async_hooks require). +let _heapProfileLabelsALS; + +function ensureHeapProfileLabelsALS() { + if (_heapProfileLabelsALS === undefined) { + // When V8_HEAP_PROFILER_SAMPLE_LABELS is compiled out, the C++ binding + // for _setHeapProfileLabelsStore is not registered — labels are a no-op. + if (typeof _setHeapProfileLabelsStore !== 'function') return; + const { AsyncLocalStorage } = require('async_hooks'); + _heapProfileLabelsALS = new AsyncLocalStorage(); + // The ALS instance is passed to C++ as the key used to look up labels in + // the AsyncContextFrame map (via ContinuationPreservedEmbedderData). + // This relies on the async-context-frame implementation storing ALS + // instances as Map keys — if that internal representation changes, the + // C++ label-resolution code in node_v8.cc must be updated to match. + _setHeapProfileLabelsStore(_heapProfileLabelsALS); + } + return _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 in GetAllocationProfile() BEFORE BuildSamples() where + * it resolves labels from CPED captured at allocation time. + * @param {Record} labels + * @returns {string[]} + */ +function labelsToFlat(labels) { + const keys = ObjectKeys(labels); + const len = keys.length; + const flat = new Array(len * 2); + for (let i = 0; i < len; i++) { + const key = keys[i]; + const val = ReflectGet(labels, key); + validateString(val, `labels.${key}`); + flat[i * 2] = key; + flat[i * 2 + 1] = val; + } + 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', true); + 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). + // The C++ callback resolves labels from CPED in GetAllocationProfile() + // before BuildSamples() — pre-flattening avoids V8 Object property + // access during label resolution. + const flat = labelsToFlat(labels); + const als = ensureHeapProfileLabelsALS(); + // When labels are compiled out, still run the callback — just without + // label tracking. + if (als === undefined) return fn(); + return als.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); + const als = ensureHeapProfileLabelsALS(); + // When labels are compiled out, setHeapProfileLabels is a no-op. + if (als === undefined) return; + als.enterWith(flat); +} + module.exports = { cachedDataVersionTag, getHeapSnapshot, @@ -518,4 +630,9 @@ module.exports = { GCProfiler, isStringOneByteRepresentation, startCpuProfile, + startSamplingHeapProfiler, + stopSamplingHeapProfiler: _stopSamplingHeapProfiler, + getAllocationProfile: _getAllocationProfile, + withHeapProfileLabels, + setHeapProfileLabels, }; From c1934a563c98b7114b1f5f4a031d80cf8708491e Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Wed, 15 Apr 2026 16:19:16 +0100 Subject: [PATCH 04/13] test: add heap profile labels tests Cover basic labeling, multi-key labels, async propagation, worker cleanup, and C++ callback tests. Signed-off-by: Rudolf Meijering --- test/cctest/test_heap_profile_labels.cc | 358 ++++++++++++++++++ .../test-v8-heap-profile-labels-async.js | 154 ++++++++ .../test-v8-heap-profile-labels-worker.js | 96 +++++ test/parallel/test-v8-heap-profile-labels.js | 353 +++++++++++++++++ 4 files changed, 961 insertions(+) create mode 100644 test/cctest/test_heap_profile_labels.cc create mode 100644 test/parallel/test-v8-heap-profile-labels-async.js create mode 100644 test/parallel/test-v8-heap-profile-labels-worker.js create mode 100644 test/parallel/test-v8-heap-profile-labels.js diff --git a/test/cctest/test_heap_profile_labels.cc b/test/cctest/test_heap_profile_labels.cc new file mode 100644 index 00000000000000..33435c52c8e374 --- /dev/null +++ b/test/cctest/test_heap_profile_labels.cc @@ -0,0 +1,358 @@ +// 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 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..81b3f9bd3345c8 --- /dev/null +++ b/test/parallel/test-v8-heap-profile-labels-async.js @@ -0,0 +1,154 @@ +// Heap profile labels require async-context-frame (enabled by default in +// Node.js 24+; use --experimental-async-context-frame on Node.js 22). +'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-worker.js b/test/parallel/test-v8-heap-profile-labels-worker.js new file mode 100644 index 00000000000000..d4dbbed5a0aac7 --- /dev/null +++ b/test/parallel/test-v8-heap-profile-labels-worker.js @@ -0,0 +1,96 @@ +// Heap profile labels require async-context-frame (enabled by default in +// Node.js 24+; use --experimental-async-context-frame on Node.js 22). +'use strict'; +// Test that terminating a worker thread while heap profiling is active +// does not crash. The cleanup hook in node_v8.cc must clear the V8 +// HeapProfiler callback and disable allocator tracking before the +// Isolate is disposed. +const common = require('../common'); +const assert = require('assert'); +const { Worker } = require('worker_threads'); + +// Test 1: Worker starts profiling, is terminated without stopping profiler. +{ + const worker = new Worker(` + const v8 = require('v8'); + const { parentPort } = require('worker_threads'); + + v8.startSamplingHeapProfiler(64); + + // Allocate some objects to generate samples + const arr = []; + for (let i = 0; i < 500; i++) arr.push({ x: i }); + + // Signal that profiling is active + parentPort.postMessage('profiling'); + + // Keep worker alive until terminated + setTimeout(() => {}, 60000); + `, { eval: true }); + + worker.on('message', common.mustCall((msg) => { + assert.strictEqual(msg, 'profiling'); + // Terminate the worker while profiling is still active + worker.terminate(); + })); + + worker.on('exit', common.mustCall((code) => { + assert.strictEqual(code, 1); + })); +} + +// Test 2: Worker starts profiling with labels, is terminated. +{ + const worker = new Worker(` + const v8 = require('v8'); + const { parentPort } = require('worker_threads'); + + v8.startSamplingHeapProfiler(64); + + v8.withHeapProfileLabels({ route: '/worker' }, () => { + const arr = []; + for (let i = 0; i < 500; i++) arr.push({ x: i }); + + // Signal that labeled profiling is active + parentPort.postMessage('labeled'); + + // Keep worker alive until terminated + setTimeout(() => {}, 60000); + }); + `, { eval: true }); + + worker.on('message', common.mustCall((msg) => { + assert.strictEqual(msg, 'labeled'); + worker.terminate(); + })); + + worker.on('exit', common.mustCall((code) => { + assert.strictEqual(code, 1); + })); +} + +// Test 3: Worker starts and stops profiling normally, then exits. +{ + const worker = new Worker(` + const v8 = require('v8'); + const { parentPort } = require('worker_threads'); + + v8.startSamplingHeapProfiler(64); + const arr = []; + for (let i = 0; i < 500; i++) arr.push({ x: i }); + const profile = v8.getAllocationProfile(); + v8.stopSamplingHeapProfiler(); + + parentPort.postMessage({ + hasSamples: profile && profile.samples && profile.samples.length > 0 + }); + `, { eval: true }); + + worker.on('message', common.mustCall((msg) => { + assert.ok(msg.hasSamples, 'Worker profiling should produce samples'); + })); + + worker.on('exit', common.mustCall((code) => { + assert.strictEqual(code, 0); + })); +} 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..49e30709cbadd7 --- /dev/null +++ b/test/parallel/test-v8-heap-profile-labels.js @@ -0,0 +1,353 @@ +// Flags: --expose-gc +// Heap profile labels require async-context-frame (enabled by default in +// Node.js 24+; use --experimental-async-context-frame on Node.js 22). +'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: startSamplingHeapProfiler(0) throws RangeError (0 hits CHECK_GT in V8) +assert.throws(() => v8.startSamplingHeapProfiler(0), { + code: 'ERR_OUT_OF_RANGE', + name: 'RangeError', +}); + +// 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. +// Regression test for nodejs/node#62649: 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 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 ' + + '(nodejs/node#62649 regression)' + ); +} From 0e7004b0720f7c2dfaaeea37cd42cda4b08bfebf Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Wed, 15 Apr 2026 16:19:50 +0100 Subject: [PATCH 05/13] doc: add heap profiler labels API documentation Document the new labels API on startSamplingHeapProfiler, getAllocationProfile, withHeapProfileLabels, and setHeapProfileLabels. Signed-off-by: Rudolf Meijering --- doc/api/v8.md | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/doc/api/v8.md b/doc/api/v8.md index 7ee7a748674cae..260054c95fc6b6 100644 --- a/doc/api/v8.md +++ b/doc/api/v8.md @@ -1453,6 +1453,108 @@ 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" } } + ] +} +``` + +* `samples[].labels` — key-value string pairs from the active label context + at allocation time. Empty object if no labels were active. + +### `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). + +Not measured: `Buffer`/`ArrayBuffer` backing stores, native addon memory, +JIT code space, OS-level allocations. + ## Class: `v8.GCProfiler`