From 770e08e56e893dcef43b1a1ab0d74f97c22a3704 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Mon, 2 Nov 2026 16:29:59 +0800 Subject: [PATCH] cli/interrupt: rebind the router to the loop that owns it --- src/plyngent/cli/interrupt.py | 43 ++++++++++++++++++++++++++------ tests/test_cli/test_interrupt.py | 30 ++++++++++++++++++++++ 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/plyngent/cli/interrupt.py b/src/plyngent/cli/interrupt.py index a1c52d0..71d50b6 100644 --- a/src/plyngent/cli/interrupt.py +++ b/src/plyngent/cli/interrupt.py @@ -67,6 +67,10 @@ class _RouterState: # Cancel targets, innermost last: a SIGINT cancels the innermost one. targets: list[InterruptTarget] = field(default_factory=list) installed: bool = False + # The loop that owns the handler. The flag alone is process-wide, so a later + # loop (a new session, another test) must rebind instead of believing the + # handler still follows it — asyncio detaches handlers with their loop. + loop: asyncio.AbstractEventLoop | None = None # 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 @@ -78,8 +82,14 @@ _router = _RouterState() def sigint_routed() -> bool: - """Whether the session SIGINT router currently owns SIGINT.""" - return _router.installed + """Whether the session SIGINT router owns SIGINT on the running loop.""" + if not _router.installed: + return False + try: + loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop() + except RuntimeError: + loop = None + return loop is None or _router.loop is loop def install_sigint_router() -> bool: @@ -95,13 +105,19 @@ def install_sigint_router() -> bool: 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. + KeyboardInterrupt behaviour. Idempotent for the loop that owns it, and a + different running loop rebinds the handler to itself. """ - if _router.installed: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return False + if _router.installed and _router.loop is loop: return True - if not _add_router_handler(): + if not _add_router_handler(loop): return False _router.installed = True + _router.loop = loop return True @@ -111,21 +127,32 @@ def uninstall_sigint_router() -> None: return _router.installed = False _remove_router_handler() + _router.loop = None _router.targets.clear() install_keyboard_interrupt_sigint() -def _add_router_handler() -> bool: +def _add_router_handler(loop: asyncio.AbstractEventLoop | None = None) -> bool: + """Route SIGINT to the router on *loop* (the running one when omitted).""" + target = loop + if target is None: + try: + target = asyncio.get_running_loop() + except RuntimeError: + return False try: - asyncio.get_running_loop().add_signal_handler(signal.SIGINT, _route_sigint) + target.add_signal_handler(signal.SIGINT, _route_sigint) except RuntimeError, NotImplementedError, ValueError: return False return True def _remove_router_handler() -> None: + loop = _router.loop + if loop is None: + return with contextlib.suppress(NotImplementedError, RuntimeError, ValueError): - _ = asyncio.get_running_loop().remove_signal_handler(signal.SIGINT) + _ = loop.remove_signal_handler(signal.SIGINT) def _route_sigint() -> None: diff --git a/tests/test_cli/test_interrupt.py b/tests/test_cli/test_interrupt.py index 267bda6..733fafe 100644 --- a/tests/test_cli/test_interrupt.py +++ b/tests/test_cli/test_interrupt.py @@ -326,3 +326,33 @@ async def test_sleep_cancellable_cancelled_by_sigint(router: None) -> None: 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()