From 2d80427422ea8cebd181b0f5af1d39d43d7a950a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:34:32 +0800 Subject: [PATCH] fix(native): preserve cancellation during signature admission (#10093) * fix(native): preserve cancellation during snapshot admission * fix(native): retain cancellation during cleanup retry --- src/native/SignatureTargetBinding.test.ts | 103 ++++++++++++++++++++++ src/native/SignatureTargetBinding.ts | 29 ++++-- 2 files changed, 125 insertions(+), 7 deletions(-) diff --git a/src/native/SignatureTargetBinding.test.ts b/src/native/SignatureTargetBinding.test.ts index 3c76b142d9..1a9958db02 100644 --- a/src/native/SignatureTargetBinding.test.ts +++ b/src/native/SignatureTargetBinding.test.ts @@ -457,6 +457,109 @@ it.each([false, true])( }, ); +it("retains cancellation during failed cleanup retry without losing ownership", async () => { + const target = await createTarget("same"); + const resources = new ArtifactResourceScope(); + const controller = new AbortController(); + const reason = { caller: "cancelled during cleanup retry" }; + const root = await createRoot(); + let attempts = 0; + const create = async () => ({ + path: root.path, + async close() { + attempts += 1; + if (attempts === 2) controller.abort(reason); + if (attempts <= 2) throw new Error("controlled root close failure"); + await root.close(); + }, + }); + try { + const first = await withSignatureTarget( + target, + resources, + async () => ok(signatureInspectionFixture()), + undefined, + create, + ); + if (first.ok) throw new Error("Expected retained root"); + const second = await withSignatureTarget( + target, + resources, + unusedInspection, + controller.signal, + create, + ); + if (second.ok) throw new Error("Expected failed cleanup retry"); + expect(second.error.cleanupIncomplete).toBe(true); + expect(second.error.cleanupResources).toContain(root.path); + expect(second.error.cleanupPrimary?.secondaryFailures).toContainEqual( + expect.objectContaining({ + _tag: "AnalysisCancelledError", + cause: reason, + }), + ); + await resources.close(); + expect(first.error.cleanupIncomplete).toBe(false); + expect(second.error.cleanupIncomplete).toBe(false); + await expect(stat(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await resources.close(); + } +}); + +it("retains late cancellation when creating the signature root fails", async () => { + const target = await createTarget("same"); + const resources = new ArtifactResourceScope(); + const controller = new AbortController(); + const reason = { caller: "cancelled during root creation" }; + const physical = Object.assign(new Error("denied root creation"), { + code: "EACCES", + }); + const result = await withSignatureTarget( + target, + resources, + unusedInspection, + controller.signal, + async () => { + controller.abort(reason); + throw physical; + }, + ); + if (result.ok) throw new Error("Expected root creation failure"); + expect(result.error.cause).toBe(physical); + expect(result.error.secondaryFailures).toHaveLength(1); + expect(result.error.secondaryFailures[0]).toMatchObject({ + _tag: "AnalysisCancelledError", + cause: reason, + }); + expect(result.error.cleanupIncomplete).toBe(false); + await resources.close(); +}); + +it("does not adopt a typed caller abort reason as a root-creation failure", async () => { + const target = await createTarget("same"); + const resources = new ArtifactResourceScope(); + const controller = new AbortController(); + const reason = new ProviderCleanupError("foreign", ["unowned"], {}); + const result = await withSignatureTarget( + target, + resources, + unusedInspection, + controller.signal, + async () => { + controller.abort(reason); + throw reason; + }, + ); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error).toMatchObject({ + _tag: "AnalysisCancelledError", + cause: reason, + }); + expect(result.error.cleanupResources).not.toContain("unowned"); + await resources.close(); +}); + it.each([false, true])( "retains late cancellation after a signature failure while root cleanup fails: %s", async (cleanupFails) => { diff --git a/src/native/SignatureTargetBinding.ts b/src/native/SignatureTargetBinding.ts index 4e85dfd175..559e85a0d4 100644 --- a/src/native/SignatureTargetBinding.ts +++ b/src/native/SignatureTargetBinding.ts @@ -114,10 +114,19 @@ export const withSignatureTarget = async ( assertRegularFileActive(signal); root = await createRoot(); } catch (cause: unknown) { - return err( + const failure = cause instanceof RegularFileOperationCancelled ? signatureReadFailure(target, cause) - : snapshotFailure(target, "create", cause), + : snapshotFailure(target, "create", cause); + return err( + signal?.aborted + ? analysisErrorWithCancellation( + cause === signal.reason ? undefined : failure, + new AnalysisCancelledError("inspect_signature", { + cause: signal.reason, + }), + ) + : failure, ); } const children = new ArtifactResourceScope(); @@ -186,6 +195,15 @@ export const withSignatureTarget = async ( return result; }); } catch (cause: unknown) { + const primary = signatureReadFailure(target, cause); + const outcome = signal?.aborted + ? analysisErrorWithCancellation( + cause === signal.reason ? undefined : primary, + new AnalysisCancelledError("inspect_signature", { + cause: signal.reason, + }), + ) + : primary; const retained = cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined ? cause.cleanup @@ -198,12 +216,9 @@ export const withSignatureTarget = async ( "retry", ); bindArtifactFileCleanup(cause, failure); - return combineCleanupFailure( - err(signatureReadFailure(target, cause)), - failure, - ); + return combineCleanupFailure(err(outcome), failure); } - return err(signatureReadFailure(target, cause)); + return err(outcome); } };