fix(export): retain verification cleanup failures

This commit is contained in:
morluto
2026-10-27 13:19:20 +08:00
parent 1ea6903595
commit 6ed97a22ed
4 changed files with 103 additions and 7 deletions
@@ -1,4 +1,11 @@
import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import {
mkdir,
readFile,
rm,
stat,
symlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -13,6 +20,7 @@ import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
import { EnhancedTools } from "../EnhancedTools.js";
import { DecompileExport } from "./exportTree.js";
import { ExportResources } from "./exportResources.js";
import { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js";
import { OwnedStagedOutput } from "../../filesystem/OwnedStagedOutput.js";
import {
composedLimitationBudget,
@@ -1930,3 +1938,50 @@ describe("decompile_all publication cleanup", () => {
},
);
});
describe("decompile_all resumed file cleanup", () => {
const directory = useOutputDirectory();
afterEach(() => vi.restoreAllMocks());
it.each(["journaled", "unjournaled"])(
"reports a %s file verification close failure after final cleanup succeeds",
async (kind) => {
const { tools } = harness();
expect((await run(tools, directory())).ok).toBe(true);
if (kind === "unjournaled") {
const journal = join(directory(), DECOMPILE_DIRECTORY_JOURNAL_NAME);
const header = (await readFile(journal, "utf8")).split("\n")[0];
await writeFile(journal, `${header}\n`);
}
const identity = await stat(join(directory(), "functions/0x1000.c"));
const close = OwnedFileHandle.prototype.close;
let failed = false;
vi.spyOn(OwnedFileHandle.prototype, "close").mockImplementation(
async function (this: OwnedFileHandle) {
const observed = await this.handle.stat();
if (
!failed &&
observed.dev === identity.dev &&
observed.ino === identity.ino
) {
failed = true;
throw new Error("verification close denied");
}
return close.call(this);
},
);
const result = await run(tools, directory(), { resume: true });
expect(failed).toBe(true);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toContain("verification cleanup failed");
expect(result.error.cleanupIncomplete).toBe(false);
expect(
await readFile(join(directory(), "functions/0x1000.c"), "utf8"),
).toContain(sentinel);
const resumed = await run(tools, directory(), { resume: true });
expect(resumed.ok).toBe(true);
if (resumed.ok)
expect(resumed.value).toMatchObject({ counts: { skipped: 3 } });
},
);
});
@@ -1,6 +1,8 @@
import { join } from "node:path";
import { z } from "zod";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import {
DECOMPILE_DIRECTORY_INDEX_NAME,
DECOMPILE_DIRECTORY_INLINE_FAILURE_LIMIT,
@@ -33,6 +35,7 @@ import { err, ok, type Result } from "../../domain/result.js";
import {
classifyOutputPath,
DecompileExport,
exportOutputFailure,
type ExportIdentity,
} from "./exportTree.js";
import {
@@ -355,6 +358,7 @@ const publishSelected = async (
procedure.address,
input.signal,
);
if (typeof existing === "object") return err(existing.error);
if (isAborted(input.signal)) return err(cancelled(input.signal));
if (existing === "skip") {
input.state.skipped += 1;
@@ -707,7 +711,12 @@ const readBody = async (
};
};
type ExistingFile = "skip" | "write" | "conflict" | "io";
type ExistingFile =
| "skip"
| "write"
| "conflict"
| "io"
| { readonly error: AnalysisError };
const existingFile = async (
session: DecompileExport,
@@ -724,6 +733,13 @@ const existingFile = async (
const digest = await session
.hashFile(record.relative_path, signal)
.catch((cause: unknown) => cause);
if (digest instanceof ArtifactReaderFailure && digest.cleanup !== undefined)
return {
error: exportOutputFailure(
"decompile file verification cleanup failed",
digest,
),
};
if (typeof digest === "string")
return digest === record.sha256 ? "skip" : "conflict";
return nodeAbsent(digest) ? "write" : "io";
@@ -11,6 +11,8 @@ import {
import { join } from "node:path";
import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
import { ArtifactFileCleanupFailure } from "../../artifacts/ArtifactFileOperation.js";
import { RegularFileCleanupFailure } from "../../filesystem/OwnedRegularFile.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { DECOMPILE_DIRECTORY_JOURNAL_NAME } from "../../contracts/decompileDirectory.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
@@ -97,14 +99,26 @@ export class ExportResources {
/** Check existing output with bounded memory and the shared file identity owner. */
async hashFile(path: string, signal?: AbortSignal): Promise<string> {
let cleanupLease: AnalysisCleanupLease | undefined;
try {
const hash = await hashStableFile(path, {
...(signal === undefined ? {} : { signal }),
retainFailedClose: (handle) =>
this.#files.retain({ kind: "file-handle", resource: path, handle }),
retainFailedClose: (handle) => {
cleanupLease = new AnalysisCleanupLease(() => handle.close(), [path]);
this.#files.retain({
kind: "lease",
resource: path,
lease: cleanupLease,
});
},
});
return hash.sha256;
} catch (cause: unknown) {
if (
cause instanceof RegularFileCleanupFailure &&
cleanupLease !== undefined
)
throw new ArtifactFileCleanupFailure(cause, cleanupLease);
throw stableFileHashCause(cause);
}
}
@@ -396,7 +396,7 @@ const commitFunctionFile = async (
if (linked instanceof ArtifactReaderFailure && linked.cleanup !== undefined)
return {
status: "error",
error: exportPublicationFailure(
error: exportOutputFailure(
"decompile function publication cleanup failed",
linked,
),
@@ -405,6 +405,17 @@ const commitFunctionFile = async (
const existing = await resources
.hashFile(finalPath)
.catch((cause: unknown) => cause);
if (
existing instanceof ArtifactReaderFailure &&
existing.cleanup !== undefined
)
return {
status: "error",
error: exportOutputFailure(
"decompile file verification cleanup failed",
existing,
),
};
if (typeof existing !== "string") return { status: "io" };
return existing === digest
? { status: "matched", sha256: digest }
@@ -972,7 +983,7 @@ const writeOwnedIndex = async (
return ok(undefined);
} catch (cause: unknown) {
return err(
exportPublicationFailure(
exportOutputFailure(
cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined
? "decompile index publication cleanup failed"
: "decompile index could not be written",
@@ -983,7 +994,7 @@ const writeOwnedIndex = async (
};
/** Preserve the publisher's parsed failure and exact cleanup authority. */
const exportPublicationFailure = (
export const exportOutputFailure = (
message: string,
cause: unknown,
): AnalysisOutputError => {