Merge pull request #60 from pselamy/fix/56

feat: add graceful shutdown handler with signal trapping (#56)
This commit is contained in:
Patrick Selamy
2026-01-04 20:57:09 -05:00
committed by GitHub
3 changed files with 668 additions and 5 deletions
+24 -5
View File
@@ -21,6 +21,7 @@ from pydantic import ValidationError
from polymarket_insider_tracker import __version__
from polymarket_insider_tracker.config import Settings, clear_settings_cache, get_settings
from polymarket_insider_tracker.pipeline import Pipeline
from polymarket_insider_tracker.shutdown import GracefulShutdown
# Application info
APP_NAME = "Polymarket Insider Tracker"
@@ -216,23 +217,41 @@ def run_config_check(settings: Settings) -> int:
return EXIT_SUCCESS
async def run_pipeline(settings: Settings, dry_run: bool) -> int:
"""Run the main pipeline.
async def run_pipeline(
settings: Settings,
dry_run: bool,
shutdown_timeout: float = 30.0,
) -> int:
"""Run the main pipeline with graceful shutdown handling.
Args:
settings: Application settings.
dry_run: Whether to skip sending alerts.
shutdown_timeout: Maximum time to wait for graceful shutdown.
Returns:
Exit code.
"""
logger = logging.getLogger(__name__)
shutdown = GracefulShutdown(timeout=shutdown_timeout)
try:
pipeline = Pipeline(settings, dry_run=dry_run)
async with shutdown:
pipeline = Pipeline(settings, dry_run=dry_run)
logger.info("Starting pipeline...")
await pipeline.run()
# Register pipeline cleanup
shutdown.register_cleanup(pipeline.stop)
logger.info("Starting pipeline...")
await pipeline.start()
logger.info("Pipeline running. Press Ctrl+C to stop.")
# Wait for shutdown signal
await shutdown.wait()
logger.info("Shutdown signal received, stopping pipeline...")
await pipeline.stop()
return EXIT_SUCCESS
except KeyboardInterrupt:
+325
View File
@@ -0,0 +1,325 @@
"""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()
+319
View File
@@ -0,0 +1,319 @@
"""Tests for the graceful shutdown handler."""
from __future__ import annotations
import asyncio
import signal
import sys
from unittest.mock import MagicMock, patch
import pytest
from polymarket_insider_tracker.shutdown import (
DEFAULT_SHUTDOWN_TIMEOUT,
SHUTDOWN_SIGNALS,
GracefulShutdown,
run_with_graceful_shutdown,
)
class TestGracefulShutdownInit:
"""Tests for GracefulShutdown initialization."""
def test_default_timeout(self) -> None:
"""Should use default timeout when not specified."""
shutdown = GracefulShutdown()
assert shutdown.timeout == DEFAULT_SHUTDOWN_TIMEOUT
def test_custom_timeout(self) -> None:
"""Should accept custom timeout."""
shutdown = GracefulShutdown(timeout=60.0)
assert shutdown.timeout == 60.0
def test_initial_state(self) -> None:
"""Should start in non-shutdown state."""
shutdown = GracefulShutdown()
assert shutdown.is_shutdown_requested is False
assert shutdown.is_force_exit_requested is False
class TestRequestShutdown:
"""Tests for programmatic shutdown requests."""
async def test_request_shutdown_sets_flag(self) -> None:
"""Should set shutdown requested flag."""
shutdown = GracefulShutdown()
shutdown.request_shutdown()
assert shutdown.is_shutdown_requested is True
async def test_request_shutdown_sets_event(self) -> None:
"""Should set the shutdown event when called."""
shutdown = GracefulShutdown()
shutdown._shutdown_event = asyncio.Event()
shutdown.request_shutdown()
assert shutdown._shutdown_event.is_set()
async def test_request_shutdown_idempotent(self) -> None:
"""Multiple requests should be idempotent."""
shutdown = GracefulShutdown()
shutdown._shutdown_event = asyncio.Event()
shutdown.request_shutdown()
shutdown.request_shutdown()
shutdown.request_shutdown()
assert shutdown.is_shutdown_requested is True
class TestWait:
"""Tests for waiting for shutdown."""
async def test_wait_blocks_until_shutdown(self) -> None:
"""Wait should block until shutdown is requested."""
shutdown = GracefulShutdown()
async def request_after_delay() -> None:
await asyncio.sleep(0.1)
shutdown.request_shutdown()
asyncio.create_task(request_after_delay())
# Should complete after the delay
await asyncio.wait_for(shutdown.wait(), timeout=1.0)
assert shutdown.is_shutdown_requested is True
async def test_wait_with_timeout_returns_true_on_shutdown(self) -> None:
"""wait_with_timeout should return True when shutdown is requested."""
shutdown = GracefulShutdown(timeout=1.0)
async def request_after_delay() -> None:
await asyncio.sleep(0.05)
shutdown.request_shutdown()
asyncio.create_task(request_after_delay())
result = await shutdown.wait_with_timeout()
assert result is True
async def test_wait_with_timeout_returns_false_on_timeout(self) -> None:
"""wait_with_timeout should return False when timeout occurs."""
shutdown = GracefulShutdown(timeout=0.05)
# Don't request shutdown, let it timeout
result = await shutdown.wait_with_timeout()
assert result is False
class TestSignalHandlers:
"""Tests for signal handler installation and removal."""
async def test_install_signal_handlers_creates_event(self) -> None:
"""Installing handlers should create the shutdown event."""
shutdown = GracefulShutdown()
shutdown.install_signal_handlers()
try:
assert shutdown._shutdown_event is not None
assert not shutdown._shutdown_event.is_set()
finally:
shutdown.remove_signal_handlers()
@pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific test")
async def test_unix_signal_handler_installed(self) -> None:
"""On Unix, should install loop signal handlers."""
shutdown = GracefulShutdown()
with patch.object(asyncio.get_running_loop(), "add_signal_handler") as mock_add:
shutdown.install_signal_handlers()
# Should have been called for both SIGTERM and SIGINT
assert mock_add.call_count >= 1
shutdown.remove_signal_handlers()
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test")
async def test_windows_signal_handler_installed(self) -> None:
"""On Windows, should install signal.signal handlers."""
shutdown = GracefulShutdown()
with patch("signal.signal") as mock_signal:
shutdown.install_signal_handlers()
# Should have been called for available signals
assert mock_signal.call_count >= 1
shutdown.remove_signal_handlers()
class TestHandleSignal:
"""Tests for signal handling behavior."""
async def test_first_signal_sets_shutdown_event(self) -> None:
"""First signal should set the shutdown event."""
shutdown = GracefulShutdown()
shutdown._shutdown_event = asyncio.Event()
shutdown._handle_signal(signal.SIGTERM)
assert shutdown.is_shutdown_requested is True
assert shutdown._shutdown_event.is_set()
assert shutdown.is_force_exit_requested is False
async def test_second_signal_force_exits(self) -> None:
"""Second signal should trigger force exit."""
shutdown = GracefulShutdown()
shutdown._shutdown_event = asyncio.Event()
shutdown._shutdown_requested = True # First signal already received
with pytest.raises(SystemExit) as exc_info:
shutdown._handle_signal(signal.SIGTERM)
assert exc_info.value.code == 128 + signal.SIGTERM.value
assert shutdown.is_force_exit_requested is True
class TestCleanupCallbacks:
"""Tests for cleanup callback registration and execution."""
async def test_register_sync_callback(self) -> None:
"""Should register sync cleanup callbacks."""
shutdown = GracefulShutdown()
callback = MagicMock()
shutdown.register_cleanup(callback)
assert callback in shutdown._cleanup_callbacks
async def test_run_sync_cleanup_callback(self) -> None:
"""Should run sync cleanup callbacks."""
shutdown = GracefulShutdown()
callback = MagicMock()
shutdown.register_cleanup(callback)
await shutdown.run_cleanup_callbacks()
callback.assert_called_once()
async def test_run_async_cleanup_callback(self) -> None:
"""Should run async cleanup callbacks."""
shutdown = GracefulShutdown()
called = False
async def async_callback() -> None:
nonlocal called
called = True
shutdown.register_cleanup(async_callback)
await shutdown.run_cleanup_callbacks()
assert called is True
async def test_cleanup_callback_error_logged(self) -> None:
"""Cleanup callback errors should be logged, not raised."""
shutdown = GracefulShutdown()
def failing_callback() -> None:
raise ValueError("Cleanup failed")
shutdown.register_cleanup(failing_callback)
# Should not raise
await shutdown.run_cleanup_callbacks()
class TestAsyncContextManager:
"""Tests for async context manager protocol."""
async def test_context_manager_installs_handlers(self) -> None:
"""Entering context should install signal handlers."""
shutdown = GracefulShutdown()
async with shutdown:
assert shutdown._shutdown_event is not None
assert shutdown._loop is not None
async def test_context_manager_removes_handlers(self) -> None:
"""Exiting context should remove signal handlers."""
shutdown = GracefulShutdown()
with patch.object(shutdown, "remove_signal_handlers") as mock_remove:
async with shutdown:
pass
mock_remove.assert_called_once()
async def test_context_manager_runs_cleanup(self) -> None:
"""Exiting context should run cleanup callbacks."""
shutdown = GracefulShutdown()
callback = MagicMock()
shutdown.register_cleanup(callback)
async with shutdown:
pass
callback.assert_called_once()
class TestRunWithGracefulShutdown:
"""Tests for the run_with_graceful_shutdown helper."""
async def test_runs_coroutine_to_completion(self) -> None:
"""Should run the coroutine to completion if no shutdown."""
result = []
async def my_coro() -> None:
result.append("done")
await run_with_graceful_shutdown(my_coro(), timeout=1.0)
assert result == ["done"]
async def test_stops_on_shutdown_signal(self) -> None:
"""Should stop when shutdown is signaled."""
result = []
async def long_running() -> None:
try:
await asyncio.sleep(10.0)
except asyncio.CancelledError:
result.append("cancelled")
raise
# Create a wrapper that will trigger shutdown
async def run_with_interrupt() -> None:
async def trigger_shutdown() -> None:
await asyncio.sleep(0.1)
# We can't easily trigger a signal in tests, so we test the task
# completion path instead
pass
# Run both tasks
task = asyncio.create_task(long_running())
asyncio.create_task(trigger_shutdown())
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
result.append("finished")
await run_with_interrupt()
assert "finished" in result
class TestShutdownSignals:
"""Tests for shutdown signal configuration."""
def test_shutdown_signals_includes_sigterm(self) -> None:
"""SHUTDOWN_SIGNALS should include SIGTERM."""
assert signal.SIGTERM in SHUTDOWN_SIGNALS
def test_shutdown_signals_includes_sigint(self) -> None:
"""SHUTDOWN_SIGNALS should include SIGINT."""
assert signal.SIGINT in SHUTDOWN_SIGNALS