fix(native): retain LLDB evidence prefixes after read failures

Return byte prefixes with tagged read outcomes and exact causes. Share those facts across partial journals and captures, preserve typed primary failures and successful siblings, and keep zero-byte failures unavailable. Retain failed descriptor and root owners through retry.

Fixes #5058
This commit is contained in:
morluto committed 2026-10-19 02:06:07 +08:00
1 parent d4e233946a
commit eb2c3b589e
5 files changed
+554 -198

No files matched your search

+183 -105
View File
@@ -143,6 +143,14 @@ export interface CapturedOutput {
readonly complete: boolean;
}
type CapturedOutputRead =
| { readonly ok: true; readonly value: CapturedOutput }
| {
readonly ok: false;
readonly error: unknown;
readonly partial?: CapturedOutput;
};
/** One completed LLDB observation, before projection into the result contract. */
export interface NativeCallTrace {
readonly debugger: ResolvedTool & { readonly version: string | null };
@@ -231,7 +239,7 @@ export class LldbCallTracer implements NativeCallTracer {
try {
result = await this.#run(lldb.value, owner, request, signal);
} catch (cause: unknown) {
const partialObservation = await partialNativeObservation({
const partial = await partialNativeObservation({
request,
files: owner.files,
pidPath: paths.pid,
@@ -247,21 +255,20 @@ export class LldbCallTracer implements NativeCallTracer {
owner.files.hasPending
)
this.#pendingCleanup = owner;
result = err(
new ProviderAdapterError(PROVIDER, OPERATION, {
cause,
diagnostics: { reason: errorReason(cause) },
partialObservation,
...(this.#pendingCleanup === owner
? {
cleanup: {
reason: "LLDB trace failed while owned resources remain",
resources: lldbCleanupResources(owner),
},
}
: {}),
}),
);
const failure = new ProviderAdapterError(PROVIDER, OPERATION, {
cause,
diagnostics: { reason: errorReason(cause) },
partialObservation: partial.observation,
...(this.#pendingCleanup === owner
? {
cleanup: {
reason: "LLDB trace failed while owned resources remain",
resources: lldbCleanupResources(owner),
},
}
: {}),
});
result = err(retainPartialReadFailures(failure, partial.readFailures));
}
if (result.ok && signal?.aborted)
result = err(
@@ -549,7 +556,7 @@ export class LldbCallTracer implements NativeCallTracer {
: "tracer-failure";
const getPartialObservation = (
reason: NativeCallPartialObservation["coverage"]["reason"] = partialReason,
): Promise<NativeCallPartialObservation> =>
): Promise<PartialNativeObservationRead> =>
partialNativeObservation({
request,
files: retained.files,
@@ -560,97 +567,115 @@ export class LldbCallTracer implements NativeCallTracer {
version: output?.status === "traced" ? output.version : null,
reason,
});
if (exited.kind === "cancelled")
const failureWithPartial = async <Failure extends AnalysisError>(
createFailure: (observation: NativeCallPartialObservation) => Failure,
reason?: NativeCallPartialObservation["coverage"]["reason"],
): Promise<Result<NativeCallTrace, AnalysisError>> => {
const partial = await getPartialObservation(reason);
return err(
new AnalysisCancelledError(OPERATION, {
cause: signal?.reason,
...(lifecycleCleanup === undefined
? {}
: { cleanup: lifecycleCleanup }),
partialObservation: await getPartialObservation(),
}),
retainPartialReadFailures(
createFailure(partial.observation),
partial.readFailures,
),
);
if (exited.kind === "timeout")
return err(
new AnalysisTimeoutError(
OPERATION,
input.duration_ms + DEADLINE_GRACE_MS,
{
};
if (exited.kind === "cancelled")
return failureWithPartial(
(partialObservation) =>
new AnalysisCancelledError(OPERATION, {
cause: signal?.reason,
...(lifecycleCleanup === undefined
? {}
: { cleanup: lifecycleCleanup }),
partialObservation: await getPartialObservation(),
},
),
partialObservation,
}),
);
if (exited.kind === "timeout")
return failureWithPartial(
(partialObservation) =>
new AnalysisTimeoutError(
OPERATION,
input.duration_ms + DEADLINE_GRACE_MS,
{
...(lifecycleCleanup === undefined
? {}
: { cleanup: lifecycleCleanup }),
partialObservation,
},
),
);
if (output !== undefined && output.status !== "traced")
return err(
tracerFailure(output, lifecycleCleanup, await getPartialObservation()),
return failureWithPartial((partialObservation) =>
tracerFailure(output, lifecycleCleanup, partialObservation),
);
if (exited.cleanupFailure !== undefined)
return err(
new ProviderCleanupError(
PROVIDER,
lldbCleanupResources(retained),
{
reason: "LLDB process-group cleanup could not be verified",
cleanup_failure: exited.cleanupFailure,
...(lifecycleCleanup === undefined
? {}
: { target_cleanup: lifecycleCleanup }),
},
{
operation: OPERATION,
partialObservation: await getPartialObservation(),
},
),
const processCleanupFailure = exited.cleanupFailure;
if (processCleanupFailure !== undefined)
return failureWithPartial(
(partialObservation) =>
new ProviderCleanupError(
PROVIDER,
lldbCleanupResources(retained),
{
reason: "LLDB process-group cleanup could not be verified",
cleanup_failure: processCleanupFailure,
...(lifecycleCleanup === undefined
? {}
: { target_cleanup: lifecycleCleanup }),
},
{
operation: OPERATION,
partialObservation,
},
),
);
if (!terminated)
return err(
new ProviderCleanupError(
PROVIDER,
lldbCleanupResources(retained),
{
reason: "LLDB target process termination could not be verified",
...(lifecycleCleanup === undefined
? {}
: { target_cleanup: lifecycleCleanup }),
},
{
operation: OPERATION,
partialObservation: await getPartialObservation(),
},
),
return failureWithPartial(
(partialObservation) =>
new ProviderCleanupError(
PROVIDER,
lldbCleanupResources(retained),
{
reason: "LLDB target process termination could not be verified",
...(lifecycleCleanup === undefined
? {}
: { target_cleanup: lifecycleCleanup }),
},
{
operation: OPERATION,
partialObservation,
},
),
);
if (output === undefined)
return err(
new ProviderAdapterError(PROVIDER, OPERATION, {
...(readback !== undefined && !readback.ok
? { cause: readback.error }
: {}),
diagnostics: {
reason: "LLDB exited without a usable tracer result",
return failureWithPartial(
(partialObservation) =>
new ProviderAdapterError(PROVIDER, OPERATION, {
...(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),
}
? { cause: readback.error }
: {}),
exit_code: exited.exitCode,
lldb_output: exited.output,
diagnostics: {
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
? {}
: { target_cleanup: lifecycleCleanup }),
},
partialObservation,
...(lifecycleCleanup === undefined
? {}
: { target_cleanup: lifecycleCleanup }),
},
partialObservation: await getPartialObservation(),
...(lifecycleCleanup === undefined
? {}
: { cleanup: lifecycleCleanup }),
}),
: { cleanup: lifecycleCleanup }),
}),
);
const stdout = await capturedOutput(
paths.stdoutCapture,
@@ -686,8 +711,8 @@ export class LldbCallTracer implements NativeCallTracer {
{
debugger: { ...lldb, version: output.version },
run: output,
stdout: stdout.ok ? stdout.value : null,
stderr: stderr.ok ? stderr.value : null,
stdout: stdout.ok ? stdout.value : (stdout.partial ?? null),
stderr: stderr.ok ? stderr.value : (stderr.partial ?? null),
terminated,
},
"capture-failure",
@@ -784,6 +809,31 @@ const tracerFailure = (
);
};
interface PartialNativeObservationRead {
readonly observation: NativeCallPartialObservation;
readonly readFailures: readonly unknown[];
}
const retainPartialReadFailures = <Failure extends AnalysisError>(
failure: Failure,
readFailures: readonly unknown[],
): Failure => {
if (readFailures.length === 0) return failure;
const readbackFailure = new ProviderAdapterError(PROVIDER, OPERATION, {
cause:
readFailures.length === 1
? readFailures[0]
: new AggregateError(readFailures, "LLDB partial readback failed"),
diagnostics: {
reason: "LLDB could not read all available partial observation data",
read_failures: readFailures.map(errorReason),
},
});
return failure.retainSecondaryFailure(readbackFailure, {
partial_readback_failure: projectAnalysisError(readbackFailure),
});
};
const partialNativeObservation = async (options: {
readonly request: Parameters<NativeCallTracer["trace"]>[0];
readonly files: LldbReadbackFiles;
@@ -793,14 +843,14 @@ const partialNativeObservation = async (options: {
readonly observationPath: string;
readonly version: string | null;
readonly reason: NativeCallPartialObservation["coverage"]["reason"];
}): Promise<NativeCallPartialObservation> => {
}): Promise<PartialNativeObservationRead> => {
const [journal, pid, stdout, stderr] = await Promise.all([
readLldbObservationJournal(options.observationPath, options.files),
options.files.readTargetPid(options.pidPath),
readPartialCapture(options.stdoutPath, options.files),
readPartialCapture(options.stderrPath, options.files),
]);
return projectLldbPartialObservation({
const observation = projectLldbPartialObservation({
target: {
path: options.request.executable,
sha256: options.request.expectedSha256,
@@ -835,6 +885,14 @@ const partialNativeObservation = async (options: {
],
reason: options.reason,
});
const readFailures = [journal, stdout, stderr].flatMap((read) =>
!read.ok
? [read.error]
: read.value.status === "read-failure"
? [read.value.readFailure]
: [],
);
return { observation, readFailures };
};
const partialObservationFromTrace = (
@@ -886,28 +944,48 @@ const readTracerOutput = async (
return output.success ? ok(output.data) : err(output.error);
};
const capturedOutput = (
const capturedOutput = async (
path: string,
observedBytes: number,
truncated: boolean,
complete: boolean,
files: LldbReadbackFiles,
): Promise<Result<CapturedOutput, unknown>> =>
files.read(path, async (handle) => {
): Promise<CapturedOutputRead> => {
const readback = await files.read(path, async (handle) => {
const { size } = await handle.stat();
const { buffer, bytesRead } = await readBoundedPrefix(
const read = await readBoundedPrefix(
Math.min(size, MAX_OUTPUT_BYTES),
async (target, offset, length, position) =>
(await handle.read(target, offset, length, position)).bytesRead,
);
const { buffer, bytesRead } = read;
if (read.status === "read-failure" && bytesRead === 0) throw read.cause;
return {
text: buffer.subarray(0, bytesRead).toString("utf8"),
bytes: observedBytes,
truncated: truncated || size > MAX_OUTPUT_BYTES,
complete:
complete && bytesRead >= Math.min(observedBytes, MAX_OUTPUT_BYTES),
status: read.status,
capture: {
text: buffer.subarray(0, bytesRead).toString("utf8"),
bytes: observedBytes,
truncated:
truncated ||
size > MAX_OUTPUT_BYTES ||
read.status === "read-failure",
complete:
read.status !== "read-failure" &&
complete &&
bytesRead >= Math.min(observedBytes, MAX_OUTPUT_BYTES),
},
...(read.status === "read-failure" ? { readFailure: read.cause } : {}),
};
});
if (!readback.ok) return readback;
if (readback.value.status === "read-failure")
return {
ok: false,
error: readback.value.readFailure,
partial: readback.value.capture,
};
return ok(readback.value.capture);
};
interface RetainedLldbResources {
readonly root: PrivateRuntimeRoot;
+85 -23
View File
@@ -15,13 +15,47 @@ export interface ParsedLldbJournal {
readonly limitations: string[];
}
export interface PartialCaptureRead {
interface LldbObservationJournalValue {
readonly parsed: ParsedLldbJournal;
readonly limitations: readonly string[];
}
export type LldbObservationJournalRead =
| (LldbObservationJournalValue & {
readonly status: "complete" | "eof";
})
| (LldbObservationJournalValue & {
readonly status: "read-failure";
readonly readFailure: unknown;
});
interface PartialCaptureValue {
readonly capture: NonNullable<
NativeCallPartialObservation["process"]["stdout"]
>;
readonly limitations: readonly string[];
}
export type PartialCaptureRead =
| (PartialCaptureValue & { readonly status: "complete" | "eof" })
| (PartialCaptureValue & {
readonly status: "read-failure";
readonly readFailure: unknown;
});
export type BoundedPrefixRead =
| {
readonly status: "complete" | "eof";
readonly buffer: Buffer;
readonly bytesRead: number;
}
| {
readonly status: "read-failure";
readonly buffer: Buffer;
readonly bytesRead: number;
readonly cause: unknown;
};
/** Join safely retained journal and process evidence into its failure contract. */
export const projectLldbPartialObservation = (input: {
readonly target: NativeCallPartialObservation["target"];
@@ -136,22 +170,31 @@ export const readBoundedPrefix = async (
length: number,
position: number,
) => Promise<number>,
): Promise<{ readonly buffer: Buffer; readonly bytesRead: number }> => {
): Promise<BoundedPrefixRead> => {
const buffer = Buffer.alloc(requestedBytes);
let bytesRead = 0;
while (bytesRead < requestedBytes) {
const count = await readChunk(
buffer,
bytesRead,
requestedBytes - bytesRead,
bytesRead,
);
let count: number;
try {
count = await readChunk(
buffer,
bytesRead,
requestedBytes - bytesRead,
bytesRead,
);
} catch (cause: unknown) {
return { status: "read-failure", buffer, bytesRead, cause };
}
if (count === 0) break;
if (count > requestedBytes - bytesRead)
throw new Error("The bounded read returned more bytes than requested");
bytesRead += count;
}
return { buffer, bytesRead };
return {
status: bytesRead < requestedBytes ? "eof" : "complete",
buffer,
bytesRead,
};
};
const readPrefix = (handle: FileHandle, requestedBytes: number) =>
@@ -165,17 +208,14 @@ const readPrefix = (handle: FileHandle, requestedBytes: number) =>
export const readLldbObservationJournal = (
path: string,
files: LldbReadbackFiles,
): Promise<
Result<
{ readonly parsed: ParsedLldbJournal; readonly limitations: string[] },
unknown
>
> =>
): Promise<Result<LldbObservationJournalRead, unknown>> =>
files.read(path, async (handle) => {
const { size } = await handle.stat();
const length = Math.min(size, LLDB_OBSERVATION_JOURNAL_MAX_BYTES);
const { buffer, bytesRead } = await readPrefix(handle, length);
return {
const read = await readPrefix(handle, length);
const { buffer, bytesRead } = read;
if (read.status === "read-failure" && bytesRead === 0) throw read.cause;
const value: LldbObservationJournalValue = {
parsed: parseLldbObservationJournal(buffer.subarray(0, bytesRead)),
limitations: [
...(size > LLDB_OBSERVATION_JOURNAL_MAX_BYTES
@@ -183,13 +223,21 @@ export const readLldbObservationJournal = (
"The observation journal exceeded 8 MiB; only its bounded prefix was read.",
]
: []),
...(bytesRead < length
...(read.status === "eof"
? [
"The observation journal became shorter while being read; the available prefix was parsed.",
]
: []),
...(read.status === "read-failure"
? [
`The observation journal read failed after ${String(bytesRead)} bytes: ${errorReason(read.cause)}`,
]
: []),
],
};
return read.status === "read-failure"
? { ...value, status: read.status, readFailure: read.cause }
: { ...value, status: read.status };
});
/** Retain an available output prefix without claiming an unreported total. */
@@ -200,19 +248,33 @@ export const readPartialCapture = (
files.read(path, async (handle) => {
const { size } = await handle.stat();
const length = Math.min(size, MAX_CAPTURE_BYTES);
const { buffer, bytesRead } = await readPrefix(handle, length);
return {
const read = await readPrefix(handle, length);
const { buffer, bytesRead } = read;
if (read.status === "read-failure" && bytesRead === 0) throw read.cause;
const value: PartialCaptureValue = {
capture: {
text: buffer.subarray(0, bytesRead).toString("utf8"),
bytes: Math.max(size, bytesRead),
truncated: true,
complete: false,
},
limitations:
bytesRead < length
limitations: [
...(read.status === "eof"
? [
"A target output capture became shorter while being read; its retained prefix may be incomplete.",
]
: [],
: []),
...(read.status === "read-failure"
? [
`A target output capture read failed after ${String(bytesRead)} bytes: ${errorReason(read.cause)}`,
]
: []),
],
};
return read.status === "read-failure"
? { ...value, status: read.status, readFailure: read.cause }
: { ...value, status: read.status };
});
const errorReason = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
@@ -303,10 +303,16 @@ describe("LLDB failure retention through the production tracer", () => {
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] },
});
expect(result.error.cause).toMatchObject({
code: "ERR_ENCODING_INVALID_ENCODED_DATA",
});
expect(result.error.secondaryFailures).toHaveLength(1);
expect(result.error.secondaryFailures[0]?.cause).toBeInstanceOf(
AggregateError,
);
if (configPath === undefined)
throw new Error("LLDB fixture did not launch");
await expect(access(dirname(configPath))).rejects.toMatchObject({
@@ -740,7 +746,106 @@ const readbackPath = (
pid: config.pid_path,
})[selected];
const readbackCases = [
const controlReadbackDescriptor = async (options: {
readonly path: string;
readonly selected: "journal" | "stdout" | "stderr" | "result" | "pid";
readonly readFails: boolean;
readonly readFailsAfterPrefix: boolean;
readonly cancels: boolean;
readonly controller: AbortController;
readonly reason: unknown;
readonly physicalFailure: Error;
}) => {
const selectedStat = await stat(options.path);
const probe = await open(options.path, "r");
const prototype = Object.getPrototypeOf(probe) as FileHandle;
await probe.close();
let captured: FileHandle | undefined;
let originalClose: (() => Promise<void>) | undefined;
let allowClose = false;
let partialReadAttempted = false;
const arm = async (file: FileHandle) => {
if (captured !== undefined) return;
const metadata = await file.stat();
if (metadata.ino !== selectedStat.ino || metadata.dev !== selectedStat.dev)
return;
captured = file;
originalClose = file.close.bind(file);
file.close = async () => {
if (!allowClose) {
if (options.cancels) options.controller.abort(options.reason);
throw new Error("controlled selected descriptor cleanup failure");
}
await originalClose!();
};
};
const readMethod = prototype.read;
const readFileMethod = prototype.readFile;
const read = vi.spyOn(prototype, "read").mockImplementation(async function (
this: FileHandle,
...parameters: Parameters<FileHandle["read"]>
) {
await arm(this);
if (options.readFails && captured === this) throw options.physicalFailure;
if (options.readFailsAfterPrefix && captured === this) {
if (partialReadAttempted) throw options.physicalFailure;
partialReadAttempted = true;
const [buffer, offset, length, position] = parameters as unknown as [
Buffer,
number,
number,
number,
];
const prefixLength =
options.selected === "journal"
? Buffer.byteLength(
JSON.stringify({ kind: "event", event: EVENT }) + "\n",
)
: 4;
return Reflect.apply(readMethod, this, [
buffer,
offset,
Math.min(prefixLength, length),
position,
]);
}
return Reflect.apply(readMethod, this, parameters);
});
const readFile = vi
.spyOn(prototype, "readFile")
.mockImplementation(async function (
this: FileHandle,
...parameters: Parameters<FileHandle["readFile"]>
) {
await arm(this);
return Reflect.apply(readFileMethod, this, parameters);
});
return {
get captured() {
return captured;
},
allowClose() {
allowClose = true;
},
restore() {
read.mockRestore();
readFile.mockRestore();
},
closeCaptured() {
return originalClose?.();
},
};
};
interface ReadbackCase {
readonly selected: "journal" | "stdout" | "stderr" | "result" | "pid";
readonly readFails: boolean;
readonly readFailsAfterPrefix?: boolean;
readonly rootFails: boolean;
readonly cancels: boolean;
}
const readbackCases: readonly ReadbackCase[] = [
...(["journal", "stdout", "stderr", "result", "pid"] as const).map(
(selected) => ({
selected,
@@ -752,9 +857,24 @@ const readbackCases = [
{
selected: "stdout" as const,
readFails: true,
readFailsAfterPrefix: false,
rootFails: false,
cancels: false,
},
{
selected: "stdout" as const,
readFails: false,
readFailsAfterPrefix: true,
rootFails: false,
cancels: false,
},
{
selected: "journal" as const,
readFails: false,
readFailsAfterPrefix: true,
rootFails: false,
cancels: true,
},
{
selected: "stdout" as const,
readFails: false,
@@ -768,16 +888,21 @@ const readbackCases = [
cancels: true,
},
];
for (const { selected, readFails, rootFails, cancels } of readbackCases)
it(`retains ${selected} readback cleanup and sibling facts (read fails: ${readFails}, root fails: ${rootFails})`, async () => {
for (const {
selected,
readFails,
readFailsAfterPrefix = false,
rootFails,
cancels,
} of readbackCases)
it(`retains ${selected} readback cleanup and sibling facts (read fails: ${readFails || readFailsAfterPrefix}, root fails: ${rootFails})`, async () => {
const controller = new AbortController();
const reason = { caller: "readback-final-close" };
let allowClose = false;
let launches = 0;
let runtime: string | undefined;
let captured: FileHandle | undefined;
let originalClose: (() => Promise<void>) | undefined;
let restore: (() => void) | undefined;
let descriptorControl:
| Awaited<ReturnType<typeof controlReadbackDescriptor>>
| undefined;
let restoreRoot: (() => void) | undefined;
const physicalFailure = new Error("controlled capture read failure");
const tracer = new LldbCallTracer(
@@ -787,54 +912,20 @@ for (const { selected, readFails, rootFails, cancels } of readbackCases)
const { config } = await configFromArguments(args);
runtime = dirname(config.result_path);
await writeReadbackFixture(config, "stdout fact", "stderr fact");
const selectedPath = readbackPath(config, selected);
const selectedStat = await stat(selectedPath);
const probe = await open(selectedPath, "r");
const prototype = Object.getPrototypeOf(probe) as FileHandle;
await probe.close();
const arm = async (file: FileHandle) => {
if (captured !== undefined) return;
const metadata = await file.stat();
if (
metadata.ino !== selectedStat.ino ||
metadata.dev !== selectedStat.dev
)
return;
captured = file;
originalClose = file.close.bind(file);
file.close = async () => {
if (!allowClose) {
if (cancels) controller.abort(reason);
throw new Error("controlled selected descriptor cleanup failure");
}
await originalClose!();
};
};
const readMethod = prototype.read;
const readFileMethod = prototype.readFile;
const read = vi
.spyOn(prototype, "read")
.mockImplementation(async function (
this: FileHandle,
...parameters: Parameters<FileHandle["read"]>
) {
await arm(this);
if (readFails && captured === this) throw physicalFailure;
return Reflect.apply(readMethod, this, parameters);
});
const readFile = vi
.spyOn(prototype, "readFile")
.mockImplementation(async function (
this: FileHandle,
...parameters: Parameters<FileHandle["readFile"]>
) {
await arm(this);
return Reflect.apply(readFileMethod, this, parameters);
});
restore = () => {
read.mockRestore();
readFile.mockRestore();
};
const journalRow =
JSON.stringify({ kind: "event", event: EVENT }) + "\n";
if (selected === "journal" && readFailsAfterPrefix)
await writeFile(config.observation_path, journalRow + '{"kind":');
descriptorControl = await controlReadbackDescriptor({
path: readbackPath(config, selected),
selected,
readFails,
readFailsAfterPrefix,
cancels,
controller,
reason,
physicalFailure,
});
return selected === "journal"
? {
kind: "cancelled",
@@ -854,11 +945,11 @@ for (const { selected, readFails, rootFails, cancels } of readbackCases)
async () => ok(fakeTool),
);
onTestFinished(async () => {
restore?.();
descriptorControl?.restore();
restoreRoot?.();
allowClose = true;
descriptorControl?.allowClose();
await tracer.close();
await originalClose?.();
await descriptorControl?.closeCaptured();
});
const result = await tracer.trace(request, controller.signal);
expect(result.ok).toBe(false);
@@ -881,15 +972,30 @@ for (const { selected, readFails, rootFails, cancels } of readbackCases)
kind: "failed",
cause: physicalFailure,
});
} else if (readFailsAfterPrefix && selected === "stdout") {
expect(partial.process.stdout).toMatchObject({
text: "stdo",
truncated: true,
complete: false,
});
const primary = result.error.cleanupPrimary;
if (!(primary?.cause instanceof AggregateError))
throw new Error("Expected retained capture read failure");
expect(primary.cause.errors).toContain(physicalFailure);
} else if (readFailsAfterPrefix) {
const primary = result.error.cleanupPrimary ?? result.error;
expect(primary.cause).toBe(reason);
expect(primary.secondaryFailures).toHaveLength(1);
expect(primary.secondaryFailures[0]?.cause).toBe(physicalFailure);
} else expect(partial.process.stdout?.text).toBe("stdout fact");
expect(partial.process.stderr?.text).toBe("stderr fact");
expect(captured?.fd).toBeGreaterThanOrEqual(0);
expect(descriptorControl?.captured?.fd).toBeGreaterThanOrEqual(0);
if (runtime === undefined) throw new Error("Missing owned runtime");
await expect(access(runtime)).resolves.toBeUndefined();
const replacement = await tracer.trace(request);
expect(replacement.ok).toBe(false);
expect(launches).toBe(1);
allowClose = true;
descriptorControl?.allowClose();
if (rootFails) {
const close = vi
.spyOn(PrivateRuntimeRoot.prototype, "close")
@@ -897,7 +1003,7 @@ for (const { selected, readFails, rootFails, cancels } of readbackCases)
restoreRoot = () => close.mockRestore();
const rootRetry = await tracer.close();
expect(rootRetry.ok).toBe(false);
expect(captured?.fd).toBe(-1);
expect(descriptorControl?.captured?.fd).toBe(-1);
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toEqual([runtime]);
expect((await tracer.close()).ok).toBe(true);
@@ -1,4 +1,10 @@
import { mkdtemp, open, rm, writeFile } from "node:fs/promises";
import {
mkdtemp,
open,
rm,
writeFile,
type FileHandle,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
@@ -24,30 +30,40 @@ it("bounds PID readback while retaining newline-terminated producer fixtures", a
for (const [name, contents] of values)
await writeFile(join(directory, name), contents);
const sample = await open(join(directory, "max-safe.pid"), "r");
const prototype = Object.getPrototypeOf(sample);
const prototype = Object.getPrototypeOf(sample) as {
read(
this: FileHandle,
buffer: Buffer,
offset: number,
length: number,
position: number,
): Promise<{ bytesRead: number; buffer: Buffer }>;
readFile(this: FileHandle, encoding: "utf8"): Promise<string>;
};
await sample.close();
let bytesRead = 0;
const requestedLengths: number[] = [];
const originalReadFile = prototype.readFile;
const readFileSpy = vi
.spyOn(prototype, "readFile")
.mockImplementation(async function (...args) {
const value = await Reflect.apply(originalReadFile, this, args);
.mockImplementation(async function (this: FileHandle, ...args) {
const value = await originalReadFile.call(this, ...args);
bytesRead += Buffer.byteLength(value);
return value;
});
const originalRead = prototype.read;
const readSpy = vi
.spyOn(prototype, "read")
.mockImplementation(async function (...args) {
const requestedLength = args[2] ?? 0;
.mockImplementation(async function (this: FileHandle, ...args) {
const requestedLength = typeof args[2] === "number" ? args[2] : 0;
requestedLengths.push(requestedLength);
const result = await Reflect.apply(originalRead, this, [
const result = await originalRead.call(
this,
args[0],
args[1],
Math.min(requestedLength, 1),
args[3],
]);
);
bytesRead += result.bytesRead;
return result;
});
@@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
import type { FileHandle } from "node:fs/promises";
import { err, ok, type Result } from "../../../../src/domain/result.js";
import { nativeCallPartialObservationSchema } from "../../../../src/domain/native/nativeCallPartialObservation.js";
import {
@@ -6,7 +8,10 @@ import {
parseLldbObservationJournal,
projectLldbPartialObservation,
readBoundedPrefix,
readLldbObservationJournal,
readPartialCapture,
} from "../../../../src/native/LldbRetainedObservations.js";
import type { LldbReadbackFiles } from "../../../../src/native/LldbReadbackFiles.js";
const event = {
sequence: 0,
@@ -136,7 +141,9 @@ describe("LLDB retained observations", () => {
expect.stringContaining("malformed or invalid"),
);
});
});
describe("LLDB retained observation read failures", () => {
it("bounds malformed-row diagnostics for an 8 MiB journal", () => {
const valid = JSON.stringify({ kind: "event", event });
const malformed = '{"kind":"unknown"}\n';
@@ -178,4 +185,91 @@ describe("LLDB retained observations", () => {
"retained prefix",
);
});
it("retains completed journal rows and the exact cause after a later read fails", async () => {
const completeRow = `${JSON.stringify({ kind: "event", event })}\n`;
const source = Buffer.from(`${completeRow}{"kind":"event"`);
const failure = Object.assign(new Error("controlled EIO"), { code: "EIO" });
const files = readbackFilesFailingAfter(
source,
Buffer.byteLength(completeRow),
failure,
);
const result = await readLldbObservationJournal("journal", files);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value.status).toBe("read-failure");
if (result.value.status !== "read-failure")
throw new Error("Expected partial journal read failure");
expect(result.value.parsed.events).toEqual([event]);
expect(result.value.readFailure).toBe(failure);
expect(result.value.limitations).toContain(
`The observation journal read failed after ${String(Buffer.byteLength(completeRow))} bytes: controlled EIO`,
);
});
it("retains an output prefix with a tagged read failure, but zero-byte failure remains an error", async () => {
const prefix = "first output prefix";
const source = Buffer.from(`${prefix} followed by unavailable bytes`);
const failure = Object.assign(new Error("controlled EIO"), { code: "EIO" });
const partial = await readPartialCapture(
"stdout",
readbackFilesFailingAfter(source, Buffer.byteLength(prefix), failure),
);
expect(partial.ok).toBe(true);
if (!partial.ok) throw partial.error;
expect(partial.value.status).toBe("read-failure");
if (partial.value.status !== "read-failure")
throw new Error("Expected partial output read failure");
expect(partial.value.capture).toMatchObject({
text: prefix,
truncated: true,
complete: false,
});
expect(partial.value.readFailure).toBe(failure);
const firstReadFailure = await readPartialCapture(
"stdout",
readbackFilesFailingAfter(source, 0, failure),
);
expect(firstReadFailure.ok).toBe(false);
if (!firstReadFailure.ok) expect(firstReadFailure.error).toBe(failure);
});
});
const readbackFilesFailingAfter = (
source: Buffer,
retainedBytes: number,
failure: Error,
): LldbReadbackFiles => {
let calls = 0;
const handle = {
stat: async () => ({ size: source.byteLength }),
read: async (
buffer: Buffer,
offset: number,
length: number,
position: number,
) => {
calls += 1;
if (calls === 1 && retainedBytes === 0) throw failure;
if (calls > 1) throw failure;
const bytesRead = Math.min(retainedBytes, length);
source.copy(buffer, offset, position, position + bytesRead);
return { bytesRead, buffer };
},
} as unknown as FileHandle;
return {
read: async <Value>(
_path: string,
read: (file: FileHandle) => Promise<Value>,
): Promise<Result<Value, unknown>> => {
try {
return ok(await read(handle));
} catch (cause: unknown) {
return err(cause);
}
},
} as LldbReadbackFiles;
};