diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 7053d3efce..91bc936e31 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -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 { 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")); } diff --git a/src/application/binary/BinarySessionRestoration.test.ts b/src/application/binary/BinarySessionRestoration.test.ts new file mode 100644 index 0000000000..6720d03d99 --- /dev/null +++ b/src/application/binary/BinarySessionRestoration.test.ts @@ -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(); + } + }, +);