yukey

e2e: signal handlers before Chrome starts; a dead Chrome fails fast

commit 3bee2a2df839f16d05a9e97cf25b215d82dd8b8c · parent d32b1d6ae9 · browse this tree
Ximianth, 2026-10-10 05:55:44 UTC

SIGINT during Harness.start() exited 130 but left Chrome (its own process
group) and the profile dir behind, because main.ts installed the handlers
only after start() returned.  Reproduced with kill -INT at 0.25s: 6 Chrome
processes and 1 profile dir orphaned.  The handlers now go in inside start(),
right after spawn, with the same kill() the throw path uses; main.ts no
longer installs its own.

chrome.on("exit") fails the run with the code/signal as soon as Chrome dies
on its own.  Before, YUKEY_E2E_CHROME=/bin/true sat through the 15s poll
deadline; now it exits in under a second.  kill() sets a flag so our own
SIGKILL at teardown isn't reported as unexpected.

Harness keeps only the kill closure now, rather than the server, child,
and profile dir separately; stop() is cdp.close() plus kill().

make check-e2e 215/215 after.
diff --git a/packages/e2e/harness.ts b/packages/e2e/harness.ts
index a485a96..4f9941c 100644
--- a/packages/e2e/harness.ts
+++ b/packages/e2e/harness.ts
@@ -6,7 +6,7 @@
 // Everything here is per-run and ephemeral: fresh Chrome profile, ephemeral
 // port for the static server, no shared state with anything else.
 
-import { type ChildProcess, spawn } from "node:child_process";
+import { spawn } from "node:child_process";
 import { accessSync, constants, mkdtempSync, readFileSync, rmSync } from "node:fs";
 import { createServer, type Server } from "node:http";
 import { tmpdir } from "node:os";
@@ -79,23 +79,12 @@ function serveStatic(): Promise<{ baseUrl: string; server: Server }> {
 export class Harness {
 	readonly baseUrl: string;
 	readonly cdp: Cdp;
-	private readonly server: Server;
-	private readonly chrome: ChildProcess;
-	private readonly profileDir: string;
-	private stopped = false;
-
-	private constructor(
-		baseUrl: string,
-		cdp: Cdp,
-		server: Server,
-		chrome: ChildProcess,
-		profileDir: string,
-	) {
+	private readonly kill: () => void;
+
+	private constructor(baseUrl: string, cdp: Cdp, kill: () => void) {
 		this.baseUrl = baseUrl;
 		this.cdp = cdp;
-		this.server = server;
-		this.chrome = chrome;
-		this.profileDir = profileDir;
+		this.kill = kill;
 	}
 
 	static async start(): Promise<Harness> {
@@ -131,7 +120,13 @@ export class Harness {
 			{ stdio: "ignore", detached: true },
 		);
 
+		// Unconditional, idempotent teardown of everything spawned above: stop the
+		// server, kill the Chrome tree, remove the profile dir.  Every exit path
+		// below funnels through it, including stop().
+		let killed = false;
 		const kill = () => {
+			if (killed) return;
+			killed = true;
 			server.close();
 			if (chrome.pid !== undefined) {
 				try {
@@ -141,6 +136,26 @@ export class Harness {
 			rmSync(profileDir, { recursive: true, force: true });
 		};
 
+		// Signal handlers go in before anything waits, so a Ctrl-C during Chrome's
+		// startup still takes Chrome and the profile dir with it.
+		process.on("SIGINT", () => {
+			kill();
+			process.exit(130);
+		});
+		process.on("SIGTERM", () => {
+			kill();
+			process.exit(143);
+		});
+
+		// A Chrome that dies on its own fails the run now, not at a poll deadline.
+		// Our own SIGKILL in kill() fires this too; `killed` tells the two apart.
+		chrome.on("exit", (code, signal) => {
+			if (killed) return;
+			console.error(`chrome exited unexpectedly (code ${code}, signal ${signal})`);
+			kill();
+			process.exit(1);
+		});
+
 		try {
 			// Chrome publishes its chosen debugging port in the profile dir.
 			let debugPort = 0;
@@ -170,27 +185,18 @@ export class Harness {
 			});
 
 			const cdp = await Cdp.connect(wsUrl);
-			return new Harness(baseUrl, cdp, server, chrome, profileDir);
+			return new Harness(baseUrl, cdp, kill);
 		} catch (err) {
 			kill();
 			throw err;
 		}
 	}
 
-	// Idempotent, unconditional teardown: close the socket, stop the server,
-	// kill the Chrome process tree, remove the profile dir.
+	// Close the socket and tear down the server, Chrome, and profile dir.
 	stop(): void {
-		if (this.stopped) return;
-		this.stopped = true;
 		try {
 			this.cdp.close();
 		} catch {}
-		this.server.close();
-		if (this.chrome.pid !== undefined) {
-			try {
-				process.kill(-this.chrome.pid, "SIGKILL");
-			} catch {}
-		}
-		rmSync(this.profileDir, { recursive: true, force: true });
+		this.kill();
 	}
 }
diff --git a/packages/e2e/main.ts b/packages/e2e/main.ts
index 77a8c29..f23b4c4 100644
--- a/packages/e2e/main.ts
+++ b/packages/e2e/main.ts
@@ -24,15 +24,8 @@ async function main(): Promise<void> {
 	}
 	const chosen = wanted.length > 0 ? ALL.filter((s) => wanted.includes(s.name)) : ALL;
 
+	// Harness.start owns the SIGINT/SIGTERM handlers (and the exit codes 130/143).
 	const harness = await Harness.start();
-	process.on("SIGINT", () => {
-		harness.stop();
-		process.exit(130);
-	});
-	process.on("SIGTERM", () => {
-		harness.stop();
-		process.exit(143);
-	});
 
 	let failedScenarios = 0;
 	const summary: string[] = [];