Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
14 changes: 13 additions & 1 deletion src/liveshell/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions src/liveshell/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions tests/test_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading