From 3d1601595e305c6626d0847cccfe5d7368e4f551 Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 7 Oct 2026 22:10:24 +0200 Subject: [PATCH] u --- test/capture.mjs | 60 +++++++++++++++++++++++++++++++++++++++++++++--- test/cdp.mjs | 17 +++++++++++--- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/test/capture.mjs b/test/capture.mjs index 3e5cbe3..bfce082 100644 --- a/test/capture.mjs +++ b/test/capture.mjs @@ -65,7 +65,12 @@ function startProc(cmd, procArgs, env, tag) { cwd: FRONT_ROOT, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'], + // Own process group per child so stopProc can kill the whole tree + // (e.g. `npx vite` wrapper + the real vite process). + detached: process.platform !== 'win32', }); + // Piped stdio keeps the parent alive even after kill(); detach the streams + // so teardown below can actually exit. child.stdout.on('data', (d) => process.stdout.write(`[${tag}] ${d}`)); child.stderr.on('data', (d) => { const text = String(d); @@ -73,9 +78,55 @@ function startProc(cmd, procArgs, env, tag) { if (tag === 'vite' && text.includes('[vite-proxy]')) return; process.stderr.write(`[${tag}] ${d}`); }); + child.stdout.unref?.(); + child.stderr.unref?.(); + child.unref?.(); return child; } +async function stopProc(child, tag, timeoutMs = 8000) { + if (!child || child.exitCode != null || child.signalCode != null) return; + // Kill the whole process group (child + grandchildren like npx/vite). + const killTree = (sig) => { + try { + if (child.pid && process.platform !== 'win32') { + process.kill(-child.pid, sig); + return; + } + } catch { + // fall through to single kill + } + try { + child.kill(sig); + } catch { + // already gone + } + }; + await new Promise((resolve) => { + const timer = setTimeout(() => { + killTree('SIGKILL'); + resolve(); + }, timeoutMs); + timer.unref?.(); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + killTree('SIGTERM'); + }); + // Drain any buffered pipe listeners so they can't hold the loop open. + try { + child.stdout?.destroy(); + } catch { + // ignore + } + try { + child.stderr?.destroy(); + } catch { + // ignore + } +} + async function main() { if (!fs.existsSync(CHROME)) { throw new Error(`Chrome not found at ${CHROME} (pass --chrome=...)`); @@ -135,15 +186,18 @@ async function main() { } } } finally { - chrome.kill(); - vite.kill(); - dummy.kill(); + await stopProc(chrome, 'chrome'); + await stopProc(vite, 'vite'); + await stopProc(dummy, 'dummy'); } if (failed > 0) { console.error(`[capture] ${failed} shots failed`); process.exit(1); } console.log(`[capture] done → ${OUT_DIR}`); + // Explicit exit: spawned grandchildren (vite/esbuild, chrome helpers) can + // otherwise keep the event loop alive after teardown. + process.exit(0); } main().catch((err) => { diff --git a/test/cdp.mjs b/test/cdp.mjs index 4cb4bdf..dab99a1 100644 --- a/test/cdp.mjs +++ b/test/cdp.mjs @@ -89,14 +89,25 @@ class CdpConnection { send(method, params = {}, timeoutMs = 30000) { const callId = ++this.id; return new Promise((resolve, reject) => { - this.pending.set(callId, { resolve, reject }); - this.sendFrame(0x1, Buffer.from(JSON.stringify({ id: callId, method, params }))); - setTimeout(() => { + const timer = setTimeout(() => { if (this.pending.has(callId)) { this.pending.delete(callId); reject(new Error(`CDP timeout: ${method}`)); } }, timeoutMs); + // Don't let a settled call's timer hold the process open. + timer.unref?.(); + this.pending.set(callId, { + resolve: (v) => { + clearTimeout(timer); + resolve(v); + }, + reject: (e) => { + clearTimeout(timer); + reject(e); + }, + }); + this.sendFrame(0x1, Buffer.from(JSON.stringify({ id: callId, method, params }))); }); }