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
@@ -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);