Files
reverse-engineering-anything/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts
morluto 0e1f0bf2d0 fix(lifecycle): preserve ownership, cancellation, and cleanup outcomes (#4238)
* 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
2026-10-16 05:09:35 +08:00

255 lines
8.1 KiB
TypeScript

import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js";
import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { err, ok } from "../../../src/domain/result.js";
import { silentLogger } from "../../../src/logger.js";
import { createServer } from "../../../src/server/createServer.js";
import { observed } from "../../fixtures/analysisExecution.js";
import {
createBinarySessionTargets,
createCacheProvider,
createDeferred,
createTestBinarySession,
} from "../../fixtures/binarySession.js";
it("restores MCP tool availability after cancelling a target switch", async () => {
const [first, second] = await createBinarySessionTargets();
const closing = createDeferred<void>();
const release = createDeferred<void>();
const cancellationObserved = createDeferred<void>();
const provider = createCacheProvider([]);
const resolveProfile = provider.resolveAnalysisProfile;
if (resolveProfile === undefined)
throw new Error("Missing test analysis profile");
const clients: { calls: number; closed: number }[] = [];
provider.createClient = () => {
const state = { calls: 0, closed: 0 };
const firstClient = clients.length === 0;
clients.push(state);
return {
execute: (operation) => {
state.calls += 1;
return Promise.resolve(
firstClient
? err(new ProviderAdapterError("fixture", operation))
: observed("restored-name"),
);
},
close: async () => {
state.closed += 1;
if (firstClient) {
closing.resolve();
await release.promise;
}
return ok(null);
},
};
};
const session = createTestBinarySession(provider, {
resolveAnalysisProfile: (target, options) => {
if (target.path === second) {
options?.signal?.addEventListener(
"abort",
() => cancellationObserved.resolve(),
{ once: true },
);
}
return resolveProfile(target, options);
},
});
const server = createServer(
{ kind: "session", session },
{ environment: {}, logger: silentLogger },
);
const client = new Client({ name: "session-recovery", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const controller = new AbortController();
const readStatus = async () => {
const reply = await client.callTool({
name: "binary_session",
arguments: {},
});
return toolContract("binary_session").outputSchema.parse(
reply.structuredContent,
).result;
};
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const opened = await client.callTool({
name: "open_binary",
arguments: { path: first },
});
expect(opened.isError).not.toBe(true);
const query = { address: "0x1000", document: "fixture" };
expect(
(await client.callTool({ name: "address_name", arguments: query }))
.isError,
).toBe(true);
const before = await readStatus();
expect(
expandToolAvailability(before.tool_availability).find(
(tool) => tool.name === "address_name",
),
).toMatchObject({
available: false,
reason: "provider_unavailable",
});
const switching = client.callTool(
{ name: "open_binary", arguments: { path: second } },
{ signal: controller.signal },
);
const cancelled = expect(switching).rejects.toThrow();
await closing.promise;
controller.abort();
await cancellationObserved.promise;
release.resolve();
await cancelled;
// Cancellation ends the client's wait before the server restores its session.
const settled = await session.withAdmittedAnalysis(
"binary_session",
undefined,
async () => null,
);
if (!settled.ok) throw settled.error;
const restored = await readStatus();
expect(restored).toMatchObject({ open: true, path: first });
expect(restored.analysis_run?.run_id).not.toBe(before.analysis_run?.run_id);
expect(clients).toEqual([{ calls: 1, closed: 1 }]);
expect(
expandToolAvailability(restored.tool_availability).find(
(tool) => tool.name === "address_name",
),
).toMatchObject({
available: true,
reason: "available",
remediation: null,
});
const retried = await client.callTool({
name: "address_name",
arguments: query,
});
expect(retried.isError).not.toBe(true);
expect(retried.structuredContent).toMatchObject({
normalized_result: "restored-name",
});
expect(clients).toEqual([
{ calls: 1, closed: 1 },
{ calls: 1, closed: 0 },
]);
} finally {
controller.abort();
release.resolve();
await client.close();
await server.close();
await session.close();
}
});
it("keeps a retired run and its lineage visible over MCP until cleanup succeeds", async () => {
const [path] = await createBinarySessionTargets();
let allowed = false;
const provider = createCacheProvider([]);
provider.createClient = (_target, _profile, context) => {
if (context === undefined) throw new Error("missing analysis run context");
return {
execute: () => Promise.resolve(observed("fixture-name")),
runtimeLineageSnapshots: () => [
{
provider: provider.identity(),
observation: {
status: "unavailable" as const,
observedAt: "2026-10-16T00:00:00Z",
runId: context.runId,
launcherPid: 123,
processGroupId: 123,
reason: "fixture identity could not be revalidated",
},
},
],
close: () =>
Promise.resolve(
allowed
? ok(null)
: err(
new ProviderCleanupError("fixture", ["owned-runtime"], {
reason: "fixture cleanup denied",
}),
),
),
};
};
const session = createTestBinarySession(provider);
const server = createServer(
{ kind: "session", session },
{ environment: {}, logger: silentLogger },
);
const client = new Client({ name: "retained-run", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const readStatus = async () => {
const reply = await client.callTool({
name: "binary_session",
arguments: {},
});
return toolContract("binary_session").outputSchema.parse(
reply.structuredContent,
).result;
};
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
expect((await readStatus()).analysis_run).toBeNull();
expect(
(await client.callTool({ name: "open_binary", arguments: { path } }))
.isError,
).not.toBe(true);
expect(
(
await client.callTool({
name: "address_name",
arguments: { address: "0x1000", document: "fixture" },
})
).isError,
).not.toBe(true);
const before = await readStatus();
expect(before.analysis_run).toMatchObject({
run_id: expect.any(String),
process_lineage: { status: "snapshots" },
});
for (let attempt = 0; attempt < 2; attempt++) {
expect(
(await client.callTool({ name: "close_binary", arguments: {} }))
.isError,
).toBe(true);
expect(await readStatus()).toMatchObject({
open: false,
cleanup_pending: true,
analysis_run: before.analysis_run,
});
}
allowed = true;
expect(
(await client.callTool({ name: "close_binary", arguments: {} })).isError,
).not.toBe(true);
expect(await readStatus()).toMatchObject({
open: false,
cleanup_pending: false,
analysis_run: null,
});
} finally {
allowed = true;
await client.close();
await server.close();
await session.close();
}
});