cli/interrupt: route SIGINT through a session handler

A SIGINT delivered while the event loop is parked in its selector is raised in
the loop's own frame: it escapes run_until_complete, the Runner cancels the main
task, and the REPL is left half-shut-down (no prompt, and the next line the
human types is eaten by an abandoned prompt reader) until the following Ctrl+C
kills the process. Off-loop prompts (ask_user_* / wait / tool confirms run in a
worker thread via asyncio.to_thread) and the auto-retry countdown are the usual
windows.

Keep one asyncio-level SIGINT handler installed for the whole session instead:
it cancels the innermost registered target (run_cancellable registers the turn
task, sleep_cancellable the retry countdown, off_loop_prompt the waiting prompt)
and never raises. Reads that block on the main thread inside a coroutine (the
REPL entry prompt, synchronous click prompts) suspend the router and take SIGINT
as a KeyboardInterrupt. Off-loop readline prompts cannot be interrupted without
abandoning their reader thread on stdin, so they ignore SIGINT with a one-line
hint.
This commit is contained in:
2026-09-22 19:16:05 +08:00
parent 0790b5dd9f
commit 6ec7b4de12
6 changed files with 434 additions and 228 deletions
+11 -1
View File
@@ -17,7 +17,11 @@ 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.interrupt import (
install_keyboard_interrupt_sigint,
install_sigint_router,
uninstall_sigint_router,
)
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
@@ -251,6 +255,11 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
yolo: YoloMode | None = "on" if yes else None
memory = await MemoryStore.open(_database_config(store, quiet=quiet or oneshot))
# A SIGINT must never surface as a KeyboardInterrupt raised in the event
# loop's own frame (it would tear this run — and the shutdown below — down
# half-finished). The router cancels the innermost registered target instead
# and stays installed through cleanup.
_ = install_sigint_router()
try:
from plyngent.cli.provider_recovery import ensure_provider_ready
@@ -402,6 +411,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
from plyngent.tools.process.pty_session import PtyManager
PtyManager.close_all()
uninstall_sigint_router()
def _configure_logging(level: str) -> None:
+166 -52
View File
@@ -4,7 +4,10 @@ import asyncio
import contextlib
import signal
from contextlib import contextmanager
from typing import TYPE_CHECKING
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Protocol
import click
if TYPE_CHECKING:
from collections.abc import Callable, Generator
@@ -12,21 +15,17 @@ if TYPE_CHECKING:
type SigHandler = Callable[[int, FrameType | None], None] | int | None
# Depth of nested pause_task_cancel_for_prompt. Must be a plain int (not ContextVar):
# asyncio.add_signal_handler freezes the ContextVar snapshot at install time, so a
# ContextVar reset after reinstall would leave SIGINT permanently non-cancelling.
_prompt_depth: int = 0
_reinstall_holder: list[Callable[[], None] | None] = [None]
class InterruptTarget(Protocol):
"""Something a pending SIGINT can cancel (a task, a prompt read, a hint)."""
def allow_task_cancel() -> bool:
"""Whether the CLI SIGINT handler should cancel the in-flight turn task."""
return _prompt_depth == 0
def cancel(self) -> object:
"""Cancel the pending work; the return value is ignored.
def set_sigint_reinstall(callback: Callable[[], None] | None) -> None:
"""Register how to re-bind SIGINT after a TTY prompt (set by run_cancellable)."""
_reinstall_holder[0] = callback
Declared loosely (``object``) so ``asyncio.Task.cancel`` — which returns
``bool`` and takes an optional message — satisfies it.
"""
...
def raise_keyboard_interrupt_sigint(signum: int, frame: FrameType | None) -> None:
@@ -56,57 +55,172 @@ def install_keyboard_interrupt_sigint() -> None:
_ = signal.signal(signal.SIGINT, raise_keyboard_interrupt_sigint)
# --------------------------------------------------------------------------- #
# SIGINT routing (session scope)
# --------------------------------------------------------------------------- #
@dataclass(slots=True)
class _RouterState:
"""Session SIGINT routing state (one instance, kept in ``_router``)."""
# Cancel targets, innermost last: a SIGINT cancels the innermost one.
targets: list[InterruptTarget] = field(default_factory=list)
installed: bool = False
# Nesting depth of main-thread blocking reads. A plain counter (not a
# ContextVar): asyncio.add_signal_handler freezes the ContextVar snapshot at
# install time, so a ContextVar reset after reinstall would leave SIGINT
# permanently routed to the wrong place.
blocking_reads: int = 0
_router = _RouterState()
def sigint_routed() -> bool:
"""Whether the session SIGINT router currently owns SIGINT."""
return _router.installed
def install_sigint_router() -> bool:
"""Install the session SIGINT handler; False when the loop cannot take it.
A SIGINT delivered while the event loop is parked in its selector is raised
in the *loop's* frame: it escapes ``run_until_complete``, the Runner cancels
the main task and the REPL (or a half-finished shutdown) is left broken —
the next Ctrl+C then kills the process. Keeps an asyncio-level SIGINT
handler installed for the whole session instead: it cancels the innermost
target registered with :func:`sigint_cancels` and never raises.
Blocking main-thread reads suspend the router via
:func:`pause_task_cancel_for_prompt`. Platforms without asyncio signal
handlers (Windows proactor) return False and keep the plain
KeyboardInterrupt behaviour. Idempotent.
"""
if _router.installed:
return True
if not _add_router_handler():
return False
_router.installed = True
return True
def uninstall_sigint_router() -> None:
"""Drop the session SIGINT handler and restore the KeyboardInterrupt one."""
if not _router.installed:
return
_router.installed = False
_remove_router_handler()
_router.targets.clear()
install_keyboard_interrupt_sigint()
def _add_router_handler() -> bool:
try:
asyncio.get_running_loop().add_signal_handler(signal.SIGINT, _route_sigint)
except RuntimeError, NotImplementedError, ValueError:
return False
return True
def _remove_router_handler() -> None:
with contextlib.suppress(NotImplementedError, RuntimeError, ValueError):
_ = asyncio.get_running_loop().remove_signal_handler(signal.SIGINT)
def _route_sigint() -> None:
"""Cancel the innermost target (none → ignore: never escape the loop)."""
if _router.targets:
_ = _router.targets[-1].cancel()
@contextmanager
def sigint_cancels(target: InterruptTarget) -> Generator[None]:
"""Route SIGINT to ``target.cancel()`` while inside (innermost wins)."""
_router.targets.append(target)
try:
yield
finally:
for index in range(len(_router.targets) - 1, -1, -1):
if _router.targets[index] is target:
del _router.targets[index]
break
@contextmanager
def pause_task_cancel_for_prompt() -> Generator[None]:
"""Disable turn-task cancel during blocking TTY prompts (confirm, etc.).
"""Give SIGINT to a blocking *main-thread* read instead of routing it.
Must run on the main thread (signal handlers are main-thread only).
Restores the default SIGINT handler so ``click.confirm`` can receive
KeyboardInterrupt / Abort instead of the asyncio turn being cancelled.
The read (REPL entry prompt, synchronous click confirm/prompt) runs on the
main thread inside a coroutine, so the resulting KeyboardInterrupt is raised
in the reader's own frame: its handlers see it, and neither the in-flight
turn nor the event loop is cancelled. Use it only around blocking reads — an
``await`` that parks the loop in its selector must keep the router installed
(:func:`install_sigint_router`).
Nested prompts are supported via a depth counter. The asyncio SIGINT
handler is reinstalled only after depth returns to 0, and only after
cancel is re-enabled, so the handler never freezes ``allow=False``.
Suspends the router; nested calls restore it when the outermost exits.
"""
global _prompt_depth # noqa: PLW0603
_prompt_depth += 1
loop_handler_removed = False
_router.blocking_reads += 1
resuming = _router.blocking_reads == 1 and _router.installed
if resuming:
_remove_router_handler()
previous: SigHandler = signal.SIG_DFL
try:
try:
loop = asyncio.get_running_loop()
_ = loop.remove_signal_handler(signal.SIGINT)
loop_handler_removed = True
except RuntimeError, NotImplementedError, ValueError:
loop_handler_removed = False
try:
previous = signal.getsignal(signal.SIGINT)
_ = signal.signal(signal.SIGINT, signal.default_int_handler)
except ValueError:
# Not on the main thread — skip OS signal rebinding.
previous = signal.SIG_DFL
previous = signal.getsignal(signal.SIGINT)
_ = signal.signal(signal.SIGINT, raise_keyboard_interrupt_sigint)
except ValueError:
# Not on the main thread — signal handlers cannot be rebound here.
previous = signal.SIG_DFL
try:
yield
finally:
with contextlib.suppress(ValueError):
_ = signal.signal(signal.SIGINT, previous)
_prompt_depth = max(0, _prompt_depth - 1)
# Reinstall only when fully out of prompts and cancel is allowed again.
# Order matters: decrement depth first so allow_task_cancel() is True
# before reinstall (and handlers never freeze allow=False).
if _prompt_depth == 0 and loop_handler_removed:
reinstall = _reinstall_holder[0]
if reinstall is not None:
with contextlib.suppress(RuntimeError, NotImplementedError, ValueError):
reinstall()
_router.blocking_reads = max(0, _router.blocking_reads - 1)
if resuming:
_ = _add_router_handler()
async def run_in_prompt_thread[**P, R](func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
"""Run a blocking TTY prompt off the event loop with cancel paused.
@dataclass(frozen=True, slots=True)
class _CancelHook:
"""SIGINT target for a poll-based prompt read (abort that read)."""
Pause/SIGINT rebinding happens on the main thread; only the prompt body
runs in a worker so the asyncio loop stays free and turn cancel is disabled.
cancel_read: Callable[[], None]
def cancel(self) -> None:
self.cancel_read()
@dataclass(slots=True)
class _WaitingPrompt:
"""SIGINT target for a prompt read that cannot be interrupted.
A readline read blocks inside the C library on a worker thread; aborting it
would abandon that thread on stdin, where it would eat the next line the
human types. Explaining itself once is the best available answer.
"""
with pause_task_cancel_for_prompt():
return await asyncio.to_thread(func, *args, **kwargs)
_hinted: bool = False
def cancel(self) -> None:
if self._hinted:
return
self._hinted = True
click.secho(
"Ctrl+C ignored while a prompt is waiting — answer it to continue",
fg="yellow",
err=True,
)
@contextmanager
def off_loop_prompt(cancel: Callable[[], None] | None = None) -> Generator[None]:
"""A human prompt is waiting in a worker thread.
While inside, SIGINT must not cancel the enclosing turn: poll-based reads
(``cancel`` hook) abort just that read, readline-based ones ignore it with a
one-line hint (see :class:`_WaitingPrompt`).
"""
target: InterruptTarget = _WaitingPrompt() if cancel is None else _CancelHook(cancel)
with sigint_cancels(target):
yield
+9 -5
View File
@@ -7,7 +7,7 @@ import sys
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
from plyngent.cli.interrupt import pause_task_cancel_for_prompt
from plyngent.cli.interrupt import off_loop_prompt, pause_task_cancel_for_prompt
from plyngent.prompting import (
ChoiceOption,
NonInteractiveError,
@@ -521,10 +521,14 @@ async def prompt_workspace_mismatch_async(
def install_cli_limit_hooks() -> None:
"""Register interactive continue hooks and prompt cancel-pause.
"""Register interactive continue hooks and prompt guards.
Command denylist policy confirm is set on :class:`~plyngent.tools.context.InstanceState`
when the REPL starts (no process-global hook).
Off-loop prompts (worker-thread reads) register their own SIGINT target so a
Ctrl+C there never cancels the enclosing turn; synchronous prompts read on
the main thread and take SIGINT as a KeyboardInterrupt instead. Command
denylist policy confirm is set on
:class:`~plyngent.tools.context.InstanceState` when the REPL starts (no
process-global hook).
"""
configure_prompting(pause_factory=pause_task_cancel_for_prompt)
configure_prompting(pause_factory=off_loop_prompt)
PtyManager.set_limit_continue_hook(prompt_continue_limit)
+13 -6
View File
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
import click
from plyngent.cli.input_text import read_repl_entry
from plyngent.cli.interrupt import pause_task_cancel_for_prompt
from plyngent.cli.readline_setup import setup_readline
from plyngent.cli.retry import run_user_text_with_retries
from plyngent.cli.slash import handle_slash
@@ -25,7 +26,7 @@ def _echo_user(text: str) -> None:
def _echo_interrupted() -> None:
# A further Ctrl+C during this output must stay benign (same rule as
# retry._echo_cancel_lines): after a turn the SIGINT handler is removed, so
# retry._echo_cancel_lines): on platforms without asyncio signal handlers
# the next Ctrl+C raises KeyboardInterrupt at an arbitrary bytecode.
with contextlib.suppress(KeyboardInterrupt):
click.echo()
@@ -51,16 +52,22 @@ async def run_repl(state: ReplState) -> None:
click.echo('Type /help for commands. Multiline: """ … """. Empty line is ignored.')
while True:
# The prompt read blocks the main thread inside this coroutine, so hand
# SIGINT to it: Ctrl+C raises KeyboardInterrupt here (caught below and
# inside read_repl_entry) and re-prompts. Everywhere else the session
# SIGINT router owns the signal (turn cancel, prompt cancel).
try:
entry = read_repl_entry()
with pause_task_cancel_for_prompt():
entry = read_repl_entry()
except EOFError:
click.echo()
break
except KeyboardInterrupt:
# Stray interrupt outside the read (e.g. right after the router was
# suspended): re-prompt rather than leave the REPL.
_echo_interrupted()
continue
# Between turns the turn-task SIGINT handler is removed (run_cancellable),
# so a stray Ctrl+C raises KeyboardInterrupt at whatever bytecode is
# executing — echoing user text, expire_yolo_once, gaps between
# statements. Swallow it and re-prompt instead of exiting the REPL.
try:
if entry is None:
continue
+27 -49
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import asyncio
import contextlib
import signal
import time
from typing import TYPE_CHECKING
@@ -10,7 +9,7 @@ import click
from plyngent.agent import AssistantMessageEvent
from plyngent.cli.display import render_events
from plyngent.cli.interrupt import allow_task_cancel, set_sigint_reinstall
from plyngent.cli.interrupt import install_sigint_router, sigint_cancels
from plyngent.cli.limits import reset_auto_continue_turn
if TYPE_CHECKING:
@@ -47,7 +46,9 @@ async def sleep_cancellable(seconds: float) -> bool:
remaining = deadline - time.monotonic()
if remaining <= 0:
return True
await asyncio.sleep(min(0.5, remaining))
sleeper = asyncio.ensure_future(asyncio.sleep(min(0.5, remaining)))
with sigint_cancels(sleeper):
await sleeper
except asyncio.CancelledError:
return False
except KeyboardInterrupt:
@@ -57,56 +58,32 @@ async def sleep_cancellable(seconds: float) -> bool:
async def run_cancellable[T](coro: Coroutine[object, object, T]) -> T:
"""Await ``coro`` as a task; Ctrl+C / SIGINT cancels the task.
Interactive prompts (max-rounds / destructive confirm) temporarily disable
task cancel so the user can answer instead of aborting the whole turn.
Interactive prompts (max-rounds / destructive confirm) register their own
SIGINT target for as long as they wait, so the turn is not cancelled while
the human is answering (see :func:`~plyngent.cli.interrupt.off_loop_prompt`).
Raises:
asyncio.CancelledError: If the task was cancelled (including via SIGINT).
"""
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)
# so this remains correct even if the handler was installed under a
# frozen context (asyncio signal handles capture contextvars).
if allow_task_cancel() and not task.done():
_ = task.cancel()
def _reinstall() -> None:
try:
loop.add_signal_handler(signal.SIGINT, _on_sigint)
except NotImplementedError, RuntimeError, ValueError:
return
# Keep a loop-level SIGINT handler in place for the rest of the session, so
# a Ctrl+C that lands while the loop is parked in its selector cancels this
# task instead of being raised in the loop's own frame.
_ = install_sigint_router()
try:
loop.add_signal_handler(signal.SIGINT, _on_sigint)
installed = True
set_sigint_reinstall(_reinstall)
except NotImplementedError, RuntimeError, ValueError:
installed = False
try:
return await task
except KeyboardInterrupt:
if not task.done():
_ = task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
raise asyncio.CancelledError from None
with sigint_cancels(task):
try:
return await task
except KeyboardInterrupt:
# Platforms without asyncio signal handlers (Windows proactor)
# keep the CLI's KeyboardInterrupt handler, so the interrupt
# surfaces here while the task is still awaiting.
if not task.done():
_ = task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
raise asyncio.CancelledError from None
finally:
set_sigint_reinstall(None)
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():
@@ -143,9 +120,10 @@ def _echo_turn_usage(agent: ChatAgent) -> None:
def _echo_cancel_lines(*lines: str) -> None:
"""Print post-cancel lines, treating a further Ctrl+C as benign.
After a turn-cancel the asyncio SIGINT handler is removed, so a second
Ctrl+C during this output would otherwise raise KeyboardInterrupt and
exit the REPL instead of returning to the prompt.
The session SIGINT router keeps the loop safe between turns, but platforms
without asyncio signal handlers (Windows proactor) deliver a second Ctrl+C
here as a KeyboardInterrupt, which must not exit the REPL instead of
returning to the prompt.
"""
try:
for line in lines: