* 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>
179 lines
6.6 KiB
TypeScript
179 lines
6.6 KiB
TypeScript
import { access, mkdtemp, rmdir } from "node:fs/promises";
|
|
import { expect, it, onTestFinished } from "vitest";
|
|
|
|
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
|
import { HopperStartError } from "../../../src/domain/hopperErrors.js";
|
|
import { err, ok as resultOk } from "../../../src/domain/result.js";
|
|
import type {
|
|
BridgeLauncher,
|
|
BridgeSession,
|
|
} from "../../../src/hopper/BridgeLauncher.js";
|
|
import { HopperClient } from "../../../src/hopper/HopperClient.js";
|
|
import { acquireHopperTargetLease } from "../../../src/process/HopperLease.js";
|
|
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
|
|
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
|
|
import { hopperFixturePath } from "../providers/hopper/hopperClient.fixture.js";
|
|
import {
|
|
createBinarySessionTargets,
|
|
createTestBinarySession,
|
|
} from "../../fixtures/binarySession.js";
|
|
|
|
const createShortLeaseDirectory = async (): Promise<string> => {
|
|
// A redirected TMPDIR can exceed macOS's Unix socket path limit once
|
|
// Hopper appends its lease key. Remove only an empty directory after the
|
|
// owning client has closed; recursive removal could hide a retained lease.
|
|
const directory = await mkdtemp("/tmp/rea-hl-");
|
|
onTestFinished(async () => {
|
|
await rmdir(directory);
|
|
await expect(access(directory)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
return directory;
|
|
};
|
|
|
|
class RetryingHopperLauncher implements BridgeLauncher {
|
|
cleanupAllowed = false;
|
|
cleanupAttempts = 0;
|
|
directory: string | undefined;
|
|
|
|
constructor(
|
|
readonly leaseDirectory: string,
|
|
readonly targetPath: string,
|
|
readonly cleanupRequired = false,
|
|
) {}
|
|
|
|
acquire(runId: string) {
|
|
return acquireHopperTargetLease({
|
|
targetPath: this.targetPath,
|
|
targetKind: "database",
|
|
loaderArgs: [],
|
|
runId,
|
|
directory: this.leaseDirectory,
|
|
});
|
|
}
|
|
|
|
async launch(session: BridgeSession) {
|
|
const lease = await this.acquire(session.runId);
|
|
if (!lease.acquired)
|
|
return err(
|
|
new HopperStartError({ userMessage: "fixture lease is held" }),
|
|
);
|
|
this.directory = session.directory;
|
|
const started = await spawnOwnedProviderProcess({
|
|
command: process.execPath,
|
|
arguments: [
|
|
hopperFixturePath,
|
|
session.socketPath,
|
|
session.token,
|
|
session.runId,
|
|
this.cleanupRequired ? "cleanup-required" : "acknowledge",
|
|
],
|
|
runId: session.runId,
|
|
expectedCommand: null,
|
|
});
|
|
return resultOk({
|
|
...started,
|
|
ownsProcessLifetime: true as const,
|
|
providerLifetime: "launcher-process" as const,
|
|
shutdownMode: "process-cleanup" as const,
|
|
cleanup: () => {
|
|
this.cleanupAttempts += 1;
|
|
return this.cleanupAllowed
|
|
? cleanupOwnedProcessGroup(started.ownership)
|
|
: Promise.resolve({
|
|
cleaned: false as const,
|
|
reason: "fixture process cleanup not confirmed",
|
|
});
|
|
},
|
|
releaseLease: () => lease.lease.release(),
|
|
});
|
|
}
|
|
}
|
|
|
|
it("retains Hopper process handles, private files, and lease until an actual session cleanup retry succeeds", async () => {
|
|
const leaseDirectory = await createShortLeaseDirectory();
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const launcher = new RetryingHopperLauncher(leaseDirectory, first);
|
|
const hopper = new HopperClient({ launcher, startupTimeoutMs: 10_000 });
|
|
let clientsCreated = 0;
|
|
const session = createTestBinarySession(() => {
|
|
clientsCreated += 1;
|
|
return {
|
|
execute: async () => {
|
|
const started = await hopper.start();
|
|
return started.ok
|
|
? resultOk(
|
|
createAnalysisExecution(null, {
|
|
id: "hopper",
|
|
name: "fixture Hopper",
|
|
version: null,
|
|
}),
|
|
)
|
|
: started;
|
|
},
|
|
close: (options) => hopper.close(options),
|
|
};
|
|
});
|
|
onTestFinished(async () => {
|
|
launcher.cleanupAllowed = true;
|
|
const sessionClosed = await session.close();
|
|
if (!sessionClosed.ok) throw sessionClosed.error;
|
|
const hopperClosed = await hopper.close();
|
|
if (!hopperClosed.ok) throw hopperClosed.error;
|
|
});
|
|
expect(await session.open(first)).toMatchObject({ ok: true });
|
|
expect(await session.close()).toMatchObject({
|
|
ok: false,
|
|
error: { cleanupIncomplete: true },
|
|
});
|
|
const runtime = launcher.directory;
|
|
if (runtime === undefined)
|
|
throw new Error("Fixture did not capture its runtime");
|
|
await access(runtime);
|
|
// The fixture exits after its shutdown acknowledgement. When the supervisor
|
|
// observes that exit only after a failed verification began, it verifies
|
|
// once more; a failed stop never runs more than two verifications.
|
|
const firstCloseAttempts = launcher.cleanupAttempts;
|
|
expect(firstCloseAttempts).toBeGreaterThanOrEqual(1);
|
|
expect(firstCloseAttempts).toBeLessThanOrEqual(2);
|
|
expect(await launcher.acquire("other-owner")).toMatchObject({
|
|
acquired: false,
|
|
});
|
|
expect(await session.open(second)).toMatchObject({
|
|
ok: false,
|
|
error: { cleanupIncomplete: true },
|
|
});
|
|
expect(clientsCreated).toBe(1);
|
|
// The launcher exit was already observed, so the retry has no new evidence.
|
|
expect(launcher.cleanupAttempts).toBe(firstCloseAttempts + 1);
|
|
launcher.cleanupAllowed = true;
|
|
expect(await session.close()).toEqual(resultOk(null));
|
|
expect(launcher.cleanupAttempts).toBe(firstCloseAttempts + 2);
|
|
await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" });
|
|
const replacement = await launcher.acquire("replacement-owner");
|
|
expect(replacement.acquired).toBe(true);
|
|
if (replacement.acquired) await replacement.lease.release();
|
|
expect((await session.open(second)).ok).toBe(true);
|
|
expect(clientsCreated).toBe(2);
|
|
});
|
|
|
|
it("reports a direct Hopper close failure and retries the retained owned process", async () => {
|
|
const leaseDirectory = await createShortLeaseDirectory();
|
|
const [target] = await createBinarySessionTargets();
|
|
const launcher = new RetryingHopperLauncher(leaseDirectory, target, true);
|
|
const hopper = new HopperClient({ launcher, startupTimeoutMs: 10_000 });
|
|
onTestFinished(async () => {
|
|
launcher.cleanupAllowed = true;
|
|
const closed = await hopper.close();
|
|
if (!closed.ok) throw closed.error;
|
|
});
|
|
expect(await hopper.start()).toMatchObject({ ok: true });
|
|
expect(await hopper.close()).toMatchObject({
|
|
ok: false,
|
|
error: { cleanupIncomplete: true },
|
|
});
|
|
expect(launcher.cleanupAttempts).toBe(1);
|
|
launcher.cleanupAllowed = true;
|
|
expect(await hopper.close()).toEqual(resultOk(null));
|
|
expect(launcher.cleanupAttempts).toBe(2);
|
|
});
|