From c5d83e4274236106f157093f765ceef6847eac0e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:19:24 +0000 Subject: [PATCH 1/2] test(http2): do not stop collecting while a JIT compile plan roots streams The release cases in h2-conformance.test.ts fail on a debug build with `Expected: <= 3, Received: 4` (or 5). A heap snapshot shows the surviving streams as direct roots with reason JITWorkList, not as leaks. A DFG compile plan roots the arguments of the call that started it (the stream, as `this`). A finished plan stays in the worklist until the main thread reaches its next tier-up check. In liveCount() that check comes at about pass 11, one pass after the 10-pass plateau exit gives up. liveCount() now stops as soon as the count is within GC_STRAGGLERS and does not stop on a plateau. It still stops after 10 passes that collected nothing at all, so the leak these cases guard against fails fast with `Received: 16`. --- test/js/node/http2/h2-conformance.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/test/js/node/http2/h2-conformance.test.ts b/test/js/node/http2/h2-conformance.test.ts index 414bf6c3b68b..f6e7f2f2a07a 100644 --- a/test/js/node/http2/h2-conformance.test.ts +++ b/test/js/node/http2/h2-conformance.test.ts @@ -1297,8 +1297,13 @@ describe("request pseudo-header requirements (RFC 9113 ยง8.3.1)", () => { const GC_STRAGGLERS = 3; /** - * Collects until every one of `refs` is gone, giving up after 50 passes or once the count has not - * moved for 10 passes, and resolves to how many survived. + * Collects until no more than GC_STRAGGLERS of `refs` are left, giving up after 50 passes, or after + * 10 that collected nothing at all (the leak itself), and resolves to how many survived. + * + * A count that holds steady above GC_STRAGGLERS is no reason to stop. A concurrent JIT compile roots + * the arguments of the call that started it (a stream, as `this`) until the main thread installs the + * result, which it only does at its next tier-up check. On a debug build, where a compile takes + * seconds, 4 to 9 streams stay rooted that way for a dozen passes before they go. */ async function liveCount(refs: WeakRef[]): Promise { const live = () => refs.filter(ref => ref.deref() !== undefined).length; @@ -1306,12 +1311,11 @@ async function liveCount(refs: WeakRef[]): Promise { // destroy), and a WeakRef target survives the job that dereferenced it, so every pass gets a // fresh turn before collecting. let last = live(); - for (let pass = 0, stuck = 0; pass < 50 && last > 0 && stuck < 10; pass++) { + for (let pass = 0; pass < 50 && last > GC_STRAGGLERS; pass++) { + if (pass === 10 && last === refs.length) break; await new Promise(resolve => setImmediate(resolve)); await gcTick(); - const now = live(); - stuck = now === last ? stuck + 1 : 0; - last = now; + last = live(); } return last; } From a91069632163d915b4ea6ef6a40a13abf6a2f73d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:54:13 +0000 Subject: [PATCH 2/2] ci: retrigger