mirror of
https://github.com/morluto/rea.git
synced 2026-10-31 11:56:12 +00: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:
1 parent
bdf15b9bd5
commit
98ec00f406
23 files changed
+1543
-188
No files matched your search
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user