mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 16:26:36 +00:00
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:
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user