mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(decompile): preserve provider cleanup through export finalization
This commit is contained in:
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",
|
||||
|
||||
@@ -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)");
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user