cli: keep REPL alive on stray Ctrl+C outside turn calls

After a turn the run_cancellable SIGINT handler is removed, so a Ctrl+C
landing between turns (echoing user text, expire_yolo_once, gaps between
statements, or re-entering _echo_interrupted) raised KeyboardInterrupt at
an arbitrary bytecode and escaped run_repl, killing the whole REPL. Wrap
the loop body in one try/except KeyboardInterrupt that re-prompts, and
make _echo_interrupted itself suppress a further interrupt.
This commit is contained in:
2026-08-11 20:54:13 +08:00
parent 28a191cd79
commit a197a67142
2 changed files with 73 additions and 30 deletions
+33 -30
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import click import click
@@ -23,9 +24,13 @@ def _echo_user(text: str) -> None:
def _echo_interrupted() -> None: def _echo_interrupted() -> None:
click.echo() # A further Ctrl+C during this output must stay benign (same rule as
click.secho("interrupted", fg="yellow") # retry._echo_cancel_lines): after a turn the SIGINT handler is removed, so
click.echo() # the next Ctrl+C raises KeyboardInterrupt at an arbitrary bytecode.
with contextlib.suppress(KeyboardInterrupt):
click.echo()
click.secho("interrupted", fg="yellow")
click.echo()
async def run_repl(state: ReplState) -> None: async def run_repl(state: ReplState) -> None:
@@ -49,33 +54,31 @@ async def run_repl(state: ReplState) -> None:
click.echo() click.echo()
break break
if entry is None: # Between turns the turn-task SIGINT handler is removed (run_cancellable),
continue # so a stray Ctrl+C raises KeyboardInterrupt at whatever bytecode is
# executing — echoing user text, expire_yolo_once, gaps between
if entry.startswith("/"): # statements. Swallow it and re-prompt instead of exiting the REPL.
try:
cont = await handle_slash(state, entry)
except KeyboardInterrupt:
_echo_interrupted()
continue
if not cont:
break
if state.pending_user_text is not None:
text = state.pending_user_text
state.pending_user_text = None
_echo_user(text)
try:
_ = await run_user_text_with_retries(state.agent, text)
except KeyboardInterrupt:
_echo_interrupted()
finally:
state.expire_yolo_once()
continue
_echo_user(entry)
try: try:
_ = await run_user_text_with_retries(state.agent, entry) if entry is None:
continue
if entry.startswith("/"):
cont = await handle_slash(state, entry)
if not cont:
break
if state.pending_user_text is not None:
text = state.pending_user_text
state.pending_user_text = None
_echo_user(text)
try:
_ = await run_user_text_with_retries(state.agent, text)
finally:
state.expire_yolo_once()
continue
_echo_user(entry)
try:
_ = await run_user_text_with_retries(state.agent, entry)
finally:
state.expire_yolo_once()
except KeyboardInterrupt: except KeyboardInterrupt:
_echo_interrupted() _echo_interrupted()
finally:
state.expire_yolo_once()
+40
View File
@@ -593,3 +593,43 @@ async def test_repl_ctrl_c_from_turn_returns_to_prompt(
await run_repl(state) # must return normally (EOF), never raise KeyboardInterrupt await run_repl(state) # must return normally (EOF), never raise KeyboardInterrupt
assert calls["reads"] == 2 assert calls["reads"] == 2
async def test_repl_second_ctrl_c_after_cancel_returns_to_prompt(
state: ReplState,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""A second Ctrl+C landing outside the turn call (e.g. while echoing the
next user input, right after a cancel) must return to the prompt, not exit."""
from plyngent.cli import repl as repl_mod
from plyngent.cli.repl import run_repl
reads = ["first", "second", "third"]
calls: dict[str, int] = {"reads": 0, "echoes": 0}
def fake_read_entry() -> str:
calls["reads"] += 1
if calls["reads"] > len(reads):
raise EOFError
return reads[calls["reads"] - 1]
async def cancel_turn(_agent: object, _text: str) -> bool:
raise KeyboardInterrupt # first Ctrl+C: cancels every turn
def echo_user(text: str) -> None:
calls["echoes"] += 1
if calls["echoes"] == 2:
# second Ctrl+C lands while echoing user text — outside the turn
# call, previously unguarded (used to exit the whole REPL).
raise KeyboardInterrupt
monkeypatch.setattr(repl_mod, "setup_readline", lambda _state: None)
monkeypatch.setattr(repl_mod, "read_repl_entry", fake_read_entry)
monkeypatch.setattr(repl_mod, "run_user_text_with_retries", cancel_turn)
monkeypatch.setattr(repl_mod, "_echo_user", echo_user)
await run_repl(state) # must return normally (EOF), never raise KeyboardInterrupt
assert calls["reads"] == 4 # three entries + EOF
assert calls["echoes"] == 3 # the interrupting echo still counted, loop survived
assert "interrupted" in capsys.readouterr().out