diff --git a/docs/tool-design.md b/docs/tool-design.md index c085fff200..ba3632dc20 100644 --- a/docs/tool-design.md +++ b/docs/tool-design.md @@ -83,6 +83,9 @@ providers need not expose identical state machines. recovery handshakes. Bound abandoned unauthenticated listeners where the protocol permits it; that deadline must not expire authenticated idle sessions or long-running native work. Listener expiry does not prove target closure. +- Readiness observations may await owned prerequisite commands. Status must + remain readable during owner finalization without starting replacement work; + report readiness as unknown when it cannot be re-observed. - Shared startup has one producer and independently cancellable caller waits. Keep it running while any caller still needs it. The final departing caller waits for owned cleanup and retains its failure. Provider closure is distinct diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index c921d766cf..d971a316ea 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -300,8 +300,15 @@ export interface AnalysisProviderCandidate extends AnalysisProvider { target: BinaryTarget, options?: AnalysisProfileResolutionOptions, ): Promise>; - inspectAvailability(): ProviderAvailability; - inspectReadinessSnapshot?(): AnalysisProviderReadinessSnapshot; + /** Await prerequisite observations and finalization under the supplied owner signal. */ + inspectAvailability( + signal?: AbortSignal, + ): ProviderAvailability | Promise; + inspectReadinessSnapshot?( + signal?: AbortSignal, + ): + | AnalysisProviderReadinessSnapshot + | Promise; inspectTargetSupport(target: BinaryTarget): ProviderTargetSupport; /** Observe retained candidate cleanup without starting or retrying work. */ hasPendingOwnedResources?(): boolean; diff --git a/src/application/DirectAnalysisStatus.ts b/src/application/DirectAnalysisStatus.ts index 5889987b3c..26c479df6c 100644 --- a/src/application/DirectAnalysisStatus.ts +++ b/src/application/DirectAnalysisStatus.ts @@ -21,7 +21,7 @@ export const runSessionStatus = async ( if (!config.ok) return { error: projectAnalysisError(config.error) }; const session = await dependencies.createBinarySession(config.value, logger); try { - const status = jsonObjectSchema.parse(session.status()); + const status = jsonObjectSchema.parse(await session.status()); return { ...status, capabilities: [ diff --git a/src/application/binary/AnalysisProviderRegistry.test.ts b/src/application/binary/AnalysisProviderRegistry.test.ts index 13986a4684..d053c3bd7c 100644 --- a/src/application/binary/AnalysisProviderRegistry.test.ts +++ b/src/application/binary/AnalysisProviderRegistry.test.ts @@ -65,7 +65,7 @@ it("rejects malformed provider capability declarations", () => { ).toThrow(/Invalid analysis provider ID: auto/u); }); -it("uses one owner readiness snapshot for candidate availability and capabilities", () => { +it("uses one owner readiness snapshot for candidate availability and capabilities", async () => { const base = candidate("alpha").provider; let observed = 0; let capabilityReads = 0; @@ -92,7 +92,7 @@ it("uses one owner readiness snapshot for candidate availability and capabilitie }, }; - const [status] = new AnalysisProviderRegistry([provider]).candidates(); + const [status] = await new AnalysisProviderRegistry([provider]).candidates(); expect(observed).toBe(1); expect(capabilityReads).toBe(1); expect(status).toMatchObject({ @@ -763,3 +763,197 @@ it("registers before production and gates fresh selections until shutdown has jo await registry.closeOwnedResources(); } }); + +it("joins cancelled readiness finalization and shutdown before admitting another observation", async () => { + const base = candidate("alpha").provider; + let enter!: () => void; + let finish!: () => void; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const gate = new Promise((resolve) => { + finish = resolve; + }); + let calls = 0; + let observed: AbortSignal | undefined; + const registry = new AnalysisProviderRegistry([ + { + ...base, + inspectAvailability: async (signal) => { + calls++; + if (calls === 1) { + observed = signal; + enter(); + await gate; + } + return base.inspectAvailability(); + }, + }, + ]); + const controller = new AbortController(); + const reason = { request: "opaque readiness cancellation" }; + let settled = false; + const observing = registry + .candidates(undefined, { signal: controller.signal }) + .catch((cause) => { + settled = true; + return cause; + }); + await entered; + controller.abort(reason); + let closed = false; + const closing = registry.closeOwnedResources().then((value) => { + closed = true; + return value; + }); + const next = registry.candidates(); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + expect(closed).toBe(false); + expect(calls).toBe(1); + expect(observed?.reason).toBe(reason); + expect(registry.hasPendingOwnedResources()).toBe(true); + finish(); + const failure = await observing; + expect(failure).toBeInstanceOf(AnalysisCancelledError); + expect(failure.cause).toBe(reason); + expect(await closing).toEqual(ok(null)); + expect(await next).toMatchObject([ + { + availability: { + status: "unknown", + diagnostics: { finalization: "in_progress" }, + }, + }, + ]); + expect(await registry.candidates()).toHaveLength(1); + expect(calls).toBe(2); + expect(registry.hasPendingOwnedResources()).toBe(false); + } finally { + finish(); + await Promise.allSettled([observing, closing, next]); + await registry.closeOwnedResources(); + } +}); + +it("joins every candidate and retains physical failure with exact mixed cleanup owners", async () => { + const alpha = candidate("alpha").provider; + const beta = candidate("beta").provider; + const gamma = candidate("gamma").provider; + let finish!: () => void; + let enter!: () => void; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const gate = new Promise((resolve) => { + finish = resolve; + }); + const physical = new ProviderAdapterError("alpha", "binary_session", { + diagnostics: { reason: "physical observation failed" }, + }); + const cleanup = new ProviderCleanupError( + "beta", + ["beta retained prerequisite"], + { reason: "controlled cleanup refusal" }, + ); + let releases = 0; + const registry = new AnalysisProviderRegistry([ + { + ...alpha, + inspectAvailability: async () => { + throw physical; + }, + }, + { + ...beta, + inspectAvailability: async () => { + enter(); + await gate; + throw cleanup; + }, + closeOwnedResources: async () => { + releases++; + cleanup.resolveCleanup(); + return ok(null); + }, + }, + gamma, + ]); + let settled = false; + const observing = registry.candidates().catch((cause) => { + settled = true; + return cause; + }); + await entered; + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + finish(); + const failure = await observing; + expect(failure.cleanupIncomplete).toBe(true); + expect(failure.cleanupResources).toContain("beta retained prerequisite"); + expect(failure.diagnostics).toMatchObject({ + readiness_candidates: [ + { provider: { id: "gamma" }, availability: { status: "available" } }, + ], + }); + expect(failure.cleanupPrimary).toBe(physical); + expect(projectAnalysisError(failure).details).toMatchObject({ + diagnostics: { + readiness_errors: expect.arrayContaining([ + { + provider_id: "alpha", + error: expect.objectContaining({ code: "execution_failure" }), + }, + { + provider_id: "beta", + error: expect.objectContaining({ code: "cleanup_incomplete" }), + }, + ]), + }, + }); + expect(registry.hasPendingOwnedResources()).toBe(true); + expect(await registry.closeOwnedResources()).toEqual(ok(null)); + expect(releases).toBe(1); + expect(failure.cleanupIncomplete).toBe(false); + expect(projectAnalysisError(failure)).toMatchObject({ + code: "execution_failure", + details: { diagnostics: { reason: "physical observation failed" } }, + }); + expect(registry.hasPendingOwnedResources()).toBe(false); + } finally { + finish(); + await observing; + await registry.closeOwnedResources(); + } +}); + +it("registers selection before async readiness can race shutdown", async () => { + let calls = 0; + let observed: AbortSignal | undefined; + const base = candidate("alpha").provider; + const registry = new AnalysisProviderRegistry([ + { + ...base, + inspectAvailability: async (signal) => { + calls++; + observed = signal; + return base.inspectAvailability(); + }, + }, + ]); + const selecting = registry.select(DATABASE_TARGET, "alpha"); + const closing = registry.closeOwnedResources(); + const [selected, closed] = await Promise.all([selecting, closing]); + expect(selected).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(closed).toEqual(ok(null)); + expect(calls).toBe(1); + expect(observed?.aborted).toBe(true); + expect((await registry.select(DATABASE_TARGET, "alpha")).ok).toBe(true); + expect(calls).toBe(2); + expect(registry.hasPendingOwnedResources()).toBe(false); +}); diff --git a/src/application/binary/AnalysisProviderRegistry.ts b/src/application/binary/AnalysisProviderRegistry.ts index 1a08e2efcb..22647d73ac 100644 --- a/src/application/binary/AnalysisProviderRegistry.ts +++ b/src/application/binary/AnalysisProviderRegistry.ts @@ -14,13 +14,17 @@ import { import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js"; import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; -import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { ProviderSelectionError, type ProviderSelectionFailureReason, type ProviderSelectionRejection, } from "../../domain/providerSelectionError.js"; -import { jsonObjectSchema, type JsonValue } from "../../domain/jsonValue.js"; +import { + jsonObjectSchema, + jsonValueSchema, + type JsonValue, +} from "../../domain/jsonValue.js"; import { err, ok, type Result } from "../../domain/result.js"; import type { AnalysisProviderCandidate, @@ -39,7 +43,14 @@ import type { FileCloseResourceOwner } from "../AnalysisProvider.js"; /** Deterministic discovery truth for one configured deep provider. */ export interface AnalysisProviderCandidateStatus { readonly provider: ProviderIdentity; - readonly availability: ProviderAvailability; + readonly availability: + | ProviderAvailability + | { + readonly status: "unknown"; + readonly code: null; + readonly reason: string; + readonly diagnostics: Readonly>; + }; readonly targetSupport: | ProviderTargetSupport | { @@ -89,6 +100,11 @@ export class AnalysisProviderRegistry { readonly #defaultSelector: AnalysisProviderSelector; readonly #selections = new Set>(); + readonly #discoveries = new Set< + SharedStartupAttempt< + Result + > + >(); readonly #selectionCleanup = new Set(); readonly #profileFiles = new ArtifactResourceScope(); readonly #profileFileOwner: FileCloseResourceOwner = { @@ -167,6 +183,7 @@ export class AnalysisProviderRegistry { hasPendingOwnedResources(): boolean { return ( this.#selections.size > 0 || + this.#discoveries.size > 0 || this.#profileFiles.hasPending || [...this.#selectionCleanup].some((error) => error.cleanupIncomplete) || this.#providers.some( @@ -187,8 +204,13 @@ export class AnalysisProviderRegistry { async #closeOwnedResources(): Promise> { const selections = [...this.#selections]; + const discoveries = [...this.#discoveries]; + for (const discovery of discoveries) discovery.close(); for (const selection of selections) selection.close(); - await Promise.allSettled(selections.map((selection) => selection.promise)); + await Promise.allSettled([ + ...discoveries.map((discovery) => discovery.promise), + ...selections.map((selection) => selection.promise), + ]); const failures: AnalysisError[] = []; try { await this.#profileFiles.retryCleanup(); @@ -236,10 +258,98 @@ export class AnalysisProviderRegistry { } /** Discover candidates for status without resolving profiles or starting clients. */ - candidates( + async candidates( target?: BinaryTarget, - ): readonly AnalysisProviderCandidateStatus[] { - return this.#providers.map((provider) => candidateStatus(provider, target)); + options: { readonly signal?: AbortSignal } = {}, + ): Promise { + if (options.signal?.aborted) + throw new AnalysisCancelledError("binary_session", { + cause: options.signal.reason, + }); + if (this.#closing !== undefined) + return this.#providers.map((provider) => + projectCandidateStatus(provider, target, { + availability: { + status: "unknown", + code: null, + reason: + "Provider ownership finalization is in progress; readiness has not been re-observed.", + diagnostics: { finalization: "in_progress" }, + }, + capabilities: provider.capabilities(), + }), + ); + const discovery = new SharedStartupAttempt< + Result + >( + (signal) => + Promise.resolve().then(() => this.#discoverCandidates(target, signal)), + options.signal, + ); + this.#discoveries.add(discovery); + try { + const result = await discovery.promise; + if (!result.ok) throw result.error; + if (discovery.signal.aborted) + throw new AnalysisCancelledError("binary_session", { + cause: discovery.signal.reason, + diagnostics: { + readiness_candidates: jsonValueSchema.parse(result.value), + }, + }); + return result.value; + } finally { + this.#discoveries.delete(discovery); + } + } + + async #discoverCandidates( + target: BinaryTarget | undefined, + signal: AbortSignal, + operation: "binary_session" | "open_binary" = "binary_session", + ): Promise< + Result + > { + if (signal.aborted) + return err( + new AnalysisCancelledError(operation, { cause: signal.reason }), + ); + const results = await Promise.allSettled( + this.#providers.map((provider) => + candidateStatus(provider, target, signal, operation), + ), + ); + const statuses: AnalysisProviderCandidateStatus[] = []; + const failures: { + readonly providerId: string; + readonly error: AnalysisError; + }[] = []; + for (const [index, result] of results.entries()) { + if (result.status === "fulfilled") statuses.push(result.value); + else + failures.push({ + providerId: this.#providers[index]!.identity().id, + error: + result.reason instanceof AnalysisError + ? result.reason + : new ProviderAdapterError( + this.#providers[index]!.identity().id, + operation, + { cause: result.reason }, + ), + }); + } + for (const { error } of failures) + if (error.cleanupIncomplete) this.#selectionCleanup.add(error); + if (failures.length > 0) + return err( + providerInspectionFailure( + failures, + { operation, candidates: statuses }, + signal, + ), + ); + return ok(statuses); } /** Select one whole deep-operation family for a parsed target. */ @@ -265,7 +375,7 @@ export class AnalysisProviderRegistry { ) { // A retry may release retained owners; never stop another live selection // merely because a new caller wants admission. - if (this.#selections.size > 0) + if (this.#selections.size > 0 || this.#discoveries.size > 0) return err( [...this.#selectionCleanup].find( (failure) => failure.cleanupIncomplete, @@ -324,6 +434,13 @@ export class AnalysisProviderRegistry { return err( new AnalysisCancelledError("open_binary", { cause: signal.reason }), ); + const discovery = await this.#discoverCandidates( + target, + signal, + "open_binary", + ); + if (!discovery.ok) return discovery; + const baseline = discovery.value; const parsedRequest = requestedSelector === undefined ? undefined @@ -335,12 +452,11 @@ export class AnalysisProviderRegistry { typeof requestedSelector === "string" ? requestedSelector : "", - this.candidates(target), + baseline, ), ); const request = parsedRequest?.data; const selector = request ?? this.#defaultSelector; - const baseline = this.candidates(target); if (signalIsAborted(signal)) return err( new AnalysisCancelledError("open_binary", { cause: signal.reason }), @@ -496,7 +612,13 @@ export class AnalysisProviderRegistry { failures.some(({ error }) => error.cleanupIncomplete) || signalIsAborted(signal) ) - return err(profileSelectionFailure(failures, signal)); + return err( + providerInspectionFailure( + failures, + { operation: "resolve_analysis_profile" }, + signal, + ), + ); const statuses = evaluations.map(({ status }) => status); const usable = evaluations.filter( ( @@ -522,14 +644,32 @@ export class AnalysisProviderRegistry { } } -const candidateStatus = ( +const candidateStatus = async ( provider: AnalysisProviderCandidate, target: BinaryTarget | undefined, + signal: AbortSignal, + operation: "binary_session" | "open_binary", +): Promise => { + if (signal.aborted) + throw new AnalysisCancelledError(operation, { cause: signal.reason }); + const readiness = + provider.inspectReadinessSnapshot === undefined + ? { + availability: await provider.inspectAvailability(signal), + capabilities: provider.capabilities(), + } + : await provider.inspectReadinessSnapshot(signal); + return projectCandidateStatus(provider, target, readiness); +}; + +const projectCandidateStatus = ( + provider: AnalysisProviderCandidate, + target: BinaryTarget | undefined, + readiness: { + readonly availability: AnalysisProviderCandidateStatus["availability"]; + readonly capabilities: readonly CapabilityDescriptor[]; + }, ): AnalysisProviderCandidateStatus => { - const readiness = provider.inspectReadinessSnapshot?.() ?? { - availability: provider.inspectAvailability(), - capabilities: provider.capabilities(), - }; return { provider: structuredClone(provider.identity()), availability: cloneAvailability(readiness.availability), @@ -626,8 +766,8 @@ const candidateRejections = ( ); const cloneAvailability = ( - availability: ProviderAvailability, -): ProviderAvailability => ({ + availability: AnalysisProviderCandidateStatus["availability"], +): AnalysisProviderCandidateStatus["availability"] => ({ ...availability, diagnostics: jsonObjectSchema.parse(availability.diagnostics), }); @@ -640,28 +780,49 @@ const cloneTargetSupport = ( }); /** Every terminal child remains attributed; cleanup is conjunctive across owners. */ -const profileSelectionFailure = ( +const providerInspectionFailure = ( failures: readonly { readonly providerId: string; readonly error: AnalysisError; }[], + context: + | { readonly operation: "resolve_analysis_profile" } + | { + readonly operation: "binary_session" | "open_binary"; + readonly candidates: readonly AnalysisProviderCandidateStatus[]; + }, signal?: AbortSignal, ): AnalysisError => { const primary = failures.find(({ error }) => !(error instanceof AnalysisCancelledError)) ?? failures[0]; if (primary === undefined) - return new AnalysisCancelledError("open_binary", { cause: signal?.reason }); - if (failures.length === 1) return primary.error; + return new AnalysisCancelledError( + context.operation === "resolve_analysis_profile" + ? "open_binary" + : context.operation, + { cause: signal?.reason }, + ); + if ( + failures.length === 1 && + (context.operation === "resolve_analysis_profile" || + context.candidates.length === 0) + ) + return primary.error; const cleanupFailures = failures .filter(({ error }) => error.cleanupIncomplete) .map(({ error }) => error); const options = { diagnostics: { - profile_errors: failures.map(({ providerId, error }) => ({ + [context.operation === "resolve_analysis_profile" + ? "profile_errors" + : "readiness_errors"]: failures.map(({ providerId, error }) => ({ provider_id: providerId, error: projectAnalysisError(error), })), + ...(context.operation === "resolve_analysis_profile" + ? {} + : { readiness_candidates: jsonValueSchema.parse(context.candidates) }), }, ...(primary.error.capturedOutput === undefined ? {} @@ -672,6 +833,7 @@ const profileSelectionFailure = ( ...(cleanupFailures.length === 0 ? {} : { + cleanupPrimary: primary.error, cleanup: { reason: cleanupFailures.map((error) => error.message).join("; "), resources: [ @@ -684,16 +846,21 @@ const profileSelectionFailure = ( }; const failure = primary.error instanceof AnalysisCancelledError - ? new AnalysisCancelledError("open_binary", { - ...options, - cause: primary.error.cause, - }) - : new ProviderAdapterError("rea", "resolve_analysis_profile", { + ? new AnalysisCancelledError( + context.operation === "resolve_analysis_profile" + ? "open_binary" + : context.operation, + { + ...options, + cause: primary.error.cause, + }, + ) + : new ProviderAdapterError("rea", context.operation, { ...options, userMessage: projectAnalysisError(primary.error).message, cause: new AggregateError( failures.map(({ error }) => error), - "Provider profile resolutions failed", + "Provider inspections failed", { cause: primary.error }, ), }); diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 7ac894cc99..bfae9bc886 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -591,10 +591,11 @@ export class BinarySession } /** Describe the active binary session. */ - status(): JsonValue { + status(options: { readonly signal?: AbortSignal } = {}): Promise { const run = this.#active ?? this.#pendingCleanup?.run; const client = run?.client; return binarySessionStatus({ + ...options, target: this.#active?.target, route: this.#currentRoute(), router: this.#providerRouter, diff --git a/src/application/binary/BinarySessionLiveness.test.ts b/src/application/binary/BinarySessionLiveness.test.ts index 52685b9f67..4cfb3b3946 100644 --- a/src/application/binary/BinarySessionLiveness.test.ts +++ b/src/application/binary/BinarySessionLiveness.test.ts @@ -205,17 +205,17 @@ it("clears retired runtime availability after cancelled-switch restoration", asy }); expect((await session.open(process.execPath)).ok).toBe(true); expect((await session.execute("address_name", {})).ok).toBe(false); - const availability = () => - buildCapabilityInventory(session.status(), { + const availability = async () => + buildCapabilityInventory(await session.status(), { processCaptureEnabled: false, }).find(({ name }) => name === "address_name"); - const initialAvailability = availability(); + const initialAvailability = await availability(); expect(initialAvailability).toMatchObject({ available: false, reason: "provider_unavailable", }); const originalTarget = session.activeTarget(); - const initialRunId = sessionRunId(session.status()); + const initialRunId = sessionRunId(await session.status()); const controller = new AbortController(); const switching = session.open("/bin/ls", { signal: controller.signal }); @@ -231,9 +231,9 @@ it("clears retired runtime availability after cancelled-switch restoration", asy expect(session.activeTarget()).toEqual(originalTarget); expect(deep.created).toHaveLength(1); expect(deep.created[0]?.closeCalled).toBe(true); - const restoredStatus = session.status(); + const restoredStatus = await session.status(); expect(sessionRunId(restoredStatus)).not.toBe(initialRunId); - expect(availability()).toMatchObject({ + expect(await availability()).toMatchObject({ available: true, reason: "available", remediation: null, @@ -242,20 +242,20 @@ it("clears retired runtime availability after cancelled-switch restoration", asy // 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({ + expect(await 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({ + expect(await availability()).toMatchObject({ available: false, reason: "provider_unavailable", }); deep.created[1]?.setOperationFailure(false); expect((await session.execute("address_name", {})).ok).toBe(true); - expect(availability()).toMatchObject({ + expect(await availability()).toMatchObject({ available: true, reason: "available", remediation: null, @@ -263,7 +263,9 @@ it("clears retired runtime availability after cancelled-switch restoration", asy await session.close(); }); -const sessionRunId = (status: ReturnType): string => { +const sessionRunId = ( + status: Awaited>, +): string => { if (typeof status !== "object" || status === null || Array.isArray(status)) throw new Error("Expected a binary session status object"); const analysisRun = status.analysis_run; diff --git a/src/application/binary/BinarySessionPort.ts b/src/application/binary/BinarySessionPort.ts index e66dfc1afb..7e8dee1098 100644 --- a/src/application/binary/BinarySessionPort.ts +++ b/src/application/binary/BinarySessionPort.ts @@ -87,7 +87,7 @@ export interface BinarySessionPort overwrite: boolean, options?: Pick, ): Promise>; - status(): JsonValue; + status(options?: { readonly signal?: AbortSignal }): Promise; activeTarget(): BinaryTarget | undefined; exportAnalysisSnapshot(): Result; importAnalysisSnapshot( diff --git a/src/application/binary/BinarySessionStatus.ts b/src/application/binary/BinarySessionStatus.ts index 5234f8b7aa..e563249867 100644 --- a/src/application/binary/BinarySessionStatus.ts +++ b/src/application/binary/BinarySessionStatus.ts @@ -31,10 +31,11 @@ interface BinarySessionStatusInput { readonly requestActivitySnapshots: readonly ProviderRequestActivitySnapshot[]; readonly providerOperationHealth?: ProviderOperationHealth | null; readonly cleanupPending?: boolean; + readonly signal?: AbortSignal; } /** Project internal provider routing state into the caller-visible session status. */ -export const binarySessionStatus = ({ +export const binarySessionStatus = async ({ target, route, router, @@ -44,7 +45,8 @@ export const binarySessionStatus = ({ requestActivitySnapshots, providerOperationHealth, cleanupPending = false, -}: BinarySessionStatusInput): JsonValue => { + signal, +}: BinarySessionStatusInput): Promise => { const routeCapabilities = [...route.capabilities.values()] .filter(({ operation }) => operation !== "inventory_artifact") .sort((left, right) => @@ -69,22 +71,22 @@ export const binarySessionStatus = ({ selection_source: route.binding.selectionSource, analysis_profile: structuredClone(route.binding.profile), }; - const analysisProviderCandidates = router - .candidateStatuses(route) - .map((candidate) => ({ - provider: providerSummary(candidate.provider), - availability: structuredClone(candidate.availability), - target_support: structuredClone(candidate.targetSupport), - selected: candidate.selected, - capabilities: providerCapabilitySets( - candidate.capabilities - .slice() - .filter(({ operation }) => operation !== "inventory_artifact") - .sort((left, right) => - compareUnicodeCodePoints(left.operation, right.operation), - ), - ), - })); + const analysisProviderCandidates = ( + await router.candidateStatuses(route, signal) + ).map((candidate) => ({ + provider: providerSummary(candidate.provider), + availability: structuredClone(candidate.availability), + target_support: structuredClone(candidate.targetSupport), + selected: candidate.selected, + capabilities: providerCapabilitySets( + candidate.capabilities + .slice() + .filter(({ operation }) => operation !== "inventory_artifact") + .sort((left, right) => + compareUnicodeCodePoints(left.operation, right.operation), + ), + ), + })); const common = { cleanup_pending: cleanupPending, capabilities, diff --git a/src/application/binary/SessionProviderRouter.ts b/src/application/binary/SessionProviderRouter.ts index e5babdf669..b6c62782f6 100644 --- a/src/application/binary/SessionProviderRouter.ts +++ b/src/application/binary/SessionProviderRouter.ts @@ -98,10 +98,17 @@ export class SessionProviderRouter { } /** Candidate status authoritative for the active or target-free route. */ - candidateStatuses( + async candidateStatuses( route: SessionProviderRoute, - ): readonly AnalysisProviderCandidateStatus[] { - return route.selection?.candidates ?? this.registry.candidates(); + signal?: AbortSignal, + ): Promise { + return ( + route.selection?.candidates ?? + this.registry.candidates( + undefined, + signal === undefined ? {} : { signal }, + ) + ); } /** Produce exact candidate rejections for an intentionally unbound route. */ diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index bee7c7eae3..d86d352c1a 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -259,6 +259,12 @@ const providerRejectionCode: z.ZodType = z.enum( const providerDiagnostics = z.record(z.string(), jsonValueSchema); const providerAvailability = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("unknown"), + code: z.null(), + reason: z.string().min(1), + diagnostics: providerDiagnostics, + }), z.object({ status: z.literal("available"), code: z.null(), diff --git a/src/server/promptCompletion.ts b/src/server/promptCompletion.ts index 21d67ededc..97e66546f4 100644 --- a/src/server/promptCompletion.ts +++ b/src/server/promptCompletion.ts @@ -146,11 +146,14 @@ const completionInventoryLookup = async ( return signal?.aborted === true ? undefined : result; }; -const providerCandidates = ( +const providerCandidates = async ( session: BinarySessionPort | undefined, -): readonly string[] => { +): Promise => { if (session === undefined) return []; - const parsed = providerStatusSchema.safeParse(session.status()); + const signal = currentMcpRequestSignal(); + const parsed = providerStatusSchema.safeParse( + await session.status(signal === undefined ? {} : { signal }), + ); if (!parsed.success) return []; const candidates = parsed.data.analysis_provider_candidates.map( ({ provider }) => provider.id, diff --git a/src/server/registerSessionStatusTool.ts b/src/server/registerSessionStatusTool.ts index 08587b0a7b..455f1de482 100644 --- a/src/server/registerSessionStatusTool.ts +++ b/src/server/registerSessionStatusTool.ts @@ -3,7 +3,7 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js"; import type { ProviderAvailability } from "../application/AnalysisProvider.js"; import type { JebAnalysisAvailability } from "../application/jeb/JebAnalysisPort.js"; -import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; import { buildCapabilityInventory, compactToolAvailability, @@ -13,7 +13,7 @@ import type { ClientFeatureAvailability, ToolAvailability, } from "../contracts/toolOutputSchemaPrimitives.js"; -import { jsonObjectSchema } from "../domain/jsonValue.js"; +import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js"; import { createServerIdentity } from "../serverIdentity.js"; import { mcpClientMetadata } from "./mcpClientMetadata.js"; import type { AvailabilityPolicy } from "../application/CapabilityInventory.js"; @@ -73,6 +73,7 @@ export const registerSessionStatusTool = ( let adbAvailability: ProviderAvailability | undefined; let apktoolAvailability: ProviderAvailability | undefined; let jebAvailability: JebAnalysisAvailability | undefined; + let status: JsonValue; try { const [android, adb, apktool, jeb] = await Promise.all([ androidAnalysisAvailability(context.mcpReq.signal), @@ -84,14 +85,11 @@ export const registerSessionStatusTool = ( adbAvailability = adb; apktoolAvailability = apktool; jebAvailability = jeb; + status = await session.status({ signal: context.mcpReq.signal }); } catch (cause) { - if (!context.mcpReq.signal.aborted) throw cause; - return server.delivery.toCallToolResult( - err(new AnalysisCancelledError("binary_session")), - contract, - ); + if (!(cause instanceof AnalysisError)) throw cause; + return server.delivery.toCallToolResult(err(cause), contract); } - const status = session.status(); const statusObject = jsonObjectSchema.parse(status); const toolAvailability = buildCapabilityInventory( status, diff --git a/tests/boundary/filesystem/analysisSnapshot.test.ts b/tests/boundary/filesystem/analysisSnapshot.test.ts index ef7e1cf088..fe0d80b0c5 100644 --- a/tests/boundary/filesystem/analysisSnapshot.test.ts +++ b/tests/boundary/filesystem/analysisSnapshot.test.ts @@ -107,7 +107,7 @@ describe("analysis snapshots: persistence", () => { expect( parseAnalysisSnapshot(JSON.parse(await readFile(path, "utf8"))), ).toEqual(snapshotFixture()); - expect(session.status()).toMatchObject({ + expect(await session.status()).toMatchObject({ open: false, cleanup_pending: true, }); @@ -115,11 +115,11 @@ describe("analysis snapshots: persistence", () => { ok: false, error: { cleanupIncomplete: true }, }); - expect(session.status()).toMatchObject({ cleanup_pending: true }); + expect(await session.status()).toMatchObject({ cleanup_pending: true }); close.mockRestore(); expect(await session.close()).toEqual({ ok: true, value: null }); - expect(session.status()).toMatchObject({ cleanup_pending: false }); + expect(await session.status()).toMatchObject({ cleanup_pending: false }); }); it("writes canonical private v2 JSON and rejects changed query identity", async () => { diff --git a/tests/boundary/filesystem/ghidraPspExtension.test.ts b/tests/boundary/filesystem/ghidraPspExtension.test.ts index c2fdfa6988..81fdbb0226 100644 --- a/tests/boundary/filesystem/ghidraPspExtension.test.ts +++ b/tests/boundary/filesystem/ghidraPspExtension.test.ts @@ -1027,7 +1027,9 @@ describe.skipIf(process.platform === "win32")( expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError"); expect(result.error.message).toContain(readFailure.message); expect(result.error.cleanupIncomplete).toBe(true); - expect(binary.status()).toMatchObject({ cleanup_pending: true }); + expect(await binary.status()).toMatchObject({ + cleanup_pending: true, + }); expect(createClient).not.toHaveBeenCalled(); const failedClose = await binary.close(); expect(failedClose.ok).toBe(false); @@ -1039,7 +1041,9 @@ describe.skipIf(process.platform === "win32")( expect(result.error.cleanupIncomplete).toBe(false); if (failedClose.ok) throw new Error("Close unexpectedly succeeded"); expect(failedClose.error.cleanupIncomplete).toBe(false); - expect(binary.status()).toMatchObject({ cleanup_pending: false }); + expect(await binary.status()).toMatchObject({ + cleanup_pending: false, + }); } finally { allowClose = true; await binary.close(); diff --git a/tests/boundary/mcp/providerSelectionMcp.test.ts b/tests/boundary/mcp/providerSelectionMcp.test.ts index 13d69bce44..b955534078 100644 --- a/tests/boundary/mcp/providerSelectionMcp.test.ts +++ b/tests/boundary/mcp/providerSelectionMcp.test.ts @@ -20,6 +20,7 @@ import { composeBinarySession } from "../../../src/application/binary/BinarySess import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; import { err, ok } from "../../../src/domain/result.js"; import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; @@ -301,12 +302,12 @@ it("retains attributed mixed profile cleanup through registered MCP calls and pa code: "cleanup_incomplete", details: { resources: ["beta-file"] }, }); - expect(session.status()).toMatchObject({ cleanup_pending: true }); + expect(await session.status()).toMatchObject({ cleanup_pending: true }); released.add("beta"); expect( (await mcp.callTool({ name: "close_binary", arguments: {} })).isError, ).not.toBe(true); - expect(session.status()).toMatchObject({ cleanup_pending: false }); + expect(await session.status()).toMatchObject({ cleanup_pending: false }); expect(failures.alpha.cleanupIncomplete).toBe(false); expect(failures.beta.cleanupIncomplete).toBe(false); } finally { @@ -315,3 +316,116 @@ it("retains attributed mixed profile cleanup through registered MCP calls and pa await session.close(); } }); + +it.each(["ready", "cancelled", "physical"] as const)( + "awaits asynchronous candidate finalization through registered MCP status (%s)", + async (mode) => { + const starts: string[] = []; + const base = candidate("alpha", starts); + let enter!: () => void; + let finish!: () => void; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const gate = new Promise((resolve) => { + finish = resolve; + }); + const session = composeBinarySession( + new AnalysisProviderRegistry([ + { + ...base, + inspectAvailability: async (signal) => { + enter(); + await gate; + if (mode === "physical") + throw new ProviderAdapterError("alpha", "binary_session", { + diagnostics: { + reason: "controlled physical readiness failure", + }, + }); + if (signal?.aborted) + throw new AnalysisCancelledError("binary_session", { + cause: signal.reason, + }); + return base.inspectAvailability(); + }, + }, + ]), + ); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const mcp = new Client({ name: "async-readiness", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(mcp, server, { close: () => session.close() }); + await server.connect(serverTransport); + await mcp.connect(clientTransport); + let settled = false; + const observing = mcp + .callTool({ name: "binary_session", arguments: {} }) + .then((result) => { + settled = true; + return result; + }); + let closing: ReturnType | undefined; + try { + await entered; + if (mode !== "ready") { + closing = session.close(); + const during = structured( + await mcp.callTool({ name: "binary_session", arguments: {} }), + ); + expect(during.result).toMatchObject({ + cleanup_pending: true, + analysis_provider_candidates: [ + { + provider: { id: "alpha" }, + availability: { + status: "unknown", + diagnostics: { finalization: "in_progress" }, + }, + }, + ], + }); + } + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + finish(); + const result = await observing; + if (mode === "ready") { + expect(result.isError).not.toBe(true); + expect(structured(result).result).toMatchObject({ + analysis_provider_candidates: [ + { + provider: { id: "alpha" }, + availability: { status: "available" }, + }, + ], + }); + } else { + expect(parseMcpToolError(result).error).toMatchObject( + mode === "cancelled" + ? { code: "cancelled", details: { operation: "binary_session" } } + : { + code: "execution_failure", + details: { + operation: "binary_session", + diagnostics: { + reason: "controlled physical readiness failure", + }, + }, + }, + ); + expect(await closing).toEqual(ok(null)); + } + expect(starts).toEqual([]); + } finally { + finish(); + await observing; + await closing; + await session.close(); + } + }, +); diff --git a/tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts b/tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts index 67d14090ff..e1f623daf9 100644 --- a/tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts +++ b/tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts @@ -125,7 +125,7 @@ it("admits Rizin startup across a bound analysis session", async () => { expect((await analysisSession.open(target)).ok).toBe(true); const initialRunId = z .object({ analysis_run: z.object({ run_id: z.string() }) }) - .parse(analysisSession.status()).analysis_run.run_id; + .parse(await analysisSession.status()).analysis_run.run_id; const manager = { start: async () => ({ ok: true, @@ -170,7 +170,7 @@ it("admits Rizin startup across a bound analysis session", async () => { expect((await analysisSession.open(target)).ok).toBe(true); const currentRunId = z .object({ analysis_run: z.object({ run_id: z.string() }) }) - .parse(analysisSession.status()).analysis_run.run_id; + .parse(await analysisSession.status()).analysis_run.run_id; expect(currentRunId).not.toBe(initialRunId); expect(analysisSession.activeTarget()?.path).toBe(target); } finally { diff --git a/tests/boundary/providers/hopper/hopperClient.externalExit.test.ts b/tests/boundary/providers/hopper/hopperClient.externalExit.test.ts index 396906e5a6..d65dc07a63 100644 --- a/tests/boundary/providers/hopper/hopperClient.externalExit.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.externalExit.test.ts @@ -196,7 +196,7 @@ it.skipIf(process.platform === "win32")( }); expect(switched.ok).toBe(true); expect(session.providerIdentity().id).toBe("ghidra"); - expect(session.status()).toMatchObject({ open: true }); + expect(await session.status()).toMatchObject({ open: true }); expect(external.fixture.requests.map(({ method }) => method)).toEqual([ "health", "address_name", diff --git a/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts b/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts index 5ff68f8ffd..48dfdec03a 100644 --- a/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts +++ b/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts @@ -468,12 +468,12 @@ it cleanupIncomplete: true, cleanupResources: [fixture.kuna], }); - expect(binary.status()).toMatchObject({ cleanup_pending: true }); + expect(await binary.status()).toMatchObject({ cleanup_pending: true }); expect(await binary.close()).toEqual({ ok: true, value: null }); expect(originatingError.cleanupIncomplete).toBe(false); expect(injected.closeCalls).toBe(stage === "fingerprint" ? 2 : 3); - expect(binary.status()).toMatchObject({ cleanup_pending: false }); + expect(await binary.status()).toMatchObject({ cleanup_pending: false }); } finally { injected.failCloseReadStage = undefined; const closed = await binary.close(); @@ -501,7 +501,7 @@ it.skipIf(process.platform === "win32")( cleanupResources: [fixture.kuna], }); expect(injected.closeCalls).toBe(1); - expect(binary.status()).toMatchObject({ + expect(await binary.status()).toMatchObject({ open: false, cleanup_pending: true, }); @@ -510,12 +510,12 @@ it.skipIf(process.platform === "win32")( error: { cleanupIncomplete: true, cleanupResources: [fixture.kuna] }, }); expect(injected.closeCalls).toBe(2); - expect(binary.status()).toMatchObject({ cleanup_pending: true }); + expect(await binary.status()).toMatchObject({ cleanup_pending: true }); injected.releaseCloseFailures = true; expect(await binary.close()).toEqual({ ok: true, value: null }); expect(injected.closeCalls).toBe(3); - expect(binary.status()).toMatchObject({ cleanup_pending: false }); + expect(await binary.status()).toMatchObject({ cleanup_pending: false }); expect((await fakeKunaCalls(fixture.kuna)).map(({ argv }) => argv)).toEqual( [["--version"], ["--version"]], ); @@ -583,7 +583,7 @@ it.skipIf(process.platform === "win32")( ok: false, error: { cleanupIncomplete: true, cleanupResources: [retired] }, }); - expect(binary.status()).toMatchObject({ + expect(await binary.status()).toMatchObject({ open: false, cleanup_pending: true, }); @@ -592,7 +592,7 @@ it.skipIf(process.platform === "win32")( ); await chmod(retired, 0o700); expect(await binary.close()).toEqual({ ok: true, value: null }); - expect(binary.status()).toMatchObject({ cleanup_pending: false }); + expect(await binary.status()).toMatchObject({ cleanup_pending: false }); await expect(readFile(join(retired, "evidence"))).rejects.toMatchObject({ code: "ENOENT", }); diff --git a/tests/boundary/providers/kuna/kunaProvider.test.ts b/tests/boundary/providers/kuna/kunaProvider.test.ts index 546ec09cf7..e6035e9b4b 100644 --- a/tests/boundary/providers/kuna/kunaProvider.test.ts +++ b/tests/boundary/providers/kuna/kunaProvider.test.ts @@ -148,24 +148,27 @@ it("waits for an aborted pre-session hash before retrying its late close owner", await new Promise((resolve) => setImmediate(resolve)); expect(previewSettled).toBe(false); expect(closeSettled).toBe(false); - expect(binary.status()).toMatchObject({ cleanup_pending: true }); + expect(await binary.status()).toMatchObject({ cleanup_pending: true }); releaseClose(); const preview = await previewWork; expect(preview).toMatchObject({ ok: false, - error: { _tag: "ProviderAdapterError", cleanupIncomplete: true }, + error: { _tag: "AnalysisCancelledError", cleanupIncomplete: true }, }); + if (preview.ok) throw new Error("Cancellation unexpectedly succeeded"); + expect(preview.error.cause).toBe(controller.signal.reason); + expect(preview.error.cleanupResources).toContain(fixture.kuna); const firstClose = await closing; expect(firstClose).toMatchObject({ ok: false, error: { cleanupIncomplete: true }, }); - expect(binary.status()).toMatchObject({ cleanup_pending: true }); + expect(await binary.status()).toMatchObject({ cleanup_pending: true }); close.mockRestore(); expect(await binary.close()).toEqual({ ok: true, value: null }); - expect(binary.status()).toMatchObject({ cleanup_pending: false }); + expect(await binary.status()).toMatchObject({ cleanup_pending: false }); if (preview.ok || firstClose.ok) throw new Error("Cleanup unexpectedly succeeded"); expect(preview.error.cleanupIncomplete).toBe(false); diff --git a/tests/boundary/providers/native/artifactProvider.directAnalysis.test.ts b/tests/boundary/providers/native/artifactProvider.directAnalysis.test.ts index c614f7d75e..f6fb12853b 100644 --- a/tests/boundary/providers/native/artifactProvider.directAnalysis.test.ts +++ b/tests/boundary/providers/native/artifactProvider.directAnalysis.test.ts @@ -32,7 +32,7 @@ it("runs static .app decoding without registering native analysis engines", asyn const staticSession = await createArtifactBinarySession({ REA_ANALYSIS_PROVIDER: "auto", }); - expect(staticSession.status()).toMatchObject({ + expect(await staticSession.status()).toMatchObject({ analysis_provider_candidates: [], }); await staticSession.close(); diff --git a/tests/composition/analysis-sessions/binarySession.test.ts b/tests/composition/analysis-sessions/binarySession.test.ts index 2a98223b70..20a537f3a6 100644 --- a/tests/composition/analysis-sessions/binarySession.test.ts +++ b/tests/composition/analysis-sessions/binarySession.test.ts @@ -83,7 +83,7 @@ describe("detached provider and target metadata", () => { const [first] = await createBinarySessionTargets(); const session = createTestBinarySession(createCacheProvider([])); expect((await session.open(first)).ok).toBe(true); - expect(session.status()).toMatchObject({ + expect(await session.status()).toMatchObject({ analysis_run: { run_id: expect.any(String), process_lineage: { status: "not_observed" }, @@ -127,12 +127,12 @@ describe("detached provider and target metadata", () => { }), }; const session = createTestBinarySession(provider); - expect(session.status()).toMatchObject({ + expect(await session.status()).toMatchObject({ open: false, provider_operation_health: null, }); expect((await session.open(first)).ok).toBe(true); - expect(session.status()).toMatchObject({ + expect(await session.status()).toMatchObject({ open: true, provider_operation_health: { state: "busy", @@ -149,7 +149,7 @@ describe("detached provider and target metadata", () => { }, }); healthState = "exited"; - expect(session.status()).toMatchObject({ + expect(await session.status()).toMatchObject({ open: true, provider_operation_health: { state: "exited", @@ -166,7 +166,7 @@ describe("detached provider and target metadata", () => { }, }); await session.close(); - expect(session.status()).toMatchObject({ + expect(await session.status()).toMatchObject({ open: false, provider_operation_health: null, }); @@ -942,7 +942,7 @@ describe("provider health and availability", () => { }); expect((await session.open(first)).ok).toBe(true); expect((await session.execute("address_name", {})).ok).toBe(false); - expect(session.status()).toMatchObject({ + expect(await session.status()).toMatchObject({ capabilities: expect.arrayContaining([ expect.objectContaining({ provider: { @@ -963,7 +963,7 @@ describe("provider health and availability", () => { }); expect(changes).toBe(1); expect((await session.open(second)).ok).toBe(true); - expect(session.status()).toMatchObject({ + expect(await session.status()).toMatchObject({ capabilities: expect.arrayContaining([ expect.objectContaining({ provider: { @@ -1020,7 +1020,7 @@ describe("provider health and availability", () => { { ok: true }, ); expect(delivered).toBe(1); - expect(session.status()).toMatchObject({ + expect(await session.status()).toMatchObject({ capabilities: expect.arrayContaining([ expect.objectContaining({ provider: { @@ -1123,13 +1123,13 @@ describe("typed unavailability without dispatching", () => { const session = createTestBinarySession(() => client(created++ === 1)); expect((await session.open(first)).ok).toBe(true); expect((await session.open(second)).ok).toBe(false); - expect(session.status()).toMatchObject({ + expect(await session.status()).toMatchObject({ open: true, sha256: "7692c3ad3540bb803c020b3aee66cd8887123234ea0c6e7143c0add73ff431ed", architecture: null, }); - expect(JSON.stringify(session.status())).toContain("first.hop"); + expect(JSON.stringify(await session.status())).toContain("first.hop"); expect(created).toBe(3); }); @@ -1164,7 +1164,7 @@ describe("typed unavailability without dispatching", () => { 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(await session.status()).toMatchObject({ open: false }); expect(clients[0]?.closed).toBe(1); expect(clients[1]?.closed).toBe(1); }); @@ -1394,7 +1394,7 @@ describe("cancellation during profile resolution", () => { expect(observedSignal?.reason).toBe(reason); expect(openSettled).toBe(false); expect(closeSettled).toBe(false); - expect(session.status()).toMatchObject({ cleanup_pending: true }); + expect(await session.status()).toMatchObject({ cleanup_pending: true }); release.resolve(); await expect(opening).resolves.toMatchObject({ ok: false, @@ -1402,7 +1402,7 @@ describe("cancellation during profile resolution", () => { }); expect(await closing).toEqual(resultOk(null)); expect(created).toBe(0); - expect(session.status()).toMatchObject({ cleanup_pending: false }); + expect(await session.status()).toMatchObject({ cleanup_pending: false }); } finally { release.resolve(); await Promise.all([opening, closing]); @@ -1452,7 +1452,7 @@ describe("cancellation during client transitions", () => { expect(clients[1]?.closed).toBe(1); expect(clients[0]?.closed).toBe(1); expect(clients[2]?.closed).toBe(0); - expect(JSON.stringify(session.status())).toContain("first.hop"); + expect(JSON.stringify(await session.status())).toContain("first.hop"); await session.close(); await session.close(); expect(clients[2]?.closed).toBe(1); @@ -1495,7 +1495,7 @@ describe("cancellation during client transitions", () => { expect((await session.open(first)).ok).toBe(true); expect(await session.close()).toEqual(err(cleanupError)); - expect(session.status()).toMatchObject({ + expect(await session.status()).toMatchObject({ open: false, analysis_activity: { status: "not_observed", providers: [] }, }); @@ -1522,7 +1522,7 @@ describe("no replacement start", () => { expect(await session.open(second)).toEqual(err(cleanupError)); expect(created).toBe(1); - expect(session.status()).toMatchObject({ open: false }); + expect(await session.status()).toMatchObject({ open: false }); }); }); diff --git a/tests/composition/analysis-sessions/binarySessionCleanupRetry.test.ts b/tests/composition/analysis-sessions/binarySessionCleanupRetry.test.ts index 76ea8712da..ce52603da1 100644 --- a/tests/composition/analysis-sessions/binarySessionCleanupRetry.test.ts +++ b/tests/composition/analysis-sessions/binarySessionCleanupRetry.test.ts @@ -113,7 +113,7 @@ it.each(["close", "switch", "open after close"] as const)( transition === "switch" ? await attempt() : await session.close(); expect(initial).toEqual(err(cleanupError)); expect(session.activeTarget()).toBeUndefined(); - expect(session.status()).toMatchObject({ + expect(await session.status()).toMatchObject({ open: false, cleanup_pending: true, analysis_run: { @@ -133,7 +133,7 @@ it.each(["close", "switch", "open after close"] as const)( await access(runtime); expect(await attempt()).toEqual(err(cleanupError)); - expect(session.status()).toMatchObject({ + expect(await session.status()).toMatchObject({ analysis_run: { run_id: fixture.runIds[0] }, }); expect(fixture.created()).toBe(1); @@ -141,10 +141,10 @@ it.each(["close", "switch", "open after close"] as const)( fixture.allowCleanup(); expect((await attempt()).ok).toBe(true); - expect(session.status()).toMatchObject({ cleanup_pending: false }); + expect(await session.status()).toMatchObject({ cleanup_pending: false }); await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" }); expect(fixture.created()).toBe(transition === "close" ? 1 : 2); - expect(session.status()).toMatchObject({ + expect(await session.status()).toMatchObject({ analysis_run: transition === "close" ? null @@ -173,7 +173,7 @@ it("keeps a failed candidate for cleanup and preserves its startup failure", asy }, }); expect(fixture.session.activeTarget()).toBeUndefined(); - expect(fixture.session.status()).toMatchObject({ + expect(await fixture.session.status()).toMatchObject({ open: false, cleanup_pending: true, analysis_run: { @@ -206,7 +206,7 @@ it("retains cancelled startup cleanup and the cancellation reason", async () => diagnostics: { primary_error: { code: "cancelled" } }, }, }); - expect(fixture.session.status()).toMatchObject({ + expect(await fixture.session.status()).toMatchObject({ open: false, cleanup_pending: true, analysis_run: { @@ -290,7 +290,7 @@ it("keeps failed restoration resources available for a close retry", async () => error: { cleanupIncomplete: true, cleanupResources: [runtime] }, }); expect(session.activeTarget()).toBeUndefined(); - expect(session.status()).toMatchObject({ + expect(await session.status()).toMatchObject({ open: false, cleanup_pending: true, analysis_run: { run_id: runIds[2] }, diff --git a/tests/composition/analysis-sessions/compositeProvider.test.ts b/tests/composition/analysis-sessions/compositeProvider.test.ts index fd6d230ee8..02e4bc9d5e 100644 --- a/tests/composition/analysis-sessions/compositeProvider.test.ts +++ b/tests/composition/analysis-sessions/compositeProvider.test.ts @@ -86,7 +86,7 @@ describe("composite analysis provider", () => { ok: true, }); - expect(session.status()).toMatchObject({ + expect(await session.status()).toMatchObject({ analysis_run: { process_lineage: { status: "snapshots", @@ -183,7 +183,7 @@ describe("composite provider health status", () => { expect((await session.execute("procedure_info", {})).ok).toBe(true); failed = true; - expect(session.status()).toMatchObject({ + expect(await session.status()).toMatchObject({ provider_operation_health: { state: "exited", stage: "connection", diff --git a/tests/composition/analysis-sessions/providerBinding.test.ts b/tests/composition/analysis-sessions/providerBinding.test.ts index 9ed21abfa8..f38174497a 100644 --- a/tests/composition/analysis-sessions/providerBinding.test.ts +++ b/tests/composition/analysis-sessions/providerBinding.test.ts @@ -61,7 +61,7 @@ describe("target-bound provider routing", () => { expect(session.activeTarget()).toBeUndefined(); expect((await session.open(target, { providerId: "alpha" })).ok).toBe(true); - expect(bindingStatus(session)).toMatchObject({ + expect(await bindingStatus(session)).toMatchObject({ analysis_provider_binding: { provider: { id: "alpha", version: "1" }, selection_source: "request", @@ -100,7 +100,7 @@ describe("target-bound provider routing", () => { alpha.available = false; expect((await session.open(target)).ok).toBe(true); - expect(bindingStatus(session)).toMatchObject({ + expect(await bindingStatus(session)).toMatchObject({ analysis_provider_binding: { provider: { id: "alpha" } }, }); @@ -108,7 +108,7 @@ describe("target-bound provider routing", () => { expect(unknown.ok).toBe(false); if (!unknown.ok) expect(unknown.error).toMatchObject({ reason: "unknown_provider" }); - expect(bindingStatus(session)).toMatchObject({ + expect(await bindingStatus(session)).toMatchObject({ analysis_provider_binding: { provider: { id: "alpha" } }, }); @@ -119,7 +119,7 @@ describe("target-bound provider routing", () => { expect(failed.ok).toBe(false); if (!failed.ok) expect(failed.error._tag).toBe("ProviderAdapterError"); expect(alpha.clients[0]?.calls).toEqual(["address_name"]); - expect(bindingStatus(session)).toMatchObject({ + expect(await bindingStatus(session)).toMatchObject({ analysis_provider_binding: { provider: { id: "beta" } }, }); @@ -178,7 +178,7 @@ describe("target-bound provider routing", () => { }); expect(alpha.clients[0]?.closed).toBe(false); expect(beta.clients).toHaveLength(0); - expect(bindingStatus(session)).toMatchObject({ + expect(await bindingStatus(session)).toMatchObject({ analysis_provider_binding: { provider: { id: "alpha" } }, }); @@ -199,7 +199,7 @@ describe("selected provider capability provenance", () => { expect((await session.open(target, { providerId: "alpha" })).ok).toBe(true); const resolved = { id: "alpha", name: "alpha provider", version: "1" }; - const status = bindingStatus(session); + const status = await bindingStatus(session); expect(status).toMatchObject({ analysis_provider_binding: { provider: resolved }, analysis_provider_candidates: [ @@ -391,14 +391,14 @@ const databaseTarget = async (): Promise => { return path; }; -const bindingStatus = (session: BinarySession) => +const bindingStatus = async (session: BinarySession) => z .object({ analysis_provider_binding: z.unknown(), analysis_provider_candidates: z.array(z.unknown()), }) .passthrough() - .parse(session.status()); + .parse(await session.status()); interface Deferred { readonly promise: Promise;