diff --git a/README.md b/README.md index a80c89e..82213c0 100644 --- a/README.md +++ b/README.md @@ -317,7 +317,7 @@ Discovery reports backend and protocol capabilities such as: - `command.stdout.streaming` - `command.stderr.separate` - `command.events.streaming.best_effort` -- `command.exit_code.native` +- `command.exit_code` - `command.cancel.best_effort` - `shell.cmd.available` - `shell.bash.available` diff --git a/src/liveshell/capabilities.py b/src/liveshell/capabilities.py index 11d5ff8..2fe4d90 100644 --- a/src/liveshell/capabilities.py +++ b/src/liveshell/capabilities.py @@ -22,7 +22,19 @@ def discover_capabilities() -> list[Capability]: Capability("command.async", True), Capability("command.poll", True), Capability("command.timeout", True), - Capability("command.exit_code.native", True), + Capability( + "command.exit_code", + True, + { + # cmd/bash surface the shell's real exit status ($?/%ERRORLEVEL%), + # but it is scraped from a stdout sentinel, not an OS-native process + # exit. Only hosted PowerShell reads it natively in-process + # ($LASTEXITCODE + HadErrors via the .NET runspace). + "cmd": "sentinel_parsed", + "bash": "sentinel_parsed", + "hosted_powershell": "native", + }, + ), Capability( "daemon.protocol", True, diff --git a/src/liveshell/daemon.py b/src/liveshell/daemon.py index 2b408e2..5df2f31 100644 --- a/src/liveshell/daemon.py +++ b/src/liveshell/daemon.py @@ -632,6 +632,22 @@ def _run_command_worker( if self._is_terminal(command_id): return + # Handle the case where cancel_command killed the process before the + # while loop above detected cancel_event. The exec_thread may finish + # (process killed → non-zero exit) and exit the loop via + # exec_thread.is_alive() becoming False before cancel_event.is_set() + # is checked. Without this guard the worker would call _finish_failed + # and win the race against _finish_canceled in cancel_command. + if cancel_event.is_set(): + self._finish_canceled_if_active( + command_id, + reason="cancel_requested", + session=session, + termination_strategy="process_killed", + close_session=True, + ) + return + error = result_holder.get("error") if error is not None: error_text = str(error) diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 8cd0954..27e7200 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -57,6 +57,21 @@ def test_daemon_protocol_advertises_both_transports(self) -> None: # There is still no remote/auto-started network server. self.assertFalse(details["remote_network"]) + def test_exit_code_capability_distinguishes_native_from_sentinel(self) -> None: + payload = [capability.to_dict() for capability in discover_capabilities()] + names = {item["name"] for item in payload} + # The misleading unconditional `command.exit_code.native` is gone; the + # capability now states the mechanism per backend. + self.assertNotIn("command.exit_code.native", names) + exit_code = next(item for item in payload if item["name"] == "command.exit_code") + self.assertTrue(exit_code["available"]) + details = exit_code["details"] + # cmd/bash scrape the exit status from a stdout sentinel; only hosted + # PowerShell reads it natively in-process. + self.assertEqual(details["cmd"], "sentinel_parsed") + self.assertEqual(details["bash"], "sentinel_parsed") + self.assertEqual(details["hosted_powershell"], "native") + if __name__ == "__main__": unittest.main()