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:
1 parent
9e259f87c8
commit
7201e8a443
4 files changed
+129
-16
No files matched your search
@@ -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 = (
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user