diff --git a/src/javascript/recovery/WakaruProvider.ts b/src/javascript/recovery/WakaruProvider.ts index 88a51a74bd..e258126630 100644 --- a/src/javascript/recovery/WakaruProvider.ts +++ b/src/javascript/recovery/WakaruProvider.ts @@ -9,7 +9,11 @@ import type { import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js"; import { SafeOutputTreeCreationFailure } from "../../artifacts/SafeOutputTreeCreationFailure.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { javascriptRecoveryResultSchema, type JavaScriptRecoveryInput, @@ -144,7 +148,11 @@ export class WakaruProvider implements JavaScriptRecoveryPort { failed = { cause }; } if (failed !== undefined) { - const failure = failed.cause; + const failure = + failed.cause instanceof ArtifactReaderFailure && + failed.cause.reason === "cancelled" + ? new AnalysisCancelledError(OPERATION, { cause: failed.cause }) + : failed.cause; const residuals: string[] = []; const cleanupFailures: { resource: string; reason: string }[] = []; const cleanupOwner = diff --git a/tests/boundary/javascript-recovery/wakaruPublicationCancellation.test.ts b/tests/boundary/javascript-recovery/wakaruPublicationCancellation.test.ts new file mode 100644 index 0000000000..250e756239 --- /dev/null +++ b/tests/boundary/javascript-recovery/wakaruPublicationCancellation.test.ts @@ -0,0 +1,128 @@ +import { createHash } from "node:crypto"; +import { access, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import { expect, it, vi } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const publicationCancellation = vi.hoisted(() => ({ + controller: undefined as AbortController | undefined, + directorySyncs: 0, +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const filesystem = await importOriginal(); + return { + ...filesystem, + open: async (...args: Parameters) => { + const handle = await filesystem.open(...args); + if (publicationCancellation.controller !== undefined) { + const stats = await handle.stat(); + if (stats.isDirectory()) { + const sync = handle.sync.bind(handle); + handle.sync = async () => { + await sync(); + publicationCancellation.directorySyncs += 1; + if (publicationCancellation.directorySyncs === 2) + publicationCancellation.controller?.abort(); + }; + } + } + return handle; + }, + }; +}); + +vi.mock( + "../../../src/javascript/recovery/WakaruCommand.js", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/javascript/recovery/WakaruCommand.js") + >(); + return { + ...actual, + resolveWakaruCommand: async () => ({ + command: "/unused/wakaru", + limiter: "/unused/prlimit", + sha256: "a".repeat(64), + bytes: 1, + }), + }; + }, +); + +vi.mock( + "../../../src/javascript/recovery/WakaruExecution.js", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/javascript/recovery/WakaruExecution.js") + >(); + const { createAnalysisExecution } = + await import("../../../src/application/AnalysisProvider.js"); + return { + ...actual, + prepareWakaruExecution: async ( + context: Parameters[0], + ) => { + const source = context.sources[0]; + if (source === undefined) throw new Error("Missing recovery source"); + const bytes = Buffer.from("module.exports = 42;"); + await context.tree.write("module.js", Readable.from([bytes]), { + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.byteLength, + }); + return createAnalysisExecution( + { status: "complete" }, + { id: "wakaru", name: "Wakaru", version: "1.14.0" }, + { + subject: { + path: source.path, + sha256: source.sha256, + format: "javascript", + }, + }, + ); + }, + }; + }, +); + +import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { WakaruProvider } from "../../../src/javascript/recovery/WakaruProvider.js"; + +it.skipIf(process.platform === "win32")( + "keeps mid-commit output-tree cancellation typed through the provider", + async () => { + const root = await createTestTempDirectory("rea-wakaru-cancel-"); + const sourcePath = join(root, "source.js"); + const output = join(root, "output"); + await writeFile(sourcePath, "module.exports = 42;"); + const controller = new AbortController(); + publicationCancellation.controller = controller; + publicationCancellation.directorySyncs = 0; + const provider = new WakaruProvider({}); + try { + const result = await provider.recover( + { + path: sourcePath, + output_directory: output, + extraction_mode: "structural", + rewrite_level: "standard", + }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected publication cancellation"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(projectAnalysisError(result.error).code).toBe("cancelled"); + expect(publicationCancellation.directorySyncs).toBe(2); + await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + publicationCancellation.controller = undefined; + publicationCancellation.directorySyncs = 0; + await provider.close(); + } + }, +);