fix(session): preserve failed target restoration

This commit is contained in:
morluto committed 2026-10-27 08:21:41 +08:00
1 parent 3431541073
commit 0ddde829b2
2 files changed
+154 -5

No files matched your search

+15 -5
View File
@@ -10,7 +10,11 @@ import {
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisError,
primaryAnalysisError,
} from "../../domain/analysisErrorBase.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { err, ok, type Result } from "../../domain/result.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js";
@@ -1031,9 +1035,15 @@ export class BinarySession
primary: AnalysisError,
): Promise<AnalysisError> {
const restored = await this.#restore(previous);
return restored.ok
? primary
: sessionCleanupFailure(primary, restored.error, "open_binary");
if (restored.ok) return primary;
primary.retainSecondaryFailure(restored.error, {
restoration_error: projectAnalysisError(
primaryAnalysisError(restored.error),
),
});
return restored.error.cleanupIncomplete
? sessionCleanupFailure(primary, restored.error, "open_binary")
: primary;
}
/** Describe the active binary session. */
@@ -1460,7 +1470,7 @@ export class BinarySession
}
const closed = await this.#retireClient(run);
return closed.ok
? ok(null)
? err(started.error)
: err(sessionCleanupFailure(started.error, closed.error, "open_binary"));
}
@@ -0,0 +1,139 @@
import { expect, it } from "vitest";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { err, ok } from "../../domain/result.js";
import { createAnalysisExecution } from "../AnalysisProvider.js";
import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
import { BinarySession } from "./BinarySession.js";
import {
SessionProviderRouter,
type SessionProviderRoute,
} from "./SessionProviderRouter.js";
it.each([
{ cancelled: false, cleanupFails: false },
{ cancelled: true, cleanupFails: false },
{ cancelled: false, cleanupFails: true },
{ cancelled: true, cleanupFails: true },
])(
"retains failed restoration after switch cancellation=$cancelled, cleanup failure=$cleanupFails",
async ({ cancelled, cleanupFails }) => {
const session = new BinarySession(
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
);
const controller = new AbortController();
const identity = {
id: "restoration-test",
name: "Restoration test",
version: "1",
};
const restorationFailure = new AnalysisOutputError(
"health",
"Previous target could not restart",
);
const switchFailure = new AnalysisOutputError(
"health",
"Selected target could not start",
);
const cleanupFailure = new ProviderCleanupError(
identity.id,
["restored-client"],
{ reason: "close failed" },
);
let created = 0;
let restorationCloseCalls = 0;
const route: SessionProviderRoute = {
identity,
capabilities: new Map(),
profile: createAnalysisProfile(identity, { fixture: "original" }),
binding: null,
selection: undefined,
createClient: () => {
const restoring = created++ > 0;
return {
execute: async () =>
restoring
? err(restorationFailure)
: ok(createAnalysisExecution(null, identity)),
close: async () => {
if (!restoring) {
if (cancelled) controller.abort("cancel switch");
return ok(null);
}
restorationCloseCalls += 1;
if (cleanupFails && restorationCloseCalls === 1)
return err(cleanupFailure);
cleanupFailure.resolveCleanup();
return ok(null);
},
};
},
};
try {
const target = await session.resolveTarget(process.execPath);
expect(target.ok).toBe(true);
if (!target.ok) return;
expect(
(await session.openResolvedTarget({ target: target.value, route })).ok,
).toBe(true);
expect(session.activeTarget()).toEqual(target.value);
const result = await session.openResolvedTarget(
{
target: target.value,
route: {
...route,
profile: createAnalysisProfile(identity, { fixture: "selected" }),
createClient: () => ({
execute: async () => err(switchFailure),
close: async () => ok(null),
}),
},
},
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(session.activeTarget()).toBeUndefined();
expect(await session.status()).toMatchObject({
open: false,
cleanup_pending: cleanupFails,
});
expect(projectAnalysisError(result.error)).toMatchObject({
code: cleanupFails
? "cleanup_incomplete"
: cancelled
? "cancelled"
: "unreadable_output",
});
expect((await session.close()).ok).toBe(true);
expect(restorationCloseCalls).toBe(cleanupFails ? 2 : 1);
expect(await session.status()).toMatchObject({
open: false,
cleanup_pending: false,
});
expect(projectAnalysisError(result.error).code).toBe(
cancelled ? "cancelled" : "unreadable_output",
);
expect(projectAnalysisError(result.error)).toMatchObject({
details: {
diagnostics: {
restoration_error: projectAnalysisError(restorationFailure),
},
},
});
if (cancelled) {
const primary = result.error.cleanupPrimary ?? result.error;
expect(primary).toBeInstanceOf(AnalysisCancelledError);
} else {
expect(result.error.cleanupPrimary ?? result.error).toBe(switchFailure);
}
} finally {
await session.close();
}
},
);