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.
174 lines
5.7 KiB
TypeScript
174 lines
5.7 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
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";
|
|
|
|
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"])(
|
|
"rejects a reparented %s without signaling it",
|
|
async (mode) => {
|
|
const runId = randomUUID();
|
|
const childCode =
|
|
"process.title = 'Xvfb'; setInterval(() => {}, 1000); process.send('ready');";
|
|
const directory = await createTestTempDirectory("rea-clean-runtime-test-");
|
|
const launcher = join(directory, "Hopper");
|
|
const alternate = join(directory, "Hopper-alternate");
|
|
const alternateMode = mode === "alternate launcher";
|
|
if (alternateMode) {
|
|
await copyFile("/usr/bin/sleep", launcher);
|
|
await chmod(launcher, 0o700);
|
|
await symlink(launcher, alternate);
|
|
}
|
|
const command = alternateMode ? alternate : process.execPath;
|
|
const arguments_ = alternateMode ? ["30"] : ["-e", childCode];
|
|
const parentCode = `
|
|
const { spawn } = require('node:child_process');
|
|
const child = spawn(${JSON.stringify(command)}, ${JSON.stringify(arguments_)}, {
|
|
detached: true, stdio: ${JSON.stringify(alternateMode ? "ignore" : ["ignore", "ignore", "ignore", "ipc"])}
|
|
});
|
|
child.once('error', () => process.exit(1));
|
|
child.once(${JSON.stringify(alternateMode ? "spawn" : "message")}, () => {
|
|
process.stdout.write(JSON.stringify({ pid: child.pid, parentPid: process.pid }));
|
|
if (child.connected) child.disconnect();
|
|
child.unref();
|
|
});
|
|
`;
|
|
const parent = execFileAsync(process.execPath, ["-e", parentCode], {
|
|
env: { ...process.env, REA_PROCESS_RUN_ID: runId },
|
|
timeout: 10_000,
|
|
});
|
|
const parentPid = parent.child.pid;
|
|
if (parentPid === undefined)
|
|
throw new Error("Fixture parent did not spawn");
|
|
let orphanPid: number | undefined;
|
|
try {
|
|
const { stdout } = await parent;
|
|
const { pid } = JSON.parse(stdout) as { pid: number };
|
|
orphanPid = pid;
|
|
const { stdout: coordinates } = await execFileAsync("ps", [
|
|
"-p",
|
|
String(pid),
|
|
"-o",
|
|
"ppid=,pgid=",
|
|
]);
|
|
const [currentParent, group] = coordinates
|
|
.trim()
|
|
.split(/\s+/u)
|
|
.map(Number);
|
|
expect(currentParent).not.toBe(parentPid);
|
|
expect(group).toBe(pid);
|
|
|
|
const lineage = await completeVerifierRun({
|
|
run_id: runId,
|
|
verifier_pid: process.pid,
|
|
parent_pid: process.ppid,
|
|
});
|
|
expect(lineage.process_lineage.status).toBe("verified");
|
|
if (lineage.process_lineage.status === "verified")
|
|
expect(
|
|
lineage.process_lineage.descendants.some((p) => p.pid === pid),
|
|
).toBe(false);
|
|
|
|
if (alternateMode) {
|
|
const commandLine = await readFile(
|
|
`/proc/${String(pid)}/cmdline`,
|
|
"utf8",
|
|
);
|
|
expect(commandLine.split("\0")[0]).toBe(alternate);
|
|
await unlink(alternate);
|
|
}
|
|
await expect(
|
|
assertCleanLinuxHopperRuntime(alternateMode ? [launcher] : []),
|
|
).rejects.toThrow("Clean Linux runner retained a Hopper demo process");
|
|
// Observation must not terminate the fixture, even though it resembles Xvfb.
|
|
const { stdout: alive } = await execFileAsync("ps", [
|
|
"-p",
|
|
String(pid),
|
|
"-o",
|
|
"stat=",
|
|
]);
|
|
expect(alive.trim()).not.toMatch(/^Z/u);
|
|
} finally {
|
|
if (orphanPid !== undefined) {
|
|
const cleanup = await cleanupOwnedProcessGroup({
|
|
runId,
|
|
leaderPid: orphanPid,
|
|
processGroupId: orphanPid,
|
|
});
|
|
expect(cleanup.cleaned, JSON.stringify(cleanup)).toBe(true);
|
|
}
|
|
}
|
|
},
|
|
);
|