From a588dc6376b7dbf6e5588fae51b371dafed8fb5d Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:41:40 +0000 Subject: [PATCH] fix(hopper): distinguish runtime processes from argument text Parse procfs argument boundaries rather than searching rendered ps command text. Keep executable identity checks and deferred permission failures, eliminate the extra ps process, and put shared process observations in the serial process-boundary lane. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> --- scripts/lib/clean-linux-hopper-runtime.mjs | 40 ++++++----- scripts/lib/real-hopper-search.d.mts | 7 +- .../process}/cleanLinuxHopperRuntime.test.ts | 70 +++++++++++++++++-- 3 files changed, 92 insertions(+), 25 deletions(-) rename tests/{process-global => boundary/process}/cleanLinuxHopperRuntime.test.ts (65%) 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"])(