Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc9bdb0175 | ||
|
|
f762790e30 |
@@ -0,0 +1,33 @@
|
|||||||
|
name: Process cleanup stress
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/process-cleanup-stress.yml"
|
||||||
|
- "scripts/verify-process-cleanup-stress.mjs"
|
||||||
|
- "src/application/ProcessCaptureLifecycle.ts"
|
||||||
|
- "src/application/ProcessHarness.ts"
|
||||||
|
- "src/application/ProcessOwnership.ts"
|
||||||
|
- "tests/fixtures/processFidelity.mjs"
|
||||||
|
- "package.json"
|
||||||
|
- "package-lock.json"
|
||||||
|
schedule:
|
||||||
|
- cron: "17 4 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
real-forkpty-cleanup:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run verify:process:stress
|
||||||
@@ -71,6 +71,7 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"verify:hopper": "npm run build && node scripts/verify-real-hopper.mjs",
|
"verify:hopper": "npm run build && node scripts/verify-real-hopper.mjs",
|
||||||
"verify:browser": "npm run build && node scripts/verify-real-browser.mjs",
|
"verify:browser": "npm run build && node scripts/verify-real-browser.mjs",
|
||||||
|
"verify:process:stress": "npm run build && node scripts/verify-process-cleanup-stress.mjs",
|
||||||
"verify:hopper:linux": "npm run build && node scripts/verify-real-hopper-linux.mjs",
|
"verify:hopper:linux": "npm run build && node scripts/verify-real-hopper-linux.mjs",
|
||||||
"verify:setup:linux": "npm run build && node scripts/verify-linux-setup-e2e.mjs",
|
"verify:setup:linux": "npm run build && node scripts/verify-linux-setup-e2e.mjs",
|
||||||
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
|
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
import {
|
||||||
|
captureProcessScenario,
|
||||||
|
probeProcessCaptureCapability,
|
||||||
|
} from "../dist/application/ProcessHarness.js";
|
||||||
|
import { parseProcessScenario } from "../dist/domain/processScenario.js";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const fixture = fileURLToPath(
|
||||||
|
new URL("../tests/fixtures/processFidelity.mjs", import.meta.url),
|
||||||
|
);
|
||||||
|
const iterations = Number.parseInt(
|
||||||
|
process.env.REA_PROCESS_STRESS_RUNS ?? "25",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (process.platform !== "linux")
|
||||||
|
throw new Error("Real forkpty cleanup stress verification requires Linux");
|
||||||
|
if (!Number.isSafeInteger(iterations) || iterations < 1 || iterations > 250)
|
||||||
|
throw new Error("REA_PROCESS_STRESS_RUNS must be an integer from 1 to 250");
|
||||||
|
const capability = await probeProcessCaptureCapability();
|
||||||
|
if (!capability.available)
|
||||||
|
throw new Error(`Process capture unavailable: ${capability.reason}`);
|
||||||
|
|
||||||
|
for (let iteration = 1; iteration <= iterations; iteration += 1) {
|
||||||
|
const result = await captureProcessScenario(
|
||||||
|
parseProcessScenario({
|
||||||
|
approved: true,
|
||||||
|
executable: process.execPath,
|
||||||
|
arguments: [fixture, "tree"],
|
||||||
|
working_directory: dirname(fixture),
|
||||||
|
timeout_ms: 1_000,
|
||||||
|
idle_timeout_ms: 2_000,
|
||||||
|
settle_ms: 50,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
executableRoots: [dirname(process.execPath)],
|
||||||
|
workingRoots: [dirname(fixture)],
|
||||||
|
allowedEnvironment: [],
|
||||||
|
allowExternalNetwork: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!result.ok)
|
||||||
|
throw new Error(`forkpty cleanup run ${String(iteration)} failed`, {
|
||||||
|
cause: result.error,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
result.value.cleanup.owned_process_group !== "verified" ||
|
||||||
|
result.value.cleanup.temporary_root !== "removed"
|
||||||
|
)
|
||||||
|
throw new Error(`forkpty cleanup run ${String(iteration)} was incomplete`);
|
||||||
|
const commands = result.value.process_samples.map(({ command }) => command);
|
||||||
|
if (!commands.some((command) => command.includes("tree-child")))
|
||||||
|
throw new Error(
|
||||||
|
`forkpty cleanup run ${String(iteration)} missed its child`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { stdout } = await execFileAsync("ps", ["-axo", "command="]);
|
||||||
|
if (
|
||||||
|
stdout.includes(`${fixture} tree-child`) ||
|
||||||
|
stdout.includes(`${fixture} tree-grandchild`)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`forkpty cleanup run ${String(iteration)} leaked a process`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
`${JSON.stringify({ platform: process.platform, iterations, verified: true })}\n`,
|
||||||
|
);
|
||||||
@@ -285,7 +285,7 @@ export const releaseProcessResources = async (options: {
|
|||||||
readonly checkpoints: ProcessCheckpoints | undefined;
|
readonly checkpoints: ProcessCheckpoints | undefined;
|
||||||
readonly runId: string;
|
readonly runId: string;
|
||||||
readonly temporaryRoot: string;
|
readonly temporaryRoot: string;
|
||||||
readonly sampledProcessGroupIds: readonly number[];
|
readonly capturedProcessGroupIds: readonly number[];
|
||||||
}): Promise<string | undefined> => {
|
}): Promise<string | undefined> => {
|
||||||
for (const timer of options.timers) clearTimeout(timer);
|
for (const timer of options.timers) clearTimeout(timer);
|
||||||
let failure: string | undefined;
|
let failure: string | undefined;
|
||||||
@@ -310,11 +310,7 @@ export const releaseProcessResources = async (options: {
|
|||||||
failure ??= "filesystem checkpoint cleanup failed";
|
failure ??= "filesystem checkpoint cleanup failed";
|
||||||
}
|
}
|
||||||
if (options.terminal !== undefined && process.platform !== "win32") {
|
if (options.terminal !== undefined && process.platform !== "win32") {
|
||||||
const processGroupIds = new Set([
|
for (const processGroupId of new Set(options.capturedProcessGroupIds)) {
|
||||||
options.terminal.pid,
|
|
||||||
...options.sampledProcessGroupIds,
|
|
||||||
]);
|
|
||||||
for (const processGroupId of processGroupIds) {
|
|
||||||
const cleaned = await cleanupOwnedProcessGroup({
|
const cleaned = await cleanupOwnedProcessGroup({
|
||||||
runId: options.runId,
|
runId: options.runId,
|
||||||
leaderPid: processGroupId,
|
leaderPid: processGroupId,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
type CommandShimReplay,
|
type CommandShimReplay,
|
||||||
} from "./CommandShimReplay.js";
|
} from "./CommandShimReplay.js";
|
||||||
import { normalizeProcessText } from "./ProcessNormalization.js";
|
import { normalizeProcessText } from "./ProcessNormalization.js";
|
||||||
|
import { selectCapturedProcessGroupIds } from "./ProcessOwnership.js";
|
||||||
import {
|
import {
|
||||||
awaitTerminalExit,
|
awaitTerminalExit,
|
||||||
buildCaptureResult,
|
buildCaptureResult,
|
||||||
@@ -157,7 +158,8 @@ const cleanupFailedStartup = async (options: {
|
|||||||
options.terminal?.kill("SIGKILL");
|
options.terminal?.kill("SIGKILL");
|
||||||
const cleanupFailure = await releaseProcessResources({
|
const cleanupFailure = await releaseProcessResources({
|
||||||
...options,
|
...options,
|
||||||
sampledProcessGroupIds: [],
|
capturedProcessGroupIds:
|
||||||
|
options.terminal === undefined ? [] : [options.terminal.pid],
|
||||||
});
|
});
|
||||||
if (cleanupFailure !== undefined)
|
if (cleanupFailure !== undefined)
|
||||||
throw new ProcessCaptureError(cleanupFailure, {
|
throw new ProcessCaptureError(cleanupFailure, {
|
||||||
@@ -319,9 +321,13 @@ const finishProcessRun = async (options: {
|
|||||||
checkpoints: options.runtime?.checkpoints,
|
checkpoints: options.runtime?.checkpoints,
|
||||||
runId: options.runId,
|
runId: options.runId,
|
||||||
temporaryRoot: options.temporaryRoot,
|
temporaryRoot: options.temporaryRoot,
|
||||||
sampledProcessGroupIds: options.samples.flatMap(({ process_group_id }) =>
|
capturedProcessGroupIds:
|
||||||
process_group_id === null ? [] : [process_group_id],
|
options.runtime === undefined
|
||||||
),
|
? []
|
||||||
|
: selectCapturedProcessGroupIds(
|
||||||
|
options.runtime.terminal.pid,
|
||||||
|
options.samples,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
let { capture, executionFailure } = options;
|
let { capture, executionFailure } = options;
|
||||||
if (capture !== undefined) {
|
if (capture !== undefined) {
|
||||||
@@ -365,12 +371,7 @@ const completeCapture = async (options: {
|
|||||||
if (reason === "cancelled") throw processCaptureCancelled();
|
if (reason === "cancelled") throw processCaptureCancelled();
|
||||||
const settlement = await observeSettlement(
|
const settlement = await observeSettlement(
|
||||||
options.runId,
|
options.runId,
|
||||||
[
|
selectCapturedProcessGroupIds(runtime.terminal.pid, options.samples),
|
||||||
runtime.terminal.pid,
|
|
||||||
...options.samples.flatMap(({ process_group_id }) =>
|
|
||||||
process_group_id === null ? [] : [process_group_id],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
scenario.settle_ms,
|
scenario.settle_ms,
|
||||||
);
|
);
|
||||||
runtime.checkpoints.trigger("settled");
|
runtime.checkpoints.trigger("settled");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
import type { ProcessSample } from "../domain/processCapture.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -40,6 +41,22 @@ export type ProcessGroupObservation =
|
|||||||
| { readonly state: "alive" }
|
| { readonly state: "alive" }
|
||||||
| { readonly state: "unverifiable"; readonly reason: string };
|
| { readonly state: "unverifiable"; readonly reason: string };
|
||||||
|
|
||||||
|
/** Select the PTY root group and groups led by an observed captured process. */
|
||||||
|
export const selectCapturedProcessGroupIds = (
|
||||||
|
rootPid: number,
|
||||||
|
samples: readonly Pick<ProcessSample, "pid" | "process_group_id">[],
|
||||||
|
): readonly number[] => {
|
||||||
|
const processGroupIds = new Set<number>([rootPid]);
|
||||||
|
for (const sample of samples) {
|
||||||
|
// A sampled member may transiently inherit an unrelated group. POSIX group
|
||||||
|
// leaders have pid === pgid, so only that observation establishes that the
|
||||||
|
// group leader itself belonged to the captured tree. Token checks below
|
||||||
|
// remain the final authority immediately before observation or signaling.
|
||||||
|
if (sample.pid === sample.process_group_id) processGroupIds.add(sample.pid);
|
||||||
|
}
|
||||||
|
return [...processGroupIds];
|
||||||
|
};
|
||||||
|
|
||||||
/** Parse the NUL-delimited Linux process environment without nameless keys. */
|
/** Parse the NUL-delimited Linux process environment without nameless keys. */
|
||||||
export const parseProcessEnvironment = (
|
export const parseProcessEnvironment = (
|
||||||
value: string,
|
value: string,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
cleanupOwnedProcessGroup,
|
cleanupOwnedProcessGroup,
|
||||||
parseProcessEnvironment,
|
parseProcessEnvironment,
|
||||||
|
selectCapturedProcessGroupIds,
|
||||||
type ProcessOwnershipHost,
|
type ProcessOwnershipHost,
|
||||||
} from "../src/application/ProcessOwnership.js";
|
} from "../src/application/ProcessOwnership.js";
|
||||||
|
|
||||||
@@ -37,6 +38,18 @@ const host = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("owned process-group cleanup", () => {
|
describe("owned process-group cleanup", () => {
|
||||||
|
it("excludes sampled groups whose leader was outside the captured tree", () => {
|
||||||
|
expect(
|
||||||
|
selectCapturedProcessGroupIds(100, [
|
||||||
|
{ pid: 100, process_group_id: 42 },
|
||||||
|
{ pid: 100, process_group_id: 100 },
|
||||||
|
{ pid: 101, process_group_id: 42 },
|
||||||
|
{ pid: 102, process_group_id: 102 },
|
||||||
|
{ pid: 103, process_group_id: 102 },
|
||||||
|
]),
|
||||||
|
).toEqual([100, 102]);
|
||||||
|
});
|
||||||
|
|
||||||
it("drops nameless Linux environment entries", () => {
|
it("drops nameless Linux environment entries", () => {
|
||||||
expect(
|
expect(
|
||||||
parseProcessEnvironment("=ignored\0REA_PROCESS_RUN_ID=owned\0EMPTY=\0"),
|
parseProcessEnvironment("=ignored\0REA_PROCESS_RUN_ID=owned\0EMPTY=\0"),
|
||||||
|
|||||||
Reference in New Issue
Block a user