diff --git a/src/cli/gate.ts b/src/cli/gate.ts index f49be99f..7020d382 100644 --- a/src/cli/gate.ts +++ b/src/cli/gate.ts @@ -283,7 +283,7 @@ export function processStart(pid: number): string | undefined { try { return `${readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim()} ${startTime(pid)}`; } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + if (["ENOENT", "ESRCH"].includes((error as NodeJS.ErrnoException).code ?? "")) return undefined; throw error; } } diff --git a/src/cli/supervise.ts b/src/cli/supervise.ts index e4f9b11a..c67052ab 100644 --- a/src/cli/supervise.ts +++ b/src/cli/supervise.ts @@ -30,7 +30,10 @@ export interface Owner { start?: string; } -/** Field 22 of /proc//stat; the command name in parentheses may hold spaces. */ +/** + * Field 22 of /proc//stat; the command name in parentheses may hold spaces. Throws once the + * process is gone: ENOENT, or ESRCH when it exits mid-read. + */ export function startTime(pid: number): string | undefined { if (process.platform !== "linux") return undefined; const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); @@ -48,7 +51,7 @@ export function alive(owner: Owner): boolean { try { return startTime(owner.pid) === owner.start; } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + if (["ENOENT", "ESRCH"].includes((error as NodeJS.ErrnoException).code ?? "")) return false; throw error; } } diff --git a/test/gate.test.ts b/test/gate.test.ts index b897e8e0..7aa7def6 100644 --- a/test/gate.test.ts +++ b/test/gate.test.ts @@ -13,7 +13,7 @@ import { utimesSync, writeFileSync, } from "node:fs"; -import { constants } from "node:os"; +import { constants, hostname } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, test, vi } from "vitest"; @@ -42,9 +42,27 @@ import { } from "../src/cli/gate"; import { forwards, foregroundStat, statProcess } from "../src/cli/interrupt"; import { gateBoundary, main } from "../src/cli/main"; -import { questScope } from "../src/cli/supervise"; +import { alive as ownerAlive, questScope } from "../src/cli/supervise"; import { created, onTerminal, run, signalled, tempDir } from "./fixture"; +// Linux reports a /proc read that races a process's exit as ESRCH rather than ENOENT, so fail +// one process's stat read with either code, without timing. +const racing = vi.hoisted(() => ({ read: undefined as { path: string; code: string } | undefined })); +vi.mock("node:fs", async (importOriginal) => { + const fs = await importOriginal(); + return { + ...fs, + readFileSync: (...args: Parameters) => { + if (racing.read && args[0] === racing.read.path) { + throw Object.assign(new Error(`${racing.read.code}: no such process, read`), { + code: racing.read.code, + }); + } + return fs.readFileSync(...args); + }, + }; +}); + const GiB = 2 ** 30; /** How this process's gate records itself in an admission. */ @@ -1146,6 +1164,24 @@ function detached(dir: string, rest: string) { }; } +describe.runIf(process.platform === "linux")("a process that exits while its stat is read", () => { + const pid = 4242; + const identity = { host: hostname(), pid, start: "1" }; + afterEach(() => (racing.read = undefined)); + + test.each(["ENOENT", "ESRCH"])("reads as gone when the stat read fails with %s", (code) => { + racing.read = { path: `/proc/${pid}/stat`, code }; + expect(processStart(pid)).toBeUndefined(); + expect(ownerAlive(identity)).toBe(false); + }); + + test("surfaces any other read error", () => { + racing.read = { path: `/proc/${pid}/stat`, code: "EACCES" }; + expect(() => processStart(pid)).toThrow("EACCES"); + expect(() => ownerAlive(identity)).toThrow("EACCES"); + }); +}); + describe("the Windows job stub", () => { test("passes the exact exit status and restores the caller's environment", async () => { const script =