From 7201e8a4439b3ece4a5470014525ea2e13e4c183 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:41:48 +0800 Subject: [PATCH] fix(native): validate LLDB readback encoding before reporting facts Decode completed journal rows strictly while retaining valid neighbors and torn-tail semantics. Preserve structured result read, decoding, JSON and schema failures through the typed fallback. Fixes #5055 --- src/native/LldbCallTracer.ts | 38 +++++++++++---- src/native/LldbRetainedObservations.ts | 15 +++--- .../native/lldbCallTracerFailures.test.ts | 48 +++++++++++++++++++ .../native/lldbRetainedObservations.test.ts | 44 +++++++++++++++++ 4 files changed, 129 insertions(+), 16 deletions(-) diff --git a/src/native/LldbCallTracer.ts b/src/native/LldbCallTracer.ts index bf1dcc5b83..2319d6a9af 100644 --- a/src/native/LldbCallTracer.ts +++ b/src/native/LldbCallTracer.ts @@ -497,10 +497,11 @@ export class LldbCallTracer implements NativeCallTracer { retained.processStopped = exited.cleanupOwner === undefined && exited.cleanupFailure === undefined; retained.targetStopped = !exited.targetLaunched; - const output = + const readback = exited.kind === "exited" ? await readTracerOutput(paths.result, retained.files) : undefined; + const output = readback?.ok ? readback.value : undefined; // An explicit bridge observation can prove no process was created or that // the created process exited. A missing PID alone remains ambiguous. const bridgeObservedExit = @@ -624,8 +625,21 @@ export class LldbCallTracer implements NativeCallTracer { if (output === undefined) return err( new ProviderAdapterError(PROVIDER, OPERATION, { + ...(readback !== undefined && !readback.ok + ? { cause: readback.error } + : {}), diagnostics: { - reason: "LLDB exited without a tracer result", + reason: "LLDB exited without a usable tracer result", + ...(readback !== undefined && !readback.ok + ? { + result_failure: + readback.error instanceof z.ZodError + ? `Tracer result failed schema validation (${readback.error.issues.length} issues)` + : readback.error instanceof Error + ? readback.error.message + : String(readback.error), + } + : {}), exit_code: exited.exitCode, lldb_output: exited.output, ...(lifecycleCleanup === undefined @@ -855,13 +869,21 @@ const lldbRuntimePaths = (directory: string) => ({ const readTracerOutput = async ( path: string, files: LldbReadbackFiles, -): Promise | undefined> => { - const text = await files.read(path, (file) => file.readFile("utf8")); - if (!text.ok) return undefined; - const parsed = safeParseJson(text.value); - if (!parsed.ok) return undefined; +): Promise, unknown>> => { + const bytes = await files.read(path, (file) => file.readFile()); + if (!bytes.ok) return bytes; + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes.value, + ); + } catch (cause: unknown) { + return err(cause); + } + const parsed = safeParseJson(text); + if (!parsed.ok) return err(parsed.cause); const output = tracerOutputSchema.safeParse(parsed.value); - return output.success ? output.data : undefined; + return output.success ? ok(output.data) : err(output.error); }; const capturedOutput = ( diff --git a/src/native/LldbRetainedObservations.ts b/src/native/LldbRetainedObservations.ts index 7253607bb3..d0e2ab1cc0 100644 --- a/src/native/LldbRetainedObservations.ts +++ b/src/native/LldbRetainedObservations.ts @@ -66,20 +66,19 @@ export const projectLldbPartialObservation = (input: { export const parseLldbObservationJournal = ( bytes: Uint8Array, ): ParsedLldbJournal => { - const completeText = Buffer.from(bytes).toString("utf8"); - const newline = completeText.lastIndexOf("\n"); - const hasTornTail = newline !== completeText.length - 1; + const hasTornTail = bytes.length > 0 && bytes[bytes.length - 1] !== 0x0a; const events: ParsedLldbJournal["events"] = []; const otherStops: string[] = []; const limitations: string[] = []; + const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); let malformedCount = 0; let firstMalformedRow: number | undefined; let start = 0; let rowNumber = 0; - while (start <= newline && newline >= 0) { - const end = completeText.indexOf("\n", start); - if (end < 0 || end > newline) break; - const line = completeText.slice(start, end); + while (start < bytes.length) { + const end = bytes.indexOf(0x0a, start); + if (end < 0) break; + const line = bytes.subarray(start, end); start = end + 1; if (line.length === 0) continue; rowNumber += 1; @@ -89,7 +88,7 @@ export const parseLldbObservationJournal = ( }; let row: unknown; try { - row = JSON.parse(line); + row = JSON.parse(decoder.decode(line)); } catch { markMalformed(); continue; diff --git a/tests/boundary/providers/native/lldbCallTracerFailures.test.ts b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts index 1489045307..8317af1aa5 100644 --- a/tests/boundary/providers/native/lldbCallTracerFailures.test.ts +++ b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts @@ -266,6 +266,54 @@ describe("LLDB completed trace retention through finalization", () => { }); describe("LLDB failure retention through the production tracer", () => { + it("treats an invalid UTF-8 tracer result as unavailable and retains journal facts", async () => { + let configPath: string | undefined; + const tracer = new LldbCallTracer( + {}, + async (_executable, arguments_) => { + const resolved = await configFromArguments(arguments_); + configPath = resolved.configPath; + await writeFile( + resolved.config.observation_path, + `${JSON.stringify({ kind: "event", event: EVENT })}\n`, + ); + await writeFile( + resolved.config.result_path, + Buffer.concat([ + Buffer.from('{"status":"tracer-error","error":"before'), + Buffer.from([0xff]), + Buffer.from('after"}'), + ]), + ); + return { + kind: "exited", + targetLaunched: false, + exitCode: 1, + output: "invalid UTF-8 tracer result", + targetIdentity: undefined, + cleanupFailure: undefined, + }; + }, + async () => ok(fakeTool), + ); + + const result = await tracer.trace(request); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected unavailable tracer result"); + expect(result.error).toMatchObject({ + _tag: "ProviderAdapterError", + cause: { code: "ERR_ENCODING_INVALID_ENCODED_DATA" }, + diagnostics: { reason: "LLDB exited without a usable tracer result" }, + partialObservation: { events: [EVENT] }, + }); + if (configPath === undefined) + throw new Error("LLDB fixture did not launch"); + await expect(access(dirname(configPath))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + it("waits for an active trace before client close removes its runtime root", async () => { const launchGate = createDeferred(); const launchStarted = createDeferred(); diff --git a/tests/boundary/providers/native/lldbRetainedObservations.test.ts b/tests/boundary/providers/native/lldbRetainedObservations.test.ts index 5c981a2a85..8da02d200b 100644 --- a/tests/boundary/providers/native/lldbRetainedObservations.test.ts +++ b/tests/boundary/providers/native/lldbRetainedObservations.test.ts @@ -93,6 +93,50 @@ describe("LLDB retained observations", () => { ); }); + it("rejects invalid UTF-8 rows while retaining valid neighbors and literal replacement characters", () => { + const before = Buffer.from(`${JSON.stringify({ kind: "event", event })}\n`); + const invalid = Buffer.concat([ + Buffer.from('{"kind":"other-stop","reason":"before'), + Buffer.from([0xff]), + Buffer.from('after"}\n'), + ]); + const literalReplacement = Buffer.from( + `${JSON.stringify({ kind: "other-stop", reason: "literal �" })}\n`, + ); + const after = Buffer.from( + `${JSON.stringify({ kind: "event", event: { ...event, sequence: 1 } })}\n`, + ); + const parsed = parseLldbObservationJournal( + Buffer.concat([before, invalid, literalReplacement, after]), + ); + + expect(parsed.events).toEqual([event, { ...event, sequence: 1 }]); + expect(parsed.otherStops).toEqual(["literal �"]); + expect(parsed.limitations).toContain( + "1 observation journal row(s) were malformed or invalid and ignored (first at row 2).", + ); + }); + + it("ignores a torn non-ASCII tail without discarding completed rows", () => { + const complete = Buffer.from( + `${JSON.stringify({ kind: "event", event })}\n`, + ); + const torn = Buffer.concat([ + Buffer.from('{"kind":"other-stop","reason":"'), + Buffer.from([0xe2, 0x82]), + ]); + const parsed = parseLldbObservationJournal(Buffer.concat([complete, torn])); + + expect(parsed.events).toEqual([event]); + expect(parsed.otherStops).toEqual([]); + expect(parsed.limitations).toContain( + "The final observation journal record was incomplete and was ignored.", + ); + expect(parsed.limitations).not.toContain( + expect.stringContaining("malformed or invalid"), + ); + }); + it("bounds malformed-row diagnostics for an 8 MiB journal", () => { const valid = JSON.stringify({ kind: "event", event }); const malformed = '{"kind":"unknown"}\n';