From a2d0af602321ba60fadf7c098231f80ce165a53d Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:05:13 +0800 Subject: [PATCH] fix(native): report absent launch process before cleanup classification --- bridge/native/rea_lldb_tracer.py | 9 +-- bridge/native/test_rea_lldb_tracer.py | 67 +++++++++++++++++++ docs/native-investigation.md | 3 + .../native/lldbCallTracerFailures.test.ts | 64 ++++++++++++++++++ 4 files changed, 137 insertions(+), 6 deletions(-) diff --git a/bridge/native/rea_lldb_tracer.py b/bridge/native/rea_lldb_tracer.py index 5495b2fd59..dccceb56fe 100644 --- a/bridge/native/rea_lldb_tracer.py +++ b/bridge/native/rea_lldb_tracer.py @@ -661,18 +661,15 @@ def trace(debugger, config): open(observation_path, "wb") ) process = target.Launch(_launch_info(target, config, listener), error) - if not error.Success() or not process.IsValid(): + process_created = process.IsValid() + if not error.Success() or not process_created: exit_observed = _retire_failed_launch( process, config, listener, stdout, stderr ) return { "status": "launch-error", "error": error.GetCString(), - **( - {"target_process_created": True} - if process.IsValid() - else {} - ), + "target_process_created": process_created, **( {"target_process_exit_observed": True} if exit_observed diff --git a/bridge/native/test_rea_lldb_tracer.py b/bridge/native/test_rea_lldb_tracer.py index 2fe616b1e4..9cce55ff28 100644 --- a/bridge/native/test_rea_lldb_tracer.py +++ b/bridge/native/test_rea_lldb_tracer.py @@ -25,6 +25,73 @@ spec.loader.exec_module(tracer) class NativeTraceBudgetTests(unittest.TestCase): + def test_invalid_launch_process_reports_explicit_no_creation(self): + class InvalidProcess: + def IsValid(self): + return False + + def Kill(self): + raise AssertionError("an invalid process cannot own target cleanup") + + class LaunchError: + def Success(self): + return False + + def GetCString(self): + return "No such file or directory: fixture/absent" + + class Target: + def IsValid(self): + return True + + def Launch(self, _launch_info, _error): + return InvalidProcess() + + class Debugger: + def SetAsync(self, _enabled): + pass + + def GetInstanceName(self): + return "fixture" + + def CreateTarget(self, *_arguments): + return Target() + + class Pipe: + def __init__(self, *_arguments): + self.closed = False + pipes.append(self) + + def start(self): + pass + + def close(self, expected_exit=False): + self.closed = True + + pipes = [] + debugger_api = types.SimpleNamespace(SetInternalVariable=lambda *_arguments: None) + with patch.object(tracer.lldb, "SBDebugger", debugger_api, create=True), patch.object( + tracer.lldb, "SBError", LaunchError, create=True + ), patch.object(tracer.lldb, "SBListener", lambda _name: object(), create=True), patch.object( + tracer, "_hash_file", return_value="expected" + ), patch.object(tracer, "_file_identity", return_value=(1, 2)), patch.object( + tracer, "_module_identity", return_value=("fixture", (1, 2), "fixture-uuid") + ), patch.object(tracer, "_launch_info", return_value=object()), patch.object( + tracer, "_OutputPipe", Pipe + ): + result = tracer.trace(Debugger(), { + "executable": "fixture", "architecture": "arm64", "expected_sha256": "expected", + "breakpoints": [], "working_directory": "fixture/absent", + "stdout_path": "stdout", "stdout_capture_path": "stdout-capture", + "stderr_path": "stderr", "stderr_capture_path": "stderr-capture", + }) + self.assertEqual(result["status"], "launch-error") + self.assertEqual(result["error"], "No such file or directory: fixture/absent") + self.assertIs(result["target_process_created"], False) + self.assertNotIn("target_process_exit_observed", result) + self.assertEqual(len(pipes), 2) + self.assertTrue(all(pipe.closed for pipe in pipes)) + def test_failed_launch_does_not_infer_exit_from_a_failed_kill(self): class Process: def IsValid(self): diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 48679e3c3b..998e8da57a 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -769,6 +769,9 @@ 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. +An invalid process returned by `Launch`, including a rejected working directory, +reports explicit no-process creation. With no target PID or other retained +resource, REA releases that runtime and admits the next observation. Cancellation observed while output and Evidence are finalized returns partial cancellation Evidence, even if the target has already exited. diff --git a/tests/boundary/providers/native/lldbCallTracerFailures.test.ts b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts index 5620c644ac..59d2a2c76f 100644 --- a/tests/boundary/providers/native/lldbCallTracerFailures.test.ts +++ b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts @@ -842,6 +842,70 @@ describe("LLDB late timeout publication", () => { }); describe("LLDB pre-target phase proof", () => { + it("releases an invalid-process launch failure and permits the next complete observation", async () => { + let launches = 0; + const runtimes: string[] = []; + const tracer = new LldbCallTracer( + {}, + async (_executable, arguments_) => { + const { config } = await configFromArguments(arguments_); + launches += 1; + runtimes.push(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( + launches === 1 + ? { + status: "launch-error", + error: "No such file or directory: fixture/absent", + target_process_created: false, + } + : successfulTracerOutput, + ), + ); + return { + kind: "exited", + exitCode: launches === 1 ? 1 : 0, + output: "", + targetLaunched: launches === 1, + targetIdentity: undefined, + cleanupFailure: undefined, + }; + }, + async () => ok(fakeTool), + ); + try { + const rejected = await tracer.trace(request); + expect(rejected.ok).toBe(false); + if (rejected.ok) throw new Error("Expected launch refusal"); + expect(projectAnalysisError(rejected.error)).toMatchObject({ + code: "capability_unavailable", + details: { + reason: "launch-failed: No such file or directory: fixture/absent", + }, + }); + expect(rejected.error.cleanupIncomplete).toBe(false); + expect(rejected.error.cleanupResources).toEqual([]); + await expect(access(runtimes[0]!)).rejects.toMatchObject({ + code: "ENOENT", + }); + const completed = await tracer.trace(request); + expect(completed.ok).toBe(true); + if (!completed.ok) throw completed.error; + expect(completed.value.run).toMatchObject(successfulTracerOutput); + expect(launches).toBe(2); + await expect(access(runtimes[1]!)).rejects.toMatchObject({ + code: "ENOENT", + }); + expect((await tracer.close()).ok).toBe(true); + } finally { + await tracer.close(); + } + }); + it("releases a proven pre-target integrity rejection and allows tracer reuse", async () => { let launches = 0; let firstRuntime: string | undefined;