cli: keep Ctrl+C a plain KeyboardInterrupt under asyncio.run

asyncio.run (Runner) replaces SIGINT with its own handler when the
current one is signal.default_int_handler: the first Ctrl+C silently
cancels the main task (invisible at the REPL prompt, leaving the task
marked cancelled) and the second raises KeyboardInterrupt, so a Ctrl+D
exit after a cancel was cancelled mid-memory.close() (SQLAlchemy
CancelledError traceback) and the CLI died with "Aborted!".

Install a distinct KeyboardInterrupt-raising handler before asyncio.run
so Runner leaves SIGINT alone, and have run_cancellable restore the
previous handler after each turn (remove_signal_handler leaves SIG_DFL,
which would kill the process on a stray Ctrl+C between turns).
This commit is contained in:
2026-08-11 21:13:17 +08:00
parent a197a67142
commit 1973ef93bb
4 changed files with 112 additions and 0 deletions
+8
View File
@@ -17,6 +17,7 @@ from plyngent.cli.editor import (
resolve_config_path,
)
from plyngent.cli.exit_codes import EXIT_CANCELLED, EXIT_OK, EXIT_TURN_FAILED
from plyngent.cli.interrupt import install_keyboard_interrupt_sigint
from plyngent.cli.limits import install_cli_limit_hooks
from plyngent.cli.repl import run_repl
from plyngent.cli.retry import run_user_text_with_retries
@@ -484,6 +485,13 @@ def chat_cmd(
raise click.ClickException(msg)
root = workspace if workspace is not None else Path.cwd()
# asyncio.run installs its own SIGINT handler when the current one is the
# default: the first Ctrl+C then silently cancels the main task (invisible
# at the REPL prompt) and the second raises KeyboardInterrupt, which also
# cancels memory.close() on a Ctrl+D exit ("Aborted!"). Pre-install a plain
# KeyboardInterrupt-raising handler so Ctrl+C stays a normal KeyboardInterrupt
# that the REPL / turn-retry / oneshot code handles deliberately.
install_keyboard_interrupt_sigint()
code = asyncio.run(
_run_chat(
config_path=config_path,
+27
View File
@@ -29,6 +29,33 @@ def set_sigint_reinstall(callback: Callable[[], None] | None) -> None:
_reinstall_holder[0] = callback
def raise_keyboard_interrupt_sigint(signum: int, frame: FrameType | None) -> None:
"""SIGINT handler: raise KeyboardInterrupt (same effect as the default).
A distinct function object — deliberately *not*
``signal.default_int_handler`` — so that :func:`asyncio.run`'s Runner does
not recognise it and install its own silent-cancel handler on top.
"""
del signum, frame
raise KeyboardInterrupt
def install_keyboard_interrupt_sigint() -> None:
"""Install :func:`raise_keyboard_interrupt_sigint` as the SIGINT handler.
``asyncio.run`` (Runner) replaces the SIGINT handler with its own when the
current one *is* ``signal.default_int_handler``: the first Ctrl+C then
silently cancels the main task (invisible at the REPL prompt, leaving the
task marked cancelled) and only the second raises KeyboardInterrupt. A
later Ctrl+D (EOF) is then cancelled mid-``memory.close()`` and the CLI
exits with ``Aborted!``. Installing a plain KeyboardInterrupt-raising
handler first keeps every Ctrl+C a normal KeyboardInterrupt that the CLI
(prompt, turn-retry, oneshot) already handles deliberately.
"""
with contextlib.suppress(ValueError):
_ = signal.signal(signal.SIGINT, raise_keyboard_interrupt_sigint)
@contextmanager
def pause_task_cancel_for_prompt() -> Generator[None]:
"""Disable turn-task cancel during blocking TTY prompts (confirm, etc.).
+7
View File
@@ -64,6 +64,11 @@ async def run_cancellable[T](coro: Coroutine[object, object, T]) -> T:
task: asyncio.Task[T] = asyncio.create_task(coro)
loop = asyncio.get_running_loop()
installed = False
# What to restore once the turn-cancel handler is removed. remove_signal_handler
# leaves SIG_DFL, which would terminate the process on a stray Ctrl+C between
# turns; restore the handler from before (the CLI installs a
# KeyboardInterrupt-raising one so the REPL can catch it).
previous_sigint = signal.getsignal(signal.SIGINT)
def _on_sigint() -> None:
# allow_task_cancel() uses a process-level depth counter (not ContextVar)
@@ -98,6 +103,8 @@ async def run_cancellable[T](coro: Coroutine[object, object, T]) -> T:
if installed:
with contextlib.suppress(NotImplementedError, RuntimeError, ValueError):
_ = loop.remove_signal_handler(signal.SIGINT)
with contextlib.suppress(ValueError):
_ = signal.signal(signal.SIGINT, previous_sigint)
# Only cancel if still running (e.g. KeyboardInterrupt path above).
# Do not cancel a finished task — that would mask success.
if not task.done():