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:
1 parent
f79fd59e96
commit
345c47ccf8
2 files changed
+111
-4
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user