diff --git a/.github/workflows/process-cleanup-stress.yml b/.github/workflows/process-cleanup-stress.yml new file mode 100644 index 000000000..e9ca57ffe --- /dev/null +++ b/.github/workflows/process-cleanup-stress.yml @@ -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 diff --git a/package.json b/package.json index e3a3148a4..f2121defd 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "typecheck": "tsc --noEmit", "verify:hopper": "npm run build && node scripts/verify-real-hopper.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:setup:linux": "npm run build && node scripts/verify-linux-setup-e2e.mjs", "build:fixtures": "node scripts/build-conformance-fixtures.mjs", diff --git a/scripts/verify-process-cleanup-stress.mjs b/scripts/verify-process-cleanup-stress.mjs new file mode 100644 index 000000000..684b04903 --- /dev/null +++ b/scripts/verify-process-cleanup-stress.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`, +);