`node:diagnostics_channel`: a subscribed channel can be garbage-collected, and a dead channel's finalizer removes a live channel of the same name
What version of Bun is running?
1.4.2
What platform is your computer?
Linux x64 (Cloud Run, gVisor) and Linux arm64 (Docker), macOS arm64 — all reproduce.
What steps can reproduce the bug?
Bug 1 — a subscribed channel is collectable. In Node, subscribe() makes the registry's WeakReference strong (IncRef → ClearWeak), so a subscribed channel lives as long as its subscription. In Bun, WeakReference.incRef() only increments a counter, so the channel is kept alive only by whoever holds the object. If the subscriber's module does not keep a reference (the common dc.subscribe(name, fn) / dc.channel(name).subscribe(fn) shape), the next GC collects it, the FinalizationRegistry callback deletes the map entry, and every later dc.channel(name) returns a fresh channel with no subscribers. publish() then reaches nobody.
// bug1.mjs — run with: bun bug1.mjs (node: node --expose-gc bug1.mjs)
import dc from "node:diagnostics_channel";
const gc = globalThis.Bun ? () => Bun.gc(true) : globalThis.gc;
let received = 0;
dc.subscribe("my:channel", () => { received++; }); // nothing else references the channel
await new Promise((r) => setTimeout(r, 0));
gc(); gc();
await new Promise((r) => setTimeout(r, 50)); // let finalizers run
dc.channel("my:channel").publish({});
console.log({ hasSubscribers: dc.hasSubscribers("my:channel"), received });
// Bun 1.4.2: { hasSubscribers: false, received: 0 }
// Node 26: { hasSubscribers: true, received: 1 }
Bug 2 — a dead channel's finalizer deletes its live successor. The WeakRefMap finalizer does this.delete(key) without checking that the entry still refers to the finalized object. If a channel died and, before its finalizer ran, a new channel was created under the same name and held strongly, the pending finalizer removes the new channel's registry entry. From then on lookups by name return yet another fresh channel while the held one, still alive and subscribed, is unreachable by name.
// bug2.mjs
import dc from "node:diagnostics_channel";
const gc = globalThis.Bun ? () => Bun.gc(true) : globalThis.gc;
(function () { dc.channel("my:channel"); })(); // created, immediately unreferenced
await new Promise((r) => setTimeout(r, 0));
gc(); gc(); // collected; finalizer now pending
const held = dc.channel("my:channel"); // fresh channel, strongly held
held.subscribe(() => {});
await new Promise((r) => setTimeout(r, 50)); // pending finalizer runs
console.log({ sameObject: dc.channel("my:channel") === held, hasSubscribers: dc.hasSubscribers("my:channel"), heldHasSubscribers: held.hasSubscribers });
// Bun 1.4.2: { sameObject: false, hasSubscribers: false, heldHasSubscribers: true }
// Node 26: { sameObject: true, hasSubscribers: true, heldHasSubscribers: true }
What is the expected behavior?
Both scripts should print the Node output. Node keeps a subscribed channel alive for the lifetime of the subscription, and a finalizer only removes the entry for the object that actually died.
What do you see instead?
Subscriptions silently vanish after the first GC. Real-world impact: dd-trace's esbuild integration hooks bundled modules by publishing on dd-trace:bundler:load; on a 2–3 s Cloud Run cold start every hook was lost (no express/router/pg/winston spans) while a fast local boot worked, because the GC happened to land before or after the publishes.
Additional information
Suggested fix, mirroring Node: have WeakReference.incRef() take a strong reference to the target (e.g. store this.deref() in a field while #refs > 0, clear it in decRef() when it reaches 0), and make the FinalizationRegistry callback only delete when the entry's deref() is undefined, or register with a unique token per channel and compare.
Source: oven-sh/bun