* 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>
137 lines
4.2 KiB
TypeScript
137 lines
4.2 KiB
TypeScript
import type {
|
|
AnalysisError,
|
|
AnalysisCleanupResourceOwner,
|
|
} from "../domain/analysisErrorBase.js";
|
|
|
|
/** Link one resource's confirmed release to its originating analysis error. */
|
|
export class AnalysisCleanupLease {
|
|
#released = false;
|
|
#releasePromise: Promise<void> | undefined;
|
|
readonly #groups = new Set<AnalysisCleanupLeaseGroup>();
|
|
|
|
/** Whether this exact owner has confirmed release. */
|
|
get released(): boolean {
|
|
return this.#released;
|
|
}
|
|
|
|
readonly #resourceOwner: AnalysisCleanupResourceOwner | undefined;
|
|
readonly #resources: readonly string[] | undefined;
|
|
|
|
get cleanupResources(): readonly string[] | undefined {
|
|
return this.#resourceOwner === undefined
|
|
? this.#resources
|
|
: this.#resourceOwner.cleanupResources;
|
|
}
|
|
|
|
/** Adopt exact close authority while retaining its live labels across retries. */
|
|
static forCloseOwner(
|
|
owner: {
|
|
close(): Promise<void>;
|
|
readonly cleanupResources?: readonly string[] | undefined;
|
|
},
|
|
resource: string,
|
|
): AnalysisCleanupLease {
|
|
return new AnalysisCleanupLease(
|
|
() => owner.close(),
|
|
!("cleanupResources" in owner)
|
|
? [resource]
|
|
: {
|
|
get cleanupResources() {
|
|
return owner.cleanupResources;
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
constructor(
|
|
private readonly cleanup: () => Promise<void>,
|
|
resources?: readonly string[] | AnalysisCleanupResourceOwner,
|
|
) {
|
|
if (resources !== undefined && "cleanupResources" in resources) {
|
|
this.#resourceOwner = resources;
|
|
this.#resources = undefined;
|
|
} else {
|
|
this.#resourceOwner = undefined;
|
|
this.#resources = resources === undefined ? undefined : [...resources];
|
|
}
|
|
}
|
|
|
|
release(): Promise<void> {
|
|
if (this.#released) return Promise.resolve();
|
|
if (this.#releasePromise !== undefined) return this.#releasePromise;
|
|
const attempt = Promise.resolve()
|
|
.then(async () => {
|
|
await this.cleanup();
|
|
this.#released = true;
|
|
for (const group of this.#groups) group.ownerReleased(this);
|
|
this.#groups.clear();
|
|
})
|
|
.finally(() => {
|
|
if (this.#releasePromise === attempt) this.#releasePromise = undefined;
|
|
});
|
|
this.#releasePromise = attempt;
|
|
return attempt;
|
|
}
|
|
|
|
/** Binding can follow a concurrent retry that already confirmed release. */
|
|
bindCleanupError(error: AnalysisError): void {
|
|
new AnalysisCleanupLeaseGroup(error, [this], {
|
|
preserveResourceAttribution: true,
|
|
});
|
|
}
|
|
|
|
/** Bind one aggregate error to every exact lease in a failed retry set. */
|
|
bindCleanupGroup(group: AnalysisCleanupLeaseGroup): void {
|
|
if (this.#released) {
|
|
group.ownerReleased(this);
|
|
return;
|
|
}
|
|
for (const retained of this.#groups)
|
|
if (!retained.hasLiveError) this.#groups.delete(retained);
|
|
this.#groups.add(group);
|
|
}
|
|
}
|
|
|
|
/** Conjunctive cleanup binding for an error covering multiple exact owners. */
|
|
export class AnalysisCleanupLeaseGroup {
|
|
readonly #error: WeakRef<AnalysisError>;
|
|
readonly #pending: Set<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;
|
|
if (options.preserveResourceAttribution === true)
|
|
error.bindCleanupCompletionOwner(this);
|
|
else error.bindCleanupResourceOwner(this);
|
|
for (const owner of this.#pending) owner.bindCleanupGroup(this);
|
|
}
|
|
|
|
/** Unlabelled owners keep attribution unknown; never match resources by text. */
|
|
get cleanupResources(): readonly string[] | undefined {
|
|
if (
|
|
[...this.#pending].some((owner) => owner.cleanupResources === undefined)
|
|
)
|
|
return undefined;
|
|
return [
|
|
...new Set(
|
|
[...this.#pending].flatMap((owner) => owner.cleanupResources ?? []),
|
|
),
|
|
];
|
|
}
|
|
|
|
get hasLiveError(): boolean {
|
|
return this.#error.deref() !== undefined;
|
|
}
|
|
|
|
ownerReleased(owner: AnalysisCleanupLease): void {
|
|
if (!this.#pending.delete(owner) || this.#pending.size > 0) return;
|
|
this.#error.deref()?.resolveCleanup();
|
|
this.#pending.clear();
|
|
}
|
|
}
|