fix(decompile): retain published output receipts (#9633)

* fix(decompile): retain published index on failure

* fix(decompile): preserve publication failure facts

* docs(decompile): explain retained publication receipts

* fix(decompile): retain receipts across provider cleanup chains

* fix(evidence): retain diagnostics through cleanup primary chains

* refactor(evidence): retain receipts once on the returned error
This commit is contained in:
morluto authored and GitHub committed 2026-11-09 20:55:53 +08:00
1 parent d4b9a33348
commit 694edef951
7 files changed
+259 -75

No files matched your search

@@ -332,6 +332,10 @@ crashed run can be recovered by the next run. Failed descriptor or staging clean
could not be written or read, for example on a full disk, the export stops with
an error instead of decompiling the rest. Files and records already committed
stay, `decompile-index.json` is still written, and a later resume continues.
If the index was published before cancellation or a later failure, the error's
`diagnostics.published_output` retains its `output_directory` and `index_path`.
This receipt confirms publication, not completion of every selected function.
Failed staging cleanup retains the same receipt through cleanup retry.
Type inspection selects one exact database pathname or typed data address.
Struct/union fields, enums, pointers, arrays, typedefs, size, alignment, packing
@@ -36,6 +36,8 @@ import {
} from "../../contracts/decompileDirectory.js";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
import { createEvidence } from "../../domain/evidence.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
@@ -1516,6 +1518,14 @@ describe("decompile_all late cancellation", () => {
if (result.ok) return;
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(
projectAnalysisError(result.error).details?.diagnostics,
).toMatchObject({
published_output: {
output_directory: directory(),
index_path: join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME),
},
});
// Finalization still completed, so the export resumes without new work.
vi.restoreAllMocks();
const next = harness();
@@ -1575,6 +1585,7 @@ describe("decompile_all cancelled publish loop", () => {
controller.signal,
);
expect(result).toEqual(err(failure));
expect(failure.diagnostics?.published_output).toBeUndefined();
});
it("keeps a journal close failure primary when cancellation stops the publish loop", async () => {
@@ -1594,6 +1605,15 @@ describe("decompile_all cancelled publish loop", () => {
controller.signal,
);
expect(result).toEqual(err(failure));
expect(projectAnalysisError(failure).details?.diagnostics).toMatchObject({
published_output: {
output_directory: directory(),
index_path: join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME),
},
});
await expect(
readFile(join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME), "utf8"),
).resolves.toContain("functions");
});
it("reports the caller's reason when cancellation stops the publish loop and finalization succeeds", async () => {
@@ -1937,6 +1957,10 @@ describe("decompile_all provider cleanup ownership", () => {
expect(failure.secondaryFailures[0]?.cause).toBe(callerReason);
} else expect(failure.secondaryFailures).toHaveLength(0);
expect(failure.partialObservation).toBe(partial);
expect(failure.diagnostics?.published_output).toEqual({
output_directory: directory(),
index_path: join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME),
});
expect(failure.capturedOutput?.stdout).toBe("retained provider output");
expect(failure.cleanupIncomplete).toBe(true);
expect(observed.at(-1)).toBe(operation);
@@ -1966,64 +1990,100 @@ describe("decompile_all mixed cleanup ownership", () => {
const directory = useOutputDirectory();
afterEach(() => vi.restoreAllMocks());
it("waits for both provider and journal cleanup owners after mixed finalization failures", async () => {
const { port } = harness();
const providerFailure = new AnalysisOutputError(
"procedure_info",
"provider cleanup failed",
{
cleanup: {
reason: "provider close denied",
resources: ["provider descriptor"],
it.each([false, true])(
"waits for provider and journal cleanup owners with a composed provider failure: %s",
async (composed) => {
const { port } = harness();
const providerCleanupFailure = new AnalysisOutputError(
"procedure_info",
"provider cleanup failed",
{
cleanup: {
reason: "provider close denied",
resources: ["provider descriptor"],
},
},
},
);
const closeFailure = new AnalysisOutputError(
"decompile_all",
"journal cleanup failed",
{
cleanup: {
reason: "journal close denied",
resources: ["journal descriptor"],
);
const closeFailure = new AnalysisOutputError(
"decompile_all",
"journal cleanup failed",
{
cleanup: {
reason: "journal close denied",
resources: ["journal descriptor"],
},
},
},
);
const providerLease = new AnalysisCleanupLease(
async () => undefined,
["provider descriptor"],
);
const journalLease = new AnalysisCleanupLease(
async () => undefined,
["journal descriptor"],
);
providerLease.bindCleanupError(providerFailure);
journalLease.bindCleanupError(closeFailure);
const close = DecompileExport.prototype.close;
vi.spyOn(DecompileExport.prototype, "close").mockImplementation(
async function (this: DecompileExport) {
await close.call(this);
return err(closeFailure);
},
);
const failing: AnalysisOperationPort = {
execute: (operation, parameters, options) =>
operation === "procedure_info"
? Promise.resolve(err(providerFailure))
: port.execute(operation, parameters, options),
};
const result = await run(new EnhancedTools(failing), directory());
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.cleanupPrimary).toBe(providerFailure);
expect(result.error.cleanupResources).toEqual(
expect.arrayContaining(["provider descriptor", "journal descriptor"]),
);
await journalLease.release();
expect(closeFailure.cleanupIncomplete).toBe(false);
expect(result.error.cleanupIncomplete).toBe(true);
await providerLease.release();
expect(result.error.cleanupIncomplete).toBe(false);
});
);
const providerLease = new AnalysisCleanupLease(
async () => undefined,
["provider descriptor"],
);
const journalLease = new AnalysisCleanupLease(
async () => undefined,
["journal descriptor"],
);
providerLease.bindCleanupError(providerCleanupFailure);
const providerPrimary = new AnalysisOutputError(
"procedure_info",
"provider operation failed",
{
diagnostics: { provider_fact: "preserved" },
},
);
const providerFailure = composed
? analysisErrorWithCleanupFailure(
providerPrimary,
providerCleanupFailure,
"procedure_info",
).bindCleanupDependencies([providerCleanupFailure])
: providerCleanupFailure;
journalLease.bindCleanupError(closeFailure);
const close = DecompileExport.prototype.close;
vi.spyOn(DecompileExport.prototype, "close").mockImplementation(
async function (this: DecompileExport) {
await close.call(this);
return err(closeFailure);
},
);
const failing: AnalysisOperationPort = {
execute: (operation, parameters, options) =>
operation === "procedure_info"
? Promise.resolve(err(providerFailure))
: port.execute(operation, parameters, options),
};
const result = await run(new EnhancedTools(failing), directory());
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.cleanupPrimary).toBe(providerFailure);
const receipt = {
published_output: {
output_directory: directory(),
index_path: join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME),
},
};
expect(
projectAnalysisError(result.error).details?.diagnostics,
).toMatchObject(receipt);
expect(result.error.cleanupResources).toEqual(
expect.arrayContaining(["provider descriptor", "journal descriptor"]),
);
await journalLease.release();
expect(closeFailure.cleanupIncomplete).toBe(false);
expect(result.error.cleanupIncomplete).toBe(true);
await providerLease.release();
expect(providerCleanupFailure.cleanupIncomplete).toBe(false);
expect(providerFailure.cleanupIncomplete).toBe(false);
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
expect(
projectAnalysisError(result.error).details?.diagnostics,
).toMatchObject(receipt);
if (composed)
expect(
projectAnalysisError(result.error).details?.diagnostics,
).toMatchObject({ provider_fact: "preserved" });
},
);
});
describe("decompile_all publication cleanup", () => {
@@ -2084,6 +2144,15 @@ describe("decompile_all publication cleanup", () => {
message: "staging removal denied",
});
expect(result.error.cleanupIncomplete).toBe(false);
if (publication === "index")
expect(
projectAnalysisError(result.error).details?.diagnostics,
).toMatchObject({
published_output: {
output_directory: directory(),
index_path: destination,
},
});
},
);
});
@@ -1,3 +1,4 @@
import { ExportPublishedCleanupFailure } from "./exportResources.js";
import { join } from "node:path";
import { z } from "zod";
@@ -199,7 +200,17 @@ export const decompileAll = async (
...(closed.ok ? [] : [closed.error]),
];
const primary = failures[0];
const publishedIndex = indexed.ok
? join(session.root, DECOMPILE_DIRECTORY_INDEX_NAME)
: indexed.error.cause instanceof ExportPublishedCleanupFailure
? indexed.error.cause.publishedPath
: undefined;
const publishedOutput =
publishedIndex === undefined
? undefined
: { output_directory: session.root, index_path: publishedIndex };
if (primary !== undefined) {
let failure = primary;
if (
isAborted(signal) &&
!(
@@ -210,22 +221,30 @@ export const decompileAll = async (
primary.retainSecondaryFailure(cancelled(signal));
for (const failure of failures.slice(1))
primary.retainSecondaryFailure(failure);
return !closed.ok &&
if (
!closed.ok &&
primary !== closed.error &&
closed.error.cleanupIncomplete
? err(
analysisErrorWithCleanupFailure(
primary,
closed.error,
"decompile_all",
).bindCleanupDependencies([primary, closed.error]),
)
: err(primary);
) {
failure = analysisErrorWithCleanupFailure(
primary,
closed.error,
"decompile_all",
).bindCleanupDependencies([primary, closed.error]);
}
if (publishedOutput !== undefined)
failure.retainPrimaryDiagnostics({ published_output: publishedOutput });
return err(failure);
}
// Finalization succeeded, so committed files and the index are usable on
// resume, but a caller abort that stopped the loop or arrived meanwhile is
// not a success. Report it with the caller's own reason.
if (isAborted(signal)) return err(cancelled(signal));
if (isAborted(signal)) {
const failure = cancelled(signal);
if (publishedOutput !== undefined)
failure.retainPrimaryDiagnostics({ published_output: publishedOutput });
return err(failure);
}
const manifest = decompileDirectoryManifestSchema.safeParse(
manifestOf({
root: session.root,
@@ -5,7 +5,10 @@ import { expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { OwnedStagedOutput } from "../../filesystem/OwnedStagedOutput.js";
import { ExportResources } from "./exportResources.js";
import {
ExportPublishedCleanupFailure,
ExportResources,
} from "./exportResources.js";
it.each([false, true])(
"retains publication outcome and staging cleanup authority (conflict: %s)",
@@ -32,9 +35,16 @@ it.each([false, true])(
expect(failure.cleanup?.reason).toContain("staging removal denied");
expect(failure.cleanupLeases).toHaveLength(1);
if (conflict) {
expect(failure).not.toBeInstanceOf(ExportPublishedCleanupFailure);
expect(failure.message).toContain("EEXIST");
expect(failure.cause).toBeInstanceOf(ArtifactReaderFailure);
} else expect(failure.message).toBe("staging removal denied");
} else {
expect(failure).toBeInstanceOf(ExportPublishedCleanupFailure);
expect((failure as ExportPublishedCleanupFailure).publishedPath).toBe(
destination,
);
expect(failure.message).toBe("staging removal denied");
}
expect(await readFile(destination, "utf8")).toBe(
conflict ? "existing" : "new",
);
@@ -58,3 +68,42 @@ it.each([false, true])(
}
},
);
it("retains the source failure's parsed facts and cancellation identity", () => {
const reason = { request: "cancel after publication" };
const details = {
logicalPath: "source",
unpacked: false,
declaredSha256: null,
calculatedSha256: null,
} as const;
const admission = {
kind: "asar-header",
maximumBytes: 123,
source: "setting",
} as const;
const original = new ArtifactReaderFailure(
"cancelled",
"interrupted",
{
cause: reason,
cancellation: { reason },
userMessage: "Selected publication was interrupted.",
resolvedPath: "/resolved/source",
boundary: "capacity",
admission,
},
details,
);
const published = new ExportPublishedCleanupFailure(
"/output/index",
original,
);
expect(published.cancellation?.reason).toBe(reason);
expect(published.userMessage).toBe(original.userMessage);
expect(published.resolvedPath).toBe(original.resolvedPath);
expect(published.boundary).toBe(original.boundary);
expect(published.admission).toEqual(admission);
expect(published.details).toEqual(details);
expect(published.cause).toBe(original);
});
@@ -12,6 +12,7 @@ import { join } from "node:path";
import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
import { ArtifactFileCleanupFailure } from "../../artifacts/ArtifactFileOperation.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { RegularFileCleanupFailure } from "../../filesystem/OwnedRegularFile.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { DECOMPILE_DIRECTORY_JOURNAL_NAME } from "../../contracts/decompileDirectory.js";
@@ -28,6 +29,35 @@ import {
import type { OwnedLease } from "../../process/OwnedLease.js";
import type { RetainedResourceScope } from "../AnalysisProvider.js";
/** Publication committed before its staging cleanup failed. */
export class ExportPublishedCleanupFailure extends ArtifactReaderFailure {
constructor(
readonly publishedPath: string,
failure: ArtifactReaderFailure,
) {
super(
failure.reason,
failure.message,
{
cause: failure,
userMessage: failure.userMessage,
resolvedPath: failure.resolvedPath,
cancellation: failure.cancellation,
cleanup: failure.cleanup,
cleanupLeases: failure.cleanupLeases,
partialObservation: failure.partialObservation,
...(failure.boundary === undefined
? {}
: { boundary: failure.boundary }),
...(failure.admission === undefined
? {}
: { admission: failure.admission }),
},
failure.details,
);
}
}
/** Own every descriptor and staging area until cleanup, then release the directory lease. */
export class ExportResources {
readonly #files = new ArtifactResourceScope();
@@ -159,8 +189,12 @@ export class ExportResources {
outcome = { kind: "failed", cause };
}
const released = await this.#files.release(owner);
if (released.kind === "failed")
throw this.#files.cleanupFailure(outcome, [released]);
if (released.kind === "failed") {
const failure = this.#files.cleanupFailure(outcome, [released]);
throw outcome.kind === "completed"
? new ExportPublishedCleanupFailure(destination, failure)
: failure;
}
if (outcome.kind === "failed") throw outcome.cause;
}
+13
View File
@@ -310,6 +310,19 @@ export abstract class AnalysisError extends Error {
return this;
}
/** Retain facts on every primary that cleanup settlement can reveal. */
retainPrimaryDiagnostics(
diagnostics: Readonly<Record<string, JsonValue>>,
): this {
for (
let error: AnalysisError | undefined = this;
error !== undefined;
error = error.cleanupPrimary
)
error.retainDiagnostics(diagnostics);
return this;
}
/** Diagnostics recorded for a caller with this lifetime. */
diagnosticsFor(
callerLifetime: AnalysisCallerLifetime,
+1 -5
View File
@@ -11,7 +11,6 @@ import {
} from "../domain/analysisErrorProjection.js";
import {
AnalysisError,
primaryAnalysisError,
type AnalysisCapturedOutput,
type AnalysisErrorOptions,
} from "../domain/analysisErrorBase.js";
@@ -108,10 +107,7 @@ export const sqliteDatabaseFailure = (
members: cause.completedMembers.map((member) => ({ ...member })),
},
};
failure.retainDiagnostics(diagnostics);
// Keep the facts on the semantic error after its cleanup envelope resolves.
const primary = primaryAnalysisError(failure);
if (primary !== failure) primary.retainDiagnostics(diagnostics);
failure.retainPrimaryDiagnostics(diagnostics);
return failure;
}
if (cause instanceof AnalysisError) return cause;