fix(binary): clear retired availability when restoring a session

A cancelled target switch restores a new client but retained the retired runtime availability cache. Clear that cache when installing the restored client. Preserve cancellation, target identity and later real provider failures.

Validation: all 9 BinarySession liveness tests passed. Fixes #2657.
This commit is contained in:
morluto committed 2026-10-14 13:40:51 +08:00
1 parent f79fd59e96
commit 345c47ccf8
2 files changed
+111 -4

No files matched your search

+1
View File
@@ -932,6 +932,7 @@ export class BinarySession
runId,
nativeSourceAdmitted: false,
};
this.#clearRuntimeAvailability();
return ok(null);
}
const closed = await this.#retireClient(client, previous.route.identity.id);
@@ -15,6 +15,7 @@ import { createAnalysisProfile } from "../../domain/analysisProfile.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import type { ProviderOperationHealth } from "../../domain/providerOperationHealth.js";
import { err, ok } from "../../domain/result.js";
import { buildCapabilityInventory } from "../CapabilityInventory.js";
import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
import { BinarySession } from "./BinarySession.js";
@@ -186,8 +187,99 @@ describe("BinarySession open liveness", () => {
});
});
it("clears retired runtime availability after cancelled-switch restoration", async () => {
let closeStarted!: () => void;
const closing = new Promise<void>((resolve) => {
closeStarted = resolve;
});
let releaseClose!: () => void;
const closeGate = new Promise<void>((resolve) => {
releaseClose = resolve;
});
const { session, deep } = openSession({
failOperationInitially: true,
beforeFirstClose: async () => {
closeStarted();
await closeGate;
},
});
expect((await session.open(process.execPath)).ok).toBe(true);
expect((await session.execute("address_name", {})).ok).toBe(false);
const availability = () =>
buildCapabilityInventory(session.status(), {
processCaptureEnabled: false,
}).find(({ name }) => name === "address_name");
const initialAvailability = availability();
expect(initialAvailability).toMatchObject({
available: false,
reason: "provider_unavailable",
});
const originalTarget = session.activeTarget();
const initialRunId = sessionRunId(session.status());
const controller = new AbortController();
const switching = session.open("/bin/ls", { signal: controller.signal });
await closing;
controller.abort();
releaseClose();
const cancelled = await switching;
expect(cancelled).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(session.activeTarget()).toEqual(originalTarget);
expect(deep.created).toHaveLength(1);
expect(deep.created[0]?.closeCalled).toBe(true);
const restoredStatus = session.status();
expect(sessionRunId(restoredStatus)).not.toBe(initialRunId);
expect(availability()).toMatchObject({
available: true,
reason: "available",
remediation: null,
});
// Cancellation remains the caller-visible outcome after cleanup and
// restoration, while the new restored client is healthy and usable.
expect((await session.execute("address_name", {})).ok).toBe(true);
expect(deep.created).toHaveLength(2);
expect(availability()).toMatchObject({
available: true,
reason: "available",
remediation: null,
});
deep.created[1]?.setOperationFailure(true);
expect((await session.execute("address_name", {})).ok).toBe(false);
expect(availability()).toMatchObject({
available: false,
reason: "provider_unavailable",
});
deep.created[1]?.setOperationFailure(false);
expect((await session.execute("address_name", {})).ok).toBe(true);
expect(availability()).toMatchObject({
available: true,
reason: "available",
remediation: null,
});
await session.close();
});
const sessionRunId = (status: ReturnType<BinarySession["status"]>): string => {
if (typeof status !== "object" || status === null || Array.isArray(status))
throw new Error("Expected a binary session status object");
const analysisRun = status.analysis_run;
if (
typeof analysisRun !== "object" ||
analysisRun === null ||
Array.isArray(analysisRun) ||
typeof analysisRun.run_id !== "string"
)
throw new Error("Expected an active analysis run");
return analysisRun.run_id;
};
interface KillableChild extends AnalysisClient {
kill(state?: "exited" | "unreachable" | "unknown" | "not_started"): void;
setOperationFailure(value: boolean): void;
closeCalled: boolean;
healthCalls: number;
}
@@ -225,7 +317,11 @@ const capability = (
});
const openSession = (
options: { operationHealth?: boolean } = {},
options: {
operationHealth?: boolean;
failOperationInitially?: boolean;
beforeFirstClose?: () => Promise<void>;
} = {},
): {
session: BinarySession;
deep: { created: KillableChild[] };
@@ -257,6 +353,8 @@ const openSession = (
const killableDeepProvider = (options: {
operationHealth?: boolean;
failOperationInitially?: boolean;
beforeFirstClose?: () => Promise<void>;
}): {
provider: AnalysisProviderCandidate;
created: KillableChild[];
@@ -290,7 +388,10 @@ const killableDeepProvider = (options: {
}),
),
createClient: (): KillableChild => {
const firstClient = created.length === 0;
let alive = true;
let failOperation =
firstClient && options.failOperationInitially === true;
let closeCalled = false;
let healthCalls = 0;
let deadState: "exited" | "unreachable" | "unknown" | "not_started" =
@@ -298,7 +399,7 @@ const killableDeepProvider = (options: {
const client: KillableChild = {
execute: (operation) => {
if (operation === "health") healthCalls += 1;
return alive
return alive && !(operation !== "health" && failOperation)
? Promise.resolve(
ok(
createAnalysisExecution(
@@ -331,9 +432,11 @@ const killableDeepProvider = (options: {
},
}
: {}),
close: () => {
close: async () => {
closeCalled = true;
return Promise.resolve(ok(null));
if (firstClient && options.beforeFirstClose !== undefined)
await options.beforeFirstClose();
return ok(null);
},
get closeCalled() {
return closeCalled;
@@ -351,6 +454,9 @@ const killableDeepProvider = (options: {
alive = false;
deadState = state;
},
setOperationFailure: (value: boolean) => {
failOperation = value;
},
};
created.push(client);
return client;