diff --git a/scripts/lib/clean-linux-hopper-runtime.mjs b/scripts/lib/clean-linux-hopper-runtime.mjs index 27a306fce..206cba53a 100644 --- a/scripts/lib/clean-linux-hopper-runtime.mjs +++ b/scripts/lib/clean-linux-hopper-runtime.mjs @@ -1,8 +1,5 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { readdir, stat } from "node:fs/promises"; - -const execFileAsync = promisify(execFile); +import { readFile, readdir, stat } from "node:fs/promises"; +import { basename } from "node:path"; /** * Observe leftover demo processes on a dedicated clean Linux runner, including @@ -17,9 +14,6 @@ export const assertCleanLinuxHopperRuntime = async ( : [process.env.HOPPER_LAUNCHER_PATH]), ], ) => { - const { stdout } = await execFileAsync("ps", ["-ax", "-o", "command="]); - if (/\/opt\/hopper\/bin\/Hopper|\bXvfb\b|hopper-demo-x11\.py/u.test(stdout)) - throw new Error("Clean Linux runner retained a Hopper demo process"); const launchers = []; for (const path of launcherPaths) { try { @@ -28,18 +22,34 @@ export const assertCleanLinuxHopperRuntime = async ( if (error?.code !== "ENOENT") throw error; } } - if (launchers.length === 0) return; // /proc exposes the executed file even when argv[0] names a removed symlink. // Compare file identity, not a display path or a process name. let inspectionError; for (const entry of await readdir("/proc", { withFileTypes: true })) { if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue; - let executable; + let retainedRuntime; try { - executable = await stat(`/proc/${entry.name}/exe`, { bigint: true }); + // 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"); + retainedRuntime = + arguments_[0] === "/opt/hopper/bin/Hopper" || + basename(arguments_[0] ?? "") === "Xvfb" || + (basename(arguments_[0] ?? "") === "python3" && + basename(arguments_[1] ?? "") === "hopper-demo-x11.py"); + if (!retainedRuntime && launchers.length > 0) { + const executable = await stat(`/proc/${entry.name}/exe`, { + bigint: true, + }); + retainedRuntime = launchers.some( + ({ dev, ino }) => dev === executable.dev && ino === executable.ino, + ); + } } catch (error) { if (error?.code === "ENOENT" || error?.code === "ESRCH") continue; - // Other users' executables may be unreadable. The setup lane launches + // Other users' process facts may be unreadable. The setup lane launches // providers as the caller; unreadable caller-owned processes fail closed. if (error?.code === "EACCES") { const owner = await stat(`/proc/${entry.name}`).catch((cause) => { @@ -53,11 +63,7 @@ export const assertCleanLinuxHopperRuntime = async ( inspectionError ??= error; continue; } - if ( - launchers.some( - ({ dev, ino }) => dev === executable.dev && ino === executable.ino, - ) - ) + if (retainedRuntime) throw new Error("Clean Linux runner retained a Hopper demo process"); } if (inspectionError !== undefined) throw inspectionError; diff --git a/scripts/lib/real-hopper-search.d.mts b/scripts/lib/real-hopper-search.d.mts index e64031652..82cfad325 100644 --- a/scripts/lib/real-hopper-search.d.mts +++ b/scripts/lib/real-hopper-search.d.mts @@ -1,3 +1,7 @@ +import type { z } from "zod"; + +import type { analysisStringSchema } from "../../src/domain/hopperValues.js"; + type VerifierCall = ( name: string, arguments_?: Record, @@ -25,6 +29,3 @@ export function verifyHopperStringObjects( }[]; readonly displayEscapesExcludedFromMatching: true; }>; -import type { z } from "zod"; - -import type { analysisStringSchema } from "../../src/domain/hopperValues.js"; diff --git a/tests/process-global/cleanLinuxHopperRuntime.test.ts b/tests/boundary/process/cleanLinuxHopperRuntime.test.ts similarity index 65% rename from tests/process-global/cleanLinuxHopperRuntime.test.ts rename to tests/boundary/process/cleanLinuxHopperRuntime.test.ts index d356f5abf..b41b163a1 100644 --- a/tests/process-global/cleanLinuxHopperRuntime.test.ts +++ b/tests/boundary/process/cleanLinuxHopperRuntime.test.ts @@ -1,18 +1,78 @@ import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { chmod, copyFile, readFile, symlink, unlink } from "node:fs/promises"; +import { + chmod, + copyFile, + readFile, + symlink, + unlink, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; import { expect, it } from "vitest"; -import { assertCleanLinuxHopperRuntime } from "../../scripts/lib/clean-linux-hopper-runtime.mjs"; -import { completeVerifierRun } from "../../scripts/lib/verifier-run.mjs"; -import { cleanupOwnedProcessGroup } from "../../src/process/ProcessOwnership.js"; -import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { assertCleanLinuxHopperRuntime } from "../../../scripts/lib/clean-linux-hopper-runtime.mjs"; +import { completeVerifierRun } from "../../../scripts/lib/verifier-run.mjs"; +import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const execFileAsync = promisify(execFile); +it.runIf(process.platform === "linux")( + "does not treat runtime names in another process's arguments as a runtime", + async () => { + const observer = new URL( + "../../../scripts/lib/clean-linux-hopper-runtime.mjs", + import.meta.url, + ).href; + const code = ` + const { assertCleanLinuxHopperRuntime } = await import(${JSON.stringify(observer)}); + await assertCleanLinuxHopperRuntime([]); + console.log('clean'); + `; + const { stdout } = await execFileAsync( + process.execPath, + [ + "--input-type=module", + "-e", + code, + "Xvfb", + "/opt/hopper/bin/Hopper", + "hopper-demo-x11.py", + ], + { timeout: 10_000 }, + ); + expect(stdout.trim()).toBe("clean"); + }, +); + +it.runIf(process.platform === "linux")( + "distinguishes an ordinary Node script with the helper's filename", + async () => { + const directory = await createTestTempDirectory("rea-runtime-arguments-"); + const script = join(directory, "hopper-demo-x11.py"); + const observer = new URL( + "../../../scripts/lib/clean-linux-hopper-runtime.mjs", + import.meta.url, + ).href; + await writeFile( + script, + ` + import(${JSON.stringify(observer)}).then(async ({ assertCleanLinuxHopperRuntime }) => { + await assertCleanLinuxHopperRuntime([]); + console.log('clean'); + }); + `, + ); + const { stdout } = await execFileAsync(process.execPath, [script], { + timeout: 10_000, + }); + expect(stdout.trim()).toBe("clean"); + }, +); + it .runIf(process.platform === "linux") .each(["demo helper", "alternate launcher"])(