mirror of
https://github.com/morluto/rea
synced 2026-10-27 14:42:18 -04:00
0e1f0bf2d0
* fix(cleanup): preserve owner recovery instructions Keep cleanup remediation through combined errors and direct or managed one-shot analysis while retaining primary diagnostics. Refs: #4227 * fix(hopper): recover late startup and retained cleanup Authenticate initial and cleanup handshakes through the same identity owner. Expose startup and cleanup activity, retain health after target retirement, and expire abandoned unauthenticated listeners without timing out authenticated work. Refs: #4227 * fix(providers): retain composite lifecycle facts across retries Preserve observed child health with incomplete peer coverage, retry only unresolved clients, and retain all child cleanup recovery actions. Refs: #4233 * fix(process): honor cancellation through capture cleanup Check the request signal after cleanup and before final publication, retaining completed observations under typed cancellation. Refs: #4005 * fix(native): preserve failed LLDB launch ownership and exit proof Publish failed-launch process identity and observed exit separately from object validity. Retire only the exact owned process and retain uncertainty when exit cannot be established. Refs: #4232 * fix(native): honor cancellation after trace finalization Check cancellation after runtime-root cleanup and retain collected trace observations as partial cancellation evidence. Refs: #4009 * fix(cli): preserve analysis results when provider cleanup fails Reuse the existing one-shot finalizer across thirteen command families to retain completed Evidence and primary diagnostics on close failure. Cover success, denied cleanup, and combined primary/cleanup failure through the public CLI. Refs: #3935 * docs(lifecycle): define completion invariants and regression scenarios Document distinct ownership/readiness/exit facts, late cancellation and atomic publication boundaries, partial child cleanup, and diagnostic retention. Link representative regressions and route contributor guidance through AGENTS.md. Refs: #4227 * fix(session): retain run identity through pending cleanup * fix(cli): preserve original cleanup resource diagnostics * fix(native): retain completed facts through finalization failure * fix(providers): report retained cleanup paths at their owners
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
|
import { access, rm } from "node:fs/promises";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { promisify } from "node:util";
|
|
|
|
import { expect, it, onTestFinished, vi } from "vitest";
|
|
|
|
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
|
import { ok } from "../../../../src/domain/result.js";
|
|
import type {
|
|
BridgeLauncher,
|
|
BridgeSession,
|
|
} from "../../../../src/hopper/BridgeLauncher.js";
|
|
import { HopperClient } from "../../../../src/hopper/HopperClient.js";
|
|
import { HopperFixtureLauncher } from "./hopperClient.fixture.js";
|
|
|
|
const stop = async (child: ChildProcess): Promise<void> => {
|
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
const exited = new Promise<void>((resolve) =>
|
|
child.once("exit", () => resolve()),
|
|
);
|
|
child.kill("SIGKILL");
|
|
await exited;
|
|
};
|
|
|
|
// The GUI is a sibling owned by the test, never a descendant owned by REA's helper.
|
|
class ExternalBridgeLauncher implements BridgeLauncher {
|
|
readonly fixture: HopperFixtureLauncher;
|
|
readonly sessions: BridgeSession[] = [];
|
|
readonly helpers: ChildProcess[] = [];
|
|
|
|
constructor(
|
|
readonly identity: "matching" | "wrong-run" | "wrong-token" = "matching",
|
|
healthMode: "ready" | "gated" = "ready",
|
|
shutdownMode: "acknowledge" | "exit-without-ack" = "acknowledge",
|
|
) {
|
|
this.fixture = new HopperFixtureLauncher(
|
|
identity === "wrong-token" ? "foreign-token" : undefined,
|
|
"quiet",
|
|
healthMode,
|
|
shutdownMode,
|
|
);
|
|
}
|
|
|
|
async launch(session: BridgeSession) {
|
|
this.sessions.push(session);
|
|
await this.fixture.launch({
|
|
...session,
|
|
runId: this.identity === "wrong-run" ? "foreign-run" : session.runId,
|
|
});
|
|
const helper = spawn(process.execPath, ["-e", "process.exit(0)"], {
|
|
stdio: "ignore",
|
|
});
|
|
this.helpers.push(helper);
|
|
return ok({
|
|
process: helper,
|
|
ownsProcessLifetime: false as const,
|
|
providerLifetime: "external-application" as const,
|
|
shutdownMode: "bridge-request" as const,
|
|
});
|
|
}
|
|
}
|
|
|
|
const setup = (
|
|
identity?: "matching" | "wrong-run" | "wrong-token",
|
|
healthMode?: "ready" | "gated",
|
|
shutdownMode?: "acknowledge" | "exit-without-ack",
|
|
) => {
|
|
const launcher = new ExternalBridgeLauncher(
|
|
identity,
|
|
healthMode,
|
|
shutdownMode,
|
|
);
|
|
const client = new HopperClient({ launcher, startupTimeoutMs: 2_000 });
|
|
onTestFinished(async () => {
|
|
await client.close();
|
|
await Promise.all(
|
|
[...launcher.helpers, ...launcher.fixture.processes].map(stop),
|
|
);
|
|
await Promise.all(
|
|
launcher.sessions.map(({ directory }) =>
|
|
rm(directory, { recursive: true, force: true }),
|
|
),
|
|
);
|
|
}, 45_000);
|
|
return { client, launcher };
|
|
};
|
|
|
|
it.each(["missing", "not-listening"] as const)(
|
|
"waits for a matching bridge published during close after a %s socket failure",
|
|
async (socketState) => {
|
|
const fixture = new HopperFixtureLauncher();
|
|
let session: BridgeSession | undefined;
|
|
let helper: ChildProcess | undefined;
|
|
const client = new HopperClient({
|
|
startupTimeoutMs: 2_000,
|
|
launcher: {
|
|
launch(value) {
|
|
session = value;
|
|
helper = spawn(process.execPath, ["-e", "process.exit(0)"], {
|
|
stdio: "ignore",
|
|
});
|
|
return Promise.resolve(
|
|
ok({
|
|
process: helper,
|
|
ownsProcessLifetime: false as const,
|
|
providerLifetime: "external-application" as const,
|
|
shutdownMode: "bridge-request" as const,
|
|
}),
|
|
);
|
|
},
|
|
},
|
|
});
|
|
onTestFinished(async () => {
|
|
await Promise.all(
|
|
[...(helper === undefined ? [] : [helper]), ...fixture.processes].map(
|
|
stop,
|
|
),
|
|
);
|
|
if (session !== undefined)
|
|
await rm(session.directory, { recursive: true, force: true });
|
|
});
|
|
const started = await client.start();
|
|
if (started.ok) throw new Error("Expected readiness to time out");
|
|
expect(projectAnalysisError(started.error)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: { diagnostics: { primary_error: { code: "provider_timeout" } } },
|
|
});
|
|
if (session === undefined) throw new Error("Fixture did not launch");
|
|
if (socketState === "not-listening")
|
|
await promisify(execFile)(process.execPath, [
|
|
"-e",
|
|
'require("node:net").createServer().listen(process.argv[1], () => process.exit(0))',
|
|
session.socketPath,
|
|
]);
|
|
const closing = client.close();
|
|
// Publish after close has encountered an unavailable endpoint, within its
|
|
// cleanup window rather than before the caller requests cleanup.
|
|
await delay(150);
|
|
await rm(session.socketPath, { force: true });
|
|
await fixture.launch(session);
|
|
const closed = await closing;
|
|
if (!closed.ok) throw closed.error;
|
|
expect(fixture.requests.map(({ method }) => method)).toEqual([
|
|
"health",
|
|
"shutdown_document",
|
|
]);
|
|
await vi.waitFor(() => expect(fixture.processes[0]?.exitCode).toBe(0));
|
|
await expect(access(session.directory)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each(["wrong-run", "wrong-token"] as const)(
|
|
"never requests document shutdown after the first handshake rejects %s",
|
|
async (identity) => {
|
|
const { client, launcher } = setup(identity);
|
|
const started = await client.start();
|
|
if (started.ok) throw new Error("Expected a rejected handshake");
|
|
expect(projectAnalysisError(started.error)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
});
|
|
await launcher.fixture.waitForRequest("health");
|
|
expect(launcher.fixture.requests.map(({ method }) => method)).toEqual([
|
|
"health",
|
|
]);
|
|
expect(client.operationHealth()).toMatchObject({
|
|
state: "unknown",
|
|
stage: "launch",
|
|
});
|
|
const closed = await client.close();
|
|
if (closed.ok)
|
|
throw new Error("Expected retained foreign bridge ownership");
|
|
expect(projectAnalysisError(closed.error)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
});
|
|
expect(
|
|
launcher.fixture.requests.every(({ method }) => method === "health"),
|
|
).toBe(true);
|
|
expect(launcher.fixture.processes[0]?.exitCode).toBeNull();
|
|
const session = launcher.sessions[0];
|
|
if (session === undefined) throw new Error("Fixture did not launch");
|
|
await access(session.directory);
|
|
},
|
|
);
|
|
|
|
it("reconnects for authenticated cleanup after a previously valid bridge transport fails", async () => {
|
|
const { client, launcher } = setup();
|
|
const started = await client.start();
|
|
if (!started.ok) throw started.error;
|
|
const malformed = await client.callTool("malformed");
|
|
expect(malformed).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "HopperProtocolError" },
|
|
});
|
|
const closed = await client.close();
|
|
if (!closed.ok) throw closed.error;
|
|
expect(launcher.fixture.requests.map(({ method }) => method)).toEqual([
|
|
"health",
|
|
"malformed",
|
|
"health",
|
|
"shutdown_document",
|
|
]);
|
|
await vi.waitFor(() =>
|
|
expect(launcher.fixture.processes[0]?.exitCode).toBe(0),
|
|
);
|
|
const session = launcher.sessions[0];
|
|
if (session === undefined) throw new Error("Fixture did not launch");
|
|
await expect(access(session.directory)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
});
|
|
|
|
it("preserves launcher diagnostics after a health deadline and reauthenticates for cleanup", async () => {
|
|
const { client, launcher } = setup("matching", "gated");
|
|
const starting = client.start();
|
|
const first = await launcher.fixture.waitForRequest("health");
|
|
const started = await starting;
|
|
if (started.ok) throw new Error("Expected a health deadline");
|
|
expect(projectAnalysisError(started.error)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
diagnostics: {
|
|
primary_error: {
|
|
code: "provider_timeout",
|
|
details: {
|
|
timeout_ms: 2_000,
|
|
stage: "startup",
|
|
launcher: { exit_code: 0, output_closed: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(client.requestActivity()).toBeNull();
|
|
const closing = client.close();
|
|
const next = await vi.waitFor(() => {
|
|
const request = launcher.fixture.requests.find(
|
|
({ method, id }) => method === "health" && id !== first.id,
|
|
);
|
|
if (request === undefined)
|
|
throw new Error("Waiting for recovery handshake");
|
|
return request;
|
|
});
|
|
await launcher.fixture.release(next);
|
|
const closed = await closing;
|
|
if (!closed.ok) throw closed.error;
|
|
expect(launcher.fixture.requests.map(({ method }) => method)).toEqual([
|
|
"health",
|
|
"health",
|
|
"shutdown_document",
|
|
]);
|
|
});
|
|
|
|
it("recovers an application exit after the first valid handshake occurs during cleanup", async () => {
|
|
const { client, launcher } = setup("matching", "gated", "exit-without-ack");
|
|
const starting = client.start();
|
|
const first = await launcher.fixture.waitForRequest("health");
|
|
const started = await starting;
|
|
if (started.ok) throw new Error("Expected the first handshake to time out");
|
|
expect(projectAnalysisError(started.error)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
remediation: {
|
|
action: expect.stringContaining("restart the MCP connection"),
|
|
},
|
|
details: {
|
|
diagnostics: {
|
|
primary_error: { code: "provider_timeout" },
|
|
cleanup_error: {
|
|
details: {
|
|
diagnostics: { authenticated_application: "not_observed" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const closing = client.close();
|
|
const next = await vi.waitFor(() => {
|
|
const request = launcher.fixture.requests.find(
|
|
({ method, id }) => method === "health" && id !== first.id,
|
|
);
|
|
if (request === undefined)
|
|
throw new Error("Waiting for cleanup authentication");
|
|
return request;
|
|
});
|
|
await launcher.fixture.release(next);
|
|
const closed = await closing;
|
|
if (!closed.ok) throw closed.error;
|
|
expect(launcher.fixture.requests.map(({ method }) => method)).toEqual([
|
|
"health",
|
|
"health",
|
|
"shutdown_document",
|
|
]);
|
|
const session = launcher.sessions[0];
|
|
if (session === undefined) throw new Error("Fixture did not launch");
|
|
await expect(access(session.directory)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
const restarted = client.start();
|
|
const health = await vi.waitFor(() => {
|
|
const request = launcher.fixture.requests.find(
|
|
({ method, id }) => method === "health" && id > next.id,
|
|
);
|
|
if (request === undefined)
|
|
throw new Error("Waiting for restarted authentication");
|
|
return request;
|
|
});
|
|
await launcher.fixture.release(health);
|
|
const ready = await restarted;
|
|
if (!ready.ok) throw ready.error;
|
|
const observed = await client.callTool("current_document");
|
|
if (!observed.ok) throw observed.error;
|
|
expect(observed.value).toBe("fixture");
|
|
const reclosed = await client.close();
|
|
if (!reclosed.ok) throw reclosed.error;
|
|
});
|