mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
fix(setup): preserve cleanup ownership and resource reports (#6920)
* fix(setup): report host-owned cleanup resources alongside the primary's
SetupHost.close documents that it reports the resources a setup host still
owns, but its contract returned only a string. runSetup therefore built the
ProviderCleanupError from the primary failure's cleanupResources alone, so a
resource the host failed to release reached the projection only inside a
free-text reason while the error asks the caller to review the reported local
resources.
Return a SetupCloseReport instead. closeSetupResources absorbs the structured
resources of any cause that reports them, through
ArtifactReaderFailure.cleanupObservation, and leaves opaque failures
message-only so no resource name is invented. runSetup then unions the host's
resources with the primary's before building the typed cleanup error.
Two boundary tests read the close result directly: the skill-cleanup case now
asserts the retained backup path in the report reason rather than in a bare
string, and the shared ownership helper takes an opaque close result because it
only ever awaits undefined from either host it is given.
Validation: RED then green in src/application/Setup.recovery.test.ts, where
the new case previously received only the primary resource; 116 tests across
the seven Setup and cleanup-ownership suites pass; tsc --noEmit over the
changed set, its setup callers, and both boundary tests exits 0; oxlint
reports 0 errors; oxfmt --check is clean; verify:module-boundaries passes.
The two failures in src/setupEnvironment.test.ts reproduce identically on
pristine bded9c628 with this change stashed.
* fix(setup): preserve cleanup ownership through host reports
* fix(setup): bind finalization reports to cleanup owners
* fix(setup): retain live resources in returned cleanup views
* fix(setup): preserve completed cleanup and exact installer resources
* fix(setup): retain delegated cleanup across authority changes
* fix(setup): compose callback and source cleanup authorities
* fix(setup): retain producer cleanup failure reasons
* fix(cli): render structured setup cleanup resources
* fix(setup): separate failure evidence from cleanup ownership
* fix(setup): normalize close failures and unify lease binding
* fix(setup): preserve direct lease resource attribution
---------
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
@@ -60,9 +60,11 @@ export const retryClientConfigurationCleanup = async (
|
||||
): Promise<{
|
||||
readonly primary: unknown;
|
||||
readonly failures: readonly RegularFileCleanupFailure[];
|
||||
readonly cleanupLeases: readonly AnalysisCleanupLease[];
|
||||
readonly cleanup?: AnalysisCleanupObservation;
|
||||
}> => {
|
||||
const failures: RegularFileCleanupFailure[] = [];
|
||||
const cleanupLeases = new Set<AnalysisCleanupLease>();
|
||||
const observations: AnalysisCleanupObservation[] = [];
|
||||
let current = failure;
|
||||
let primary: unknown;
|
||||
@@ -71,6 +73,7 @@ export const retryClientConfigurationCleanup = async (
|
||||
if (current instanceof RegularFileCleanupFailure) {
|
||||
const retried = await retryRegularFileCleanupFailures(current, resources);
|
||||
failures.push(...retried.failures);
|
||||
for (const lease of retried.cleanupLeases) cleanupLeases.add(lease);
|
||||
if (retried.cleanup !== undefined) observations.push(retried.cleanup);
|
||||
primary = retried.primary;
|
||||
const last = retried.failures.at(-1)!;
|
||||
@@ -84,13 +87,15 @@ export const retryClientConfigurationCleanup = async (
|
||||
resource: current.owner.resource,
|
||||
lease: current.cleanupLease,
|
||||
});
|
||||
if (released.kind === "failed")
|
||||
if (released.kind === "failed") {
|
||||
cleanupLeases.add(released.lease);
|
||||
observations.push(
|
||||
ArtifactReaderFailure.cleanupObservation(
|
||||
released.cause,
|
||||
current.owner.resource,
|
||||
),
|
||||
);
|
||||
}
|
||||
primary =
|
||||
current.outcome.kind === "failed"
|
||||
? current.outcome.cause
|
||||
@@ -104,6 +109,7 @@ export const retryClientConfigurationCleanup = async (
|
||||
return {
|
||||
primary,
|
||||
failures,
|
||||
cleanupLeases: [...cleanupLeases],
|
||||
...(observations.length === 0
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -504,7 +504,10 @@ describe("Linux Hopper temporary cleanup ownership", () => {
|
||||
attempts += 1;
|
||||
throw new Error("still busy");
|
||||
}),
|
||||
).toContain("/tmp/rea-hopper");
|
||||
).toMatchObject({
|
||||
reason: expect.stringContaining("/tmp/rea-hopper"),
|
||||
resources: ["/tmp/rea-hopper"],
|
||||
});
|
||||
expect(
|
||||
await owner.retry(async () => {
|
||||
attempts += 1;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js";
|
||||
import type { HopperInstallFailureReason } from "./SetupInstallFailure.js";
|
||||
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -268,7 +269,7 @@ export class LinuxHopperTemporaryCleanupOwner {
|
||||
|
||||
async retry(
|
||||
cleanup: (path: string) => Promise<void>,
|
||||
): Promise<string | undefined> {
|
||||
): Promise<AnalysisCleanupObservation | undefined> {
|
||||
const failures: string[] = [];
|
||||
for (const path of this.pending.keys()) {
|
||||
try {
|
||||
@@ -281,7 +282,12 @@ export class LinuxHopperTemporaryCleanupOwner {
|
||||
);
|
||||
}
|
||||
}
|
||||
return failures.length === 0 ? undefined : failures.join("; ");
|
||||
return failures.length === 0
|
||||
? undefined
|
||||
: {
|
||||
reason: failures.join("; "),
|
||||
resources: [...this.pending.keys()],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
installMacHopper,
|
||||
installMacHopperBundle,
|
||||
type MacHopperBundleVerification,
|
||||
type MacHopperBundleInstallResult,
|
||||
type MacHopperInstallHost,
|
||||
@@ -233,3 +232,32 @@ it("reports an unresolved destination probe without claiming an existing install
|
||||
expect(host.installs).toBe(0);
|
||||
expect(host.unmounts).toBe(0);
|
||||
});
|
||||
|
||||
it("retains the exact DMG temporary root with its retry capability", async () => {
|
||||
const host = new FakeMacHopperHost();
|
||||
const retained: {
|
||||
close: () => Promise<void>;
|
||||
resources: readonly string[];
|
||||
}[] = [];
|
||||
let allowCleanup = false;
|
||||
const result = await installMacHopper(
|
||||
{},
|
||||
{
|
||||
...host,
|
||||
cleanup: async () => {
|
||||
if (!allowCleanup) throw new Error("DMG root busy");
|
||||
},
|
||||
retainCleanup: (close, resources) => {
|
||||
retained.push({ close, resources });
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
status: "installed",
|
||||
cleanupFailure: expect.stringContaining("DMG root busy"),
|
||||
});
|
||||
expect(retained).toHaveLength(1);
|
||||
expect(retained[0]?.resources).toEqual(["/tmp/rea-hopper-test"]);
|
||||
allowCleanup = true;
|
||||
await expect(retained[0]!.close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { promisify } from "node:util";
|
||||
|
||||
import { z } from "zod";
|
||||
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
||||
import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js";
|
||||
import { HOPPER_PUBLISHER } from "../domain/hopperPublisher.js";
|
||||
import { macDeveloperIdRequirement } from "../process/MacCodeSignature.js";
|
||||
import { macHopperUserApplicationPath } from "./MacHopperPaths.js";
|
||||
@@ -120,7 +121,10 @@ export interface MacHopperInstallHost {
|
||||
openApplication(path: string): Promise<void>;
|
||||
cleanup(path: string): Promise<void>;
|
||||
/** Retain the cleanup capability until the owning setup host retries it. */
|
||||
retainCleanup?(cleanup: () => Promise<void>): void;
|
||||
retainCleanup?(
|
||||
cleanup: () => Promise<void>,
|
||||
resources: readonly string[],
|
||||
): void;
|
||||
destination(): string;
|
||||
}
|
||||
|
||||
@@ -228,7 +232,7 @@ export const installMacHopper = async (
|
||||
await cleanup();
|
||||
} catch (cause: unknown) {
|
||||
cleanupFailure = `Hopper DMG cleanup failed: ${failureMessage(cause)}`;
|
||||
host.retainCleanup?.(cleanup);
|
||||
host.retainCleanup?.(cleanup, [temporaryPath]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +254,10 @@ export const installMacHopperBundle = async (
|
||||
destination: string,
|
||||
replaceExisting: boolean,
|
||||
operations: MacHopperBundleInstallOperations,
|
||||
retainCleanup: (cleanup: () => Promise<void>) => void,
|
||||
retainCleanup: (
|
||||
cleanup: () => Promise<void>,
|
||||
resources: readonly string[],
|
||||
) => void,
|
||||
): Promise<MacHopperBundleInstallResult> => {
|
||||
const stagingRoot = await operations.createStagingRoot(
|
||||
posix.dirname(destination),
|
||||
@@ -297,7 +304,7 @@ export const installMacHopperBundle = async (
|
||||
originalBackedUp = false;
|
||||
}
|
||||
await operations.removeStagingRoot(stagingRoot);
|
||||
});
|
||||
}, [stagingRoot]);
|
||||
return {
|
||||
status: "failed",
|
||||
reason: failureReason,
|
||||
@@ -310,7 +317,10 @@ export const installMacHopperBundle = async (
|
||||
await operations.removeStagingRoot(stagingRoot);
|
||||
} catch (cleanupCause: unknown) {
|
||||
const cleanupFailure = `Hopper staging root cleanup failed: ${failureMessage(cleanupCause)}`;
|
||||
retainCleanup(() => operations.removeStagingRoot(stagingRoot));
|
||||
retainCleanup(
|
||||
() => operations.removeStagingRoot(stagingRoot),
|
||||
[stagingRoot],
|
||||
);
|
||||
diagnostics.push(cleanupFailure);
|
||||
return {
|
||||
status: "failed",
|
||||
@@ -331,7 +341,10 @@ export const installMacHopperBundle = async (
|
||||
return { status: "installed" };
|
||||
} catch (cause: unknown) {
|
||||
const cleanupFailure = `Hopper staging root cleanup failed after installation committed: ${failureMessage(cause)}`;
|
||||
retainCleanup(() => operations.removeStagingRoot(stagingRoot));
|
||||
retainCleanup(
|
||||
() => operations.removeStagingRoot(stagingRoot),
|
||||
[stagingRoot],
|
||||
);
|
||||
return { status: "installed", cleanupFailure };
|
||||
}
|
||||
};
|
||||
@@ -340,8 +353,10 @@ export const installMacHopperBundle = async (
|
||||
export const systemMacHopperInstallHost = (
|
||||
homeDirectory: string,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): MacHopperInstallHost & { close(): Promise<string | undefined> } => {
|
||||
const pendingCleanup = new Set<() => Promise<void>>();
|
||||
): MacHopperInstallHost & {
|
||||
close(): Promise<AnalysisCleanupObservation | undefined>;
|
||||
} => {
|
||||
const pendingCleanup = new Map<() => Promise<void>, readonly string[]>();
|
||||
return {
|
||||
download: downloadHopperPackage,
|
||||
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-mac-")),
|
||||
@@ -427,24 +442,30 @@ export const systemMacHopperInstallHost = (
|
||||
rename,
|
||||
removeStagingRoot: (path) => rm(path, { recursive: true }),
|
||||
},
|
||||
(cleanup) => pendingCleanup.add(cleanup),
|
||||
(cleanup, resources) => pendingCleanup.set(cleanup, resources),
|
||||
),
|
||||
async openApplication(path) {
|
||||
await execFileAsync("open", [path], { env: environment });
|
||||
},
|
||||
cleanup: (path) => rm(path, { recursive: true, force: true }),
|
||||
retainCleanup: (cleanup) => pendingCleanup.add(cleanup),
|
||||
retainCleanup: (cleanup, resources) =>
|
||||
pendingCleanup.set(cleanup, resources),
|
||||
async close() {
|
||||
let failure: string | undefined;
|
||||
for (const cleanup of pendingCleanup) {
|
||||
const failures: string[] = [];
|
||||
for (const cleanup of pendingCleanup.keys()) {
|
||||
try {
|
||||
await cleanup();
|
||||
pendingCleanup.delete(cleanup);
|
||||
} catch (cause: unknown) {
|
||||
failure ??= `Hopper installer cleanup remains incomplete: ${failureMessage(cause)}`;
|
||||
failures.push(failureMessage(cause));
|
||||
}
|
||||
}
|
||||
return failure;
|
||||
return failures.length === 0
|
||||
? undefined
|
||||
: {
|
||||
reason: `Hopper installer cleanup remains incomplete: ${failures.join("; ")}`,
|
||||
resources: [...new Set([...pendingCleanup.values()].flat())],
|
||||
};
|
||||
},
|
||||
destination: () => macHopperUserApplicationPath(homeDirectory),
|
||||
};
|
||||
|
||||
@@ -140,14 +140,18 @@ export const retryRegularFileCleanupFailures = async (
|
||||
): Promise<{
|
||||
readonly primary: unknown;
|
||||
readonly failures: readonly RegularFileCleanupFailure[];
|
||||
readonly cleanupLeases: readonly AnalysisCleanupLease[];
|
||||
readonly cleanup?: AnalysisCleanupObservation;
|
||||
}> => {
|
||||
const failures: RegularFileCleanupFailure[] = [];
|
||||
const cleanupLeases = new Set<AnalysisCleanupLease>();
|
||||
const observations: AnalysisCleanupObservation[] = [];
|
||||
let current = failure;
|
||||
let primary: unknown;
|
||||
for (;;) {
|
||||
const retried = await retryRegularFileCleanup(current, resources);
|
||||
if (retried.cleanupLease !== undefined)
|
||||
cleanupLeases.add(retried.cleanupLease);
|
||||
failures.push(retried.failure ?? current);
|
||||
if (retried.cleanup !== undefined) observations.push(retried.cleanup);
|
||||
primary =
|
||||
@@ -165,6 +169,7 @@ export const retryRegularFileCleanupFailures = async (
|
||||
return {
|
||||
primary,
|
||||
failures,
|
||||
cleanupLeases: [...cleanupLeases],
|
||||
...(observations.length === 0
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
type LinuxDistribution,
|
||||
} from "./LinuxHopper.js";
|
||||
import type { SetupProgressEvent } from "./SetupTypes.js";
|
||||
import { setupCleanupError, SetupCleanupOwner } from "./SetupCleanupOwner.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
|
||||
describe("setup workflow", () => {
|
||||
it("rejects Node 25 before applying setup actions", async () => {
|
||||
@@ -182,8 +184,11 @@ describe("setup failure cleanup projection", () => {
|
||||
},
|
||||
});
|
||||
class SetupHostWithCleanupFailure extends FakeSetupHost {
|
||||
close = async (): Promise<string> =>
|
||||
"Setup could not release its client configuration resources.";
|
||||
close = async () =>
|
||||
setupCleanupError(undefined, {
|
||||
reason: "Setup could not release its client configuration resources.",
|
||||
resources: [],
|
||||
});
|
||||
}
|
||||
const host = new SetupHostWithCleanupFailure();
|
||||
host.clients = [{ name: "codex", configPath }];
|
||||
@@ -215,7 +220,7 @@ describe("setup failure cleanup projection", () => {
|
||||
},
|
||||
cleanup_error: {
|
||||
code: "cleanup_incomplete",
|
||||
details: { resources: [configPath] },
|
||||
details: { resources: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -234,6 +239,175 @@ describe("setup failure cleanup projection", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["report", "throw"] as const)(
|
||||
"reports host-owned cleanup resources alongside the primary's: %s",
|
||||
async (outcome) => {
|
||||
const configPath = "/fixture/home/vscode/settings.json";
|
||||
const retainedPath = "/fixture/home/retained/handle.bin";
|
||||
const primary = new ConfigurationError(
|
||||
"Client config could not be read",
|
||||
{
|
||||
settings: [
|
||||
{
|
||||
setting: configPath,
|
||||
constraint: "The selected config could not be read.",
|
||||
},
|
||||
],
|
||||
cleanup: {
|
||||
reason: "The admitted config handle remains open.",
|
||||
resources: [configPath],
|
||||
},
|
||||
},
|
||||
);
|
||||
class HostWithRetainedResource extends FakeSetupHost {
|
||||
close = async () => {
|
||||
const reason = `Artifact cleanup remains incomplete: ${retainedPath}`;
|
||||
if (outcome === "throw")
|
||||
throw new ProviderCleanupError("setup", [retainedPath], { reason });
|
||||
return setupCleanupError(undefined, {
|
||||
reason,
|
||||
resources: [retainedPath],
|
||||
});
|
||||
};
|
||||
}
|
||||
const host = new HostWithRetainedResource();
|
||||
host.clients = [{ name: "codex", configPath }];
|
||||
host.inspectClientConfiguration = async () => {
|
||||
throw primary;
|
||||
};
|
||||
|
||||
const failure = await runSetup(
|
||||
{ ...options(true), clientIds: ["codex"] },
|
||||
host,
|
||||
).then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
expect(failure).toBeInstanceOf(AnalysisError);
|
||||
if (!(failure instanceof AnalysisError))
|
||||
throw new Error("Expected setup to preserve a typed analysis failure");
|
||||
|
||||
const projected = projectAnalysisError(failure);
|
||||
// The host still owns a resource the primary failure never saw; the
|
||||
// projection must list it, not bury it in a free-text reason.
|
||||
expect(projected).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
resources: [configPath, retainedPath],
|
||||
diagnostics: {
|
||||
primary_error: { details: { resources: [configPath] } },
|
||||
cleanup_error: { details: { resources: [retainedPath] } },
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("setup opaque failure cleanup", () => {
|
||||
it("retains the typed host cleanup report alongside an opaque primary", async () => {
|
||||
const primary = new Error("Controlled setup workflow failure");
|
||||
const retainedPath = "/fixture/home/retained/handle.bin";
|
||||
class HostWithRetainedResource extends FakeSetupHost {
|
||||
close = async () =>
|
||||
setupCleanupError(undefined, {
|
||||
reason: "Controlled host close failure",
|
||||
resources: [retainedPath],
|
||||
});
|
||||
}
|
||||
const host = new HostWithRetainedResource();
|
||||
host.initialSetupState = async () => {
|
||||
throw primary;
|
||||
};
|
||||
const failure = await runSetup(options(true), host).then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
expect(failure).toBeInstanceOf(AggregateError);
|
||||
if (!(failure instanceof AggregateError))
|
||||
throw new Error("Expected setup and cleanup failures to remain together");
|
||||
expect(failure.cause).toBe(primary);
|
||||
expect(failure.errors[0]).toBe(primary);
|
||||
const cleanup: unknown = failure.errors[1];
|
||||
expect(cleanup).toBeInstanceOf(ProviderCleanupError);
|
||||
if (!(cleanup instanceof ProviderCleanupError))
|
||||
throw new Error("Expected typed cleanup facts in the aggregate");
|
||||
expect(projectAnalysisError(cleanup)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: { resources: [retainedPath] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup returned result cleanup", () => {
|
||||
it.each([
|
||||
{ state: "ready", outcome: "return" },
|
||||
{ state: "ready", outcome: "throw" },
|
||||
{ state: "needs_human", outcome: "return" },
|
||||
{ state: "needs_human", outcome: "throw" },
|
||||
] as const)(
|
||||
"preserves $state result facts and reports retained resources: $outcome",
|
||||
async ({ state, outcome }) => {
|
||||
let held = true;
|
||||
const cleanup = new SetupCleanupOwner([
|
||||
{
|
||||
close: async () =>
|
||||
held
|
||||
? {
|
||||
reason: "Controlled host cleanup",
|
||||
resources: ["/fixture/retained"],
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
]);
|
||||
class HostWithCleanup extends FakeSetupHost {
|
||||
close = async () => {
|
||||
const failure = await cleanup.close();
|
||||
if (outcome === "throw" && failure !== undefined) throw failure;
|
||||
return failure;
|
||||
};
|
||||
}
|
||||
const host = new HostWithCleanup();
|
||||
const control = new FakeSetupHost();
|
||||
for (const selected of [host, control]) {
|
||||
selected.hopper = "/Applications/Hopper";
|
||||
selected.doctorHealthy = state === "ready";
|
||||
selected.scopedDoctorHealthy = state === "ready";
|
||||
if (state === "needs_human") selected.nodeVersion = "25.0.0";
|
||||
}
|
||||
const input = {
|
||||
...options(true),
|
||||
installSkill: false,
|
||||
proposeHopper: false,
|
||||
clientIds: [],
|
||||
};
|
||||
const baseline = await runSetup(input, control);
|
||||
expect(baseline.status).toBe(state);
|
||||
const result = await runSetup(input, host);
|
||||
expect(result).toMatchObject({
|
||||
status: "needs_human",
|
||||
code: baseline.code ?? "cleanup_failed",
|
||||
plannedActions: baseline.plannedActions,
|
||||
appliedActions: baseline.appliedActions,
|
||||
clients: baseline.clients,
|
||||
doctor: baseline.doctor,
|
||||
cleanup: {
|
||||
reason: "Controlled host cleanup",
|
||||
resources: ["/fixture/retained"],
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(JSON.stringify(result))).toMatchObject({
|
||||
cleanup: { resources: ["/fixture/retained"] },
|
||||
});
|
||||
const retainedReport = result.cleanup;
|
||||
held = false;
|
||||
expect(await host.close()).toBeUndefined();
|
||||
expect(result.cleanup).toBeUndefined();
|
||||
expect(retainedReport?.resources).toEqual([]);
|
||||
expect(JSON.parse(JSON.stringify(result))).not.toHaveProperty("cleanup");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("setup scoped readiness", () => {
|
||||
@@ -529,3 +703,74 @@ describe("setup Hopper install eligibility", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves ownerless caller-host failures unconfirmed after an unobserved direct retry", async () => {
|
||||
const failure = setupCleanupError(undefined, {
|
||||
reason: "Unknown cleanup authority",
|
||||
resources: ["/fixture/unconfirmed"],
|
||||
});
|
||||
let held = true;
|
||||
class Host extends FakeSetupHost {
|
||||
close = async () => {
|
||||
if (held) throw failure;
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
const host = new Host();
|
||||
const result = await runSetup(options(true), host);
|
||||
const report = result.cleanup;
|
||||
held = false;
|
||||
expect(await host.close()).toBeUndefined();
|
||||
expect(failure.hasCleanupOwner).toBe(false);
|
||||
expect(failure.cleanupIncomplete).toBe(true);
|
||||
expect(report?.resources).toEqual(["/fixture/unconfirmed"]);
|
||||
expect(result.cleanup).toBeDefined();
|
||||
});
|
||||
|
||||
it.each(["return", "throw"] as const)(
|
||||
"does not report already confirmed host cleanup: %s",
|
||||
async (outcome) => {
|
||||
let held = true;
|
||||
const cleanup = new SetupCleanupOwner([
|
||||
{ close: async () => (held ? "held" : undefined) },
|
||||
]);
|
||||
const resolved = await cleanup.close();
|
||||
held = false;
|
||||
await cleanup.close();
|
||||
class Host extends FakeSetupHost {
|
||||
close = async () => {
|
||||
if (outcome === "throw") throw resolved;
|
||||
return resolved;
|
||||
};
|
||||
}
|
||||
const input = options(true);
|
||||
const control = await runSetup(input, new FakeSetupHost());
|
||||
expect(await runSetup(input, new Host())).toEqual(control);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["return", "throw"] as const)(
|
||||
"reports ordinary typed host close failures consistently: %s",
|
||||
async (outcome) => {
|
||||
const failure = new ConfigurationError(
|
||||
"Controlled close configuration failure",
|
||||
);
|
||||
class Host extends FakeSetupHost {
|
||||
close = async () => {
|
||||
if (outcome === "throw") throw failure;
|
||||
return failure;
|
||||
};
|
||||
}
|
||||
const input = options(true);
|
||||
const control = await runSetup(input, new FakeSetupHost());
|
||||
const result = await runSetup(input, new Host());
|
||||
expect(result).toMatchObject({
|
||||
status: "needs_human",
|
||||
code: control.code ?? "cleanup_failed",
|
||||
cleanup: { resources: [] },
|
||||
});
|
||||
expect(result.cleanup?.reason).toContain(failure.message);
|
||||
expect(failure.cleanupIncomplete).toBe(false);
|
||||
expect(failure.hasCleanupOwner).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
+32
-24
@@ -27,9 +27,12 @@ import {
|
||||
hostRemediation,
|
||||
systemSetupHost,
|
||||
} from "./SetupHost.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisError,
|
||||
type AnalysisCleanupObservation,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import { setupCleanupError, setupCleanupReason } from "./SetupCleanupOwner.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import type { SetupSkillInstallError } from "./SetupSkill.js";
|
||||
import type { SetupHopperInstallFailure } from "./SetupInstallFailure.js";
|
||||
|
||||
@@ -49,42 +52,47 @@ export const runSetup = async (
|
||||
primaryFailure = cause;
|
||||
}
|
||||
|
||||
let cleanupFailure: string | undefined;
|
||||
let cleanup: AnalysisError | undefined;
|
||||
try {
|
||||
cleanupFailure = await host.close?.();
|
||||
const report = await host.close?.();
|
||||
cleanup = report === undefined ? undefined : setupCleanupError(report);
|
||||
} catch (cause: unknown) {
|
||||
cleanupFailure = `Setup resource cleanup failed: ${failureMessage(cause)}`;
|
||||
cleanup = setupCleanupError(cause);
|
||||
}
|
||||
|
||||
if (cleanup !== undefined && !cleanup.cleanupIncomplete) cleanup = undefined;
|
||||
if (threw) {
|
||||
if (cleanupFailure !== undefined && primaryFailure instanceof AnalysisError)
|
||||
if (cleanup === undefined) throw primaryFailure;
|
||||
if (primaryFailure instanceof AnalysisError)
|
||||
throw analysisErrorWithCleanupFailure(
|
||||
primaryFailure,
|
||||
new ProviderCleanupError(
|
||||
"setup",
|
||||
primaryFailure.cleanupResources,
|
||||
{ reason: cleanupFailure },
|
||||
{ operation: "setup" },
|
||||
),
|
||||
cleanup,
|
||||
"setup",
|
||||
);
|
||||
if (cleanupFailure !== undefined)
|
||||
throw new AggregateError(
|
||||
[primaryFailure, new Error(cleanupFailure)],
|
||||
"Setup and resource cleanup failed.",
|
||||
{ cause: primaryFailure },
|
||||
);
|
||||
throw primaryFailure;
|
||||
).bindCleanupDependencies([primaryFailure, cleanup]);
|
||||
throw new AggregateError(
|
||||
[primaryFailure, cleanup],
|
||||
"Setup and resource cleanup failed.",
|
||||
{ cause: primaryFailure },
|
||||
);
|
||||
}
|
||||
if (result === undefined) throw new Error("Setup returned no result.");
|
||||
if (cleanupFailure === undefined) return result;
|
||||
if (cleanup === undefined) return result;
|
||||
const cleanupReason = setupCleanupReason(cleanup);
|
||||
const cleanupReport: AnalysisCleanupObservation = {
|
||||
reason: cleanupReason,
|
||||
get resources() {
|
||||
return cleanup.cleanupResources;
|
||||
},
|
||||
};
|
||||
return {
|
||||
...result,
|
||||
status: "needs_human",
|
||||
code: result.code ?? "cleanup_failed",
|
||||
get cleanup() {
|
||||
return cleanup.cleanupIncomplete ? cleanupReport : undefined;
|
||||
},
|
||||
remediation: result.remediation
|
||||
? `${result.remediation} ${cleanupFailure}`
|
||||
: cleanupFailure,
|
||||
? `${result.remediation} ${cleanupReason}`
|
||||
: cleanupReason,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { SetupCleanupOwner } from "./SetupCleanupOwner.js";
|
||||
import {
|
||||
AnalysisCleanupLease,
|
||||
AnalysisCleanupLeaseGroup,
|
||||
} from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
|
||||
const errorSources = (error: unknown): ReadonlySet<unknown> => {
|
||||
const pending = [error];
|
||||
const seen = new Set<unknown>();
|
||||
while (pending.length > 0) {
|
||||
const value = pending.pop();
|
||||
if (seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
if (value instanceof Error) pending.push(value.cause);
|
||||
if (value instanceof AggregateError) pending.push(...value.errors);
|
||||
if (value instanceof AnalysisError)
|
||||
pending.push(value.cleanupPrimary, ...value.secondaryFailures);
|
||||
}
|
||||
return seen;
|
||||
};
|
||||
|
||||
it("updates every report through mixed cleanup outcomes and exact retries", async () => {
|
||||
let firstHeld = true;
|
||||
let secondHeld = true;
|
||||
let firstCalls = 0;
|
||||
const owner = new SetupCleanupOwner([
|
||||
{
|
||||
close: async () => {
|
||||
if (++firstCalls > 2)
|
||||
throw new Error("Completed child must not close again");
|
||||
return firstHeld
|
||||
? { reason: "first held", resources: ["/first"] }
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
close: async () =>
|
||||
secondHeld
|
||||
? { reason: "second held", resources: ["/second"] }
|
||||
: undefined,
|
||||
},
|
||||
]);
|
||||
const first = await owner.close();
|
||||
expect(first?.cleanupResources).toEqual(["/first", "/second"]);
|
||||
firstHeld = false;
|
||||
const second = await owner.close();
|
||||
expect(first?.cleanupIncomplete).toBe(true);
|
||||
expect(first?.cleanupResources).toEqual(["/second"]);
|
||||
expect(second?.cleanupResources).toEqual(["/second"]);
|
||||
secondHeld = false;
|
||||
expect(await owner.close()).toBeUndefined();
|
||||
expect(first?.cleanupIncomplete).toBe(false);
|
||||
expect(first?.cleanupResources).toEqual([]);
|
||||
expect(second?.cleanupIncomplete).toBe(false);
|
||||
expect(firstCalls).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps opaque cleanup unresolved without inventing paths or retaining released labels", async () => {
|
||||
let fileHeld = true;
|
||||
let opaqueHeld = true;
|
||||
const owner = new SetupCleanupOwner([
|
||||
{
|
||||
close: async () =>
|
||||
fileHeld ? { reason: "held file", resources: ["/owned"] } : undefined,
|
||||
},
|
||||
{
|
||||
close: async () => {
|
||||
if (opaqueHeld) throw new Error("opaque /not-an-owned-path");
|
||||
},
|
||||
},
|
||||
]);
|
||||
const report = await owner.close();
|
||||
expect(report?.cleanupResources).toEqual(["/owned"]);
|
||||
fileHeld = false;
|
||||
await owner.close();
|
||||
expect(report?.cleanupIncomplete).toBe(true);
|
||||
expect(report?.cleanupResources).toEqual([]);
|
||||
opaqueHeld = false;
|
||||
expect(await owner.close()).toBeUndefined();
|
||||
expect(report?.cleanupIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("starts child finalization synchronously and shares concurrent closes", async () => {
|
||||
let calls = 0;
|
||||
let complete: (() => void) | undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
complete = resolve;
|
||||
});
|
||||
const owner = new SetupCleanupOwner([
|
||||
{
|
||||
close: () => {
|
||||
calls++;
|
||||
return gate;
|
||||
},
|
||||
},
|
||||
]);
|
||||
const first = owner.close();
|
||||
expect(calls).toBe(1);
|
||||
expect(owner.close()).toBe(first);
|
||||
complete?.();
|
||||
expect(await first).toBeUndefined();
|
||||
expect(await owner.close()).toBeUndefined();
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("adopts later cleanup without reopening a completed generation", async () => {
|
||||
let held = true;
|
||||
const task = {
|
||||
close: async () =>
|
||||
held ? { reason: "held", resources: ["/same-label"] } : undefined,
|
||||
};
|
||||
const owner = new SetupCleanupOwner([task]);
|
||||
const previous = await owner.close();
|
||||
held = false;
|
||||
expect(await owner.close()).toBeUndefined();
|
||||
held = true;
|
||||
owner.admit(task);
|
||||
const current = await owner.close();
|
||||
expect(previous?.cleanupIncomplete).toBe(false);
|
||||
expect(previous?.cleanupResources).toEqual([]);
|
||||
expect(current?.cleanupIncomplete).toBe(true);
|
||||
expect(current?.cleanupResources).toEqual(["/same-label"]);
|
||||
held = false;
|
||||
expect(await owner.close()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps finalization cancellation unresolved and still attempts the other child", async () => {
|
||||
let interrupted = true;
|
||||
let otherAttempts = 0;
|
||||
const owner = new SetupCleanupOwner([
|
||||
{
|
||||
close: async () => {
|
||||
if (interrupted)
|
||||
throw new DOMException("close interrupted", "AbortError");
|
||||
},
|
||||
},
|
||||
{
|
||||
close: async () => {
|
||||
otherAttempts++;
|
||||
},
|
||||
},
|
||||
]);
|
||||
const report = await owner.close();
|
||||
expect(report?.cleanupIncomplete).toBe(true);
|
||||
expect(otherAttempts).toBe(1);
|
||||
interrupted = false;
|
||||
expect(await owner.close()).toBeUndefined();
|
||||
expect(report?.cleanupIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["group", "direct"])(
|
||||
"observes exact concurrent recovery before aggregate binding: %s",
|
||||
async (binding) => {
|
||||
const source = new ProviderCleanupError("fixture", ["/shared"], {});
|
||||
const lease = new AnalysisCleanupLease(async () => {}, ["/shared"]);
|
||||
if (binding === "direct") lease.bindCleanupError(source);
|
||||
else new AnalysisCleanupLeaseGroup(source, [lease]);
|
||||
let finish: (() => void) | undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
let sourceCalls = 0;
|
||||
const owner = new SetupCleanupOwner([
|
||||
{
|
||||
close: async () => {
|
||||
sourceCalls++;
|
||||
throw source;
|
||||
},
|
||||
},
|
||||
{ close: () => gate },
|
||||
]);
|
||||
const closing = owner.close();
|
||||
await lease.release();
|
||||
finish?.();
|
||||
expect(await closing).toBeUndefined();
|
||||
expect(await owner.close()).toBeUndefined();
|
||||
expect(sourceCalls).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["pending", "already recovered"] as const)(
|
||||
"keeps delegated cleanup pending when the next report has an independent owner: %s",
|
||||
async (timing) => {
|
||||
const source = new ProviderCleanupError("fixture", ["/independent"], {});
|
||||
const sourceLease = new AnalysisCleanupLease(async () => {}, [
|
||||
"/independent",
|
||||
]);
|
||||
new AnalysisCleanupLeaseGroup(source, [sourceLease]);
|
||||
let calls = 0;
|
||||
const owner = new SetupCleanupOwner([
|
||||
{
|
||||
close: async () => {
|
||||
calls++;
|
||||
if (calls === 1)
|
||||
return {
|
||||
reason: "delegated work pending",
|
||||
resources: ["/delegated"],
|
||||
};
|
||||
if (calls === 2) return source;
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
]);
|
||||
const first = await owner.close();
|
||||
if (timing === "already recovered") await sourceLease.release();
|
||||
const second = await owner.close();
|
||||
await sourceLease.release();
|
||||
expect(source.cleanupIncomplete).toBe(false);
|
||||
expect(first?.cleanupIncomplete).toBe(true);
|
||||
expect(second?.cleanupIncomplete).toBe(true);
|
||||
expect(first?.cleanupResources).toContain("/delegated");
|
||||
expect(second?.cleanupResources).toContain("/delegated");
|
||||
expect(first?.cleanupResources).toEqual(["/delegated"]);
|
||||
expect(second?.cleanupResources).toEqual(["/delegated"]);
|
||||
expect(await owner.close()).toBeUndefined();
|
||||
expect(calls).toBe(3);
|
||||
expect(first?.cleanupIncomplete).toBe(false);
|
||||
expect(second?.cleanupIncomplete).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps an immutable ownerless source snapshot while its owned report recovers", async () => {
|
||||
const source = new ProviderCleanupError("fixture", ["/delegated"], {});
|
||||
Object.freeze(source);
|
||||
let held = true;
|
||||
const owner = new SetupCleanupOwner([
|
||||
{
|
||||
close: async () => (held ? source : undefined),
|
||||
},
|
||||
]);
|
||||
const first = await owner.close();
|
||||
expect(first?.cleanupResources).toEqual(["/delegated"]);
|
||||
const second = await owner.close();
|
||||
expect(second?.cleanupResources).toEqual(["/delegated"]);
|
||||
held = false;
|
||||
expect(await owner.close()).toBeUndefined();
|
||||
expect(source.hasCleanupOwner).toBe(false);
|
||||
expect(source.cleanupIncomplete).toBe(true);
|
||||
expect(first?.cleanupIncomplete).toBe(false);
|
||||
expect(second?.cleanupIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps both authorities pending when the callback succeeds before its independent source", async () => {
|
||||
const source = new ProviderCleanupError("fixture", ["/independent"], {});
|
||||
const sourceLease = new AnalysisCleanupLease(async () => {}, [
|
||||
"/independent",
|
||||
]);
|
||||
new AnalysisCleanupLeaseGroup(source, [sourceLease]);
|
||||
let calls = 0;
|
||||
const owner = new SetupCleanupOwner([
|
||||
{
|
||||
close: async () => {
|
||||
calls++;
|
||||
if (calls === 1)
|
||||
return {
|
||||
reason: "delegated work pending",
|
||||
resources: ["/delegated"],
|
||||
};
|
||||
if (calls === 2) return source;
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
]);
|
||||
const first = await owner.close();
|
||||
const second = await owner.close();
|
||||
const third = await owner.close();
|
||||
expect(source.cleanupIncomplete).toBe(true);
|
||||
for (const report of [first, second, third]) {
|
||||
expect(report?.cleanupIncomplete).toBe(true);
|
||||
expect(report?.cleanupResources).toEqual(["/independent"]);
|
||||
}
|
||||
expect(await owner.close()).toBeDefined();
|
||||
expect(calls).toBe(3);
|
||||
await sourceLease.release();
|
||||
expect(await owner.close()).toBeUndefined();
|
||||
expect(calls).toBe(3);
|
||||
for (const report of [first, second, third])
|
||||
expect(report?.cleanupIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("starts a fresh report when a prior source resolves inside a retry before a new source fails", async () => {
|
||||
const firstSource = new ProviderCleanupError(
|
||||
"fixture",
|
||||
["/first-source"],
|
||||
{},
|
||||
);
|
||||
const secondSource = new ProviderCleanupError(
|
||||
"fixture",
|
||||
["/second-source"],
|
||||
{},
|
||||
);
|
||||
const firstLease = new AnalysisCleanupLease(async () => {}, [
|
||||
"/first-source",
|
||||
]);
|
||||
const secondLease = new AnalysisCleanupLease(async () => {}, [
|
||||
"/second-source",
|
||||
]);
|
||||
new AnalysisCleanupLeaseGroup(firstSource, [firstLease]);
|
||||
new AnalysisCleanupLeaseGroup(secondSource, [secondLease]);
|
||||
let calls = 0;
|
||||
const owner = new SetupCleanupOwner([
|
||||
{
|
||||
close: async () => {
|
||||
calls++;
|
||||
if (calls === 1) return firstSource;
|
||||
await firstLease.release();
|
||||
return secondSource;
|
||||
},
|
||||
},
|
||||
]);
|
||||
const first = await owner.close();
|
||||
const second = await owner.close();
|
||||
expect(first?.cleanupIncomplete).toBe(false);
|
||||
expect(second?.cleanupIncomplete).toBe(true);
|
||||
expect(second?.cleanupResources).toEqual(["/second-source"]);
|
||||
await secondLease.release();
|
||||
expect(await owner.close()).toBeUndefined();
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it.each(["artifact", "analysis"] as const)(
|
||||
"preserves a rejected %s producer's detailed cleanup reason",
|
||||
async (kind) => {
|
||||
const observation = {
|
||||
reason: "Unlink denied because the owned file remains locked",
|
||||
resources: ["/fixture/owned"],
|
||||
};
|
||||
const failure =
|
||||
kind === "artifact"
|
||||
? new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
"Retained resources: /fixture/owned",
|
||||
{ cleanup: observation },
|
||||
)
|
||||
: new ProviderCleanupError(
|
||||
"fixture",
|
||||
observation.resources,
|
||||
{},
|
||||
{ cleanup: observation },
|
||||
);
|
||||
const owner = new SetupCleanupOwner([
|
||||
{
|
||||
rejectedPrefix: "Publisher cleanup failed",
|
||||
close: async () => {
|
||||
throw failure;
|
||||
},
|
||||
},
|
||||
]);
|
||||
const report = await owner.close();
|
||||
expect(report?.cleanup?.reason).toBe(
|
||||
"Publisher cleanup failed: " + observation.reason,
|
||||
);
|
||||
expect(report?.cleanupResources).toEqual(observation.resources);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains the latest typed retry cause independently of its completion owner", async () => {
|
||||
const firstCause = new ProviderCleanupError("fixture", ["/owned"], {
|
||||
stage: "first",
|
||||
});
|
||||
const secondCause = new ProviderCleanupError(
|
||||
"fixture",
|
||||
["/owned"],
|
||||
{ stage: "retry" },
|
||||
{
|
||||
capturedOutput: {
|
||||
stdout: "retry-specific output",
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
let calls = 0;
|
||||
const owner = new SetupCleanupOwner([
|
||||
{
|
||||
close: async () => {
|
||||
if (++calls === 1) throw firstCause;
|
||||
if (calls === 2) throw secondCause;
|
||||
},
|
||||
},
|
||||
]);
|
||||
const first = await owner.close();
|
||||
const second = await owner.close();
|
||||
expect(errorSources(second).has(secondCause)).toBe(true);
|
||||
expect(secondCause.capturedOutput?.stdout).toBe("retry-specific output");
|
||||
expect(await owner.close()).toBeUndefined();
|
||||
expect(first?.cleanupIncomplete).toBe(false);
|
||||
expect(second?.cleanupIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("does not share callback ownership through a reused ownerless source error", async () => {
|
||||
const source = new ProviderCleanupError("fixture", ["/same-file"], {});
|
||||
let firstHeld = true;
|
||||
let secondHeld = true;
|
||||
const owner = new SetupCleanupOwner([
|
||||
{ close: async () => (firstHeld ? source : undefined) },
|
||||
{ close: async () => (secondHeld ? source : undefined) },
|
||||
]);
|
||||
const first = await owner.close();
|
||||
firstHeld = false;
|
||||
const second = await owner.close();
|
||||
expect(second?.cleanupIncomplete).toBe(true);
|
||||
expect(second?.cleanupResources).toEqual(["/same-file"]);
|
||||
secondHeld = false;
|
||||
expect(await owner.close()).toBeUndefined();
|
||||
expect(first?.cleanupIncomplete).toBe(false);
|
||||
expect(second?.cleanupIncomplete).toBe(false);
|
||||
expect(source.hasCleanupOwner).toBe(false);
|
||||
expect(source.cleanupIncomplete).toBe(true);
|
||||
});
|
||||
|
||||
it("retains earlier independent owner evidence alongside the latest retry cause", async () => {
|
||||
const firstSource = new ProviderCleanupError("fixture", ["/first"], {
|
||||
stage: "first",
|
||||
});
|
||||
const secondSource = new ProviderCleanupError("fixture", ["/second"], {
|
||||
stage: "retry",
|
||||
});
|
||||
const firstLease = new AnalysisCleanupLease(async () => {}, ["/first"]);
|
||||
const secondLease = new AnalysisCleanupLease(async () => {}, ["/second"]);
|
||||
new AnalysisCleanupLeaseGroup(firstSource, [firstLease]);
|
||||
new AnalysisCleanupLeaseGroup(secondSource, [secondLease]);
|
||||
let calls = 0;
|
||||
const owner = new SetupCleanupOwner([
|
||||
{ close: async () => (++calls === 1 ? firstSource : secondSource) },
|
||||
]);
|
||||
await owner.close();
|
||||
const latest = await owner.close();
|
||||
expect(errorSources(latest).has(firstSource)).toBe(true);
|
||||
expect(errorSources(latest).has(secondSource)).toBe(true);
|
||||
await firstLease.release();
|
||||
await secondLease.release();
|
||||
expect(await owner.close()).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
AnalysisCleanupLease,
|
||||
AnalysisCleanupLeaseGroup,
|
||||
} from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import {
|
||||
AnalysisError,
|
||||
type AnalysisCleanupObservation,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { bindRegularFileCleanup } from "./RegularFileRead.js";
|
||||
|
||||
interface SetupCleanupTask {
|
||||
readonly rejectedPrefix?: string;
|
||||
close(): Promise<string | AnalysisCleanupObservation | AnalysisError | void>;
|
||||
}
|
||||
|
||||
/** Construct a report from facts already parsed by its owning boundary. */
|
||||
const setupCleanupReport = (
|
||||
cause: unknown,
|
||||
observation: AnalysisCleanupObservation,
|
||||
): AnalysisError =>
|
||||
new ProviderCleanupError(
|
||||
"setup",
|
||||
observation.resources,
|
||||
{ reason: observation.reason },
|
||||
{ cause, cleanup: observation, operation: "setup" },
|
||||
);
|
||||
|
||||
/** Preserve explicit cleanup capabilities when a host rejects instead of reporting. */
|
||||
export const setupCleanupError = (
|
||||
cause: unknown,
|
||||
observation = ArtifactReaderFailure.cleanupObservation(cause, []),
|
||||
): AnalysisError => {
|
||||
if (
|
||||
cause instanceof AnalysisError &&
|
||||
(cause.cleanupIncomplete ||
|
||||
cause.hasCleanupOwner ||
|
||||
cause.cleanup !== undefined ||
|
||||
cause instanceof ProviderCleanupError)
|
||||
)
|
||||
return cause;
|
||||
const error = setupCleanupReport(cause, observation);
|
||||
bindRegularFileCleanup(cause, error);
|
||||
return error;
|
||||
};
|
||||
|
||||
export const setupCleanupReason = (error: AnalysisError): string =>
|
||||
error.cleanup?.reason ??
|
||||
(typeof error.diagnostics?.reason === "string"
|
||||
? error.diagnostics.reason
|
||||
: error.message);
|
||||
|
||||
/** One child callback, its conjunctive completion owner, and latest failure provenance. */
|
||||
class SetupCleanupChild {
|
||||
#generation = {};
|
||||
#attempted: object | undefined;
|
||||
#completed: object | undefined;
|
||||
#failure: { readonly owner: AnalysisError; cause: AnalysisError } | undefined;
|
||||
#reportedErrors = new WeakSet<AnalysisError>();
|
||||
#delegated: AnalysisError | undefined;
|
||||
#reason = "";
|
||||
#resources: readonly string[] = [];
|
||||
#attempt: ReturnType<SetupCleanupTask["close"]> = Promise.resolve();
|
||||
#lease = this.#newLease();
|
||||
|
||||
constructor(private readonly task: SetupCleanupTask) {}
|
||||
|
||||
admit(): void {
|
||||
this.#generation = {};
|
||||
}
|
||||
get hasNewGeneration(): boolean {
|
||||
return this.#attempted !== this.#generation;
|
||||
}
|
||||
|
||||
get failure():
|
||||
| {
|
||||
readonly owner: AnalysisError;
|
||||
readonly cause: AnalysisError;
|
||||
readonly reason: string;
|
||||
}
|
||||
| undefined {
|
||||
if (this.#failure !== undefined && !this.#failure.owner.cleanupIncomplete) {
|
||||
this.#completed = this.#attempted;
|
||||
this.#failure = undefined;
|
||||
this.#reportedErrors = new WeakSet();
|
||||
}
|
||||
return this.#failure === undefined
|
||||
? undefined
|
||||
: { ...this.#failure, reason: this.#reason };
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
// A source owner may have completed between rejection and this retry.
|
||||
void this.failure;
|
||||
if (this.#completed === this.#generation) return Promise.resolve();
|
||||
if (this.#lease.released || this.#completed !== undefined) {
|
||||
this.#lease = this.#newLease();
|
||||
this.#completed = undefined;
|
||||
this.#delegated = undefined;
|
||||
}
|
||||
const generation = this.#generation;
|
||||
this.#attempted = generation;
|
||||
// Seal terminal child scopes before returning from close.
|
||||
try {
|
||||
this.#attempt = this.task.close();
|
||||
} catch (cause: unknown) {
|
||||
this.#attempt = Promise.reject(cause);
|
||||
}
|
||||
return this.#lease.release().then(() => {
|
||||
this.#completed = generation;
|
||||
});
|
||||
}
|
||||
|
||||
#newLease(): AnalysisCleanupLease {
|
||||
return new AnalysisCleanupLease(() => this.#execute(), this);
|
||||
}
|
||||
|
||||
get cleanupResources(): readonly string[] {
|
||||
return this.#resources;
|
||||
}
|
||||
|
||||
async #execute(): Promise<void> {
|
||||
let failure: AnalysisError;
|
||||
try {
|
||||
const report = await this.#attempt;
|
||||
if (report === undefined) return;
|
||||
failure =
|
||||
report instanceof AnalysisError
|
||||
? setupCleanupError(report)
|
||||
: setupCleanupError(
|
||||
undefined,
|
||||
typeof report === "string"
|
||||
? { reason: report, resources: [] }
|
||||
: report,
|
||||
);
|
||||
this.#reason = setupCleanupReason(failure);
|
||||
} catch (cause: unknown) {
|
||||
failure = setupCleanupError(cause);
|
||||
this.#reason =
|
||||
(this.task.rejectedPrefix === undefined
|
||||
? ""
|
||||
: this.task.rejectedPrefix + ": ") + setupCleanupReason(failure);
|
||||
}
|
||||
// Keep this attempt's diagnostics separate from the owner followed by
|
||||
// every earlier report from this generation.
|
||||
if (this.#failure !== undefined) this.#failure.cause = failure;
|
||||
let dependency = failure;
|
||||
if (failure.cleanupIncomplete && !failure.hasCleanupOwner) {
|
||||
this.#resources = [...failure.cleanupResources];
|
||||
if (this.#delegated === undefined) {
|
||||
// Ownerless source errors remain evidence snapshots. A private report
|
||||
// binds this callback without changing shared or immutable input.
|
||||
this.#delegated = setupCleanupReport(failure, {
|
||||
reason: this.#reason,
|
||||
resources: this.#resources,
|
||||
});
|
||||
new AnalysisCleanupLeaseGroup(this.#delegated, [this.#lease]);
|
||||
}
|
||||
// Every attempt shares this exact callback lease and one dependency.
|
||||
dependency = this.#delegated;
|
||||
}
|
||||
if (dependency !== undefined && dependency.cleanupIncomplete) {
|
||||
if (!this.#reportedErrors.has(dependency)) {
|
||||
if (
|
||||
this.#failure === undefined ||
|
||||
!this.#failure.owner.cleanupIncomplete
|
||||
) {
|
||||
this.#failure = {
|
||||
owner: setupCleanupReport(
|
||||
new AggregateError(
|
||||
[dependency],
|
||||
"Setup child cleanup authorities",
|
||||
),
|
||||
{ reason: this.#reason, resources: [] },
|
||||
),
|
||||
cause: failure,
|
||||
};
|
||||
this.#reportedErrors = new WeakSet();
|
||||
} else this.#failure.owner.retainSecondaryFailure(dependency);
|
||||
this.#failure.owner.bindCleanupDependencies([dependency]);
|
||||
this.#reportedErrors.add(dependency);
|
||||
}
|
||||
}
|
||||
// A source's release cannot confirm an earlier delegated callback, and a
|
||||
// successful callback cannot release an independently owned source.
|
||||
if (failure.cleanupIncomplete) throw failure;
|
||||
if (this.#delegated?.cleanupIncomplete) throw this.#delegated;
|
||||
}
|
||||
}
|
||||
|
||||
/** Preserve completed peers and follow live source errors through exact retries. */
|
||||
export class SetupCleanupOwner {
|
||||
readonly #children: ReadonlyMap<SetupCleanupTask, SetupCleanupChild>;
|
||||
#closing: Promise<AnalysisError | undefined> | undefined;
|
||||
|
||||
constructor(tasks: readonly SetupCleanupTask[]) {
|
||||
this.#children = new Map(
|
||||
tasks.map((task) => [task, new SetupCleanupChild(task)]),
|
||||
);
|
||||
}
|
||||
|
||||
/** Admit actual new cleanup work, never merely another retry of a failed peer. */
|
||||
admit(task: SetupCleanupTask): void {
|
||||
const child = this.#children.get(task);
|
||||
if (child === undefined)
|
||||
throw new TypeError("Cleanup task is not owned by this setup host");
|
||||
child.admit();
|
||||
}
|
||||
|
||||
close(): Promise<AnalysisError | undefined> {
|
||||
if (this.#closing !== undefined) return this.#closing;
|
||||
const attempt = this.#attempt().finally(() => {
|
||||
if (this.#closing === attempt) this.#closing = undefined;
|
||||
});
|
||||
this.#closing = attempt;
|
||||
return attempt;
|
||||
}
|
||||
|
||||
async #attempt(): Promise<AnalysisError | undefined> {
|
||||
const children = [...this.#children.values()];
|
||||
// An active producer can admit its final cleanup outcome while draining.
|
||||
do {
|
||||
await Promise.allSettled(children.map((child) => child.close()));
|
||||
} while (children.some((child) => child.hasNewGeneration));
|
||||
const failures = children.flatMap((child) => {
|
||||
const failure = child.failure;
|
||||
return failure === undefined ? [] : [failure];
|
||||
});
|
||||
if (failures.length === 0) return undefined;
|
||||
const observation: AnalysisCleanupObservation = {
|
||||
reason: failures.map((failure) => failure.reason).join("; "),
|
||||
resources: [
|
||||
...new Set(
|
||||
failures.flatMap((failure) => failure.owner.cleanupResources),
|
||||
),
|
||||
],
|
||||
};
|
||||
const error = setupCleanupReport(
|
||||
new AggregateError(
|
||||
failures.map((failure) => failure.cause),
|
||||
"Setup child cleanup failures",
|
||||
{
|
||||
cause: new AggregateError(
|
||||
failures.map((failure) => failure.owner),
|
||||
"Setup cleanup ownership reports",
|
||||
),
|
||||
},
|
||||
),
|
||||
observation,
|
||||
).bindCleanupDependencies(failures.map((failure) => failure.owner));
|
||||
return error.cleanupIncomplete ? error : undefined;
|
||||
}
|
||||
}
|
||||
+110
-116
@@ -24,7 +24,6 @@ import {
|
||||
installLinuxHopper,
|
||||
LinuxHopperTemporaryCleanupOwner,
|
||||
systemLinuxHopperInstallHost,
|
||||
type LinuxHopperInstallHost,
|
||||
} from "./LinuxHopper.js";
|
||||
import { installMacHopper, systemMacHopperInstallHost } from "./MacHopper.js";
|
||||
import { linuxHopperReleaseCacheDirectory } from "./LinuxHopperCacheDirectory.js";
|
||||
@@ -46,7 +45,10 @@ import {
|
||||
inspectClientConfiguration,
|
||||
} from "./SetupClientConfiguration.js";
|
||||
import { failedConfigurationMessage } from "./SetupClients.js";
|
||||
import { setupInstallFailure } from "./SetupInstallFailure.js";
|
||||
import {
|
||||
setupInstallFailure,
|
||||
type SetupHopperInstallResult,
|
||||
} from "./SetupInstallFailure.js";
|
||||
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
|
||||
import {
|
||||
explicitSetupRegistrationEnvironment,
|
||||
@@ -62,6 +64,12 @@ import type {
|
||||
SetupProviderEnvironment,
|
||||
} from "./SetupTypes.js";
|
||||
import type { DoctorScope } from "./Doctor.js";
|
||||
import { SetupCleanupOwner } from "./SetupCleanupOwner.js";
|
||||
import {
|
||||
AnalysisCleanupLeaseGroup,
|
||||
type AnalysisCleanupLease,
|
||||
} from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { bindRegularFileCleanup } from "./RegularFileRead.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
import { analysisProviderSelectorSchema } from "../contracts/providerSelection.js";
|
||||
|
||||
@@ -151,6 +159,30 @@ export const systemSetupHost = (
|
||||
linuxDistribution,
|
||||
);
|
||||
const pendingLinuxHopperCleanup = new LinuxHopperTemporaryCleanupOwner();
|
||||
const macCleanup = {
|
||||
rejectedPrefix: "Hopper host cleanup failed",
|
||||
close: async () => macHopperHost?.close?.(),
|
||||
};
|
||||
const temporaryCleanup = {
|
||||
rejectedPrefix: "Linux Hopper cleanup retry failed",
|
||||
close: () => pendingLinuxHopperCleanup.retry(linuxHopperHost.cleanup),
|
||||
};
|
||||
const publisherCleanup = {
|
||||
rejectedPrefix: "Linux Hopper publisher cleanup failed",
|
||||
close: async () => {
|
||||
await linuxHopperHost.close?.();
|
||||
},
|
||||
};
|
||||
const cleanup = new SetupCleanupOwner([
|
||||
macCleanup,
|
||||
{
|
||||
rejectedPrefix: "Doctor host cleanup failed",
|
||||
close: async () => doctorHost.close?.(),
|
||||
},
|
||||
temporaryCleanup,
|
||||
publisherCleanup,
|
||||
{ close: () => resources.close() },
|
||||
]);
|
||||
return {
|
||||
platform,
|
||||
homeDirectory,
|
||||
@@ -166,10 +198,7 @@ export const systemSetupHost = (
|
||||
nodeVersion: process.versions.node,
|
||||
macosVersion: () => doctorHost.macosVersion(),
|
||||
linuxDistribution,
|
||||
close: () =>
|
||||
closeSetupResources(resources, macHopperHost, doctorHost, () =>
|
||||
closeLinuxHopperInstallHost(linuxHopperHost, pendingLinuxHopperCleanup),
|
||||
),
|
||||
close: () => cleanup.close(),
|
||||
initialSetupState: async (
|
||||
scope?: DoctorScope,
|
||||
): Promise<SetupInitialState> => {
|
||||
@@ -205,62 +234,74 @@ export const systemSetupHost = (
|
||||
doctor: diagnosis,
|
||||
};
|
||||
},
|
||||
installHopper: async (replaceExisting) => {
|
||||
if (platform === "linux") {
|
||||
const result = await installLinuxHopper(linuxHopperHost);
|
||||
if (result.cleanupFailure !== undefined)
|
||||
pendingLinuxHopperCleanup.retain(result.cleanupFailure);
|
||||
if (result.status === "installed")
|
||||
return { status: "installed", launcherPath: result.launcherPath };
|
||||
const failure = setupInstallFailure(
|
||||
result.reason,
|
||||
result.packageFailure,
|
||||
"linux",
|
||||
);
|
||||
const detail =
|
||||
result.verificationFailure?.message ??
|
||||
result.pythonRuntimeFailure?.message ??
|
||||
(result.operationFailure === undefined
|
||||
? undefined
|
||||
: `${result.operationFailure.operation}${result.operationFailure.path === undefined ? "" : ` at ${result.operationFailure.path}`}: ${result.operationFailure.systemCode === undefined ? "" : `${result.operationFailure.systemCode}: `}${result.operationFailure.detail}`);
|
||||
return detail !== undefined
|
||||
? {
|
||||
...failure,
|
||||
remediation: `${detail} ${failure.remediation}`,
|
||||
installHopper: (replaceExisting) =>
|
||||
resources.run<SetupHopperInstallResult>(async () => {
|
||||
const publisher = platform === "linux" ? publisherCleanup : macCleanup;
|
||||
cleanup.admit(publisher);
|
||||
try {
|
||||
if (platform === "linux") {
|
||||
const result = await installLinuxHopper(linuxHopperHost);
|
||||
if (result.cleanupFailure !== undefined) {
|
||||
pendingLinuxHopperCleanup.retain(result.cleanupFailure);
|
||||
cleanup.admit(temporaryCleanup);
|
||||
}
|
||||
: failure;
|
||||
}
|
||||
if (macHopperHost === undefined)
|
||||
return setupInstallFailure("unsupported_host");
|
||||
const result = await installMacHopper({ replaceExisting }, macHopperHost);
|
||||
if (result.status === "installed")
|
||||
return {
|
||||
status: "installed",
|
||||
launcherPath: result.launcherPath,
|
||||
...(result.openFailure === undefined
|
||||
? {}
|
||||
: {
|
||||
warning: `Hopper was installed, but macOS could not open it: ${result.openFailure}. Open ${result.launcherPath} manually to complete first-run setup.`,
|
||||
}),
|
||||
...(result.cleanupFailure === undefined
|
||||
? {}
|
||||
: { cleanupFailure: result.cleanupFailure }),
|
||||
};
|
||||
const failure = setupInstallFailure(result.reason);
|
||||
const diagnostic = setupInstallerDiagnostic(result.diagnostic);
|
||||
return {
|
||||
...failure,
|
||||
remediation: [
|
||||
failure.remediation,
|
||||
...(diagnostic === undefined
|
||||
? []
|
||||
: [`Installer detail: ${diagnostic}`]),
|
||||
...(result.cleanupFailure === undefined
|
||||
? []
|
||||
: [result.cleanupFailure]),
|
||||
].join(" "),
|
||||
};
|
||||
},
|
||||
if (result.status === "installed")
|
||||
return { status: "installed", launcherPath: result.launcherPath };
|
||||
const failure = setupInstallFailure(
|
||||
result.reason,
|
||||
result.packageFailure,
|
||||
"linux",
|
||||
);
|
||||
const detail =
|
||||
result.verificationFailure?.message ??
|
||||
result.pythonRuntimeFailure?.message ??
|
||||
(result.operationFailure === undefined
|
||||
? undefined
|
||||
: `${result.operationFailure.operation}${result.operationFailure.path === undefined ? "" : ` at ${result.operationFailure.path}`}: ${result.operationFailure.systemCode === undefined ? "" : `${result.operationFailure.systemCode}: `}${result.operationFailure.detail}`);
|
||||
return detail !== undefined
|
||||
? {
|
||||
...failure,
|
||||
remediation: `${detail} ${failure.remediation}`,
|
||||
}
|
||||
: failure;
|
||||
}
|
||||
if (macHopperHost === undefined)
|
||||
return setupInstallFailure("unsupported_host");
|
||||
const result = await installMacHopper(
|
||||
{ replaceExisting },
|
||||
macHopperHost,
|
||||
);
|
||||
if (result.status === "installed")
|
||||
return {
|
||||
status: "installed",
|
||||
launcherPath: result.launcherPath,
|
||||
...(result.openFailure === undefined
|
||||
? {}
|
||||
: {
|
||||
warning: `Hopper was installed, but macOS could not open it: ${result.openFailure}. Open ${result.launcherPath} manually to complete first-run setup.`,
|
||||
}),
|
||||
...(result.cleanupFailure === undefined
|
||||
? {}
|
||||
: { cleanupFailure: result.cleanupFailure }),
|
||||
};
|
||||
const failure = setupInstallFailure(result.reason);
|
||||
const diagnostic = setupInstallerDiagnostic(result.diagnostic);
|
||||
return {
|
||||
...failure,
|
||||
remediation: [
|
||||
failure.remediation,
|
||||
...(diagnostic === undefined
|
||||
? []
|
||||
: [`Installer detail: ${diagnostic}`]),
|
||||
...(result.cleanupFailure === undefined
|
||||
? []
|
||||
: [result.cleanupFailure]),
|
||||
].join(" "),
|
||||
};
|
||||
} finally {
|
||||
cleanup.admit(publisher);
|
||||
}
|
||||
}),
|
||||
detectedClients: () => detectClients(homeDirectory, platform, environment),
|
||||
supportedClients: () =>
|
||||
Promise.resolve(supportedClients(homeDirectory, platform, environment)),
|
||||
@@ -360,42 +401,6 @@ const clientConfigurationOperations = (
|
||||
),
|
||||
});
|
||||
|
||||
const closeSetupResources = async (
|
||||
resources: ArtifactResourceScope,
|
||||
macHopperHost: ReturnType<typeof systemMacHopperInstallHost> | undefined,
|
||||
doctorHost: DoctorHost,
|
||||
retryLinuxHopperCleanup: () => Promise<string | undefined>,
|
||||
): Promise<string | undefined> => {
|
||||
const [resourceCleanup, hopperCleanup, doctorCleanup, linuxCleanup] =
|
||||
await Promise.allSettled([
|
||||
resources.close(),
|
||||
Promise.resolve().then(() => macHopperHost?.close()),
|
||||
Promise.resolve().then(() => doctorHost.close?.()),
|
||||
retryLinuxHopperCleanup(),
|
||||
]);
|
||||
const failures: string[] = [];
|
||||
if (hopperCleanup.status === "rejected")
|
||||
failures.push(
|
||||
`Hopper host cleanup failed: ${errorMessage(hopperCleanup.reason)}`,
|
||||
);
|
||||
else if (hopperCleanup.value !== undefined)
|
||||
failures.push(hopperCleanup.value);
|
||||
if (doctorCleanup.status === "rejected")
|
||||
failures.push(
|
||||
`Doctor host cleanup failed: ${errorMessage(doctorCleanup.reason)}`,
|
||||
);
|
||||
else if (doctorCleanup.value !== undefined)
|
||||
failures.push(doctorCleanup.value);
|
||||
if (linuxCleanup.status === "rejected")
|
||||
failures.push(
|
||||
`Linux Hopper cleanup retry failed: ${errorMessage(linuxCleanup.reason)}`,
|
||||
);
|
||||
else if (linuxCleanup.value !== undefined) failures.push(linuxCleanup.value);
|
||||
if (resourceCleanup.status === "rejected")
|
||||
failures.push(errorMessage(resourceCleanup.reason));
|
||||
return failures.length === 0 ? undefined : failures.join("; ");
|
||||
};
|
||||
|
||||
const runClientConfigurationOperation = async <Value>(
|
||||
resources: ArtifactResourceScope,
|
||||
operation: () => Promise<Value>,
|
||||
@@ -419,6 +424,7 @@ const runClientConfigurationOperation = async <Value>(
|
||||
`Could not safely access client configuration at ${cause.path}: ${errorMessage(cleanup.primary)}.${rollbackMessage}`,
|
||||
cause,
|
||||
cleanup.cleanup,
|
||||
cleanup.cleanupLeases,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -435,33 +441,21 @@ const configurationCleanupError = (
|
||||
cleanup:
|
||||
| { readonly reason: string; readonly resources: readonly string[] }
|
||||
| undefined,
|
||||
): ConfigurationError =>
|
||||
new ConfigurationError(message, {
|
||||
leases?: readonly AnalysisCleanupLease[],
|
||||
): ConfigurationError => {
|
||||
const error = new ConfigurationError(message, {
|
||||
cause,
|
||||
settings: [{ setting: "client configuration", constraint: message }],
|
||||
...(cleanup === undefined ? {} : { cleanup }),
|
||||
});
|
||||
if (leases !== undefined) new AnalysisCleanupLeaseGroup(error, leases);
|
||||
else bindRegularFileCleanup(cause, error);
|
||||
return error;
|
||||
};
|
||||
|
||||
const errorMessage = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
const closeLinuxHopperInstallHost = async (
|
||||
host: LinuxHopperInstallHost,
|
||||
temporaryOwner: LinuxHopperTemporaryCleanupOwner,
|
||||
): Promise<string | undefined> => {
|
||||
const reasons: string[] = [];
|
||||
const temporary = await temporaryOwner.retry(host.cleanup);
|
||||
if (temporary !== undefined) reasons.push(temporary);
|
||||
try {
|
||||
await host.close?.();
|
||||
} catch (cause: unknown) {
|
||||
reasons.push(
|
||||
`Linux Hopper publisher cleanup failed: ${errorMessage(cause)}`,
|
||||
);
|
||||
}
|
||||
return reasons.length === 0 ? undefined : reasons.join("; ");
|
||||
};
|
||||
|
||||
/** Detect supported agents from their config files or stable installation markers. */
|
||||
export const detectClients = async (
|
||||
home: string,
|
||||
|
||||
@@ -8,6 +8,10 @@ import type {
|
||||
SetupSkillInstallError,
|
||||
} from "./SetupSkill.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type {
|
||||
AnalysisCleanupObservation,
|
||||
AnalysisError,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import type {
|
||||
SetupFailureCode,
|
||||
@@ -106,8 +110,10 @@ export interface SetupHost {
|
||||
linuxDistribution(): Promise<LinuxDistribution | undefined>;
|
||||
initialSetupState(scope?: DoctorScope): Promise<SetupInitialState>;
|
||||
installHopper(replaceExisting: boolean): Promise<SetupHopperInstallResult>;
|
||||
/** Retry and report any cleanup resources still owned by this setup host. */
|
||||
close?(): Promise<string | undefined>;
|
||||
/** Retry retained owners. Returned or thrown errors with explicit cleanup
|
||||
* owners follow live release state; ownerless failures remain unconfirmed.
|
||||
* runSetup does not observe later direct calls to a caller-supplied host. */
|
||||
close?(): Promise<AnalysisError | undefined>;
|
||||
detectedClients(): Promise<readonly SetupClient[]>;
|
||||
supportedClients(): Promise<readonly SetupClient[]>;
|
||||
configureClient(
|
||||
@@ -148,6 +154,8 @@ export interface SetupResult {
|
||||
readonly clientStates: readonly SetupClientState[];
|
||||
readonly remediation?: string;
|
||||
readonly code?: SetupFailureCode;
|
||||
/** Identified resources still retained after finalization; absent after confirmed release. */
|
||||
readonly cleanup?: AnalysisCleanupObservation | undefined;
|
||||
}
|
||||
|
||||
/** Whether the requested setup actions still need approval or human repair. */
|
||||
|
||||
@@ -23,7 +23,9 @@ it.each(["before release", "after release"])(
|
||||
if (binding === "before release") lease.bindCleanupError(error);
|
||||
await lease.release();
|
||||
if (binding === "after release") lease.bindCleanupError(error);
|
||||
expect(error.hasCleanupOwner).toBe(true);
|
||||
expect(error.cleanupIncomplete).toBe(false);
|
||||
expect(error.cleanupResources).toEqual(["owned resource"]);
|
||||
expect(error.cause).toBe(cause);
|
||||
await lease.release();
|
||||
expect(releases).toBe(1);
|
||||
@@ -178,3 +180,24 @@ it("shares an in-flight release and permits one new attempt after failure", asyn
|
||||
for (const settle of pending) settle();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves a producer's live attribution when binding direct completion", async () => {
|
||||
const error = new ProviderCleanupError("fixture", ["initial resource"], {});
|
||||
let resources = ["first resource", "second resource"];
|
||||
error.bindCleanupResourceOwner({
|
||||
get cleanupResources() {
|
||||
return resources;
|
||||
},
|
||||
});
|
||||
const lease = new AnalysisCleanupLease(async () => {
|
||||
resources = [];
|
||||
});
|
||||
lease.bindCleanupError(error);
|
||||
expect(error.hasCleanupOwner).toBe(true);
|
||||
expect(error.cleanupResources).toEqual(["first resource", "second resource"]);
|
||||
resources = ["second resource"];
|
||||
expect(error.cleanupResources).toEqual(["second resource"]);
|
||||
await lease.release();
|
||||
expect(error.cleanupIncomplete).toBe(false);
|
||||
expect(error.cleanupResources).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
export class AnalysisCleanupLease {
|
||||
#released = false;
|
||||
#releasePromise: Promise<void> | undefined;
|
||||
#errors: WeakRef<AnalysisError>[] = [];
|
||||
readonly #groups = new Set<AnalysisCleanupLeaseGroup>();
|
||||
|
||||
/** Whether this exact owner has confirmed release. */
|
||||
@@ -64,7 +63,6 @@ export class AnalysisCleanupLease {
|
||||
.then(async () => {
|
||||
await this.cleanup();
|
||||
this.#released = true;
|
||||
this.#resolveBoundErrors();
|
||||
for (const group of this.#groups) group.ownerReleased(this);
|
||||
this.#groups.clear();
|
||||
})
|
||||
@@ -77,14 +75,9 @@ export class AnalysisCleanupLease {
|
||||
|
||||
/** Binding can follow a concurrent retry that already confirmed release. */
|
||||
bindCleanupError(error: AnalysisError): void {
|
||||
if (this.#released) {
|
||||
error.resolveCleanup();
|
||||
return;
|
||||
}
|
||||
this.#errors = [
|
||||
...this.#errors.filter((reference) => reference.deref() !== undefined),
|
||||
new WeakRef(error),
|
||||
];
|
||||
new AnalysisCleanupLeaseGroup(error, [this], {
|
||||
preserveResourceAttribution: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Bind one aggregate error to every exact lease in a failed retry set. */
|
||||
@@ -97,11 +90,6 @@ export class AnalysisCleanupLease {
|
||||
if (!retained.hasLiveError) this.#groups.delete(retained);
|
||||
this.#groups.add(group);
|
||||
}
|
||||
|
||||
#resolveBoundErrors(): void {
|
||||
for (const reference of this.#errors) reference.deref()?.resolveCleanup();
|
||||
this.#errors = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Conjunctive cleanup binding for an error covering multiple exact owners. */
|
||||
@@ -109,11 +97,17 @@ export class AnalysisCleanupLeaseGroup {
|
||||
readonly #error: WeakRef<AnalysisError>;
|
||||
readonly #pending: Set<AnalysisCleanupLease>;
|
||||
|
||||
constructor(error: AnalysisError, owners: readonly AnalysisCleanupLease[]) {
|
||||
constructor(
|
||||
error: AnalysisError,
|
||||
owners: readonly AnalysisCleanupLease[],
|
||||
options: { readonly preserveResourceAttribution?: boolean } = {},
|
||||
) {
|
||||
this.#error = new WeakRef(error);
|
||||
this.#pending = new Set(owners);
|
||||
if (this.#pending.size === 0) return;
|
||||
error.bindCleanupResourceOwner(this);
|
||||
if (options.preserveResourceAttribution === true)
|
||||
error.bindCleanupCompletionOwner(this);
|
||||
else error.bindCleanupResourceOwner(this);
|
||||
for (const owner of this.#pending) owner.bindCleanupGroup(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,8 @@ export const renderInteractiveSetupResult = (
|
||||
}
|
||||
if (result.status === "planned" || result.status === "cancelled") return;
|
||||
writeLine(`! ${result.remediation ?? "Setup needs attention."}`);
|
||||
for (const resource of result.cleanup?.resources ?? [])
|
||||
writeLine(` Pending cleanup: ${resource}`);
|
||||
outro(
|
||||
`Run \`${cliInvocation(environment)} doctor\` for the remaining checks.`,
|
||||
promptStreams,
|
||||
|
||||
@@ -82,6 +82,7 @@ export abstract class AnalysisError extends Error {
|
||||
cleanupIncomplete: boolean;
|
||||
#ownedCleanupResources: readonly string[];
|
||||
#cleanupResourceOwner: AnalysisCleanupResourceOwner | undefined;
|
||||
#cleanupCompletionOwner: AnalysisCleanupResourceOwner | undefined;
|
||||
#secondaryFailures: AnalysisError[] = [];
|
||||
constructor(message: string, options?: AnalysisErrorOptions) {
|
||||
super(message, options);
|
||||
@@ -132,12 +133,27 @@ export abstract class AnalysisError extends Error {
|
||||
];
|
||||
}
|
||||
|
||||
/** Whether cleanup follows an explicit owner or dependent errors. */
|
||||
get hasCleanupOwner(): boolean {
|
||||
return (
|
||||
this.#cleanupResourceOwner !== undefined ||
|
||||
this.#cleanupCompletionOwner !== undefined ||
|
||||
this.#cleanupDependencies !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
/** Bind attribution only to the producer that owns the complete cleanup set. */
|
||||
bindCleanupResourceOwner(owner: AnalysisCleanupResourceOwner): this {
|
||||
this.#cleanupResourceOwner = owner;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Observe exact completion without replacing the producer's resource facts. */
|
||||
bindCleanupCompletionOwner(owner: AnalysisCleanupResourceOwner): this {
|
||||
this.#cleanupCompletionOwner = owner;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Initialize resource facts supplied by a more specific error producer. */
|
||||
protected setCleanupResources(resources: readonly string[]): void {
|
||||
this.#ownedCleanupResources = [...resources];
|
||||
|
||||
Reference in New Issue
Block a user