Commit bca9bbee9dc for nodejs

commit bca9bbee9dc05b81c504c8afb7f637694e63a18c
Author: Tim Perry <pimterry@gmail.com>
Date:   Sat Sep 19 20:48:22 2026 +0200

    quic: cleanly tear down QUIC after worker.terminate()

    Signed-off-by: Tim Perry <pimterry@gmail.com>
    PR-URL: https://github.com/nodejs/node/pull/66093
    Reviewed-By: Xuguang Mei <meixuguang@gmail.com>
    Reviewed-By: James M Snell <jasnell@gmail.com>

diff --git a/src/quic/bindingdata.cc b/src/quic/bindingdata.cc
index d5711d88cb9..e73ae82e980 100644
--- a/src/quic/bindingdata.cc
+++ b/src/quic/bindingdata.cc
@@ -353,6 +353,14 @@ BindingData::BindingData(Realm* realm, Local<Object> object)
   MakeWeak();
   // Unref so the check handle doesn't keep the event loop alive on its own.
   flush_check_.Unref();
+  // Ensure Clean() below is called before the tearing anything down.
+  env()->cleanable_queue()->PushFront(this);
+}
+
+void BindingData::Clean() {
+  // Make sure sessions are always properly destroyed. This does nothing in
+  // a clean shutdown, but is required for cases like worker.terminate().
+  if (session_manager_) session_manager_->DestroyAllSessions();
 }

 SessionManager& BindingData::session_manager() {
diff --git a/src/quic/bindingdata.h b/src/quic/bindingdata.h
index 96ac6a34918..2ef9f768531 100644
--- a/src/quic/bindingdata.h
+++ b/src/quic/bindingdata.h
@@ -261,6 +261,7 @@ class CheckWrapHandle : public MemoryRetainer {
 // TODO(@jasnell): Make this snapshotable?
 class BindingData final
     : public BaseObject,
+      public Cleanable,
       public mem::NgLibMemoryManager<BindingData, ngtcp2_mem> {
  public:
   SET_BINDING_ID(quic_binding_data)
@@ -392,6 +393,9 @@ class BindingData final
   bool flush_check_started_ = false;

   void OnFlushCheck();
+
+ private:
+  void Clean() override;
 };

 JS_METHOD_IMPL(IllegalConstructor);
diff --git a/test/parallel/test-quic-worker-terminate.mjs b/test/parallel/test-quic-worker-terminate.mjs
new file mode 100644
index 00000000000..edd7b2186c7
--- /dev/null
+++ b/test/parallel/test-quic-worker-terminate.mjs
@@ -0,0 +1,46 @@
+// Flags: --experimental-quic --no-warnings
+
+// Test: terminating a worker thread that still holds live QUIC sessions.
+//
+// worker.terminate() tears the environment down without running any of the
+// JavaScript close paths, so the sessions are still open when the QUIC
+// binding is cleaned up. Sessions must be properly destroyed before reaching
+// ~Session.
+
+import { hasQuic, skip, mustCall } from '../common/index.mjs';
+import assert from 'node:assert';
+import { Worker, isMainThread, parentPort } from 'node:worker_threads';
+
+if (!hasQuic) {
+  skip('QUIC is not enabled');
+}
+
+const { listen, connect } = await import('../common/quic.mjs');
+
+// Launch a client and server in a worker thread, then kill it:
+if (!isMainThread) {
+  // A client and a server session, both with an open stream, and neither
+  // closed. The worker then parks forever waiting to be terminated.
+  const serverEndpoint = await listen((session) => {
+    session.closed.catch(() => {});
+    session.onstream = (stream) => { stream.closed.catch(() => {}); };
+  });
+
+  const clientSession = await connect(serverEndpoint.address);
+  clientSession.closed.catch(() => {});
+  await clientSession.opened;
+  const stream = await clientSession.createBidirectionalStream({
+    body: new Uint8Array(1),
+  });
+  stream.closed.catch(() => {});
+
+  parentPort.postMessage('ready');
+  await new Promise(() => {});
+} else {
+  const worker = new Worker(new URL(import.meta.url));
+  worker.on('error', (err) => { assert.fail(err); });
+  worker.on('message', mustCall(async (message) => {
+    assert.strictEqual(message, 'ready');
+    assert.strictEqual(await worker.terminate(), 1);
+  }));
+}