From adcfbc6aa003f2c5ac8973dec9aacc48058d1013 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 9 Nov 2026 12:16:16 -0800 Subject: [PATCH] fix(gate): treat a process that exits mid-read as gone Linux reports a /proc//stat read that races the process's exit as ESRCH rather than ENOENT. processStart and alive caught only ENOENT, so the race threw out of the gate's admission instead of reading the process as gone. The failure reached a merge group in https://github.com/moq-dev/quest/pull/586. Co-Authored-By: Claude Haiku 5.5 --- src/cli/gate.ts | 2 +- src/cli/supervise.ts | 7 +++++-- test/gate.test.ts | 40 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 44 insertions(+), 5 deletions(-) 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 =