From 5f6342db46c2b02df98f6312fc3fbffcf58c209a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:25:18 +0800 Subject: [PATCH] fix(native): preserve failed LLDB launch ownership and exit proof Publish failed-launch process identity and observed exit separately from object validity. Retire only the exact owned process and retain uncertainty when exit cannot be established. Refs: #4232 --- bridge/native/rea_lldb_tracer.py | 52 ++++++- bridge/native/test_rea_lldb_tracer.py | 132 ++++++++++++++++++ docs/native-investigation.md | 3 + src/native/LldbCallTracer.ts | 16 ++- .../native/lldbCallTracerFailures.test.ts | 39 ++++++ 5 files changed, 233 insertions(+), 9 deletions(-) diff --git a/bridge/native/rea_lldb_tracer.py b/bridge/native/rea_lldb_tracer.py index fcec3ea92..5495b2fd5 100644 --- a/bridge/native/rea_lldb_tracer.py +++ b/bridge/native/rea_lldb_tracer.py @@ -555,6 +555,38 @@ def _failed_launch_description(process): return None +def _retire_failed_launch(process, config, listener, stdout, stderr): + """Reconcile an SBProcess returned alongside a failed Launch error.""" + if not process.IsValid(): + stdout.close() + stderr.close() + return False + + pid = process.GetProcessID() + if pid > 0: + # Even an already-exited process needs its PID published so the host + # can distinguish a completed launch failure from an unknown target. + with open(config["pid_path"], "w") as handle: + handle.write(str(pid)) + + if process.GetState() != lldb.eStateExited: + stdout.mark_launched() + stderr.mark_launched() + if pid > 0: + # Hold a surviving target until the host has captured its start + # identity. If that proof is unavailable, Kill still addresses + # this exact SBProcess created by the current launch request. + _wait_for_identity_ack(config) + process.Kill() + _wait_for_exit(listener, process, 5) + + exit_observed = process.GetState() == lldb.eStateExited + expected_exit = exit_observed + stdout.close(expected_exit=expected_exit) + stderr.close(expected_exit=expected_exit) + return exit_observed + + def trace(debugger, config): global _TRACE_CONTEXT debugger.SetAsync(True) @@ -630,8 +662,9 @@ def trace(debugger, config): ) process = target.Launch(_launch_info(target, config, listener), error) if not error.Success() or not process.IsValid(): - stdout.close() - stderr.close() + exit_observed = _retire_failed_launch( + process, config, listener, stdout, stderr + ) return { "status": "launch-error", "error": error.GetCString(), @@ -640,15 +673,26 @@ def trace(debugger, config): if process.IsValid() else {} ), + **( + {"target_process_exit_observed": True} + if exit_observed + else {} + ), } launch_failure = _failed_launch_description(process) if launch_failure is not None: - stdout.close(expected_exit=True) - stderr.close(expected_exit=True) + exit_observed = _retire_failed_launch( + process, config, listener, stdout, stderr + ) return { "status": "launch-error", "error": launch_failure, "target_process_created": True, + **( + {"target_process_exit_observed": True} + if exit_observed + else {} + ), } stdout.mark_launched() stderr.mark_launched() diff --git a/bridge/native/test_rea_lldb_tracer.py b/bridge/native/test_rea_lldb_tracer.py index 308ed0033..2fe616b1e 100644 --- a/bridge/native/test_rea_lldb_tracer.py +++ b/bridge/native/test_rea_lldb_tracer.py @@ -15,6 +15,8 @@ fake_lldb = sys.modules.setdefault("lldb", types.ModuleType("lldb")) fake_lldb.LLDB_INVALID_ADDRESS = -1 fake_lldb.eLaunchFlagDebug = 2 fake_lldb.eLaunchFlagStopAtEntry = 4 +fake_lldb.eStateExited = 10 +fake_lldb.SBEvent = lambda: object() spec = importlib.util.spec_from_file_location( "rea_lldb_tracer", os.path.join(os.path.dirname(__file__), "rea_lldb_tracer.py") ) @@ -23,6 +25,136 @@ spec.loader.exec_module(tracer) class NativeTraceBudgetTests(unittest.TestCase): + def test_failed_launch_does_not_infer_exit_from_a_failed_kill(self): + class Process: + def IsValid(self): + return True + + def GetProcessID(self): + return 12345 + + def GetState(self): + return 6 + + def Kill(self): + return None + + class Sink: + close_exit = None + + def mark_launched(self): + pass + + def close(self, expected_exit=False): + self.close_exit = expected_exit + + with tempfile.TemporaryDirectory() as directory: + stdout, stderr = Sink(), Sink() + with patch.object(tracer, "_wait_for_identity_ack", return_value="unavailable"), patch.object( + tracer, "_wait_for_exit", return_value=None + ): + exited = tracer._retire_failed_launch( + Process(), {"pid_path": os.path.join(directory, "pid")}, object(), stdout, stderr + ) + self.assertFalse(exited) + self.assertFalse(stdout.close_exit) + self.assertFalse(stderr.close_exit) + + def test_failed_launch_publishes_pid_and_retires_live_process(self): + class Process: + state = 6 + killed = False + + def IsValid(self): + return True + + def GetProcessID(self): + return 12345 + + def GetState(self): + return self.state + + def Kill(self): + self.killed = True + self.state = tracer.lldb.eStateExited + + class Sink: + launched = False + close_exit = None + + def mark_launched(self): + self.launched = True + + def close(self, expected_exit=False): + self.close_exit = expected_exit + + with tempfile.TemporaryDirectory() as directory: + pid_path = os.path.join(directory, "pid") + process = Process() + stdout, stderr = Sink(), Sink() + + def acknowledge(config): + with open(config["pid_path"], "r") as pid_file: + self.assertEqual(pid_file.read(), "12345") + return "readable" + + with patch.object(tracer, "_wait_for_identity_ack", acknowledge): + exited = tracer._retire_failed_launch( + process, + {"pid_path": pid_path}, + object(), + stdout, + stderr, + ) + + self.assertTrue(exited) + self.assertTrue(process.killed) + self.assertTrue(stdout.launched) + self.assertTrue(stderr.launched) + self.assertTrue(stdout.close_exit) + self.assertTrue(stderr.close_exit) + + def test_failed_launch_records_when_valid_process_is_already_exited(self): + class Process: + def IsValid(self): + return True + + def GetProcessID(self): + return 12345 + + def GetState(self): + return tracer.lldb.eStateExited + + def Kill(self): + raise AssertionError("an exited process must not be killed") + + class Sink: + close_exit = None + + def mark_launched(self): + raise AssertionError("an exited process has no output to drain") + + def close(self, expected_exit=False): + self.close_exit = expected_exit + + with tempfile.TemporaryDirectory() as directory: + pid_path = os.path.join(directory, "pid") + stdout, stderr = Sink(), Sink() + with patch.object( + tracer, + "_wait_for_identity_ack", + side_effect=AssertionError("exit is already observed"), + ): + exited = tracer._retire_failed_launch( + Process(), {"pid_path": pid_path}, object(), stdout, stderr + ) + with open(pid_path, "r") as pid_file: + self.assertEqual(pid_file.read(), "12345") + + self.assertTrue(exited) + self.assertTrue(stdout.close_exit) + self.assertTrue(stderr.close_exit) + def test_pre_target_integrity_failure_reports_no_process_created(self): original_debugger = getattr(tracer.lldb, "SBDebugger", None) diff --git a/docs/native-investigation.md b/docs/native-investigation.md index dfb4de356..1dc9bb046 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -451,6 +451,9 @@ The run ends when the process exits, `max_events` entries are recorded, Bounded termination reports partial coverage. REA checks process termination using a launch-time OS start identity; if ownership or cleanup cannot be verified, the result or error preserves that uncertainty. The result reports: +Launch errors keep the fact that LLDB created a process separate from whether +it remains alive. An exited `SBProcess` is reported as exited only after LLDB +confirms that state; unresolved ownership remains retained for cleanup retry. - the outcome, exit status and captured stdout/stderr (1 MiB kept per stream while excess bytes are drained and counted without growing capture files). diff --git a/src/native/LldbCallTracer.ts b/src/native/LldbCallTracer.ts index 126852c7e..def9bcb49 100644 --- a/src/native/LldbCallTracer.ts +++ b/src/native/LldbCallTracer.ts @@ -124,6 +124,7 @@ const tracerOutputSchema = z.discriminatedUnion("status", [ ]), error: z.string().nullable(), target_process_created: z.boolean().optional(), + target_process_exit_observed: z.boolean().optional(), }), ]); @@ -421,11 +422,16 @@ export class LldbCallTracer implements NativeCallTracer { exited.kind === "exited" ? await readTracerOutput(paths.result) : undefined; - let targetTermination: TargetTermination = exited.targetLaunched - ? await ensureTerminated(paths.pid, exited.targetIdentity, true) - : "terminated"; - // Only an explicit bridge phase fact may prove that no target process was - // created. A missing PID or an integrity-error status alone is ambiguous. + // An explicit bridge observation can prove no process was created or that + // the created process exited. A missing PID alone remains ambiguous. + const bridgeObservedExit = + output !== undefined && + output.status !== "traced" && + output.target_process_exit_observed === true; + let targetTermination: TargetTermination = + exited.targetLaunched && !bridgeObservedExit + ? await ensureTerminated(paths.pid, exited.targetIdentity, true) + : "terminated"; if ( output !== undefined && output.status !== "traced" && diff --git a/tests/boundary/providers/native/lldbCallTracerFailures.test.ts b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts index a365f6773..10ec53849 100644 --- a/tests/boundary/providers/native/lldbCallTracerFailures.test.ts +++ b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts @@ -277,6 +277,45 @@ describe("LLDB pre-target phase proof", () => { }); 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(