from __future__ import annotations import asyncio import os import signal import sys import threading import time from typing import TYPE_CHECKING import pytest from plyngent.cli.interrupt import ( install_keyboard_interrupt_sigint, install_sigint_router, off_loop_prompt, pause_task_cancel_for_prompt, raise_keyboard_interrupt_sigint, sigint_cancels, sigint_routed, uninstall_sigint_router, ) from plyngent.cli.limits import prompt_continue_limit from plyngent.cli.retry import run_cancellable, sleep_cancellable from plyngent.prompting import ( PromptCancelledError, configure_prompting, prompt_cancelled, run_cancellable_prompt_async, temporary_backend, ) from tests.test_prompting import ScriptedBackend if TYPE_CHECKING: from collections.abc import AsyncIterator import pytest as pytest_module # The router is asyncio's SIGINT handler, which only exists where signals do; on # Windows a test could not deliver one either — ``os.kill(pid, SIGINT)`` is # ``TerminateProcess`` there, so it kills the runner instead of interrupting it. pytestmark = pytest.mark.skipif( sys.platform == "win32", reason="SIGINT routing needs POSIX signal handling", ) class FakeTarget: """Minimal ``InterruptTarget`` recording its cancels.""" def __init__(self, log: list[str], name: str = "cancel") -> None: self.log = log self.name = name def cancel(self) -> None: self.log.append(self.name) @pytest.fixture async def router() -> AsyncIterator[None]: """Install the session SIGINT router; restore the CLI handler afterwards.""" install_keyboard_interrupt_sigint() _ = install_sigint_router() try: yield finally: uninstall_sigint_router() signal.signal(signal.SIGINT, signal.default_int_handler) async def _deliver_sigint() -> None: """Send SIGINT to this process and let the loop run its signal callback. asyncio's signal wakeup is only noticed while the loop parks in its selector, so a real (short) wait is needed — ``sleep(0)`` never parks and the callback would not run at all. """ os.kill(os.getpid(), signal.SIGINT) await asyncio.sleep(0.05) def test_install_keyboard_interrupt_sigint() -> None: """Installs a non-default SIGINT handler that raises KeyboardInterrupt. The handler must be a distinct function object: ``asyncio.run`` only skips installing its own silent-cancel handler when the current handler is not ``signal.default_int_handler``. """ install_keyboard_interrupt_sigint() try: handler = signal.getsignal(signal.SIGINT) assert handler is not signal.default_int_handler assert handler is not signal.SIG_DFL assert callable(handler) with pytest.raises(KeyboardInterrupt): handler(signal.SIGINT, None) # type: ignore[call-arg] finally: signal.signal(signal.SIGINT, signal.default_int_handler) def test_asyncio_run_keeps_keyboard_interrupt_sigint() -> None: """Regression: Runner must not replace our handler with its own. Previously the first Ctrl+C under ``asyncio.run`` silently cancelled the main task (invisible at the REPL prompt) and the second raised KeyboardInterrupt, which also cancelled ``memory.close()`` on a Ctrl+D exit. With a non-default handler installed first, Runner leaves SIGINT alone. """ install_keyboard_interrupt_sigint() try: async def quick() -> int: return 7 assert asyncio.run(quick()) == 7 assert signal.getsignal(signal.SIGINT) is raise_keyboard_interrupt_sigint finally: signal.signal(signal.SIGINT, signal.default_int_handler) async def test_sigint_cancels_registered_target(router: None) -> None: """SIGINT cancels the target registered around the awaited turn task.""" del router cancelled = asyncio.Event() async def hang() -> None: try: await asyncio.sleep(3600) except asyncio.CancelledError: cancelled.set() raise task = asyncio.create_task(hang()) await asyncio.sleep(0) with sigint_cancels(task): os.kill(os.getpid(), signal.SIGINT) # Waiting on the effect (not on a fixed sleep) keeps this deterministic. with pytest.raises(asyncio.CancelledError): await asyncio.wait_for(task, timeout=5) assert cancelled.is_set() async def test_sigint_while_loop_is_idle_does_not_escape(router: None) -> None: """Regression: a stray Ctrl+C while the loop is parked in its selector. The interrupt used to be raised in the loop's own frame (not inside any coroutine), escaping ``run_until_complete``: the Runner cancelled the main task and the REPL was left half-shut-down, so the next Ctrl+C killed the process instead of returning to the prompt. """ del router def _send() -> None: time.sleep(0.05) os.kill(os.getpid(), signal.SIGINT) sender = threading.Thread(target=_send) sender.start() try: await asyncio.sleep(0.2) # parked in the selector when SIGINT lands finally: sender.join() task = asyncio.current_task() assert task is not None assert task.cancelling() == 0 assert not task.cancelled() async def test_sigint_cancels_innermost_target_only(router: None) -> None: """Nested targets: the innermost (the active turn or prompt) wins.""" del router outer: list[str] = [] inner: list[str] = [] with sigint_cancels(FakeTarget(outer, "outer")): await _deliver_sigint() assert outer == ["outer"] with sigint_cancels(FakeTarget(inner, "inner")): await _deliver_sigint() assert inner == ["inner"] assert outer == ["outer"] # the nested signal never reached it async def test_sigint_target_stops_after_context_exit(router: None) -> None: """A popped target no longer sees SIGINT (and an empty stack ignores it).""" del router calls: list[str] = [] with sigint_cancels(FakeTarget(calls)): await _deliver_sigint() assert calls == ["cancel"] await _deliver_sigint() assert calls == ["cancel"] async def test_off_loop_prompt_keeps_turn_alive( router: None, capsys: pytest_module.CaptureFixture[str], ) -> None: """A prompt's SIGINT target shields the turn; afterwards the turn cancels. Regression (badcb3c, revisited): the router resolves the innermost target per signal instead of freezing cancel state into the handler, so a mid-turn prompt neither aborts the turn nor freezes turn-cancel. """ del router started = asyncio.Event() cancelled = asyncio.Event() async def hang() -> None: started.set() try: await asyncio.sleep(3600) except asyncio.CancelledError: cancelled.set() raise task = asyncio.create_task(hang()) await started.wait() with sigint_cancels(task): with off_loop_prompt(): await _deliver_sigint() assert not task.cancelling() err = capsys.readouterr().err assert "Ctrl+C ignored while a prompt is waiting" in err # The prompt returned (outer context still active): SIGINT cancels now. os.kill(os.getpid(), signal.SIGINT) with pytest.raises(asyncio.CancelledError): await asyncio.wait_for(task, timeout=5) assert cancelled.is_set() async def test_off_loop_prompt_cancel_hook_is_preferred(router: None) -> None: """A poll-based prompt read gets cancelled instead of the enclosing turn.""" del router hooks: list[str] = [] turn_calls: list[str] = [] with sigint_cancels(FakeTarget(turn_calls, "turn")), off_loop_prompt(lambda: hooks.append("read")): await _deliver_sigint() assert hooks == ["read"] assert turn_calls == [] async def test_sigint_cancels_off_loop_prompt_read(router: None) -> None: """Wiring: SIGINT aborts a poll-based read (not the turn) and clears the flag.""" del router turn_calls: list[str] = [] seen: list[str] = [] def _poll_read(prompt: str, timeout: float) -> str: """Stand-in for ``read_line_with_timeout``: polls the cancel flag.""" del prompt deadline = time.monotonic() + timeout while time.monotonic() < deadline: if prompt_cancelled(): seen.append("cancelled") raise PromptCancelledError time.sleep(0.01) seen.append("timed out") return "" with temporary_backend(ScriptedBackend([])): configure_prompting(pause_factory=off_loop_prompt) with sigint_cancels(FakeTarget(turn_calls, "turn")): reader = asyncio.create_task(run_cancellable_prompt_async(_poll_read, "p> ", 30.0)) await asyncio.sleep(0.05) os.kill(os.getpid(), signal.SIGINT) with pytest.raises(PromptCancelledError): await asyncio.wait_for(reader, timeout=5) assert seen == ["cancelled"] assert turn_calls == [] assert not prompt_cancelled() # cleared for the next prompt async def test_pause_task_cancel_for_prompt_swaps_sigint(router: None) -> None: """A main-thread blocking read takes SIGINT as a KeyboardInterrupt.""" del router routed = signal.getsignal(signal.SIGINT) assert routed is not raise_keyboard_interrupt_sigint with pause_task_cancel_for_prompt(): assert signal.getsignal(signal.SIGINT) is raise_keyboard_interrupt_sigint with pause_task_cancel_for_prompt(): assert signal.getsignal(signal.SIGINT) is raise_keyboard_interrupt_sigint # Inner exit must not resume the router while the outer read blocks. assert signal.getsignal(signal.SIGINT) is raise_keyboard_interrupt_sigint assert signal.getsignal(signal.SIGINT) is routed async def test_prompt_continue_limit_under_pause( router: None, monkeypatch: pytest.MonkeyPatch, ) -> None: """Synchronous prompts read on the main thread, where SIGINT is a KI.""" del router backend = ScriptedBackend(["y"]) def _read_line(prompt: str, *, default: str | None = None, completions: object = None) -> str: del prompt, default, completions assert signal.getsignal(signal.SIGINT) is raise_keyboard_interrupt_sigint return "y" monkeypatch.setattr(backend, "read_line", _read_line) with temporary_backend(backend): assert prompt_continue_limit("too many rounds") is True async def test_run_cancellable_keeps_router_installed(router: None) -> None: """Regression: after a turn SIGINT must not be left as SIG_DFL. ``loop.remove_signal_handler`` leaves SIG_DFL, which would terminate the process on a stray Ctrl+C between turns; the session router stays installed. """ del router async def quick() -> None: return None await run_cancellable(quick()) assert sigint_routed() assert signal.getsignal(signal.SIGINT) is not signal.SIG_DFL assert signal.getsignal(signal.SIGINT) is not signal.default_int_handler async def test_sleep_cancellable_cancelled_by_sigint(router: None) -> None: """The auto-retry countdown is cancelled by Ctrl+C (was a teardown window).""" del router waiter = asyncio.create_task(sleep_cancellable(30)) await asyncio.sleep(0) os.kill(os.getpid(), signal.SIGINT) assert await asyncio.wait_for(waiter, timeout=5) is False def test_a_second_loop_rebinds_the_router() -> None: """The installed flag is process-wide, so a new loop needs its own handler. asyncio drops a loop's signal handlers with the loop, so believing the flag alone left the next loop unrouted: a stray Ctrl+C then became a KeyboardInterrupt out of the loop's own frame instead of cancelling work. """ class Counter: hits = 0 def cancel(self) -> None: self.hits += 1 async def routed_once() -> int: assert install_sigint_router() target = Counter() with sigint_cancels(target): os.kill(os.getpid(), signal.SIGINT) await asyncio.sleep(0.05) return target.hits install_keyboard_interrupt_sigint() try: assert asyncio.run(routed_once()) == 1 assert asyncio.run(routed_once()) == 1 finally: uninstall_sigint_router()