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
This commit is contained in:
morluto committed 2026-10-19 01:41:48 +08:00
1 parent 9e259f87c8
commit 7201e8a443
4 files changed
+129 -16

No files matched your search

+30 -8
View File
@@ -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<z.infer<typeof tracerOutputSchema> | 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<Result<z.infer<typeof tracerOutputSchema>, 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 = (
+7 -8
View File
@@ -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;
@@ -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<void>();
const launchStarted = createDeferred<void>();
@@ -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';