mirror of
https://github.com/morluto/rea
synced 2026-10-26 14:25:49 -04:00
aa7d960ef5
* fix(process): settle owned cleanup with the launcher's own exit proof When owned process-group verification fails while the supervised launcher is still mid-exit, the supervisor now waits up to the kill grace for its own child-exit observation and verifies once more after it arrives. Verification is not repeated when the exit was already observed before cleanup started, so there is no new evidence and the original failure is kept. Nothing is signaled based on the wait. Fixes #6134 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(process): bound Hopper session cleanup attempts by exit evidence The fake Hopper launcher exits after its shutdown acknowledgement. When the supervisor first observes that exit during a failed verification, it verifies once more, so the first close makes one or two attempts depending on timing. The test now asserts that bound. Later session retries, made after the exit was already observed, make exactly one attempt each. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
7.6 KiB
TypeScript
241 lines
7.6 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import { PassThrough } from "node:stream";
|
|
import { afterEach, expect, it, vi } from "vitest";
|
|
import {
|
|
cleanupOwnedProcessGroup,
|
|
type OwnedProcessGroup,
|
|
type ProcessCleanupResult,
|
|
type ProcessIdentityObservation,
|
|
type ProcessOwnershipHost,
|
|
type ProcessTableEntry,
|
|
} from "./ProcessOwnership.js";
|
|
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
|
|
import { ProviderProcessSupervisor } from "./ProviderProcess.js";
|
|
|
|
const JAVA = "/usr/lib/jvm/java-21-openjdk-amd64/bin/java";
|
|
|
|
const ownership = (): OwnedProcessGroup => ({
|
|
runId: "run-6134",
|
|
leaderPid: 910,
|
|
processGroupId: 910,
|
|
expectedParentPid: 17,
|
|
expectedCommand: JAVA,
|
|
sweepTokenOwnedProcesses: true,
|
|
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
|
|
});
|
|
|
|
const launcherRow = (command: string): ProcessTableEntry => ({
|
|
pid: 910,
|
|
parentPid: 17,
|
|
processGroupId: 910,
|
|
state: "R",
|
|
command,
|
|
});
|
|
|
|
const nativeIdentity = (
|
|
lifecycle: "non-zombie" | "exiting",
|
|
): ProcessIdentityObservation => ({
|
|
state: "readable",
|
|
identity: "linux-starttime:4242",
|
|
lifecycle: { state: "observed", lifecycle },
|
|
relation: { state: "observed", parentPid: 17, processGroupId: 910 },
|
|
});
|
|
|
|
/** A launcher child whose exit the supervisor observes only when the test says so. */
|
|
const launcherChild = () => {
|
|
const child = Object.assign(new EventEmitter(), {
|
|
stdout: new PassThrough(),
|
|
stderr: new PassThrough(),
|
|
exitCode: null,
|
|
signalCode: null,
|
|
kill: vi.fn(() => false),
|
|
});
|
|
let exited = false;
|
|
return {
|
|
child,
|
|
get exited() {
|
|
return exited;
|
|
},
|
|
exit() {
|
|
exited = true;
|
|
child.emit("exit", 0, null);
|
|
child.stdout.end();
|
|
child.stderr.end();
|
|
child.emit("close", 0, null);
|
|
},
|
|
};
|
|
};
|
|
|
|
/** Linux-like host: the launcher row stays listed until the child exits. */
|
|
const hostFor = (
|
|
launcher: ReturnType<typeof launcherChild>,
|
|
row: ProcessTableEntry,
|
|
lifecycle?: "non-zombie" | "exiting",
|
|
): ProcessOwnershipHost => {
|
|
const host: ProcessOwnershipHost = {
|
|
platform: "linux",
|
|
listProcesses: vi.fn(async () => (launcher.exited ? [] : [row])),
|
|
environment: vi.fn(async () => ({ REA_PROCESS_RUN_ID: "run-6134" })),
|
|
signalGroup: vi.fn(),
|
|
};
|
|
if (lifecycle === undefined) return host;
|
|
return {
|
|
...host,
|
|
processIdentities: vi.fn(async (rows: readonly ProcessTableEntry[]) => {
|
|
const observation: ProcessIdentityObservation = launcher.exited
|
|
? { state: "absent", reason: "not-found-in-caller-pid-namespace" }
|
|
: nativeIdentity(lifecycle);
|
|
return new Map(rows.map(({ pid }) => [pid, observation] as const));
|
|
}),
|
|
};
|
|
};
|
|
|
|
const supervise = (
|
|
launcher: ReturnType<typeof launcherChild>,
|
|
host: ProcessOwnershipHost,
|
|
) => {
|
|
const results: ProcessCleanupResult[] = [];
|
|
const supervisor = new ProviderProcessSupervisor({
|
|
process: launcher.child,
|
|
ownsProcessLifetime: true,
|
|
ownership: ownership(),
|
|
cleanup: async () => {
|
|
const result = await cleanupOwnedProcessGroup(ownership(), host);
|
|
results.push(result);
|
|
return result;
|
|
},
|
|
});
|
|
return { supervisor, results };
|
|
};
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("re-verifies after the launcher's own exit settles an exiting procps row", async () => {
|
|
const launcher = launcherChild();
|
|
const host = hostFor(launcher, launcherRow("[java]"));
|
|
const { supervisor, results } = supervise(launcher, host);
|
|
// The JVM finishes tearing down only after the single table refresh.
|
|
vi.mocked(host.listProcesses).mockImplementation(async () => {
|
|
if (launcher.exited) return [];
|
|
setTimeout(() => launcher.exit(), 20);
|
|
return [launcherRow("[java]")];
|
|
});
|
|
|
|
await expect(supervisor.stop()).resolves.toEqual({
|
|
status: "verified-cleanup",
|
|
});
|
|
expect(results).toEqual([
|
|
{
|
|
cleaned: false,
|
|
reason: `owned launcher command identity did not match (observed=[java]; expected=${JAVA})`,
|
|
},
|
|
{ cleaned: true, signaled: false },
|
|
]);
|
|
expect(host.signalGroup).not.toHaveBeenCalled();
|
|
expect(launcher.child.kill).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("re-verifies after native exit-in-progress outlasts the settlement deadline", async () => {
|
|
const launcher = launcherChild();
|
|
const host = hostFor(launcher, launcherRow("[java]"), "exiting");
|
|
const { supervisor, results } = supervise(launcher, host);
|
|
const exitTimer = setTimeout(() => launcher.exit(), 1_200);
|
|
try {
|
|
await expect(supervisor.stop({ killGraceMs: 2_000 })).resolves.toEqual({
|
|
status: "verified-cleanup",
|
|
});
|
|
} finally {
|
|
clearTimeout(exitTimer);
|
|
}
|
|
expect(results).toEqual([
|
|
{ cleaned: false, reason: "owned launcher exit could not be confirmed" },
|
|
{ cleaned: true, signaled: false },
|
|
]);
|
|
expect(host.signalGroup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
["identityless", undefined],
|
|
["native", "non-zombie"],
|
|
] as const)(
|
|
"keeps a still-live mismatched %s launcher incomplete without signaling",
|
|
async (_host, lifecycle) => {
|
|
const launcher = launcherChild();
|
|
const host = hostFor(launcher, launcherRow("/usr/bin/other"), lifecycle);
|
|
const { supervisor, results } = supervise(launcher, host);
|
|
const started = performance.now();
|
|
|
|
const stopped = await supervisor.stop({ killGraceMs: 50 });
|
|
|
|
expect(performance.now() - started).toBeGreaterThanOrEqual(45);
|
|
expect(stopped).toEqual({
|
|
status: "incomplete",
|
|
reason: `owned launcher command identity did not match (observed=/usr/bin/other; expected=${JAVA})`,
|
|
cleanupResult: results[0],
|
|
});
|
|
expect(results).toHaveLength(1);
|
|
expect(host.signalGroup).not.toHaveBeenCalled();
|
|
expect(launcher.child.kill).not.toHaveBeenCalled();
|
|
supervisor.dispose();
|
|
},
|
|
);
|
|
|
|
it("does not repeat verification without new launcher exit evidence", async () => {
|
|
const launcher = launcherChild();
|
|
const cleanup = vi.fn(async (): Promise<ProcessCleanupResult> => ({
|
|
cleaned: false,
|
|
reason: "process table could not be inspected: spawn EPERM",
|
|
}));
|
|
const supervisor = new ProviderProcessSupervisor({
|
|
process: launcher.child,
|
|
ownsProcessLifetime: true,
|
|
ownership: ownership(),
|
|
cleanup,
|
|
});
|
|
launcher.exit();
|
|
const started = performance.now();
|
|
|
|
await expect(supervisor.stop()).resolves.toMatchObject({
|
|
status: "incomplete",
|
|
reason: "process table could not be inspected: spawn EPERM",
|
|
});
|
|
expect(cleanup).toHaveBeenCalledOnce();
|
|
expect(performance.now() - started).toBeLessThan(500);
|
|
});
|
|
|
|
it("retains the post-exit verification failure for a later owned retry", async () => {
|
|
const launcher = launcherChild();
|
|
const outcomes: ProcessCleanupResult[] = [
|
|
{ cleaned: false, reason: "owned launcher exit could not be confirmed" },
|
|
{
|
|
cleaned: false,
|
|
reason: "process ownership could not be revalidated for PID 911: EACCES",
|
|
},
|
|
{ cleaned: true, signaled: true },
|
|
];
|
|
const cleanup = vi.fn(async (): Promise<ProcessCleanupResult> => {
|
|
const next = outcomes.shift();
|
|
if (next === undefined) throw new Error("unexpected cleanup attempt");
|
|
if (cleanup.mock.calls.length === 1) setTimeout(() => launcher.exit(), 10);
|
|
return next;
|
|
});
|
|
const supervisor = new ProviderProcessSupervisor({
|
|
process: launcher.child,
|
|
ownsProcessLifetime: true,
|
|
ownership: ownership(),
|
|
cleanup,
|
|
});
|
|
|
|
await expect(supervisor.stop()).resolves.toMatchObject({
|
|
status: "incomplete",
|
|
reason: "process ownership could not be revalidated for PID 911: EACCES",
|
|
});
|
|
expect(cleanup).toHaveBeenCalledTimes(2);
|
|
await expect(supervisor.stop()).resolves.toEqual({
|
|
status: "verified-cleanup",
|
|
});
|
|
expect(cleanup).toHaveBeenCalledTimes(3);
|
|
});
|