fix(javascript): retain completed recovery hashes on cancellation

This commit is contained in:
morluto
2026-10-24 07:43:56 +08:00
parent 5940600a22
commit d442ac752e
3 changed files with 67 additions and 24 deletions
@@ -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();
});
+13 -7
View File
@@ -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