"""Tests for graceful shutdown signal handling.""" from __future__ import annotations import signal import threading import time import pytest from src.orchestration.signals import ( GracefulShutdown, ShutdownCallback, ignore_signals_in_child, ) class TestGracefulShutdown: """Tests for GracefulShutdown context manager.""" def test_initial_state(self) -> None: """Test initial state is not stopped.""" shutdown = GracefulShutdown() assert shutdown.should_stop is False assert shutdown.signal_received is None def test_context_manager_entry(self) -> None: """Test context manager returns self.""" shutdown = GracefulShutdown() with shutdown as s: assert s is shutdown assert shutdown.should_stop is False def test_request_stop(self) -> None: """Test programmatic stop request.""" shutdown = GracefulShutdown() assert shutdown.should_stop is False shutdown.request_stop() assert shutdown.should_stop is True def test_wait_for_stop_timeout(self) -> None: """Test wait_for_stop with timeout.""" shutdown = GracefulShutdown() start = time.time() result = shutdown.wait_for_stop(timeout=0.1) elapsed = time.time() - start assert result is False assert elapsed >= 0.1 assert elapsed < 0.5 # Should not take too long def test_wait_for_stop_signaled(self) -> None: """Test wait_for_stop when stop is signaled.""" shutdown = GracefulShutdown() # Signal stop in a separate thread def signal_stop() -> None: time.sleep(0.05) shutdown.request_stop() thread = threading.Thread(target=signal_stop) thread.start() result = shutdown.wait_for_stop(timeout=1.0) thread.join() assert result is True def test_context_manager_restores_handlers(self) -> None: """Test that context manager restores original handlers.""" original_sigint = signal.getsignal(signal.SIGINT) original_sigterm = signal.getsignal(signal.SIGTERM) with GracefulShutdown(): pass assert signal.getsignal(signal.SIGINT) == original_sigint assert signal.getsignal(signal.SIGTERM) == original_sigterm def test_nested_context_managers(self) -> None: """Test that nested context managers work correctly.""" with GracefulShutdown() as outer: with GracefulShutdown() as inner: inner.request_stop() assert inner.should_stop is True assert outer.should_stop is False def test_multiple_stop_requests(self) -> None: """Test that multiple stop requests are idempotent.""" shutdown = GracefulShutdown() shutdown.request_stop() shutdown.request_stop() shutdown.request_stop() assert shutdown.should_stop is True class TestShutdownCallback: """Tests for ShutdownCallback utility.""" def test_register_callback(self) -> None: """Test registering a callback.""" shutdown = GracefulShutdown() callbacks = ShutdownCallback(shutdown) called = [] callbacks.register(lambda: called.append(1)) callbacks.run_all() assert called == [1] def test_multiple_callbacks(self) -> None: """Test registering and running multiple callbacks.""" shutdown = GracefulShutdown() callbacks = ShutdownCallback(shutdown) results = [] callbacks.register(lambda: results.append("first")) callbacks.register(lambda: results.append("second")) callbacks.register(lambda: results.append("third")) callbacks.run_all() assert results == ["first", "second", "third"] def test_callback_exception_handling(self) -> None: """Test that callback exceptions are collected, not raised.""" shutdown = GracefulShutdown() callbacks = ShutdownCallback(shutdown) results = [] callbacks.register(lambda: results.append("first")) callbacks.register(lambda: (_ for _ in ()).throw(ValueError("error"))) callbacks.register(lambda: results.append("third")) exceptions = callbacks.run_all() # First and third should still run assert results == ["first", "third"] # Exception should be collected assert len(exceptions) == 1 assert isinstance(exceptions[0], ValueError) def test_run_all_empty(self) -> None: """Test run_all with no callbacks.""" shutdown = GracefulShutdown() callbacks = ShutdownCallback(shutdown) exceptions = callbacks.run_all() assert exceptions == [] class TestIgnoreSignalsInChild: """Tests for ignore_signals_in_child function.""" def test_sets_signal_handlers(self) -> None: """Test that function sets SIG_IGN handlers.""" original_sigint = signal.getsignal(signal.SIGINT) original_sigterm = signal.getsignal(signal.SIGTERM) try: ignore_signals_in_child() assert signal.getsignal(signal.SIGINT) == signal.SIG_IGN assert signal.getsignal(signal.SIGTERM) == signal.SIG_IGN finally: # Restore original handlers signal.signal(signal.SIGINT, original_sigint) signal.signal(signal.SIGTERM, original_sigterm) class TestThreadSafety: """Tests for thread safety of shutdown handling.""" def test_concurrent_stop_requests(self) -> None: """Test concurrent stop requests from multiple threads.""" shutdown = GracefulShutdown() threads = [] for _ in range(10): t = threading.Thread(target=shutdown.request_stop) threads.append(t) t.start() for t in threads: t.join() assert shutdown.should_stop is True def test_concurrent_should_stop_reads(self) -> None: """Test concurrent reads of should_stop.""" shutdown = GracefulShutdown() results = [] def reader() -> None: for _ in range(100): results.append(shutdown.should_stop) time.sleep(0.001) threads = [threading.Thread(target=reader) for _ in range(5)] for t in threads: t.start() time.sleep(0.05) shutdown.request_stop() for t in threads: t.join() # Should have some False and some True assert False in results assert True in results