diff --git a/scripts/lib/clean-linux-hopper-runtime.mjs b/scripts/lib/clean-linux-hopper-runtime.mjs index 02664ffbe..8024a56a8 100644 --- a/scripts/lib/clean-linux-hopper-runtime.mjs +++ b/scripts/lib/clean-linux-hopper-runtime.mjs @@ -62,10 +62,23 @@ export const assertCleanLinuxHopperRuntime = async ( try { // procfs preserves argument boundaries. A caller merely mentioning a // runtime in its script text or other arguments is not that runtime. - const arguments_ = ( - await readFile(`/proc/${entry.name}/cmdline`, "utf8") - ).split("\0"); + const commandLine = await readFile(`/proc/${entry.name}/cmdline`, "utf8"); + const arguments_ = commandLine.split("\0"); + let kernelCommand; + if (commandLine.length === 0) { + // Zombies have neither arguments nor an executable link, but keep + // their kernel command name until their parent reaps them. + kernelCommand = ( + await readFile(`/proc/${entry.name}/comm`, "utf8") + ).replace(/\n$/u, ""); + if (/^python3(?:\.\d+)?$/u.test(kernelCommand)) + throw new Error( + "Cannot identify a command-less Python process on the clean Linux runner", + ); + } retainedRuntime = + kernelCommand === "Hopper" || + kernelCommand === "Xvfb" || arguments_[0] === "/opt/hopper/bin/Hopper" || basename(arguments_[0] ?? "") === "Xvfb" || (/^python3(?:\.\d+)?$/u.test(basename(arguments_[0] ?? "")) && diff --git a/tests/boundary/process/cleanLinuxHopperRuntime.test.ts b/tests/boundary/process/cleanLinuxHopperRuntime.test.ts index 6194200b7..562c717bb 100644 --- a/tests/boundary/process/cleanLinuxHopperRuntime.test.ts +++ b/tests/boundary/process/cleanLinuxHopperRuntime.test.ts @@ -20,6 +20,71 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const execFileAsync = promisify(execFile); +it.runIf(process.platform === "linux").each([ + ["Xvfb", "Clean Linux runner retained a Hopper demo process"], + ["Hopper", "Clean Linux runner retained a Hopper demo process"], + ["python3", "Cannot identify a command-less Python process"], + ["unrelated", undefined], +] as const)( + "observes an unreaped %s without signaling or reaping it", + async (name, refusal) => { + const directory = await createTestTempDirectory("rea-runtime-zombie-"); + const executable = join(directory, name); + await copyFile("/usr/bin/true", executable); + await chmod(executable, 0o700); + // WNOWAIT observes exit without reaping. The parent keeps the zombie until + // the test closes its stdin, then waitpid performs the actual cleanup. + const parent = execFileAsync( + "/usr/bin/python3", + [ + "-c", + ` +import os, sys +pid = os.fork() +if pid == 0: + os.execl(sys.argv[1], sys.argv[1]) +try: + os.waitid(os.P_PID, pid, os.WEXITED | os.WNOWAIT) + print(pid, flush=True) + sys.stdin.buffer.read(1) +finally: + os.waitpid(pid, 0) +`, + executable, + ], + { timeout: 10_000 }, + ); + const ready = new Promise((resolve, reject) => { + parent.child.stdout?.once("data", (data: Buffer) => + resolve(Number(data.toString().trim())), + ); + parent.catch(reject); + }); + let zombiePid: number | undefined; + try { + zombiePid = await ready; + expect(Number.isSafeInteger(zombiePid)).toBe(true); + const prefix = `/proc/${String(zombiePid)}`; + expect(await readFile(`${prefix}/cmdline`, "utf8")).toBe(""); + expect((await readFile(`${prefix}/comm`, "utf8")).trim()).toBe(name); + const observation = assertCleanLinuxHopperRuntime([]); + if (refusal === undefined) + await expect(observation).resolves.toBeUndefined(); + else await expect(observation).rejects.toThrow(refusal); + expect(await readFile(`${prefix}/stat`, "utf8")).toContain( + `(${name}) Z `, + ); + } finally { + parent.child.stdin?.end("reap"); + await parent; + if (zombiePid !== undefined) + await expect( + readFile(`/proc/${String(zombiePid)}/stat`, "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); + } + }, +); + it.runIf(process.platform === "linux")( "does not treat runtime names in another process's arguments as a runtime", async () => {