Version
v26.10.0 and v26.11.1.
Platform
Linux arm64.
Subsystem
lib (abortcontroller)
What steps will reproduce the bug?
Run node --expose-gc repro.mjs:
// node --expose-gc e2.mjs
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
for (const combine of [false, true]) {
const refs = [];
let fired = Array.from({ length: 200 }, () => AbortSignal.timeout(1));
for (const signal of fired) refs.push(new WeakRef(signal));
await sleep(50);
if (combine) for (const signal of fired) AbortSignal.any([signal]);
fired = undefined;
for (let round = 0; round < 3; round++) { await sleep(10); globalThis.gc(); }
console.log(`combine=${combine}: ${refs.filter(ref => ref.deref() !== undefined).length}/200 fired timeouts alive`);
}
How often does it reproduce? Is there a required condition?
Always. The timeout must have fired before it is passed to AbortSignal.any().
What is the expected behavior? Why is that the expected behavior?
combine=true: 1/200 fired timeouts alive, the same as combine=false. A composite of a signal that has already aborted is aborted at once, and nothing is left to wait for, so nothing should keep the timeout alive.
What do you see instead?
combine=true: 200/200 fired timeouts alive: every fired timeout passed to AbortSignal.any() stays reachable after collection.
Additional information
It looks as if the timeout is added to gcPersistentSignals (#57867) and never removed, since it will never fire again. Passing the same fired timeout to new Request(url, { signal }) doesn't keep it alive.
Version
v26.10.0 and v26.11.1.
Platform
Linux arm64.
Subsystem
lib (abortcontroller)
What steps will reproduce the bug?
Run
node --expose-gc repro.mjs:How often does it reproduce? Is there a required condition?
Always. The timeout must have fired before it is passed to
AbortSignal.any().What is the expected behavior? Why is that the expected behavior?
combine=true: 1/200 fired timeouts alive, the same ascombine=false. A composite of a signal that has already aborted is aborted at once, and nothing is left to wait for, so nothing should keep the timeout alive.What do you see instead?
combine=true: 200/200 fired timeouts alive: every fired timeout passed toAbortSignal.any()stays reachable after collection.Additional information
It looks as if the timeout is added to
gcPersistentSignals(#57867) and never removed, since it will never fire again. Passing the same fired timeout tonew Request(url, { signal })doesn't keep it alive.