From bc4025307f2ce79ae96300ed06990832e80b6685 Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Thu, 6 Aug 2026 15:20:43 +0200 Subject: [PATCH 1/6] deps: add heap profile sample labels to V8 Attach an opaque label to each heap profiler allocation sample so a profile can attribute memory to application context. Add LabelInternTable, which refcounts Global keyed on identity hash and hands out uint32_t ids, and a label_id field on AllocationProfile::Sample. The sampler captures the value held in ContinuationPreservedEmbedderData at allocation time and interns it; callers resolve the id back to the value through the profiler. The table is owned by HeapProfiler so it outlives the sampler, which holds a reference. Intern and Lookup require the isolate main thread; Release is safe from any thread, because the embedder frees tracked allocations from a background thread, and queues work that must run on the main thread. All of it sits behind V8_HEAP_PROFILER_SAMPLE_LABELS so builds that do not define the macro see no change, including no change to the layout of AllocationProfile::Sample. Signed-off-by: Rudolf Meijering --- deps/v8/BUILD.gn | 7 + deps/v8/include/v8-profiler.h | 60 ++++ deps/v8/src/api/api.cc | 23 ++ deps/v8/src/profiler/heap-profiler.cc | 74 ++++- deps/v8/src/profiler/heap-profiler.h | 36 ++ deps/v8/src/profiler/label-intern-table.cc | 201 ++++++++++++ deps/v8/src/profiler/label-intern-table.h | 125 +++++++ .../v8/src/profiler/sampling-heap-profiler.cc | 103 +++++- deps/v8/src/profiler/sampling-heap-profiler.h | 36 +- deps/v8/test/cctest/test-heap-profiler.cc | 307 ++++++++++++++++++ tools/v8_gypfiles/features.gypi | 7 +- 11 files changed, 968 insertions(+), 11 deletions(-) create mode 100644 deps/v8/src/profiler/label-intern-table.cc create mode 100644 deps/v8/src/profiler/label-intern-table.h diff --git a/deps/v8/BUILD.gn b/deps/v8/BUILD.gn index e81430fbc393..ad520e2a199a 100644 --- a/deps/v8/BUILD.gn +++ b/deps/v8/BUILD.gn @@ -1439,6 +1439,11 @@ config("features") { } if (v8_enable_continuation_preserved_embedder_data) { defines += [ "V8_ENABLE_CONTINUATION_PRESERVED_EMBEDDER_DATA" ] + + # Heap profile sample labels ride ContinuationPreservedEmbedderData, so + # they are gated on the same feature. Mirrors tools/v8_gypfiles/ + # features.gypi so GN and GYP builds enable the feature identically. + defines += [ "V8_HEAP_PROFILER_SAMPLE_LABELS" ] } if (v8_enable_allocation_folding) { defines += [ "V8_ALLOCATION_FOLDING" ] @@ -4454,6 +4459,7 @@ v8_header_set("v8_internal_headers") { "src/profiler/heap-snapshot-common.h", "src/profiler/heap-snapshot-generator-inl.h", "src/profiler/heap-snapshot-generator.h", + "src/profiler/label-intern-table.h", "src/profiler/output-stream-writer.h", "src/profiler/profile-generator-inl.h", "src/profiler/profile-generator.h", @@ -6086,6 +6092,7 @@ v8_source_set("v8_base_without_compiler") { "src/profiler/cpu-profiler.cc", "src/profiler/heap-profiler.cc", "src/profiler/heap-snapshot-generator.cc", + "src/profiler/label-intern-table.cc", "src/profiler/profile-generator.cc", "src/profiler/profiler-listener.cc", "src/profiler/profiler-stats.cc", diff --git a/deps/v8/include/v8-profiler.h b/deps/v8/include/v8-profiler.h index 927fa12e3190..efcdc29a04ae 100644 --- a/deps/v8/include/v8-profiler.h +++ b/deps/v8/include/v8-profiler.h @@ -11,6 +11,13 @@ #include #include +// NODE-LOCAL PATCH: heap profile sample labels feature, do not remove on V8 +// update. The V8_HEAP_PROFILER_SAMPLE_LABELS blocks in this header, in +// deps/v8/src/profiler/{sampling-heap-profiler,heap-profiler, +// label-intern-table}.{h,cc} and in deps/v8/src/api/api.cc are a Node.js +// floating patch on vendored V8, not legacy V8 code. See +// doc/contributing/maintaining/maintaining-V8.md for the refloat workflow. + #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) @@ -836,6 +843,14 @@ class V8_EXPORT AllocationProfile { * been collected by GC. */ bool is_live; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + /** + * Opaque id for the label captured at allocation time, or 0 for none. + * Resolve it with HeapProfiler::ResolveLabelValue, which returns empty + * once the profiler has been stopped. + */ + uint32_t label_id; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS }; /** @@ -987,6 +1002,7 @@ class QueryObjectPredicate { virtual bool Filter(v8::Local object) = 0; }; + /** * Interface for controlling heap profiling. Instance of the * profiler can be retrieved using v8::Isolate::GetHeapProfiler. @@ -1286,6 +1302,50 @@ class V8_EXPORT HeapProfiler { void SetGetDetachednessCallback(GetDetachednessCallback callback, void* data); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + /** + * Sets the key under which each sample's label is looked up in the + * ContinuationPreservedEmbedderData Map when the sample is taken. Setting a + * key also enables label capture; passing an empty handle clears it. May be + * called at any time; changes take effect on subsequent samples. + */ + void SetHeapProfileSampleLabelsKey(Local key); + + /** + * Looks |cped| up as a Map under the key set by + * SetHeapProfileSampleLabelsKey. Allocation-free and GC-safe, so it is + * usable from sampling context. Returns empty if no key is set, |cped| is + * not a Map, or the key is absent. + */ + MaybeLocal LookupAlsValue(Local cped); + + /** + * Interns |value| and returns an id the embedder can store in place of a + * Global, or 0 if no sampling profiler is active or |value| cannot + * be interned. Interning the same value again returns the same id and + * increments its refcount, so every non-zero result must be balanced by a + * ReleaseLabelValue or the value stays pinned. + * + * Main thread only; ReleaseLabelValue is the only entry point here that + * background threads may call. + */ + uint32_t InternLabelValue(Local value); + + /** + * Decrements the refcount for |id|, letting the value be collected once it + * reaches zero. Safe to call from any thread, and safe after + * StopSamplingHeapProfiler, which leaves stale ids resolving to nothing. + */ + void ReleaseLabelValue(uint32_t id); + + /** + * Resolves |id| to the value that was interned under it, or empty if it has + * been released or the session that minted it has stopped. Main thread + * only, and the caller must hold a HandleScope. + */ + MaybeLocal ResolveLabelValue(uint32_t id); +#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 fbd628370c0b..d014ed78dcdf 100644 --- a/deps/v8/src/api/api.cc +++ b/deps/v8/src/api/api.cc @@ -12011,6 +12011,29 @@ void HeapProfiler::SetGetDetachednessCallback(GetDetachednessCallback callback, data); } +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +void HeapProfiler::SetHeapProfileSampleLabelsKey(Local key) { + reinterpret_cast(this) + ->SetHeapProfileSampleLabelsKey(key); +} + +MaybeLocal HeapProfiler::LookupAlsValue(Local cped) { + return reinterpret_cast(this)->LookupAlsValue(cped); +} + +uint32_t HeapProfiler::InternLabelValue(Local value) { + return reinterpret_cast(this)->InternLabelValue(value); +} + +void HeapProfiler::ReleaseLabelValue(uint32_t id) { + reinterpret_cast(this)->ReleaseLabelValue(id); +} + +MaybeLocal HeapProfiler::ResolveLabelValue(uint32_t id) { + return reinterpret_cast(this)->ResolveLabelValue(id); +} +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + bool HeapProfiler::IsTakingSnapshot() { return reinterpret_cast(this)->IsTakingSnapshot(); } diff --git a/deps/v8/src/profiler/heap-profiler.cc b/deps/v8/src/profiler/heap-profiler.cc index c123645e8a4d..adc3ed2a8a92 100644 --- a/deps/v8/src/profiler/heap-profiler.cc +++ b/deps/v8/src/profiler/heap-profiler.cc @@ -18,6 +18,8 @@ #include "src/heap/heap.h" #include "src/objects/cpp-heap-object-wrapper-inl.h" #include "src/objects/js-array-buffer-inl.h" +#include "src/objects/js-collection-inl.h" +#include "src/objects/ordered-hash-table.h" #include "src/profiler/allocation-tracker.h" #include "src/profiler/heap-snapshot-generator-inl.h" #include "src/profiler/sampling-heap-profiler.h" @@ -29,7 +31,13 @@ HeapProfiler::HeapProfiler(Heap* heap) : ids_(new HeapObjectsMap(heap)), names_(new StringsStorage()), is_tracking_object_moves_(false), - is_taking_snapshot_(false) {} + is_taking_snapshot_(false) +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + , + label_intern_table_(reinterpret_cast(heap->isolate())) +#endif +{ +} HeapProfiler::~HeapProfiler() = default; @@ -233,12 +241,28 @@ bool HeapProfiler::StartSamplingHeapProfiler( v8::HeapProfiler::SamplingFlags flags) { if (sampling_heap_profiler_) return false; sampling_heap_profiler_.reset(new SamplingHeapProfiler( - heap(), names_.get(), sample_interval, stack_depth, flags)); + heap(), names_.get(), sample_interval, stack_depth, flags +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + , + label_intern_table_ +#endif + )); return true; } void HeapProfiler::StopSamplingHeapProfiler() { sampling_heap_profiler_.reset(); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // Stop the finished session from pinning JS values. Ids it minted then + // resolve to empty, and releasing them is a no-op. + label_intern_table_.Clear(); + // Clear the ALS key so a later session that never requested labels does + // not inherit the previous session's key and emit labelled samples. + // Node re-arms the key on every labels:true start, so clearing here is + // safe; an out-of-band stop cannot notify Node, so this is the only + // place the clear can happen reliably. + sample_labels_als_key_.Reset(); +#endif MaybeClearStringsStorage(); } @@ -405,4 +429,50 @@ void HeapProfiler::QueryObjects(DirectHandle context, }); } +// NODE-LOCAL PATCH: heap profile sample labels feature, do not remove on V8 +// update. See the comment at the top of include/v8-profiler.h. +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +uint32_t HeapProfiler::InternLabelValue(v8::Local value) { + // Interning with no sampler running would accumulate entries that nothing + // will release. Main thread only, so reading the pointer is safe. + if (!sampling_heap_profiler_) return LabelInternTable::kNoLabelId; + if (value.IsEmpty()) return LabelInternTable::kNoLabelId; + return label_intern_table_.Intern(value); +} + +void HeapProfiler::ReleaseLabelValue(uint32_t id) { + if (id == LabelInternTable::kNoLabelId) return; + // Callable from any thread: the table lives as long as the isolate, so a + // background sweeper never reaches one that is being destroyed, and no + // check of sampling_heap_profiler_ is needed. + label_intern_table_.Release(id); +} + +v8::MaybeLocal HeapProfiler::ResolveLabelValue(uint32_t id) { + if (id == LabelInternTable::kNoLabelId) return v8::MaybeLocal(); + return label_intern_table_.Lookup(id); +} + +v8::MaybeLocal HeapProfiler::LookupAlsValue( + v8::Local cped) { + if (sample_labels_als_key_.IsEmpty() || cped.IsEmpty()) { + return v8::MaybeLocal(); + } + Tagged cped_obj = *Utils::OpenDirectHandle(*cped); + if (!IsJSMap(cped_obj)) return v8::MaybeLocal(); + + Tagged js_map = Cast(cped_obj); + Tagged table = Cast(js_map->table()); + + v8::Isolate* v8_isolate = reinterpret_cast(isolate()); + v8::Local als_key_local = sample_labels_als_key_.Get(v8_isolate); + Tagged key_obj = *Utils::OpenDirectHandle(*als_key_local); + InternalIndex entry = table->FindEntry(isolate(), key_obj); + if (!entry.is_found()) return v8::MaybeLocal(); + + Tagged value = table->ValueAt(entry); + return Utils::ToLocal(direct_handle(value, isolate())); +} +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + } // namespace v8::internal diff --git a/deps/v8/src/profiler/heap-profiler.h b/deps/v8/src/profiler/heap-profiler.h index 82d4db266e7d..597067ab99f9 100644 --- a/deps/v8/src/profiler/heap-profiler.h +++ b/deps/v8/src/profiler/heap-profiler.h @@ -14,6 +14,9 @@ #include "src/debug/debug-interface.h" #include "src/heap/heap.h" #include "src/profiler/heap-snapshot-common.h" +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +#include "src/profiler/label-intern-table.h" +#endif namespace v8 { namespace internal { @@ -79,6 +82,30 @@ class HeapProfiler : public HeapObjectAllocationTracker { bool is_sampling_allocations() { return !!sampling_heap_profiler_; } AllocationProfile* GetAllocationProfile(); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + void SetHeapProfileSampleLabelsKey(v8::Local key) { + if (key.IsEmpty()) { + sample_labels_als_key_.Reset(); + } else { + sample_labels_als_key_.Reset( + reinterpret_cast(isolate()), key); + } + } + + const v8::Global& sample_labels_als_key() const { + return sample_labels_als_key_; + } + + v8::MaybeLocal LookupAlsValue(v8::Local cped); + + // Entry points for embedder-side allocation trackers sharing the label + // table. Only ReleaseLabelValue may be called off the main thread; see + // include/v8-profiler.h for the full contract. + uint32_t InternLabelValue(v8::Local value); + void ReleaseLabelValue(uint32_t id); + v8::MaybeLocal ResolveLabelValue(uint32_t id); +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + void StartHeapObjectsTracking(bool track_allocations); void StopHeapObjectsTracking(); AllocationTracker* allocation_tracker() const { @@ -168,6 +195,15 @@ class HeapProfiler : public HeapObjectAllocationTracker { bool is_tracking_object_moves_; bool is_taking_snapshot_; base::Mutex profiler_mutex_; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // Main thread only: written by SetHeapProfileSampleLabelsKey(), read by + // SampleObject(). + v8::Global sample_labels_als_key_; + // Must stay declared before sampling_heap_profiler_. Members are destroyed + // in reverse declaration order, and ~SamplingHeapProfiler releases every + // retained sample's id into this table. + LabelInternTable label_intern_table_; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS std::unique_ptr sampling_heap_profiler_; std::vector> build_embedder_graph_callbacks_; diff --git a/deps/v8/src/profiler/label-intern-table.cc b/deps/v8/src/profiler/label-intern-table.cc new file mode 100644 index 000000000000..717c03fdbefa --- /dev/null +++ b/deps/v8/src/profiler/label-intern-table.cc @@ -0,0 +1,201 @@ +// Copyright 2026 the V8 project authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// NODE-LOCAL PATCH: heap profile sample labels feature, do not remove on V8 +// update. This whole file is part of the Node.js floating patch set; see the +// comment at the top of include/v8-profiler.h. +#include "src/profiler/label-intern-table.h" + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + +#include + +#include "include/v8-isolate.h" +#include "src/api/api-inl.h" +#include "src/execution/isolate.h" +#include "src/objects/js-objects-inl.h" +#include "src/objects/objects-inl.h" +#include "src/objects/smi.h" + +namespace v8 { +namespace internal { + +LabelInternTable::LabelInternTable(v8::Isolate* isolate) : isolate_(isolate) {} + +LabelInternTable::~LabelInternTable() { + // Draining before the walk avoids double-Reset on entries about to be freed. + base::MutexGuard guard(&mutex_); + DrainPendingFreeLocked(); + for (auto& bucket : buckets_) { + for (auto& entry : bucket.second) { + entry.global.Reset(); + } + } + buckets_.clear(); + id_to_hash_.clear(); +} + +void LabelInternTable::DrainPendingFreeLocked() { + // A duplicate id (queued, revived, released again before any drain) is + // harmless: the first occurrence erases id_to_hash_[id] and the rest miss. + for (uint32_t id : pending_free_) { + auto map_it = id_to_hash_.find(id); + if (map_it == id_to_hash_.end()) continue; + uint32_t hash = map_it->second; + auto bucket_it = buckets_.find(hash); + if (bucket_it == buckets_.end()) continue; + auto& chain = bucket_it->second; + for (auto entry_it = chain.begin(); entry_it != chain.end(); ++entry_it) { + if (entry_it->id != id) continue; + if (entry_it->refcount > 0) break; // revived; leave alone + entry_it->global.Reset(); + chain.erase(entry_it); + id_to_hash_.erase(map_it); + if (chain.empty()) buckets_.erase(bucket_it); + break; + } + } + pending_free_.clear(); +} + +uint32_t LabelInternTable::Intern(v8::Local value) { + DCHECK(!value.IsEmpty()); + Isolate* i_isolate = reinterpret_cast(isolate_); + + DisallowGarbageCollection no_gc; + Tagged value_obj = *Utils::OpenDirectHandle(*value); + // Identity hash is only defined for JSReceiver. + if (!IsJSReceiver(value_obj)) return kNoLabelId; + + Tagged receiver = Cast(value_obj); + uint32_t hash = static_cast( + receiver->GetOrCreateIdentityHash(i_isolate).value()); + hash &= hash_mask_; + + Address candidate_ptr = value_obj.ptr(); + base::MutexGuard guard(&mutex_); + auto& chain = buckets_[hash]; + for (Entry& entry : chain) { + Tagged existing = + *Utils::OpenDirectHandle(*entry.global.Get(isolate_)); + if (existing.ptr() == candidate_ptr) { + // Bumping an entry at refcount 0 revives it: the drain below skips + // queued ids that are live again, so the Global is never Reset across + // the Release/Intern race and identity is preserved. + // Saturate rather than wrap: an overflowed refcount would later + // underflow on Release and free a still-live label. + if (entry.refcount != std::numeric_limits::max()) { + ++entry.refcount; + } + // Copy the id out first. The drain can erase earlier entries in this + // chain, which invalidates `entry`. + uint32_t revived_id = entry.id; + DrainPendingFreeLocked(); + return revived_id; + } + } + + // Mint a fresh id. In steady state next_id_ has never been issued, so the + // first candidate is free. After ~2^32 interns the counter wraps; skip + // kNoLabelId and any id still mapped to a live entry. If no free id turns + // up within the probe bound, fail closed (drop the label) rather than + // aliasing a live id, which would corrupt refcounts and attribution. + // + // The probe treats ids queued in pending_free_ as occupied (they are still + // in id_to_hash_ until drained), so on wraparound it may fail closed + // before a pending drain would free an id. Draining first is not done + // here: DrainPendingFreeLocked() can erase from `chain`, invalidating the + // `chain` reference taken above. Wraparound requires roughly 2^32 interns + // in one isolate. + uint32_t id = kNoLabelId; + for (uint32_t probe = 0; probe <= kIdProbeLimit; ++probe) { + uint32_t candidate = ++next_id_; + if (candidate == kNoLabelId) continue; + if (id_to_hash_.count(candidate) == 0) { + id = candidate; + break; + } + } + if (id == kNoLabelId) { + // Do not leave an empty bucket behind from buckets_[hash] above. + if (chain.empty()) buckets_.erase(hash); + DrainPendingFreeLocked(); + return kNoLabelId; + } + chain.push_back(Entry{v8::Global(isolate_, value), 1, id}); + id_to_hash_[id] = hash; + // `chain` must not be used below: the drain can erase from it. + DrainPendingFreeLocked(); + return id; +} + +void LabelInternTable::Release(uint32_t id) { + if (id == kNoLabelId) return; + base::MutexGuard guard(&mutex_); + auto map_it = id_to_hash_.find(id); + if (map_it == id_to_hash_.end()) return; + + auto bucket_it = buckets_.find(map_it->second); + DCHECK(bucket_it != buckets_.end()); + for (Entry& entry : bucket_it->second) { + if (entry.id != id) continue; + DCHECK_GT(entry.refcount, 0u); + --entry.refcount; + // Queue only: Global::Reset() is main-thread only, and this runs on the + // sweeper thread. The entry stays in its bucket until drained, so a + // racing Intern can still revive it. + if (entry.refcount == 0) pending_free_.push_back(id); + return; + } + DCHECK(false); // id_to_hash_ pointed at a bucket with no such entry +} + +void LabelInternTable::Clear() { + // Emptying id_to_hash_ is what makes a later Release() of a stale id a + // no-op rather than a decrement of an unrelated entry. + base::MutexGuard guard(&mutex_); + DrainPendingFreeLocked(); + for (auto& bucket : buckets_) { + for (auto& entry : bucket.second) { + entry.global.Reset(); + } + } + buckets_.clear(); + id_to_hash_.clear(); +} + +v8::MaybeLocal LabelInternTable::Lookup(uint32_t id) { + if (id == kNoLabelId) return v8::MaybeLocal(); + base::MutexGuard guard(&mutex_); + // Drain first, unlike Intern: there is no revival path here, and every + // reference into buckets_ below is taken after the drain. + DrainPendingFreeLocked(); + auto map_it = id_to_hash_.find(id); + if (map_it == id_to_hash_.end()) return v8::MaybeLocal(); + + auto bucket_it = buckets_.find(map_it->second); + DCHECK(bucket_it != buckets_.end()); + for (Entry& entry : bucket_it->second) { + if (entry.id != id) continue; + if (entry.refcount == 0) return v8::MaybeLocal(); + return entry.global.Get(isolate_); + } + return v8::MaybeLocal(); +} + +size_t LabelInternTable::SizeForTesting() const { + base::MutexGuard guard(&mutex_); + size_t live = 0; + for (const auto& bucket : buckets_) { + for (const auto& entry : bucket.second) { + if (entry.refcount > 0) ++live; + } + } + return live; +} + +} // namespace internal +} // namespace v8 + +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS diff --git a/deps/v8/src/profiler/label-intern-table.h b/deps/v8/src/profiler/label-intern-table.h new file mode 100644 index 000000000000..7f41fbf8cb8b --- /dev/null +++ b/deps/v8/src/profiler/label-intern-table.h @@ -0,0 +1,125 @@ +// Copyright 2026 the V8 project authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// NODE-LOCAL PATCH: heap profile sample labels feature, do not remove on V8 +// update. This whole file is part of the Node.js floating patch set; see the +// comment at the top of include/v8-profiler.h. +#ifndef V8_PROFILER_LABEL_INTERN_TABLE_H_ +#define V8_PROFILER_LABEL_INTERN_TABLE_H_ + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + +#include +#include +#include + +#include "include/v8-local-handle.h" +#include "include/v8-persistent-handle.h" +#include "src/base/macros.h" +#include "src/base/platform/mutex.h" + +namespace v8 { + +class Isolate; +class Value; + +namespace internal { + +// Refcounted table mapping JS values to uint32_t ids, so a sample can hold a +// 4-byte id instead of a Global and its GlobalHandles::Node. Keyed on +// JSReceiver::GetOrCreateIdentityHash, which is stable across GC moves; +// collisions walk a per-bucket vector comparing object addresses. +// +// Ids are drawn from a per-table counter and are never reused, so an id minted +// by a stopped session resolves to empty rather than to an unrelated value. +// +// Release() runs on any thread, because V8's ArrayBufferSweeper frees backing +// stores off-thread, but Global::Reset() is main-thread only. Release() +// therefore only does refcount math and queues the id on pending_free_; +// Intern() and Lookup() drain the queue, and so does the destructor if neither +// is ever called again. Intern() drains after inserting, so that an entry +// revived by a racing Intern outlives the drain its own 1->0 transition +// queued; DrainPendingFreeLocked() skipping revived entries is what makes that +// safe. +class V8_EXPORT_PRIVATE LabelInternTable { + public: + // Reserved id meaning "no label". + static constexpr uint32_t kNoLabelId = 0; + + explicit LabelInternTable(v8::Isolate* isolate); + ~LabelInternTable(); + LabelInternTable(const LabelInternTable&) = delete; + LabelInternTable& operator=(const LabelInternTable&) = delete; + + // Returns an id for value, bumping the refcount if it is already interned. + // Non-receiver values return kNoLabelId. Main thread only. + uint32_t Intern(v8::Local value); + + // Decrements the refcount for id, queueing it for drain on the 1->0 + // transition. No-op for kNoLabelId or an id the table does not hold. + // Safe to call from any thread. + void Release(uint32_t id); + + // Empties the table so a stopped session stops pinning JS values. The table + // outlives the session: a later session interns into it again. Main thread + // only. + void Clear(); + + // Returns the interned value for id, or empty if it has been released. + // Main thread only. + v8::MaybeLocal Lookup(uint32_t id); + + // Counts entries with refcount > 0, excluding those pending free. + size_t SizeForTesting() const; + + // Masks every identity hash, so mask 0 forces all entries into one bucket + // and makes collision handling testable. Call before the first Intern(). + void SetHashMaskForTesting(uint32_t mask) { hash_mask_ = mask; } + + // Seeds the id counter so the wraparound path can be reached in a test + // without minting 2^32 ids. The next id issued is next + 1 (skipping + // kNoLabelId). Call before the Intern() under test. + void SetNextIdForTesting(uint32_t next) { next_id_ = next; } + + // The bound Intern() probes before failing closed on wraparound. + static constexpr uint32_t ProbeLimitForTesting() { return kIdProbeLimit; } + + private: + // Upper bound on the linear probe used to find a free id after the counter + // wraps. Live ids are a small fraction of the 2^32 space, so a free id is + // typically found in a few probes; the bound limits the search if no free + // id is present in that window. + static constexpr uint32_t kIdProbeLimit = 4096; + struct Entry { + v8::Global global; + uint32_t refcount; + uint32_t id; + }; + + // Frees each queued id that is still present and still at refcount 0, + // skipping any revived since queueing. Caller must hold mutex_. + void DrainPendingFreeLocked(); + + v8::Isolate* const isolate_; + // Guards every field below. Held across the body of each public method, so + // that a Release() from the ArrayBufferSweeper thread cannot corrupt the + // table. + mutable base::Mutex mutex_; + uint32_t hash_mask_ = 0xffffffff; + // kNoLabelId is reserved, so the first id issued is 1. Wraparound needs 4 + // billion interns in one isolate; Intern() then probes past kNoLabelId and + // any still-live id, and fails closed rather than aliasing a live id. + uint32_t next_id_ = 0; + std::unordered_map> buckets_; + // id -> hash, so Release() and Lookup() find their bucket in O(1). + std::unordered_map id_to_hash_; + std::vector pending_free_; +}; + +} // namespace internal +} // namespace v8 + +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + +#endif // V8_PROFILER_LABEL_INTERN_TABLE_H_ diff --git a/deps/v8/src/profiler/sampling-heap-profiler.cc b/deps/v8/src/profiler/sampling-heap-profiler.cc index 228234c02258..ae3b738512e1 100644 --- a/deps/v8/src/profiler/sampling-heap-profiler.cc +++ b/deps/v8/src/profiler/sampling-heap-profiler.cc @@ -4,6 +4,7 @@ #include "src/profiler/sampling-heap-profiler.h" +#include #include #include @@ -16,8 +17,44 @@ #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" +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +// label_id is appended after the pre-existing fields, so their offsets never +// change on any ABI. sizeof(Sample) is unchanged too wherever the base struct +// has tail padding to absorb the field; where it does not (i386 System V, see +// kLabelIdFitsBasePadding below) the struct grows and an addon must define the +// macro to match libnode. The asserts enforce this in code rather than only in +// prose (see doc/api/v8.md, "Native addon ABI"). +namespace { +constexpr size_t kSampleAlign = alignof(v8::AllocationProfile::Sample); +constexpr size_t kSampleBaseEnd = + offsetof(v8::AllocationProfile::Sample, is_live) + sizeof(bool); +constexpr size_t kSampleBaseSize = + (kSampleBaseEnd + kSampleAlign - 1) & ~(kSampleAlign - 1); +// On ABIs where uint64_t is 8-aligned (LP64, MSVC, ARM32 AAPCS, ...) the base +// struct carries enough tail padding to hold label_id without growing, so +// sizeof(Sample) is unchanged and an addon built without the macro strides +// GetSamples() correctly. On i386 System V, uint64_t is 4-aligned and only 3 +// tail bytes exist, so label_id grows the struct by 4; there the macro must be +// defined by such an addon. Only assert the no-growth invariant where the +// padding actually exists, so this does not break the i386 build. +constexpr bool kLabelIdFitsBasePadding = + (kSampleBaseSize - kSampleBaseEnd) >= sizeof(uint32_t); +static_assert( + !kLabelIdFitsBasePadding || + sizeof(v8::AllocationProfile::Sample) == kSampleBaseSize, + "where the base struct has tail padding, label_id must occupy it so " + "sizeof(Sample) is unchanged by V8_HEAP_PROFILER_SAMPLE_LABELS"); +// Always true regardless of ABI: label_id is appended after every pre-existing +// field, so none of their offsets shift. +static_assert(offsetof(v8::AllocationProfile::Sample, label_id) >= + kSampleBaseEnd, + "label_id must follow the pre-existing fields"); +} // namespace +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + namespace v8 { namespace internal { @@ -53,7 +90,12 @@ v8::AllocationProfile::Allocation SamplingHeapProfiler::ScaleSample( SamplingHeapProfiler::SamplingHeapProfiler( Heap* heap, StringsStorage* names, uint64_t rate, int stack_depth, - v8::HeapProfiler::SamplingFlags flags) + v8::HeapProfiler::SamplingFlags flags +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + , + LabelInternTable& label_intern_table +#endif + ) : isolate_(Isolate::FromHeap(heap)), heap_(heap), allocation_observer_(heap_, static_cast(rate), rate, this, @@ -63,7 +105,12 @@ SamplingHeapProfiler::SamplingHeapProfiler( next_node_id()), stack_depth_(stack_depth), rate_(rate), - flags_(flags) { + flags_(flags) +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + , + label_intern_table_(label_intern_table) +#endif +{ CHECK_GT(rate_, 0u); heap_->AddAllocationObserversToAllSpaces(&allocation_observer_, &allocation_observer_); @@ -72,6 +119,18 @@ SamplingHeapProfiler::SamplingHeapProfiler( SamplingHeapProfiler::~SamplingHeapProfiler() { heap_->RemoveAllocationObserversFromAllSpaces(&allocation_observer_, &allocation_observer_); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // Retained samples (still live or kept by the include-collected flags) remain + // in samples_ at teardown. OnWeakCallback erases the samples it releases from + // samples_, so the destructor only visits unreleased samples. The guard skips + // samples that never carried a label. + for (auto& [ptr, sample] : samples_) { + if (sample->label_id != LabelInternTable::kNoLabelId) { + label_intern_table_.Release(sample->label_id); + sample->label_id = LabelInternTable::kNoLabelId; + } + } +#endif } void SamplingHeapProfiler::SampleObject(Address soon_object, size_t size) { @@ -95,8 +154,31 @@ void SamplingHeapProfiler::SampleObject(Address soon_object, size_t size) { AllocationNode* node = AddStack(); node->allocations_[size]++; + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // The CPED Map holds every ALS store, so intern only the value under our + // own key: that keeps the sample from pinning the whole map, and lets + // samples sharing an ALS value share one Global. + uint32_t label_id = LabelInternTable::kNoLabelId; + { + HeapProfiler* hp = isolate_->heap()->heap_profiler(); + if (!hp->sample_labels_als_key().IsEmpty()) { + v8::Isolate* v8_isolate = reinterpret_cast(isolate_); + v8::Local context = + v8_isolate->GetContinuationPreservedEmbedderDataV2().As(); + v8::Local als_value; + if (hp->LookupAlsValue(context).ToLocal(&als_value)) { + label_id = label_intern_table_.Intern(als_value); + } + } + } + auto sample = std::make_unique(size, node, loc, this, + next_sample_id(), label_id); +#else auto sample = std::make_unique(size, node, loc, this, next_sample_id()); +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + sample->global.SetWeak(sample.get(), OnWeakCallback, WeakCallbackType::kParameter); samples_.emplace(sample.get(), std::move(sample)); @@ -116,8 +198,13 @@ void SamplingHeapProfiler::OnWeakCallback( v8::HeapProfiler::kSamplingIncludeObjectsCollectedByMajorGC); if (should_keep_sample) { sample->global.Reset(); + // Keep label_id: the sample stays in samples_, so a later + // GetAllocationProfile can still attribute the collected object. return; } +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + sample->profiler->label_intern_table_.Release(sample->label_id); +#endif AllocationNode* node = sample->owner; DCHECK_GT(node->allocations_[sample->size], 0); node->allocations_[sample->size]--; @@ -313,9 +400,15 @@ SamplingHeapProfiler::BuildSamples() const { for (const auto& it : samples_) { const Sample* sample = it.second.get(); const bool is_live = !sample->global.IsEmpty(); - samples.emplace_back(v8::AllocationProfile::Sample{ - sample->owner->id_, sample->size, ScaleSample(sample->size, 1).count, - sample->sample_id, is_live}); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + samples.push_back({sample->owner->id_, sample->size, + ScaleSample(sample->size, 1).count, sample->sample_id, + is_live, sample->label_id}); +#else + samples.push_back({sample->owner->id_, sample->size, + ScaleSample(sample->size, 1).count, sample->sample_id, + is_live}); +#endif } return samples; } diff --git a/deps/v8/src/profiler/sampling-heap-profiler.h b/deps/v8/src/profiler/sampling-heap-profiler.h index 6a1010b99931..6371828287cf 100644 --- a/deps/v8/src/profiler/sampling-heap-profiler.h +++ b/deps/v8/src/profiler/sampling-heap-profiler.h @@ -12,6 +12,9 @@ #include "include/v8-profiler.h" #include "src/heap/heap.h" +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +#include "src/profiler/label-intern-table.h" +#endif #include "src/profiler/strings-storage.h" namespace v8 { @@ -101,12 +104,23 @@ class SamplingHeapProfiler { struct Sample { Sample(size_t size_, AllocationNode* owner_, Local local_, - SamplingHeapProfiler* profiler_, uint64_t sample_id) + SamplingHeapProfiler* profiler_, uint64_t sample_id +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + , + uint32_t label_id_ = 0 +#endif + ) : size(size_), owner(owner_), global(reinterpret_cast(profiler_->isolate_), local_), profiler(profiler_), - sample_id(sample_id) {} + sample_id(sample_id) +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + , + label_id(label_id_) +#endif + { + } Sample(const Sample&) = delete; Sample& operator=(const Sample&) = delete; const size_t size; @@ -114,10 +128,20 @@ class SamplingHeapProfiler { Global global; SamplingHeapProfiler* const profiler; const uint64_t sample_id; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // Released in OnWeakCallback, or in ~SamplingHeapProfiler for samples + // retained by the kSamplingIncludeObjectsCollectedBy*GC flags. + uint32_t label_id; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS }; SamplingHeapProfiler(Heap* heap, StringsStorage* names, uint64_t rate, - int stack_depth, v8::HeapProfiler::SamplingFlags flags); + int stack_depth, v8::HeapProfiler::SamplingFlags flags +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + , + LabelInternTable& label_intern_table +#endif + ); ~SamplingHeapProfiler(); SamplingHeapProfiler(const SamplingHeapProfiler&) = delete; SamplingHeapProfiler& operator=(const SamplingHeapProfiler&) = delete; @@ -194,6 +218,12 @@ class SamplingHeapProfiler { const int stack_depth_; const uint64_t rate_; v8::HeapProfiler::SamplingFlags flags_; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // NODE-LOCAL PATCH: heap profile sample labels feature, do not remove on V8 + // update. Owned by HeapProfiler, which outlives this object, so that a + // background ReleaseLabelValue() need not read sampling_heap_profiler_. + LabelInternTable& label_intern_table_; +#endif }; } // namespace internal diff --git a/deps/v8/test/cctest/test-heap-profiler.cc b/deps/v8/test/cctest/test-heap-profiler.cc index 427ad5ce91c0..12dbfcc83c84 100644 --- a/deps/v8/test/cctest/test-heap-profiler.cc +++ b/deps/v8/test/cctest/test-heap-profiler.cc @@ -33,6 +33,7 @@ #include #include +#include "include/v8-container.h" #include "include/v8-function.h" #include "include/v8-json.h" #include "include/v8-profiler.h" @@ -4920,3 +4921,309 @@ TEST(HeapSnapshotWithWasmInstance) { #endif // V8_ENABLE_SANDBOX } #endif // V8_ENABLE_WEBASSEMBLY + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + +// --- Tests for Sample::label_id and ResolveLabelValue --- + +// Sets up the ALS key on the heap profiler and stores a flat label array +// [key, val, ...] as the ALS value in a CPED Map. Both are required for +// SampleObject to intern the value at allocation time. +static void SetupAlsContext(v8::Isolate* isolate, v8::Local ctx, + v8::HeapProfiler* hp, + v8::Local als_value) { + v8::Local als_key = + v8::String::NewFromUtf8Literal(isolate, "node-heap-profiler"); + hp->SetHeapProfileSampleLabelsKey(als_key); + v8::Local cped_map = v8::Map::New(isolate); + cped_map->Set(ctx, als_key, als_value).ToLocalChecked(); + isolate->SetContinuationPreservedEmbedderDataV2(cped_map); +} + +// Returns a flat V8 Array ["route", route_val] as the ALS label value. +static v8::Local MakeLabelArray(v8::Isolate* isolate, + v8::Local ctx, + const char* route_val) { + v8::Local arr = v8::Array::New(isolate, 2); + arr->Set(ctx, 0, v8::String::NewFromUtf8Literal(isolate, "route")).Check(); + arr->Set(ctx, 1, v8::String::NewFromUtf8(isolate, route_val).ToLocalChecked()) + .Check(); + return arr; +} + +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; + + // Set up ALS key + CPED Map so SampleObject interns the value. + v8::Local label_arr = + MakeLabelArray(isolate, env.local(), "/api/test"); + SetupAlsContext(isolate, env.local(), heap_profiler, label_arr); + + 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 a non-zero label_id that resolves to the + // expected flat array. + bool found_labeled = false; + for (const auto& sample : profile->GetSamples()) { + if (sample.label_id != 0) { + v8::HandleScope hs(isolate); + v8::Local resolved; + CHECK(heap_profiler->ResolveLabelValue(sample.label_id) + .ToLocal(&resolved)); + CHECK(resolved->IsArray()); + v8::Local arr = resolved.As(); + CHECK_GE(arr->Length(), 2u); + v8::String::Utf8Value key( + isolate, arr->Get(env.local(), 0).ToLocalChecked()); + v8::String::Utf8Value val( + isolate, arr->Get(env.local(), 1).ToLocalChecked()); + CHECK_EQ(std::string(*key), "route"); + CHECK_EQ(std::string(*val), "/api/test"); + found_labeled = true; + } + } + CHECK(found_labeled); + + heap_profiler->StopSamplingHeapProfiler(); +} + +TEST(SamplingHeapProfilerNoAlsKeySet) { + 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 ALS key set — internment gate is closed — label_id must be 0. + 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_EQ(sample.label_id, 0u); + } + + heap_profiler->StopSamplingHeapProfiler(); +} + +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; + + v8::Local als_key = + v8::String::NewFromUtf8Literal(isolate, "node-heap-profiler"); + heap_profiler->SetHeapProfileSampleLabelsKey(als_key); + heap_profiler->StartSamplingHeapProfiler(256); + + // Phase 1: allocate under label "/api/first". + v8::Local arr1 = MakeLabelArray(isolate, env.local(), "/api/first"); + { + v8::Local cped = v8::Map::New(isolate); + cped->Set(env.local(), als_key, arr1).ToLocalChecked(); + isolate->SetContinuationPreservedEmbedderDataV2(cped); + } + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate); + + // Phase 2: allocate under label "/api/second" (different array object). + v8::Local arr2 = + MakeLabelArray(isolate, env.local(), "/api/second"); + { + v8::Local cped = v8::Map::New(isolate); + cped->Set(env.local(), als_key, arr2).ToLocalChecked(); + isolate->SetContinuationPreservedEmbedderDataV2(cped); + } + 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.label_id != 0) { + v8::HandleScope hs(isolate); + v8::Local resolved; + if (!heap_profiler->ResolveLabelValue(sample.label_id) + .ToLocal(&resolved)) + continue; + if (!resolved->IsArray()) continue; + v8::Local arr = resolved.As(); + if (arr->Length() < 2) continue; + v8::String::Utf8Value val( + isolate, arr->Get(env.local(), 1).ToLocalChecked()); + if (std::string(*val) == "/api/first") found_first = true; + if (std::string(*val) == "/api/second") found_second = true; + } + } + CHECK(found_first); + CHECK(found_second); + + heap_profiler->StopSamplingHeapProfiler(); +} + +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; + + v8::Local label_arr = + MakeLabelArray(isolate, env.local(), "/api/gc-test"); + SetupAlsContext(isolate, env.local(), heap_profiler, label_arr); + + // 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); + + // Retained samples must still have their label_id resolvable. + bool found_labeled = false; + for (const auto& sample : profile->GetSamples()) { + if (sample.label_id != 0) { + v8::HandleScope hs(isolate); + v8::Local resolved; + CHECK(heap_profiler->ResolveLabelValue(sample.label_id) + .ToLocal(&resolved)); + CHECK(resolved->IsArray()); + found_labeled = true; + } + } + CHECK(found_labeled); + + heap_profiler->StopSamplingHeapProfiler(); +} + +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; + + v8::Local label_arr = + MakeLabelArray(isolate, env.local(), "/api/gc-remove"); + SetupAlsContext(isolate, env.local(), heap_profiler, label_arr); + + // 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);" + "}"); + + // Count labelled samples before GC — with suppress_randomness every + // sufficiently-large object is sampled, so there should be many. + std::unique_ptr pre_gc( + heap_profiler->GetAllocationProfile()); + CHECK(pre_gc); + size_t labeled_before = 0; + for (const auto& s : pre_gc->GetSamples()) { + if (s.label_id != 0) labeled_before++; + } + CHECK_GT(labeled_before, 0u); + + // 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, samples for collected objects are removed. + // The labelled count must be strictly less than before the GC. + size_t labeled_count = 0; + for (const auto& sample : profile->GetSamples()) { + if (sample.label_id != 0) { + labeled_count++; + } + } + CHECK_LT(labeled_count, labeled_before); + + heap_profiler->StopSamplingHeapProfiler(); +} + +TEST(SamplingHeapProfilerClearAlsKeyStopsLabels) { + 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; + + v8::Local label_arr = + MakeLabelArray(isolate, env.local(), "/api/before-clear"); + SetupAlsContext(isolate, env.local(), heap_profiler, label_arr); + + heap_profiler->StartSamplingHeapProfiler(256); + + // Allocate with ALS key set — label_id will be non-zero. + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate); + + // Clear ALS key — gate closes, new samples get label_id == 0. + heap_profiler->SetHeapProfileSampleLabelsKey(v8::Local()); + + // Allocate more — no internment since gate is closed. + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + CHECK(profile); + + // Must have both labeled and unlabeled samples. + bool found_labeled = false; + bool found_unlabeled = false; + for (const auto& sample : profile->GetSamples()) { + if (sample.label_id != 0) { + 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 a5227687d22d..d938fd03de1b 100644 --- a/tools/v8_gypfiles/features.gypi +++ b/tools/v8_gypfiles/features.gypi @@ -531,7 +531,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 96a55e114c4d156ad2eb021680fd545e9ced1ab5 Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Thu, 6 Aug 2026 15:20:55 +0200 Subject: [PATCH 2/6] src: track heap profile labels and external memory Wire the V8 label machinery into Node. The v8 binding stores an AsyncLocalStorage instance as the label key, arms it for the duration of a labels session, and resolves label ids back into frozen label objects when a profile is read. Add ProfilingArrayBufferAllocator, which wraps the ArrayBuffer allocator to attribute off-heap memory to the same labels and reports it as externalBytes. It is installed only while a labels session is running; otherwise the allocate and free paths cost one relaxed atomic load. Frees arrive on V8's ArrayBufferSweeper background thread, so the enabled flag is the sentinel and the isolate pointer is written once per session and never cleared. Sessions carry a generation counter so a handle cannot read or stop a session it does not own, which is reachable because the V8 sampler is a single shared resource that the inspector can stop out of band. Define V8_HEAP_PROFILER_SAMPLE_LABELS wherever CPED is enabled, including in common.gypi so that native addons compiling against the shipped headers see the same struct layout as libnode. Signed-off-by: Rudolf Meijering --- common.gypi | 8 + node.gyp | 1 + src/api/environment.cc | 188 +++++++++++++++++++- src/node_internals.h | 59 +++++++ src/node_profiling.cc | 2 +- src/node_v8.cc | 386 ++++++++++++++++++++++++++++++++++++++++- src/node_v8.h | 28 +++ 7 files changed, 666 insertions(+), 6 deletions(-) diff --git a/common.gypi b/common.gypi index 0b01ec8c49fe..159968657306 100644 --- a/common.gypi +++ b/common.gypi @@ -89,6 +89,7 @@ 'v8_enable_v8_checks%': 0, 'v8_use_perfetto%': 0, 'tsan%': 0, + 'v8_enable_continuation_preserved_embedder_data%': 1, ##### end V8 defaults ##### @@ -546,6 +547,13 @@ ['tsan == 1', { 'defines': ['V8_IS_TSAN',], }], + # Heap profile sample labels ride ContinuationPreservedEmbedderData, so + # they are gated on the same feature. Defined here in target_defaults + # so that every Node target and every node-gyp addon sees the same + # v8::AllocationProfile::Sample layout as libnode. + ['v8_enable_continuation_preserved_embedder_data == 1', { + 'defines': ['V8_HEAP_PROFILER_SAMPLE_LABELS',], + }], ['OS == "win"', { 'defines': [ 'WIN32', diff --git a/node.gyp b/node.gyp index 44193542fb38..6bc93a204e69 100644 --- a/node.gyp +++ b/node.gyp @@ -1386,6 +1386,7 @@ 'src', 'tools/msvs/genfiles', 'deps/v8/include', + 'deps/v8', 'deps/cares/include', 'deps/uv/include', 'test/cctest', diff --git a/src/api/environment.cc b/src/api/environment.cc index 3b94d860de1e..cfe9ebaeb960 100644 --- a/src/api/environment.cc +++ b/src/api/environment.cc @@ -16,7 +16,9 @@ #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 "v8-profiler.h" #include "node_wasm_web_api.h" #include "uv.h" #ifdef NODE_ENABLE_VTUNE_PROFILING @@ -118,6 +120,10 @@ void* NodeArrayBufferAllocator::Allocate(size_t size) { ret = allocator_->Allocate(size); if (ret != nullptr) [[likely]] { total_mem_usage_.fetch_add(size, std::memory_order_relaxed); + auto* pa = profiling_allocator_.load(std::memory_order_acquire); + if (pa != nullptr) [[unlikely]] { + pa->TrackAllocate(ret, size); + } } return ret; } @@ -127,15 +133,38 @@ void* NodeArrayBufferAllocator::AllocateUninitialized(size_t size) { void* ret = allocator_->AllocateUninitialized(size); if (ret != nullptr) [[likely]] { total_mem_usage_.fetch_add(size, std::memory_order_relaxed); + auto* pa = profiling_allocator_.load(std::memory_order_acquire); + if (pa != nullptr) [[unlikely]] { + pa->TrackAllocate(ret, size); + } } return ret; } void NodeArrayBufferAllocator::Free(void* data, size_t size) { + auto* pa = profiling_allocator_.load(std::memory_order_acquire); + if (pa != nullptr) [[unlikely]] { + pa->TrackFree(data); + } total_mem_usage_.fetch_sub(size, std::memory_order_relaxed); allocator_->Free(data, size); } +ProfilingArrayBufferAllocator* +NodeArrayBufferAllocator::CreateProfilingAllocator() { + if (!owned_profiling_allocator_) { + owned_profiling_allocator_ = + std::make_unique(); + } + auto* pa = owned_profiling_allocator_.get(); + profiling_allocator_.store(pa, std::memory_order_release); + return pa; +} + +void NodeArrayBufferAllocator::ClearProfilingAllocator() { + profiling_allocator_.store(nullptr, std::memory_order_release); +} + DebuggingArrayBufferAllocator::~DebuggingArrayBufferAllocator() { CHECK(allocations_.empty()); } @@ -192,11 +221,166 @@ void DebuggingArrayBufferAllocator::RegisterPointerInternal(void* data, allocations_[data] = size; } +void ProfilingArrayBufferAllocator::TrackAllocate(void* data, size_t size) { + // Runs inside the ArrayBuffer allocator, where V8 may prohibit heap + // allocation and JS execution. Everything below stays off the V8 heap: the + // CPED lookup and the intern table use the handle-scope stack and malloc, + // and what is stored per allocation is a POD id. + if (std::this_thread::get_id() != + main_thread_id_.load(std::memory_order_relaxed) || + !enabled_.load(std::memory_order_relaxed)) { + return; + } + v8::Isolate* isolate = isolate_.load(std::memory_order_relaxed); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + v8::HandleScope handle_scope(isolate); + v8::Local cped = + isolate->GetContinuationPreservedEmbedderDataV2().As(); + if (cped.IsEmpty() || !cped->IsMap()) return; + v8::HeapProfiler* profiler = isolate->GetHeapProfiler(); + v8::Local als_value; + if (!profiler->LookupAlsValue(cped).ToLocal(&als_value)) return; + uint32_t label_id = profiler->InternLabelValue(als_value); + // With no id there is nothing to attribute the bytes to at serialisation + // time, so the entry would be dead weight. + if (label_id == 0) return; + uint32_t old_label_id = 0; + { + Mutex::ScopedLock lock(mutex_); + auto [it, inserted] = allocations_.try_emplace(data); + if (!inserted) old_label_id = it->second.label_id; + it->second.label_id = label_id; + it->second.size = size; + } + if (old_label_id != 0) { + isolate->GetHeapProfiler()->ReleaseLabelValue(old_label_id); + } +#else + (void)isolate; + (void)data; + (void)size; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS +} + +void ProfilingArrayBufferAllocator::TrackFree(void* data) { + // Called on V8's ArrayBufferSweeper thread as well as the main one. That is + // safe because AllocationEntry holds no V8 handles, so the erase below is a + // POD operation. ReleaseLabelValue runs after the lock is dropped, to avoid + // nesting this mutex inside the intern table's. + uint32_t label_id = 0; + { + Mutex::ScopedLock lock(mutex_); + auto it = allocations_.find(data); + if (it == allocations_.end()) return; + label_id = it->second.label_id; + allocations_.erase(it); + } +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + if (label_id == 0) return; + // This acquire load pairs with the release store in Enable(); see the + // argument there for why isolate_ is non-null and valid here. + isolate_.load(std::memory_order_acquire) + ->GetHeapProfiler() + ->ReleaseLabelValue(label_id); +#else + (void)label_id; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS +} + +bool ProfilingArrayBufferAllocator::Enable(v8::Isolate* isolate) { + // TrackFree() reads isolate_ from V8's ArrayBufferSweeper thread, so the + // release store below is what makes it visible there: a non-zero label_id + // exists only because TrackAllocate inserted the entry after observing + // enabled_ on the main thread, which is sequenced after this store, and + // TrackFree's acquire load pairs with it. Disable() therefore must not + // clear isolate_. enabled_ is main-thread only, so relaxed suffices. + // + // In-tree the allocator is one-per-isolate (owned by NodeArrayBufferAllocator + // in IsolateData), so current is null or already this isolate. An embedder + // may however share one allocator across isolates via node::NewIsolate; in + // that unsupported case do not rebind (which would hijack the first + // isolate's tracking) and report failure so the caller does not record this + // allocator for the second isolate. The second isolate's samples still + // carry labels; only its per-label externalBytes are unavailable. + Mutex::ScopedLock lock(mutex_); + v8::Isolate* current = isolate_.load(std::memory_order_relaxed); + if (current != nullptr && current != isolate) return false; + main_thread_id_.store(std::this_thread::get_id(), std::memory_order_relaxed); + isolate_.store(isolate, std::memory_order_release); + enabled_.store(true, std::memory_order_relaxed); + return true; +} + +void ProfilingArrayBufferAllocator::Disable() { + // Clear the sentinel first, so a re-entrant main-thread call exits early. + // isolate_ deliberately survives: a background TrackFree() may already have + // erased its entry and still be on its way to ReleaseLabelValue(), which + // would then either crash or leak the refcount. The isolate outlives every + // TrackFree() because V8 drains its ArrayBufferSweeper before the isolate + // tears down, so ReleaseLabelValue() always has a valid isolate to call + // through. + enabled_.store(false, std::memory_order_relaxed); + v8::Isolate* isolate = isolate_.load(std::memory_order_relaxed); + // Release outside the lock, so the allocator mutex is not nested inside the + // intern table's. + std::vector label_ids; + { + Mutex::ScopedLock lock(mutex_); + label_ids.reserve(allocations_.size()); + for (const auto& [ptr, entry] : allocations_) { + if (entry.label_id != 0) { + label_ids.push_back(entry.label_id); + } + } + allocations_.clear(); + } +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + if (isolate != nullptr) { + v8::HeapProfiler* profiler = isolate->GetHeapProfiler(); + for (uint32_t id : label_ids) { + profiler->ReleaseLabelValue(id); + } + } +#else + (void)isolate; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS +} + +std::vector> +ProfilingArrayBufferAllocator::GetPerLabelBytes() const { + if (!enabled_.load(std::memory_order_relaxed)) return {}; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + std::unordered_map bytes_by_id; + { + Mutex::ScopedLock lock(mutex_); + bytes_by_id.reserve(allocations_.size()); + for (const auto& [ptr, entry] : allocations_) { + if (entry.label_id != 0) { + bytes_by_id[entry.label_id] += static_cast(entry.size); + } + } + } + std::vector> result; + result.reserve(bytes_by_id.size()); + for (const auto& [id, bytes] : bytes_by_id) { + result.emplace_back(id, bytes); + } + return result; +#else + return {}; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS +} + 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(); + // Use the plain NodeArrayBufferAllocator by default. When heap profiling + // with labels is started (v8.startHeapProfile({ labels: true })), a + // ProfilingArrayBufferAllocator tracker is created on demand and installed + // as a delegate — see NodeArrayBufferAllocator::CreateProfilingAllocator(). + // When profiling is not active, overhead is one atomic null-check in + // NodeArrayBufferAllocator's Allocate/Free (predicted not-taken). + return std::make_unique(); } ArrayBufferAllocator* CreateArrayBufferAllocator() { diff --git a/src/node_internals.h b/src/node_internals.h index 631a8d7ccdd9..929d28cef5b2 100644 --- a/src/node_internals.h +++ b/src/node_internals.h @@ -38,6 +38,7 @@ #include #include +#include #include #include @@ -123,6 +124,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 +139,14 @@ class NodeArrayBufferAllocator : public ArrayBufferAllocator { } NodeArrayBufferAllocator* GetImpl() final { return this; } + ProfilingArrayBufferAllocator* GetProfilingAllocator() { + return profiling_allocator_.load(std::memory_order_acquire); + } + // Creates the tracker on first use, then reuses it. + ProfilingArrayBufferAllocator* CreateProfilingAllocator(); + // Stops Allocate/Free delegating. The tracker itself stays alive, because a + // GC thread may still be inside it. + void ClearProfilingAllocator(); inline uint64_t total_mem_usage() const { return total_mem_usage_.load(std::memory_order_relaxed); } @@ -146,6 +157,12 @@ class NodeArrayBufferAllocator : public ArrayBufferAllocator { // Delegate to V8's allocator for compatibility with the V8 memory cage. std::unique_ptr allocator_{ v8::ArrayBuffer::Allocator::NewDefaultAllocator()}; + + std::unique_ptr owned_profiling_allocator_; + // Null unless profiling is active, so the hot path is one relaxed atomic load. + // Release/acquire ordering gives Free(), which runs on GC threads, a fully + // constructed object to see. + std::atomic profiling_allocator_{nullptr}; }; class DebuggingArrayBufferAllocator final : public NodeArrayBufferAllocator { @@ -164,6 +181,48 @@ class DebuggingArrayBufferAllocator final : public NodeArrayBufferAllocator { std::unordered_map allocations_; }; +// Tracks per-label external memory (Buffer/ArrayBuffer backing stores) while +// heap profiling with labels is active. Not an allocator itself: it is +// installed as a delegate on NodeArrayBufferAllocator, which skips it +// entirely while the pointer is null. +// +// enabled_ is the sentinel that TrackAllocate() and GetPerLabelBytes() check +// without taking mutex_, which guards allocations_ against the GC threads +// that call Free(). The ordering rules for enabled_ and isolate_ are argued +// in ProfilingArrayBufferAllocator::Enable in src/api/environment.cc. +class ProfilingArrayBufferAllocator { + public: + void TrackAllocate(void* data, size_t size); + void TrackFree(void* data); + + // Called from StartHeapProfile/StopHeapProfile. + // Returns true if this allocator now tracks for `isolate`. Returns false + // (a no-op) if it is already bound to a different isolate, which an embedder + // can trigger by sharing one allocator across isolates; the caller must not + // record this allocator for teardown in that case. + bool Enable(v8::Isolate* isolate); + void Disable(); + + // Live external bytes as { label_id, bytes }. The caller resolves each id + // through HeapProfiler::ResolveLabelValue. + std::vector> GetPerLabelBytes() const; + + private: + std::atomic enabled_{false}; + std::atomic isolate_{nullptr}; + + std::atomic main_thread_id_{}; + + mutable Mutex mutex_; + // Holding a POD id rather than a handle is what lets TrackFree() erase an + // entry from the sweeper thread. 0 means no label attribution. + struct AllocationEntry { + uint32_t label_id; + size_t size; + }; + std::unordered_map allocations_; +}; + 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_profiling.cc b/src/node_profiling.cc index b6c42b286908..7a8c90ce8f46 100644 --- a/src/node_profiling.cc +++ b/src/node_profiling.cc @@ -73,7 +73,7 @@ bool SerializeHeapProfile(Isolate* isolate, std::ostringstream& out_stream) { HeapProfileOptions ParseHeapProfileOptions( const v8::FunctionCallbackInfo& args) { HeapProfileOptions options; - CHECK_LE(args.Length(), 3); + CHECK_LE(args.Length(), 4); if (args.Length() > 0) { CHECK(args[0]->IsNumber()); options.sample_interval = diff --git a/src/node_v8.cc b/src/node_v8.cc index 34e8460790b7..cd30f3b441df 100644 --- a/src/node_v8.cc +++ b/src/node_v8.cc @@ -20,20 +20,25 @@ // USE OR OTHER DEALINGS IN THE SOFTWARE. #include "node_v8.h" +#include #include "aliased_buffer-inl.h" #include "base_object-inl.h" #include "env-inl.h" #include "memory_tracker-inl.h" #include "node.h" +#include "node_internals.h" #include "node_external_reference.h" #include "node_profiling.h" #include "permission/permission.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; @@ -46,6 +51,7 @@ using v8::FunctionCallbackInfo; using v8::FunctionTemplate; using v8::HandleScope; using v8::HeapCodeStatistics; +using v8::HeapProfiler; using v8::HeapSpaceStatistics; using v8::HeapStatistics; using v8::Integer; @@ -105,6 +111,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) @@ -146,6 +155,78 @@ BindingData::BindingData(Realm* realm, heap_code_statistics_buffer.MakeWeak(); } +// Tears down profiler state if the Environment goes away while profiling is +// still active, as on worker termination. The raw pointers are safe because +// cleanup hooks run inside Isolate::Scope, before the isolate is disposed. +// Deliberately not a BindingData*: Realm::RunCleanup() destroys BindingData +// before the env cleanup queue is drained, so that would dangle. +struct HeapProfilingCleanup { + Isolate* isolate; + NodeArrayBufferAllocator* node_allocator; + ProfilingArrayBufferAllocator* profiling_allocator; + bool is_labels_session = false; + bool cleaned_up = false; + + // Idempotent: only the first call has an effect. + void DoCleanup() { + if (cleaned_up) return; + cleaned_up = true; + + HeapProfiler* profiler = isolate->GetHeapProfiler(); + profiler->StopSamplingHeapProfiler(); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + profiler->SetHeapProfileSampleLabelsKey(Local()); +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + if (node_allocator != nullptr) { + node_allocator->ClearProfilingAllocator(); + } + if (profiling_allocator != nullptr) { + profiling_allocator->Disable(); + } + isolate = nullptr; + node_allocator = nullptr; + profiling_allocator = nullptr; + } +}; + +static void CleanupHeapProfiling(void* data) { + auto* ctx = static_cast(data); + ctx->DoCleanup(); + delete ctx; +} + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +// Stores the ALS key for later use by StartHeapProfile. Does not arm the V8 +// labelling key here — that happens only when a labels:true session starts — +// except when the ALS is first created while a labels session is already +// live (mid-session first use), in which case arm it immediately so that +// allocations after this call are labelled. +void SetHeapProfileLabelsStore(const FunctionCallbackInfo& args) { + CHECK_EQ(args.Length(), 1); + // The AsyncLocalStorage instance; only a JSReceiver can be a CPED Map key. + CHECK(args[0]->IsObject()); + Isolate* isolate = args.GetIsolate(); + BindingData* binding_data = Realm::GetBindingData(args); + binding_data->heap_profile_labels_als_key.Reset(isolate, args[0]); + auto* cleanup = binding_data->heap_profiling_cleanup_; + if (cleanup != nullptr && cleanup->is_labels_session) { + isolate->GetHeapProfiler()->SetHeapProfileSampleLabelsKey(args[0]); + } +} +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + +BindingData::~BindingData() { + // This runs during Realm::RunCleanup(), before the env cleanup queue is + // drained and while the isolate is still alive, so cleaning up here stops + // V8 holding a pointer into a BindingData that is about to go away. + 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_); @@ -188,6 +269,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) { @@ -295,19 +379,102 @@ void StartHeapProfile(const FunctionCallbackInfo& args) { Isolate* isolate = args.GetIsolate(); auto options = ParseHeapProfileOptions(args); - if (isolate->GetHeapProfiler()->StartSamplingHeapProfiler( + if (!isolate->GetHeapProfiler()->StartSamplingHeapProfiler( options.sample_interval, options.stack_depth, options.flags)) { + THROW_ERR_HEAP_PROFILE_HAVE_BEEN_STARTED(isolate, + "Heap profile has been started"); return; } - THROW_ERR_HEAP_PROFILE_HAVE_BEEN_STARTED(isolate, - "Heap profile has been started"); + + BindingData* binding_data = Realm::GetBindingData(args); + // Stamp a new generation so handles from prior sessions cannot interact + // with this one even if their V8 session was stolen. + const uint32_t gen = ++binding_data->heap_profile_session_generation_; + args.GetReturnValue().Set(gen); + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + { + Environment* env = Environment::GetCurrent(args); + // Discard stale Node-side tracking without stopping V8's new session. + // In normal usage heap_profiling_cleanup_ is null here because V8 + // returned true (no prior session running). This handles the edge case + // where V8 ended our prior session independently (e.g. an out-of-band + // inspector HeapProfiler.stopSampling call), which would otherwise leak + // an env hook whose DoCleanup would later stop an unrelated session. + // Applies unconditionally so both the labels:true and labels:false paths + // start with a clean slate. + if (binding_data->heap_profiling_cleanup_ != nullptr) { + auto* old = binding_data->heap_profiling_cleanup_; + if (old->node_allocator != nullptr) + old->node_allocator->ClearProfilingAllocator(); + if (old->profiling_allocator != nullptr) + old->profiling_allocator->Disable(); + env->RemoveCleanupHook(CleanupHeapProfiling, old); + delete old; + binding_data->heap_profiling_cleanup_ = nullptr; + } + // 4th arg (index 3): labels_enabled — set up allocator and labels key. + if (args.Length() > 3 && args[3]->IsTrue()) { + if (!binding_data->heap_profile_labels_als_key.IsEmpty()) { + isolate->GetHeapProfiler()->SetHeapProfileSampleLabelsKey( + binding_data->heap_profile_labels_als_key.Get(isolate)); + } + auto* node_allocator = env->isolate_data()->node_allocator(); + ProfilingArrayBufferAllocator* profiling_allocator = nullptr; + if (node_allocator != nullptr) { + auto* candidate = node_allocator->CreateProfilingAllocator(); + // Only own the allocator (and record it for teardown) if Enable took + // this isolate. If a shared allocator is already bound to another + // isolate, Enable() is a no-op returning false; recording it here + // would let this session's cleanup disable the other isolate's + // tracking. + if (candidate->Enable(isolate)) profiling_allocator = candidate; + } + auto* cleanup = new HeapProfilingCleanup{ + isolate, + profiling_allocator != nullptr ? node_allocator : nullptr, + profiling_allocator}; + cleanup->is_labels_session = true; + env->AddCleanupHook(CleanupHeapProfiling, cleanup); + binding_data->heap_profiling_cleanup_ = cleanup; + } else { + // Defensively clear any key left over from a previous labels session so + // that a labels:false session never emits labelled samples. + isolate->GetHeapProfiler()->SetHeapProfileSampleLabelsKey(Local()); + } + } +#endif } void StopHeapProfile(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); Isolate* isolate = env->isolate(); + // If a session generation was provided and it no longer matches the current + // one, a newer session is live. Leave it untouched and behave as if this + // handle was already stopped. + if (args.Length() > 0 && args[0]->IsUint32()) { + BindingData* bd = Realm::GetBindingData(args); + if (args[0].As()->Value() != bd->heap_profile_session_generation_) + return; + } std::ostringstream out_stream; bool success = node::SerializeHeapProfile(isolate, out_stream); + // Run Node-side teardown unconditionally whenever a session was tracked. + // SerializeHeapProfile stops V8's profiler on success; DoCleanup's own + // StopSamplingHeapProfiler is a safe no-op if that already happened. + // This covers the out-of-band case where an inspector + // HeapProfiler.stopSampling call stopped V8's sampler before we did, + // which causes serialisation to fail — without this, the profiling + // allocator would stay installed on the ArrayBuffer alloc/free path + // for the rest of the process with no way to remove it. + 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; + } if (success) { Local result; if (ToV8Value(env->context(), out_stream.str(), isolate).ToLocal(&result)) { @@ -318,6 +485,20 @@ void StopHeapProfile(const FunctionCallbackInfo& args) { } } +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +// Test-only accessor: returns true if the NodeArrayBufferAllocator currently +// has a profiling delegate installed. Exposed via internalBinding('v8') so +// tests can verify that StopHeapProfile released the allocator. +static void GetProfilingAllocatorActive( + const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + auto* node_alloc = env->isolate_data()->node_allocator(); + bool active = node_alloc != nullptr && + node_alloc->GetProfilingAllocator() != nullptr; + args.GetReturnValue().Set(active); +} +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + static void IsStringOneByteRepresentation( const FunctionCallbackInfo& args) { CHECK_EQ(args.Length(), 1); @@ -713,6 +894,191 @@ void GCProfiler::Stop(const FunctionCallbackInfo& args) { } } +void GetAllocationProfile(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + // If a session generation was provided and it no longer matches the current + // one, a newer session is live. Return undefined without touching it. + if (args.Length() > 0 && args[0]->IsUint32()) { + BindingData* bd = Realm::GetBindingData(args); + if (args[0].As()->Value() != bd->heap_profile_session_generation_) + return; + } + 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()); + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // Each id is resolved once, so all samples carrying it share one frozen + // object. The serial is its content key, used to merge externalBytes below. + std::unordered_map> id_to_labels; + std::unordered_map id_to_serial; + HeapProfiler* hp = isolate->GetHeapProfiler(); + // Returns empty on a V8 error, letting the exception reach the JS caller. A + // failed resolution is never cached. + auto resolve_label = [&](uint32_t id) -> MaybeLocal { + auto it = id_to_labels.find(id); + if (it != id_to_labels.end()) return it->second; + Local obj = Object::New(isolate); + std::string serial; + Local als_value; + if (id != 0 && hp->ResolveLabelValue(id).ToLocal(&als_value) && + als_value->IsArray()) { + Local flat = als_value.As(); + uint32_t len = flat->Length(); + for (uint32_t j = 0; j + 1 < len; j += 2) { + Local k, v; + if (!flat->Get(context, j).ToLocal(&k)) return {}; + if (!flat->Get(context, j + 1).ToLocal(&v)) return {}; + // labelsToFlat() builds this from ObjectKeys(), so a non-string key + // cannot occur; skipping rather than failing keeps that assumption + // from becoming a crash. + // Values are strings too (labelsToFlat validates them); guard both so + // the labels object and the merge serial stay in agreement, and so a + // non-string value can never reach Utf8Value -> ToString(), which + // could throw and leave a pending exception for later V8 calls. + if (!k->IsString() || !v->IsString()) continue; + // CreateDataProperty rather than Set, so that a poisoned + // Object.prototype setter cannot run here. + if (obj->CreateDataProperty(context, k.As(), v).IsNothing()) + return {}; + node::Utf8Value ks(isolate, k), vs(isolate, v); + if (*ks && *vs) { + if (!serial.empty()) serial += '\0'; + serial += std::to_string(ks.length()); + serial += ':'; + serial.append(*ks, ks.length()); + serial += '\0'; + serial += std::to_string(vs.length()); + serial += ':'; + serial.append(*vs, vs.length()); + } + } + } + if (obj->SetIntegrityLevel(context, v8::IntegrityLevel::kFrozen) + .IsNothing()) + return {}; + id_to_labels.emplace(id, obj); + id_to_serial.emplace(id, std::move(serial)); + return id_to_labels[id]; + }; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + + for (size_t i = 0; i < samples.size(); i++) { + const AllocationProfile::Sample& sample = samples[i]; + Local js_sample = Object::New(isolate); + + // CreateDataProperty defines own data properties directly, so a poisoned + // Object.prototype setter for any of these key names cannot run here. + if (js_sample->CreateDataProperty( + context, + FIXED_ONE_BYTE_STRING(isolate, "nodeId"), + Integer::NewFromUnsigned(isolate, sample.node_id)) + .IsNothing()) return; + if (js_sample->CreateDataProperty( + context, + FIXED_ONE_BYTE_STRING(isolate, "size"), + Number::New(isolate, static_cast(sample.size))) + .IsNothing()) return; + if (js_sample->CreateDataProperty( + context, + FIXED_ONE_BYTE_STRING(isolate, "count"), + Integer::NewFromUnsigned(isolate, sample.count)) + .IsNothing()) return; + if (js_sample->CreateDataProperty( + context, + FIXED_ONE_BYTE_STRING(isolate, "sampleId"), + Number::New(isolate, + static_cast(sample.sample_id))) + .IsNothing()) return; + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // Always emitted, as a frozen empty object when no label was captured. + Local js_labels; + if (!resolve_label(sample.label_id).ToLocal(&js_labels)) return; + if (js_sample->CreateDataProperty( + context, + FIXED_ONE_BYTE_STRING(isolate, "labels"), + js_labels).IsNothing()) return; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + + if (js_samples->CreateDataProperty( + context, static_cast(i), js_sample).IsNothing()) return; + } + + Local result = Object::New(isolate); + if (result->CreateDataProperty(context, + FIXED_ONE_BYTE_STRING(isolate, "samples"), + js_samples).IsNothing()) return; + + // Per-label external memory, as { labels, bytes } entries using the same + // labels shape as the samples above. + Environment* env = Environment::GetCurrent(args); + auto* node_allocator = env->isolate_data()->node_allocator(); + auto* profiling_allocator = node_allocator != nullptr + ? node_allocator->GetProfilingAllocator() : nullptr; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + if (profiling_allocator != nullptr) { + auto per_label = profiling_allocator->GetPerLabelBytes(); + if (!per_label.empty()) { + // Distinct ids can carry identical label content, so merge on content. + std::unordered_map, int64_t>> by_content; + for (const auto& [label_id, bytes] : per_label) { + Local labels_obj; + if (!resolve_label(label_id).ToLocal(&labels_obj)) return; + const std::string& serial = id_to_serial[label_id]; + // An empty serial means a stale id or an ALS value with no usable + // pairs. Such entries were dropped before labels existed, and + // emitting them now would change the shape of the output. + if (serial.empty()) continue; + auto& slot = by_content[serial]; + if (slot.first.IsEmpty()) slot.first = labels_obj; + slot.second += bytes; + } + std::vector, int64_t>> entries; + entries.reserve(by_content.size()); + for (auto& [serial, cv] : by_content) { + if (cv.second > 0) entries.emplace_back(cv.first, cv.second); + } + if (!entries.empty()) { + Local js_external = Array::New(isolate, entries.size()); + for (size_t idx = 0; idx < entries.size(); idx++) { + Local js_entry = Object::New(isolate); + if (js_entry->CreateDataProperty( + context, + FIXED_ONE_BYTE_STRING(isolate, "labels"), + entries[idx].first).IsNothing()) return; + if (js_entry->CreateDataProperty( + context, + FIXED_ONE_BYTE_STRING(isolate, "bytes"), + Number::New(isolate, + static_cast(entries[idx].second))) + .IsNothing()) return; + if (js_external->CreateDataProperty( + context, static_cast(idx), js_entry) + .IsNothing()) return; + } + if (result->CreateDataProperty(context, + FIXED_ONE_BYTE_STRING(isolate, "externalBytes"), + js_external).IsNothing()) return; + } + } + } +#else + (void)profiling_allocator; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + + args.GetReturnValue().Set(result); +} + void Initialize(Local target, Local unused, Local context, @@ -801,6 +1167,15 @@ void Initialize(Local target, NODE_DEFINE_CONSTANT(target, kSamplingIncludeObjectsCollectedByMinorGC); } + SetMethod(context, target, "getAllocationProfile", + GetAllocationProfile); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + SetMethod(context, target, "setHeapProfileLabelsStore", + SetHeapProfileLabelsStore); + SetMethodNoSideEffect(context, target, "getProfilingAllocatorActive", + GetProfilingAllocatorActive); +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + // Export symbols used by v8.isStringOneByteRepresentation() SetFastMethodNoSideEffect(context, target, @@ -849,6 +1224,11 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(StopCpuProfile); registry->Register(StartHeapProfile); registry->Register(StopHeapProfile); + registry->Register(GetAllocationProfile); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + registry->Register(SetHeapProfileLabelsStore); + registry->Register(GetProfilingAllocatorActive); +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS } } // namespace v8_utils diff --git a/src/node_v8.h b/src/node_v8.h index 581972b13d4e..10781033bdbf 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,29 @@ class BindingData : public SnapshotableObject { AliasedFloat64Array heap_space_statistics_buffer; AliasedFloat64Array heap_code_statistics_buffer; + // The AsyncLocalStorage instance behind withHeapProfileLabels, used as the + // key under which V8 finds each sample's labels in the CPED. + v8::Global heap_profile_labels_als_key; + + // Ownership sentinel: non-null only when this binding started a labels:true + // V8 sampling session. labels:false sessions leave this null (matching + // upstream). StartHeapProfile throws ERR_HEAP_PROFILE_HAVE_BEEN_STARTED + // when V8 returns false, so this pointer is set only after a successful + // start. StopHeapProfile calls SerializeHeapProfile unconditionally (which + // stops V8's sampler); this pointer guards only the Node-side DoCleanup + // teardown (profiling allocator, cleanup hook). Three main-thread paths + // can tear this down: StopHeapProfile, ~BindingData at realm teardown, and + // the CleanupHeapProfiling env hook if neither of the first two got there + // first. Whichever runs first removes the hook, deletes the struct and + // nulls this; DoCleanup() is idempotent. + HeapProfilingCleanup* heap_profiling_cleanup_ = nullptr; + + // Monotonically increasing counter bumped on every successful + // StartSamplingHeapProfiler call. Each SyncHeapProfileHandle captures the + // value at construction; a mismatch means a different session is now live + // and the handle must not touch it. + uint32_t heap_profile_session_generation_ = 0; + void MemoryInfo(MemoryTracker* tracker) const override; SET_SELF_SIZE(BindingData) SET_MEMORY_INFO_NAME(BindingData) From 7e9977809d86af839e8836cd8e168b83ff31282f Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Thu, 6 Aug 2026 15:21:04 +0200 Subject: [PATCH 3/6] lib: add heap profile labels to the v8 module Add a labels option to v8.startHeapProfile, and getAllocationProfile() on the returned handle so a labelled profile can be read while profiling continues. The profile reports samples with their labels and per-label external memory; stop() keeps returning the DevTools JSON string unchanged. Labels are set with v8.withHeapProfileLabels(labels, fn) for a scope, or v8.setHeapProfileLabels(labels) for the current context, and propagate through async work because they live in an AsyncLocalStorage. Values are validated as strings when set, so resolving a label later cannot run user code. worker.startHeapProfile rejects the labels option rather than accepting and ignoring it; labels work inside a worker that profiles itself. Signed-off-by: Rudolf Meijering --- lib/internal/v8/heap_profile.js | 4 +- lib/internal/worker.js | 7 +- lib/v8.js | 133 ++++++++++++++++++++++++++++++-- 3 files changed, 137 insertions(+), 7 deletions(-) diff --git a/lib/internal/v8/heap_profile.js b/lib/internal/v8/heap_profile.js index 45a181d49b56..e61e8ea1e428 100644 --- a/lib/internal/v8/heap_profile.js +++ b/lib/internal/v8/heap_profile.js @@ -23,6 +23,7 @@ function normalizeHeapProfileOptions(options = kEmptyObject) { forceGC = false, includeObjectsCollectedByMajorGC = false, includeObjectsCollectedByMinorGC = false, + labels = false, } = options; validateInteger(sampleInterval, 'options.sampleInterval', 1); @@ -32,6 +33,7 @@ function normalizeHeapProfileOptions(options = kEmptyObject) { 'options.includeObjectsCollectedByMajorGC'); validateBoolean(includeObjectsCollectedByMinorGC, 'options.includeObjectsCollectedByMinorGC'); + validateBoolean(labels, 'options.labels'); let flags = kSamplingNoFlags; if (forceGC) flags |= kSamplingForceGC; @@ -42,7 +44,7 @@ function normalizeHeapProfileOptions(options = kEmptyObject) { flags |= kSamplingIncludeObjectsCollectedByMinorGC; } - return { sampleInterval, stackDepth, flags }; + return { sampleInterval, stackDepth, flags, labels }; } module.exports = { diff --git a/lib/internal/worker.js b/lib/internal/worker.js index 3afd2180d705..d5d3dcf7d8fa 100644 --- a/lib/internal/worker.js +++ b/lib/internal/worker.js @@ -628,8 +628,13 @@ class Worker extends EventEmitter { startHeapProfile(options) { normalizeHeapProfileOptions ??= require('internal/v8/heap_profile').normalizeHeapProfileOptions; - const { sampleInterval, stackDepth, flags } = + const { sampleInterval, stackDepth, flags, labels } = normalizeHeapProfileOptions(options); + if (labels) { + throw new ERR_INVALID_ARG_VALUE( + 'options.labels', labels, + 'is not supported when profiling a worker from the parent thread'); + } const startTaker = this[kHandle]?.startHeapProfile( sampleInterval, stackDepth, flags); return new Promise((resolve, reject) => { diff --git a/lib/v8.js b/lib/v8.js index bb174f8d5243..565bf3920fc7 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, validateOneOf, validateUint32, @@ -164,6 +168,9 @@ const { heapSpaceStatisticsBuffer, getCppHeapStatistics: _getCppHeapStatistics, detailLevel, + + getAllocationProfile: _getAllocationProfile, + setHeapProfileLabelsStore: _setHeapProfileLabelsStore, } = binding; const kNumberOfHeapSpaces = kHeapSpaces.length; @@ -201,13 +208,26 @@ class SyncCPUProfileHandle { class SyncHeapProfileHandle { #stopped = false; + #sessionGeneration; + + constructor(generation) { + this.#sessionGeneration = generation; + } + + getAllocationProfile() { + if (this.#stopped) return undefined; + return _getAllocationProfile(this.#sessionGeneration); + } stop() { if (this.#stopped) { return; } - this.#stopped = true; - return _stopHeapProfile(); + try { + return _stopHeapProfile(this.#sessionGeneration); + } finally { + this.#stopped = true; + } }; [SymbolDispose]() { @@ -239,13 +259,17 @@ function startCpuProfile(options) { * @param {boolean} [options.forceGC] * @param {boolean} [options.includeObjectsCollectedByMajorGC] * @param {boolean} [options.includeObjectsCollectedByMinorGC] + * @param {boolean} [options.labels] * @returns {SyncHeapProfileHandle} */ function startHeapProfile(options) { - const { sampleInterval, stackDepth, flags } = + const { sampleInterval, stackDepth, flags, labels } = normalizeHeapProfileOptions(options); - _startHeapProfile(sampleInterval, stackDepth, flags); - return new SyncHeapProfileHandle(); + if (labels) { + ensureHeapProfileLabelsALS(); + } + const generation = _startHeapProfile(sampleInterval, stackDepth, flags, labels); + return new SyncHeapProfileHandle(generation); } /** @@ -542,6 +566,103 @@ class GCProfiler { } } +// --- Heap profile labels API --- +// Internal AsyncLocalStorage for propagating labels through async context. +// Requires async-context-frame (on by default in Node.js 27). +// Lazily initialized on first use so processes that never use heap profiling +// do not create an ALS instance or require async_hooks. +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; + // Without async-context-frame, CPED is not a JSMap, so every sample + // gets an empty labels object with no error. Warn once at first use. + if (!getOptionValue('--async-context-frame')) { + process.emitWarning( + 'Heap profile labels require async-context-frame, which is ' + + 'disabled. Remove --no-async-context-frame; all labels will ' + + 'be empty.', + { code: 'NODE_HEAP_PROFILE_LABELS_NO_ASYNC_CONTEXT' }, + ); + } + 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, ...]. + * Flattening happens once here at label-set time rather than per allocation. + * The flat array is stored in AsyncLocalStorage; V8 interns it at allocation + * time, and getAllocationProfile() resolves the interned value to a labels + * object at read 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; +} + +/** + * 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. V8 interns + // this value per allocation; pre-flattening avoids V8 Object property + // access on the allocation path. + 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, @@ -567,4 +688,6 @@ module.exports = { isStringOneByteRepresentation, startCpuProfile, startHeapProfile, + withHeapProfileLabels, + setHeapProfileLabels, }; From 29a127328922f4270580271070cc7576f770e155 Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Thu, 6 Aug 2026 15:21:14 +0200 Subject: [PATCH 4/6] test: add heap profile label tests Cover the label machinery in C++ and JavaScript. The cctests exercise the intern table directly, including refcounting, cross-thread release, the deferred free queue, and the destruction order that lets the table outlive the sampler. The JS tests cover label propagation across await boundaries and into workers, attribution accuracy against a known allocation ratio, external memory accounting, sessions that the inspector stops out of band, a poisoned Object.prototype, and the option gating that decides whether samples carry labels at all. Signed-off-by: Rudolf Meijering --- test/cctest/test_heap_profile_labels.cc | 504 +++++++++++++++ test/cctest/test_label_intern_table.cc | 430 +++++++++++++ .../parallel/test-v8-heap-profile-external.js | 380 ++++++++++++ .../test-v8-heap-profile-labels-async.js | 163 +++++ ...test-v8-heap-profile-labels-attribution.js | 132 ++++ .../test-v8-heap-profile-labels-hostile.js | 83 +++ .../test-v8-heap-profile-labels-http.js | 121 ++++ ...8-heap-profile-labels-include-collected.js | 123 ++++ ...-v8-heap-profile-labels-inspector-steal.js | 143 +++++ .../test-v8-heap-profile-labels-no-acf.js | 34 + .../test-v8-heap-profile-labels-sticky-key.js | 66 ++ .../test-v8-heap-profile-labels-worker.js | 95 +++ test/parallel/test-v8-heap-profile-labels.js | 581 ++++++++++++++++++ test/parallel/test-worker-heap-profile.js | 6 + 14 files changed, 2861 insertions(+) create mode 100644 test/cctest/test_heap_profile_labels.cc create mode 100644 test/cctest/test_label_intern_table.cc 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-attribution.js create mode 100644 test/parallel/test-v8-heap-profile-labels-hostile.js create mode 100644 test/parallel/test-v8-heap-profile-labels-http.js create mode 100644 test/parallel/test-v8-heap-profile-labels-include-collected.js create mode 100644 test/parallel/test-v8-heap-profile-labels-inspector-steal.js create mode 100644 test/parallel/test-v8-heap-profile-labels-no-acf.js create mode 100644 test/parallel/test-v8-heap-profile-labels-sticky-key.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 000000000000..f2c3b218d9cb --- /dev/null +++ b/test/cctest/test_heap_profile_labels.cc @@ -0,0 +1,504 @@ +// Tests for Sample::label_id and ResolveLabelValue API. +// Validates that label ids are captured at allocation time and resolvable +// via the public HeapProfiler::ResolveLabelValue API. + +#include +#include + +#include "gtest/gtest.h" +#include "node_test_fixture.h" +#include "v8-profiler.h" +#include "v8.h" + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + +// Sets up the ALS key on the heap profiler and stores als_value in a CPED Map. +static void SetupAlsContext(v8::Isolate* isolate, v8::Local ctx, + v8::HeapProfiler* hp, + v8::Local als_value) { + v8::Local als_key = + v8::String::NewFromUtf8Literal(isolate, "node-heap-profiler"); + hp->SetHeapProfileSampleLabelsKey(als_key); + v8::Local cped_map = v8::Map::New(isolate); + cped_map->Set(ctx, als_key, als_value).ToLocalChecked(); + isolate->SetContinuationPreservedEmbedderDataV2(cped_map); +} + +// Returns a flat V8 Array ["route", route_val] as the ALS label value. +static v8::Local MakeLabelArray(v8::Isolate* isolate, + v8::Local ctx, + const char* route_val) { + v8::Local arr = v8::Array::New(isolate, 2); + arr->Set(ctx, 0, v8::String::NewFromUtf8Literal(isolate, "route")).Check(); + arr->Set(ctx, 1, v8::String::NewFromUtf8(isolate, route_val).ToLocalChecked()) + .Check(); + return arr; +} + +class HeapProfileLabelsTest : public NodeTestFixture {}; + +// Test: register callback + set ALS key, allocate, verify label_id 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(); + + v8::Local label_arr = + MakeLabelArray(isolate_, context, "/api/test"); + SetupAlsContext(isolate_, context, heap_profiler, label_arr); + + 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.label_id != 0) { + v8::HandleScope hs(isolate_); + v8::Local resolved; + ASSERT_TRUE(heap_profiler->ResolveLabelValue(sample.label_id) + .ToLocal(&resolved)); + ASSERT_TRUE(resolved->IsArray()); + v8::Local arr = resolved.As(); + ASSERT_GE(arr->Length(), 2u); + v8::String::Utf8Value key( + isolate_, arr->Get(context, 0).ToLocalChecked()); + v8::String::Utf8Value val( + isolate_, arr->Get(context, 1).ToLocalChecked()); + EXPECT_EQ(std::string(*key), "route"); + EXPECT_EQ(std::string(*val), "/api/test"); + found_labeled = true; + } + } + EXPECT_TRUE(found_labeled); + + heap_profiler->StopSamplingHeapProfiler(); +} + +// Test: no ALS key set — internment gate closed — label_id must be 0. +TEST_F(HeapProfileLabelsTest, NoAlsKeySetEmptyLabels) { + 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_EQ(sample.label_id, 0u); + } + + heap_profiler->StopSamplingHeapProfiler(); +} + +// Test: multiple distinct label sets resolved from different ALS values. +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(); + + v8::Local als_key = + v8::String::NewFromUtf8Literal(isolate_, "node-heap-profiler"); + heap_profiler->SetHeapProfileSampleLabelsKey(als_key); + + heap_profiler->StartSamplingHeapProfiler(256); + + // Phase 1: allocate under "/api/first". + v8::Local arr1 = MakeLabelArray(isolate_, context, "/api/first"); + { + v8::Local cped = v8::Map::New(isolate_); + cped->Set(context, als_key, arr1).ToLocalChecked(); + isolate_->SetContinuationPreservedEmbedderDataV2(cped); + } + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate_); + + // Phase 2: allocate under "/api/second" (different array object). + v8::Local arr2 = MakeLabelArray(isolate_, context, "/api/second"); + { + v8::Local cped = v8::Map::New(isolate_); + cped->Set(context, als_key, arr2).ToLocalChecked(); + isolate_->SetContinuationPreservedEmbedderDataV2(cped); + } + 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.label_id == 0) continue; + v8::HandleScope hs(isolate_); + v8::Local resolved; + if (!heap_profiler->ResolveLabelValue(sample.label_id).ToLocal(&resolved)) + continue; + if (!resolved->IsArray()) continue; + v8::Local arr = resolved.As(); + if (arr->Length() < 2) continue; + v8::String::Utf8Value val(isolate_, + arr->Get(context, 1).ToLocalChecked()); + if (std::string(*val) == "/api/first") found_first = true; + if (std::string(*val) == "/api/second") found_second = true; + } + EXPECT_TRUE(found_first); + EXPECT_TRUE(found_second); + + heap_profiler->StopSamplingHeapProfiler(); +} + +// Test: label_id survives GC when +// kSamplingIncludeObjectsCollectedByMajorGC is set. +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(); + + v8::Local label_arr = + MakeLabelArray(isolate_, context, "/api/gc-test"); + SetupAlsContext(isolate_, context, heap_profiler, label_arr); + + // 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); + + // Retained samples must still have a resolvable label_id. + bool found_labeled = false; + for (const auto& sample : profile->GetSamples()) { + if (sample.label_id != 0) { + v8::HandleScope hs(isolate_); + v8::Local resolved; + EXPECT_TRUE(heap_profiler->ResolveLabelValue(sample.label_id) + .ToLocal(&resolved)); + EXPECT_TRUE(resolved->IsArray()); + found_labeled = true; + } + } + EXPECT_TRUE(found_labeled); + + heap_profiler->StopSamplingHeapProfiler(); +} + +// Test: samples removed by GC (no retain flags) — labelled count must drop. +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(); + + v8::Local label_arr = + MakeLabelArray(isolate_, context, "/api/gc-remove"); + SetupAlsContext(isolate_, context, heap_profiler, label_arr); + + // 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(); + + // Count labelled samples before GC — most of the 4096 short-lived arrays + // should be represented since sampling is probabilistic over 256-byte steps. + std::unique_ptr pre_gc( + heap_profiler->GetAllocationProfile()); + ASSERT_NE(pre_gc, nullptr); + size_t labeled_before = 0; + for (const auto& s : pre_gc->GetSamples()) { + if (s.label_id != 0) labeled_before++; + } + ASSERT_GT(labeled_before, 0u) << "need labelled samples before GC"; + + // 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); + EXPECT_NE(profile->GetRootNode(), nullptr); + + // Without GC retain flags, samples for collected objects are removed. + // The labelled count must be strictly less than before the GC. + size_t labeled_count = 0; + for (const auto& sample : profile->GetSamples()) { + if (sample.label_id != 0) labeled_count++; + } + EXPECT_LT(labeled_count, labeled_before); + + heap_profiler->StopSamplingHeapProfiler(); +} + +// Test: after StopSamplingHeapProfiler, ResolveLabelValue returns empty +// and ReleaseLabelValue does not crash for ids from the stopped session. +// This verifies the post-stop contract: Clear() empties the table so old +// ids are stale but safe. +TEST_F(HeapProfileLabelsTest, ReleaseAfterStopIsNoOp) { + 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(); + + v8::Local label_arr = + MakeLabelArray(isolate_, context, "/api/stop-test"); + SetupAlsContext(isolate_, context, heap_profiler, label_arr); + + 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); + + uint32_t saved_id = 0; + for (const auto& sample : profile->GetSamples()) { + if (sample.label_id != 0) { + saved_id = sample.label_id; + break; + } + } + ASSERT_NE(saved_id, 0u) << "need at least one labeled sample"; + + heap_profiler->StopSamplingHeapProfiler(); + + // After stop: the table is cleared, so Resolve returns empty. + EXPECT_TRUE(heap_profiler->ResolveLabelValue(saved_id).IsEmpty()); + + // After stop: Release must not crash (silent no-op). + heap_profiler->ReleaseLabelValue(saved_id); +} + +// ReleaseLabelValue must remain safe across session stop/start transitions +// and when called from a background thread. +TEST_F(HeapProfileLabelsTest, ReleaseLabelValueCrossThread) { + 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(); + + v8::Local label_arr = + MakeLabelArray(isolate_, context, "/api/thread-test"); + SetupAlsContext(isolate_, context, heap_profiler, label_arr); + + // First session: capture a stale id. + heap_profiler->StartSamplingHeapProfiler(256); + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate_); + std::unique_ptr prof1( + heap_profiler->GetAllocationProfile()); + ASSERT_NE(prof1, nullptr); + uint32_t stale_id = 0; + for (const auto& s : prof1->GetSamples()) { + if (s.label_id != 0) { + stale_id = s.label_id; + break; + } + } + heap_profiler->StopSamplingHeapProfiler(); + // stale_id is now cleared; ReleaseLabelValue(stale_id) must be a no-op. + ASSERT_NE(stale_id, 0u) << "need at least one labeled sample"; + + // Worker calls ReleaseLabelValue with the stale id while the main thread + // starts and stops a new session. Both calls must not crash. + std::atomic done{false}; + std::thread worker([heap_profiler, stale_id, &done]() { + while (!done.load(std::memory_order_relaxed)) { + heap_profiler->ReleaseLabelValue(stale_id); + heap_profiler->ReleaseLabelValue(0); // kNoLabelId, always a no-op + } + }); + + heap_profiler->StartSamplingHeapProfiler(256); + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate_); + heap_profiler->StopSamplingHeapProfiler(); + + done.store(true, std::memory_order_relaxed); + worker.join(); +} + +// Test: clearing the ALS key closes the internment gate — new samples +// get label_id == 0. +TEST_F(HeapProfileLabelsTest, ClearAlsKeyStopsLabels) { + 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(); + + v8::Local label_arr = + MakeLabelArray(isolate_, context, "/api/before-clear"); + SetupAlsContext(isolate_, context, heap_profiler, label_arr); + + heap_profiler->StartSamplingHeapProfiler(256); + + // Allocate with ALS key set — label_id will be non-zero. + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate_); + + // Clear ALS key — gate closes, new samples get label_id == 0. + heap_profiler->SetHeapProfileSampleLabelsKey(v8::Local()); + + // Allocate more — no internment since gate is closed. + 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_labeled = false; + bool found_unlabeled = false; + for (const auto& sample : profile->GetSamples()) { + if (sample.label_id != 0) { + found_labeled = true; + } else { + found_unlabeled = true; + } + } + EXPECT_TRUE(found_labeled); + EXPECT_TRUE(found_unlabeled); + + heap_profiler->StopSamplingHeapProfiler(); +} + +// Stopping the profiler must release labels held by collected samples before +// clearing the intern table. +TEST_F(HeapProfileLabelsTest, RetainedSampleLabelReleasedOnStop) { + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + + v8::HeapProfiler* hp = isolate_->GetHeapProfiler(); + + v8::Local label_arr = + MakeLabelArray(isolate_, context, "/api/retained-stop"); + SetupAlsContext(isolate_, context, hp, label_arr); + + // Use GC retain flag: collected samples keep their label_ids alive until + // profiler teardown, exercising the destructor release loop. + hp->StartSamplingHeapProfiler( + 256, 128, + static_cast( + v8::HeapProfiler::kSamplingIncludeObjectsCollectedByMajorGC | + v8::HeapProfiler::kSamplingIncludeObjectsCollectedByMinorGC)); + + // Allocate short-lived objects; force GC so they become retained samples. + 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(); + isolate_->RequestGarbageCollectionForTesting( + v8::Isolate::kFullGarbageCollection); + + // Verify at least one retained labelled sample exists before stop. + { + std::unique_ptr profile(hp->GetAllocationProfile()); + ASSERT_NE(profile, nullptr); + bool has_label = false; + for (const auto& s : profile->GetSamples()) { + if (s.label_id != 0) { + has_label = true; + break; + } + } + EXPECT_TRUE(has_label) << "need at least one retained labelled sample"; + } + + // This runs the retained-sample label release path. + hp->StopSamplingHeapProfiler(); + + // After stop, stale ids must not resolve. + hp->ReleaseLabelValue(1u); // stale, must be a silent no-op + EXPECT_TRUE(hp->ResolveLabelValue(1u).IsEmpty()); +} + +// TrackFree must balance its label reference before allocator disable. TSAN +// covers the concurrent TrackFree/Disable interleaving. +TEST_F(HeapProfileLabelsTest, TrackFreeReleasesLabelBeforeDisable) { + const v8::HandleScope handle_scope(isolate_); + v8::Local ctx = v8::Context::New(isolate_); + v8::Context::Scope ctx_scope(ctx); + v8::HeapProfiler* hp = isolate_->GetHeapProfiler(); + + v8::Local label_arr = + MakeLabelArray(isolate_, ctx, "/trackfree-race"); + SetupAlsContext(isolate_, ctx, hp, label_arr); + hp->StartSamplingHeapProfiler(256); + + node::ProfilingArrayBufferAllocator profiling; + profiling.Enable(isolate_); + + // Use a sentinel pointer that is unique and non-null. + void* fake_ptr = reinterpret_cast(static_cast(0x8000)); + profiling.TrackAllocate(fake_ptr, 512); + + // TrackAllocate must have interned the ALS label and added the entry. + auto entries_before = profiling.GetPerLabelBytes(); + ASSERT_EQ(entries_before.size(), 1u) + << "TrackAllocate must produce one labeled entry"; + uint32_t label_id = entries_before[0].first; + + // The id is resolvable before TrackFree. + EXPECT_FALSE(hp->ResolveLabelValue(label_id).IsEmpty()); + + // TrackFree erases the entry and releases its label reference. + profiling.TrackFree(fake_ptr); + + // allocations_ must be empty now. + EXPECT_TRUE(profiling.GetPerLabelBytes().empty()); + + // ResolveLabelValue triggers a drain of the pending_free_ queue; the id + // must be gone (refcount reached 0 via TrackFree's ReleaseLabelValue). + EXPECT_TRUE(hp->ResolveLabelValue(label_id).IsEmpty()); + + // Disable finds an empty map; it releases nothing. No double-release. + profiling.Disable(); + + hp->StopSamplingHeapProfiler(); +} + +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS diff --git a/test/cctest/test_label_intern_table.cc b/test/cctest/test_label_intern_table.cc new file mode 100644 index 000000000000..5a634190f23c --- /dev/null +++ b/test/cctest/test_label_intern_table.cc @@ -0,0 +1,430 @@ +// Tests for v8::internal::LabelInternTable. +// Exercises the refcounted, identity-hash-keyed intern table that the +// sampling heap profiler uses to dedup per-sample label values. + +#include +#include +#include // NOLINT(build/c++11) +#include + +#include "gtest/gtest.h" +#include "node_test_fixture.h" +#include "src/profiler/label-intern-table.h" +#include "v8.h" + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + +class LabelInternTableTest : public NodeTestFixture {}; + +TEST_F(LabelInternTableTest, InternSameValueTwiceSameId) { + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + v8::internal::LabelInternTable table(isolate_); + + v8::Local obj = v8::Object::New(isolate_); + uint32_t id1 = table.Intern(obj); + uint32_t id2 = table.Intern(obj); + EXPECT_NE(id1, v8::internal::LabelInternTable::kNoLabelId); + EXPECT_EQ(id1, id2); + EXPECT_EQ(1u, table.SizeForTesting()); + + // Release once: still resolvable. + table.Release(id1); + EXPECT_EQ(1u, table.SizeForTesting()); + EXPECT_FALSE(table.Lookup(id1).IsEmpty()); + + // Release twice: gone. + table.Release(id2); + EXPECT_EQ(0u, table.SizeForTesting()); + EXPECT_TRUE(table.Lookup(id1).IsEmpty()); +} + +TEST_F(LabelInternTableTest, DistinctValuesGetDistinctIds) { + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + v8::internal::LabelInternTable table(isolate_); + + v8::Local a = v8::Object::New(isolate_); + v8::Local b = v8::Object::New(isolate_); + uint32_t id_a = table.Intern(a); + uint32_t id_b = table.Intern(b); + EXPECT_NE(id_a, id_b); + EXPECT_EQ(2u, table.SizeForTesting()); + + v8::Local looked_up_a = table.Lookup(id_a).ToLocalChecked(); + v8::Local looked_up_b = table.Lookup(id_b).ToLocalChecked(); + EXPECT_TRUE(looked_up_a->StrictEquals(a)); + EXPECT_TRUE(looked_up_b->StrictEquals(b)); + + table.Release(id_a); + table.Release(id_b); + EXPECT_EQ(0u, table.SizeForTesting()); +} + +TEST_F(LabelInternTableTest, ManyDistinctValuesAllRetrievable) { + // Identity hash is generated randomly so we cannot easily force a real + // bucket collision. Instead exercise the chain-walk path indirectly by + // interning many values and confirming Lookup correctness for each. + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + v8::internal::LabelInternTable table(isolate_); + + constexpr int kN = 64; + v8::LocalVector objs(isolate_); + std::vector ids; + for (int i = 0; i < kN; ++i) { + v8::Local obj = v8::Object::New(isolate_); + objs.push_back(obj); + ids.push_back(table.Intern(obj)); + } + EXPECT_EQ(static_cast(kN), table.SizeForTesting()); + + for (int i = 0; i < kN; ++i) { + for (int j = i + 1; j < kN; ++j) { + EXPECT_NE(ids[i], ids[j]); + } + } + for (int i = 0; i < kN; ++i) { + v8::Local got = table.Lookup(ids[i]).ToLocalChecked(); + EXPECT_TRUE(got->StrictEquals(objs[i])); + } + + // Release in reverse order; table empty at end. + for (int i = kN - 1; i >= 0; --i) { + table.Release(ids[i]); + } + EXPECT_EQ(0u, table.SizeForTesting()); + for (int i = 0; i < kN; ++i) { + EXPECT_TRUE(table.Lookup(ids[i]).IsEmpty()); + } +} + +TEST_F(LabelInternTableTest, NoLabelIdAndNonReceiver) { + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + v8::internal::LabelInternTable table(isolate_); + + // kNoLabelId is reserved. + EXPECT_TRUE(table.Lookup(v8::internal::LabelInternTable::kNoLabelId) + .IsEmpty()); + table.Release(v8::internal::LabelInternTable::kNoLabelId); // no-op + + // Smi (non-receiver) cannot be interned; returns kNoLabelId. + v8::Local smi = v8::Integer::New(isolate_, 42); + EXPECT_EQ(v8::internal::LabelInternTable::kNoLabelId, + table.Intern(smi)); + EXPECT_EQ(0u, table.SizeForTesting()); +} + +TEST_F(LabelInternTableTest, RefcountReleaseOrderIndependent) { + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + v8::internal::LabelInternTable table(isolate_); + + v8::Local obj = v8::Object::New(isolate_); + uint32_t id_a = table.Intern(obj); + uint32_t id_b = table.Intern(obj); + uint32_t id_c = table.Intern(obj); + EXPECT_EQ(id_a, id_b); + EXPECT_EQ(id_a, id_c); + EXPECT_EQ(1u, table.SizeForTesting()); + + table.Release(id_a); + EXPECT_FALSE(table.Lookup(id_a).IsEmpty()); + table.Release(id_b); + EXPECT_FALSE(table.Lookup(id_a).IsEmpty()); + table.Release(id_c); + EXPECT_TRUE(table.Lookup(id_a).IsEmpty()); + EXPECT_EQ(0u, table.SizeForTesting()); +} + +// Concurrent Release() from multiple threads must not corrupt the +// table. Production: ProfilingArrayBufferAllocator::TrackFree() runs on +// V8's ArrayBufferSweeper background worker thread and calls +// Release() while the main thread may also call Intern()/Lookup(). +// +// This test pre-bumps the refcount on a single id from the main thread, +// spawns four worker threads that each issue 10000 Release() calls, +// then asserts the refcount drained to exactly one (held by the main +// thread) and the table is internally consistent. The final Release() +// happens on the main thread to avoid racing the underlying +// Global::Reset() against the isolate. +TEST_F(LabelInternTableTest, ConcurrentReleaseFromManyThreads) { + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + v8::internal::LabelInternTable table(isolate_); + + constexpr int kThreads = 4; + constexpr int kIters = 10000; + constexpr int kReleasesByWorkers = kThreads * kIters; + + v8::Local obj = v8::Object::New(isolate_); + uint32_t id = table.Intern(obj); + ASSERT_NE(id, v8::internal::LabelInternTable::kNoLabelId); + + // Bump refcount to (kReleasesByWorkers + 1). Workers will drain + // kReleasesByWorkers; main thread does the final Release. + for (int i = 0; i < kReleasesByWorkers; ++i) { + uint32_t bumped = table.Intern(obj); + ASSERT_EQ(id, bumped); + } + EXPECT_EQ(1u, table.SizeForTesting()); + + std::vector workers; + workers.reserve(kThreads); + for (int t = 0; t < kThreads; ++t) { + workers.emplace_back([&table, id]() { + for (int i = 0; i < kIters; ++i) table.Release(id); + }); + } + for (auto& w : workers) w.join(); + + // Workers drained kReleasesByWorkers refs; one ref remains. + EXPECT_EQ(1u, table.SizeForTesting()); + EXPECT_FALSE(table.Lookup(id).IsEmpty()); + + table.Release(id); + EXPECT_EQ(0u, table.SizeForTesting()); + EXPECT_TRUE(table.Lookup(id).IsEmpty()); +} + +// Revival race against the drain queue. Off-thread Release(N) queues +// the id; main-thread Intern(V) must find the still-bucketed entry and +// revive it (refcount==0 -> 1) BEFORE the drain pass runs in the same +// call. The drain skip-when-non-zero guard then leaves the revived +// entry alone, preserving id N and the underlying Global. +// +// Failure modes this catches: +// * Drain ordered before chain walk -> entry freed -> fresh id +// allocated, original id N becomes unresolvable, Global Reset. +// * Drain skip guard missing -> revived entry freed in same call, +// Lookup(N) returns empty. +// * Off-thread Release calling Global::Reset() -> use-after-free or +// GlobalHandles CHECK on isolate's main thread. +TEST_F(LabelInternTableTest, RevivalRaceQueueSafety) { + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + v8::internal::LabelInternTable table(isolate_); + + v8::Local v = v8::Object::New(isolate_); + uint32_t n = table.Intern(v); + ASSERT_NE(n, v8::internal::LabelInternTable::kNoLabelId); + EXPECT_EQ(1u, table.SizeForTesting()); + + // Worker drops the only refcount off-thread. The id is queued for + // free; the entry stays in the bucket at refcount==0 until the next + // main-thread Intern/Lookup drains it. + std::thread worker([&table, n]() { table.Release(n); }); + worker.join(); + + // Bucket invariants pre-revival: SizeForTesting excludes refcount==0 + // entries (so it reports 0), but Lookup(N) routed through Intern's + // chain walk should still find the entry because Release only queued + // it. Note: a direct Lookup() here would drain and free first; that + // is tested separately. We jump straight to revival via Intern. + EXPECT_EQ(0u, table.SizeForTesting()); + + uint32_t revived = table.Intern(v); + EXPECT_EQ(n, revived) << "revival must return the original id"; + EXPECT_EQ(1u, table.SizeForTesting()); + + v8::Local looked_up = table.Lookup(n).ToLocalChecked(); + EXPECT_TRUE(looked_up->StrictEquals(v)) + << "Global must not have been Reset across the race"; + + // Now drop the revived refcount and force a flush. After this the + // entry should be properly freed (not just queued forever). + table.Release(revived); + EXPECT_EQ(0u, table.SizeForTesting()); + // A second Intern of an unrelated value drains the queue, freeing + // the entry for v. + v8::Local other = v8::Object::New(isolate_); + uint32_t other_id = table.Intern(other); + EXPECT_EQ(1u, table.SizeForTesting()); + EXPECT_TRUE(table.Lookup(n).IsEmpty()) + << "drained entry must not be resolvable"; + table.Release(other_id); + EXPECT_EQ(0u, table.SizeForTesting()); +} + +// Tests that the revival path in Intern() does not hold a reference into +// buckets_ across DrainPendingFreeLocked(). Three entries A (chain[0]), +// B (chain[1]), C (chain[2]) all share one bucket via SetHashMaskForTesting. +// Off-thread Release(id_a) queues A for drain. Main-thread Intern(B) finds +// Reviving B drains A and shifts the bucket chain. Intern must preserve B's +// id across that mutation rather than reading an invalidated entry. +TEST_F(LabelInternTableTest, DrainDuringRevivalDoesNotInvalidateEntry) { + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + v8::internal::LabelInternTable table(isolate_); + + // Force all values into a single bucket: A at chain[0], B at chain[1], + // C at chain[2]. C acts as the witness: after erasing A, C shifts to + // chain[1] (where `entry` points), making the wrong return visible. + table.SetHashMaskForTesting(0); + + v8::Local a = v8::Object::New(isolate_); + v8::Local b = v8::Object::New(isolate_); + v8::Local c = v8::Object::New(isolate_); + + uint32_t id_a = table.Intern(a); + uint32_t id_b = table.Intern(b); + uint32_t id_c = table.Intern(c); + ASSERT_NE(id_a, v8::internal::LabelInternTable::kNoLabelId); + ASSERT_NE(id_b, v8::internal::LabelInternTable::kNoLabelId); + ASSERT_NE(id_c, v8::internal::LabelInternTable::kNoLabelId); + ASSERT_NE(id_a, id_b); + ASSERT_NE(id_b, id_c); + EXPECT_EQ(3u, table.SizeForTesting()); + + // Off-thread: Release A -> refcount 1->0, id_a queued on pending_free_. + // A stays in the bucket at chain[0] (Release does not erase; no drain + // has run). B and C retain refcount 1. + std::thread worker([&table, id_a]() { table.Release(id_a); }); + worker.join(); + + EXPECT_EQ(2u, table.SizeForTesting()); // A dead; B and C alive + + // Reviving B drains A and shifts the bucket chain. Intern must return B's + // saved id rather than reading through an invalidated entry reference. + uint32_t revived = table.Intern(b); + EXPECT_EQ(id_b, revived) + << "revival must return id_b after the bucket chain shifts"; + EXPECT_EQ(2u, table.SizeForTesting()); // B (refcount 2) and C (1) alive + + v8::Local looked_up = table.Lookup(id_b).ToLocalChecked(); + EXPECT_TRUE(looked_up->StrictEquals(b)) + << "Global for B must survive the drain"; + + // Clean up. B was interned once and revived once (refcount 2), so it + // needs two releases. Lookup drains the queue for both. + table.Release(id_b); // refcount 2 -> 1 + table.Release(id_b); // refcount 1 -> 0 + table.Release(id_c); // refcount 1 -> 0 + EXPECT_EQ(0u, table.SizeForTesting()); + EXPECT_TRUE(table.Lookup(id_b).IsEmpty()); + EXPECT_TRUE(table.Lookup(id_c).IsEmpty()); +} + +// Within one table, Clear() must not make previously issued ids reusable. +TEST_F(LabelInternTableTest, IdsAreNotReusedAfterClear) { + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + v8::internal::LabelInternTable table(isolate_); + + // Intern several values before the clear. + constexpr int kPreClear = 4; + std::vector pre_ids; + for (int i = 0; i < kPreClear; ++i) { + v8::Local obj = v8::Object::New(isolate_); + uint32_t id = table.Intern(obj); + ASSERT_NE(id, v8::internal::LabelInternTable::kNoLabelId); + pre_ids.push_back(id); + table.Release(id); + } + EXPECT_EQ(0u, table.SizeForTesting()); + + // Simulate stopping the sampling session. + table.Clear(); + + // Intern new values. Their ids must not collide with any pre-Clear id. + constexpr int kPostClear = 4; + for (int i = 0; i < kPostClear; ++i) { + v8::Local obj = v8::Object::New(isolate_); + uint32_t post_id = table.Intern(obj); + ASSERT_NE(post_id, v8::internal::LabelInternTable::kNoLabelId); + for (uint32_t pre_id : pre_ids) { + EXPECT_NE(pre_id, post_id) + << "id " << pre_id << " was reused after Clear()"; + } + table.Release(post_id); + } + EXPECT_EQ(0u, table.SizeForTesting()); +} + +// When the id counter wraps, Intern() must skip kNoLabelId and any id still +// mapped to a live entry instead of aliasing it. Seed next_id_ just below the +// wrap so the next mints roll over 2^32 - 1 -> 0 (skipped) -> 1, 2, ... +TEST_F(LabelInternTableTest, IdWraparoundDoesNotAliasLiveId) { + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + v8::internal::LabelInternTable table(isolate_); + + // Hold a live entry whose id is 1 (the first id issued after the wrap), so + // the post-wrap mint would collide with it if the guard were absent. + v8::Local live = v8::Object::New(isolate_); + uint32_t live_id = table.Intern(live); + ASSERT_EQ(live_id, 1u); // first id ever issued by this table + + // Drive the counter to the top of the range. + table.SetNextIdForTesting(std::numeric_limits::max() - 1); + + // max-1 -> max: a normal fresh id. + v8::Local a = v8::Object::New(isolate_); + uint32_t id_a = table.Intern(a); + EXPECT_EQ(id_a, std::numeric_limits::max()); + + // max -> 0 (kNoLabelId, skipped) -> 1 (live_id, in use, skipped) -> 2. + v8::Local b = v8::Object::New(isolate_); + uint32_t id_b = table.Intern(b); + EXPECT_NE(id_b, v8::internal::LabelInternTable::kNoLabelId); + EXPECT_NE(id_b, live_id) << "wrapped id aliased a still-live id"; + EXPECT_EQ(id_b, 2u); + + // The live entry is intact and still resolves to its original value. + v8::Local looked_up = table.Lookup(live_id).ToLocalChecked(); + EXPECT_TRUE(looked_up->StrictEquals(live)); + + table.Release(id_a); + table.Release(id_b); + table.Release(live_id); + EXPECT_EQ(0u, table.SizeForTesting()); +} + +// If a full probe sweep after a wrap finds no free id, Intern() must fail +// closed (return kNoLabelId) and leave every existing live entry intact, +// rather than aliasing one. +TEST_F(LabelInternTableTest, IdWraparoundFailsClosedWhenNoFreeId) { + const v8::HandleScope handle_scope(isolate_); + v8::Local context = v8::Context::New(isolate_); + v8::Context::Scope context_scope(context); + v8::internal::LabelInternTable table(isolate_); + + // Occupy ids 1..probe+1 with live entries (the table's own Global keeps each + // object alive), so a full sweep of candidates after a rewind hits no gap. + const uint32_t probe = + v8::internal::LabelInternTable::ProbeLimitForTesting(); + std::vector ids; + ids.reserve(probe + 1); + for (uint32_t i = 0; i < probe + 1; ++i) { + v8::Local obj = v8::Object::New(isolate_); + uint32_t id = table.Intern(obj); + ASSERT_NE(id, v8::internal::LabelInternTable::kNoLabelId); + ids.push_back(id); + } + EXPECT_EQ(static_cast(probe + 1), table.SizeForTesting()); + + // Rewind so the next mint sweeps candidates 1..probe+1, all occupied. + table.SetNextIdForTesting(0); + v8::Local extra = v8::Object::New(isolate_); + uint32_t failed = table.Intern(extra); + EXPECT_EQ(failed, v8::internal::LabelInternTable::kNoLabelId) + << "Intern must fail closed when no free id is available"; + + // The pre-existing live entries are untouched. + EXPECT_EQ(static_cast(probe + 1), table.SizeForTesting()); + for (uint32_t id : ids) table.Release(id); + EXPECT_EQ(0u, table.SizeForTesting()); +} + +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS 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 000000000000..7ea0cf010823 --- /dev/null +++ b/test/parallel/test-v8-heap-profile-external.js @@ -0,0 +1,380 @@ +// 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. +{ + const handle = v8.startHeapProfile({ sampleInterval: 512 * 1024, labels: true }); + + // 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 = handle.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); + handle.stop(); +} + +// Test 2: Buffer.alloc() outside any label context is not tracked. +{ + const handle = v8.startHeapProfile({ sampleInterval: 512 * 1024, labels: true }); + + // Allocate outside any label context. + const buf = Buffer.alloc(5 * 1024 * 1024); + assert.strictEqual(buf.length, 5 * 1024 * 1024); + + const profile = handle.getAllocationProfile(); + assert.ok(profile, 'profile should exist'); + + // The profiling allocator skips unlabelled allocations, so externalBytes + // must be absent (undefined). If it is present for some reason, assert it + // carries zero attributed bytes. + 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}`); + } else { + // Absence is the expected outcome: unlabelled allocations are not + // tracked, so the field is omitted from the profile. + assert.strictEqual(profile.externalBytes, undefined); + } + + handle.stop(); +} + +// Test 3: After dropping Buffer references and forcing GC, per-label bytes +// decrease (Free is called). +{ + const handle = v8.startHeapProfile({ sampleInterval: 512 * 1024, labels: true }); + + 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 = handle.getAllocationProfile(); + assert.ok(Array.isArray(profile.externalBytes), + 'externalBytes should be an array (ProfilingArrayBufferAllocator active)'); + { + // Entry must exist: 8MB was just allocated inside this context. + const entry = findByLabel(profile, 'route', '/gc-test'); + assert.ok(entry, 'Expected entry for route=/gc-test before GC'); + 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 = handle.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}`); + } + + handle.stop(); +} + +// Test 4: Multiple labels — allocate Buffers with different labels, verify +// externalBytes shows correct per-label totals. +{ + const handle = v8.startHeapProfile({ sampleInterval: 512 * 1024, labels: true }); + + 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 = handle.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); + handle.stop(); +} + +// Test 5: JSON serialization of the profile includes externalBytes. +{ + const handle = v8.startHeapProfile({ sampleInterval: 512 * 1024, labels: true }); + + const buf = v8.withHeapProfileLabels({ route: '/json-test' }, () => { + return Buffer.alloc(2 * 1024 * 1024); + }); + + const profile = handle.getAllocationProfile(); + const json = JSON.stringify(profile); + const parsed = JSON.parse(json); + + assert.ok(Array.isArray(parsed.samples), 'samples should be an array'); + // externalBytes must be present: 2MB was allocated inside the label context. + assert.ok(Array.isArray(parsed.externalBytes), + 'externalBytes should survive JSON round-trip'); + { + 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); + handle.stop(); +} + +// Test 6: Multi-label context — both key-value pairs appear in externalBytes. +{ + const handle = v8.startHeapProfile({ sampleInterval: 512 * 1024, labels: true }); + + const buf = v8.withHeapProfileLabels( + { route: '/foo', handler: 'bar' }, () => { + return Buffer.alloc(3 * 1024 * 1024); + }); + + const profile = handle.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); + handle.stop(); +} + +// Test 7: externalBytes labels match heap sample labels for same context. +{ + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + + 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 = handle.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)'); + { + // extEntry must exist: 5MB Buffer was allocated inside the context. + const extEntry = findByLabel(profile, 'route', '/match-test'); + assert.ok(extEntry, 'Expected /match-test entry in externalBytes'); + // With 64-byte interval and 1000 × ~800-byte heap allocations, + // labeled heap samples are reliably expected. + assert.ok(labeledSamples.length > 0, + 'Expected heap samples for /match-test (64-byte interval, ~800KB allocated)'); + 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); + handle.stop(); +} + +// Test 8: every externalBytes entry has at least one own label key. +{ + const handle = v8.startHeapProfile({ sampleInterval: 512 * 1024, labels: true }); + + const buf = v8.withHeapProfileLabels({ route: '/invariant-test' }, () => { + return Buffer.alloc(2 * 1024 * 1024); + }); + + const profile = handle.getAllocationProfile(); + + // externalBytes must be present: 2MB was allocated inside the label context. + assert.ok(Array.isArray(profile.externalBytes), + 'externalBytes should be an array (labelled Buffer allocated)'); + for (const entry of profile.externalBytes) { + assert.ok( + Object.keys(entry.labels).length > 0, + 'Every externalBytes entry must have at least one own key on its ' + + `labels object; got ${JSON.stringify(entry.labels)}` + ); + } + + // Keep buf alive. + assert.ok(buf.length > 0); + handle.stop(); +} + +// Test 9: externalBytes is absent (undefined, not an empty array) when no +// labelled backing stores are live. The profiling allocator skips untagged +// allocations, so a Buffer allocated outside any label context produces no +// tracked entry and the field must be omitted from the profile entirely. +{ + const handle = v8.startHeapProfile({ sampleInterval: 512 * 1024, labels: true }); + + const buf = Buffer.alloc(2 * 1024 * 1024); + assert.ok(buf.length > 0); + + const profile = handle.getAllocationProfile(); + handle.stop(); + + assert.strictEqual(profile.externalBytes, undefined, + 'externalBytes must be absent (not an empty array) when no labelled ' + + 'backing stores are live'); +} + +// Test 10: content-based merging — two distinct label contexts with identical +// label content merge into a single externalBytes entry whose bytes are the sum. +// The two contexts have different label_ids (separate flat arrays, separate +// intern-table entries) but the same serialised content, so GetAllocationProfile +// deduplicates them by content before building the output array. +{ + const handle = v8.startHeapProfile({ sampleInterval: 512 * 1024, labels: true }); + + const buf1 = v8.withHeapProfileLabels({ route: '/merge-test' }, () => { + return Buffer.alloc(3 * 1024 * 1024); + }); + const buf2 = v8.withHeapProfileLabels({ route: '/merge-test' }, () => { + return Buffer.alloc(4 * 1024 * 1024); + }); + + const profile = handle.getAllocationProfile(); + + // Keep both Buffers alive through the profile read. + assert.ok(buf1.length > 0); + assert.ok(buf2.length > 0); + + assert.ok(Array.isArray(profile.externalBytes), + 'externalBytes must be present (7MB of labelled Buffers allocated)'); + const merged = profile.externalBytes.filter( + (e) => e.labels.route === '/merge-test' + ); + assert.strictEqual(merged.length, 1, + 'Two distinct label contexts with identical content must merge into one ' + + 'externalBytes entry'); + assert.ok(merged[0].bytes >= 6 * 1024 * 1024, + `Merged entry must sum both allocations (>= 6 MB), got ${merged[0].bytes}`); + + handle.stop(); +} + +// Test 11: label serialisation keeps NUL-containing values distinct. +{ + const handle = v8.startHeapProfile({ sampleInterval: 512 * 1024, labels: true }); + + const buf1 = v8.withHeapProfileLabels({ route: 'a' }, () => { + return Buffer.alloc(3 * 1024 * 1024); + }); + const buf2 = v8.withHeapProfileLabels({ route: 'a\0x' }, () => { + return Buffer.alloc(3 * 1024 * 1024); + }); + + const profile = handle.getAllocationProfile(); + + assert.ok(buf1.length > 0); + assert.ok(buf2.length > 0); + + assert.ok(Array.isArray(profile.externalBytes), + 'externalBytes must be present (two labelled Buffers allocated)'); + + const routeA = profile.externalBytes.filter((e) => e.labels.route === 'a'); + const routeNul = profile.externalBytes.filter( + (e) => e.labels.route === 'a\0x'); + + assert.strictEqual(routeA.length, 1, + 'Expected exactly one entry for route="a"'); + assert.strictEqual(routeNul.length, 1, + 'Expected exactly one entry for route="a\\0x"; ' + + 'NUL bytes in labels must not collide with shorter values'); + + handle.stop(); +} + +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 000000000000..8e38d1b0d869 --- /dev/null +++ b/test/parallel/test-v8-heap-profile-labels-async.js @@ -0,0 +1,163 @@ +// Heap profile labels require async-context-frame (on by default). +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const v8 = require('v8'); + +// Test: labels survive await boundaries +async function testAwaitBoundary() { + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + + 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 = handle.getAllocationProfile(); + handle.stop(); + + 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() { + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + + 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 = handle.getAllocationProfile(); + handle.stop(); + + // Attribution-correctness: every sample that carries a route label must + // carry one of the expected routes. A label-bleed bug would produce samples + // with an unexpected route value. + const expectedRoutes = new Set(['/users', '/products', '/orders']); + const allLabeledSamples = profile.samples.filter( + (s) => s.labels.route !== undefined + ); + for (const sample of allLabeledSamples) { + assert.ok(expectedRoutes.has(sample.labels.route), + `Sample carries unexpected route: ${sample.labels.route}`); + } + // Weaker existence check: 5000 × 3 tasks at 64-byte interval must yield + // at least some labeled samples. + assert.ok( + allLabeledSamples.length > 0, + 'Concurrent contexts should produce labeled samples' + ); +} + +// Test: setHeapProfileLabels with async work +async function testSetLabelsAsync() { + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + + // 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 = handle.getAllocationProfile(); + handle.stop(); + + 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() { + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + + 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 = handle.getAllocationProfile(); + handle.stop(); + assert.ok(profile); +} + +// Test: nested withHeapProfileLabels +async function testNestedLabels() { + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + + await v8.withHeapProfileLabels({ route: '/outer' }, async () => { + // No pre-inner allocations: outer-labeled samples will only come from + // the post-inner block, so their presence directly verifies reversion. + await v8.withHeapProfileLabels({ route: '/inner' }, async () => { + const inner = []; + for (let i = 0; i < 2000; i++) inner.push({ inner: i }); + }); + + // After inner exits the label must revert to '/outer'. These allocations + // verify that nesting contract. + const outer = []; + for (let i = 0; i < 2000; i++) outer.push({ outer: i }); + }); + + const profile = handle.getAllocationProfile(); + handle.stop(); + + const outerSamples = profile.samples.filter( + (s) => s.labels.route === '/outer' + ); + const innerSamples = profile.samples.filter( + (s) => s.labels.route === '/inner' + ); + + // Inner samples must exist in their own right (not merged with outer). + assert.ok(innerSamples.length > 0, + 'Inner context must produce its own labeled samples'); + // Outer samples come from the post-inner block, proving label reversion. + assert.ok(outerSamples.length > 0, + 'Label must revert to outer after inner exits'); +} + +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-attribution.js b/test/parallel/test-v8-heap-profile-labels-attribution.js new file mode 100644 index 000000000000..59ea8d078a2a --- /dev/null +++ b/test/parallel/test-v8-heap-profile-labels-attribution.js @@ -0,0 +1,132 @@ +// Heap profile labels require async-context-frame (on by default). +// +// End-to-end attribution accuracy: the other label tests check that samples +// carry the labels they should, which catches label bleed but would still +// pass if the bytes attributed to each label were meaningless. These tests +// allocate a known ratio of memory under interleaved async tasks and check +// that the profile reproduces that ratio. +// +// Tolerances are wide on purpose. Sampling is statistical, so the point is to +// catch attribution that is broken (roughly equal shares, or everything +// landing on one label), not to pin down the sampler's precision. +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const v8 = require('v8'); + +// A heavy and a light task, interleaved through await boundaries so that the +// async context is torn down and restored repeatedly while both are in +// flight. Ground truth is 10:1 by allocation count. +async function testHeapAttribution() { + const HEAVY_N = 10000; + const LIGHT_N = 1000; + const ROUNDS = 20; + const sink = []; + + const task = async (route, n) => { + await v8.withHeapProfileLabels({ route }, async () => { + for (let round = 0; round < 5; round++) { + await null; + const arr = new Array(n); + for (let i = 0; i < n; i++) arr[i] = { route, i, pad: 'x'.repeat(16) }; + sink.push(arr[0]); + } + }); + }; + + const handle = v8.startHeapProfile({ sampleInterval: 4096, labels: true }); + for (let r = 0; r < ROUNDS; r++) { + await Promise.all([ + task('/heavy', HEAVY_N), + task('/light', LIGHT_N), + task('/heavy', HEAVY_N), + task('/light', LIGHT_N), + ]); + } + const profile = handle.getAllocationProfile(); + handle.stop(); + + let heavy = 0; + let light = 0; + let unlabeled = 0; + for (const sample of profile.samples) { + const bytes = sample.size * sample.count; + if (sample.labels.route === '/heavy') heavy += bytes; + else if (sample.labels.route === '/light') light += bytes; + else if (sample.labels.route === undefined) unlabeled += bytes; + else assert.fail(`Unexpected route: ${sample.labels.route}`); + } + + assert.ok(heavy > 0 && light > 0, + `Both tasks should be attributed, got heavy=${heavy} light=${light}`); + + // Observed 9.3 to 11.3 across runs against a ground truth of 10. A bug that + // attributed allocations to whichever context happened to be current would + // land near 1. + const ratio = heavy / light; + assert.ok(ratio > 5 && ratio < 20, + `heavy:light byte ratio ${ratio.toFixed(2)} is not near the ` + + 'expected 10:1; attribution looks wrong'); + + // Almost everything allocated during the profile happens inside a labeled + // task, so unlabeled bytes should be a small remainder. + const labeledShare = (heavy + light) / (heavy + light + unlabeled); + assert.ok(labeledShare > 0.9, + `Only ${(labeledShare * 100).toFixed(1)}% of sampled bytes were ` + + 'attributed to a label'); +} + +// Off-heap attribution goes through ProfilingArrayBufferAllocator rather than +// the sampler, so it is exact rather than statistical. Buffers must be larger +// than Buffer.poolSize / 2 to get their own BackingStore; pooled allocations +// are attributed to whichever label triggered the pool refill. +async function testExternalAttribution() { + const BIG_KB = 64; + const SMALL_KB = 40; + const COUNT = 20; + const ROUNDS = 5; + const live = []; + + const task = async (route, sizeKB) => { + await v8.withHeapProfileLabels({ route }, async () => { + for (let i = 0; i < COUNT; i++) { + await null; + live.push(Buffer.allocUnsafe(sizeKB * 1024)); + } + }); + }; + + const handle = v8.startHeapProfile({ sampleInterval: 4096, labels: true }); + for (let r = 0; r < ROUNDS; r++) { + await Promise.all([task('/big', BIG_KB), task('/small', SMALL_KB)]); + } + const profile = handle.getAllocationProfile(); + handle.stop(); + + assert.ok(Array.isArray(profile.externalBytes), + 'externalBytes should be present when buffers were allocated'); + + let big = 0; + let small = 0; + for (const entry of profile.externalBytes) { + if (entry.labels.route === '/big') big += entry.bytes; + else if (entry.labels.route === '/small') small += entry.bytes; + } + + const expectedBig = BIG_KB * 1024 * COUNT * ROUNDS; + assert.ok(big >= expectedBig * 0.9, + `/big external bytes ${big} well below the ${expectedBig} ` + + 'actually allocated'); + const ratio = big / small; + const expectedRatio = BIG_KB / SMALL_KB; + assert.ok(ratio > expectedRatio * 0.6 && ratio < expectedRatio * 1.6, + `big:small external byte ratio ${ratio.toFixed(2)} is not near ` + + `the expected ${expectedRatio.toFixed(2)}`); +} + +async function main() { + await testHeapAttribution(); + await testExternalAttribution(); +} + +main().then(common.mustCall()); diff --git a/test/parallel/test-v8-heap-profile-labels-hostile.js b/test/parallel/test-v8-heap-profile-labels-hostile.js new file mode 100644 index 000000000000..674d57183d59 --- /dev/null +++ b/test/parallel/test-v8-heap-profile-labels-hostile.js @@ -0,0 +1,83 @@ +// Flags: --expose-gc +// Label materialisation must define own properties without invoking inherited +// setters. +'use strict'; +require('../common'); +const assert = require('assert'); +const v8 = require('v8'); + +// Poison the prototype setter for the label key and for every own-property +// name getAllocationProfile() writes onto its sample, entry, and result +// objects. All of those must be defined with CreateDataProperty, so none of +// these setters may run. +const POISONED_KEYS = [ + 'route', 'labels', 'nodeId', 'size', 'count', 'sampleId', 'samples', + 'externalBytes', 'bytes', +]; +for (const key of POISONED_KEYS) { + Object.defineProperty(Object.prototype, key, { + set() { throw new Error(`boom: ${key}`); }, + configurable: true, + }); +} + +const EXTERNAL_BYTES = 1024 * 1024; +let buf; + +try { + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + v8.withHeapProfileLabels({ route: '/x' }, () => { + const arr = []; + for (let i = 0; i < 10000; i++) arr.push({ data: i }); + // Allocate an external backing store so externalBytes entries are built + // (their labels/bytes fields also go through CreateDataProperty). It is + // kept alive in an outer binding until after the profile is read, so the + // entry cannot vanish to a GC before getAllocationProfile() runs. + buf = Buffer.allocUnsafeSlow(EXTERNAL_BYTES); + }); + + let profile; + // Must not throw and must not abort the process. + assert.doesNotThrow(() => { + profile = handle.getAllocationProfile(); + }); + handle.stop(); + + assert.ok(profile, 'getAllocationProfile() must return a profile object'); + assert.ok(Array.isArray(profile.samples), 'profile.samples must be an array'); + assert.ok(profile.samples.length > 0, 'must have at least one sample'); + + // Every sample must have a labels field. + for (const sample of profile.samples) { + assert.strictEqual(typeof sample.labels, 'object'); + assert.ok(sample.labels !== null); + } + + // Label materialisation must bypass the poisoned setter. + const labeled = profile.samples.filter((s) => s.labels.route === '/x'); + assert.ok( + labeled.length > 0, + 'Samples under withHeapProfileLabels({ route: "/x" }) must carry ' + + 'labels.route === "/x" — if this fails the poisoned setter ran' + ); + + // The externalBytes path builds its own objects with the poisoned keys + // 'externalBytes', 'labels' and 'bytes'; assert it actually ran and + // attributed the backing store, otherwise this test would silently stop + // covering it. + assert.ok(Array.isArray(profile.externalBytes), + 'externalBytes must be present: a labelled backing store is live'); + const external = profile.externalBytes.filter((e) => e.labels.route === '/x'); + assert.strictEqual(external.length, 1, + 'Expected exactly one externalBytes entry for ' + + `route="/x", got ${external.length}`); + assert.ok(external[0].bytes >= EXTERNAL_BYTES, + `Expected >= ${EXTERNAL_BYTES} external bytes for route="/x", ` + + `got ${external[0].bytes}`); + + // Keep the backing store alive across the profile read above. + assert.strictEqual(buf.length, EXTERNAL_BYTES); +} finally { + // Clean up the prototype mutations so other tests are not affected. + for (const key of POISONED_KEYS) delete Object.prototype[key]; +} diff --git a/test/parallel/test-v8-heap-profile-labels-http.js b/test/parallel/test-v8-heap-profile-labels-http.js new file mode 100644 index 000000000000..51c78a0f8b44 --- /dev/null +++ b/test/parallel/test-v8-heap-profile-labels-http.js @@ -0,0 +1,121 @@ +// Flags: --expose-gc +// Exercise labelled Buffer allocation and off-thread backing-store cleanup +// under sustained HTTP traffic and concurrent ArrayBuffer sweeping. +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const v8 = require('v8'); + +// Buffer sizes well above Buffer.poolSize/2 (4KB) so allocUnsafe goes +// through the V8 ArrayBufferAllocator and produces sweep-eligible +// BackingStores. Cycling sizes spreads allocations across young/old +// generations and gives the sweeper a steady stream of work. +const BUFFER_SIZES = [64 * 1024, 128 * 1024, 256 * 1024]; + +const TRAFFIC_DURATION_MS = 3000; +const CONCURRENCY = 60; +const HARD_DEADLINE_MS = 13_000; + +function makeHandler() { + let i = 0; + return function handler(req, res) { + const size = BUFFER_SIZES[i % BUFFER_SIZES.length]; + const route = `r${i % 10}`; + i++; + + v8.withHeapProfileLabels({ route }, () => { + // Allocate a sweep-eligible Buffer per request. The Buffer becomes + // garbage as soon as the response ends, putting pressure on the + // ArrayBufferSweeper. + const buf = Buffer.allocUnsafe(size); + buf.fill(0); + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(buf.length), + }); + res.end(buf); + }); + }; +} + +function driveTraffic(port, done) { + let started = 0; + let finished = 0; + let errored = 0; + let stopping = false; + const startTime = Date.now(); + const stopAt = startTime + TRAFFIC_DURATION_MS; + + function maybeStop() { + if (Date.now() >= stopAt) stopping = true; + if (stopping && started === finished + errored) { + done({ started, finished, errored }); + } else if (!stopping) { + kick(); + } + } + + function kick() { + while (!stopping && + started - (finished + errored) < CONCURRENCY) { + started++; + const req = http.get({ port, path: '/' }, (res) => { + res.on('data', () => {}); + res.on('end', () => { + finished++; + // Keep the ArrayBufferSweeper active during the traffic burst. + if (finished % 100 === 0 && global.gc) global.gc(); + maybeStop(); + }); + res.on('error', () => { + errored++; + maybeStop(); + }); + }); + req.on('error', () => { + errored++; + maybeStop(); + }); + } + } + + kick(); +} + +const server = http.createServer(makeHandler()); + +// Hard deadline to keep total wall time bounded even if traffic stalls. +const deadline = setTimeout(() => { + assert.fail(`Test exceeded ${HARD_DEADLINE_MS}ms wall-time deadline`); +}, HARD_DEADLINE_MS); +deadline.unref(); + +const handle = v8.startHeapProfile({ + sampleInterval: 65536, + stackDepth: 16, + includeObjectsCollectedByMajorGC: true, + includeObjectsCollectedByMinorGC: true, + labels: true, +}); + +server.listen(0, common.mustCall(() => { + const { port } = server.address(); + driveTraffic(port, common.mustCall(({ finished, errored }) => { + try { + assert.ok(finished > 0, + `Expected some successful requests, got ${finished} ` + + `(errored=${errored})`); + + // Exercise label resolution after concurrent backing-store cleanup. + const profile = handle.getAllocationProfile(); + assert.ok(profile, 'getAllocationProfile returned no profile'); + assert.ok(Array.isArray(profile.samples), + 'profile.samples should be an array'); + } finally { + handle.stop(); + server.close(); + clearTimeout(deadline); + } + })); +})); diff --git a/test/parallel/test-v8-heap-profile-labels-include-collected.js b/test/parallel/test-v8-heap-profile-labels-include-collected.js new file mode 100644 index 000000000000..c284487b125b --- /dev/null +++ b/test/parallel/test-v8-heap-profile-labels-include-collected.js @@ -0,0 +1,123 @@ +// Flags: --expose-gc +// Verify that label storage is deduplicated and that samples retained after +// collection keep their labels until profiler teardown. +'use strict'; +require('../common'); +const assert = require('assert'); +const v8 = require('v8'); + +const VOCAB = 10; +const N = 2000; + +function runWorkload(iterations) { + for (let i = 0; i < iterations; i++) { + // Cycle over a small vocabulary of routes so the intern table sees + // VOCAB distinct ALS values regardless of N. + const route = `r-${i % VOCAB}`; + v8.withHeapProfileLabels({ route }, () => { + // Allocate a small object that immediately dies. + const _dead = { i, payload: 'x'.repeat(8) }; + return _dead.i; + }); + // Drive weak callbacks periodically so retained samples accumulate + // throughout the loop, not only at the end. + if ((i + 1) % 100 === 0) { + global.gc(); + } + } + global.gc(); + global.gc(); +} + +// Label-array storage should scale with VOCAB rather than N. Sample structs +// still scale with the number of retained samples. +{ + global.gc(); + global.gc(); + const baseHeap = process.memoryUsage().heapUsed; + const handle = v8.startHeapProfile({ + sampleInterval: 64, + stackDepth: 16, + includeObjectsCollectedByMajorGC: true, + includeObjectsCollectedByMinorGC: true, + labels: true, + }); + try { + runWorkload(N); + const delta = process.memoryUsage().heapUsed - baseHeap; + assert.ok( + delta < 5 * 1024 * 1024, + `Heap delta after ${N} iterations across ${VOCAB} routes is ` + + `${(delta / 1024 / 1024).toFixed(2)} MB (limit 5 MB). The intern ` + + `table should keep ALS-array footprint bounded by VOCAB, not N.` + ); + } finally { + handle.stop(); + } +} + +// Collected samples retained by the profiler must keep their labels. +{ + global.gc(); + global.gc(); + const handle = v8.startHeapProfile({ + sampleInterval: 64, + stackDepth: 16, + includeObjectsCollectedByMajorGC: true, + includeObjectsCollectedByMinorGC: true, + labels: true, + }); + try { + for (let i = 0; i < 1000; i++) { + const route = `r-${i % VOCAB}`; + v8.withHeapProfileLabels({ route }, () => { + const _dead = { i, payload: 'x'.repeat(8) }; + return _dead.i; + }); + if ((i + 1) % 100 === 0) { + global.gc(); + } + } + global.gc(); + global.gc(); + + const profile = handle.getAllocationProfile(); + assert.ok(profile); + assert.ok(Array.isArray(profile.samples)); + assert.ok(profile.samples.length > 0, + 'Profiler should retain samples with includeObjectsCollected*'); + + // Filter to samples whose label.route is one we set. Samples + // attributed to internal V8 allocations made outside the + // withHeapProfileLabels block legitimately have empty labels + // (no ALS frame at allocation time) — those are not the + // population we care about here. + const ourSamples = profile.samples.filter((s) => { + if (!s.labels) return false; + const r = s.labels.route; + if (typeof r !== 'string') return false; + return /^r-\d+$/.test(r); + }); + + // Require enough labelled samples to show attribution survives the + // periodic collections throughout the workload. + assert.ok( + ourSamples.length >= 1000, + `Expected >=1000 retained samples carrying our route labels, ` + + `got ${ourSamples.length} ` + + `(of ${profile.samples.length} total samples)` + ); + + // Vocabulary check: all VOCAB routes should appear among the + // retained samples (the intern table preserves attribution for + // dead-retained samples, so we should see all 10 routes). + const seenRoutes = new Set(ourSamples.map((s) => s.labels.route)); + assert.strictEqual( + seenRoutes.size, VOCAB, + `Expected all ${VOCAB} routes to appear in retained labels, ` + + `got ${seenRoutes.size}: ${[...seenRoutes].sort().join(',')}` + ); + } finally { + handle.stop(); + } +} diff --git a/test/parallel/test-v8-heap-profile-labels-inspector-steal.js b/test/parallel/test-v8-heap-profile-labels-inspector-steal.js new file mode 100644 index 000000000000..326acce95c4b --- /dev/null +++ b/test/parallel/test-v8-heap-profile-labels-inspector-steal.js @@ -0,0 +1,143 @@ +// Flags: --expose-internals +// Verify Node-side teardown and handle identity when the inspector stops the +// underlying V8 sampling session. +'use strict'; +require('../common'); +const assert = require('assert'); +const v8 = require('v8'); +const inspector = require('inspector'); +const { internalBinding } = require('internal/test/binding'); +const { getProfilingAllocatorActive } = internalBinding('v8'); + +if (typeof getProfilingAllocatorActive !== 'function') { + // Build does not have V8_HEAP_PROFILER_SAMPLE_LABELS; nothing to test. + process.exit(0); +} + +assert.strictEqual(getProfilingAllocatorActive(), false, + 'allocator must be inactive before any session'); + +const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + +assert.strictEqual(getProfilingAllocatorActive(), true, + 'allocator must be active after startHeapProfile with labels:true'); + +// Allocate some objects so the sampler has work to do. +const arr = []; +for (let i = 0; i < 1000; i++) arr.push(new Array(100).fill(i)); + +// Steal V8's sampling profiler via the inspector, simulating what any user +// code that opens a plain inspector Session can do. +const session = new inspector.Session(); +session.connect(); +session.post('HeapProfiler.stopSampling', (err) => { + assert.strictEqual(err, null, 'inspector stopSampling must not error'); + + // handle.stop() must throw because V8 has no profile, but the teardown + // must run first — that is the invariant this test guards. + let threw = false; + try { + handle.stop(); + } catch (e) { + threw = true; + assert.strictEqual(e.code, 'ERR_HEAP_PROFILE_NOT_STARTED', + 'stop() must throw ERR_HEAP_PROFILE_NOT_STARTED'); + } + assert.ok(threw, 'handle.stop() must throw after inspector steal'); + + // The profiling allocator must be released even though stop() threw. + assert.strictEqual(getProfilingAllocatorActive(), false, + 'allocator must be inactive after stop() following an inspector steal'); + + // The handle is now stopped; subsequent stop() returns undefined. + assert.strictEqual(handle.stop(), undefined, + 'second stop() must return undefined'); + + // getAllocationProfile() must return undefined on a stopped handle. + assert.strictEqual(handle.getAllocationProfile(), undefined, + 'getAllocationProfile() must return undefined after stop'); + + // A new labels session must be startable after the stolen stop. + const handle2 = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + assert.strictEqual(getProfilingAllocatorActive(), true, + 'allocator must be active again after fresh startHeapProfile'); + const profile2 = handle2.getAllocationProfile(); + handle2.stop(); + assert.ok(profile2, 'second session must return a valid profile'); + assert.ok(Array.isArray(profile2.samples), + 'second session profile must have samples array'); + + session.disconnect(); + + // Starting a labels:false session must discard stale labels:true state. + const handle3 = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + assert.strictEqual(getProfilingAllocatorActive(), true, + 'allocator must be active after second labels:true start'); + + const session2 = new inspector.Session(); + session2.connect(); + session2.post('HeapProfiler.stopSampling', (err2) => { + assert.strictEqual(err2, null, + 'second inspector stopSampling must not error'); + + // handle3 has NOT been stopped. Its heap_profiling_cleanup_ is still + // set. Starting a labels:false session must discard that stale cleanup + // (and the allocator it owns) before starting the new V8 session. + const handle4 = v8.startHeapProfile({ sampleInterval: 64 }); + + assert.strictEqual(getProfilingAllocatorActive(), false, + 'allocator must be inactive after labels:false start discards stale ' + + 'cleanup from a stolen labels:true session'); + + handle4.stop(); + + // handle3 was never stopped and its session was stolen before handle4 + // started. The generation counter stamps handle4 with a new generation, + // so handle3's generation no longer matches. Its stop() must be a no-op + // (return undefined) rather than disturbing whatever session is current. + assert.strictEqual(handle3.stop(), undefined, + 'stale handle stop() must be a no-op when a newer session has run'); + + session2.disconnect(); + + // A stale handle must not read or stop a newer live session. + const handle5 = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + const arr5 = []; + for (let i = 0; i < 500; i++) arr5.push(new Array(100).fill(i)); + + const session3 = new inspector.Session(); + session3.connect(); + session3.post('HeapProfiler.stopSampling', (err3) => { + assert.strictEqual(err3, null, + 'third inspector stopSampling must not error'); + + // Start a new session (handle6) while handle5 is stale. + const handle6 = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + const arr6 = []; + for (let i = 0; i < 500; i++) arr6.push(new Array(100).fill(i)); + + // Stale handle5 must not see handle6's samples. + assert.strictEqual(handle5.getAllocationProfile(), undefined, + 'stale handle getAllocationProfile() must return undefined'); + + // Stale handle5 stop() must be a no-op — must not consume handle6. + assert.strictEqual(handle5.stop(), undefined, + 'stale handle stop() must return undefined'); + + // handle6 must still be running and return a valid profile. + const profile6 = handle6.getAllocationProfile(); + assert.ok(profile6 !== undefined, + 'live handle getAllocationProfile() must return a profile after ' + + 'stale handle.stop()'); + assert.ok(Array.isArray(profile6.samples), + 'live handle profile must have samples array'); + + // handle6.stop() must succeed. + const result6 = handle6.stop(); + assert.ok(typeof result6 === 'string', + 'live handle stop() must return the DevTools JSON string'); + + session3.disconnect(); + }); + }); +}); diff --git a/test/parallel/test-v8-heap-profile-labels-no-acf.js b/test/parallel/test-v8-heap-profile-labels-no-acf.js new file mode 100644 index 000000000000..ede122a2ad63 --- /dev/null +++ b/test/parallel/test-v8-heap-profile-labels-no-acf.js @@ -0,0 +1,34 @@ +// Flags: --no-async-context-frame --expose-internals +// Verify that the runtime warning fired when async-context-frame is disabled +// names the correct flag (--no-async-context-frame) rather than the obsolete +// --experimental-async-context-frame, which does not exist in Node.js 27. +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const v8 = require('v8'); +const { internalBinding } = require('internal/test/binding'); + +const { getProfilingAllocatorActive } = internalBinding('v8'); +if (typeof getProfilingAllocatorActive !== 'function') { + // Build does not have V8_HEAP_PROFILER_SAMPLE_LABELS; warning is a no-op. + process.exit(0); +} + +// Use process.on (not process.once): internal/test/binding emits its own +// warning on load which fires before ours and would capture a process.once +// listener first. +const captured = []; +process.on('warning', (w) => { + if (w.code === 'NODE_HEAP_PROFILE_LABELS_NO_ASYNC_CONTEXT') captured.push(w); +}); + +// Trigger ensureHeapProfileLabelsALS, which emits the warning. +v8.setHeapProfileLabels({ route: '/test' }); + +// Warnings are emitted asynchronously; setImmediate runs after microtasks. +setImmediate(common.mustCall(() => { + assert.strictEqual(captured.length, 1, 'expected exactly one label warning'); + assert.match(captured[0].message, /async-context-frame/); + assert.match(captured[0].message, /--no-async-context-frame/); +})); diff --git a/test/parallel/test-v8-heap-profile-labels-sticky-key.js b/test/parallel/test-v8-heap-profile-labels-sticky-key.js new file mode 100644 index 000000000000..6585dccb2ea5 --- /dev/null +++ b/test/parallel/test-v8-heap-profile-labels-sticky-key.js @@ -0,0 +1,66 @@ +// Flags: --expose-internals +// An inspector-owned sampling session must not inherit the labels key from a +// Node-owned session that the inspector stopped. +'use strict'; +require('../common'); +const assert = require('assert'); +const v8 = require('v8'); +const inspector = require('inspector'); +const { internalBinding } = require('internal/test/binding'); +const { getProfilingAllocatorActive } = internalBinding('v8'); + +if (typeof getProfilingAllocatorActive !== 'function') { + // Build does not have V8_HEAP_PROFILER_SAMPLE_LABELS; nothing to test. + process.exit(0); +} + +// Step 1: start a labels:true session via Node. +const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + +// Warm up the ALS by running a labelled callback before the steal, so the +// ALS key is definitely set in V8 when the out-of-band stop happens. +// Keep a reference so GC does not collect samples before getAllocationProfile. +let warmUp = []; +for (let i = 0; i < 200; i++) warmUp.push(new Array(50).fill(i)); + +// Step 2: steal the session with an out-of-band inspector stop. +const session = new inspector.Session(); +session.connect(); +session.post('HeapProfiler.stopSampling', (stopErr) => { + assert.strictEqual(stopErr, null, 'inspector stopSampling must not error'); + + // Step 3: start a new V8 sampling session via the inspector. This session + // never requested labels and must not receive any. + session.post('HeapProfiler.startSampling', {}, (startErr) => { + assert.strictEqual(startErr, null, 'inspector startSampling must not error'); + + // Keep the labelled allocations live until the inspector profile is read. + let leaked = []; + v8.withHeapProfileLabels({ route: '/leak-test' }, () => { + for (let i = 0; i < 3000; i++) leaked.push(new Array(200).fill(i)); + }); + + // Read the active inspector-owned profile. + const profile = handle.getAllocationProfile(); + assert.ok(profile, 'getAllocationProfile must return a profile'); + assert.ok(Array.isArray(profile.samples), + 'profile.samples must be an array'); + assert.ok(profile.samples.length > 0, + 'inspector session must have captured at least one sample'); + + // The inspector-owned session did not opt in to labels. + const labelled = profile.samples.filter( + (s) => Object.keys(s.labels).length > 0, + ); + assert.strictEqual(labelled.length, 0, + `inspector session must have 0 labelled samples; got ${labelled.length} ` + + `out of ${profile.samples.length} total`); + + // Release references before stopping. + warmUp = null; + leaked = null; + session.post('HeapProfiler.stopSampling', () => { + session.disconnect(); + }); + }); +}); 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 000000000000..9e13b2bb8d4e --- /dev/null +++ b/test/parallel/test-v8-heap-profile-labels-worker.js @@ -0,0 +1,95 @@ +// Heap profile labels require async-context-frame (on by default). +'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.startHeapProfile({ sampleInterval: 64, labels: true }); + + // 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.startHeapProfile({ sampleInterval: 64, labels: true }); + + 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'); + + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + const arr = []; + for (let i = 0; i < 500; i++) arr.push({ x: i }); + const profile = handle.getAllocationProfile(); + handle.stop(); + + 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 000000000000..fdea04e1a0b4 --- /dev/null +++ b/test/parallel/test-v8-heap-profile-labels.js @@ -0,0 +1,581 @@ +// Flags: --expose-gc +// Heap profile labels require async-context-frame (on by default). +'use strict'; +require('../common'); +const assert = require('assert'); +const v8 = require('v8'); + +// Test: labels API functions are exported +assert.strictEqual(typeof v8.startHeapProfile, 'function'); +assert.strictEqual(typeof v8.withHeapProfileLabels, 'function'); +assert.strictEqual(typeof v8.setHeapProfileLabels, 'function'); + +// A labels:false session must ignore labels set before session start. +{ + v8.setHeapProfileLabels({ route: '/gate-false' }); + const handle = v8.startHeapProfile({ sampleInterval: 64 }); // labels:false + const arr = []; + for (let i = 0; i < 2000; i++) arr.push(new Array(200).fill(i)); + const profile = handle.getAllocationProfile(); + handle.stop(); + + assert.ok(profile); + const labeled = profile.samples.filter( + (s) => Object.keys(s.labels).length > 0, + ); + assert.strictEqual(labeled.length, 0, + 'labels:false session must emit zero labelled samples'); + + // Reset the ALS so the /gate-false label does not contaminate later tests. + // setHeapProfileLabels uses enterWith, which persists in the async context. + v8.setHeapProfileLabels({}); +} + +// Test: handle has getAllocationProfile method +{ + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + assert.strictEqual(typeof handle.getAllocationProfile, 'function'); + handle.stop(); +} + +// Test: getAllocationProfile returns undefined after stop +{ + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + handle.stop(); + assert.strictEqual(handle.getAllocationProfile(), undefined); +} + +// Test: basic profiling without labels +{ + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + const arr = []; + for (let i = 0; i < 1000; i++) arr.push({ x: i }); + const profile = handle.getAllocationProfile(); + handle.stop(); + + 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 +{ + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + + v8.withHeapProfileLabels({ route: '/test' }, () => { + const arr = []; + for (let i = 0; i < 5000; i++) arr.push({ data: i }); + }); + + const profile = handle.getAllocationProfile(); + handle.stop(); + + 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 +{ + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + + 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 = handle.getAllocationProfile(); + handle.stop(); + + const heavy = profile.samples.filter((s) => s.labels.route === '/heavy'); + const light = profile.samples.filter((s) => s.labels.route === '/light'); + + // Attribution-correctness: every labeled sample must carry one of the + // two expected routes. A misattribution bug would produce samples with an + // unexpected route value. + const expectedRoutes = new Set(['/heavy', '/light']); + const allLabeled = profile.samples.filter( + (s) => s.labels.route !== undefined + ); + for (const sample of allLabeled) { + assert.ok(expectedRoutes.has(sample.labels.route), + `Sample carries unexpected route: ${sample.labels.route}`); + } + // /heavy allocates 10× more than /light: samples are reliably expected. + assert.ok(heavy.length > 0, 'Should have /heavy samples'); + // /light may have zero samples due to its low allocation volume. +} + +// Test: multi-key labels +{ + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + + v8.withHeapProfileLabels({ route: '/api', method: 'GET' }, () => { + const arr = []; + for (let i = 0; i < 5000; i++) arr.push({ data: i }); + }); + + const profile = handle.getAllocationProfile(); + handle.stop(); + + 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 +{ + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + + v8.withHeapProfileLabels({ route: '/json' }, () => { + const arr = []; + for (let i = 0; i < 5000; i++) arr.push({ data: i }); + }); + + const profile = handle.getAllocationProfile(); + handle.stop(); + + 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: startHeapProfile({ sampleInterval: 0 }) throws RangeError +assert.throws(() => v8.startHeapProfile({ sampleInterval: 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', +}); +// Non-string label VALUES must be rejected (validateString in labelsToFlat). +// Only non-object labels are covered by the tests above; this pins the value path. +assert.throws(() => v8.withHeapProfileLabels({ route: 42 }, () => {}), { + code: 'ERR_INVALID_ARG_TYPE', +}); +assert.throws(() => v8.withHeapProfileLabels({ route: null }, () => {}), { + 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++) { + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + v8.withHeapProfileLabels({ route: `/cycle${cycle}` }, () => { + const arr = []; + for (let i = 0; i < 1000; i++) arr.push({ x: i }); + }); + const profile = handle.getAllocationProfile(); + handle.stop(); + assert.ok(profile); + assert.ok(profile.samples.length > 0); + } +} + +// Test: samples are retained with includeObjectsCollectedByMajorGC and +// includeObjectsCollectedByMinorGC (sample entries themselves survive GC of +// their underlying allocation). Labels for retained samples are kept alive by +// the LabelInternTable refcount — both live and dead-but-retained samples carry +// their labels. The underlying ALS JSArray is unpinned only when ALL samples +// (live and retained) sharing it are gone, i.e. at profiler teardown for +// retained samples. See test-v8-heap-profile-labels-include-collected.js for +// the dedup + label-visibility regression test. +{ + const handle = v8.startHeapProfile({ + sampleInterval: 64, + stackDepth: 16, + includeObjectsCollectedByMajorGC: true, + includeObjectsCollectedByMinorGC: true, + labels: true, + }); + + // Keep some allocations alive so their samples retain labels through + // GC: label_id is only released when the underlying object's weak + // callback fires. + const heavyAlive = []; + v8.withHeapProfileLabels({ route: '/heavy-gc' }, () => { + for (let i = 0; i < 500; i++) { + // Half the arrays become garbage immediately; half are kept alive. + const a = new Array(25000).fill(i); + if (i % 2 === 0) heavyAlive.push(a); + } + }); + + // Force garbage collection to retire the dead-from-birth allocations. + global.gc(); + + const profile = handle.getAllocationProfile(); + handle.stop(); + + // Profile must have samples (retention works at all). + assert.ok(profile.samples.length > 0); + + // Live-allocation samples in the heavy block keep their labels because + // their weak callback hasn't fired (the underlying object is still + // reachable via heavyAlive[]). + const heavyAliveLabeled = + profile.samples.filter((s) => s.labels.route === '/heavy-gc'); + assert.ok(heavyAliveLabeled.length > 0, + 'Live-allocation samples should retain their labels'); + // Touch heavyAlive after the assertion to keep it reachable through GC. + assert.strictEqual(heavyAlive.length, 250); +} + +// Test: GC'd samples are removed without includeObjectsCollected* (default) +{ + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + + 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 includeObjectsCollected*, samples are + // removed from the profile via V8's OnWeakCallback + global.gc(); + + const profile = handle.getAllocationProfile(); + handle.stop(); + + 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 includeObjectsCollected*, GC'd samples should mostly be removed ` + + `(got ${(totalBytes / 1024 / 1024).toFixed(1)}MB)` + ); +} + +// Test: includeObjectsCollected* retains samples, omitting it does not. +// Labels on retained-but-collected samples are released by the intern +// table refcount, so we compare total profile bytes (not labeled +// bytes) — the retained sample entries themselves are what matters. +{ + // Start WITH includeObjectsCollected* + const handleWith = v8.startHeapProfile({ + sampleInterval: 64, + stackDepth: 16, + includeObjectsCollectedByMajorGC: true, + includeObjectsCollectedByMinorGC: true, + labels: true, + }); + v8.withHeapProfileLabels({ route: '/retained' }, () => { + for (let i = 0; i < 200; i++) new Array(25000).fill(i); + }); + global.gc(); + const withProfile = handleWith.getAllocationProfile(); + handleWith.stop(); + + const withBytes = withProfile.samples.reduce( + (sum, s) => sum + s.size * s.count, 0 + ); + + // Start WITHOUT includeObjectsCollected* + const handleWithout = v8.startHeapProfile({ + sampleInterval: 64, + labels: true, + }); + v8.withHeapProfileLabels({ route: '/removed' }, () => { + for (let i = 0; i < 200; i++) new Array(25000).fill(i); + }); + global.gc(); + const withoutProfile = handleWithout.getAllocationProfile(); + handleWithout.stop(); + + const withoutBytes = withoutProfile.samples.reduce( + (sum, s) => sum + s.size * s.count, 0 + ); + + // With includeObjectsCollected* should retain significantly more bytes. + assert.ok(withBytes > 0, + `includeObjectsCollected* should retain samples: withBytes=${withBytes}`); + assert.ok( + withBytes > withoutBytes * 5, + `includeObjectsCollected* 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 call replaces the current ALS store via enterWith. Use two profiler +// sessions: one to exercise the label rotation with profiling active, a fresh +// one to capture only post-loop allocations, so we can assert that ONLY the +// final label appears (no stale routes leaking through). +{ + // Session 1: run the label rotation with profiling active to exercise cleanup. + const handle1 = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + for (let i = 0; i < 100; i++) { + v8.setHeapProfileLabels({ route: `/iter${i}` }); + } + handle1.stop(); + + // Session 2: capture only post-loop allocations. + const handle2 = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + const arr = []; + for (let i = 0; i < 5000; i++) arr.push({ data: i }); + const profile = handle2.getAllocationProfile(); + handle2.stop(); + + // Only the final label (/iter99) must appear: stale route labels must not + // outlive the loop. + const finalLabeled = profile.samples.filter( + (s) => s.labels.route === '/iter99' + ); + assert.ok(finalLabeled.length > 0, + 'Should have samples labeled with final /iter99'); + + const staleLabeled = profile.samples.filter( + (s) => s.labels.route && s.labels.route !== '/iter99' + ); + assert.strictEqual(staleLabeled.length, 0, + `Old label routes must not appear after loop; found: ` + + JSON.stringify([...new Set(staleLabeled.map((s) => s.labels.route))])); +} + +// Labels survive when another ALS store changes the shared +// AsyncContextFrame Map identity. +// 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, so a change in the CPED Map identity does not drop the +// labels. +{ + const { AsyncLocalStorage } = require('async_hooks'); + const otherALS = new AsyncLocalStorage(); + + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + + 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 = handle.getAllocationProfile(); + handle.stop(); + + 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' + ); +} + +// Test: labels object is frozen — Object.isFrozen is true and mutation throws +// in strict mode. This is a documented public API guarantee. +{ + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + + v8.withHeapProfileLabels({ route: '/frozen-test' }, () => { + const arr = []; + for (let i = 0; i < 5000; i++) arr.push({ data: i }); + }); + + const profile = handle.getAllocationProfile(); + handle.stop(); + + const labeled = profile.samples.filter( + (s) => s.labels.route === '/frozen-test' + ); + assert.ok(labeled.length > 0, 'Expected labeled samples for /frozen-test'); + + for (const sample of labeled) { + assert.ok(Object.isFrozen(sample.labels), + 'sample.labels must be frozen (documented guarantee)'); + // In strict mode (this file has "use strict") assigning to a frozen object + // throws TypeError. + assert.throws( + () => { sample.labels.newKey = 'value'; }, + TypeError, + 'Mutating a frozen labels object must throw in strict mode' + ); + } +} + +// Test: samples captured under the same label context share the identical (===) +// labels object. This identity guarantee is what makes it safe to freeze and +// share a single object rather than copying it per sample. +// Per the corrected doc: identity is only guaranteed within one context — two +// separate withHeapProfileLabels calls with equal content produce distinct +// frozen objects. +{ + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + + v8.withHeapProfileLabels({ route: '/shared-test' }, () => { + const arr = []; + for (let i = 0; i < 10000; i++) arr.push({ data: i }); + }); + + const profile = handle.getAllocationProfile(); + handle.stop(); + + const labeled = profile.samples.filter( + (s) => s.labels.route === '/shared-test' + ); + // At 64-byte interval, 10000 × ~30-byte objects yields ~4000+ samples. + assert.ok(labeled.length > 1, + 'Expected multiple samples from the same context to test sharing'); + + const firstLabels = labeled[0].labels; + for (const sample of labeled) { + assert.strictEqual(sample.labels, firstLabels, + 'All samples from the same label context must share the identical (===) ' + + 'labels object'); + } +} + +// A second startHeapProfile call must throw without stopping the active +// session. +{ + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + const arr = []; + for (let i = 0; i < 1000; i++) arr.push({ x: i }); + // First session should be active. + assert.ok(handle.getAllocationProfile(), 'First session must be active'); + + // Second start must throw, not silently kill the first session. + assert.throws( + () => v8.startHeapProfile({ sampleInterval: 64, labels: true }), + { code: 'ERR_HEAP_PROFILE_HAVE_BEEN_STARTED' } + ); + + // After the failed second start the first session is still running. + assert.ok(handle.getAllocationProfile(), + 'First session must still be active after failed second start'); + + handle.stop(); +} + +// A second labels:true start must not steal the active session. +{ + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + + assert.throws( + () => v8.startHeapProfile({ sampleInterval: 64, labels: true }), + { code: 'ERR_HEAP_PROFILE_HAVE_BEEN_STARTED' } + ); + + // The first session is still alive; stop() must return a profile string. + const profile = handle.stop(); + assert.ok(typeof profile === 'string' && profile.length > 0, + 'handle.stop() must return a profile string when session was not stolen'); +} + +// A labels:false start must not steal an active labels:true session. +{ + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + + assert.throws(() => v8.startHeapProfile(), { + code: 'ERR_HEAP_PROFILE_HAVE_BEEN_STARTED', + }); + + handle.stop(); +} + +// A later labels:true session must reinstall the labels key. +{ + // First session with labels (clears the V8 key on stop via DoCleanup). + { + const h = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + v8.setHeapProfileLabels({ route: '/first' }); + const arr = []; + for (let i = 0; i < 2000; i++) arr.push(new Array(200).fill(i)); + h.stop(); + } + + // Second session with labels after the first ended. + const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + v8.withHeapProfileLabels({ route: '/second' }, () => { + const arr = []; + for (let i = 0; i < 5000; i++) arr.push(new Array(200).fill(i)); + }); + const profile = handle.getAllocationProfile(); + handle.stop(); + + assert.ok(profile); + const labeled = profile.samples.filter( + (s) => s.labels.route === '/second', + ); + assert.ok(labeled.length > 0, + 'labels:true session after a prior labels session must emit labelled samples'); +} + +// A labels:false session following labels:true must emit no labels. +{ + // First session with labels: arm the key, allocate, then stop. + { + const h = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + v8.withHeapProfileLabels({ route: '/prior-true' }, () => { + const arr = []; + for (let i = 0; i < 2000; i++) arr.push(new Array(200).fill(i)); + }); + h.stop(); + } + + // Second session without labels: must emit zero labelled samples even + // though a labels:true session ran immediately before. + const handle = v8.startHeapProfile({ sampleInterval: 64 }); + v8.setHeapProfileLabels({ route: '/after-prior-true' }); + const arr = []; + for (let i = 0; i < 2000; i++) arr.push(new Array(200).fill(i)); + const profile = handle.getAllocationProfile(); + handle.stop(); + + assert.ok(profile); + assert.ok(profile.samples.length > 0, 'expected samples from labels:false session'); + const labeled = profile.samples.filter( + (s) => Object.keys(s.labels).length > 0, + ); + assert.strictEqual(labeled.length, 0, + 'labels:false session after a prior labels:true session must emit ' + + 'zero labelled samples'); + + // Reset the ALS so the label does not contaminate later tests. + v8.setHeapProfileLabels({}); +} diff --git a/test/parallel/test-worker-heap-profile.js b/test/parallel/test-worker-heap-profile.js index 2a466dc2f186..7f7da9577671 100644 --- a/test/parallel/test-worker-heap-profile.js +++ b/test/parallel/test-worker-heap-profile.js @@ -47,6 +47,12 @@ worker.on('online', common.mustCall(async () => { () => worker.startHeapProfile({ includeObjectsCollectedByMinorGC: 1 }), { code: 'ERR_INVALID_ARG_TYPE', }); + assert.throws(() => worker.startHeapProfile({ labels: 1 }), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => worker.startHeapProfile({ labels: true }), { + code: 'ERR_INVALID_ARG_VALUE', + }); { const handle = await worker.startHeapProfile({ From 71037dfed8d18cef2ff7a6146398da660d2c6b8c Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Thu, 6 Aug 2026 15:21:24 +0200 Subject: [PATCH 5/6] doc: document heap profile labels Document the labels option on v8.startHeapProfile, the getAllocationProfile() method on the handle, and the two functions that set labels. Document the limitations: labelled data is only available through getAllocationProfile() and never from stop(), external memory accounting needs Node's own ArrayBuffer allocator, small pooled Buffers are attributed to whichever label triggered the pool refill, and addons built outside node-gyp must define V8_HEAP_PROFILER_SAMPLE_LABELS to see the same struct layout as libnode. Signed-off-by: Rudolf Meijering --- doc/api/v8.md | 157 +++++++++++++++++++++++++++++++++++++- doc/api/worker_threads.md | 7 ++ 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/doc/api/v8.md b/doc/api/v8.md index 371d5348f65b..7fe679eb86ba 100644 --- a/doc/api/v8.md +++ b/doc/api/v8.md @@ -1446,6 +1446,90 @@ 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.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. + +```mjs +const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + +await v8.withHeapProfileLabels({ route: '/users' }, async () => { + const data = await fetchUsers(); + return processData(data); +}); + +const profile = handle.getAllocationProfile(); +handle.stop(); +``` + +### `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. + +**Native addon ABI.** The `v8::AllocationProfile::Sample` struct in +`v8-profiler.h` includes the `label_id` field only when +`V8_HEAP_PROFILER_SAMPLE_LABELS` is defined at compile time. Node.js sets +this macro for its own builds and for addons compiled through node-gyp +(via `common.gypi`). Addons built with other build systems (cmake, meson, +Makefile) must define `-DV8_HEAP_PROFILER_SAMPLE_LABELS` themselves to match +libnode. The `label_id` field is appended after the pre-existing fields, so +their offsets never change. On the common 64-bit ABIs (and on 32-bit targets +whose ABI 8-aligns `uint64_t`, such as Windows and ARM), it lands in existing +tail padding, `sizeof(AllocationProfile::Sample)` is unchanged, and iterating +`GetSamples()` strides correctly whether or not the macro is defined; an addon +only needs the macro to name `label_id`. On i386 System V (32-bit x86 on +Linux and the BSDs), `uint64_t` is 4-aligned and the field grows the struct by +4 bytes, so an addon there must define the macro to stride `GetSamples()` +correctly. A `static_assert` in V8 enforces the no-growth invariant on the +ABIs that provide the padding. When the macro is not defined, the +label APIs (`withHeapProfileLabels()`, `setHeapProfileLabels()`) are no-ops +and `getAllocationProfile()` omits `samples[].labels` and `externalBytes` +entirely. + +**Label availability.** Labels depend on async-context-frame, which propagates +the `AsyncLocalStorage` map through continuation callbacks via +`ContinuationPreservedEmbedderData`. The option is on by default; passing +`--no-async-context-frame` disables it, leaving every sample's `labels` object +empty. A one-time `process.emitWarning` fires at first label use when the +option is off. + ## Class: `v8.GCProfiler` + +* Returns: {Object | undefined} + +Returns the current allocation profile without stopping the profiler, or +`undefined` if the handle has already been stopped or if its session was +superseded by a newer one started on the same binding (for example when +an inspector `HeapProfiler.stopSampling` call ended the handle's V8 +session out of band and a new session was subsequently started). In the +superseded case the handle carries a stale session identity and will never +return a profile; call `stop()` on the new handle instead. The method is +always available regardless of the `labels` option. For sessions started with +`labels: true`, each sample's `labels` object is populated with the +active label context at allocation time and `externalBytes` is included +when labelled backing stores are live. For sessions started without +`labels: true`, each sample carries an empty `labels` object and +`externalBytes` is omitted. + +```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. The object is + **frozen** and **shared** across all samples captured under the same + active label context — mutating it throws in strict mode. +* `externalBytes[]` — live `Buffer`/`ArrayBuffer` backing-store bytes per + label context. Omitted when the profiling allocator is inactive, when no + labelled backing stores are live, or when all live stores were allocated + under an empty label set. + +**Label memory model.** The V8 heap profiler retains one copy of each +distinct label set for the profiler's lifetime. In the default mode +(`includeObjectsCollectedByMajorGC: false`) samples are dropped when their +objects are GC'd. With `includeObjectsCollectedByMajorGC: true` or +`includeObjectsCollectedByMinorGC: true`, dead samples are kept and each +unique label set pins one internal array for the profiler's lifetime — +label cardinality should be bounded to avoid unbounded growth. + ### `syncHeapProfileHandle.stop()`