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:
Dery Ferdika
2026-10-24 14:08:10 +07:00
committed by GitHub
parent bdf15b9bd5
commit 98ec00f406
23 changed files with 1543 additions and 188 deletions
@@ -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
? {}
: {
+4 -1
View File
@@ -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;
+8 -2
View File
@@ -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()],
};
}
}
+29 -1
View File
@@ -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();
});
+35 -14
View File
@@ -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),
};
+5
View File
@@ -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
? {}
: {
+248 -3
View File
@@ -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
View File
@@ -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,
};
};
+439
View File
@@ -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();
});
+254
View File
@@ -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
View File
@@ -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,
+10 -2
View File
@@ -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. */