Commit 45ae12c3aab for nodejs

commit 45ae12c3aabf3afe2343e637f338b0e964af3cc5
Author: Trevor Burnham <trevor@databraid.com>
Date:   Sun Oct 25 03:24:48 2026 -0400

    diagnostics_channel: fix dangling binding pointer

    `Channel` reads its subscriber count through a raw `BindingData*` that
    was never cleared, so any native holder that outlives environment
    cleanup reads a destroyed object. The null check in `HasSubscribers()`
    could not fire, because the pointer was only ever assigned in the
    constructor.

    `node:sqlite` holds a strong `BaseObjectPtr<Channel>` for the lifetime
    of a `Database`, which made this reachable from ordinary
    JavaScript. A statement left mid-step at exit is finalized by the
    destructor chain after `Environment::RunCleanup()` has destroyed the
    binding, and `sqlite3_finalize()` invokes the profile callback for such
    a statement. The result was a segfault at normal process exit; inside a
    worker it took down the whole process.

    Clear `binding_data_` on every `Channel` the binding owns whenever it
    gives up that ownership, both in the destructor and in
    `PrepareForSerialization()`, so that the existing null check in
    `HasSubscribers()` does its job. The second check in `Publish()` is now
    unreachable and is dropped. This protects any holder that is itself a
    `BaseObject`, and so is destroyed later in the same cleanup. A holder
    that is not a `BaseObject` still needs a cleanup hook or a weak
    reference, because `Realm::~Realm()` checks that no `BaseObject`s
    remain.

    On the `node:sqlite` side, switch `Database::trace_channel_` to a
    `BaseObjectWeakPtr`, so that it follows the same convention `permission`
    documents, where `BindingData` is the sole owner of channels.
    `TraceCallback` already null-checks, so this needs no other change
    there.

    Also check `AreTraceEventsSuppressed()` before the channel in
    `TraceCallback()`, so that a suppressed callback does not dereference it
    at all. `Statement::Finalize()` already suppresses trace events, so
    the reported path was meant to be a no-op; only the order of the `||`
    operands took it through the channel first.

    Fixes: https://github.com/nodejs/node/issues/65858
    Assisted-by: Claude Opus 5
    Signed-off-by: Trevor Burnham <trevorburnham@gmail.com>
    PR-URL: https://github.com/nodejs/node/pull/65860
    Reviewed-By: Bryan English <bryan@bryanenglish.com>
    Reviewed-By: Gerhard Stöbich <deb2001-github@yahoo.de>

diff --git a/src/node_diagnostics_channel.cc b/src/node_diagnostics_channel.cc
index 2593f6eab90..2cc3309542f 100644
--- a/src/node_diagnostics_channel.cc
+++ b/src/node_diagnostics_channel.cc
@@ -44,6 +44,21 @@ BindingData::BindingData(Realm* realm,
   subscribers_.MakeWeak();
 }

+void BindingData::DetachChannels() {
+  // A native holder may keep a strong reference to a Channel that outlives this
+  // binding's ownership of it, and the binding is destroyed during environment
+  // cleanup while BaseObject destructors still run after it. Drop the
+  // back-pointer so that the null check in HasSubscribers() stops those holders
+  // from reading the subscribers_ array of a binding that no longer owns them.
+  for (auto& channel : channels_) {
+    if (channel) channel->binding_data_ = nullptr;
+  }
+}
+
+BindingData::~BindingData() {
+  DetachChannels();
+}
+
 void BindingData::MemoryInfo(MemoryTracker* tracker) const {
   tracker->TrackField("subscribers", subscribers_);
 }
@@ -104,6 +119,7 @@ bool BindingData::PrepareForSerialization(Local<Context> context,
   internal_field_info_->subscribers_capacity = subscribers_.Length();
   link_callback_.Reset();
   channel_wrap_template_.Reset();
+  DetachChannels();
   channels_.clear();
   return true;
 }
@@ -281,8 +297,6 @@ void Channel::CachePublishFn(Isolate* isolate, Local<Object> js_channel) {
 void Channel::Publish(Environment* env, Local<Value> message) {
   if (!HasSubscribers()) return;

-  if (binding_data_ == nullptr) return;
-
   if (js_channel_.IsEmpty()) return;

   // Publishing is not possible during shutdown or GC.
diff --git a/src/node_diagnostics_channel.h b/src/node_diagnostics_channel.h
index c8c1a79994b..b1da5fcee3d 100644
--- a/src/node_diagnostics_channel.h
+++ b/src/node_diagnostics_channel.h
@@ -31,6 +31,7 @@ class BindingData : public SnapshotableObject {
   BindingData(Realm* realm,
               v8::Local<v8::Object> wrap,
               InternalFieldInfo* info = nullptr);
+  ~BindingData() override;

   SERIALIZABLE_OBJECT_METHODS()
   SET_BINDING_ID(diagnostics_channel_binding_data)
@@ -70,6 +71,10 @@ class BindingData : public SnapshotableObject {
   static void RegisterExternalReferences(ExternalReferenceRegistry* registry);

  private:
+  // Clears the back-pointer of every Channel in channels_, so that a Channel
+  // this binding no longer owns cannot read through it.
+  void DetachChannels();
+
   InternalFieldInfo* internal_field_info_ = nullptr;
   std::unordered_map<uint32_t, ChannelStatusCallback> channel_status_callbacks_;
 };
diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc
index 2c1bba2c9b4..d717745b2c3 100644
--- a/src/node_sqlite.cc
+++ b/src/node_sqlite.cc
@@ -3424,8 +3424,11 @@ int Database::TraceCallback(unsigned int type,
   Environment* env = db->env();

   diagnostics_channel::Channel* ch = db->trace_channel_.get();
-  if (ch == nullptr || !ch->HasSubscribers() ||
-      db->AreTraceEventsSuppressed()) {
+  // Checked before the channel, so that a suppressed callback does not
+  // dereference it at all. Statement finalization suppresses trace events, and
+  // that is the path SQLite takes during environment teardown.
+  if (db->AreTraceEventsSuppressed() || ch == nullptr ||
+      !ch->HasSubscribers()) {
     return 0;
   }

diff --git a/src/node_sqlite.h b/src/node_sqlite.h
index 784f957acaf..c4d7e55547d 100644
--- a/src/node_sqlite.h
+++ b/src/node_sqlite.h
@@ -354,7 +354,9 @@ class Database : public BaseObject {
   std::set<BackupJob*> backups_;
   std::unordered_set<Session*> sessions_;
   std::unordered_set<Statement*> statements_;
-  BaseObjectPtr<diagnostics_channel::Channel> trace_channel_;
+  // Weak ref: BindingData is the sole owner of Channels, so a strong ref here
+  // would outlive the binding that backs it during environment cleanup.
+  BaseObjectWeakPtr<diagnostics_channel::Channel> trace_channel_;

   friend class UserDefinedFunction;
   friend class CustomAggregate;
diff --git a/test/cctest/test_diagnostics_channel.cc b/test/cctest/test_diagnostics_channel.cc
index d4d1fd1c9fa..6932a27640e 100644
--- a/test/cctest/test_diagnostics_channel.cc
+++ b/test/cctest/test_diagnostics_channel.cc
@@ -1,5 +1,6 @@
 #include "node_diagnostics_channel.h"

+#include "base_object-inl.h"
 #include "gtest/gtest.h"
 #include "node_test_fixture.h"

@@ -302,3 +303,75 @@ TEST_F(DiagnosticsChannelTest, NativeChannelsGrowSubscriberStorage) {
         "                          globalThis.__lastSubscriber);");
   EXPECT_TRUE(last->HasSubscribers());
 }
+
+// Mirrors how node:sqlite holds a Channel: a strong reference kept for the
+// lifetime of a BaseObject, which is destroyed during environment cleanup
+// after the BindingData that owns the channel is already gone.
+class ChannelHolder : public node::BaseObject {
+ public:
+  ChannelHolder(node::Environment* env,
+                v8::Local<v8::Object> obj,
+                BaseObjectPtr<Channel> channel)
+      : BaseObject(env, obj), channel_(std::move(channel)) {}
+
+  ~ChannelHolder() override {
+    destroyed = true;
+    // Would read the destroyed BindingData if the back-pointer were stale.
+    had_subscribers = channel_->HasSubscribers();
+  }
+
+  static bool destroyed;
+  static bool had_subscribers;
+
+  SET_NO_MEMORY_INFO()
+  SET_MEMORY_INFO_NAME(ChannelHolder)
+  SET_SELF_SIZE(ChannelHolder)
+
+ private:
+  BaseObjectPtr<Channel> channel_;
+};
+
+bool ChannelHolder::destroyed = false;
+bool ChannelHolder::had_subscribers = false;
+
+// A Channel whose holder outlives the BindingData must report no subscribers
+// rather than reading the destroyed binding's subscriber array.
+TEST_F(DiagnosticsChannelTest, ChannelOutlivingBindingHasNoSubscribers) {
+  const v8::HandleScope handle_scope(isolate_);
+  Argv argv;
+
+  ChannelHolder::destroyed = false;
+  ChannelHolder::had_subscribers = false;
+
+  {
+    Env env{handle_scope, argv};
+
+    SetProcessExitHandler(*env, [&](node::Environment* env_, int exit_code) {
+      EXPECT_EQ(exit_code, 0);
+      node::Stop(*env);
+    });
+
+    node::LoadEnvironment(
+        *env,
+        "const dc = require('diagnostics_channel');"
+        "dc.subscribe('test:cctest:outlives-binding', () => {});");
+
+    auto channel = Channel::Get(*env, "test:cctest:outlives-binding");
+    ASSERT_TRUE(channel);
+    ASSERT_TRUE(channel->HasSubscribers());
+
+    v8::Local<v8::Context> context = (*env)->context();
+    v8::Local<v8::Object> obj =
+        node::BaseObject::MakeLazilyInitializedJSTemplate(*env)
+            ->GetFunction(context)
+            .ToLocalChecked()
+            ->NewInstance(context)
+            .ToLocalChecked();
+    // MakeBaseObject leaves the holder a strong root, so it survives until the
+    // environment is torn down, like a DatabaseSync still reachable at exit.
+    node::MakeBaseObject<ChannelHolder>(*env, obj, channel);
+  }
+
+  EXPECT_TRUE(ChannelHolder::destroyed);
+  EXPECT_FALSE(ChannelHolder::had_subscribers);
+}
diff --git a/test/parallel/test-sqlite-diagnostic-channel-exit.js b/test/parallel/test-sqlite-diagnostic-channel-exit.js
new file mode 100644
index 00000000000..9f754522e96
--- /dev/null
+++ b/test/parallel/test-sqlite-diagnostic-channel-exit.js
@@ -0,0 +1,51 @@
+'use strict';
+
+// A statement left unfinished at exit is finalized by the destructor chain,
+// which runs after the diagnostics channel binding has been destroyed. SQLite
+// invokes the profile callback for such a statement, so the channel must not
+// read through the destroyed binding.
+
+const common = require('../common');
+common.skipIfSQLiteMissing();
+
+const { spawnSyncAndExitWithoutError } = require('../common/child_process');
+
+// Stays reachable for the rest of the process so the statement is finalized
+// during teardown rather than by the garbage collector.
+const keepAlive = [];
+
+function leaveStatementUnfinished() {
+  const dc = require('node:diagnostics_channel');
+  const { Database } = require('node:sqlite');
+
+  dc.subscribe('sqlite.db.query', () => {});
+
+  const db = new Database(':memory:');
+  db.exec('CREATE TABLE t (x INTEGER)');
+  const insert = db.prepare('INSERT INTO t VALUES (?)');
+  for (let i = 0; i < 200; i++) insert.run(i);
+
+  const iterator = db.prepare('SELECT x FROM t').iterate();
+  iterator.next();
+  keepAlive.push({ db, iterator });
+}
+
+switch (process.argv[2]) {
+  case 'main':
+    leaveStatementUnfinished();
+    break;
+
+  case 'worker': {
+    const { Worker, isMainThread } = require('node:worker_threads');
+    if (isMainThread) {
+      new Worker(__filename, { argv: ['worker'] });
+    } else {
+      leaveStatementUnfinished();
+    }
+    break;
+  }
+
+  default:
+    spawnSyncAndExitWithoutError(process.execPath, [__filename, 'main']);
+    spawnSyncAndExitWithoutError(process.execPath, [__filename, 'worker']);
+}