fix(artifacts): serialize concurrent cleanup lease releases

Share the in-flight release for one exact owner, preserve failed attempts for retry, and notify dependent errors only after confirmed completion.

Validation: concurrency control fails on3fcba0796 with two cleanup attempts; isolated foundation passes38 selected lease/scope/resource tests. Authored lifecycle controls, no native provider qualification.
This commit is contained in:
morluto committed 2026-10-18 03:21:02 +08:00
1 parent 3fcba07962
commit 16924e275e
2 files changed
+53 -7

No files matched your search

@@ -142,3 +142,39 @@ it.each([false, true])(
expect(group.cleanupResources).toBeUndefined();
},
);
it("shares an in-flight release and permits one new attempt after failure", async () => {
const pending: ((failure?: Error) => void)[] = [];
let attempts = 0;
const lease = new AnalysisCleanupLease(() => {
attempts++;
return new Promise<void>((resolve, reject) => {
pending.push((failure) =>
failure === undefined ? resolve() : reject(failure),
);
});
}, ["owned control"]);
try {
const first = lease.release();
const shared = lease.release();
const rejected = Promise.allSettled([first, shared]);
await Promise.resolve();
expect(attempts).toBe(1);
const failure = new Error("controlled release failure");
pending[0]!(failure);
for (const outcome of await rejected)
expect(outcome).toEqual({ status: "rejected", reason: failure });
expect(lease.released).toBe(false);
const retry = lease.release();
const sharedRetry = lease.release();
await Promise.resolve();
expect(attempts).toBe(2);
pending[1]!();
await Promise.all([retry, sharedRetry]);
expect(lease.released).toBe(true);
await lease.release();
expect(attempts).toBe(2);
} finally {
for (const settle of pending) settle();
}
});
+17 -7
View File
@@ -6,6 +6,7 @@ import type {
/** Link one resource's confirmed release to its originating analysis error. */
export class AnalysisCleanupLease {
#released = false;
#releasePromise: Promise<void> | undefined;
#errors: WeakRef<AnalysisError>[] = [];
readonly #groups = new Set<AnalysisCleanupLeaseGroup>();
@@ -56,13 +57,22 @@ export class AnalysisCleanupLease {
}
}
async release(): Promise<void> {
if (this.#released) return;
await this.cleanup();
this.#released = true;
this.#resolveBoundErrors();
for (const group of this.#groups) group.ownerReleased(this);
this.#groups.clear();
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;
this.#resolveBoundErrors();
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. */