test(hopper): verify external exit recovery and retained ownership
This commit is contained in:
1 parent
3c4bd35180
commit
b01db67586
2 files changed
+286
-12
No files matched your search
@@ -89,18 +89,21 @@ describe("external Hopper process exit confirmation", () => {
|
||||
expect(released).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it("retains cleanup when process identity cannot be verified", async () => {
|
||||
const resources = externalApplicationResources();
|
||||
const releaseTransport = vi.fn();
|
||||
await confirmExternalApplicationExit(
|
||||
resources,
|
||||
undefined,
|
||||
releaseTransport,
|
||||
async () => "unverified",
|
||||
);
|
||||
expect(resources.shutdownConfirmed).toBe(false);
|
||||
expect(releaseTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["present", "unverified"] as const)(
|
||||
"does not confirm an external application while its exact identity is %s",
|
||||
async (observation) => {
|
||||
const resources = externalApplicationResources();
|
||||
const releaseTransport = vi.fn();
|
||||
await confirmExternalApplicationExit(
|
||||
resources,
|
||||
undefined,
|
||||
releaseTransport,
|
||||
async () => observation,
|
||||
);
|
||||
expect(resources.shutdownConfirmed).toBe(false);
|
||||
expect(releaseTransport).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Hopper shutdown rejection diagnostics", () => {
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisClient,
|
||||
type AnalysisOperation,
|
||||
type AnalysisProviderCandidate,
|
||||
type CapabilityDescriptor,
|
||||
type ProviderIdentity,
|
||||
} from "../../../../src/application/AnalysisProvider.js";
|
||||
import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js";
|
||||
import { BinarySession } from "../../../../src/application/binary/BinarySession.js";
|
||||
import { SessionProviderRouter } from "../../../../src/application/binary/SessionProviderRouter.js";
|
||||
import { createAnalysisProfile } from "../../../../src/domain/analysisProfile.js";
|
||||
import { ok } from "../../../../src/domain/result.js";
|
||||
import type { BridgeSession } from "../../../../src/hopper/BridgeLauncher.js";
|
||||
import {
|
||||
cleanupHopperSession,
|
||||
type HopperOwnedResources,
|
||||
} from "../../../../src/hopper/HopperCleanup.js";
|
||||
import { HopperClient } from "../../../../src/hopper/HopperClient.js";
|
||||
import { silentLogger } from "../../../../src/logger.js";
|
||||
import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js";
|
||||
import { observeProcessStartIdentity } from "../../../../src/process/ProcessOwnershipObservation.js";
|
||||
import { HopperFixtureLauncher } from "./hopperClient.fixture.js";
|
||||
|
||||
const identities = {
|
||||
hopper: { id: "hopper", name: "Hopper fixture", version: null },
|
||||
ghidra: { id: "ghidra", name: "Ghidra fixture", version: null },
|
||||
} satisfies Record<string, ProviderIdentity>;
|
||||
const executionIdentities = {
|
||||
hopper: { ...identities.hopper, version: "1" },
|
||||
ghidra: { ...identities.ghidra, version: "1" },
|
||||
} satisfies Record<string, ProviderIdentity>;
|
||||
|
||||
const makeCandidate = (
|
||||
id: keyof typeof identities,
|
||||
createClient: () => AnalysisClient,
|
||||
): AnalysisProviderCandidate => {
|
||||
const identity = identities[id];
|
||||
const capability: CapabilityDescriptor = {
|
||||
provider: identity,
|
||||
operation: "address_name",
|
||||
available: true,
|
||||
reason: null,
|
||||
cachePolicy: "live",
|
||||
effects: {
|
||||
mutatesArtifact: false,
|
||||
launchesProcess: id === "hopper",
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem: false,
|
||||
changesPermissions: false,
|
||||
requiresRoot: false,
|
||||
},
|
||||
limitations: [],
|
||||
};
|
||||
return {
|
||||
identity: () => identity,
|
||||
capabilities: () => [capability],
|
||||
inspectAvailability: () => ({
|
||||
status: "available",
|
||||
code: null,
|
||||
reason: null,
|
||||
diagnostics: {},
|
||||
}),
|
||||
inspectTargetSupport: () => ({
|
||||
status: "supported",
|
||||
code: null,
|
||||
reason: null,
|
||||
diagnostics: {},
|
||||
}),
|
||||
resolveAnalysisProfile: () =>
|
||||
Promise.resolve(
|
||||
ok({
|
||||
profile: createAnalysisProfile(
|
||||
{ id, name: identity.name, version: "1" },
|
||||
{ fixture: id },
|
||||
),
|
||||
}),
|
||||
),
|
||||
createClient: () => createClient(),
|
||||
};
|
||||
};
|
||||
|
||||
const fixtureAnalysisClient = (identity: ProviderIdentity): AnalysisClient => ({
|
||||
execute: (operation: AnalysisOperation) =>
|
||||
Promise.resolve(ok(createAnalysisExecution(null, identity))),
|
||||
close: () => Promise.resolve(ok(null)),
|
||||
});
|
||||
|
||||
const stopFixture = async (child: ChildProcess): Promise<void> => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
const exited = once(child, "exit");
|
||||
child.kill("SIGKILL");
|
||||
await exited;
|
||||
};
|
||||
|
||||
class ExternalFixtureBridge {
|
||||
readonly fixture = new HopperFixtureLauncher();
|
||||
child: ChildProcess | undefined;
|
||||
readonly client: HopperClient;
|
||||
|
||||
constructor() {
|
||||
const thisFixture = this;
|
||||
this.client = new HopperClient({
|
||||
startupTimeoutMs: 2_000,
|
||||
launcher: {
|
||||
async launch(session: BridgeSession) {
|
||||
const launched = await thisFixture.fixture.launch(session);
|
||||
if (!launched.ok) return launched;
|
||||
thisFixture.child = launched.value.process;
|
||||
return ok({
|
||||
process: launched.value.process,
|
||||
ownsProcessLifetime: false as const,
|
||||
providerLifetime: "external-application" as const,
|
||||
shutdownMode: "bridge-request" as const,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await this.client.close();
|
||||
for (const child of this.fixture.processes) await stopFixture(child);
|
||||
await Promise.all(
|
||||
this.fixture.directories.map((directory) =>
|
||||
rm(directory, { recursive: true, force: true }),
|
||||
),
|
||||
);
|
||||
}, 45_000);
|
||||
}
|
||||
}
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"retires an exited authenticated Hopper process before switching providers in the same session",
|
||||
async () => {
|
||||
const external = new ExternalFixtureBridge();
|
||||
const session = new BinarySession(
|
||||
SessionProviderRouter.selectable(
|
||||
new AnalysisProviderRegistry([
|
||||
makeCandidate("hopper", () => ({
|
||||
execute: async (operation) => {
|
||||
const started = await external.client.start();
|
||||
if (!started.ok) return started;
|
||||
if (operation === "health")
|
||||
return ok(
|
||||
createAnalysisExecution(null, executionIdentities.hopper),
|
||||
);
|
||||
const observed = await external.client.callTool(operation, {});
|
||||
return observed.ok
|
||||
? ok(
|
||||
createAnalysisExecution(
|
||||
observed.value,
|
||||
executionIdentities.hopper,
|
||||
),
|
||||
)
|
||||
: observed;
|
||||
},
|
||||
close: (options) => external.client.close(options),
|
||||
})),
|
||||
makeCandidate("ghidra", () =>
|
||||
fixtureAnalysisClient(executionIdentities.ghidra),
|
||||
),
|
||||
]),
|
||||
[],
|
||||
),
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
const closed = await session.close();
|
||||
if (!closed.ok) throw closed.error;
|
||||
});
|
||||
|
||||
const opened = await session.open(process.execPath, {
|
||||
providerId: "hopper",
|
||||
});
|
||||
if (!opened.ok) throw opened.error;
|
||||
const observed = await session.execute("address_name", {});
|
||||
if (!observed.ok) throw observed.error;
|
||||
const externalProcess = external.child;
|
||||
if (externalProcess === undefined)
|
||||
throw new Error("The external Hopper bridge process was not recorded");
|
||||
expect(external.fixture.requests.map(({ method }) => method)).toEqual([
|
||||
"health",
|
||||
"address_name",
|
||||
]);
|
||||
|
||||
await stopFixture(externalProcess);
|
||||
|
||||
const switched = await session.open(process.execPath, {
|
||||
providerId: "ghidra",
|
||||
});
|
||||
expect(switched.ok).toBe(true);
|
||||
expect(session.providerIdentity().id).toBe("ghidra");
|
||||
expect(session.status()).toMatchObject({ open: true });
|
||||
expect(external.fixture.requests.map(({ method }) => method)).toEqual([
|
||||
"health",
|
||||
"address_name",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"retains cleanup while the authenticated Hopper process is present and releases it after exit",
|
||||
async () => {
|
||||
const external = new ExternalFixtureBridge();
|
||||
const started = await external.client.start();
|
||||
if (!started.ok) throw started.error;
|
||||
const externalProcess = external.child;
|
||||
if (externalProcess === undefined)
|
||||
throw new Error("The external Hopper bridge process was not recorded");
|
||||
const identity = await observeProcessStartIdentity(started.value.processId);
|
||||
if (identity?.state !== "readable")
|
||||
throw new Error(
|
||||
"The authenticated fixture process identity was unavailable",
|
||||
);
|
||||
|
||||
const runtimeRoot = await PrivateRuntimeRoot.create();
|
||||
onTestFinished(() => runtimeRoot.close());
|
||||
let leaseReleases = 0;
|
||||
const resources: HopperOwnedResources = {
|
||||
launch: {
|
||||
process: externalProcess,
|
||||
ownsProcessLifetime: false,
|
||||
providerLifetime: "external-application",
|
||||
shutdownMode: "bridge-request",
|
||||
releaseLease: async () => void (leaseReleases += 1),
|
||||
},
|
||||
processSupervisor: undefined,
|
||||
runtimeRoot,
|
||||
shutdownConfirmed: false,
|
||||
externalApplication: {
|
||||
pid: started.value.processId,
|
||||
identity,
|
||||
},
|
||||
};
|
||||
const cleanup = () =>
|
||||
cleanupHopperSession({
|
||||
socket: undefined,
|
||||
resources,
|
||||
activeRequest: null,
|
||||
retainDocument: false,
|
||||
progress: undefined,
|
||||
logger: silentLogger,
|
||||
onDiagnostic: undefined,
|
||||
request: async () => {
|
||||
throw new Error("Cleanup must not request a disconnected bridge");
|
||||
},
|
||||
releaseTransport: () => undefined,
|
||||
});
|
||||
|
||||
const whilePresent = await cleanup();
|
||||
expect(whilePresent).toMatchObject({
|
||||
ok: false,
|
||||
error: { cleanupResources: ["hopper-document", runtimeRoot.path] },
|
||||
});
|
||||
expect(resources.shutdownConfirmed).toBe(false);
|
||||
expect(leaseReleases).toBe(0);
|
||||
|
||||
await stopFixture(externalProcess);
|
||||
|
||||
expect(await cleanup()).toEqual({ ok: true, value: null });
|
||||
expect(resources.launch).toBeUndefined();
|
||||
expect(resources.runtimeRoot).toBeUndefined();
|
||||
expect(leaseReleases).toBe(1);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user