fix(decompile): preserve provider cleanup through export finalization

This commit is contained in:
morluto committed 2026-10-27 07:34:22 +08:00
1 parent af9bc3ab50
commit 381004b664
3 files changed
+232 -6

No files matched your search

@@ -9,6 +9,7 @@ import {
type AnalysisOperation,
type AnalysisOperationPort,
} from "../AnalysisProvider.js";
import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
import { EnhancedTools } from "../EnhancedTools.js";
import { DecompileExport } from "./exportTree.js";
import {
@@ -24,6 +25,7 @@ import {
decompileJournalLineSchema,
} from "../../contracts/decompileDirectory.js";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
import { createEvidence } from "../../domain/evidence.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
@@ -1704,3 +1706,163 @@ describe("decompile_all repeated output failures", () => {
});
});
});
describe("decompile_all provider cleanup ownership", () => {
const directory = useOutputDirectory();
afterEach(() => vi.restoreAllMocks());
it.each([
["procedure_info", false],
["procedure_info", true],
["procedure_pseudo_code", false],
["procedure_pseudo_code", true],
] as const)(
"retains %s cleanup and committed work with caller aborted=%s",
async (operation, abort) => {
const { port } = harness();
const controller = new AbortController();
const cause = new Error("provider cleanup stopped");
const callerReason = new Error("caller stopped");
const partial = createEvidence(subject, provider, {
operation,
parameters: { procedure: "0x2000" },
result:
operation === "procedure_info"
? infoFor("0x2000")
: "int partial(void) {}",
analysisProfile: profile,
confidence: "observed",
});
const failure = new AnalysisCancelledError(operation, {
cause,
partialObservation: partial,
capturedOutput: {
stdout: "retained provider output",
stderr: "",
truncated: false,
},
cleanup: {
reason: "descriptor close denied",
resources: ["provider descriptor"],
},
});
const lease = new AnalysisCleanupLease(
async () => undefined,
["provider descriptor"],
);
lease.bindCleanupError(failure);
const observed: AnalysisOperation[] = [];
const failing: AnalysisOperationPort = {
execute: (selected, parameters, options) => {
observed.push(selected);
if (selected === operation && parameters.procedure === "0x2000") {
if (abort) controller.abort(callerReason);
return Promise.resolve(err(failure));
}
return port.execute(selected, parameters, options);
},
};
const result = await run(
new EnhancedTools(failing),
directory(),
{},
controller.signal,
);
expect(result).toEqual(err(failure));
expect(failure.cause).toBe(cause);
if (abort) {
expect(failure.secondaryFailures).toHaveLength(1);
expect(failure.secondaryFailures[0]).toBeInstanceOf(
AnalysisCancelledError,
);
expect(failure.secondaryFailures[0]?.cause).toBe(callerReason);
} else expect(failure.secondaryFailures).toHaveLength(0);
expect(failure.partialObservation).toBe(partial);
expect(failure.capturedOutput?.stdout).toBe("retained provider output");
expect(failure.cleanupIncomplete).toBe(true);
expect(observed.at(-1)).toBe(operation);
const index = decompileDirectoryIndexSchema.parse(
JSON.parse(
await readFile(
join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME),
"utf8",
),
),
);
expect(index.functions).toHaveLength(1);
expect(index.functions[0]).toMatchObject({
address: "0x1000",
status: "written",
});
await expect(
readFile(join(directory(), "functions", "0x1000.c"), "utf8"),
).resolves.toContain(sentinel);
await lease.release();
expect(failure.cleanupIncomplete).toBe(false);
},
);
});
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"],
},
},
);
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);
});
});
@@ -173,6 +173,7 @@ export const decompileAll = async (
const callerStoppedPublish =
!published.ok &&
published.error instanceof AnalysisCancelledError &&
!published.error.cleanupIncomplete &&
isAborted(signal);
const failures = [
...(!published.ok && !callerStoppedPublish ? [published.error] : []),
@@ -181,6 +182,14 @@ export const decompileAll = async (
];
const primary = failures[0];
if (primary !== undefined) {
if (
isAborted(signal) &&
!(
primary instanceof AnalysisCancelledError &&
primary.cause === signal?.reason
)
)
primary.retainSecondaryFailure(cancelled(signal));
for (const failure of failures.slice(1))
primary.retainSecondaryFailure(failure);
return !closed.ok &&
@@ -191,7 +200,7 @@ export const decompileAll = async (
primary,
closed.error,
"decompile_all",
).bindCleanupDependencies([closed.error]),
).bindCleanupDependencies([primary, closed.error]),
)
: err(primary);
}
@@ -465,7 +474,7 @@ const publishOne = async (input: {
input.signal,
input.bodySupport,
);
if (body.kind === "cancelled")
if (body.kind === "error")
return { result: err(body.error), bodySupport: input.bodySupport };
if (body.kind === "unavailable") input.state.bodyUnavailable = true;
// Caveats of the observations this attempt used. The journal record keeps
@@ -489,6 +498,9 @@ const publishOne = async (input: {
{ procedure: input.procedure.address },
executionOptions(input.signal),
);
// A provider still owns cleanup even if the caller aborted meanwhile.
if (!decompiled.ok && decompiled.error.cleanupIncomplete)
return { result: decompiled, bodySupport: body.support };
if (isAborted(input.signal))
return { result: err(cancelled(input.signal)), bodySupport: body.support };
if (!decompiled.ok) {
@@ -613,7 +625,7 @@ const readBody = async (
readonly fact: FunctionBody;
readonly support: "unavailable";
}
| { readonly kind: "cancelled"; readonly error: AnalysisCancelledError }
| { readonly kind: "error"; readonly error: AnalysisError }
> => {
if (support === "unavailable")
return {
@@ -627,8 +639,11 @@ const readBody = async (
executionOptions(signal),
);
if (!observed.ok) {
if (observed.error instanceof AnalysisCancelledError)
return { kind: "cancelled", error: observed.error };
if (
observed.error.cleanupIncomplete ||
observed.error instanceof AnalysisCancelledError
)
return { kind: "error", error: observed.error };
if (observed.error instanceof AnalysisCapabilityUnavailableError)
return {
kind: "unavailable",
+50 -1
View File
@@ -9,7 +9,11 @@ import {
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { err, ok } from "../../../src/domain/result.js";
import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js";
import {
AnalysisOutputError,
AnalysisInputError,
} from "../../../src/domain/analysisErrorCore.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import {
decompileDirectoryIndexSchema,
decompileDirectoryManifestSchema,
@@ -144,3 +148,48 @@ it("keeps long-name failures inside the tool window and complete in the index",
index.functions.every((record) => record.limitations?.includes(caveat)),
).toBe(true);
});
it.each(["procedure_info", "procedure_pseudo_code"] as const)(
"reports %s cleanup failure through MCP while retaining completed files",
async (operation) => {
const parent = await createTestTempDirectory("rea-decompile-cleanup-");
const output = join(parent, "out");
const failure = new AnalysisOutputError(
operation,
"provider close failed",
{
cleanup: {
reason: "provider close denied",
resources: ["provider descriptor"],
},
},
);
const port: AnalysisOperationPort = {
execute: (selected, parameters, options) =>
selected === operation && parameters.procedure === addresses[1]
? Promise.resolve(err(failure))
: addressCaveats.execute(selected, parameters, options),
};
const client = await connect(port);
const result = await client.callTool({
name: "decompile_all",
arguments: { output_directory: output },
});
expect(result.isError).toBe(true);
expect(parseMcpToolError(result).error).toMatchObject({
code: "cleanup_incomplete",
details: { cleanup: "incomplete" },
});
const index = decompileDirectoryIndexSchema.parse(
JSON.parse(await readFile(join(output, "decompile-index.json"), "utf8")),
);
expect(index.functions).toHaveLength(1);
expect(index.functions[0]).toMatchObject({
address: addresses[0],
status: "written",
});
await expect(
readFile(join(output, "functions", `${addresses[0]}.c`), "utf8"),
).resolves.toContain("int f(void)");
},
);