fix(native): report absent launch process before cleanup classification

This commit is contained in:
morluto
2026-10-24 04:05:13 +08:00
parent 301993db04
commit a2d0af6023
4 changed files with 137 additions and 6 deletions
+3 -6
View File
@@ -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
+67
View File
@@ -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):
+3
View File
@@ -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.
@@ -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;