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>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
29968897c5
commit
a18fc3493a
@@ -21,6 +21,7 @@ from pydantic import ValidationError
|
|||||||
from polymarket_insider_tracker import __version__
|
from polymarket_insider_tracker import __version__
|
||||||
from polymarket_insider_tracker.config import Settings, clear_settings_cache, get_settings
|
from polymarket_insider_tracker.config import Settings, clear_settings_cache, get_settings
|
||||||
from polymarket_insider_tracker.pipeline import Pipeline
|
from polymarket_insider_tracker.pipeline import Pipeline
|
||||||
|
from polymarket_insider_tracker.shutdown import GracefulShutdown
|
||||||
|
|
||||||
# Application info
|
# Application info
|
||||||
APP_NAME = "Polymarket Insider Tracker"
|
APP_NAME = "Polymarket Insider Tracker"
|
||||||
@@ -216,23 +217,41 @@ def run_config_check(settings: Settings) -> int:
|
|||||||
return EXIT_SUCCESS
|
return EXIT_SUCCESS
|
||||||
|
|
||||||
|
|
||||||
async def run_pipeline(settings: Settings, dry_run: bool) -> int:
|
async def run_pipeline(
|
||||||
"""Run the main pipeline.
|
settings: Settings,
|
||||||
|
dry_run: bool,
|
||||||
|
shutdown_timeout: float = 30.0,
|
||||||
|
) -> int:
|
||||||
|
"""Run the main pipeline with graceful shutdown handling.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
settings: Application settings.
|
settings: Application settings.
|
||||||
dry_run: Whether to skip sending alerts.
|
dry_run: Whether to skip sending alerts.
|
||||||
|
shutdown_timeout: Maximum time to wait for graceful shutdown.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Exit code.
|
Exit code.
|
||||||
"""
|
"""
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
shutdown = GracefulShutdown(timeout=shutdown_timeout)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pipeline = Pipeline(settings, dry_run=dry_run)
|
async with shutdown:
|
||||||
|
pipeline = Pipeline(settings, dry_run=dry_run)
|
||||||
|
|
||||||
logger.info("Starting pipeline...")
|
# Register pipeline cleanup
|
||||||
await pipeline.run()
|
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
|
return EXIT_SUCCESS
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user