Files
polymarket-insider-tracker/src/polymarket_insider_tracker/shutdown.py
T
Patrick SelamyandClaude Opus 4.5 a18fc3493a feat: add graceful shutdown handler with signal trapping (#56)
Implement graceful shutdown handling for the async pipeline with proper
signal trapping and cleanup coordination.

Features:
- GracefulShutdown class with SIGTERM/SIGINT signal trapping
- Async event-based shutdown coordination
- Configurable shutdown timeout (default: 30 seconds)
- Cleanup callback registration (sync and async)
- Force exit on second signal
- Async context manager support
- Platform-specific signal handling (Unix/Windows)

Integration:
- Updated __main__.py to use GracefulShutdown wrapper
- Pipeline cleanup registered as shutdown callback
- Proper logging of shutdown stages

Tests: 25 new tests covering signal handling, timeouts, cleanup callbacks,
and context manager behavior.

Closes #56

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:54:38 -05:00

326 lines
10 KiB
Python

"""Graceful shutdown handler for Polymarket Insider Tracker.
This module provides signal handling and graceful shutdown coordination
for the async pipeline components.
Usage:
```python
async def main():
shutdown = GracefulShutdown()
async with shutdown:
pipeline = Pipeline(settings)
await pipeline.start()
# Wait for shutdown signal
await shutdown.wait()
# Graceful cleanup
await pipeline.stop()
```
"""
from __future__ import annotations
import asyncio
import logging
import signal
import sys
from collections.abc import Callable
from contextlib import suppress
from types import FrameType
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
logger = logging.getLogger(__name__)
# Default shutdown timeout in seconds
DEFAULT_SHUTDOWN_TIMEOUT = 30.0
# Signals to trap for graceful shutdown
SHUTDOWN_SIGNALS = (signal.SIGTERM, signal.SIGINT)
class ShutdownTimeoutError(Exception):
"""Raised when graceful shutdown exceeds timeout."""
class GracefulShutdown:
"""Graceful shutdown handler with signal trapping.
This class provides coordinated shutdown handling for async applications.
It traps SIGTERM and SIGINT signals and provides an async event that
can be awaited to detect shutdown requests.
Features:
- SIGTERM and SIGINT signal trapping
- Async event-based shutdown coordination
- Configurable shutdown timeout
- Async context manager support
- Cleanup callback registration
- Force exit on second signal or timeout
Example:
```python
shutdown = GracefulShutdown(timeout=30.0)
async with shutdown:
await some_long_running_task()
# Automatically handles cleanup on signals
```
"""
def __init__(
self,
timeout: float = DEFAULT_SHUTDOWN_TIMEOUT,
*,
exit_on_timeout: bool = True,
) -> None:
"""Initialize the shutdown handler.
Args:
timeout: Maximum time in seconds to wait for graceful shutdown.
exit_on_timeout: If True, force exit when timeout is exceeded.
"""
self._timeout = timeout
self._exit_on_timeout = exit_on_timeout
self._shutdown_event: asyncio.Event | None = None
self._shutdown_requested = False
self._force_exit_requested = False
self._original_handlers: dict[signal.Signals, Any] = {}
self._cleanup_callbacks: list[Callable[[], Any]] = []
self._loop: asyncio.AbstractEventLoop | None = None
@property
def timeout(self) -> float:
"""Shutdown timeout in seconds."""
return self._timeout
@property
def is_shutdown_requested(self) -> bool:
"""Check if shutdown has been requested."""
return self._shutdown_requested
@property
def is_force_exit_requested(self) -> bool:
"""Check if force exit has been requested (second signal received)."""
return self._force_exit_requested
def register_cleanup(self, callback: Callable[[], Any]) -> None:
"""Register a cleanup callback to run during shutdown.
Args:
callback: A callable (sync or async) to run during shutdown.
"""
self._cleanup_callbacks.append(callback)
def request_shutdown(self) -> None:
"""Programmatically request shutdown.
This can be used to trigger shutdown from application code
instead of waiting for a signal.
"""
if not self._shutdown_requested:
self._shutdown_requested = True
logger.info("Shutdown requested programmatically")
if self._shutdown_event:
self._shutdown_event.set()
async def wait(self) -> None:
"""Wait for a shutdown signal.
This coroutine blocks until a shutdown signal is received
or request_shutdown() is called.
"""
if self._shutdown_event is None:
self._shutdown_event = asyncio.Event()
await self._shutdown_event.wait()
async def wait_with_timeout(self) -> bool:
"""Wait for shutdown with timeout.
Returns:
True if shutdown was requested, False if timeout occurred.
"""
if self._shutdown_event is None:
self._shutdown_event = asyncio.Event()
try:
await asyncio.wait_for(
self._shutdown_event.wait(),
timeout=self._timeout,
)
return True
except TimeoutError:
return False
def install_signal_handlers(self) -> None:
"""Install signal handlers for graceful shutdown.
Traps SIGTERM and SIGINT to trigger graceful shutdown.
On Windows, only SIGINT is trapped as SIGTERM is not available.
"""
self._loop = asyncio.get_running_loop()
self._shutdown_event = asyncio.Event()
# Platform-specific signal handling
if sys.platform == "win32":
# Windows: use signal.signal for SIGINT only
self._install_windows_handlers()
else:
# Unix: use loop.add_signal_handler for both signals
self._install_unix_handlers()
logger.debug("Signal handlers installed")
def _install_unix_handlers(self) -> None:
"""Install Unix signal handlers using the event loop."""
if self._loop is None:
return
for sig in SHUTDOWN_SIGNALS:
try:
self._loop.add_signal_handler(
sig,
self._handle_signal,
sig,
)
logger.debug("Installed handler for %s", sig.name)
except (ValueError, OSError) as e:
logger.warning("Could not install handler for %s: %s", sig.name, e)
def _install_windows_handlers(self) -> None:
"""Install Windows signal handlers using signal.signal."""
for sig in SHUTDOWN_SIGNALS:
try:
self._original_handlers[sig] = signal.signal(
sig,
self._handle_signal_sync,
)
logger.debug("Installed handler for %s", sig.name)
except (ValueError, OSError) as e:
logger.warning("Could not install handler for %s: %s", sig.name, e)
def remove_signal_handlers(self) -> None:
"""Remove installed signal handlers and restore originals."""
if sys.platform == "win32":
self._remove_windows_handlers()
else:
self._remove_unix_handlers()
logger.debug("Signal handlers removed")
def _remove_unix_handlers(self) -> None:
"""Remove Unix signal handlers."""
if self._loop is None:
return
for sig in SHUTDOWN_SIGNALS:
with suppress(ValueError, OSError):
self._loop.remove_signal_handler(sig)
def _remove_windows_handlers(self) -> None:
"""Remove Windows signal handlers and restore originals."""
for sig, original in self._original_handlers.items():
with suppress(ValueError, OSError):
signal.signal(sig, original)
self._original_handlers.clear()
def _handle_signal(self, sig: signal.Signals) -> None:
"""Handle shutdown signal (Unix version).
Args:
sig: The signal that was received.
"""
if self._shutdown_requested:
# Second signal - force exit
self._force_exit_requested = True
logger.warning("Received %s again - forcing exit!", sig.name)
sys.exit(128 + sig.value)
else:
self._shutdown_requested = True
logger.info("Received %s - initiating graceful shutdown...", sig.name)
if self._shutdown_event:
self._shutdown_event.set()
def _handle_signal_sync(self, sig: int, _frame: FrameType | None) -> None:
"""Handle shutdown signal (Windows version).
Args:
sig: The signal number that was received.
_frame: The current stack frame (unused).
"""
sig_enum = signal.Signals(sig)
self._handle_signal(sig_enum)
async def run_cleanup_callbacks(self) -> None:
"""Run all registered cleanup callbacks."""
for callback in self._cleanup_callbacks:
try:
result = callback()
if asyncio.iscoroutine(result):
await result
except Exception as e:
logger.error("Cleanup callback failed: %s", e)
async def __aenter__(self) -> GracefulShutdown:
"""Async context manager entry - install signal handlers."""
self.install_signal_handlers()
return self
async def __aexit__(self, *_args: Any) -> None:
"""Async context manager exit - cleanup."""
self.remove_signal_handlers()
await self.run_cleanup_callbacks()
async def run_with_graceful_shutdown(
coro: Any,
*,
timeout: float = DEFAULT_SHUTDOWN_TIMEOUT,
) -> None:
"""Run an async coroutine with graceful shutdown handling.
This is a convenience function that wraps a coroutine with
signal handling and timeout-based cleanup.
Args:
coro: The coroutine to run.
timeout: Maximum time to wait for graceful shutdown.
Example:
```python
async def my_app():
await asyncio.sleep(3600) # Run for an hour
# Will handle SIGTERM/SIGINT gracefully
await run_with_graceful_shutdown(my_app())
```
"""
shutdown = GracefulShutdown(timeout=timeout)
async with shutdown:
task = asyncio.create_task(coro)
# Wait for either task completion or shutdown signal
shutdown_wait = asyncio.create_task(shutdown.wait())
done, pending = await asyncio.wait(
[task, shutdown_wait],
return_when=asyncio.FIRST_COMPLETED,
)
# Cancel pending tasks
for pending_task in pending:
pending_task.cancel()
with suppress(asyncio.CancelledError):
await pending_task
# If the main task is in done, get any exception
if task in done:
task.result()