/** * One run at a time over the shared wallet profile. * * ── Why ────────────────────────────────────────────────────────────────────── * Both suites call `ensureWallet()`, and its first act is `fs.rmSync(PROFILE_DIR)` — it * discards the previous batch's physical user on purpose (see `broker.ts`). Started while * another run is alive, that deletes the profile out from under a browser which is USING * it, and the first run then fails somewhere far from the cause, looking like a product * defect. That has already cost several undecidable measurements: a suite blamed for a * hang that was really a second run wiping its wallet. * * So the exclusion is made structural rather than remembered. * * ── Fail, not wait ─────────────────────────────────────────────────────────── * A second run is REFUSED, immediately, naming the holder. Queueing would be the wrong * answer for a harness: these batches run for minutes, and a command that silently blocks * for a quarter of an hour is the same disease as the hang this was written alongside — * you cannot tell it from a freeze. A refusal is legible in one line and costs nothing. * * ── Where the file lives ───────────────────────────────────────────────────── * Under the system temp dir, NOT inside the profile it guards: `ensureWallet` deletes that * directory wholesale, which would erase the lock at the exact moment it is protecting * something. Naming it after the profile's path keeps one lock per guarded profile, and * keeps it out of the repository (nothing to gitignore, nothing to commit by accident). */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; interface LockRecord { pid: number; suite: string; startedAt: string; } function lockPathFor(guarded: string): string { const slug = guarded.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); return path.join(os.tmpdir(), `ng-eventually-e2e-${slug}.lock`); } /** Is that process still alive? Signal 0 tests for existence without touching it. */ export function isAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (e) { // EPERM means it exists and is someone else's — still alive, still holding the lock. return (e as NodeJS.ErrnoException).code === "EPERM"; } } function readRecord(lockPath: string): LockRecord | null { try { const parsed: unknown = JSON.parse(fs.readFileSync(lockPath, "utf-8")); if (parsed && typeof parsed === "object" && typeof (parsed as LockRecord).pid === "number") { return parsed as LockRecord; } return null; } catch { return null; } } /** * Take the lock for `suite` over `guarded`, or throw naming who holds it. * * A lock left by a process that no longer exists is taken over — a run killed mid-batch * (which is how every one of this harness's hangs ended) must not make the next one * unrunnable. That check is on the OS's view of the pid, not on the file's age: a timeout * would either strand a slow-but-healthy batch or hand the profile to a second run while * the first still holds it, and both are the corruption this exists to stop. */ export function acquireRunLock(suite: string, guarded: string): void { const lockPath = lockPathFor(guarded); const record: LockRecord = { pid: process.pid, suite, startedAt: new Date().toISOString() }; for (let attempt = 0; attempt < 2; attempt++) { try { fs.writeFileSync(lockPath, JSON.stringify(record), { flag: "wx" }); installRelease(lockPath); return; } catch (e) { if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e; const held = readRecord(lockPath); if (held !== null && isAlive(held.pid)) { const ageMin = Math.round((Date.now() - Date.parse(held.startedAt)) / 60000); throw new Error( `[e2e] refusing to start: another e2e run holds ${guarded}.\n` + ` holder: ${held.suite} (pid ${held.pid}, started ${held.startedAt}, ${ageMin} min ago)\n` + " Two runs share one wallet profile, and each one's setup DELETES it — so the\n" + " second would corrupt the first. Wait for it, or stop it, then run again.\n" + ` If that process is gone, remove ${lockPath}.`, ); } // Nobody is behind it: a killed run's leftover. Take it over and say so. console.warn( `[e2e] taking over a stale run lock (${held === null ? "unreadable" : `pid ${held.pid} is gone`}) — ${lockPath}`, ); fs.rmSync(lockPath, { force: true }); } } throw new Error(`[e2e] could not take the run lock at ${lockPath} (raced twice)`); } /** * Release on the way out, including the ways out nobody plans for. * * `exit` covers the normal end and `process.exit()`, which is how both suites finish; the * signal handlers cover Ctrl-C and `kill`, which is how a hung batch ends. A lock that * outlives its run is only a nuisance — the takeover above clears it — but leaving one * behind on every interrupt would make the nuisance the norm. */ function installRelease(lockPath: string): void { const release = (): void => { const held = readRecord(lockPath); if (held !== null && held.pid !== process.pid) return; // someone else's now; leave it try { fs.rmSync(lockPath, { force: true }); } catch { /* the takeover path handles whatever is left */ } }; process.on("exit", release); for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { process.on(signal, () => { release(); process.exit(130); }); } }