diff --git a/src/javascript/recovery/RecoveryFiles.ownership.test.ts b/src/javascript/recovery/RecoveryFiles.ownership.test.ts index 80b578ce8c..d4b3155272 100644 --- a/src/javascript/recovery/RecoveryFiles.ownership.test.ts +++ b/src/javascript/recovery/RecoveryFiles.ownership.test.ts @@ -7,11 +7,16 @@ import { afterEach, expect, it, onTestFinished, vi } from "vitest"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { ArtifactFileCleanupFailure } from "../../artifacts/ArtifactFileOperation.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; -import { inventoryRecoveryFiles, readRecoveryFile } from "./RecoveryFiles.js"; +import { + fingerprintRecoveryFile, + inventoryRecoveryFiles, + readRecoveryFile, +} from "./RecoveryFiles.js"; const closeFault = vi.hoisted(() => ({ path: undefined as string | undefined, denied: false, + cancel: undefined as AbortController | undefined, handles: [] as FileHandle[], failure: new Error("controlled recovery descriptor close failure"), })); @@ -57,6 +62,7 @@ vi.mock("node:fs/promises", async (importOriginal) => { if (handle.fd >= 0) await close(); }); handle.close = async () => { + closeFault.cancel?.abort(); if (closeFault.denied) throw closeFault.failure; await close(); }; @@ -73,6 +79,7 @@ afterEach(() => { directoryFault.closeAttempts = 0; closeFault.path = undefined; closeFault.denied = false; + closeFault.cancel = undefined; closeFault.handles = []; }); @@ -165,3 +172,29 @@ it.each([ expect(resources.hasPending).toBe(true); }, ); + +it("retains completed hash facts and the exact reason when cancellation arrives during file close", async () => { + const { path, bytes } = await selectedFile(); + const controller = new AbortController(); + const resources = new ArtifactResourceScope(); + closeFault.cancel = controller; + const failure: unknown = await fingerprintRecoveryFile( + path, + 1024, + controller.signal, + resources, + ).catch((cause: unknown) => cause); + expect(failure).toMatchObject({ + _tag: "AnalysisCancelledError", + cause: controller.signal.reason, + cleanupIncomplete: false, + partialObservation: { + kind: "file-snapshot-hash", + path, + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }, + }); + expect(closeFault.handles[0]?.fd).toBe(-1); + await resources.close(); +}); diff --git a/src/javascript/recovery/RecoveryFiles.ts b/src/javascript/recovery/RecoveryFiles.ts index 2f71c6aea2..d5793dde9c 100644 --- a/src/javascript/recovery/RecoveryFiles.ts +++ b/src/javascript/recovery/RecoveryFiles.ts @@ -4,6 +4,7 @@ import { ArtifactFileCleanupFailure, withArtifactRegularFile, } from "../../artifacts/ArtifactFileOperation.js"; +import { fileSnapshotPartialObservationSchema } from "../../domain/fileSnapshotObservation.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { OwnedDirectoryHandle } from "../../filesystem/OwnedDirectoryHandle.js"; @@ -100,11 +101,17 @@ const scanRecoveryFile = async ( }, ); } catch (cause: unknown) { - if ( - cause instanceof ArtifactFileCleanupFailure || - cause instanceof ArtifactFileCancelled - ) - throw cause; + if (cause instanceof ArtifactFileCleanupFailure) throw cause; + if (cause instanceof ArtifactFileCancelled) + throw new AnalysisCancelledError(OPERATION, { + cause: cause.fileCancellation.reason, + partialObservation: fileSnapshotPartialObservationSchema.parse( + Object.assign({}, cause.fileCancellation.outcome.value, { + kind: "file-snapshot-hash", + path, + }), + ), + }); if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") throw new AnalysisCancelledError(OPERATION, { cause: artifactFileCancellationCause(cause), @@ -182,8 +189,7 @@ const snapshotRecoveryInput = async ( cause instanceof AnalysisCancelledError || cause instanceof AnalysisAccessDeniedError || cause instanceof AnalysisArtifactChangedError || - cause instanceof ArtifactFileCleanupFailure || - cause instanceof ArtifactFileCancelled + cause instanceof ArtifactFileCleanupFailure ) throw cause; // The operation-wide input allowance is a deterministic byte budget, not diff --git a/tests/boundary/javascript-recovery/wakaruFileOwnership.test.ts b/tests/boundary/javascript-recovery/wakaruFileOwnership.test.ts index 7933622f69..98c687bee5 100644 --- a/tests/boundary/javascript-recovery/wakaruFileOwnership.test.ts +++ b/tests/boundary/javascript-recovery/wakaruFileOwnership.test.ts @@ -2,10 +2,7 @@ import { createHash } from "node:crypto"; import { access, readFile, type FileHandle } from "node:fs/promises"; import { basename, join } from "node:path"; import { afterEach, expect, it, onTestFinished, vi } from "vitest"; -import { - ArtifactFileCancelled, - ArtifactFileCleanupFailure, -} from "../../../src/artifacts/ArtifactFileOperation.js"; +import { ArtifactFileCleanupFailure } from "../../../src/artifacts/ArtifactFileOperation.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; import { @@ -177,20 +174,27 @@ linuxX64.each([ expect(result.error).toBeInstanceOf( denied ? ProviderCleanupError : AnalysisCancelledError, ); - const failure = result.error.cause; - const physical = - failure instanceof ArtifactFileCleanupFailure - ? failure.fileCleanupFailure - : failure instanceof ArtifactFileCancelled - ? failure.fileCancellation - : undefined; - expect(physical?.outcome).toMatchObject({ - kind: "completed", - value: { + if (denied) { + const failure = result.error.cause; + expect(failure).toBeInstanceOf(ArtifactFileCleanupFailure); + if (!(failure instanceof ArtifactFileCleanupFailure)) + throw new Error("Expected exact file cleanup owner"); + expect(failure.fileCleanupFailure.outcome).toMatchObject({ + kind: "completed", + value: { + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }, + }); + } else { + expect(result.error.cause).toBe(controller.signal.reason); + expect(result.error.partialObservation).toEqual({ + kind: "file-snapshot-hash", + path: selected, bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex"), - }, - }); + }); + } expect(fixture.launches).toHaveLength(0); if (denied) { expect(result.error.cleanupIncomplete).toBe(true);