mirror of
https://github.com/morluto/rea
synced 2026-10-26 14:25:49 -04:00
d01ee7b19a
Join identity-verified PTY family cleanup and native exit before workspace removal, retaining failed cleanup capabilities for retry. Replace duplicate kill-and-reject loops with shared process ownership and deadlines. Capture configuration before setup using owned file handles and compare raw bytes. Preserve interactive cancellation, terminal restoration, redirected output, and selected inputs; remove the test-only forwarding export. Verified 122 focused cases, 9 README cases, build, static checks, docs, discovery, and duplication at 3.58%.
322 lines
13 KiB
TypeScript
322 lines
13 KiB
TypeScript
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { join, resolve } from "node:path";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { pathToFileURL } from "node:url";
|
|
import { expect } from "vitest";
|
|
import { z } from "zod";
|
|
|
|
import {
|
|
createPackageVerifierCommands,
|
|
type PackageVerifierCommands,
|
|
} from "../../../scripts/lib/verify-package-commands.mjs";
|
|
import {
|
|
verifyInteractiveSetup,
|
|
verifyRedirectedStdoutSetup,
|
|
} from "../../../scripts/lib/verify-setup-terminal.mjs";
|
|
import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs";
|
|
import { systemProcessOwnershipHost } from "../../../src/process/ProcessOwnershipObservation.js";
|
|
import { liveProcesses } from "../../../src/process/ProcessOwnershipProcessTree.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { processTest } from "../../support/process/processFixture.js";
|
|
|
|
const unchangedConfiguration = '{"callerSelected":"preserved"}\n';
|
|
const timeoutMessage =
|
|
"packaged setup started an interactive prompt with redirected stdout:";
|
|
|
|
const descendantSource = `
|
|
import { appendFileSync, existsSync } from "node:fs";
|
|
const finish = (reason) => {
|
|
appendFileSync(process.env.TERMINAL_EVENTS, JSON.stringify({ kind: "descendant-exit", reason, rootExists: existsSync(process.env.HOME) }) + "\\n");
|
|
process.exit(0);
|
|
};
|
|
process.on("SIGHUP", () => undefined);
|
|
process.on("SIGTERM", () => finish("SIGTERM"));
|
|
process.send({ pid: process.pid });
|
|
setInterval(() => {
|
|
if (existsSync(process.env.TERMINAL_RELEASE)) finish("fixture-release");
|
|
}, 25);
|
|
setTimeout(() => finish("fixture-deadline"), 30000);
|
|
`;
|
|
|
|
const setupStandinSource = `
|
|
import assert from "node:assert/strict";
|
|
import { spawn } from "node:child_process";
|
|
import { appendFileSync, existsSync, writeFileSync } from "node:fs";
|
|
assert.deepEqual(process.argv.slice(2), ["setup"]);
|
|
const tty = { stdin: Boolean(process.stdin.isTTY), stdout: Boolean(process.stdout.isTTY), stderr: Boolean(process.stderr.isTTY) };
|
|
assert.deepEqual(tty, { stdin: true, stdout: false, stderr: true });
|
|
const parent = process.ppid;
|
|
const finish = (reason) => {
|
|
appendFileSync(process.env.TERMINAL_EVENTS, JSON.stringify({ kind: "setup-exit", reason, rootExists: existsSync(process.env.HOME) }) + "\\n");
|
|
process.exit(0);
|
|
};
|
|
process.on("SIGHUP", () => finish("SIGHUP"));
|
|
process.on("SIGTERM", () => finish("SIGTERM"));
|
|
const child = spawn(process.execPath, [process.env.TERMINAL_DESCENDANT], {
|
|
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
});
|
|
const ready = await new Promise((resolve, reject) => {
|
|
child.once("message", resolve);
|
|
child.once("error", reject);
|
|
});
|
|
writeFileSync(process.env.TERMINAL_READY, JSON.stringify({ pids: [process.pid, ready.pid], tty, arguments: process.argv.slice(2) }));
|
|
process.stderr.write("authored setup pending\\n");
|
|
setInterval(() => {
|
|
if (existsSync(process.env.TERMINAL_RELEASE)) finish("fixture-release");
|
|
if (process.ppid !== parent) finish("wrapper-exit");
|
|
}, 25);
|
|
setTimeout(() => finish("fixture-deadline"), 30000);
|
|
`;
|
|
|
|
const reportSchema = z.object({
|
|
ready: z.object({
|
|
pids: z.array(z.number().int().positive()).length(2),
|
|
tty: z.object({
|
|
stdin: z.literal(true),
|
|
stdout: z.literal(false),
|
|
stderr: z.literal(true),
|
|
}),
|
|
arguments: z.tuple([z.literal("setup")]),
|
|
}),
|
|
liveAtRemoval: z.array(z.number().int().positive()),
|
|
configurationAtRemoval: z.string(),
|
|
rootRemoved: z.boolean(),
|
|
failure: z.object({ name: z.string(), message: z.string() }),
|
|
});
|
|
|
|
const releaseStandin = async (release: string, readyPath: string) => {
|
|
await writeFile(release, "release these authored setup processes");
|
|
const ready = await readFile(readyPath, "utf8").then(
|
|
(text) => reportSchema.shape.ready.parse(JSON.parse(text)),
|
|
(cause: unknown) => {
|
|
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
return undefined;
|
|
throw cause;
|
|
},
|
|
);
|
|
if (ready === undefined) return;
|
|
const deadline = Date.now() + 7_500;
|
|
while (
|
|
liveProcesses(await systemProcessOwnershipHost.listProcesses()).some(
|
|
({ pid }) => ready.pids.includes(pid),
|
|
)
|
|
) {
|
|
if (Date.now() >= deadline)
|
|
throw new Error(
|
|
"Authored setup processes did not consume their release marker",
|
|
);
|
|
await delay(25);
|
|
}
|
|
};
|
|
|
|
// The executable only acknowledges terminal shape and owns a harmless child.
|
|
// The PTY, deadline, process census, command owner and workspace removal are real.
|
|
processTest.skipIf(process.platform === "win32")(
|
|
"joins a redirected-stdout setup descendant before removing its verifier workspace",
|
|
{ timeout: 35_000 },
|
|
async ({ processes }) => {
|
|
const root = await createTestTempDirectory("rea-package-setup-terminal-");
|
|
const scratch = join(root, "scratch");
|
|
const entry = join(root, "setup stand-in.mjs");
|
|
const descendant = join(root, "descendant.mjs");
|
|
const ready = join(root, "ready.json");
|
|
const release = join(root, "release-setup");
|
|
const events = join(root, "events.jsonl");
|
|
const driver = join(root, "driver.mjs");
|
|
await writeFile(entry, `#!${process.execPath}\n${setupStandinSource}`);
|
|
await chmod(entry, 0o755);
|
|
await writeFile(descendant, descendantSource);
|
|
const moduleUrl = (path: string) => pathToFileURL(resolve(path)).href;
|
|
await writeFile(
|
|
driver,
|
|
`
|
|
import { existsSync } from "node:fs";
|
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { createPackageVerifierCommands } from ${JSON.stringify(moduleUrl("scripts/lib/verify-package-commands.mjs"))};
|
|
import { verifyRedirectedStdoutSetup } from ${JSON.stringify(moduleUrl("scripts/lib/verify-setup-terminal.mjs"))};
|
|
import { withVerifierCleanup } from ${JSON.stringify(moduleUrl("scripts/lib/verifier-cleanup.mjs"))};
|
|
import { systemProcessOwnershipHost } from ${JSON.stringify(moduleUrl("dist/process/ProcessOwnershipObservation.js"))};
|
|
import { liveProcesses } from ${JSON.stringify(moduleUrl("dist/process/ProcessOwnershipProcessTree.js"))};
|
|
const scratch = process.env.HOME;
|
|
await mkdir(scratch);
|
|
const config = ${JSON.stringify(join(scratch, "selected-config.json"))};
|
|
await writeFile(config, ${JSON.stringify(unchangedConfiguration)});
|
|
let commands, recoveryOwner, ready, liveAtRemoval, configurationAtRemoval, failure;
|
|
let removed = false;
|
|
const workspace = {
|
|
path: scratch,
|
|
async close() {
|
|
if (removed) return;
|
|
ready = JSON.parse(await readFile(process.env.TERMINAL_READY, "utf8"));
|
|
liveAtRemoval = liveProcesses(await systemProcessOwnershipHost.listProcesses()).filter(({ pid }) => ready.pids.includes(pid)).map(({ pid }) => pid);
|
|
configurationAtRemoval = await readFile(config, "utf8");
|
|
await rm(scratch, { recursive: true, force: true });
|
|
removed = true;
|
|
},
|
|
};
|
|
try {
|
|
await withVerifierCleanup(async () => {
|
|
commands = await createPackageVerifierCommands({ workspace });
|
|
await verifyRedirectedStdoutSetup({ commands, command: ${JSON.stringify(entry)}, environment: process.env, root: ${JSON.stringify(process.cwd())}, unchangedPaths: [config] });
|
|
}, [() => commands === undefined ? workspace.close() : commands.close()]);
|
|
} catch (cause) {
|
|
const failures = cause instanceof AggregateError ? cause.errors : [cause];
|
|
// Capture exact returned cleanup authority before projecting or asserting facts.
|
|
recoveryOwner = failures.find(error => typeof error?.cleanupOwner?.close === "function")?.cleanupOwner ?? commands;
|
|
const primary = failures.find(error => error instanceof Error && error.message.startsWith(${JSON.stringify(timeoutMessage)}));
|
|
failure = primary === undefined ? undefined : { name: primary.name, message: primary.message };
|
|
} finally {
|
|
if (recoveryOwner !== undefined) await recoveryOwner.close();
|
|
else if (commands !== undefined) await commands.close();
|
|
else await workspace.close();
|
|
}
|
|
process.stdout.write(JSON.stringify({ ready, liveAtRemoval, configurationAtRemoval, rootRemoved: !existsSync(scratch), failure }));
|
|
`,
|
|
);
|
|
try {
|
|
const result = await processes.run(process.execPath, [driver], {
|
|
timeoutMs: 25_000,
|
|
cwd: process.cwd(),
|
|
env: {
|
|
...process.env,
|
|
HOME: scratch,
|
|
USERPROFILE: scratch,
|
|
TERMINAL_DESCENDANT: descendant,
|
|
TERMINAL_READY: ready,
|
|
TERMINAL_RELEASE: release,
|
|
TERMINAL_EVENTS: events,
|
|
},
|
|
});
|
|
expect(result.exitCode, result.stderr).toBe(0);
|
|
expect(result.signal, result.stderr).toBeNull();
|
|
const report = reportSchema.parse(JSON.parse(result.stdout));
|
|
expect(report.failure.name).toBe("Error");
|
|
expect(report.failure.message).toContain(timeoutMessage);
|
|
expect(report.failure.message).toContain("authored setup pending");
|
|
expect(report.configurationAtRemoval).toBe(unchangedConfiguration);
|
|
expect(report.liveAtRemoval).toEqual([]);
|
|
expect(report.rootRemoved).toBe(true);
|
|
} finally {
|
|
await releaseStandin(release, ready);
|
|
}
|
|
},
|
|
);
|
|
|
|
const interactiveStandinSource = `
|
|
import assert from "node:assert/strict";
|
|
import { writeFileSync } from "node:fs";
|
|
assert.deepEqual(process.argv.slice(2), ["setup"]);
|
|
const tty = { stdin: Boolean(process.stdin.isTTY), stdout: Boolean(process.stdout.isTTY), stderr: Boolean(process.stderr.isTTY) };
|
|
assert.deepEqual(tty, { stdin: true, stdout: true, stderr: true });
|
|
process.stdin.setRawMode(true);
|
|
process.stdin.on("data", (data) => {
|
|
assert.deepEqual([...data], [3]);
|
|
process.stdin.setRawMode(false);
|
|
process.stdin.pause();
|
|
writeFileSync(process.env.TERMINAL_RECEIPT, JSON.stringify({ mode: "interactive", pid: process.pid, tty, interrupt: [...data], rawRestored: process.stdin.isRaw === false, exitStatus: 0 }));
|
|
process.stdout.write("\\u001b[?25hNo changes were made.\\n", () => process.exit(0));
|
|
});
|
|
process.stdin.resume();
|
|
process.stdout.write("Which agents should use REA?");
|
|
`;
|
|
|
|
const redirectedStandinSource = `
|
|
import assert from "node:assert/strict";
|
|
import { writeFileSync } from "node:fs";
|
|
assert.deepEqual(process.argv.slice(2), ["setup"]);
|
|
const tty = { stdin: Boolean(process.stdin.isTTY), stdout: Boolean(process.stdout.isTTY), stderr: Boolean(process.stderr.isTTY) };
|
|
assert.deepEqual(tty, { stdin: true, stdout: false, stderr: true });
|
|
const plainOutput = "Authored plain setup output.\\n";
|
|
writeFileSync(process.env.TERMINAL_RECEIPT, JSON.stringify({ mode: "redirected", pid: process.pid, tty, plainOutput, exitStatus: 1 }));
|
|
process.stdout.write(plainOutput);
|
|
process.exitCode = 1;
|
|
`;
|
|
|
|
const positiveReceiptSchema = z.discriminatedUnion("mode", [
|
|
z.strictObject({
|
|
mode: z.literal("interactive"),
|
|
pid: z.number().int().positive(),
|
|
tty: z.strictObject({
|
|
stdin: z.literal(true),
|
|
stdout: z.literal(true),
|
|
stderr: z.literal(true),
|
|
}),
|
|
interrupt: z.tuple([z.literal(3)]),
|
|
rawRestored: z.literal(true),
|
|
exitStatus: z.literal(0),
|
|
}),
|
|
z.strictObject({
|
|
mode: z.literal("redirected"),
|
|
pid: z.number().int().positive(),
|
|
tty: z.strictObject({
|
|
stdin: z.literal(true),
|
|
stdout: z.literal(false),
|
|
stderr: z.literal(true),
|
|
}),
|
|
plainOutput: z.literal("Authored plain setup output.\n"),
|
|
exitStatus: z.literal(1),
|
|
}),
|
|
]);
|
|
|
|
processTest
|
|
.skipIf(process.platform === "win32")
|
|
.each(["interactive", "redirected"] as const)(
|
|
"preserves selected configuration and closes the real PTY for %s setup",
|
|
{ timeout: 30_000 },
|
|
async (mode) => {
|
|
const root = await createTestTempDirectory("rea-package-setup-success-");
|
|
const scratch = join(root, "scratch");
|
|
await mkdir(scratch);
|
|
const config = join(scratch, "selected-config.json");
|
|
await writeFile(config, unchangedConfiguration);
|
|
const entry = join(root, "selected setup.mjs");
|
|
const receiptPath = join(root, "terminal-receipt.json");
|
|
await writeFile(
|
|
entry,
|
|
`#!${process.execPath}\n${mode === "interactive" ? interactiveStandinSource : redirectedStandinSource}`,
|
|
);
|
|
await chmod(entry, 0o755);
|
|
let commands: PackageVerifierCommands | undefined;
|
|
let receipt: z.output<typeof positiveReceiptSchema> | undefined;
|
|
let configurationAtRemoval: string | undefined;
|
|
let liveAtRemoval: readonly number[] | undefined;
|
|
const workspace = {
|
|
path: scratch,
|
|
async close() {
|
|
const pid = receipt?.pid;
|
|
liveAtRemoval = liveProcesses(
|
|
await systemProcessOwnershipHost.listProcesses(),
|
|
)
|
|
.filter((entry) => entry.pid === pid)
|
|
.map(({ pid }) => pid);
|
|
configurationAtRemoval = await readFile(config, "utf8");
|
|
await rm(scratch, { recursive: true, force: true });
|
|
},
|
|
};
|
|
await withVerifierCleanup(async () => {
|
|
commands = await createPackageVerifierCommands({ workspace });
|
|
const options = {
|
|
commands,
|
|
command: entry,
|
|
environment: {
|
|
...process.env,
|
|
HOME: scratch,
|
|
TERMINAL_RECEIPT: receiptPath,
|
|
},
|
|
root: process.cwd(),
|
|
unchangedPaths: [config],
|
|
};
|
|
if (mode === "interactive") await verifyInteractiveSetup(options);
|
|
else await verifyRedirectedStdoutSetup(options);
|
|
receipt = positiveReceiptSchema.parse(
|
|
JSON.parse(await readFile(receiptPath, "utf8")),
|
|
);
|
|
}, [() => (commands === undefined ? workspace.close() : commands.close())]);
|
|
|
|
expect(receipt).toMatchObject({ mode });
|
|
expect(configurationAtRemoval).toBe(unchangedConfiguration);
|
|
expect(liveAtRemoval).toEqual([]);
|
|
await expect(readFile(config)).rejects.toMatchObject({ code: "ENOENT" });
|
|
},
|
|
);
|