fix(session): serialize snapshot lifecycle with admitted requests
This commit is contained in:
parent
8d39eae6b9
commit
e44cb45908
|
|
@ -101,6 +101,15 @@ Cancelling an active request terminates the ephemeral database and discards its
|
|||
annotations. REA retains the selected target and imports the original artifact
|
||||
again on the next Ghidra query.
|
||||
|
||||
Opening the same target and profile again retains the live database, including
|
||||
annotations. It does not make those edits eligible for an immutable snapshot.
|
||||
Snapshot closes drain earlier provider requests before saving and keep later
|
||||
requests waiting until the save and close finish. A concurrent successful edit
|
||||
therefore rejects the snapshot save and leaves the edited session open.
|
||||
After metadata edits, snapshot saves and imports remain unavailable in that
|
||||
session. Use `export_evidence_bundle` to retain the observations, close without
|
||||
`snapshot_path`, and reopen the target before importing or saving a snapshot.
|
||||
|
||||
```bash
|
||||
rea inspect-native-instruction <binary> <address> --provider ghidra
|
||||
rea resolve-native-call-targets <binary> <call-site> --provider ghidra
|
||||
|
|
|
|||
|
|
@ -299,6 +299,15 @@ external addresses remain unresolved and external annotations are rejected.
|
|||
An adversarial regex over a full 12 KB literal must report stack exhaustion as
|
||||
a resource constraint, preserve live annotations, and allow complete literal
|
||||
searches afterward; CLI and MCP must agree on both results and recovery advice.
|
||||
Real snapshot lifecycle checks retain edited API results as Evidence while
|
||||
rejecting immutable snapshot saves and imports before and after a repeated
|
||||
open of the same target. They verify unchanged live annotations and run identity,
|
||||
absent rejected output files, an unchanged source snapshot, and successful
|
||||
snapshot import/save after closing and recreating the database. The pristine
|
||||
snapshot is written by an independent real CLI session.
|
||||
They also start a real annotation and snapshot close concurrently: the edit
|
||||
must succeed, the snapshot must be rejected without creating a file, and the
|
||||
edited session must remain usable until explicitly closed.
|
||||
Malformed annotation readback, memory completeness, and inventory data remain separate
|
||||
SDK/provider integration cases; success from a real
|
||||
provider cannot establish rejection of a contradictory provider response.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import Ajv from "ajv/dist/2020.js";
|
|||
import addFormats from "ajv-formats";
|
||||
import { mcpTextValue, requireMcpResult } from "./mcp-verifier-results.mjs";
|
||||
import { verifyLegacyGhidraReferenceSnapshot } from "./ghidra-reference-snapshot-e2e.mjs";
|
||||
import { verifyGhidraSnapshotLifecycle } from "./real-ghidra-snapshot-lifecycle.mjs";
|
||||
|
||||
/** Probe real Ghidra location, annotation and error contracts through public adapters. */
|
||||
export async function verifyGhidraBoundaries(
|
||||
|
|
@ -639,10 +640,16 @@ export async function verifyGhidraBoundaries(
|
|||
selected,
|
||||
);
|
||||
}
|
||||
await call("close_binary");
|
||||
const snapshotLifecycle = await verifyGhidraSnapshotLifecycle(
|
||||
client,
|
||||
target,
|
||||
await call("analyze_function", { procedure: address }),
|
||||
cli,
|
||||
);
|
||||
successfulCalls += snapshotLifecycle.successfulCalls;
|
||||
rejectedCalls += snapshotLifecycle.rejectedCalls;
|
||||
await assert.rejects(access(socketRoot), { code: "ENOENT" });
|
||||
await assert.rejects(access(runtimeRoot), { code: "ENOENT" });
|
||||
await call("open_binary", { path: target.path, provider_id: "ghidra" });
|
||||
assert.equal(await call("procedure_address", { procedure: name }), address);
|
||||
assert.equal(await call("address_name", { address }), name);
|
||||
assert.equal(
|
||||
|
|
@ -674,6 +681,8 @@ export async function verifyGhidraBoundaries(
|
|||
complete_function_body_references: true,
|
||||
ambiguity_candidates_inline: true,
|
||||
legacy_reference_snapshot_rejected: true,
|
||||
mutation_snapshot_lifecycle: true,
|
||||
concurrent_annotation_snapshot_close: true,
|
||||
source_immutable: true,
|
||||
reopen_discards_edits: true,
|
||||
long_tmpdir_private_socket_cleanup: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parseAnalysisSnapshot } from "../../dist/domain/analysisSnapshot.js";
|
||||
import { parseEvidenceBundle } from "../../dist/domain/evidenceBundle.js";
|
||||
import { requireMcpResult } from "./mcp-verifier-results.mjs";
|
||||
|
||||
/** Keep mutable Ghidra observations out of immutable snapshots across target opens. */
|
||||
export async function verifyGhidraSnapshotLifecycle(
|
||||
client,
|
||||
target,
|
||||
edited,
|
||||
cli,
|
||||
) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-ghidra-snapshot-proof-"));
|
||||
let successfulCalls = 0;
|
||||
let rejectedCalls = 0;
|
||||
const call = async (name, args = {}) => {
|
||||
const result = requireMcpResult(
|
||||
await client.callTool({ name, arguments: args }, { timeout: 180000 }),
|
||||
name,
|
||||
);
|
||||
successfulCalls++;
|
||||
return result;
|
||||
};
|
||||
const rejectSnapshot = async (name, args) => {
|
||||
const reply = await client.callTool({ name, arguments: args });
|
||||
assert.equal(
|
||||
reply.isError,
|
||||
true,
|
||||
`${name} accepted a mutated snapshot binding`,
|
||||
);
|
||||
const error = reply.structuredContent?.error;
|
||||
assert.equal(
|
||||
error?.code,
|
||||
"evidence_integrity_mismatch",
|
||||
JSON.stringify(reply),
|
||||
);
|
||||
assert.match(error.message, /after analysis metadata mutations/u);
|
||||
assert.match(
|
||||
error.remediation.action,
|
||||
/close.*without saving a snapshot/iu,
|
||||
);
|
||||
assert.match(error.remediation.action, /export_evidence_bundle/u);
|
||||
rejectedCalls++;
|
||||
};
|
||||
try {
|
||||
const entry = edited.procedure.address;
|
||||
const snapshotPath = join(workspace, "original.json");
|
||||
// The CLI has its own unmodified database and writes the production snapshot format.
|
||||
const original = await cli("function", entry, ["--snapshot", snapshotPath]);
|
||||
const snapshotBytes = await readFile(snapshotPath);
|
||||
const snapshot = parseAnalysisSnapshot(
|
||||
JSON.parse(snapshotBytes.toString("utf8")),
|
||||
);
|
||||
assert.equal(snapshot.target.sha256, target.sha256);
|
||||
assert.notEqual(original.procedure.name, edited.procedure.name);
|
||||
const runId = (await call("binary_session")).analysis_run.run_id;
|
||||
const api = await call("inspect_native_api", { procedure: entry });
|
||||
assert.equal(api.procedure.name, edited.procedure.name);
|
||||
for (const repeatedOpen of [false, true]) {
|
||||
if (repeatedOpen)
|
||||
await call("open_binary", { path: target.path, provider_id: "ghidra" });
|
||||
const destination = join(
|
||||
workspace,
|
||||
repeatedOpen ? "repeated.json" : "edited.json",
|
||||
);
|
||||
await rejectSnapshot("close_binary", { snapshot_path: destination });
|
||||
await assert.rejects(access(destination), { code: "ENOENT" });
|
||||
assert.equal((await call("binary_session")).analysis_run.run_id, runId);
|
||||
assert.deepEqual(
|
||||
await call("analyze_function", { procedure: entry }),
|
||||
edited,
|
||||
);
|
||||
}
|
||||
await rejectSnapshot("open_binary", {
|
||||
path: target.path,
|
||||
provider_id: "ghidra",
|
||||
snapshot_path: snapshotPath,
|
||||
});
|
||||
assert.deepEqual(await readFile(snapshotPath), snapshotBytes);
|
||||
assert.equal((await call("binary_session")).analysis_run.run_id, runId);
|
||||
assert.deepEqual(
|
||||
await call("analyze_function", { procedure: entry }),
|
||||
edited,
|
||||
);
|
||||
|
||||
// Mutable results remain exportable as observations, with their mutation Evidence.
|
||||
const bundlePath = join(workspace, "observations.json");
|
||||
await call("export_evidence_bundle", { path: bundlePath });
|
||||
const bundle = parseEvidenceBundle(
|
||||
JSON.parse(await readFile(bundlePath, "utf8")),
|
||||
);
|
||||
assert.ok(
|
||||
bundle.records.some(
|
||||
(record) => record.operation === "annotate_native_function",
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
bundle.records.some(
|
||||
(record) =>
|
||||
record.operation === "inspect_native_api" &&
|
||||
record.normalized_result.procedure.name === edited.procedure.name,
|
||||
),
|
||||
);
|
||||
|
||||
await call("close_binary");
|
||||
await call("open_binary", {
|
||||
path: target.path,
|
||||
provider_id: "ghidra",
|
||||
snapshot_path: snapshotPath,
|
||||
});
|
||||
const restored = await call("analyze_function", { procedure: entry });
|
||||
assert.deepEqual(restored.procedure, original.procedure);
|
||||
assert.deepEqual(restored.comments, original.comments);
|
||||
assert.notEqual((await call("binary_session")).analysis_run.run_id, runId);
|
||||
const freshPath = join(workspace, "fresh.json");
|
||||
await call("close_binary", { snapshot_path: freshPath });
|
||||
const fresh = parseAnalysisSnapshot(
|
||||
JSON.parse(await readFile(freshPath, "utf8")),
|
||||
);
|
||||
assert.ok(
|
||||
!fresh.evidence_bundle.records.some(
|
||||
(record) =>
|
||||
record.operation === "annotate_native_function" ||
|
||||
(record.operation === "inspect_native_api" &&
|
||||
record.normalized_result.procedure.name === edited.procedure.name),
|
||||
),
|
||||
);
|
||||
await call("open_binary", {
|
||||
path: target.path,
|
||||
provider_id: "ghidra",
|
||||
snapshot_path: freshPath,
|
||||
});
|
||||
await verifyConcurrentSnapshotClose({
|
||||
call,
|
||||
rejectSnapshot,
|
||||
workspace,
|
||||
target,
|
||||
entry,
|
||||
original,
|
||||
});
|
||||
return { successfulCalls, rejectedCalls };
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyConcurrentSnapshotClose({
|
||||
call,
|
||||
rejectSnapshot,
|
||||
workspace,
|
||||
target,
|
||||
entry,
|
||||
original,
|
||||
}) {
|
||||
const destination = join(workspace, "concurrent.json");
|
||||
const annotation = call("annotate_native_function", {
|
||||
procedure: entry,
|
||||
name: "rea_concurrent_snapshot_edit",
|
||||
comment:
|
||||
"A successful edit must invalidate a concurrently requested snapshot.",
|
||||
});
|
||||
const close = rejectSnapshot("close_binary", { snapshot_path: destination });
|
||||
const [updated] = await Promise.all([annotation, close]);
|
||||
await assert.rejects(access(destination), { code: "ENOENT" });
|
||||
assert.equal((await call("binary_session")).open, true);
|
||||
assert.deepEqual(
|
||||
await call("analyze_function", { procedure: entry }),
|
||||
updated.dossier,
|
||||
);
|
||||
// Discard the ephemeral edit and leave the caller with a pristine database.
|
||||
await call("close_binary");
|
||||
await call("open_binary", { path: target.path, provider_id: "ghidra" });
|
||||
assert.deepEqual(
|
||||
(await call("analyze_function", { procedure: entry })).procedure,
|
||||
original.procedure,
|
||||
);
|
||||
}
|
||||
|
|
@ -20,7 +20,11 @@ import type {
|
|||
import type { AnalysisOperation } from "../AnalysisProvider.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../../contracts/officialToolContracts.js";
|
||||
import { ENHANCED_TOOL_CONTRACTS } from "../../contracts/enhancedToolContracts.js";
|
||||
import type { BinarySessionPort } from "./BinarySessionPort.js";
|
||||
import type {
|
||||
BinarySessionPort,
|
||||
SavedAnalysisSnapshot,
|
||||
} from "./BinarySessionPort.js";
|
||||
import { writeAnalysisSnapshot } from "./AnalysisSnapshotFiles.js";
|
||||
import {
|
||||
SessionProviderRouter,
|
||||
type SessionProviderRoute,
|
||||
|
|
@ -71,7 +75,8 @@ export class BinarySession
|
|||
}
|
||||
| undefined;
|
||||
#transition: Promise<void> = Promise.resolve();
|
||||
readonly #calls = new Set<Promise<unknown>>();
|
||||
#transitionGeneration = 0;
|
||||
readonly #calls = new Map<Promise<unknown>, number>();
|
||||
readonly #providerRouter: SessionProviderRouter;
|
||||
readonly #runtimeUnavailability = new Map<
|
||||
string,
|
||||
|
|
@ -220,7 +225,7 @@ export class BinarySession
|
|||
resolve: () => Promise<Result<ResolvedSessionOpen, AnalysisError>>,
|
||||
options: Pick<BinarySessionOpenOptions, "signal" | "snapshot">,
|
||||
): Promise<Result<BinaryTarget, AnalysisError>> {
|
||||
return this.#serialize(async () => {
|
||||
return this.#serialize(async (admittedThrough) => {
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const resolved = await resolve();
|
||||
|
|
@ -234,15 +239,16 @@ export class BinarySession
|
|||
activeProfile === null || activeProfile === undefined
|
||||
? profile === null
|
||||
: profile !== null && analysisProfilesEqual(activeProfile, profile);
|
||||
await this.#drainCalls(admittedThrough);
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
if (sameTarget && sameProfile) {
|
||||
if (options.snapshot !== undefined) {
|
||||
const imported = this.importAnalysisSnapshot(options.snapshot);
|
||||
if (!imported.ok) return imported;
|
||||
}
|
||||
this.resetSnapshotInvalidation();
|
||||
return ok(target);
|
||||
}
|
||||
await this.#drainCalls();
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
if (previous !== undefined) {
|
||||
|
|
@ -278,6 +284,7 @@ export class BinarySession
|
|||
runId,
|
||||
};
|
||||
this.#clearRuntimeAvailability();
|
||||
this.resetSnapshotInvalidation();
|
||||
if (options.snapshot === undefined) {
|
||||
if (profile === null) this.clearSnapshot();
|
||||
else this.selectSnapshot(target, profile);
|
||||
|
|
@ -291,7 +298,6 @@ export class BinarySession
|
|||
return imported;
|
||||
}
|
||||
}
|
||||
this.resetSnapshotInvalidation();
|
||||
return ok(target);
|
||||
});
|
||||
}
|
||||
|
|
@ -302,31 +308,62 @@ export class BinarySession
|
|||
readonly retainProviderDocuments?: boolean;
|
||||
} = {},
|
||||
): Promise<Result<null, AnalysisError>> {
|
||||
return this.#serialize(async () => {
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
await this.#drainCalls();
|
||||
const closed =
|
||||
previous === undefined
|
||||
? ok(null)
|
||||
: await closeAnalysisClient(
|
||||
previous.client,
|
||||
previous.route.identity.id,
|
||||
{
|
||||
...(options.progress === undefined
|
||||
? {}
|
||||
: { progress: options.progress }),
|
||||
...(options.retainProviderDocuments === true
|
||||
? { retainDocument: true }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
this.clearSessionRecords();
|
||||
this.#clearRuntimeAvailability();
|
||||
return closed;
|
||||
return this.#serialize(async (admittedThrough) => {
|
||||
await this.#drainCalls(admittedThrough);
|
||||
return this.#closeActive(options);
|
||||
});
|
||||
}
|
||||
|
||||
/** Drain earlier requests, save an immutable snapshot, and close under one lifecycle lock. */
|
||||
closeWithSnapshot(
|
||||
path: string,
|
||||
overwrite: boolean,
|
||||
options: Pick<ExecutionOptions, "progress"> = {},
|
||||
): Promise<Result<SavedAnalysisSnapshot, AnalysisError>> {
|
||||
return this.#serialize(async (admittedThrough) => {
|
||||
await this.#drainCalls(admittedThrough);
|
||||
const snapshot = this.exportAnalysisSnapshot();
|
||||
if (!snapshot.ok) return snapshot;
|
||||
const written = await writeAnalysisSnapshot(
|
||||
snapshot.value,
|
||||
path,
|
||||
overwrite,
|
||||
);
|
||||
if (!written.ok) return written;
|
||||
const closed = await this.#closeActive(options);
|
||||
return closed.ok
|
||||
? ok({ ...written.value, entries: snapshot.value.entries.length })
|
||||
: closed;
|
||||
});
|
||||
}
|
||||
|
||||
async #closeActive(
|
||||
options: Pick<ExecutionOptions, "progress"> & {
|
||||
readonly retainProviderDocuments?: boolean;
|
||||
},
|
||||
): Promise<Result<null, AnalysisError>> {
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
const closed =
|
||||
previous === undefined
|
||||
? ok(null)
|
||||
: await closeAnalysisClient(
|
||||
previous.client,
|
||||
previous.route.identity.id,
|
||||
{
|
||||
...(options.progress === undefined
|
||||
? {}
|
||||
: { progress: options.progress }),
|
||||
...(options.retainProviderDocuments === true
|
||||
? { retainDocument: true }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
this.clearSessionRecords();
|
||||
this.#clearRuntimeAvailability();
|
||||
return closed;
|
||||
}
|
||||
|
||||
/** Describe the active binary session. */
|
||||
status(): JsonValue {
|
||||
return binarySessionStatus({
|
||||
|
|
@ -360,12 +397,28 @@ export class BinarySession
|
|||
* Calls may overlap, but a pending target transition prevents new calls from
|
||||
* entering until the transition has settled.
|
||||
*/
|
||||
async execute(
|
||||
execute(
|
||||
name: Parameters<AnalysisOperationPort["execute"]>[0],
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
options?: { readonly signal?: AbortSignal },
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
const transitioned = await this.#waitForTransition(name, options?.signal);
|
||||
const generation = this.#transitionGeneration;
|
||||
const call = this.#execute(name, arguments_, options, this.#transition);
|
||||
this.#calls.set(call, generation);
|
||||
return call.finally(() => this.#calls.delete(call));
|
||||
}
|
||||
|
||||
async #execute(
|
||||
name: Parameters<AnalysisOperationPort["execute"]>[0],
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
options: { readonly signal?: AbortSignal } | undefined,
|
||||
transition: Promise<void>,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
const transitioned = await this.#waitForTransition(
|
||||
name,
|
||||
options?.signal,
|
||||
transition,
|
||||
);
|
||||
if (!transitioned.ok) return transitioned;
|
||||
const prepared = prepareSessionExecution({
|
||||
active: this.#active,
|
||||
|
|
@ -385,53 +438,48 @@ export class BinarySession
|
|||
const { active, capability, profile, cacheable, cached } = prepared.value;
|
||||
if (cached !== undefined) return ok(cached);
|
||||
const call = active.client.execute(name, arguments_, options);
|
||||
this.#calls.add(call);
|
||||
try {
|
||||
const result = await call;
|
||||
const profiled = bindExecutionTarget(
|
||||
commitExecutionProfile(name, result, profile),
|
||||
name,
|
||||
active.target,
|
||||
);
|
||||
this.#observeRuntimeAvailability(name, profiled);
|
||||
if (
|
||||
profiled.ok &&
|
||||
cacheable &&
|
||||
profile !== undefined &&
|
||||
name !== "decode_interface_builder" &&
|
||||
name !== "inspect_asset_catalog" &&
|
||||
name !== "inspect_keyed_archive" &&
|
||||
name !== "trace_dylib_resolution"
|
||||
) {
|
||||
const evidence = createEvidence(
|
||||
profiled.value.subject ?? active.target,
|
||||
profiled.value.provider,
|
||||
{
|
||||
operation: name,
|
||||
parameters: arguments_,
|
||||
result: profiled.value.result,
|
||||
analysisProfile: profile,
|
||||
rawResult: profiled.value.rawResult,
|
||||
limitations: profiled.value.limitations,
|
||||
locations: profiled.value.locations,
|
||||
},
|
||||
);
|
||||
const recorded = this.recordEvidence(evidence);
|
||||
if (!recorded.ok) return recorded;
|
||||
this.recordSnapshot({
|
||||
target: active.target,
|
||||
profile,
|
||||
const result = await call;
|
||||
const profiled = bindExecutionTarget(
|
||||
commitExecutionProfile(name, result, profile),
|
||||
name,
|
||||
active.target,
|
||||
);
|
||||
this.#observeRuntimeAvailability(name, profiled);
|
||||
if (
|
||||
profiled.ok &&
|
||||
cacheable &&
|
||||
profile !== undefined &&
|
||||
name !== "decode_interface_builder" &&
|
||||
name !== "inspect_asset_catalog" &&
|
||||
name !== "inspect_keyed_archive" &&
|
||||
name !== "trace_dylib_resolution"
|
||||
) {
|
||||
const evidence = createEvidence(
|
||||
profiled.value.subject ?? active.target,
|
||||
profiled.value.provider,
|
||||
{
|
||||
operation: name,
|
||||
parameters: arguments_,
|
||||
execution: profiled.value,
|
||||
});
|
||||
} else if (profiled.ok && capability?.effects.mutatesArtifact === true) {
|
||||
this.invalidateSnapshot();
|
||||
}
|
||||
return profiled;
|
||||
} finally {
|
||||
this.#calls.delete(call);
|
||||
result: profiled.value.result,
|
||||
analysisProfile: profile,
|
||||
rawResult: profiled.value.rawResult,
|
||||
limitations: profiled.value.limitations,
|
||||
locations: profiled.value.locations,
|
||||
},
|
||||
);
|
||||
const recorded = this.recordEvidence(evidence);
|
||||
if (!recorded.ok) return recorded;
|
||||
this.recordSnapshot({
|
||||
target: active.target,
|
||||
profile,
|
||||
operation: name,
|
||||
parameters: arguments_,
|
||||
execution: profiled.value,
|
||||
});
|
||||
} else if (profiled.ok && capability?.effects.mutatesArtifact === true) {
|
||||
this.invalidateSnapshot();
|
||||
}
|
||||
return profiled;
|
||||
}
|
||||
|
||||
#observeRuntimeAvailability(
|
||||
|
|
@ -507,8 +555,15 @@ export class BinarySession
|
|||
return this.#active?.route ?? this.#providerRouter.initialRoute();
|
||||
}
|
||||
|
||||
#serialize<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.#transition.then(operation, operation);
|
||||
#serialize<T>(
|
||||
operation: (admittedThrough: number) => Promise<T>,
|
||||
): Promise<T> {
|
||||
// Later calls wait for this transition and must not be included in its drain.
|
||||
const admittedThrough = this.#transitionGeneration++;
|
||||
const result = this.#transition.then(
|
||||
() => operation(admittedThrough),
|
||||
() => operation(admittedThrough),
|
||||
);
|
||||
this.#transition = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
|
|
@ -516,8 +571,12 @@ export class BinarySession
|
|||
return result;
|
||||
}
|
||||
|
||||
async #drainCalls(): Promise<void> {
|
||||
await Promise.allSettled(this.#calls);
|
||||
async #drainCalls(admittedThrough: number): Promise<void> {
|
||||
await Promise.allSettled(
|
||||
[...this.#calls]
|
||||
.filter(([, generation]) => generation <= admittedThrough)
|
||||
.map(([call]) => call),
|
||||
);
|
||||
}
|
||||
|
||||
async #restore(
|
||||
|
|
@ -550,12 +609,13 @@ export class BinarySession
|
|||
|
||||
async #waitForTransition(
|
||||
operation: string,
|
||||
signal?: AbortSignal,
|
||||
signal: AbortSignal | undefined,
|
||||
transition: Promise<void>,
|
||||
): Promise<Result<undefined, AnalysisCancelledError>> {
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
if (signal === undefined) {
|
||||
await this.#transition;
|
||||
await transition;
|
||||
return ok(undefined);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
|
|
@ -563,7 +623,7 @@ export class BinarySession
|
|||
resolve(err(new AnalysisCancelledError(operation)));
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
this.#transition.then(
|
||||
transition.then(
|
||||
() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ import type {
|
|||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
|
||||
/** Receipt for an atomically saved snapshot followed by provider cleanup. */
|
||||
export interface SavedAnalysisSnapshot {
|
||||
readonly path: string;
|
||||
readonly bytes: number;
|
||||
readonly entries: number;
|
||||
}
|
||||
|
||||
/** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
|
||||
export interface BinarySessionPort
|
||||
extends AnalysisOperationPort, InvestigationRecordPort {
|
||||
|
|
@ -35,6 +42,12 @@ export interface BinarySessionPort
|
|||
readonly retainProviderDocuments?: boolean;
|
||||
},
|
||||
): Promise<Result<null, AnalysisError>>;
|
||||
/** Save a snapshot and close while excluding later provider requests. */
|
||||
closeWithSnapshot(
|
||||
path: string,
|
||||
overwrite: boolean,
|
||||
options?: Pick<ExecutionOptions, "progress">,
|
||||
): Promise<Result<SavedAnalysisSnapshot, AnalysisError>>;
|
||||
status(): JsonValue;
|
||||
activeTarget(): BinaryTarget | undefined;
|
||||
exportAnalysisSnapshot(): Result<AnalysisSnapshot, AnalysisError>;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ import type {
|
|||
import { AnalysisSnapshotCache } from "./AnalysisSnapshotCache.js";
|
||||
import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
|
||||
|
||||
const SNAPSHOT_MUTATION_RECOVERY =
|
||||
"Export session observations through export_evidence_bundle if needed. Close the active target without saving a snapshot, then reopen it before importing or saving a snapshot.";
|
||||
|
||||
export interface ActiveAnalysisBinding {
|
||||
readonly target: BinaryTarget;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
|
|
@ -104,6 +107,9 @@ export abstract class BinarySessionRecords {
|
|||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshots are unavailable after analysis metadata mutations",
|
||||
{
|
||||
userMessage: `Analysis snapshots are unavailable after analysis metadata mutations. ${SNAPSHOT_MUTATION_RECOVERY}`,
|
||||
},
|
||||
),
|
||||
);
|
||||
const target = active?.target;
|
||||
|
|
@ -130,6 +136,15 @@ export abstract class BinarySessionRecords {
|
|||
snapshot: AnalysisSnapshot,
|
||||
): Result<number, AnalysisError> {
|
||||
const active = this.activeAnalysisBinding();
|
||||
if (active !== undefined && this.#snapshotInvalidated)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshots cannot be imported after analysis metadata mutations",
|
||||
{
|
||||
userMessage: `Analysis snapshots cannot be imported after analysis metadata mutations. ${SNAPSHOT_MUTATION_RECOVERY}`,
|
||||
},
|
||||
),
|
||||
);
|
||||
if (active?.profile === null)
|
||||
return err(
|
||||
new AnalysisSnapshotMismatchError(
|
||||
|
|
@ -173,12 +188,14 @@ export abstract class BinarySessionRecords {
|
|||
Record<string, import("../../domain/jsonValue.js").JsonValue>
|
||||
>,
|
||||
): AnalysisExecution | undefined {
|
||||
if (this.#snapshotInvalidated) return undefined;
|
||||
return this.#snapshot.lookup(target, profile, operation, parameters);
|
||||
}
|
||||
|
||||
protected recordSnapshot(
|
||||
input: Parameters<AnalysisSnapshotCache["record"]>[0],
|
||||
): void {
|
||||
if (this.#snapshotInvalidated) return;
|
||||
this.#snapshot.record(input);
|
||||
this.#emitSnapshotChanged();
|
||||
}
|
||||
|
|
@ -194,6 +211,7 @@ export abstract class BinarySessionRecords {
|
|||
"Workflow snapshot entries require an active concrete provider profile",
|
||||
),
|
||||
);
|
||||
if (this.#snapshotInvalidated) return ok(null);
|
||||
try {
|
||||
this.#snapshot.recordWorkflow({
|
||||
target: active.target,
|
||||
|
|
@ -222,6 +240,7 @@ export abstract class BinarySessionRecords {
|
|||
|
||||
protected resetSnapshotInvalidation(): void {
|
||||
if (!this.#snapshotInvalidated) return;
|
||||
this.#snapshot.clear();
|
||||
this.#snapshotInvalidated = false;
|
||||
this.#emitSnapshotChanged();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,12 +65,12 @@ export const exportEvidenceBundleInputSchema = z.strictObject({
|
|||
export const SESSION_TOOL_CONTRACTS = [
|
||||
session(
|
||||
"open_binary",
|
||||
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image; omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly.",
|
||||
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image; omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. Reopening the same target and profile retains its active database and metadata edits. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly; importing into a session with metadata mutations requires closing and reopening it first.",
|
||||
openBinaryInputSchema,
|
||||
),
|
||||
session(
|
||||
"close_binary",
|
||||
"Optionally write a provider-neutral analysis snapshot atomically to the caller-supplied path, then close the active target and every provider resource started for it. Existing files require explicit overwrite; a failed save leaves the session open so cached analysis is not lost.",
|
||||
"Drain earlier provider requests, optionally write a provider-neutral analysis snapshot atomically to the caller-supplied path, then close the active target and every provider resource started for it. Later provider requests wait until this lifecycle operation finishes. Existing files require explicit overwrite; a failed save leaves the session open so cached analysis is not lost. Metadata edits prevent immutable snapshot saves until the session is recreated; export_evidence_bundle preserves mutable observations before closing without a snapshot.",
|
||||
closeBinaryInputSchema,
|
||||
),
|
||||
session(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { writeAnalysisSnapshot } from "../application/binary/AnalysisSnapshotFiles.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import {
|
||||
reportLifecycleEnd,
|
||||
reportLifecycleStart,
|
||||
} from "./lifecycleProgress.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import { mcpProgressReporter } from "./mcpProgress.js";
|
||||
import type { LifecycleToolRegistration } from "./registerSessionTools.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
|
|
@ -22,7 +21,8 @@ export const registerCloseLifecycleTool = ({
|
|||
toolRegistrationOptions(closeContract),
|
||||
async (input, context) => {
|
||||
const progress = mcpProgressReporter(context);
|
||||
if (input.snapshot_path === undefined) {
|
||||
const snapshotPath = input.snapshot_path;
|
||||
if (snapshotPath === undefined) {
|
||||
await reportLifecycleStart(progress, closeContract.name);
|
||||
const closed = await logToolExecution(logger, closeContract.name, () =>
|
||||
session.close({ progress }),
|
||||
|
|
@ -30,33 +30,21 @@ export const registerCloseLifecycleTool = ({
|
|||
await reportLifecycleEnd(progress, closeContract.name, closed.ok);
|
||||
return toCallToolResult(closed, closeContract);
|
||||
}
|
||||
const snapshot = session.exportAnalysisSnapshot();
|
||||
if (!snapshot.ok) return toCallToolResult(snapshot, closeContract);
|
||||
const written = await writeAnalysisSnapshot(
|
||||
snapshot.value,
|
||||
input.snapshot_path,
|
||||
input.overwrite,
|
||||
);
|
||||
if (!written.ok) return toCallToolResult(written, closeContract);
|
||||
await reportLifecycleStart(
|
||||
progress,
|
||||
closeContract.name,
|
||||
"snapshot written; closing provider",
|
||||
"saving snapshot; closing provider",
|
||||
);
|
||||
const closed = await logToolExecution(logger, closeContract.name, () =>
|
||||
session.close({ progress }),
|
||||
session.closeWithSnapshot(snapshotPath, input.overwrite, {
|
||||
progress,
|
||||
}),
|
||||
);
|
||||
await reportLifecycleEnd(progress, closeContract.name, closed.ok);
|
||||
return closed.ok
|
||||
? toCallToolResult(
|
||||
ok({
|
||||
path: written.value.path,
|
||||
bytes: written.value.bytes,
|
||||
entries: snapshot.value.entries.length,
|
||||
}),
|
||||
closeContract,
|
||||
)
|
||||
: toCallToolResult(closed, closeContract);
|
||||
return toCallToolResult(
|
||||
closed.ok ? ok({ ...closed.value }) : closed,
|
||||
closeContract,
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ import { z } from "zod";
|
|||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import {
|
||||
createDeferred,
|
||||
createTestBinarySession,
|
||||
} from "../../fixtures/binarySession.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisProvider,
|
||||
|
|
@ -18,6 +21,7 @@ import { createServer } from "../../../src/server/createServer.js";
|
|||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
|
||||
import { MAX_JSON_DEPTH } from "../../../src/domain/jsonValue.js";
|
||||
import { parseAnalysisSnapshot } from "../../../src/domain/analysisSnapshot.js";
|
||||
import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js";
|
||||
import { ok as resultOk } from "../../../src/domain/result.js";
|
||||
import {
|
||||
|
|
@ -39,6 +43,111 @@ afterEach(async () => {
|
|||
directory = undefined;
|
||||
});
|
||||
|
||||
describe("MCP snapshot lifecycle ordering", () => {
|
||||
it("saves earlier in-flight observations, blocks later calls, and preserves the session on write failure", async () => {
|
||||
directory = await createTestTempDirectory("rea-mcp-snapshot-order-");
|
||||
const started = createDeferred<void>();
|
||||
const release = createDeferred<ReturnType<typeof ok>>();
|
||||
const { mcp, first, closed } = await createSessionMcpHarness(
|
||||
directory,
|
||||
(closed) => {
|
||||
const base = provider(closed);
|
||||
return {
|
||||
...base,
|
||||
capabilities: () =>
|
||||
base.capabilities().map((capability) => ({
|
||||
...capability,
|
||||
operation: "address_name",
|
||||
})),
|
||||
createClient: (target) => ({
|
||||
...client(target.path, closed),
|
||||
execute: (name) => {
|
||||
if (name === "health") return Promise.resolve(ok(null));
|
||||
started.resolve();
|
||||
return release.promise;
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
resources,
|
||||
);
|
||||
expect(
|
||||
(await mcp.callTool({ name: "open_binary", arguments: { path: first } }))
|
||||
.isError,
|
||||
).not.toBe(true);
|
||||
const observation = mcp.callTool({
|
||||
name: "address_name",
|
||||
arguments: { address: "0x1000", document: "fixture" },
|
||||
});
|
||||
await started.promise;
|
||||
const snapshotPath = join(directory, "analysis.json");
|
||||
const closing = mcp.callTool({
|
||||
name: "close_binary",
|
||||
arguments: { snapshot_path: snapshotPath },
|
||||
});
|
||||
const later = mcp.callTool({
|
||||
name: "address_name",
|
||||
arguments: { address: "0x1001", document: "fixture" },
|
||||
});
|
||||
expect(
|
||||
structured(await mcp.callTool({ name: "binary_session", arguments: {} }))
|
||||
.result,
|
||||
).toMatchObject({ open: true });
|
||||
expect(closed).toEqual([]);
|
||||
release.resolve(ok(first));
|
||||
expect((await observation).isError).not.toBe(true);
|
||||
expect((await closing).isError).not.toBe(true);
|
||||
expect((await later).isError).toBe(true);
|
||||
const snapshot = parseAnalysisSnapshot(
|
||||
JSON.parse(await readFile(snapshotPath, "utf8")),
|
||||
);
|
||||
expect(snapshot.entries).toHaveLength(1);
|
||||
expect(snapshot.entries[0]?.execution.result).toBe(first);
|
||||
expect(
|
||||
snapshot.evidence_bundle.records.some(
|
||||
(record) =>
|
||||
record.operation === "address_name" &&
|
||||
record.normalized_result === first,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
(await mcp.callTool({ name: "open_binary", arguments: { path: first } }))
|
||||
.isError,
|
||||
).not.toBe(true);
|
||||
const bytes = await readFile(snapshotPath);
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "close_binary",
|
||||
arguments: { snapshot_path: snapshotPath },
|
||||
})
|
||||
).isError,
|
||||
).toBe(true);
|
||||
expect(await readFile(snapshotPath)).toEqual(bytes);
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "address_name",
|
||||
arguments: { address: "0x1000", document: "fixture" },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
expect(
|
||||
structured(await mcp.callTool({ name: "binary_session", arguments: {} }))
|
||||
.result,
|
||||
).toMatchObject({ open: true });
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "close_binary",
|
||||
arguments: { snapshot_path: snapshotPath, overwrite: true },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe("target-free MCP lifecycle", () => {
|
||||
it("reopens replaced content at one canonical path through MCP", async () => {
|
||||
directory = await createTestTempDirectory("rea-mcp-replaced-target-");
|
||||
|
|
|
|||
|
|
@ -414,17 +414,10 @@ describe("evidence metadata imports and snapshot cache", () => {
|
|||
).ok,
|
||||
).toBe(true);
|
||||
const before = session.exportAnalysisSnapshot();
|
||||
expect(before.ok).toBe(true);
|
||||
if (!before.ok) {
|
||||
await session.close();
|
||||
return;
|
||||
}
|
||||
if (!before.ok) throw before.error;
|
||||
const existing = session.exportEvidenceBundle().records[0];
|
||||
expect(existing?.subject).not.toBeNull();
|
||||
if (existing?.subject === null || existing === undefined) {
|
||||
await session.close();
|
||||
return;
|
||||
}
|
||||
if (existing === undefined || existing.subject === null)
|
||||
throw new Error("Expected artifact-bound Evidence");
|
||||
const addition = createEvidence(
|
||||
{
|
||||
path: existing.subject.local_path,
|
||||
|
|
@ -445,6 +438,9 @@ describe("evidence metadata imports and snapshot cache", () => {
|
|||
expect(
|
||||
session.importEvidenceBundle(createEvidenceBundle([addition])),
|
||||
).toEqual({ ok: true, value: 1 });
|
||||
expect(
|
||||
session.importEvidenceBundle(createEvidenceBundle([addition])),
|
||||
).toEqual({ ok: true, value: 0 });
|
||||
expect(session.exportAnalysisSnapshot()).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
|
|
@ -466,7 +462,7 @@ describe("evidence metadata imports and snapshot cache", () => {
|
|||
await session.close();
|
||||
});
|
||||
|
||||
it("invalidates cached analysis when an import changes evidence path metadata", async () => {
|
||||
it("keeps changed evidence metadata out of snapshots until the session is recreated", async () => {
|
||||
const [target] = await createBinarySessionTargets();
|
||||
const calls: string[] = [];
|
||||
const session = createTestBinarySession(createCacheProvider(calls));
|
||||
|
|
@ -479,30 +475,15 @@ describe("evidence metadata imports and snapshot cache", () => {
|
|||
})
|
||||
).ok,
|
||||
).toBe(true);
|
||||
expect(session.exportAnalysisSnapshot().ok).toBe(true);
|
||||
const baseline = session.exportAnalysisSnapshot();
|
||||
if (!baseline.ok) throw baseline.error;
|
||||
|
||||
const evidence = session
|
||||
.exportEvidenceBundle()
|
||||
.records.find(({ operation }) => operation === "address_name");
|
||||
expect(evidence?.subject).not.toBeNull();
|
||||
if (evidence?.subject === null || evidence === undefined) {
|
||||
await session.close();
|
||||
return;
|
||||
}
|
||||
expect(
|
||||
session.importEvidenceBundle(createEvidenceBundle([evidence])),
|
||||
).toEqual({ ok: true, value: 0 });
|
||||
expect(session.exportAnalysisSnapshot().ok).toBe(true);
|
||||
if (evidence === undefined || evidence.subject === null)
|
||||
throw new Error("Expected artifact-bound Evidence");
|
||||
const callsAfterInitialRead = [...calls];
|
||||
expect(
|
||||
(
|
||||
await session.execute("address_name", {
|
||||
address: "0x1000",
|
||||
document: "first",
|
||||
})
|
||||
).ok,
|
||||
).toBe(true);
|
||||
expect(calls).toEqual(callsAfterInitialRead);
|
||||
|
||||
const relocated = {
|
||||
...evidence,
|
||||
|
|
@ -533,6 +514,32 @@ describe("evidence metadata imports and snapshot cache", () => {
|
|||
),
|
||||
},
|
||||
});
|
||||
expect((await session.open(target)).ok).toBe(true);
|
||||
expect(session.exportAnalysisSnapshot().ok).toBe(false);
|
||||
expect(session.importAnalysisSnapshot(baseline.value)).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
userMessage: expect.stringContaining(
|
||||
"Close the active target without saving a snapshot",
|
||||
),
|
||||
},
|
||||
});
|
||||
expect(session.exportEvidenceBundle().records).toContainEqual(relocated);
|
||||
const callsAfterInvalidation = calls.length;
|
||||
expect(
|
||||
(
|
||||
await session.execute("address_name", {
|
||||
address: "0x1000",
|
||||
document: "first",
|
||||
})
|
||||
).ok,
|
||||
).toBe(true);
|
||||
expect(calls).toHaveLength(callsAfterInvalidation + 1);
|
||||
await session.close();
|
||||
expect((await session.open(target, { snapshot: baseline.value })).ok).toBe(
|
||||
true,
|
||||
);
|
||||
expect(session.exportAnalysisSnapshot().ok).toBe(true);
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -591,11 +598,6 @@ describe("replay of exact immutable calls", () => {
|
|||
).toBe(true);
|
||||
expect(replayCalls).toEqual(["health", "set_address_name", "address_name"]);
|
||||
expect(replay.exportAnalysisSnapshot().ok).toBe(false);
|
||||
expect((await replay.open(first)).ok).toBe(true);
|
||||
expect(replay.exportAnalysisSnapshot()).toMatchObject({
|
||||
ok: true,
|
||||
value: { entries: [{ operation: "address_name" }] },
|
||||
});
|
||||
await replay.close();
|
||||
|
||||
const mismatch = createTestBinarySession(createCacheProvider([]));
|
||||
|
|
@ -863,9 +865,16 @@ describe("typed unavailability without dispatching", () => {
|
|||
await session.close();
|
||||
});
|
||||
|
||||
it("requires an open binary and closes idempotently", async () => {
|
||||
it("drains an admitted call before closing and closes idempotently", async () => {
|
||||
const [first] = await createBinarySessionTargets();
|
||||
const session = createTestBinarySession(() => client());
|
||||
expect((await session.execute("binary_overview", {})).ok).toBe(false);
|
||||
expect((await session.open(first)).ok).toBe(true);
|
||||
// The call has been admitted but has not resumed after the transition wait.
|
||||
const admitted = session.execute("binary_overview", {});
|
||||
const closing = session.close();
|
||||
expect((await admitted).ok).toBe(true);
|
||||
expect(await closing).toEqual({ ok: true, value: null });
|
||||
expect(await session.close()).toEqual({ ok: true, value: null });
|
||||
expect(await session.close()).toEqual({ ok: true, value: null });
|
||||
});
|
||||
|
|
@ -886,7 +895,7 @@ describe("typed unavailability without dispatching", () => {
|
|||
expect(created).toBe(3);
|
||||
});
|
||||
|
||||
it("serializes concurrent opens and leaves the last target active", async () => {
|
||||
it("orders calls between queued transitions without deadlocking", async () => {
|
||||
const [first, second] = await createBinarySessionTargets();
|
||||
const clients: ControllableAnalysisClient[] = [];
|
||||
const session = createTestBinarySession(() => {
|
||||
|
|
@ -895,12 +904,31 @@ describe("typed unavailability without dispatching", () => {
|
|||
return value;
|
||||
});
|
||||
const one = session.open(first);
|
||||
const firstCall = session.execute("binary_overview", {});
|
||||
const controller = new AbortController();
|
||||
const cancelled = session.execute(
|
||||
"binary_overview",
|
||||
{},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
controller.abort();
|
||||
const two = session.open(second);
|
||||
const secondCall = session.execute("binary_overview", {});
|
||||
const closing = session.close();
|
||||
const afterClose = session.execute("binary_overview", {});
|
||||
expect((await one).ok).toBe(true);
|
||||
expect((await firstCall).ok).toBe(true);
|
||||
expect(await cancelled).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
expect((await two).ok).toBe(true);
|
||||
expect(session.status()).toMatchObject({ open: true });
|
||||
expect(JSON.stringify(session.status())).toContain("second.hop");
|
||||
expect((await secondCall).ok).toBe(true);
|
||||
expect((await closing).ok).toBe(true);
|
||||
expect((await afterClose).ok).toBe(false);
|
||||
expect(session.status()).toMatchObject({ open: false });
|
||||
expect(clients[0]?.closed).toBe(1);
|
||||
expect(clients[1]?.closed).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -977,12 +1005,27 @@ describe("active client replacement", () => {
|
|||
return value;
|
||||
});
|
||||
expect((await session.open(first)).ok).toBe(true);
|
||||
const current = session.activeTarget();
|
||||
if (current === undefined) throw new Error("Missing active target");
|
||||
const preview = await session.previewTarget(current);
|
||||
if (!preview.ok) throw preview.error;
|
||||
const call = session.execute("procedure_pseudo_code", {});
|
||||
const controller = new AbortController();
|
||||
const reopening = session.openResolvedTarget(preview.value, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
// The resolved route needs no I/O; one event-loop turn lets it reach the drain.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
controller.abort();
|
||||
const switching = session.open(second);
|
||||
await Promise.resolve();
|
||||
expect(clients[0]?.closed).toBe(0);
|
||||
active.resolve(ok(null));
|
||||
expect((await call).ok).toBe(true);
|
||||
expect(await reopening).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError", operation: "open_binary" },
|
||||
});
|
||||
expect((await switching).ok).toBe(true);
|
||||
expect(clients[0]?.closed).toBe(1);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue