Source code for unilab.interruption
"""Interruption behavior shared by entrypoints and child-process routers."""
from __future__ import annotations
import signal
import subprocess
import sys
from collections.abc import Sequence
from functools import wraps
from typing import Any, Callable, TypeVar
_F = TypeVar("_F", bound=Callable[..., Any])
[docs]
def graceful_keyboard_interrupt(entrypoint: _F) -> _F:
"""Let entrypoint cleanup finish, then exit Ctrl+C without a traceback.
Runners own their ``finally`` blocks. This outer boundary converts only the
resulting ``KeyboardInterrupt`` into the conventional shell status after all
collector, logger, and distributed-process cleanup has completed.
"""
@wraps(entrypoint)
def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return entrypoint(*args, **kwargs)
except KeyboardInterrupt:
print("Interrupted by Ctrl+C; shutdown completed.", file=sys.stderr, flush=True)
raise SystemExit(128 + signal.SIGINT) from None
return wrapper # type: ignore[return-value]
def run_process_with_graceful_interrupt(command: Sequence[str]) -> int:
"""Run a child while treating the first Ctrl+C as cooperative shutdown.
The first SIGINT is already delivered to the complete foreground process
group. The child owns cooperative cleanup, so this routing process records
the request and keeps waiting. A second SIGINT escalates to terminate/kill.
"""
interrupted = False
force_exit = False
def handle_ctrl_c(signum: int, frame: Any) -> None:
nonlocal interrupted, force_exit
if not interrupted:
interrupted = True
return
force_exit = True
previous_handler = signal.signal(signal.SIGINT, handle_ctrl_c)
try:
try:
process = subprocess.Popen(command)
except KeyboardInterrupt:
return 130
while process.poll() is None:
try:
process.wait(timeout=0.1)
except subprocess.TimeoutExpired:
pass
if force_exit:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
if process.poll() is None:
process.kill()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
break
finally:
signal.signal(signal.SIGINT, previous_handler)
returncode = process.returncode
if returncode is None:
return 1
return 130 if interrupted else returncode