mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
Replace duplicate path-helper checks with real ZIP inventory coverage and replace global memory sampling with bounded-chunk assertions while retaining 64 MiB digest coverage. Await artifact client cleanup, preserve Hopper lease ownership during teardown, and expose typed application failures.
173 lines
6.1 KiB
TypeScript
173 lines
6.1 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/hopper/HopperTargetLease.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);
|
|
expect(launcher.cleanupAttempts).toBe(1);
|
|
expect(await launcher.acquire("other-owner")).toMatchObject({
|
|
acquired: false,
|
|
});
|
|
expect(await session.open(second)).toMatchObject({
|
|
ok: false,
|
|
error: { cleanupIncomplete: true },
|
|
});
|
|
expect(clientsCreated).toBe(1);
|
|
expect(launcher.cleanupAttempts).toBe(2);
|
|
launcher.cleanupAllowed = true;
|
|
expect(await session.close()).toEqual(resultOk(null));
|
|
expect(launcher.cleanupAttempts).toBe(3);
|
|
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);
|
|
});
|