mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
fix(providers): own deferred client acquisition through final cleanup
Return a closeable owner before prerequisite work begins. Share creation across independent callers, join the final departure and late handoffs, retain exact failed resource owners, and gate operation dispatch again after acquisition. Keep reuse admission separate from native health. Move shared provider ownership below application workflows and consolidate failure attribution; remove the superseded lazy client. Validation: 34 focused owner, lazy, composite and session controls; check:fast. Compiled baseline/candidate recording-provider workflow reproduces then fences dispatch after close. No native provider claim. Refs #4561, #4667
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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<string> = new Set(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
|
||||
@@ -927,6 +927,7 @@ export class BinarySession
|
||||
client: AnalysisClient,
|
||||
options: Pick<BinarySessionOpenOptions, "signal">,
|
||||
): Promise<boolean> {
|
||||
if (client.reuseReadiness?.() === "blocked") return false;
|
||||
const snapshot = client.operationHealthSnapshot?.();
|
||||
if (snapshot !== undefined)
|
||||
return snapshot.state === "idle" || snapshot.state === "busy";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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?.(),
|
||||
|
||||
@@ -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 = <T = void>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((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<AnalysisClient>();
|
||||
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<AnalysisClient>(),
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -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<void>((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<void>((resolve) => {
|
||||
enter = resolve;
|
||||
});
|
||||
const provider = new LazyAnalysisProvider({
|
||||
identity,
|
||||
capabilities: [],
|
||||
load: () =>
|
||||
new Promise<AnalysisProvider>((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<void>((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 () => {
|
||||
|
||||
@@ -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<AnalysisProvider>;
|
||||
|
||||
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<AnalysisClient>;
|
||||
readonly #providerId: string;
|
||||
#client: AnalysisClient | undefined;
|
||||
#loading: Promise<AnalysisClient> | undefined;
|
||||
#closed = false;
|
||||
|
||||
executeIfReady: NonNullable<AnalysisClient["executeIfReady"]> = (
|
||||
operation,
|
||||
parameters,
|
||||
options,
|
||||
) =>
|
||||
this.#client?.executeIfReady?.(operation, parameters, options) ??
|
||||
Promise.resolve(undefined);
|
||||
|
||||
constructor(loadClient: () => Promise<AnalysisClient>, 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<AnalysisClient> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ export class AnalysisCleanupLease {
|
||||
#errors: WeakRef<AnalysisError>[] = [];
|
||||
readonly #groups = new Set<AnalysisCleanupLeaseGroup>();
|
||||
|
||||
/** Whether this exact owner has confirmed release. */
|
||||
get released(): boolean {
|
||||
return this.#released;
|
||||
}
|
||||
|
||||
readonly cleanupResources: readonly string[] | undefined;
|
||||
|
||||
constructor(
|
||||
|
||||
+7
-4
@@ -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 (
|
||||
@@ -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<Result<AnalysisClient, AnalysisError>>
|
||||
| undefined;
|
||||
#client: AnalysisClient | undefined;
|
||||
#failure: AnalysisError | undefined;
|
||||
#closed = false;
|
||||
#clientLease: AnalysisCleanupLease | undefined;
|
||||
#closing: Promise<Result<null, AnalysisError>> | undefined;
|
||||
#closeOptions: Parameters<AnalysisClient["close"]>[0];
|
||||
|
||||
constructor(
|
||||
private readonly providerId: string,
|
||||
private readonly create: (
|
||||
signal: AbortSignal,
|
||||
resourceOwner: FileCloseResourceOwner,
|
||||
) => Promise<AnalysisClient>,
|
||||
) {}
|
||||
|
||||
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<Result<AnalysisClient, AnalysisError>> {
|
||||
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<Result<AnalysisClient, AnalysisError>> {
|
||||
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<void>,
|
||||
): Promise<Result<null, AnalysisError>> {
|
||||
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<ExecutionOptions, "progress"> & {
|
||||
readonly retainDocument?: boolean;
|
||||
},
|
||||
): Promise<Result<null, AnalysisError>> {
|
||||
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<AnalysisClient["close"]>[0],
|
||||
): Promise<Result<null, AnalysisError>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<AnalysisClient["executeIfReady"]> = (
|
||||
operation,
|
||||
parameters,
|
||||
options,
|
||||
) =>
|
||||
this.#creation.readyClient?.executeIfReady?.(
|
||||
operation,
|
||||
parameters,
|
||||
options,
|
||||
) ?? Promise.resolve(undefined);
|
||||
|
||||
constructor(
|
||||
loadClient: (
|
||||
signal: AbortSignal,
|
||||
resourceOwner: FileCloseResourceOwner,
|
||||
) => Promise<AnalysisClient>,
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user