fix(native): preserve bridge failure types during cleanup

This commit is contained in:
morluto committed 2026-10-09 01:55:12 +08:00
1 parent 7c323870c2
commit 1d0baa352f
4 files changed
+165 -21

No files matched your search

+4 -3
View File
@@ -127,7 +127,8 @@ export const nativeCallEventSchema = z.strictObject({
backtrace: z.array(nativeCodeLocationSchema),
});
const capturedOutputSchema = z.strictObject({
/** Retained native output with explicit stream completeness and byte counts. */
export const nativeCapturedOutputSchema = z.strictObject({
text: z.string(),
/** Bytes observed by the bridge, not a total when complete is false. */
bytes: z.number().int().nonnegative(),
@@ -177,8 +178,8 @@ export const nativeCallObservationResultSchema = z.strictObject({
/** REA confirmed the process is gone when observation ended. */
terminated: z.boolean(),
elapsed_ms: z.number().nonnegative(),
stdout: capturedOutputSchema,
stderr: capturedOutputSchema,
stdout: nativeCapturedOutputSchema,
stderr: nativeCapturedOutputSchema,
/** Signal and exception stops LLDB reported; the process continued past each. */
other_stops: z.array(z.string()),
}),
@@ -1,13 +1,8 @@
import { z } from "zod";
import { nativeCallEventSchema } from "./nativeCallObservation.js";
const capturedOutputSchema = z.strictObject({
text: z.string(),
/** Bytes observed so far; the final stream size may be unknown. */
bytes: z.number().int().nonnegative(),
truncated: z.boolean(),
complete: z.boolean(),
});
import {
nativeCallEventSchema,
nativeCapturedOutputSchema,
} from "./nativeCallObservation.js";
/** Native call evidence retained when observation fails before a full result. */
export const nativeCallPartialObservationSchema = z.strictObject({
@@ -22,8 +17,8 @@ export const nativeCallPartialObservationSchema = z.strictObject({
}),
process: z.strictObject({
pid: z.number().int().positive().nullable(),
stdout: capturedOutputSchema.nullable(),
stderr: capturedOutputSchema.nullable(),
stdout: nativeCapturedOutputSchema.nullable(),
stderr: nativeCapturedOutputSchema.nullable(),
other_stops: z.array(z.string()),
}),
debugger: z.strictObject({ version: z.string().nullable() }),
+12 -7
View File
@@ -163,11 +163,16 @@ const ATTACH_REMEDIATION =
/** Production tracer: `lldb --batch` running the REA LLDB bridge. */
export class LldbCallTracer implements NativeCallTracer {
constructor(
private readonly launch: typeof runLldb = runLldb,
private readonly resolveTool: typeof resolveXcrunTool = resolveXcrunTool,
) {}
async trace(
request: Parameters<NativeCallTracer["trace"]>[0],
signal?: AbortSignal,
): Promise<Result<NativeCallTrace, AnalysisError>> {
const lldb = await resolveXcrunTool("lldb", signal);
const lldb = await this.resolveTool("lldb", signal);
if (!lldb.ok)
return err(
lldb.error.reason === "cancelled"
@@ -235,7 +240,7 @@ export class LldbCallTracer implements NativeCallTracer {
{ mode: 0o600 },
);
await prepareProcessOwnershipInspection(signal);
const exited = await runLldb(
const exited = await this.launch(
lldb.path,
[
"--batch",
@@ -333,6 +338,10 @@ export class LldbCallTracer implements NativeCallTracer {
},
),
);
if (output !== undefined && output.status !== "traced")
return err(
tracerFailure(output, lifecycleCleanup, await getPartialObservation()),
);
if (exited.cleanupFailure !== undefined)
return err(
new ProviderAdapterError(PROVIDER, OPERATION, {
@@ -367,11 +376,6 @@ export class LldbCallTracer implements NativeCallTracer {
: { cleanup: lifecycleCleanup }),
}),
);
if (output.status !== "traced") {
return err(
tracerFailure(output, lifecycleCleanup, await getPartialObservation()),
);
}
const stdout = await capturedOutput(
paths.stdoutCapture,
output.target_output.stdout_bytes,
@@ -417,6 +421,7 @@ export class LldbCallTracer implements NativeCallTracer {
const quote = (value: string): string =>
`"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
/** Preserve a decoded bridge failure's type independently of cleanup outcome. */
const tracerFailure = (
output: {
readonly status:
@@ -0,0 +1,143 @@
import { access, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js";
import { EvidenceIntegrityError } from "../../../../src/domain/evidenceErrors.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { nativeCallObservationInputSchema } from "../../../../src/domain/native/nativeCallObservation.js";
import type { NativeCallTracer } from "../../../../src/native/LldbCallTracer.js";
import { LldbCallTracer } from "../../../../src/native/LldbCallTracer.js";
import { ok } from "../../../../src/domain/result.js";
const EVENT = {
sequence: 0,
elapsed_ms: 1,
thread_id: 2,
breakpoint_index: 0,
load_address: "0x1000",
file_address: "0x1000",
module: "fixture",
module_path: "/tmp/fixture",
symbol: "main",
receiver_class: null,
selector: null,
registers: [],
backtrace: [],
};
const request: Parameters<NativeCallTracer["trace"]>[0] = {
executable: "/tmp/fixture",
architecture: "arm64",
expectedSha256: "a".repeat(64),
input: nativeCallObservationInputSchema.parse({
breakpoints: [{ kind: "function", name: "main" }],
arguments: ["--fixture"],
environment: { CASE: "retained" },
}),
};
const fakeTool = { path: "/usr/bin/lldb", sha256: "b".repeat(64) };
const configSchema = z.object({
result_path: z.string(),
observation_path: z.string(),
stdout_capture_path: z.string(),
});
const configFromArguments = async (arguments_: readonly string[]) => {
const command = arguments_.at(-1);
const match =
command === undefined ? null : /^rea_trace (.+)$/u.exec(command);
if (match?.[1] === undefined)
throw new Error("fake LLDB launcher did not receive a tracer config path");
const raw: unknown = JSON.parse(await readFile(match[1], "utf8"));
return { config: configSchema.parse(raw), configPath: match[1] };
};
const fixtureTracer = (
outcome: "cancelled" | "integrity-error-cleanup-failure",
) => {
let configPath: string | undefined;
const tracer = new LldbCallTracer(
async (_executable, arguments_) => {
const resolved = await configFromArguments(arguments_);
const { config } = resolved;
configPath = resolved.configPath;
await writeFile(
config.observation_path,
`${JSON.stringify({ kind: "event", event: EVENT })}\n`,
);
await writeFile(config.stdout_capture_path, "captured prefix");
if (outcome === "integrity-error-cleanup-failure")
await writeFile(
config.result_path,
JSON.stringify({
status: "target-integrity-error",
error: "launched target digest changed",
}),
);
return outcome === "cancelled"
? {
kind: "cancelled",
targetIdentity: undefined,
cleanupFailure: undefined,
}
: {
kind: "exited",
exitCode: 1,
output: "bridge reported target integrity failure",
targetIdentity: undefined,
cleanupFailure: "LLDB process-group cleanup could not be verified",
};
},
async () => ok(fakeTool),
);
return {
tracer,
runtimeRoot: async () => {
if (configPath === undefined)
throw new Error("fake LLDB launcher was not called");
const root = dirname(configPath);
await expect(access(root)).rejects.toThrow();
},
};
};
describe("LLDB failure retention through the production tracer", () => {
it("preserves cancellation and journal/output evidence after runtime cleanup", async () => {
const fixture = fixtureTracer("cancelled");
const result = await fixture.tracer.trace(request);
await fixture.runtimeRoot();
if (result.ok) throw new Error("expected cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.partialObservation).toMatchObject({
coverage: { status: "partial", reason: "cancelled" },
events: [EVENT],
process: { stdout: { text: "captured prefix", complete: false } },
target: {
arguments: ["--fixture"],
environment: { CASE: "retained" },
working_directory: null,
},
});
});
it("preserves bridge integrity failure type when process-group cleanup also fails", async () => {
const fixture = fixtureTracer("integrity-error-cleanup-failure");
const result = await fixture.tracer.trace(request);
await fixture.runtimeRoot();
if (result.ok) throw new Error("expected integrity failure");
expect(result.error).toBeInstanceOf(EvidenceIntegrityError);
expect(result.error.partialObservation).toMatchObject({
coverage: { status: "partial", reason: "cleanup-failure" },
events: [EVENT],
process: { stdout: { text: "captured prefix", complete: false } },
});
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cleanup_incomplete",
details: { execution_failure: "evidence_integrity_mismatch" },
});
});
});