Files
rea/tests/boundary/providers/native/lldbCallTracerFailures.test.ts
T

1339 lines
48 KiB
TypeScript

import {
access,
mkdtemp,
readFile,
rm,
writeFile,
open,
stat,
type FileHandle,
} from "node:fs/promises";
import { dirname } from "node:path";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { z } from "zod";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisTimeoutError,
} from "../../../../src/domain/analysisErrorCore.js";
import { EvidenceIntegrityError } from "../../../../src/domain/evidenceErrors.js";
import { ArtifactFileCleanupFailure } from "../../../../src/artifacts/ArtifactFileOperation.js";
import { resolvedAnalysisError } from "../../../../src/domain/analysisErrorBase.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { nativeCallObservationInputSchema } from "../../../../src/domain/native/nativeCallObservation.js";
import { nativeCallPartialObservationSchema } from "../../../../src/domain/native/nativeCallPartialObservation.js";
import type { NativeCallTracer } from "../../../../src/native/LldbCallTracer.js";
import { LldbCallTracer } from "../../../../src/native/LldbCallTracer.js";
import { err, ok } from "../../../../src/domain/result.js";
import { NativeCommandFailure } from "../../../../src/native/CommandRunner.js";
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
import { createDeferred } from "../../../fixtures/binarySession.js";
import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js";
import {
ProviderProcessSupervisor,
type ProviderProcessStopOptions,
type ProviderProcessStopResult,
} from "../../../../src/process/ProviderProcess.js";
import { ProviderStartupDeadline } from "../../../../src/process/ProviderDeadline.js";
import {
NativeFixtureRunner,
nativeMachoTarget,
} from "../../../fixtures/nativeCommands.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<NativeCallTracer["trace"]>[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 successfulTracerOutput = {
status: "traced",
version: "lldb-fixture",
pid: 12345,
outcome: "exited",
resource_limit_reached: false,
target_output: {
stdout_bytes: 0,
stderr_bytes: 0,
stdout_truncated: false,
stderr_truncated: false,
stdout_complete: true,
stderr_complete: true,
},
exit_status: 0,
exit_description: null,
killed: false,
terminated: true,
target_identity: {
loaded_image_sha256: null,
selected_file_sha256: request.expectedSha256,
file_device: "1",
file_inode: "2",
module_path: request.executable,
module_uuid: "fixture",
stable: true,
},
elapsed_ms: 1,
breakpoints: [{ index: 0, location_count: 1, locations: [] }],
events: [EVENT],
other_stops: [],
};
const configSchema = z.object({
result_path: z.string(),
observation_path: z.string(),
stdout_capture_path: z.string(),
stderr_capture_path: z.string(),
pid_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",
targetLaunched: false,
targetIdentity: undefined,
cleanupFailure: undefined,
}
: {
kind: "exited",
targetLaunched: false,
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);
if (outcome === "cancelled") await expect(access(root)).rejects.toThrow();
else {
// The simulated launcher reported incomplete cleanup without returning
// its process owner. That absence cannot establish completion.
await expect(access(root)).resolves.toBeUndefined();
onTestFinished(async () => {
await rm(root, { recursive: true, force: true });
});
}
},
};
};
describe("LLDB completed trace retention through finalization", () => {
it.each([
"after launch",
"during runtime cleanup",
"runtime cleanup failure",
])("retains completed trace facts after %s", async (phase) => {
const controller = new AbortController();
const close = PrivateRuntimeRoot.prototype.close;
let runtime: string | undefined;
const closeSpy =
phase !== "after launch"
? vi
.spyOn(PrivateRuntimeRoot.prototype, "close")
.mockImplementation(async function (this: PrivateRuntimeRoot) {
if (phase === "runtime cleanup failure")
throw new Error("fixture runtime removal denied");
await close.call(this);
controller.abort();
})
: undefined;
const tracer = new LldbCallTracer(
{},
async (_executable, arguments_) => {
const { config } = await configFromArguments(arguments_);
runtime = dirname(config.result_path);
await writeFile(config.observation_path, "");
await writeFile(config.stdout_capture_path, "");
await writeFile(config.stderr_capture_path, "");
await writeFile(
config.result_path,
JSON.stringify(successfulTracerOutput),
);
if (phase === "after launch") controller.abort();
return {
kind: "exited",
targetLaunched: false,
exitCode: 0,
output: "",
targetIdentity: undefined,
cleanupFailure: undefined,
};
},
async () => ok(fakeTool),
);
onTestFinished(async () => {
closeSpy?.mockRestore();
await tracer.close();
});
const result = await tracer
.trace(request, controller.signal)
.finally(() => closeSpy?.mockRestore());
if (result.ok)
throw new Error("expected finalization failure after LLDB exit");
if (runtime === undefined) throw new Error("LLDB fixture did not launch");
if (phase === "runtime cleanup failure") {
expect(result.error.cleanupResources).toEqual([runtime]);
await access(runtime);
expect((await tracer.close()).ok).toBe(true);
} else expect(result.error).toBeInstanceOf(AnalysisCancelledError);
await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" });
expect(result.error.partialObservation).toMatchObject({
coverage: {
status: "partial",
reason:
phase === "runtime cleanup failure" ? "cleanup-failure" : "cancelled",
},
target: { launch_identity: successfulTracerOutput.target_identity },
debugger: { ...fakeTool, version: successfulTracerOutput.version },
process: {
outcome: "exited",
exit_status: 0,
exit_description: null,
terminated: true,
elapsed_ms: 1,
stdout: { complete: true },
},
breakpoints: [
{
index: 0,
request: { kind: "function", name: "main" },
location_count: 1,
},
],
events: [EVENT],
});
const partial = nativeCallPartialObservationSchema.parse(
result.error.partialObservation,
);
expect(partial).toEqual(result.error.partialObservation);
expect(partial.limitations).not.toContain(
"Captured output byte counts are observed lower bounds because LLDB did not report final stream counters.",
);
});
});
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",
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({
code: "ENOENT",
});
});
it("waits for an active trace before client close removes its runtime root", async () => {
const launchGate = createDeferred<void>();
const launchStarted = createDeferred<void>();
const tracer = new LldbCallTracer(
{},
async (_executable, arguments_) => {
const { config } = await configFromArguments(arguments_);
await writeFile(
config.observation_path,
`${JSON.stringify({ kind: "event", event: EVENT })}\n`,
);
await writeFile(config.stdout_capture_path, "captured prefix");
launchStarted.resolve();
await launchGate.promise;
return {
kind: "exited",
targetLaunched: false,
exitCode: 0,
output: "",
targetIdentity: undefined,
cleanupFailure: undefined,
};
},
async () => ok(fakeTool),
);
const client = new NativeMacOSProvider(
{},
new NativeFixtureRunner(),
"darwin",
() => tracer,
).createClient(nativeMachoTarget(request.executable));
const tracePromise = tracer.trace(request);
await launchStarted.promise;
let closeFinished = false;
const closePromise = client.close().then((result) => {
closeFinished = true;
return result;
});
await Promise.resolve();
expect(closeFinished).toBe(false);
launchGate.resolve();
const [trace, close] = await Promise.all([tracePromise, closePromise]);
expect(close.ok).toBe(true);
if (trace.ok) throw new Error("expected missing LLDB result");
expect(trace.error.partialObservation).toMatchObject({
events: [EVENT],
process: { stdout: { text: "captured prefix", complete: false } },
});
});
it("names the missing LLDB requirement instead of asking for another target", async () => {
const tracer = new LldbCallTracer(
{},
() => Promise.reject(new Error("LLDB must not launch")),
async (tool) => err(new NativeCommandFailure(tool, "unavailable")),
);
const result = await tracer.trace(request);
if (result.ok) throw new Error("expected an unavailable LLDB");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "capability_unavailable",
message: "LLDB from Xcode or the Command Line Tools is not available",
});
});
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" },
});
});
});
describe("LLDB primary outcome during owned process cleanup", () => {
it
.skipIf(process.platform === "win32")
.each(["caller cancellation", "deadline timeout"] as const)(
"preserves a physical bridge failure and no-target proof when %s arrives during successful process cleanup",
async (interruption) => {
const fixtureRoot = await mkdtemp(
join(tmpdir(), "rea-lldb-primary-outcome-"),
);
const fakeLldb = join(fixtureRoot, "fake-lldb");
await writeFile(
fakeLldb,
`#!${process.execPath}\nconst fs = require("node:fs");\nconst command = process.argv.at(-1);\nif (!command?.startsWith("rea_trace ")) process.exit(91);\nconst config = JSON.parse(fs.readFileSync(command.slice("rea_trace ".length), "utf8"));\nfs.writeFileSync(config.result_path, JSON.stringify({ status: "target-error", error: "fixture physical bridge failure", target_process_created: false }));\nprocess.exit(17);\n`,
{ mode: 0o700 },
);
const controller = new AbortController();
const stopEntered = createDeferred<void>();
const stopReleased = createDeferred<void>();
let stopping = false;
const deadlineSpy =
interruption === "deadline timeout"
? vi
.spyOn(ProviderStartupDeadline.prototype, "interruption", "get")
.mockImplementation(() => (stopping ? "timeout" : undefined))
: undefined;
let observedExit:
| ReturnType<ProviderProcessSupervisor["observedExit"]>
| undefined;
let observedStop: ProviderProcessStopResult | undefined;
const originalStop = ProviderProcessSupervisor.prototype.stop;
const stopSpy = vi
.spyOn(ProviderProcessSupervisor.prototype, "stop")
.mockImplementation(async function (
this: ProviderProcessSupervisor,
options?: ProviderProcessStopOptions,
): Promise<ProviderProcessStopResult> {
observedExit = this.observedExit();
stopping = true;
stopEntered.resolve(undefined);
await stopReleased.promise;
observedStop = await originalStop.call(this, options);
return observedStop;
});
const tracer = new LldbCallTracer({}, undefined, async () =>
ok({ path: fakeLldb, sha256: "c".repeat(64) }),
);
try {
const pending = tracer.trace(
request,
interruption === "caller cancellation"
? controller.signal
: undefined,
);
await stopEntered.promise;
expect(observedExit).toEqual({ code: 17, signal: null });
if (interruption === "caller cancellation")
controller.abort(new Error("abort during final cleanup"));
stopReleased.resolve(undefined);
const result = await pending;
expect(observedStop?.status).toMatch(
/^(already-exited|verified-cleanup|terminated|killed)$/u,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the bridge target error");
expect(result.error).toBeInstanceOf(AnalysisCapabilityUnavailableError);
if (!(result.error instanceof AnalysisCapabilityUnavailableError))
return;
expect(result.error.reason).toBe(
"lldb-target-unloadable: fixture physical bridge failure",
);
expect(result.error.cleanup).toBeUndefined();
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
expect(result.error.partialObservation).toMatchObject({
coverage: { status: "partial", reason: "tracer-failure" },
process: { pid: null },
});
} finally {
stopReleased.resolve(undefined);
stopSpy.mockRestore();
deadlineSpy?.mockRestore();
await tracer.close();
await rm(fixtureRoot, { recursive: true, force: true });
}
},
);
});
describe("LLDB process exit while output is still open", () => {
it.skipIf(process.platform === "win32")(
"keeps a nonzero leader exit observed before cancellation while inherited output is still open",
async () => {
const fixtureRoot = await mkdtemp(
join(tmpdir(), "rea-lldb-exit-before-cancel-"),
);
const fakeLldb = join(fixtureRoot, "fake-lldb");
await writeFile(
fakeLldb,
`#!${process.execPath}\nconst fs = require("node:fs");\nconst { spawn } = require("node:child_process");\nconst command = process.argv.at(-1);\nif (!command?.startsWith("rea_trace ")) process.exit(91);\nconst config = JSON.parse(fs.readFileSync(command.slice("rea_trace ".length), "utf8"));\nconst child = spawn(process.execPath, ["-e", "setTimeout(() => {}, 60000)"], { stdio: ["ignore", "inherit", "inherit"] });\nchild.once("error", () => process.exit(92));\nchild.once("spawn", () => { fs.writeFileSync(config.result_path, JSON.stringify({ status: "target-error", error: "fixture leader failure before drain", target_process_created: false })); process.exit(17); });\n`,
{ mode: 0o700 },
);
const controller = new AbortController();
const drainObserved = createDeferred<void>();
const drainReleased = createDeferred<void>();
const originalWait =
ProviderProcessSupervisor.prototype.waitForOutputClose;
const drainSpy = vi
.spyOn(ProviderProcessSupervisor.prototype, "waitForOutputClose")
.mockImplementation(async function (
this: ProviderProcessSupervisor,
timeoutMs?: number,
signal?: AbortSignal,
): Promise<boolean> {
const closed = await originalWait.call(this, timeoutMs, signal);
if (!closed && !stopping && this.observedExit()?.code === 17) {
drainObserved.resolve(undefined);
await drainReleased.promise;
}
return closed;
});
let stopping = false;
let stopReceipt: ProviderProcessStopResult | undefined;
const originalStop = ProviderProcessSupervisor.prototype.stop;
const stopSpy = vi
.spyOn(ProviderProcessSupervisor.prototype, "stop")
.mockImplementation(async function (
this: ProviderProcessSupervisor,
options?: ProviderProcessStopOptions,
): Promise<ProviderProcessStopResult> {
stopping = true;
stopReceipt = await originalStop.call(this, options);
return stopReceipt;
});
const tracer = new LldbCallTracer({}, undefined, async () =>
ok({ path: fakeLldb, sha256: "d".repeat(64) }),
);
try {
const pending = tracer.trace(request, controller.signal);
await drainObserved.promise;
controller.abort(new Error("abort after leader exit"));
drainReleased.resolve(undefined);
const result = await pending;
expect(stopReceipt?.status).toMatch(
/^(already-exited|verified-cleanup|terminated|killed)$/u,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the bridge target error");
expect(result.error).toBeInstanceOf(AnalysisCapabilityUnavailableError);
if (!(result.error instanceof AnalysisCapabilityUnavailableError))
return;
expect(result.error.reason).toBe(
"lldb-target-unloadable: fixture leader failure before drain",
);
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
} finally {
drainReleased.resolve(undefined);
drainSpy.mockRestore();
stopSpy.mockRestore();
await tracer.close();
await rm(fixtureRoot, { recursive: true, force: true });
}
},
);
});
describe("LLDB process exit at output-close loop completion", () => {
it.skipIf(process.platform === "win32")(
"keeps a nonzero exit when output closure and cancellation meet at loop exit",
async () => {
const fixtureRoot = await mkdtemp(
join(tmpdir(), "rea-lldb-closed-output-cancel-"),
);
const fakeLldb = join(fixtureRoot, "fake-lldb");
await writeFile(
fakeLldb,
`#!${process.execPath}\nconst fs = require("node:fs");\nconst command = process.argv.at(-1);\nif (!command?.startsWith("rea_trace ")) process.exit(91);\nconst config = JSON.parse(fs.readFileSync(command.slice("rea_trace ".length), "utf8"));\nfs.writeFileSync(config.result_path, JSON.stringify({ status: "target-error", error: "fixture exit before closed-output cancellation", target_process_created: false }));\nprocess.exit(17);\n`,
{ mode: 0o700 },
);
const controller = new AbortController();
let interruptionApplied = false;
let stopReceipt: ProviderProcessStopResult | undefined;
const originalWait =
ProviderProcessSupervisor.prototype.waitForOutputClose;
const waitSpy = vi
.spyOn(ProviderProcessSupervisor.prototype, "waitForOutputClose")
.mockImplementation(async function (
this: ProviderProcessSupervisor,
timeoutMs?: number,
signal?: AbortSignal,
): Promise<boolean> {
const closed = await originalWait.call(this, timeoutMs, signal);
if (
!interruptionApplied &&
closed &&
this.observedExit()?.code === 17
) {
interruptionApplied = true;
controller.abort(new Error("abort at output-close loop exit"));
}
return closed;
});
const originalStop = ProviderProcessSupervisor.prototype.stop;
const stopSpy = vi
.spyOn(ProviderProcessSupervisor.prototype, "stop")
.mockImplementation(async function (
this: ProviderProcessSupervisor,
options?: ProviderProcessStopOptions,
): Promise<ProviderProcessStopResult> {
stopReceipt = await originalStop.call(this, options);
return stopReceipt;
});
const tracer = new LldbCallTracer({}, undefined, async () =>
ok({ path: fakeLldb, sha256: "f".repeat(64) }),
);
try {
const result = await tracer.trace(request, controller.signal);
expect(interruptionApplied).toBe(true);
expect(stopReceipt?.status).toMatch(
/^(already-exited|verified-cleanup|terminated|killed)$/u,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the bridge target error");
expect(result.error).toBeInstanceOf(AnalysisCapabilityUnavailableError);
if (!(result.error instanceof AnalysisCapabilityUnavailableError))
return;
expect(result.error.reason).toBe(
"lldb-target-unloadable: fixture exit before closed-output cancellation",
);
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
} finally {
waitSpy.mockRestore();
stopSpy.mockRestore();
await tracer.close();
await rm(fixtureRoot, { recursive: true, force: true });
}
},
);
});
describe("LLDB late timeout publication", () => {
it("converts a late deadline to timeout at successful-result publication", async () => {
let runtime: string | undefined;
const tracer = new LldbCallTracer(
{},
async (_executable, arguments_) => {
const { config } = await configFromArguments(arguments_);
runtime = dirname(config.result_path);
await writeFile(
config.observation_path,
`${JSON.stringify({ kind: "event", event: EVENT })}\n`,
);
await writeFile(config.stdout_capture_path, "");
await writeFile(config.stderr_capture_path, "");
await writeFile(
config.result_path,
JSON.stringify(successfulTracerOutput),
);
return {
kind: "exited",
targetLaunched: false,
exitCode: 0,
output: "",
targetIdentity: undefined,
cleanupFailure: undefined,
lateTimeout: true,
};
},
async () => ok(fakeTool),
);
const result = await tracer.trace(request);
if (runtime === undefined) throw new Error("LLDB fixture did not run");
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the late deadline to be retained");
expect(result.error).toBeInstanceOf(AnalysisTimeoutError);
if (!(result.error instanceof AnalysisTimeoutError)) return;
expect(result.error.timeoutMs).toBeGreaterThan(request.input.duration_ms);
expect(result.error.cleanup).toBeUndefined();
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.partialObservation).toMatchObject({
coverage: { status: "partial", reason: "timeout" },
process: {
outcome: "exited",
exit_status: 0,
terminated: true,
},
events: [EVENT],
});
await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" });
});
});
describe("LLDB pre-target phase proof", () => {
it("releases a proven pre-target integrity rejection and allows tracer reuse", async () => {
let launches = 0;
let firstRuntime: string | undefined;
const tracer = new LldbCallTracer(
{},
async (_executable, arguments_) => {
const { config } = await configFromArguments(arguments_);
launches += 1;
if (firstRuntime === undefined)
firstRuntime = dirname(config.result_path);
await writeFile(config.observation_path, "");
await writeFile(config.stdout_capture_path, "");
await writeFile(
config.result_path,
JSON.stringify(
launches === 1
? {
status: "target-integrity-error",
error:
"selected executable digest changed before LLDB target creation",
target_process_created: false,
}
: {
status: "target-error",
error: "second launch reached the bridge",
},
),
);
return {
kind: "exited",
targetLaunched: launches === 1,
exitCode: 1,
output: "",
targetIdentity: undefined,
cleanupFailure: undefined,
};
},
async () => ok(fakeTool),
);
const rejected = await tracer.trace(request);
if (rejected.ok) throw new Error("expected pre-target integrity failure");
expect(rejected.error).toBeInstanceOf(EvidenceIntegrityError);
expect(projectAnalysisError(rejected.error).code).toBe(
"evidence_integrity_mismatch",
);
if (firstRuntime === undefined) throw new Error("LLDB was not launched");
await expect(access(firstRuntime)).rejects.toThrow();
const retried = await tracer.trace(request);
expect(launches).toBe(2);
if (retried.ok) throw new Error("expected the second bridge result");
expect(retried.error).toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining("second launch reached the bridge"),
});
});
});
describe("LLDB retains ambiguous target ownership", () => {
it("releases a launch failure after the bridge proves the process exited", async () => {
let configPath: string | undefined;
const tracer = new LldbCallTracer(
{},
async (_executable, arguments_) => {
const resolved = await configFromArguments(arguments_);
configPath = resolved.configPath;
await writeFile(
resolved.config.result_path,
JSON.stringify({
status: "launch-error",
error: "attach failed after the launched process exited",
target_process_created: true,
target_process_exit_observed: true,
}),
);
return {
kind: "exited",
targetLaunched: true,
exitCode: 1,
output: "",
targetIdentity: undefined,
cleanupFailure: undefined,
};
},
async () => ok(fakeTool),
);
const result = await tracer.trace(request);
if (result.ok) throw new Error("expected LLDB launch failure");
expect(projectAnalysisError(result.error).code).toBe(
"capability_unavailable",
);
expect(result.error.cleanup).toBeUndefined();
expect(configPath).toBeDefined();
if (configPath !== undefined)
await expect(access(dirname(configPath))).rejects.toThrow();
});
it("keeps a post-target integrity rejection unresolved when its PID is missing", async () => {
let runtime: string | undefined;
const tracer = new LldbCallTracer(
{},
async (_executable, arguments_) => {
const { config } = await configFromArguments(arguments_);
runtime = dirname(config.result_path);
await writeFile(config.observation_path, "");
await writeFile(config.stdout_capture_path, "");
await writeFile(
config.result_path,
JSON.stringify({
status: "target-integrity-error",
error: "selected executable changed during LLDB launch",
target_process_created: true,
}),
);
return {
kind: "exited",
targetLaunched: true,
exitCode: 1,
output: "",
targetIdentity: undefined,
cleanupFailure: undefined,
};
},
async () => ok(fakeTool),
);
try {
const result = await tracer.trace(request);
if (result.ok) throw new Error("expected unresolved target identity");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cleanup_incomplete",
details: {
resources: expect.arrayContaining(["native-target:unknown"]),
},
});
expect(runtime).toBeDefined();
if (runtime !== undefined) await access(runtime);
const retry = await tracer.trace(request);
if (retry.ok) throw new Error("expected retained target cleanup");
expect(projectAnalysisError(retry.error).code).toBe("cleanup_incomplete");
} finally {
if (runtime !== undefined)
await rm(runtime, { recursive: true, force: true });
}
});
it("keeps an invalid process after Launch unresolved without producer proof", async () => {
let launches = 0;
let runtime: string | undefined;
const tracer = new LldbCallTracer(
{},
async (_executable, arguments_) => {
launches += 1;
const { config } = await configFromArguments(arguments_);
runtime = dirname(config.result_path);
await writeFile(config.observation_path, "");
await writeFile(config.stdout_capture_path, "");
await writeFile(
config.result_path,
JSON.stringify({
status: "launch-error",
error: "LLDB returned an invalid process after Launch",
}),
);
return {
kind: "exited",
targetLaunched: true,
exitCode: 1,
output: "",
targetIdentity: undefined,
cleanupFailure: undefined,
};
},
async () => ok(fakeTool),
);
try {
const result = await tracer.trace(request);
if (result.ok) throw new Error("expected unresolved launch cleanup");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cleanup_incomplete",
details: {
resources: expect.arrayContaining(["native-target:unknown"]),
},
});
expect(runtime).toBeDefined();
if (runtime !== undefined) await access(runtime);
const retry = await tracer.trace(request);
if (retry.ok) throw new Error("expected retained target cleanup");
expect(projectAnalysisError(retry.error).code).toBe("cleanup_incomplete");
expect(launches).toBe(1);
} finally {
if (runtime !== undefined)
await rm(runtime, { recursive: true, force: true });
}
});
});
it("retains journal descriptor cleanup after a cancelled run instead of removing its runtime", async () => {
let release: (() => Promise<void>) | undefined;
let restoreRead: (() => void) | undefined;
let captured: FileHandle | undefined;
let allowClose = false;
let configPath: string | undefined;
let attempts = 0;
const tracer = new LldbCallTracer(
{},
async (_executable, args) => {
const resolved = await configFromArguments(args);
configPath = resolved.configPath;
await writeFile(
resolved.config.observation_path,
JSON.stringify({
kind: "other-stop",
reason: "controlled journal fact",
}) + "\n",
);
const probe = await open(resolved.config.observation_path, "r");
const prototype = Object.getPrototypeOf(probe) as FileHandle;
await probe.close();
const original = prototype.read;
const read = vi
.spyOn(prototype, "read")
.mockImplementationOnce(async function (
this: FileHandle,
...parameters: Parameters<FileHandle["read"]>
) {
captured = this;
release = this.close.bind(this);
this.close = async () => {
attempts += 1;
if (!allowClose)
throw new Error("controlled journal descriptor close failure");
await release!();
};
return Reflect.apply(original, this, parameters);
});
restoreRead = () => read.mockRestore();
return {
kind: "cancelled",
targetLaunched: false,
targetIdentity: undefined,
cleanupFailure: undefined,
};
},
async () => ok(fakeTool),
);
onTestFinished(async () => {
restoreRead?.();
allowClose = true;
await tracer.close();
await release?.();
});
const result = await tracer.trace(request);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancelled controlled run");
const partial = nativeCallPartialObservationSchema.parse(
result.error.partialObservation,
);
expect(partial.process.other_stops).toEqual(["controlled journal fact"]);
expect(attempts).toBeGreaterThan(0);
if (configPath === undefined)
throw new Error("Expected owned runtime config path");
const rootRetained = await access(dirname(configPath)).then(
() => true,
() => false,
);
expect({
cleanupIncomplete: result.error.cleanupIncomplete,
rootRetained,
descriptorStillOpen: captured !== undefined && captured.fd >= 0,
}).toEqual({
cleanupIncomplete: true,
rootRetained: true,
descriptorStillOpen: true,
});
expect((await tracer.close()).ok).toBe(false);
allowClose = true;
expect((await tracer.close()).ok).toBe(true);
expect(result.error.cleanupIncomplete).toBe(false);
await expect(access(dirname(configPath))).rejects.toMatchObject({
code: "ENOENT",
});
});
/** Write the complete producer facts used by readback ownership controls. */
const writeReadbackFixture = async (
config: z.infer<typeof configSchema>,
stdout: string,
stderr: string,
) => {
await writeFile(
config.observation_path,
JSON.stringify({ kind: "event", event: EVENT }) + "\n",
);
await writeFile(config.stdout_capture_path, stdout);
await writeFile(config.stderr_capture_path, stderr);
await writeFile(config.pid_path, "12345\n");
await writeFile(
config.result_path,
JSON.stringify({
...successfulTracerOutput,
target_output: {
...successfulTracerOutput.target_output,
stdout_bytes: Buffer.byteLength(stdout),
stderr_bytes: Buffer.byteLength(stderr),
},
}),
);
};
const readbackPath = (
config: z.infer<typeof configSchema>,
selected: "journal" | "stdout" | "stderr" | "result" | "pid",
): string =>
({
journal: config.observation_path,
stdout: config.stdout_capture_path,
stderr: config.stderr_capture_path,
result: config.result_path,
pid: config.pid_path,
})[selected];
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,
readFails: false,
rootFails: false,
cancels: false,
}),
),
{
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,
rootFails: true,
cancels: false,
},
{
selected: "stdout" as const,
readFails: false,
rootFails: false,
cancels: true,
},
];
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 launches = 0;
let runtime: string | undefined;
let descriptorControl:
| Awaited<ReturnType<typeof controlReadbackDescriptor>>
| undefined;
let restoreRoot: (() => void) | undefined;
const physicalFailure = new Error("controlled capture read failure");
const tracer = new LldbCallTracer(
{},
async (_executable, args) => {
launches++;
const { config } = await configFromArguments(args);
runtime = dirname(config.result_path);
await writeReadbackFixture(config, "stdout fact", "stderr fact");
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",
targetLaunched: false,
targetIdentity: undefined,
cleanupFailure: undefined,
}
: {
kind: "exited",
targetLaunched: false,
targetIdentity: undefined,
cleanupFailure: undefined,
exitCode: 0,
output: "",
};
},
async () => ok(fakeTool),
);
onTestFinished(async () => {
descriptorControl?.restore();
restoreRoot?.();
descriptorControl?.allowClose();
await tracer.close();
await descriptorControl?.closeCaptured();
});
const result = await tracer.trace(request, controller.signal);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected retained readback cleanup");
expect(result.error.cleanupIncomplete).toBe(true);
const partial = nativeCallPartialObservationSchema.parse(
result.error.partialObservation,
);
expect(partial.events).toEqual([EVENT]);
if (selected === "pid") expect(partial.process.pid).toBe(12345);
if (readFails) {
expect(partial.process.stdout).toBeNull();
const primary = result.error.cleanupPrimary;
if (!(primary?.cause instanceof AggregateError))
throw new Error("Expected retained capture read failure");
const frame = primary.cause.errors[0];
if (!(frame instanceof ArtifactFileCleanupFailure))
throw new Error("Expected exact file cleanup outcome");
expect(frame.fileCleanupFailure.outcome).toEqual({
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(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);
descriptorControl?.allowClose();
if (rootFails) {
const close = vi
.spyOn(PrivateRuntimeRoot.prototype, "close")
.mockRejectedValueOnce(new Error("controlled parent cleanup failure"));
restoreRoot = () => close.mockRestore();
const rootRetry = await tracer.close();
expect(rootRetry.ok).toBe(false);
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);
if (!rootRetry.ok) expect(rootRetry.error.cleanupIncomplete).toBe(false);
} else expect((await tracer.close()).ok).toBe(true);
expect(result.error.cleanupIncomplete).toBe(false);
if (cancels)
expect(resolvedAnalysisError(result.error)).toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
});
expect(result.error.cleanupResources).toEqual([]);
if (!replacement.ok)
expect(replacement.error.cleanupIncomplete).toBe(false);
await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" });
});