Skip to content

Commit a393276

Browse files
committed
src: add heap profile labels bindings
Add Node.js C++ bindings that wire up the V8 sample labels API. Handles callback registration, ALS key setup, and cleanup on worker termination. Signed-off-by: Rudolf Meijering <[email protected]>
1 parent 2febdb4 commit a393276

3 files changed

Lines changed: 330 additions & 0 deletions

File tree

node.gyp

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
'v8_trace_maps%': 0,
55
'v8_enable_pointer_compression%': 0,
66
'v8_enable_31bit_smis_on_64bit_arch%': 0,
7+
# Matches V8 default (BUILD.gn); must be declared here for GYP conditions
8+
# that gate V8_HEAP_PROFILER_SAMPLE_LABELS below.
9+
'v8_enable_continuation_preserved_embedder_data%': 1,
710
'force_dynamic_crt%': 0,
811
'node_builtin_modules_path%': '',
912
'node_core_target_name%': 'node',
@@ -937,6 +940,12 @@
937940
'msvs_disabled_warnings!': [4244],
938941

939942
'conditions': [
943+
[ 'v8_enable_continuation_preserved_embedder_data==1', {
944+
'defines': [
945+
# Enable heap profiler sample labels when CPED is available.
946+
'V8_HEAP_PROFILER_SAMPLE_LABELS',
947+
],
948+
}],
940949
[ 'openssl_default_cipher_list!=""', {
941950
'defines': [
942951
'NODE_OPENSSL_DEFAULT_CIPHER_LIST="<(openssl_default_cipher_list)"'
@@ -1322,6 +1331,11 @@
13221331
'sources': [ '<@(node_cctest_sources)' ],
13231332

13241333
'conditions': [
1334+
[ 'v8_enable_continuation_preserved_embedder_data==1', {
1335+
'defines': [
1336+
'V8_HEAP_PROFILER_SAMPLE_LABELS',
1337+
],
1338+
}],
13251339
[ 'node_shared_gtest=="false"', {
13261340
'dependencies': [
13271341
'deps/googletest/googletest.gyp:gtest',

src/node_v8.cc

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,16 @@
2727
#include "node.h"
2828
#include "node_external_reference.h"
2929
#include "util-inl.h"
30+
#include "v8-container.h"
3031
#include "v8-profiler.h"
3132
#include "v8.h"
3233

3334
namespace node {
3435
namespace v8_utils {
36+
37+
// V8's default sampling interval for the sampling heap profiler (512 KB).
38+
static constexpr uint64_t kDefaultSamplingInterval = 512 * 1024;
39+
using v8::AllocationProfile;
3540
using v8::Array;
3641
using v8::BigInt;
3742
using v8::CFunction;
@@ -44,12 +49,14 @@ using v8::FunctionCallbackInfo;
4449
using v8::FunctionTemplate;
4550
using v8::HandleScope;
4651
using v8::HeapCodeStatistics;
52+
using v8::HeapProfiler;
4753
using v8::HeapSpaceStatistics;
4854
using v8::HeapStatistics;
4955
using v8::Integer;
5056
using v8::Isolate;
5157
using v8::Local;
5258
using v8::LocalVector;
59+
using v8::Map;
5360
using v8::MaybeLocal;
5461
using v8::Number;
5562
using v8::Object;
@@ -59,6 +66,63 @@ using v8::Uint32;
5966
using v8::V8;
6067
using v8::Value;
6168

69+
#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS
70+
// V8 callback invoked at profile-read time (BuildSamples) for each sample
71+
// that has a stored CPED. Receives the CPED (AsyncContextFrame = JS Map),
72+
// looks up the heap profile labels ALS store value, and converts the
73+
// pre-flattened [key1, val1, key2, val2, ...] array to string pairs.
74+
//
75+
// GC safety: this callback is invoked from GetAllocationProfile() BEFORE
76+
// BuildSamples() iteration, in a context where GC is allowed. The caller
77+
// iterates a snapshot of ALS values (not the live samples_ map), so
78+
// GC-triggered weak callbacks that erase from samples_ cannot cause
79+
// iterator invalidation. Array::Get() may allocate on the V8 heap — that
80+
// is safe in this pre-resolution phase.
81+
//
82+
// The |context| parameter is the ALS value (flat [key1, val1, key2, val2, ...]
83+
// array) extracted by V8 at allocation time via OrderedHashMap::FindEntry.
84+
// If no ALS value was found for a sample, context will be empty or undefined.
85+
static bool HeapProfileLabelsCallback(
86+
void* data, v8::Local<v8::Value> context,
87+
std::vector<std::pair<std::string, std::string>>* out_labels) {
88+
auto* binding_data = static_cast<BindingData*>(data);
89+
if (!binding_data) return false;
90+
91+
// context is the ALS value (flat label array) — no Map lookup needed.
92+
if (context.IsEmpty() || !context->IsArray()) return false;
93+
94+
Isolate* isolate = binding_data->env()->isolate();
95+
HandleScope handle_scope(isolate);
96+
Local<v8::Context> v8_context = isolate->GetCurrentContext();
97+
98+
// Convert flat [key1, val1, key2, val2, ...] array to string pairs.
99+
Local<Array> flat = context.As<Array>();
100+
uint32_t len = flat->Length();
101+
std::vector<std::pair<std::string, std::string>> result;
102+
for (uint32_t j = 0; j + 1 < len; j += 2) {
103+
Local<Value> k, v;
104+
if (!flat->Get(v8_context, j).ToLocal(&k)) return false;
105+
if (!flat->Get(v8_context, j + 1).ToLocal(&v)) return false;
106+
String::Utf8Value key_str(isolate, k);
107+
String::Utf8Value val_str(isolate, v);
108+
if (*key_str == nullptr || *val_str == nullptr) continue;
109+
result.emplace_back(*key_str, *val_str);
110+
}
111+
112+
*out_labels = std::move(result);
113+
return !out_labels->empty();
114+
}
115+
116+
// C++ binding: store the AsyncLocalStorage instance used for heap profile
117+
// labels. V8 uses this key at allocation time to extract the ALS value from
118+
// the CPED (AsyncContextFrame) via OrderedHashMap::FindEntry.
119+
void SetHeapProfileLabelsStore(const FunctionCallbackInfo<Value>& args) {
120+
Isolate* isolate = args.GetIsolate();
121+
BindingData* binding_data = Realm::GetBindingData<BindingData>(args);
122+
binding_data->heap_profile_labels_als_key.Reset(isolate, args[0]);
123+
}
124+
#endif // V8_HEAP_PROFILER_SAMPLE_LABELS
125+
62126
#define HEAP_STATISTICS_PROPERTIES(V) \
63127
V(0, total_heap_size, kTotalHeapSizeIndex) \
64128
V(1, total_heap_size_executable, kTotalHeapSizeExecutableIndex) \
@@ -103,6 +167,9 @@ static const size_t kHeapCodeStatisticsPropertiesCount =
103167
HEAP_CODE_STATISTICS_PROPERTIES(V);
104168
#undef V
105169

170+
// Forward declaration for the env cleanup hook (used by ~BindingData).
171+
static void CleanupHeapProfiling(void* data);
172+
106173
BindingData::BindingData(Realm* realm,
107174
Local<Object> obj,
108175
InternalFieldInfo* info)
@@ -144,6 +211,25 @@ BindingData::BindingData(Realm* realm,
144211
heap_code_statistics_buffer.MakeWeak();
145212
}
146213

214+
BindingData::~BindingData() {
215+
// BindingData is destroyed during Realm::RunCleanup() (via
216+
// binding_data_store_.reset()), which runs BEFORE the environment cleanup
217+
// queue is drained. At this point the Isolate and Environment are
218+
// guaranteed to still be alive (Realm::RunCleanup runs inside
219+
// Environment::RunCleanup, before the Environment is deleted and long
220+
// before the Isolate is disposed).
221+
//
222+
// If profiling was active, DoCleanup() stops the sampler, clears V8's
223+
// callback pointer into this (about-to-be-destroyed) BindingData, and
224+
// disables the allocator tracker — preventing use-after-free.
225+
if (heap_profiling_cleanup_ != nullptr) {
226+
heap_profiling_cleanup_->DoCleanup();
227+
env()->RemoveCleanupHook(CleanupHeapProfiling, heap_profiling_cleanup_);
228+
delete heap_profiling_cleanup_;
229+
heap_profiling_cleanup_ = nullptr;
230+
}
231+
}
232+
147233
bool BindingData::PrepareForSerialization(Local<Context> context,
148234
v8::SnapshotCreator* creator) {
149235
DCHECK_NULL(internal_field_info_);
@@ -186,6 +272,9 @@ void BindingData::MemoryInfo(MemoryTracker* tracker) const {
186272
heap_space_statistics_buffer);
187273
tracker->TrackField("heap_code_statistics_buffer",
188274
heap_code_statistics_buffer);
275+
tracker->TrackFieldWithSize("heap_profile_labels_als_key",
276+
heap_profile_labels_als_key.IsEmpty() ? 0 :
277+
sizeof(v8::Global<v8::Value>));
189278
}
190279

191280
void CachedDataVersionTag(const FunctionCallbackInfo<Value>& args) {
@@ -673,6 +762,180 @@ void GCProfiler::Stop(const FunctionCallbackInfo<v8::Value>& args) {
673762
}
674763
}
675764

765+
// Data captured when heap profiling starts, used by the cleanup hook to
766+
// safely tear down profiler state if the Environment is destroyed while
767+
// profiling is still active (e.g. worker thread termination).
768+
//
769+
// Raw pointers are safe here because Environment cleanup hooks are
770+
// guaranteed to run before the Isolate is disposed: FreeEnvironment()
771+
// calls env->RunCleanup() (which drains the cleanup queue) while still
772+
// inside Isolate::Scope, and the ArrayBufferAllocator that owns
773+
// ProfilingArrayBufferAllocator outlives the Isolate.
774+
//
775+
// We intentionally do NOT store a BindingData* here — Realm::RunCleanup()
776+
// destroys BindingData (via binding_data_store_.reset()) before the
777+
// environment cleanup queue is drained, so any BindingData* would be
778+
// dangling by the time this hook fires.
779+
struct HeapProfilingCleanup {
780+
Isolate* isolate;
781+
bool cleaned_up = false;
782+
783+
// Idempotent: stops the sampling profiler and clears the labels callback.
784+
// Safe to call multiple times — only the first call has effect.
785+
void DoCleanup() {
786+
if (cleaned_up) return;
787+
cleaned_up = true;
788+
789+
HeapProfiler* profiler = isolate->GetHeapProfiler();
790+
profiler->StopSamplingHeapProfiler();
791+
#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS
792+
profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr);
793+
profiler->SetHeapProfileSampleLabelsKey(Local<Value>());
794+
#endif // V8_HEAP_PROFILER_SAMPLE_LABELS
795+
isolate = nullptr;
796+
}
797+
};
798+
799+
static void CleanupHeapProfiling(void* data) {
800+
auto* ctx = static_cast<HeapProfilingCleanup*>(data);
801+
ctx->DoCleanup();
802+
delete ctx;
803+
}
804+
805+
void StartSamplingHeapProfiler(const FunctionCallbackInfo<Value>& args) {
806+
Isolate* isolate = args.GetIsolate();
807+
Local<Context> context = isolate->GetCurrentContext();
808+
HeapProfiler* profiler = isolate->GetHeapProfiler();
809+
BindingData* binding_data = Realm::GetBindingData<BindingData>(args);
810+
uint64_t interval = kDefaultSamplingInterval;
811+
if (args.Length() > 0 && args[0]->IsNumber()) {
812+
interval = static_cast<uint64_t>(args[0].As<Number>()->Value());
813+
}
814+
int stack_depth = 16; // Default stack depth
815+
if (args.Length() > 1 && args[1]->IsNumber()) {
816+
stack_depth = static_cast<int>(args[1].As<Number>()->Value());
817+
}
818+
#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS
819+
profiler->SetHeapProfileSampleLabelsCallback(
820+
HeapProfileLabelsCallback, binding_data);
821+
if (!binding_data->heap_profile_labels_als_key.IsEmpty()) {
822+
profiler->SetHeapProfileSampleLabelsKey(
823+
binding_data->heap_profile_labels_als_key.Get(isolate));
824+
}
825+
#endif // V8_HEAP_PROFILER_SAMPLE_LABELS
826+
// By default, GC'd samples are removed from the profile (live-memory mode).
827+
// When includeCollectedObjects is true, retain GC'd samples so allocation
828+
// attribution reflects total allocations (allocation-rate mode).
829+
v8::HeapProfiler::SamplingFlags flags =
830+
static_cast<v8::HeapProfiler::SamplingFlags>(
831+
v8::HeapProfiler::kSamplingNoFlags);
832+
if (args.Length() > 2 && args[2]->IsObject()) {
833+
Local<Object> options = args[2].As<Object>();
834+
Local<String> key = String::NewFromUtf8Literal(isolate,
835+
"includeCollectedObjects");
836+
Local<Value> val;
837+
if (options->Get(context, key).ToLocal(&val) && val->IsTrue()) {
838+
flags = static_cast<v8::HeapProfiler::SamplingFlags>(
839+
v8::HeapProfiler::kSamplingIncludeObjectsCollectedByMajorGC |
840+
v8::HeapProfiler::kSamplingIncludeObjectsCollectedByMinorGC);
841+
}
842+
}
843+
profiler->StartSamplingHeapProfiler(interval, stack_depth, flags);
844+
845+
// Register a cleanup hook so that if the Environment is torn down while
846+
// profiling is active (e.g. a worker thread is terminated), V8's callback
847+
// pointer into the now-destroyed BindingData is cleared. Remove any prior
848+
// hook first (handles repeated Start calls without an intervening Stop).
849+
Environment* env = Environment::GetCurrent(args);
850+
if (binding_data->heap_profiling_cleanup_ != nullptr) {
851+
binding_data->heap_profiling_cleanup_->DoCleanup();
852+
env->RemoveCleanupHook(
853+
CleanupHeapProfiling, binding_data->heap_profiling_cleanup_);
854+
delete binding_data->heap_profiling_cleanup_;
855+
}
856+
auto* cleanup = new HeapProfilingCleanup{isolate};
857+
env->AddCleanupHook(CleanupHeapProfiling, cleanup);
858+
binding_data->heap_profiling_cleanup_ = cleanup;
859+
}
860+
861+
void StopSamplingHeapProfiler(const FunctionCallbackInfo<Value>& args) {
862+
Environment* env = Environment::GetCurrent(args);
863+
BindingData* binding_data = Realm::GetBindingData<BindingData>(args);
864+
865+
if (binding_data->heap_profiling_cleanup_ != nullptr) {
866+
binding_data->heap_profiling_cleanup_->DoCleanup();
867+
env->RemoveCleanupHook(
868+
CleanupHeapProfiling, binding_data->heap_profiling_cleanup_);
869+
delete binding_data->heap_profiling_cleanup_;
870+
binding_data->heap_profiling_cleanup_ = nullptr;
871+
}
872+
}
873+
874+
void GetAllocationProfile(const FunctionCallbackInfo<Value>& args) {
875+
Isolate* isolate = args.GetIsolate();
876+
HeapProfiler* profiler = isolate->GetHeapProfiler();
877+
HandleScope scope(isolate);
878+
Local<Context> context = isolate->GetCurrentContext();
879+
880+
std::unique_ptr<AllocationProfile> profile(profiler->GetAllocationProfile());
881+
if (!profile) {
882+
return; // Returns undefined if profiler not started
883+
}
884+
885+
const std::vector<AllocationProfile::Sample>& samples = profile->GetSamples();
886+
Local<Array> js_samples = Array::New(isolate, samples.size());
887+
888+
for (size_t i = 0; i < samples.size(); i++) {
889+
const AllocationProfile::Sample& sample = samples[i];
890+
Local<Object> js_sample = Object::New(isolate);
891+
892+
if (js_sample->Set(context,
893+
FIXED_ONE_BYTE_STRING(isolate, "nodeId"),
894+
Integer::NewFromUnsigned(isolate, sample.node_id))
895+
.IsNothing()) return;
896+
if (js_sample->Set(context,
897+
FIXED_ONE_BYTE_STRING(isolate, "size"),
898+
Number::New(isolate, static_cast<double>(sample.size)))
899+
.IsNothing()) return;
900+
if (js_sample->Set(context,
901+
FIXED_ONE_BYTE_STRING(isolate, "count"),
902+
Integer::NewFromUnsigned(isolate, sample.count))
903+
.IsNothing()) return;
904+
if (js_sample->Set(context,
905+
FIXED_ONE_BYTE_STRING(isolate, "sampleId"),
906+
Number::New(isolate, static_cast<double>(sample.sample_id)))
907+
.IsNothing()) return;
908+
909+
#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS
910+
// Always emit labels field (empty {} when no labels captured)
911+
Local<Object> js_labels = Object::New(isolate);
912+
for (const auto& label : sample.labels) {
913+
Local<String> key;
914+
if (!String::NewFromUtf8(isolate, label.first.c_str(),
915+
v8::NewStringType::kNormal)
916+
.ToLocal(&key)) return;
917+
Local<String> value;
918+
if (!String::NewFromUtf8(isolate, label.second.c_str(),
919+
v8::NewStringType::kNormal)
920+
.ToLocal(&value)) return;
921+
if (js_labels->Set(context, key, value).IsNothing()) return;
922+
}
923+
if (js_sample->Set(context,
924+
FIXED_ONE_BYTE_STRING(isolate, "labels"),
925+
js_labels).IsNothing()) return;
926+
#endif // V8_HEAP_PROFILER_SAMPLE_LABELS
927+
928+
if (js_samples->Set(context, i, js_sample).IsNothing()) return;
929+
}
930+
931+
Local<Object> result = Object::New(isolate);
932+
if (result->Set(context,
933+
FIXED_ONE_BYTE_STRING(isolate, "samples"),
934+
js_samples).IsNothing()) return;
935+
936+
args.GetReturnValue().Set(result);
937+
}
938+
676939
void Initialize(Local<Object> target,
677940
Local<Value> unused,
678941
Local<Context> context,
@@ -741,6 +1004,18 @@ void Initialize(Local<Object> target,
7411004
SetMethod(context, target, "startCpuProfile", StartCpuProfile);
7421005
SetMethod(context, target, "stopCpuProfile", StopCpuProfile);
7431006

1007+
// Sampling heap profiler with context support
1008+
SetMethod(context, target, "startSamplingHeapProfiler",
1009+
StartSamplingHeapProfiler);
1010+
SetMethod(context, target, "stopSamplingHeapProfiler",
1011+
StopSamplingHeapProfiler);
1012+
SetMethod(context, target, "getAllocationProfile",
1013+
GetAllocationProfile);
1014+
#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS
1015+
SetMethod(context, target, "setHeapProfileLabelsStore",
1016+
SetHeapProfileLabelsStore);
1017+
#endif // V8_HEAP_PROFILER_SAMPLE_LABELS
1018+
7441019
// Export symbols used by v8.isStringOneByteRepresentation()
7451020
SetFastMethodNoSideEffect(context,
7461021
target,
@@ -787,6 +1062,12 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
7871062
registry->Register(fast_is_string_one_byte_representation_);
7881063
registry->Register(StartCpuProfile);
7891064
registry->Register(StopCpuProfile);
1065+
registry->Register(StartSamplingHeapProfiler);
1066+
registry->Register(StopSamplingHeapProfiler);
1067+
registry->Register(GetAllocationProfile);
1068+
#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS
1069+
registry->Register(SetHeapProfileLabelsStore);
1070+
#endif // V8_HEAP_PROFILER_SAMPLE_LABELS
7901071
}
7911072

7921073
} // namespace v8_utils

0 commit comments

Comments
 (0)