diff --git a/docs/guides/native/native-investigation.md b/docs/guides/native/native-investigation.md index 8a4cd8ff47..b1f6b864d3 100644 --- a/docs/guides/native/native-investigation.md +++ b/docs/guides/native/native-investigation.md @@ -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 diff --git a/src/application/decompileDirectory/decompileAll.test.ts b/src/application/decompileDirectory/decompileAll.test.ts index 6e3d9263cd..f7527b34d7 100644 --- a/src/application/decompileDirectory/decompileAll.test.ts +++ b/src/application/decompileDirectory/decompileAll.test.ts @@ -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, + }, + }); }, ); }); diff --git a/src/application/decompileDirectory/decompileAll.ts b/src/application/decompileDirectory/decompileAll.ts index 4a8207aa03..98d9e23eee 100644 --- a/src/application/decompileDirectory/decompileAll.ts +++ b/src/application/decompileDirectory/decompileAll.ts @@ -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, diff --git a/src/application/decompileDirectory/exportResources.test.ts b/src/application/decompileDirectory/exportResources.test.ts index 36e83e7763..ba9fc91a02 100644 --- a/src/application/decompileDirectory/exportResources.test.ts +++ b/src/application/decompileDirectory/exportResources.test.ts @@ -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); +}); diff --git a/src/application/decompileDirectory/exportResources.ts b/src/application/decompileDirectory/exportResources.ts index 01c41fb28c..731312b5b4 100644 --- a/src/application/decompileDirectory/exportResources.ts +++ b/src/application/decompileDirectory/exportResources.ts @@ -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; } diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 9e3cb3495d..61aa0c2ec3 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -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>, + ): 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, diff --git a/src/sqlite/SqliteDatabaseFailures.ts b/src/sqlite/SqliteDatabaseFailures.ts index 0bf8620e58..da6220b6e4 100644 --- a/src/sqlite/SqliteDatabaseFailures.ts +++ b/src/sqlite/SqliteDatabaseFailures.ts @@ -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;