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>
This commit is contained in:
parent
747e729914
commit
a588dc6376
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import type { z } from "zod";
|
||||
|
||||
import type { analysisStringSchema } from "../../src/domain/hopperValues.js";
|
||||
|
||||
type VerifierCall = (
|
||||
name: string,
|
||||
arguments_?: Record<string, unknown>,
|
||||
|
|
@ -25,6 +29,3 @@ export function verifyHopperStringObjects(
|
|||
}[];
|
||||
readonly displayEscapesExcludedFromMatching: true;
|
||||
}>;
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { analysisStringSchema } from "../../src/domain/hopperValues.js";
|
||||
|
|
|
|||
|
|
@ -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"])(
|
||||
Loading…
Reference in New Issue