From 1d0baa352fc76d1760abbe29a3cc80fa17296916 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:29:32 +0800 Subject: [PATCH] fix(native): preserve bridge failure types during cleanup --- src/domain/native/nativeCallObservation.ts | 7 +- .../native/nativeCallPartialObservation.ts | 17 +-- src/native/LldbCallTracer.ts | 19 ++- .../native/lldbCallTracerFailures.test.ts | 143 ++++++++++++++++++ 4 files changed, 165 insertions(+), 21 deletions(-) create mode 100644 tests/boundary/providers/native/lldbCallTracerFailures.test.ts diff --git a/src/domain/native/nativeCallObservation.ts b/src/domain/native/nativeCallObservation.ts index a7948b4e63..b07a5a0f12 100644 --- a/src/domain/native/nativeCallObservation.ts +++ b/src/domain/native/nativeCallObservation.ts @@ -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()), }), diff --git a/src/domain/native/nativeCallPartialObservation.ts b/src/domain/native/nativeCallPartialObservation.ts index 9453394143..904113dc53 100644 --- a/src/domain/native/nativeCallPartialObservation.ts +++ b/src/domain/native/nativeCallPartialObservation.ts @@ -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() }), diff --git a/src/native/LldbCallTracer.ts b/src/native/LldbCallTracer.ts index d986d8eeaf..e08bb8facc 100644 --- a/src/native/LldbCallTracer.ts +++ b/src/native/LldbCallTracer.ts @@ -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[0], signal?: AbortSignal, ): Promise> { - 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: diff --git a/tests/boundary/providers/native/lldbCallTracerFailures.test.ts b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts new file mode 100644 index 0000000000..aa8a932fdb --- /dev/null +++ b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts @@ -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[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" }, + }); + }); +});