From ef64631f0712d71c1e22e8a03ed595f5168a4cf5 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:08:00 +0800 Subject: [PATCH] fix(native): retain completed LLDB facts across output-close cancellation --- src/native/LldbCallTracer.ts | 4 +- .../native/lldbCallTracerFailures.test.ts | 105 ++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/src/native/LldbCallTracer.ts b/src/native/LldbCallTracer.ts index 0130300c34..6f88f624fe 100644 --- a/src/native/LldbCallTracer.ts +++ b/src/native/LldbCallTracer.ts @@ -1453,7 +1453,9 @@ const runLldb = async ( const snapshot = supervisor.snapshot(); const interruption = deadline.interruption; return await finish( - interruption === undefined + // Closed output with an observed exit owns a physical result. Read it + // before the outer success-only cancellation projection retains facts. + interruption === undefined || supervisor.observedExit() !== undefined ? { kind: "exited", exitCode: snapshot.exitCode ?? null, diff --git a/tests/boundary/providers/native/lldbCallTracerFailures.test.ts b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts index a2b653cd68..5620c644ac 100644 --- a/tests/boundary/providers/native/lldbCallTracerFailures.test.ts +++ b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts @@ -606,6 +606,111 @@ describe("LLDB process exit while output is still open", () => { }); describe("LLDB process exit at output-close loop completion", () => { + for (const abort of [false, true]) + it.skipIf(process.platform === "win32")( + `retains completed trace facts after output closure (cancelled=${String(abort)})`, + async () => { + const fixtureRoot = await mkdtemp( + join(tmpdir(), "rea-lldb-complete-output-"), + ); + const fakeLldb = join(fixtureRoot, "fake-lldb"); + const completed = { + ...successfulTracerOutput, + target_output: { + ...successfulTracerOutput.target_output, + stdout_bytes: 2, + }, + }; + await writeFile( + fakeLldb, + `#!${process.execPath} +const fs = require("node:fs"); +const { spawnSync } = require("node:child_process"); +const command = process.argv.at(-1); +if (!command?.startsWith("rea_trace ")) process.exit(91); +const config = JSON.parse(fs.readFileSync(command.slice("rea_trace ".length), "utf8")); +const child = spawnSync(process.execPath, ["-e", ""]); +const result = ${JSON.stringify(completed)}; +result.pid = child.pid; +fs.writeFileSync(config.pid_path, String(child.pid)); +fs.writeFileSync(config.stdout_capture_path, "ok"); +fs.writeFileSync(config.stderr_capture_path, ""); +fs.writeFileSync(config.observation_path, JSON.stringify({ kind: "event", event: result.events[0] }) + "\\n"); +fs.writeFileSync(config.result_path, JSON.stringify(result)); +process.exit(0); +`, + { mode: 0o700 }, + ); + const controller = new AbortController(); + const reason = { requested: "stop after completed output closure" }; + let interrupted = false; + const originalWait = + ProviderProcessSupervisor.prototype.waitForOutputClose; + const waitSpy = vi + .spyOn(ProviderProcessSupervisor.prototype, "waitForOutputClose") + .mockImplementation(async function ( + this: ProviderProcessSupervisor, + timeoutMs?: number, + signal?: AbortSignal, + ): Promise { + const closed = await originalWait.call(this, timeoutMs, signal); + if ( + abort && + !interrupted && + closed && + this.observedExit()?.code === 0 + ) { + interrupted = true; + controller.abort(reason); + } + return closed; + }); + const tracer = new LldbCallTracer({}, undefined, async () => + ok({ path: fakeLldb, sha256: "e".repeat(64) }), + ); + try { + const result = await tracer.trace(request, controller.signal); + expect(interrupted).toBe(abort); + if (result.ok) { + expect(abort).toBe(false); + expect(result.value.run).toMatchObject(completed); + expect(result.value.stdout).toMatchObject({ + text: "ok", + bytes: 2, + complete: true, + }); + } else { + expect(abort).toBe(true); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(result.error.cleanupIncomplete).toBe(false); + expect( + nativeCallPartialObservationSchema.parse( + result.error.partialObservation, + ), + ).toMatchObject({ + coverage: { status: "partial", reason: "cancelled" }, + debugger: { version: completed.version }, + process: { + outcome: "exited", + exit_status: 0, + terminated: true, + elapsed_ms: 1, + stdout: { text: "ok", bytes: 2, complete: true }, + }, + target: { launch_identity: completed.target_identity }, + breakpoints: [{ index: 0, location_count: 1 }], + events: [EVENT], + }); + } + } finally { + waitSpy.mockRestore(); + expect((await tracer.close()).ok).toBe(true); + await rm(fixtureRoot, { recursive: true, force: true }); + } + }, + ); + it.skipIf(process.platform === "win32")( "keeps a nonzero exit when output closure and cancellation meet at loop exit", async () => {