Commit 10559877f4c for nodejs

commit 10559877f4c0b9a30f764c2de1b3ca38b8b0a55f
Author: James M Snell <jasnell@gmail.com>
Date:   Thu Sep 17 23:16:56 2026 +0000

    perf_hooks: track GC callback installation natively

    Whether the V8 GC callbacks used for `'gc'` performance entries were
    installed was tracked by a boolean in JavaScript, separately from the
    native state, and the two could get out of sync. After deserializing a
    user-land snapshot built while a `'gc'` PerformanceObserver was active,
    the boolean claimed the callbacks were installed, although V8 GC
    callbacks do not survive a snapshot. Observing `'gc'` again did not
    install them, and disconnecting removed callbacks that had never been
    registered, crashing the process.

    Track the installation state in `PerformanceState` instead, and replace
    the install and remove bindings with a single idempotent
    `updateGarbageCollectionTracking()` binding, which registers the
    callbacks if and only if there are `'gc'` observers.

    Assisted-by: OpenCode
    Signed-off-by: James M Snell <jasnell@gmail.com>
    PR-URL: https://github.com/nodejs/node/pull/66097
    Reviewed-By: Xuguang Mei <meixuguang@gmail.com>
    Reviewed-By: Matteo Collina <matteo.collina@gmail.com>

diff --git a/lib/internal/perf/observe.js b/lib/internal/perf/observe.js
index 3ed9fd3fa73..a4a962a8537 100644
--- a/lib/internal/perf/observe.js
+++ b/lib/internal/perf/observe.js
@@ -29,10 +29,9 @@ const {
     NODE_PERFORMANCE_ENTRY_TYPE_DNS,
     NODE_PERFORMANCE_ENTRY_TYPE_QUIC,
   },
-  installGarbageCollectionTracking,
   observerCounts,
-  removeGarbageCollectionTracking,
   setupObservers,
+  updateGarbageCollectionTracking,
 } = internalBinding('performance');

 const {
@@ -77,8 +76,6 @@ const kMaybeBuffer = Symbol('kMaybeBuffer');
 const kTypeSingle = 0;
 const kTypeMultiple = 1;

-let gcTrackingInstalled = false;
-
 const kSupportedEntryTypes = ObjectFreeze([
   'dns',
   'function',
@@ -144,10 +141,9 @@ function maybeDecrementObserverCounts(entryTypes) {
     if (observerType !== undefined) {
       observerCounts[observerType]--;

-      if (observerType === NODE_PERFORMANCE_ENTRY_TYPE_GC &&
-          observerCounts[observerType] === 0) {
-        removeGarbageCollectionTracking();
-        gcTrackingInstalled = false;
+      // Removes the GC callbacks once the last 'gc' observer is gone.
+      if (observerType === NODE_PERFORMANCE_ENTRY_TYPE_GC) {
+        updateGarbageCollectionTracking();
       }
     }
   }
@@ -158,10 +154,10 @@ function maybeIncrementObserverCount(type) {

   if (observerType !== undefined) {
     observerCounts[observerType]++;
-    if (!gcTrackingInstalled &&
-        observerType === NODE_PERFORMANCE_ENTRY_TYPE_GC) {
-      installGarbageCollectionTracking();
-      gcTrackingInstalled = true;
+    // Installs the GC callbacks if they are not installed yet. This is
+    // idempotent, so it is called whenever the 'gc' observer count changes.
+    if (observerType === NODE_PERFORMANCE_ENTRY_TYPE_GC) {
+      updateGarbageCollectionTracking();
     }
   }
 }
diff --git a/src/node_perf.cc b/src/node_perf.cc
index fea9c8e7cde..381a8bdf91e 100644
--- a/src/node_perf.cc
+++ b/src/node_perf.cc
@@ -239,30 +239,41 @@ void MarkGarbageCollectionEnd(

 void GarbageCollectionCleanupHook(void* data) {
   Environment* env = static_cast<Environment*>(data);
+  PerformanceState* state = env->performance_state();
+  if (!state->gc_tracking_installed) return;
   // Reset current_gc_type to 0
-  env->performance_state()->current_gc_type = 0;
+  state->current_gc_type = 0;
   env->isolate()->RemoveGCPrologueCallback(MarkGarbageCollectionStart, data);
   env->isolate()->RemoveGCEpilogueCallback(MarkGarbageCollectionEnd, data);
+  state->gc_tracking_installed = false;
 }

-static void InstallGarbageCollectionTracking(
-    const FunctionCallbackInfo<Value>& args) {
-  Environment* env = Environment::GetCurrent(args);
-  // Reset current_gc_type to 0
-  env->performance_state()->current_gc_type = 0;
-  env->isolate()->AddGCPrologueCallback(MarkGarbageCollectionStart,
-                                        static_cast<void*>(env));
-  env->isolate()->AddGCEpilogueCallback(MarkGarbageCollectionEnd,
-                                        static_cast<void*>(env));
-  env->AddCleanupHook(GarbageCollectionCleanupHook, env);
+// Registers the GC callbacks with V8 if and only if GC timing is needed,
+// i.e. there are 'gc' PerformanceObservers. This is idempotent, so it never
+// adds the callbacks twice or removes callbacks that are not registered.
+static void ReconcileGarbageCollectionTracking(Environment* env) {
+  PerformanceState* state = env->performance_state();
+  const bool wanted = state->observers[NODE_PERFORMANCE_ENTRY_TYPE_GC] > 0;
+  if (wanted == state->gc_tracking_installed) return;
+
+  if (wanted) {
+    // Reset current_gc_type to 0
+    state->current_gc_type = 0;
+    env->isolate()->AddGCPrologueCallback(MarkGarbageCollectionStart,
+                                          static_cast<void*>(env));
+    env->isolate()->AddGCEpilogueCallback(MarkGarbageCollectionEnd,
+                                          static_cast<void*>(env));
+    env->AddCleanupHook(GarbageCollectionCleanupHook, env);
+    state->gc_tracking_installed = true;
+  } else {
+    env->RemoveCleanupHook(GarbageCollectionCleanupHook, env);
+    GarbageCollectionCleanupHook(env);
+  }
 }

-static void RemoveGarbageCollectionTracking(
-  const FunctionCallbackInfo<Value> &args) {
-  Environment* env = Environment::GetCurrent(args);
-
-  env->RemoveCleanupHook(GarbageCollectionCleanupHook, env);
-  GarbageCollectionCleanupHook(env);
+static void UpdateGarbageCollectionTracking(
+    const FunctionCallbackInfo<Value>& args) {
+  ReconcileGarbageCollectionTracking(Environment::GetCurrent(args));
 }

 // Notify a custom PerformanceEntry to observers
@@ -363,12 +374,8 @@ static void CreatePerIsolateProperties(IsolateData* isolate_data,
   SetMethod(isolate, target, "setupObservers", SetupPerformanceObservers);
   SetMethod(isolate,
             target,
-            "installGarbageCollectionTracking",
-            InstallGarbageCollectionTracking);
-  SetMethod(isolate,
-            target,
-            "removeGarbageCollectionTracking",
-            RemoveGarbageCollectionTracking);
+            "updateGarbageCollectionTracking",
+            UpdateGarbageCollectionTracking);
   SetMethod(isolate, target, "notify", Notify);
   SetMethod(isolate, target, "loopIdleTime", LoopIdleTime);
   SetMethod(isolate, target, "createELDHistogram", CreateELDHistogram);
@@ -445,8 +452,7 @@ void CreatePerContextProperties(Local<Object> target,

 void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
   registry->Register(SetupPerformanceObservers);
-  registry->Register(InstallGarbageCollectionTracking);
-  registry->Register(RemoveGarbageCollectionTracking);
+  registry->Register(UpdateGarbageCollectionTracking);
   registry->Register(Notify);
   registry->Register(LoopIdleTime);
   registry->Register(CreateELDHistogram);
diff --git a/src/node_perf_common.h b/src/node_perf_common.h
index 43ff1122d2a..80d6013f2e6 100644
--- a/src/node_perf_common.h
+++ b/src/node_perf_common.h
@@ -85,6 +85,9 @@ class PerformanceState {

   uint64_t performance_last_gc_start_mark = 0;
   uint16_t current_gc_type = 0;
+  // Whether MarkGarbageCollectionStart/End are registered with V8. This is
+  // not serialized, as V8 GC callbacks do not survive a snapshot.
+  bool gc_tracking_installed = false;

   void Mark(enum PerformanceMilestone milestone,
             uint64_t ts = PERFORMANCE_NOW());
diff --git a/test/fixtures/snapshot/perf-hooks-gc-observer.js b/test/fixtures/snapshot/perf-hooks-gc-observer.js
new file mode 100644
index 00000000000..cf98bf2692a
--- /dev/null
+++ b/test/fixtures/snapshot/perf-hooks-gc-observer.js
@@ -0,0 +1,40 @@
+'use strict';
+
+const { PerformanceObserver } = require('node:perf_hooks');
+const { setDeserializeMainFunction } = require('node:v8').startupSnapshot;
+
+// Observe 'gc' entries while building the snapshot.
+const observer = new PerformanceObserver(() => {});
+observer.observe({ type: 'gc' });
+
+// Performance entries are dispatched asynchronously, so trigger GCs until the
+// entries arrive.
+function waitForEntries(getCount, callback, attempts = 10) {
+  globalThis.gc();
+  setImmediate(() => {
+    if (getCount() > 0) {
+      callback();
+    } else if (attempts > 1) {
+      waitForEntries(getCount, callback, attempts - 1);
+    } else {
+      throw new Error('No gc entries were received after deserialization');
+    }
+  });
+}
+
+setDeserializeMainFunction(() => {
+  // The GC callbacks registered while building the snapshot do not survive
+  // it. Observing 'gc' after deserialization must register them again.
+  let received = 0;
+  const newObserver = new PerformanceObserver((list) => {
+    received += list.getEntries().length;
+  });
+  newObserver.observe({ type: 'gc' });
+
+  waitForEntries(() => received, () => {
+    // Disconnecting must only remove GC callbacks that are registered.
+    newObserver.disconnect();
+    observer.disconnect();
+    console.log('ok');
+  });
+});
diff --git a/test/parallel/test-snapshot-perf-hooks-gc-observer.js b/test/parallel/test-snapshot-perf-hooks-gc-observer.js
new file mode 100644
index 00000000000..13222665fe7
--- /dev/null
+++ b/test/parallel/test-snapshot-perf-hooks-gc-observer.js
@@ -0,0 +1,38 @@
+'use strict';
+
+// Tests that 'gc' PerformanceObservers work after deserializing a snapshot
+// that was built while a 'gc' PerformanceObserver was active, and that they
+// can be disconnected without crashing.
+
+require('../common');
+const tmpdir = require('../common/tmpdir');
+const fixtures = require('../common/fixtures');
+const {
+  spawnSyncAndAssert,
+  spawnSyncAndExitWithoutError,
+} = require('../common/child_process');
+
+tmpdir.refresh();
+const blobPath = tmpdir.resolve('snapshot.blob');
+const entry = fixtures.path('snapshot', 'perf-hooks-gc-observer.js');
+
+spawnSyncAndExitWithoutError(process.execPath, [
+  '--expose-gc',
+  '--snapshot-blob',
+  blobPath,
+  '--build-snapshot',
+  entry,
+], {
+  cwd: tmpdir.path,
+});
+
+spawnSyncAndAssert(process.execPath, [
+  '--expose-gc',
+  '--snapshot-blob',
+  blobPath,
+], {
+  cwd: tmpdir.path,
+}, {
+  stdout: 'ok',
+  trim: true,
+});
diff --git a/typings/internalBinding/performance.d.ts b/typings/internalBinding/performance.d.ts
index 4bd3a9b805e..da9af2df2cb 100644
--- a/typings/internalBinding/performance.d.ts
+++ b/typings/internalBinding/performance.d.ts
@@ -136,8 +136,7 @@ export interface PerformanceBinding {
   observerCounts: Uint32Array;
   milestones: Float64Array;
   setupObservers(callback: PerformanceObserverCallback): void;
-  installGarbageCollectionTracking(): void;
-  removeGarbageCollectionTracking(): void;
+  updateGarbageCollectionTracking(): void;
   notify(type: string, entry: unknown): void;
   loopIdleTime(): number;
   createELDHistogram(