Summary
Pressing Ctrl+C to interrupt an in-flight gateway turn can crash the turn with an unexpected ConnectionError instead of cancelling it gracefully, breaking the REPL's "cancel and re-prompt" contract.
Reproduction
- Start a CLI chat session connected to the gateway.
- Let the gateway connection drop (gateway restart, network blip) so
GatewayClient._ws is gone / _connection_error is set.
- Press Ctrl+C while the turn is still awaiting stream events.
Root cause
In src/opensquilla/cli/chat/turn_stream.py (gateway stream loop, around line 1182), the cancellation handler awaits client.abort_session(...) inside the except (KeyboardInterrupt, asyncio.CancelledError) block:
except (KeyboardInterrupt, asyncio.CancelledError):
stream_deps.cancel_clearer()
await client.abort_session(session_key) # <-- may raise
cancelled = True
GatewayClient._call() raises ConnectionError("Gateway connection lost; ...") when the connection is already gone (src/opensquilla/cli/gateway_client.py, _call error path, around lines 634-639).
An exception raised inside an except block escapes it — the sibling except Exception handler can no longer catch it — so the turn never reaches TurnResult(cancelled=True), renderer_finalize is skipped, and the REPL surfaces an unexpected ConnectionError (in the TUI, violating the invariant documented in tests/unit/cli/tui/test_native_input.py that KeyboardInterrupt must cancel the turn and re-prompt, never exit).
Interrupting a turn whose connection just died is exactly the scenario where users press Ctrl+C, so this is easy to hit in practice.
Suggested fix
Wrap the abort_session call in try/except Exception inside the cancellation handler: the turn is already cancelled locally, and the abort RPC is best-effort:
except (KeyboardInterrupt, asyncio.CancelledError):
stream_deps.cancel_clearer()
try:
await client.abort_session(session_key)
except Exception:
# The gateway connection may already be gone (that is
# often why the user interrupted). The turn is still
# cancelled locally so the REPL can re-prompt.
pass
cancelled = True
Regression test
test_gateway_stream_interrupt_tolerates_abort_failure (in tests/test_cli/test_chat_cmd.py) — send_message raises KeyboardInterrupt, abort_session raises ConnectionError; asserts TurnResult.cancelled is True.
Verified: the new test fails on the unfixed code and passes with the fix; all 63 tests in tests/test_cli/test_chat_cmd.py pass.
Summary
Pressing Ctrl+C to interrupt an in-flight gateway turn can crash the turn with an unexpected
ConnectionErrorinstead of cancelling it gracefully, breaking the REPL's "cancel and re-prompt" contract.Reproduction
GatewayClient._wsis gone /_connection_erroris set.Root cause
In
src/opensquilla/cli/chat/turn_stream.py(gateway stream loop, around line 1182), the cancellation handler awaitsclient.abort_session(...)inside theexcept (KeyboardInterrupt, asyncio.CancelledError)block:GatewayClient._call()raisesConnectionError("Gateway connection lost; ...")when the connection is already gone (src/opensquilla/cli/gateway_client.py,_callerror path, around lines 634-639).An exception raised inside an
exceptblock escapes it — the siblingexcept Exceptionhandler can no longer catch it — so the turn never reachesTurnResult(cancelled=True),renderer_finalizeis skipped, and the REPL surfaces an unexpectedConnectionError(in the TUI, violating the invariant documented intests/unit/cli/tui/test_native_input.pythat KeyboardInterrupt must cancel the turn and re-prompt, never exit).Interrupting a turn whose connection just died is exactly the scenario where users press Ctrl+C, so this is easy to hit in practice.
Suggested fix
Wrap the
abort_sessioncall intry/except Exceptioninside the cancellation handler: the turn is already cancelled locally, and the abort RPC is best-effort:Regression test
test_gateway_stream_interrupt_tolerates_abort_failure(intests/test_cli/test_chat_cmd.py) —send_messageraisesKeyboardInterrupt,abort_sessionraisesConnectionError; assertsTurnResult.cancelled is True.Verified: the new test fails on the unfixed code and passes with the fix; all 63 tests in
tests/test_cli/test_chat_cmd.pypass.