fix(core): keep released cleanup dependencies empty

Distinguish unbound resource attribution from a completed dependency set so final release cannot restore historical resource names.

Refs #4578
This commit is contained in:
morluto committed 2026-10-17 16:43:14 +08:00
1 parent 9744d07211
commit 26bb85c007
2 files changed
+29 -11

No files matched your search

+9 -11
View File
@@ -70,7 +70,8 @@ export interface AnalysisCleanupResourceOwner {
/** Base class for expected analysis, provider, and session failures. */
export abstract class AnalysisError extends Error {
readonly #cleanupPrimary: AnalysisError | undefined;
readonly #cleanupDependencies = new Set<AnalysisError>();
// Undefined means unbound; a bound empty set records confirmed completion.
#cleanupDependencies: Set<AnalysisError> | undefined;
#cleanupDependents: WeakRef<AnalysisError>[] = [];
readonly diagnostics: Readonly<Record<string, JsonValue>> | undefined;
readonly capturedOutput: AnalysisCapturedOutput | undefined;
@@ -107,7 +108,7 @@ export abstract class AnalysisError extends Error {
/** Live dependencies own current resource attribution; cleanup keeps history. */
get cleanupResources(): readonly string[] {
if (this.#cleanupDependencies.size === 0)
if (this.#cleanupDependencies === undefined)
return (
this.#cleanupResourceOwner?.cleanupResources ??
this.#ownedCleanupResources
@@ -152,13 +153,10 @@ export abstract class AnalysisError extends Error {
/** Keep a composed error incomplete until all explicitly owned source errors resolve. */
bindCleanupDependencies(dependencies: readonly AnalysisError[]): this {
const pending = (this.#cleanupDependencies ??= new Set<AnalysisError>());
for (const dependency of dependencies) {
if (
!dependency.cleanupIncomplete ||
this.#cleanupDependencies.has(dependency)
)
continue;
this.#cleanupDependencies.add(dependency);
if (!dependency.cleanupIncomplete || pending.has(dependency)) continue;
pending.add(dependency);
dependency.#cleanupDependents = [
...dependency.#cleanupDependents.filter(
(reference) => reference.deref() !== undefined,
@@ -166,19 +164,19 @@ export abstract class AnalysisError extends Error {
new WeakRef(this),
];
}
if (this.#cleanupDependencies.size === 0) this.resolveCleanup();
if (pending.size === 0) this.resolveCleanup();
return this;
}
/** Clear live cleanup status only after its owning resource confirms retry success. */
resolveCleanup(): this {
if (
[...this.#cleanupDependencies].some(
[...(this.#cleanupDependencies ?? [])].some(
(dependency) => dependency.cleanupIncomplete,
)
)
return this;
this.#cleanupDependencies.clear();
this.#cleanupDependencies?.clear();
this.cleanupIncomplete = false;
for (const reference of this.#cleanupDependents)
reference.deref()?.resolveCleanup();
+20
View File
@@ -16,6 +16,26 @@ import {
import { ProviderCleanupError } from "./providerCleanupError.js";
import { createEvidence } from "./evidence.js";
it("keeps dependency-owned resources empty after the final owner releases", () => {
const file = new ProviderCleanupError("fixture", ["file-owner"], {});
const trace = new ProviderCleanupError("fixture", ["trace-owner"], {});
const composed = analysisErrorWithCleanupFailure(
file,
trace,
).bindCleanupDependencies([file, trace]);
expect(new Set(composed.cleanupResources)).toEqual(
new Set(["file-owner", "trace-owner"]),
);
file.resolveCleanup();
expect(composed.cleanupResources).toEqual(["trace-owner"]);
trace.resolveCleanup();
expect(composed.cleanupIncomplete).toBe(false);
expect(composed.cleanupResources).toEqual([]);
expect(new Set(composed.cleanup?.resources)).toEqual(
new Set(["file-owner", "trace-owner"]),
);
});
it("keeps primary diagnostics separate from historical cleanup diagnostics with the same keys", () => {
const primary = new AnalysisInputError("inspect", {
diagnostics: {