diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index d971a316ea..7105bfbe24 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -158,6 +158,8 @@ export interface AnalysisClient extends AnalysisOperationPort { runtimeLineageSnapshots?(): readonly ProviderRuntimeLineageSnapshot[]; /** Current provider work that may outlive a timed-out or cancelled caller. */ requestActivitySnapshots?(): readonly ProviderRequestActivitySnapshot[]; + /** Local reuse admission is separate from observed native process health. */ + reuseReadiness?(): "ready" | "blocked" | "unknown"; /** Current operational health for the active provider bridge. */ operationHealthSnapshot?(): ProviderOperationHealth | undefined; /** Close owned resources and retain failed cleanup for a later retry. */ diff --git a/src/application/binary/AnalysisClientCleanup.test.ts b/src/application/binary/AnalysisClientCleanup.test.ts index 3ca21b06ff..fff856c740 100644 --- a/src/application/binary/AnalysisClientCleanup.test.ts +++ b/src/application/binary/AnalysisClientCleanup.test.ts @@ -5,7 +5,7 @@ import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import { err } from "../../domain/result.js"; import type { AnalysisClient } from "../AnalysisProvider.js"; -import { closeAnalysisClient } from "./AnalysisClientCleanup.js"; +import { closeAnalysisClient } from "../../providers/AnalysisClientCleanup.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; const clientClosingWith = (cause: unknown): AnalysisClient => ({ diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index bfae9bc886..2ce4bc1524 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -46,7 +46,7 @@ import { commitExecutionProfile, prepareSessionExecution, } from "./BinarySessionExecution.js"; -import { closeAnalysisClient } from "./AnalysisClientCleanup.js"; +import { closeAnalysisClient } from "../../providers/AnalysisClientCleanup.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; const OFFICIAL_OPERATIONS: ReadonlySet = new Set( OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name), @@ -927,6 +927,7 @@ export class BinarySession client: AnalysisClient, options: Pick, ): Promise { + if (client.reuseReadiness?.() === "blocked") return false; const snapshot = client.operationHealthSnapshot?.(); if (snapshot !== undefined) return snapshot.state === "idle" || snapshot.state === "busy"; diff --git a/src/application/binary/BinarySessionLiveness.test.ts b/src/application/binary/BinarySessionLiveness.test.ts index 4cfb3b3946..d488ef9d98 100644 --- a/src/application/binary/BinarySessionLiveness.test.ts +++ b/src/application/binary/BinarySessionLiveness.test.ts @@ -37,6 +37,23 @@ import { SessionProviderRouter } from "./SessionProviderRouter.js"; * recorded process exit. */ describe("BinarySession open liveness", () => { + it("retires a locally blocked owner even when its native health is idle", async () => { + const { session, deep } = openSession(); + try { + expect((await session.open(process.execPath)).ok).toBe(true); + expect((await session.execute("address_name", {})).ok).toBe(true); + const retained = deep.created[0]; + if (retained === undefined) throw new Error("Expected an acquired child"); + retained.reuseReadiness = () => "blocked"; + expect(retained.operationHealthSnapshot?.()?.state).toBe("idle"); + expect((await session.open(process.execPath)).ok).toBe(true); + expect(retained.closeCalled).toBe(true); + expect((await session.execute("address_name", {})).ok).toBe(true); + expect(deep.created).toHaveLength(2); + } finally { + await session.close(); + } + }); it("reuses the retained client while the child stays live", async () => { const { session, deep } = openSession(); expect((await session.open(process.execPath)).ok).toBe(true); diff --git a/src/application/binary/CompositeProvider.test.ts b/src/application/binary/CompositeProvider.test.ts index 56334fa1c2..b0e7a37a6b 100644 --- a/src/application/binary/CompositeProvider.test.ts +++ b/src/application/binary/CompositeProvider.test.ts @@ -78,6 +78,26 @@ const executableTarget = { availableArchitectures: ["x86_64"], } as const; +it("does not create a previously unused child after the composite closes", async () => { + const child = failingProvider("provider-only", "address_name", "one-db"); + const create = vi.spyOn(child.provider, "createClient"); + const client = new CompositeProvider([child.provider]).createClient( + executableTarget, + ); + expect(await client.close()).toEqual(ok(null)); + expect(client.reuseReadiness?.()).toBe("blocked"); + expect(await client.executeIfReady?.("address_name", {})).toBeUndefined(); + expect(await client.execute("address_name", {})).toMatchObject({ + ok: false, + error: { + _tag: "ProviderAdapterError", + diagnostics: { reason: "client_closed" }, + }, + }); + expect(create).not.toHaveBeenCalled(); + expect(child.close).not.toHaveBeenCalled(); +}); + it("retains every failed child resource across composite cleanup retries", async () => { const left = failingProvider("provider-left", "address_name", "left-db"); const right = failingProvider("provider-right", "procedure_info", "right-db"); diff --git a/src/application/binary/CompositeProvider.ts b/src/application/binary/CompositeProvider.ts index c7cac1333e..672757eec3 100644 --- a/src/application/binary/CompositeProvider.ts +++ b/src/application/binary/CompositeProvider.ts @@ -13,7 +13,7 @@ import type { ExecutionOptions, ProviderIdentity, } from "../AnalysisProvider.js"; -import { closeAnalysisClient } from "./AnalysisClientCleanup.js"; +import { closeAnalysisClient } from "../../providers/AnalysisClientCleanup.js"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; /** Synthetic identity for a deterministic provider set. */ @@ -83,6 +83,10 @@ const closeCompositeClients = ( "Composed provider cleanup failed.", ), }, + ).bindCleanupDependencies( + failures + .map(({ error }) => error) + .filter((error) => error.cleanupIncomplete), ), ); }; @@ -181,18 +185,29 @@ export class CompositeProvider implements AnalysisProvider { clients.set(provider, created); return created; }; - const close = closeCompositeClients(this.#identity, clients); + let closed = false; + const closeClients = closeCompositeClients(this.#identity, clients); + const close: AnalysisClient["close"] = (options) => { + closed = true; + return closeClients(options); + }; return { executeIfReady: (operation, parameters, options) => { const provider = this.#providerByOperation.get(operation); const client = - provider === undefined ? undefined : clients.get(provider); + provider === undefined || closed ? undefined : clients.get(provider); return ( client?.executeIfReady?.(operation, parameters, options) ?? Promise.resolve(undefined) ); }, execute: async (operation, parameters, options) => { + if (closed) + return err( + new ProviderAdapterError(this.#identity.id, operation, { + diagnostics: { reason: "client_closed" }, + }), + ); if (operation === "health") return executeCompositeHealth(this.#identity, clients, options); const provider = this.#providerByOperation.get(operation); @@ -220,6 +235,16 @@ export class CompositeProvider implements AnalysisProvider { .sort((left, right) => compareUnicodeCodePoints(left.provider.id, right.provider.id), ), + reuseReadiness: () => { + if (closed) return "blocked"; + const states = [...clients.values()].map( + (client) => client.reuseReadiness?.() ?? "unknown", + ); + if (states.some((state) => state === "blocked")) return "blocked"; + return states.some((state) => state === "unknown") + ? "unknown" + : "ready"; + }, operationHealthSnapshot: () => { const snapshots = [...clients.values()].map((client) => client.operationHealthSnapshot?.(), diff --git a/src/application/binary/DeferredAnalysisClient.test.ts b/src/application/binary/DeferredAnalysisClient.test.ts new file mode 100644 index 0000000000..861df20a35 --- /dev/null +++ b/src/application/binary/DeferredAnalysisClient.test.ts @@ -0,0 +1,379 @@ +import { expect, it } from "vitest"; +import { DeferredAnalysisClient } from "../../providers/DeferredAnalysisClient.js"; +import { + createAnalysisExecution, + type AnalysisClient, +} from "../AnalysisProvider.js"; +import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { resolvedAnalysisError } from "../../domain/analysisErrorBase.js"; +import { err, ok } from "../../domain/result.js"; + +const identity = { id: "controlled", name: "Controlled client", version: "1" }; +const gate = () => { + let resolve!: (value: T) => void; + const promise = new Promise((finish) => { + resolve = finish; + }); + return { promise, resolve }; +}; +const child = ( + close: AnalysisClient["close"] = async () => ok(null), +): AnalysisClient => ({ + execute: async () => ok(createAnalysisExecution(null, identity)), + close, + operationHealthSnapshot: () => ({ + state: "idle", + stage: null, + retryAction: null, + exitCode: null, + requests: [], + }), +}); + +it("refuses dispatch when close starts after a ready client was acquired", async () => { + let operations = 0; + const acquired: AnalysisClient = { + ...child(), + execute: async () => { + operations++; + return ok(createAnalysisExecution(null, identity)); + }, + }; + const client = new DeferredAnalysisClient(async () => acquired, identity.id); + expect((await client.execute("health", {})).ok).toBe(true); + const pending = client.execute("analyze_function", {}); + const closing = client.close(); + try { + expect(await pending).toMatchObject({ + ok: false, + error: { + _tag: "ProviderAdapterError", + operation: "analyze_function", + diagnostics: { reason: "client_closed" }, + }, + }); + expect(operations).toBe(1); + expect( + await client.executeIfReady?.("analyze_function", {}), + ).toBeUndefined(); + expect(await closing).toEqual(ok(null)); + } finally { + await pending; + await closing; + await client.close(); + } +}); + +it.each([ + null, + "caller reason", + new ProviderCleanupError("foreign", ["foreign resource"], { + reason: "caller metadata", + }), +])( + "does not acquire or adopt resources for pre-aborted opaque reasons (%s)", + async (reason) => { + let creates = 0; + const controller = new AbortController(); + controller.abort(reason); + const client = new DeferredAnalysisClient(async () => { + creates++; + return child(); + }, identity.id); + const result = await client.execute( + "health", + {}, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.cleanupResources).toEqual([]); + expect(creates).toBe(0); + expect(await client.close()).toEqual(ok(null)); + }, +); + +it("keeps a shared creation alive for an interested peer", async () => { + const entered = gate(), + loading = gate(); + let observed: AbortSignal | undefined, + creates = 0, + closes = 0; + const client = new DeferredAnalysisClient(async (signal) => { + creates++; + observed = signal; + entered.resolve(); + return loading.promise; + }, identity.id); + const controller = new AbortController(), + reason = { request: "one peer leaves" }; + const first = client.execute("health", {}, { signal: controller.signal }); + const second = client.execute("health", {}); + try { + await entered.promise; + controller.abort(reason); + const cancelled = await first; + if (cancelled.ok) throw new Error("Expected peer cancellation"); + expect(cancelled.error.cause).toBe(reason); + expect(observed?.aborted).toBe(false); + loading.resolve( + child(async () => { + closes++; + return ok(null); + }), + ); + expect((await second).ok).toBe(true); + expect(creates).toBe(1); + expect(await client.close()).toEqual(ok(null)); + expect(closes).toBe(1); + } finally { + loading.resolve(child()); + await Promise.allSettled([first, second]); + await client.close(); + } +}); + +it("joins a late acquired client's final cleanup before cancellation settles", async () => { + const entered = gate(), + loading = gate(), + closeEntered = gate(), + closing = gate(); + let closes = 0, + settled = false; + const client = new DeferredAnalysisClient(async () => { + entered.resolve(); + return loading.promise; + }, identity.id); + const controller = new AbortController(), + reason = { request: "late acquisition" }; + const pending = client + .execute("analyze_function", {}, { signal: controller.signal }) + .then((result) => { + settled = true; + return result; + }); + try { + await entered.promise; + controller.abort(reason); + loading.resolve( + child(async () => { + closes++; + closeEntered.resolve(); + await closing.promise; + return ok(null); + }), + ); + await closeEntered.promise; + expect(settled).toBe(false); + expect(client.operationHealthSnapshot()?.state).toBe("idle"); + expect(client.reuseReadiness()).toBe("blocked"); + closing.resolve(); + const result = await pending; + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error.cause).toBe(reason); + expect(result.error.cleanupIncomplete).toBe(false); + expect(await client.close()).toEqual(ok(null)); + expect(closes).toBe(1); + } finally { + loading.resolve(child()); + closing.resolve(); + await pending; + await client.close(); + } +}); + +it("keeps a producer-authored physical failure primary even when it is the caller's abort reason", async () => { + const entered = gate(), + finishing = gate(); + const physical = new ProviderAdapterError( + identity.id, + "create_analysis_client", + { diagnostics: { reason: "physical fixture fault" } }, + ); + const client = new DeferredAnalysisClient(async () => { + entered.resolve(); + await finishing.promise; + throw physical; + }, identity.id); + const controller = new AbortController(); + const pending = client.execute( + "analyze_function", + {}, + { signal: controller.signal }, + ); + try { + await entered.promise; + controller.abort(physical); + finishing.resolve(); + const result = await pending; + if (result.ok) throw new Error("Expected physical failure"); + expect(result.error).toMatchObject({ + _tag: "ProviderAdapterError", + operation: "analyze_function", + diagnostics: { + reason: "physical fixture fault", + }, + }); + expect(result.error.cause).toBe(physical); + expect(physical.cause).toBeUndefined(); + expect(projectAnalysisError(result.error).code).toBe("execution_failure"); + } finally { + finishing.resolve(); + await pending; + await client.close(); + } +}); + +it("retains a failed late client's exact close owner and restores cancellation after confirmed retry", async () => { + let denied = true, + closes = 0; + const controller = new AbortController(), + reason = { request: "late cleanup refusal" }; + const client = new DeferredAnalysisClient(async () => { + controller.abort(reason); + return child(async () => { + closes++; + return denied + ? err( + new ProviderCleanupError(identity.id, ["controlled child"], { + reason: "controlled close refusal", + }), + ) + : ok(null); + }); + }, identity.id); + const result = await client.execute( + "analyze_function", + {}, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected cancellation with cleanup"); + try { + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toEqual(["controlled child"]); + expect(client.reuseReadiness()).toBe("blocked"); + const firstClose = await client.close(); + if (firstClose.ok) throw new Error("Expected retained close failure"); + expect(firstClose.error.cleanupIncomplete).toBe(true); + expect(closes).toBe(2); + denied = false; + expect(await client.close()).toEqual(ok(null)); + expect(result.error.cleanupIncomplete).toBe(false); + expect(firstClose.error.cleanupIncomplete).toBe(false); + expect(projectAnalysisError(result.error).code).toBe("cancelled"); + expect(resolvedAnalysisError(result.error).cause).toBe(reason); + expect(await client.close()).toEqual(ok(null)); + expect(closes).toBe(3); + } finally { + denied = false; + await client.close(); + } +}); + +it("retains a failed prerequisite owner without creating a native client", async () => { + let denied = true, + releases = 0; + const cleanup = new ProviderCleanupError( + identity.id, + ["controlled prerequisite"], + { reason: "controlled refusal" }, + ); + const reason = { request: "prerequisite finalization" }; + const client = new DeferredAnalysisClient(async (_signal, resources) => { + resources.retain( + { + close: async () => { + releases++; + if (denied) throw cleanup; + cleanup.resolveCleanup(); + }, + }, + "controlled prerequisite", + ); + throw new AnalysisCancelledError("create_analysis_client", { + cause: reason, + cleanup: { + reason: "controlled refusal", + resources: cleanup.cleanupResources, + }, + }).bindCleanupDependencies([cleanup]); + }, identity.id); + const result = await client.execute("analyze_function", {}); + if (result.ok) throw new Error("Expected prerequisite failure"); + try { + expect(result.error.cleanupIncomplete).toBe(true); + expect((await client.close()).ok).toBe(false); + denied = false; + expect(await client.close()).toEqual(ok(null)); + expect(result.error.cleanupIncomplete).toBe(false); + expect(projectAnalysisError(result.error).code).toBe("cancelled"); + expect(resolvedAnalysisError(result.error).cause).toBe(reason); + expect(releases).toBe(3); + } finally { + denied = false; + await client.close(); + } +}); + +it("retries only unresolved prerequisite and native owners across mixed cleanup outcomes", async () => { + let denyPrerequisite = true, + denyChild = true, + prerequisites = 0, + closes = 0; + const prerequisiteFailure = new ProviderCleanupError( + identity.id, + ["mixed prerequisite"], + { reason: "controlled prerequisite refusal" }, + ); + const client = new DeferredAnalysisClient(async (_signal, resources) => { + resources.retain( + { + close: async () => { + prerequisites++; + if (denyPrerequisite) throw prerequisiteFailure; + prerequisiteFailure.resolveCleanup(); + }, + }, + "mixed prerequisite", + ); + return child(async () => { + closes++; + return denyChild + ? err( + new ProviderCleanupError(identity.id, ["mixed child"], { + reason: "controlled child refusal", + }), + ) + : ok(null); + }); + }, identity.id); + const result = await client.execute("analyze_function", {}); + if (result.ok) + throw new Error("Expected mixed initialization cleanup failure"); + try { + expect(result.error.cleanupResources).toEqual( + expect.arrayContaining(["mixed prerequisite", "mixed child"]), + ); + expect(client.reuseReadiness()).toBe("blocked"); + denyPrerequisite = false; + const first = await client.close(); + expect(first.ok).toBe(false); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toEqual(["mixed child"]); + expect(prerequisites).toBe(2); + denyChild = false; + expect(await client.close()).toEqual(ok(null)); + expect(result.error.cleanupIncomplete).toBe(false); + expect(prerequisites).toBe(2); + expect(closes).toBe(3); + } finally { + denyPrerequisite = false; + denyChild = false; + await client.close(); + } +}); diff --git a/src/application/binary/LazyAnalysisProvider.test.ts b/src/application/binary/LazyAnalysisProvider.test.ts index 66e92dcd65..497ebafcb9 100644 --- a/src/application/binary/LazyAnalysisProvider.test.ts +++ b/src/application/binary/LazyAnalysisProvider.test.ts @@ -5,9 +5,10 @@ import type { AnalysisProvider, ProviderIdentity, } from "../AnalysisProvider.js"; +import { createAnalysisExecution } from "../AnalysisProvider.js"; import { LazyAnalysisProvider } from "./LazyAnalysisProvider.js"; import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; -import { err, ok as resultOk } from "../../domain/result.js"; +import { ok as resultOk } from "../../domain/result.js"; const identity: ProviderIdentity = { id: "test-provider", @@ -23,7 +24,7 @@ const providerWithClient = ( createClient: () => { onCreate(); return { - execute: async (operation) => err(new AnalysisCancelledError(operation)), + execute: async () => resultOk(createAnalysisExecution(null, identity)), close: async () => resultOk(null), }; }, @@ -58,34 +59,57 @@ it("returns a tagged provider error when lazy loading fails", async () => { }); }); -it("returns cancellation while an unabortable lazy load continues safely", async () => { - let finishLoading: ((provider: AnalysisProvider) => void) | undefined; - let markCreated: (() => void) | undefined; - const created = new Promise((resolve) => { - markCreated = resolve; +it("joins a cancelled unabortable module load and refuses late client acquisition", async () => { + let finishLoading!: (provider: AnalysisProvider) => void; + let enter!: () => void; + let creates = 0; + const entered = new Promise((resolve) => { + enter = resolve; }); const provider = new LazyAnalysisProvider({ identity, capabilities: [], - load: () => - new Promise((resolve) => { + load: () => { + enter(); + return new Promise((resolve) => { finishLoading = resolve; - }), + }); + }, }); const controller = new AbortController(); + const reason = { request: "cancel during import" }; const client = provider.createClient(await target()); - const pending = client.execute("health", {}, { signal: controller.signal }); - - await Promise.resolve(); - controller.abort(); - expect(await pending).toMatchObject({ - ok: false, - error: { _tag: "AnalysisCancelledError", operation: "health" }, - }); - - finishLoading?.(providerWithClient(() => markCreated?.())); - await created; - await client.close(); + let settled = false; + const pending = client + .execute("health", {}, { signal: controller.signal }) + .then((value) => { + settled = true; + return value; + }); + await entered; + controller.abort(reason); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + finishLoading( + providerWithClient(() => { + creates++; + }), + ); + const result = await pending; + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError", operation: "health" }, + }); + if (result.ok) throw new Error("Cancellation unexpectedly succeeded"); + expect(result.error.cause).toBe(reason); + expect(creates).toBe(0); + expect(await client.close()).toEqual(resultOk(null)); + } finally { + finishLoading(providerWithClient()); + await pending; + await client.close(); + } }); it("reports that a lazy client was closed before its first operation", async () => { diff --git a/src/application/binary/LazyAnalysisProvider.ts b/src/application/binary/LazyAnalysisProvider.ts index 095acf5edd..25165bf813 100644 --- a/src/application/binary/LazyAnalysisProvider.ts +++ b/src/application/binary/LazyAnalysisProvider.ts @@ -1,26 +1,17 @@ import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js"; import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; -import { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; -import type { ProviderOperationHealth } from "../../domain/providerOperationHealth.js"; -import { err, ok } from "../../domain/result.js"; -import { ABORTED, waitForAbortable } from "../../process/AbortablePromise.js"; +import { DeferredAnalysisClient } from "../../providers/DeferredAnalysisClient.js"; import type { AnalysisClient, AnalysisClientContext, AnalysisProvider, CapabilityDescriptor, ProviderIdentity, - ProviderRequestActivitySnapshot, - ProviderRuntimeLineageSnapshot, } from "../AnalysisProvider.js"; type LoadAnalysisProvider = () => Promise; -const isAborted = (signal: AbortSignal | undefined): boolean => - signal?.aborted === true; - /** * Preserve synchronous provider discovery while loading implementation modules * only when a target-bound client first executes. @@ -57,8 +48,12 @@ export class LazyAnalysisProvider implements AnalysisProvider { profile?: AnalysisProfileCommitment, context?: AnalysisClientContext, ): AnalysisClient { - return new LazyAnalysisClient(async () => { + return new DeferredAnalysisClient(async (signal) => { const provider = await this.#load(); + if (signal.aborted) + throw new AnalysisCancelledError("create_analysis_client", { + cause: signal.reason, + }); return provider.createClient(target, profile, context); }, this.#providerIdentity.id); } @@ -71,84 +66,3 @@ export class LazyAnalysisProvider implements AnalysisProvider { return this.#provider; } } - -class LazyAnalysisClient implements AnalysisClient { - readonly #loadClient: () => Promise; - readonly #providerId: string; - #client: AnalysisClient | undefined; - #loading: Promise | undefined; - #closed = false; - - executeIfReady: NonNullable = ( - operation, - parameters, - options, - ) => - this.#client?.executeIfReady?.(operation, parameters, options) ?? - Promise.resolve(undefined); - - constructor(loadClient: () => Promise, providerId: string) { - this.#loadClient = loadClient; - this.#providerId = providerId; - } - - execute: AnalysisClient["execute"] = async ( - operation, - parameters, - options, - ) => { - try { - if (isAborted(options?.signal)) - return err(new AnalysisCancelledError(operation)); - const loaded = await waitForAbortable( - this.#load(operation), - options?.signal, - ); - if (loaded === ABORTED) return err(new AnalysisCancelledError(operation)); - return await loaded.execute(operation, parameters, options); - } catch (cause: unknown) { - if (cause instanceof AnalysisError) return err(cause); - return err( - new ProviderAdapterError(this.#providerId, operation, { - cause, - }), - ); - } - }; - - runtimeLineageSnapshots(): readonly ProviderRuntimeLineageSnapshot[] { - return this.#client?.runtimeLineageSnapshots?.() ?? []; - } - - requestActivitySnapshots(): readonly ProviderRequestActivitySnapshot[] { - return this.#client?.requestActivitySnapshots?.() ?? []; - } - - operationHealthSnapshot(): ProviderOperationHealth | undefined { - return this.#client?.operationHealthSnapshot?.(); - } - - close: AnalysisClient["close"] = async (options) => { - this.#closed = true; - if (this.#loading === undefined) return ok(null); - const client = await this.#loading; - return client.close(options); - }; - - async #load(operation: string): Promise { - if (this.#closed) - throw new ProviderAdapterError(this.#providerId, operation, { - diagnostics: { reason: "client_closed" }, - }); - this.#loading ??= this.#loadClient() - .then((client) => { - this.#client = client; - return client; - }) - .catch((cause: unknown) => { - this.#loading = undefined; - throw cause; - }); - return this.#loading; - } -} diff --git a/src/artifacts/AnalysisCleanupLease.ts b/src/artifacts/AnalysisCleanupLease.ts index b823d1d730..90d363a57e 100644 --- a/src/artifacts/AnalysisCleanupLease.ts +++ b/src/artifacts/AnalysisCleanupLease.ts @@ -6,6 +6,11 @@ export class AnalysisCleanupLease { #errors: WeakRef[] = []; readonly #groups = new Set(); + /** Whether this exact owner has confirmed release. */ + get released(): boolean { + return this.#released; + } + readonly cleanupResources: readonly string[] | undefined; constructor( diff --git a/src/application/binary/AnalysisClientCleanup.ts b/src/providers/AnalysisClientCleanup.ts similarity index 72% rename from src/application/binary/AnalysisClientCleanup.ts rename to src/providers/AnalysisClientCleanup.ts index db283cc252..b926488f0c 100644 --- a/src/application/binary/AnalysisClientCleanup.ts +++ b/src/providers/AnalysisClientCleanup.ts @@ -1,7 +1,10 @@ -import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; -import { err, type Result } from "../../domain/result.js"; -import { AnalysisError } from "../../domain/analysisErrorBase.js"; -import type { AnalysisClient, ExecutionOptions } from "../AnalysisProvider.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { err, type Result } from "../domain/result.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import type { + AnalysisClient, + ExecutionOptions, +} from "../application/AnalysisProvider.js"; /** Close one provider client and normalize unexpected adapter rejection. */ export const closeAnalysisClient = async ( diff --git a/src/providers/AnalysisClientCreation.ts b/src/providers/AnalysisClientCreation.ts new file mode 100644 index 0000000000..fb520e5577 --- /dev/null +++ b/src/providers/AnalysisClientCreation.ts @@ -0,0 +1,281 @@ +import { analysisClientFailureForOperation } from "./AnalysisClientFailure.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js"; +import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import type { + AnalysisClient, + ExecutionOptions, + FileCloseResourceOwner, +} from "../application/AnalysisProvider.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { SharedStartupAttempt } from "../process/SharedStartupAttempt.js"; +import { closeAnalysisClient } from "./AnalysisClientCleanup.js"; + +/** Own deferred client creation while callers wait independently and close joins it. */ +export class AnalysisClientCreation { + readonly #inspectionResources = new ArtifactResourceScope(); + readonly #inspectionOwner: FileCloseResourceOwner = { + retain: (owner, resource) => + this.#inspectionResources.retain({ + kind: "lease", + resource, + lease: new AnalysisCleanupLease(() => owner.close(), [resource]), + }), + run: (operation) => this.#inspectionResources.run(operation), + }; + #attempt: + | SharedStartupAttempt> + | undefined; + #client: AnalysisClient | undefined; + #failure: AnalysisError | undefined; + #closed = false; + #clientLease: AnalysisCleanupLease | undefined; + #closing: Promise> | undefined; + #closeOptions: Parameters[0]; + + constructor( + private readonly providerId: string, + private readonly create: ( + signal: AbortSignal, + resourceOwner: FileCloseResourceOwner, + ) => Promise, + ) {} + + get ownedClient(): AnalysisClient | undefined { + return this.#client; + } + get readyClient(): AnalysisClient | undefined { + return this.#closed || + this.#attempt !== undefined || + this.#failure !== undefined + ? undefined + : this.#client; + } + get reusable(): boolean { + return ( + !this.#closed && + this.#attempt === undefined && + this.#failure === undefined && + this.#clientLease?.released !== true + ); + } + async acquire( + operation: string, + signal?: AbortSignal, + ): Promise> { + if (signal?.aborted) + return err( + new AnalysisCancelledError(operation, { cause: signal.reason }), + ); + if (this.#closed) + return err( + new ProviderAdapterError(this.providerId, operation, { + diagnostics: { reason: "client_closed" }, + }), + ); + if (this.#failure?.cleanupIncomplete) + return err( + analysisClientFailureForOperation( + this.#failure, + this.providerId, + operation, + ), + ); + const ready = this.readyClient; + if (ready !== undefined && this.#clientLease?.released !== true) + return ok(ready); + if (this.#clientLease?.released === true) { + this.#client = undefined; + this.#clientLease = undefined; + } + let attempt = this.#attempt; + if (attempt === undefined) { + this.#failure = undefined; + attempt = new SharedStartupAttempt((ownerSignal) => + Promise.resolve().then(() => this.#createOwned(ownerSignal)), + ); + const owned = attempt; + this.#attempt = owned; + void owned.promise.then((result) => { + if (!result.ok) this.#failure = result.error; + if (this.#attempt === owned) this.#attempt = undefined; + }); + } + const waited = await attempt.wait(signal); + if (waited.status === "cancelled") + return err( + new AnalysisCancelledError(operation, { cause: signal?.reason }), + ); + if (!waited.value.ok) + return err( + analysisClientFailureForOperation( + waited.value.error, + this.providerId, + operation, + ), + ); + if (signal?.aborted) + return err( + new AnalysisCancelledError(operation, { cause: signal.reason }), + ); + return waited.value; + } + + async #createOwned( + signal: AbortSignal, + ): Promise> { + if (signal.aborted) + return err( + new AnalysisCancelledError("create_analysis_client", { + cause: signal.reason, + }), + ); + let primary: AnalysisError | undefined; + try { + this.#client = await this.create(signal, this.#inspectionOwner); + } catch (cause: unknown) { + primary = + cause instanceof AnalysisError + ? cause + : new ProviderAdapterError( + this.providerId, + "create_analysis_client", + { cause }, + ); + } + const inspections = await this.#cleanupInspectionResources(() => + this.#inspectionResources.retryCleanup(), + ); + if (signal.aborted && primary === undefined) + primary = new AnalysisCancelledError("create_analysis_client", { + cause: signal.reason, + }); + if (!inspections.ok) + primary = + primary === undefined + ? inspections.error + : analysisErrorWithCleanupFailure( + primary, + inspections.error, + "create_analysis_client", + ).bindCleanupDependencies([primary, inspections.error]); + if (primary !== undefined) { + const closed = await this.#releaseClient(this.#closeOptions); + return err( + closed.ok + ? primary + : analysisErrorWithCleanupFailure( + primary, + closed.error, + "create_analysis_client", + ).bindCleanupDependencies([primary, closed.error]), + ); + } + if (this.#client === undefined) + throw new Error("Client creation completed without a client"); + return ok(this.#client); + } + + async #cleanupInspectionResources( + cleanup: () => Promise, + ): Promise> { + try { + await cleanup(); + return ok(null); + } catch (cause: unknown) { + const observation = ArtifactReaderFailure.cleanupObservation( + cause, + "client prerequisite resources", + ); + const failure = new ProviderCleanupError( + this.providerId, + observation.resources, + { reason: observation.reason }, + { cause, operation: "create_analysis_client" }, + ); + bindArtifactFileCleanup(cause, failure); + return err(failure); + } + } + + close( + options?: Pick & { + readonly retainDocument?: boolean; + }, + ): Promise> { + this.#closed = true; + this.#closeOptions = options; + this.#attempt?.close(); + this.#closing ??= Promise.resolve() + .then(async () => { + await this.#attempt?.promise; + const inspections = await this.#cleanupInspectionResources(() => + this.#inspectionResources.close(), + ); + const inspectionFailure = inspections.ok + ? undefined + : inspections.error; + const closed = await this.#releaseClient(options); + if (!closed.ok && inspectionFailure !== undefined) + return err( + analysisErrorWithCleanupFailure( + closed.error, + inspectionFailure, + "create_analysis_client", + ).bindCleanupDependencies([closed.error, inspectionFailure]), + ); + if (!closed.ok) return closed; + if (inspectionFailure !== undefined) return err(inspectionFailure); + return this.#failure?.cleanupIncomplete ? err(this.#failure) : ok(null); + }) + .finally(() => { + this.#closing = undefined; + }); + return this.#closing; + } + + async #releaseClient( + options: Parameters[0], + ): Promise> { + if (this.#client === undefined) return ok(null); + const client = this.#client; + this.#closeOptions = options; + this.#clientLease ??= new AnalysisCleanupLease(async () => { + const result = await closeAnalysisClient( + client, + this.providerId, + this.#closeOptions, + ); + if (!result.ok) throw result.error; + }); + try { + await this.#clientLease.release(); + return ok(null); + } catch (cause: unknown) { + const failure = new ProviderCleanupError( + this.providerId, + cause instanceof AnalysisError && cause.cleanupResources.length > 0 + ? cause.cleanupResources + : ["provider-client"], + { + reason: + "Client creation retains its exact client until close confirms release", + ...(cause instanceof AnalysisError + ? { client_close_failure: projectAnalysisError(cause) } + : {}), + }, + { cause, operation: "create_analysis_client" }, + ); + failure.bindCleanupResourceOwner(this.#clientLease); + this.#clientLease.bindCleanupError(failure); + return err(failure); + } + } +} diff --git a/src/providers/AnalysisClientFailure.ts b/src/providers/AnalysisClientFailure.ts new file mode 100644 index 0000000000..5cb04cc269 --- /dev/null +++ b/src/providers/AnalysisClientFailure.ts @@ -0,0 +1,92 @@ +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, +} from "../domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js"; + +/** Attribute producer-owned failure facts without inferring interruption. */ +export const analysisClientFailureForOperation = ( + cause: unknown, + providerId: string, + operation: string, +): AnalysisError => { + if (!(cause instanceof AnalysisError)) { + const failure = new ProviderAdapterError(providerId, operation, { + cause, + ...(cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined + ? { cleanup: cause.cleanup } + : {}), + }); + bindArtifactFileCleanup(cause, failure); + return failure; + } + if ( + !(cause instanceof AnalysisCancelledError) && + !(cause instanceof ProviderAdapterError) && + !(cause instanceof AnalysisCapabilityUnavailableError) + ) + return cause; + const context = { + cause, + ...(cause.cleanupPrimary === undefined + ? {} + : { + cleanupPrimary: analysisClientFailureForOperation( + cause.cleanupPrimary, + providerId, + operation, + ), + }), + ...(cause.partialObservation === undefined + ? {} + : { partialObservation: cause.partialObservation }), + ...(cause.capturedOutput === undefined + ? {} + : { capturedOutput: cause.capturedOutput }), + ...(cause.diagnostics === undefined + ? {} + : { diagnostics: cause.diagnostics }), + ...(cause.cleanupIncomplete + ? { + cleanup: { + reason: cause.cleanup?.reason ?? cause.message, + resources: cause.cleanupResources, + }, + } + : {}), + }; + const result = + cause instanceof AnalysisCancelledError + ? new AnalysisCancelledError(operation, { + ...context, + cause: cause.cause, + }) + : cause instanceof AnalysisCapabilityUnavailableError + ? new AnalysisCapabilityUnavailableError( + cause.providerId, + operation, + cause.reason, + { + ...context, + ...(cause.userMessage === undefined + ? {} + : { userMessage: cause.userMessage }), + retryable: cause.retryable, + }, + ) + : new ProviderAdapterError(providerId, operation, { + ...context, + ...(cause.userMessage === undefined + ? {} + : { userMessage: cause.userMessage }), + ...(cause.remediationAction === undefined + ? {} + : { remediationAction: cause.remediationAction }), + }); + return cause.cleanupIncomplete + ? result.bindCleanupDependencies([cause]) + : result; +}; diff --git a/src/providers/DeferredAnalysisClient.ts b/src/providers/DeferredAnalysisClient.ts new file mode 100644 index 0000000000..a42f593ab3 --- /dev/null +++ b/src/providers/DeferredAnalysisClient.ts @@ -0,0 +1,84 @@ +import type { + AnalysisClient, + FileCloseResourceOwner, + ProviderRuntimeLineageSnapshot, + ProviderRequestActivitySnapshot, +} from "../application/AnalysisProvider.js"; +import type { ProviderOperationHealth } from "../domain/providerOperationHealth.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { err } from "../domain/result.js"; +import { AnalysisClientCreation } from "./AnalysisClientCreation.js"; + +/** Expose a closeable client before deferred prerequisite work starts. */ +export class DeferredAnalysisClient implements AnalysisClient { + readonly #creation: AnalysisClientCreation; + readonly #providerId: string; + + executeIfReady: NonNullable = ( + operation, + parameters, + options, + ) => + this.#creation.readyClient?.executeIfReady?.( + operation, + parameters, + options, + ) ?? Promise.resolve(undefined); + + constructor( + loadClient: ( + signal: AbortSignal, + resourceOwner: FileCloseResourceOwner, + ) => Promise, + providerId: string, + ) { + this.#creation = new AnalysisClientCreation(providerId, loadClient); + this.#providerId = providerId; + } + + execute: AnalysisClient["execute"] = async ( + operation, + parameters, + options, + ) => { + try { + const loaded = await this.#creation.acquire(operation, options?.signal); + if (!loaded.ok) return loaded; + if (this.#creation.readyClient !== loaded.value) + return err( + new ProviderAdapterError(this.#providerId, operation, { + diagnostics: { reason: "client_closed" }, + }), + ); + return await loaded.value.execute(operation, parameters, options); + } catch (cause: unknown) { + if (cause instanceof AnalysisError) return err(cause); + return err( + new ProviderAdapterError(this.#providerId, operation, { + cause, + }), + ); + } + }; + + runtimeLineageSnapshots(): readonly ProviderRuntimeLineageSnapshot[] { + return this.#creation.ownedClient?.runtimeLineageSnapshots?.() ?? []; + } + + requestActivitySnapshots(): readonly ProviderRequestActivitySnapshot[] { + return this.#creation.ownedClient?.requestActivitySnapshots?.() ?? []; + } + + reuseReadiness(): "ready" | "blocked" | "unknown" { + if (!this.#creation.reusable || this.#creation.readyClient === undefined) + return "blocked"; + return this.#creation.readyClient.reuseReadiness?.() ?? "unknown"; + } + + operationHealthSnapshot(): ProviderOperationHealth | undefined { + return this.#creation.ownedClient?.operationHealthSnapshot?.(); + } + + close: AnalysisClient["close"] = (options) => this.#creation.close(options); +}