mirror of
https://github.com/morluto/rea
synced 2026-10-27 06:33:32 -04:00
3ad1032b11
Record the first transport failure before SDK teardown, then capture terminal stderr after owned retirement. Preserve exact opaque cancellation reasons and keep foreign cleanup ownership separate through retries. Validation: 119 focused tests across eight files; five selected baseline regressions; check:fast, docs:check, test discovery; nine real CLI and nine real MCP tiny-APK inspections. Native Windows/Linux startup failures and actual JVM OOM remain unverified. Closes #4844
474 lines
15 KiB
TypeScript
474 lines
15 KiB
TypeScript
import { RegularFileCleanupFailure } from "../../../src/filesystem/OwnedRegularFile.js";
|
|
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
|
|
import { StableFileHashFailure } from "../../../src/filesystem/StableFileHash.js";
|
|
import { z } from "zod";
|
|
import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErrorCore.js";
|
|
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
|
import { JadxSession } from "../../../src/android/JadxSession.js";
|
|
import { createJadxProtocolFixture } from "../../fixtures/android/jadx.js";
|
|
import { access, open } from "node:fs/promises";
|
|
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|
import { expect, it as test, vi } from "vitest";
|
|
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
|
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
|
import { createServer } from "../../../src/server/createServer.js";
|
|
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
|
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
|
import { createJadxStartupFixture as setup } from "../../fixtures/android/jadxStartup.js";
|
|
|
|
const it = test.skipIf(process.platform === "win32");
|
|
|
|
it.each(["exit", "process", "stdout", "protocol"])(
|
|
"retains %s failure and final diagnostics when cancellation arrives during cleanup",
|
|
async (kind) => {
|
|
const f = await setup();
|
|
const controller = new AbortController();
|
|
const outcome = f.service.execute(
|
|
"inspect_android_package",
|
|
{ path: f.apk },
|
|
{ signal: controller.signal },
|
|
);
|
|
await f.request;
|
|
const physical = new Error("controlled physical startup failure");
|
|
if (kind === "exit") f.process.exit();
|
|
else if (kind === "process") f.process.emit("error", physical);
|
|
else if (kind === "stdout") f.process.stdout.emit("error", physical);
|
|
else f.process.stdout.write("{malformed\n");
|
|
await f.cleanup;
|
|
controller.abort({ later: "caller cancellation" });
|
|
f.release();
|
|
const result = await outcome;
|
|
if (result.ok) throw new Error("Expected startup failure");
|
|
expect(result.error).toMatchObject({
|
|
_tag:
|
|
kind === "protocol"
|
|
? "AnalysisOutputError"
|
|
: "AnalysisCapabilityUnavailableError",
|
|
capturedOutput: {
|
|
stderr: "late startup diagnostic",
|
|
// stdout carries the failed protocol; the retained stderr closes completely.
|
|
truncated: false,
|
|
exit_code: 17,
|
|
},
|
|
});
|
|
if (kind === "process" || kind === "stdout")
|
|
expect(result.error.cause).toBe(physical);
|
|
expect(result.error.partialObservation).toMatchObject({
|
|
provider_id: "jadx",
|
|
operation: "inspect_android_package",
|
|
});
|
|
expect(projectAnalysisError(result.error).message).not.toContain(
|
|
"Select a full JDK",
|
|
);
|
|
await expect(access(f.workspace()!)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("keeps observed exit ahead of an abort before the SDK rejection is consumed", async () => {
|
|
const f = await setup();
|
|
const controller = new AbortController();
|
|
const outcome = f.service.execute(
|
|
"inspect_android_package",
|
|
{ path: f.apk },
|
|
{ signal: controller.signal },
|
|
);
|
|
await f.request;
|
|
f.process.exit();
|
|
controller.abort("after physical exit");
|
|
await f.cleanup;
|
|
f.release();
|
|
const result = await outcome;
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
|
});
|
|
});
|
|
|
|
const typedReason = () =>
|
|
new AnalysisResourceConstraintError(
|
|
"unrelated_operation",
|
|
"memory",
|
|
"caller-owned reason",
|
|
null,
|
|
);
|
|
const foreignCleanup = () =>
|
|
new ProviderCleanupError("foreign", ["/foreign/owned-resource"], {
|
|
reason: "foreign cleanup pending",
|
|
});
|
|
|
|
it.each([
|
|
null,
|
|
"caller stopped",
|
|
{ caller: "exact reason" },
|
|
typedReason(),
|
|
foreignCleanup(),
|
|
new z.ZodError([]),
|
|
new StableFileHashFailure(
|
|
"/foreign/source",
|
|
new Error("foreign hash failed"),
|
|
),
|
|
])(
|
|
"preserves first cancellation reason %j through later process errors and final output",
|
|
async (reason) => {
|
|
const f = await setup();
|
|
const controller = new AbortController();
|
|
const outcome = f.service.execute(
|
|
"inspect_android_package",
|
|
{ path: f.apk },
|
|
{ signal: controller.signal },
|
|
);
|
|
await f.request;
|
|
controller.abort(reason);
|
|
await f.cleanup;
|
|
f.process.stdin.emit("error", new Error("later teardown error"));
|
|
f.release();
|
|
const result = await outcome;
|
|
if (result.ok) throw new Error("Expected cancellation");
|
|
expect(result.error).toMatchObject({
|
|
_tag: "AnalysisCancelledError",
|
|
capturedOutput: { stderr: "late startup diagnostic" },
|
|
});
|
|
expect(result.error.cause).toBe(reason);
|
|
},
|
|
);
|
|
|
|
it("keeps a first deadline and its reason ahead of later caller cancellation", async () => {
|
|
const f = await setup();
|
|
const deadline = new AbortController();
|
|
const caller = new AbortController();
|
|
const reason = new Error("controlled first deadline");
|
|
const clock = vi
|
|
.spyOn(AbortSignal, "timeout")
|
|
.mockReturnValueOnce(deadline.signal);
|
|
try {
|
|
const outcome = f.service.execute(
|
|
"inspect_android_package",
|
|
{ path: f.apk },
|
|
{ signal: caller.signal },
|
|
);
|
|
await f.request;
|
|
deadline.abort(reason);
|
|
await f.cleanup;
|
|
caller.abort("later caller cancellation");
|
|
f.release();
|
|
const result = await outcome;
|
|
if (result.ok) throw new Error("Expected timeout");
|
|
expect(result.error).toMatchObject({
|
|
_tag: "AnalysisTimeoutError",
|
|
capturedOutput: { stderr: "late startup diagnostic" },
|
|
});
|
|
expect(result.error.cause).toBe(reason);
|
|
} finally {
|
|
clock.mockRestore();
|
|
}
|
|
});
|
|
|
|
it.each([null, "admission stopped", { caller: "admission" }])(
|
|
"preserves pre-admission cancellation reason %j",
|
|
async (reason) => {
|
|
const f = await setup();
|
|
const caller = new AbortController();
|
|
caller.abort(reason);
|
|
const result = await f.service.execute(
|
|
"inspect_android_package",
|
|
{ path: f.apk },
|
|
{ signal: caller.signal },
|
|
);
|
|
if (result.ok) throw new Error("Expected admission cancellation");
|
|
expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" });
|
|
expect(result.error.cause).toBe(reason);
|
|
expect(f.workspace()).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
it("retains an independently queued caller's exact cancellation reason", async () => {
|
|
const f = await setup();
|
|
const first = new AbortController();
|
|
const queued = new AbortController();
|
|
const active = f.service.execute(
|
|
"inspect_android_package",
|
|
{ path: f.apk },
|
|
{ signal: first.signal },
|
|
);
|
|
await f.request;
|
|
const target = await parseBinaryTarget(f.apk);
|
|
if (!target.ok) throw target.error;
|
|
const waiting = f.provider.execute(
|
|
target.value,
|
|
{ operation: "inspect_android_package", input: { path: f.apk } },
|
|
{ signal: queued.signal },
|
|
);
|
|
const reason = { caller: "queued owner" };
|
|
queued.abort(reason);
|
|
const result = await waiting;
|
|
if (result.ok) throw new Error("Expected queued cancellation");
|
|
expect(result.error.cause).toBe(reason);
|
|
first.abort("finish active fixture");
|
|
await f.cleanup;
|
|
f.release();
|
|
expect(await active).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError" },
|
|
});
|
|
});
|
|
|
|
it("preserves the frozen physical failure when the operation deadline elapses during cleanup", async () => {
|
|
const f = await setup();
|
|
const deadline = new AbortController();
|
|
const clock = vi
|
|
.spyOn(AbortSignal, "timeout")
|
|
.mockReturnValueOnce(deadline.signal);
|
|
try {
|
|
const outcome = f.service.execute("inspect_android_package", {
|
|
path: f.apk,
|
|
});
|
|
await f.request;
|
|
f.process.exit();
|
|
await f.cleanup;
|
|
deadline.abort(new Error("controlled deadline"));
|
|
f.release();
|
|
const result = await outcome;
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
capturedOutput: { stderr: "late startup diagnostic" },
|
|
},
|
|
});
|
|
} finally {
|
|
clock.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("retains the startup primary across cleanup failure and retry", async () => {
|
|
const f = await setup();
|
|
f.failCleanup(true);
|
|
const outcome = f.service.execute("inspect_android_package", { path: f.apk });
|
|
await f.request;
|
|
f.process.exit();
|
|
await f.cleanup;
|
|
f.process.stderr.write("available before failed cleanup");
|
|
f.release();
|
|
const result = await outcome;
|
|
if (result.ok) throw new Error("Expected retained cleanup failure");
|
|
const error = result.error;
|
|
expect(error.cleanupIncomplete).toBe(true);
|
|
expect(error.cleanupPrimary).toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
capturedOutput: {
|
|
stderr: "available before failed cleanup",
|
|
truncated: true,
|
|
},
|
|
});
|
|
expect(error.cleanupResources).toContain(f.workspace());
|
|
f.failCleanup(false);
|
|
await f.provider.close();
|
|
expect(projectAnalysisError(error)).toMatchObject({
|
|
code: "capability_unavailable",
|
|
details: {
|
|
captured_output: {
|
|
stderr: "available before failed cleanup",
|
|
truncated: true,
|
|
},
|
|
},
|
|
});
|
|
expect(error.cleanupResources).toEqual([]);
|
|
});
|
|
|
|
it("returns terminal startup diagnostics and partial identity through the MCP contract", async () => {
|
|
const f = await setup();
|
|
const session = createTestBinarySession(() => {
|
|
throw new Error("Unexpected native provider");
|
|
});
|
|
const server = createServer(
|
|
{ kind: "session", session },
|
|
{ androidAnalysis: f.provider },
|
|
);
|
|
const client = new Client({
|
|
name: "jadx-startup-finalization",
|
|
version: "1",
|
|
});
|
|
try {
|
|
const [local, remote] = InMemoryTransport.createLinkedPair();
|
|
await server.connect(remote);
|
|
await client.connect(local);
|
|
const pending = client.callTool({
|
|
name: "inspect_android_package",
|
|
arguments: { path: f.apk },
|
|
});
|
|
await f.request;
|
|
f.process.exit();
|
|
await f.cleanup;
|
|
f.release();
|
|
const response = await pending;
|
|
expect(parseMcpToolError(response).error).toMatchObject({
|
|
code: "capability_unavailable",
|
|
details: {
|
|
captured_output: {
|
|
stderr: "late startup diagnostic",
|
|
truncated: false,
|
|
exit_code: 17,
|
|
},
|
|
partial_observation: {
|
|
provider_id: "jadx",
|
|
target: { selected_path: f.apk },
|
|
},
|
|
},
|
|
});
|
|
} finally {
|
|
f.release();
|
|
await client.close();
|
|
await server.close();
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
it("does not adopt a typed caller reason's foreign cleanup across failed retirement and retry", async () => {
|
|
const f = await setup();
|
|
const caller = new AbortController();
|
|
const reason = foreignCleanup();
|
|
f.failCleanup(true);
|
|
const outcome = f.service.execute(
|
|
"inspect_android_package",
|
|
{ path: f.apk },
|
|
{ signal: caller.signal },
|
|
);
|
|
await f.request;
|
|
caller.abort(reason);
|
|
await f.cleanup;
|
|
f.release();
|
|
const result = await outcome;
|
|
if (result.ok) throw new Error("Expected cancellation with retained cleanup");
|
|
expect(result.error.cleanupPrimary).toMatchObject({
|
|
_tag: "AnalysisCancelledError",
|
|
cause: reason,
|
|
});
|
|
expect(result.error.cleanupResources).not.toContain(
|
|
"/foreign/owned-resource",
|
|
);
|
|
f.failCleanup(false);
|
|
await f.provider.close();
|
|
expect(projectAnalysisError(result.error).code).toBe("cancelled");
|
|
expect(reason.cleanupIncomplete).toBe(true);
|
|
expect(reason.cleanupResources).toEqual(["/foreign/owned-resource"]);
|
|
});
|
|
|
|
it("preserves an independently recorded typed physical failure sharing the later abort reason", async () => {
|
|
const f = await setup();
|
|
const caller = new AbortController();
|
|
const reason = typedReason();
|
|
const outcome = f.service.execute(
|
|
"inspect_android_package",
|
|
{ path: f.apk },
|
|
{ signal: caller.signal },
|
|
);
|
|
await f.request;
|
|
f.process.emit("error", reason);
|
|
caller.abort(reason);
|
|
await f.cleanup;
|
|
f.release();
|
|
const result = await outcome;
|
|
if (result.ok) throw new Error("Expected physical failure");
|
|
expect(result.error).toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
});
|
|
expect(result.error.cause).toBe(reason);
|
|
});
|
|
|
|
it("preserves typed cancellation at the completed-analysis handoff", async () => {
|
|
const f = await createJadxProtocolFixture();
|
|
const caller = new AbortController();
|
|
const reason = typedReason();
|
|
const execute = JadxSession.prototype.execute;
|
|
const checkpoint = vi
|
|
.spyOn(JadxSession.prototype, "execute")
|
|
.mockImplementationOnce(async function (this: JadxSession, context) {
|
|
const result = await execute.call(this, context);
|
|
caller.abort(reason);
|
|
return result;
|
|
});
|
|
try {
|
|
const result = await f.service.execute(
|
|
"inspect_android_package",
|
|
{ path: f.apk },
|
|
{ signal: caller.signal },
|
|
);
|
|
if (result.ok)
|
|
throw new Error("Expected cancellation at completion handoff");
|
|
expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" });
|
|
expect(result.error.cause).toBe(reason);
|
|
} finally {
|
|
checkpoint.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("normalizes a typed reason at the session's already-aborted checkpoint", async () => {
|
|
const f = await createJadxProtocolFixture();
|
|
const caller = new AbortController();
|
|
const reason = foreignCleanup();
|
|
const execute = JadxSession.prototype.execute;
|
|
const checkpoint = vi
|
|
.spyOn(JadxSession.prototype, "execute")
|
|
.mockImplementationOnce(async function (this: JadxSession, context) {
|
|
caller.abort(reason);
|
|
return execute.call(this, context);
|
|
});
|
|
try {
|
|
const result = await f.service.execute(
|
|
"inspect_android_package",
|
|
{ path: f.apk },
|
|
{ signal: caller.signal },
|
|
);
|
|
if (result.ok)
|
|
throw new Error("Expected cancellation before session acquisition");
|
|
expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" });
|
|
expect(result.error.cause).toBe(reason);
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
expect(f.launches).toEqual([]);
|
|
expect(reason.cleanupIncomplete).toBe(true);
|
|
expect(reason.cleanupResources).toEqual(["/foreign/owned-resource"]);
|
|
} finally {
|
|
checkpoint.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("keeps a foreign file-cleanup abort reason opaque at the session checkpoint", async () => {
|
|
const f = await createJadxProtocolFixture();
|
|
const caller = new AbortController();
|
|
const handle = await open(f.apk, "r");
|
|
const owner = new OwnedFileHandle(handle);
|
|
const reason = new RegularFileCleanupFailure(
|
|
f.apk,
|
|
owner,
|
|
{ kind: "failed", cause: new Error("foreign read failed") },
|
|
new Error("foreign close failed"),
|
|
);
|
|
const execute = JadxSession.prototype.execute;
|
|
const checkpoint = vi
|
|
.spyOn(JadxSession.prototype, "execute")
|
|
.mockImplementationOnce(async function (this: JadxSession, context) {
|
|
caller.abort(reason);
|
|
return execute.call(this, context);
|
|
});
|
|
try {
|
|
const result = await f.service.execute(
|
|
"inspect_android_package",
|
|
{ path: f.apk },
|
|
{ signal: caller.signal },
|
|
);
|
|
if (result.ok) throw new Error("Expected checkpoint cancellation");
|
|
expect(result.error).toMatchObject({
|
|
_tag: "AnalysisCancelledError",
|
|
cleanupIncomplete: false,
|
|
});
|
|
expect(result.error.cause).toBe(reason);
|
|
// The foreign owner is still open and under this test's authority.
|
|
expect((await handle.stat()).isFile()).toBe(true);
|
|
} finally {
|
|
checkpoint.mockRestore();
|
|
await owner.close();
|
|
}
|
|
});
|