diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae604d7..b0ef4e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: meshcore-proxy-${{ github.ref_name }} + name: meshcore-proxy-${{ github.sha }} path: dist/* publish-pypi: @@ -74,7 +74,7 @@ jobs: - name: Download build artifacts uses: actions/download-artifact@v4 with: - name: meshcore-proxy-${{ github.ref_name }} + name: meshcore-proxy-${{ github.sha }} path: dist - name: Set up Python diff --git a/src/meshcore_proxy/cli.py b/src/meshcore_proxy/cli.py index d6462da..b2d7840 100644 --- a/src/meshcore_proxy/cli.py +++ b/src/meshcore_proxy/cli.py @@ -2,7 +2,9 @@ import argparse import asyncio +import functools import logging +import signal import sys from meshcore_proxy.proxy import EventLogLevel, MeshCoreProxy @@ -104,6 +106,48 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() +async def run_with_shutdown(proxy: MeshCoreProxy) -> None: + """Run the proxy with proper signal handling for graceful shutdown.""" + loop = asyncio.get_running_loop() + shutdown_event = asyncio.Event() + + def signal_handler(sig): + """Handle shutdown signals.""" + signame = signal.Signals(sig).name + logging.info(f"Received {signame}, shutting down gracefully...") + shutdown_event.set() + + # Register signal handlers for graceful shutdown + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(sig, functools.partial(signal_handler, sig)) + + # Create the proxy task + proxy_task = asyncio.create_task(proxy.run()) + + # Wait for either the proxy to complete or a shutdown signal + shutdown_task = asyncio.create_task(shutdown_event.wait()) + done, pending = await asyncio.wait( + [proxy_task, shutdown_task], + return_when=asyncio.FIRST_COMPLETED, + ) + + # If shutdown was signaled, cancel the proxy task + if shutdown_task in done: + proxy_task.cancel() + try: + await proxy_task + except asyncio.CancelledError: + pass + + # Cancel any remaining tasks + for task in pending: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + def main() -> int: """Main entry point.""" args = parse_args() @@ -144,18 +188,14 @@ def main() -> int: event_log_json=args.json, ) - # Run + # Run with signal handling try: - asyncio.run(proxy.run()) - except KeyboardInterrupt: - logging.info("Shutting down...") + asyncio.run(run_with_shutdown(proxy)) return 0 except Exception as e: logging.error(f"Fatal error: {e}") return 1 - return 0 - if __name__ == "__main__": sys.exit(main()) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..4ad4fdd --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,116 @@ +"""Tests for CLI signal handling.""" + +import asyncio +import os +import signal +from unittest.mock import patch + +import pytest + +from meshcore_proxy.cli import run_with_shutdown +from meshcore_proxy.proxy import EventLogLevel, MeshCoreProxy + +# Test timing constants +STARTUP_DELAY = 0.2 # Time to wait for proxy to start before sending signal +SHUTDOWN_TIMEOUT = 5 # Maximum time to wait for graceful shutdown + + +class MockRadio: + """Mock radio connection for testing.""" + + def __init__(self): + self.is_connected = False + self.on_disconnect = None + self.on_receive = None + + async def connect(self): + self.is_connected = True + return "mock-radio" + + async def disconnect(self): + self.is_connected = False + if self.on_disconnect: + result = self.on_disconnect() + if asyncio.iscoroutine(result): + await result + + async def send(self, data): + pass + + def set_disconnect_handler(self, handler): + self.on_disconnect = handler + + def set_reader(self, reader): + self.on_receive = reader.handle_rx + + +@pytest.mark.asyncio +@patch("meshcore_proxy.proxy.SerialConnection") +async def test_sigterm_triggers_graceful_shutdown(mock_serial_connection): + """Test that SIGTERM signal triggers graceful shutdown.""" + mock_radio = MockRadio() + mock_serial_connection.return_value = mock_radio + + proxy = MeshCoreProxy( + serial_port="/dev/ttyUSB0", + event_log_level=EventLogLevel.OFF, + tcp_port=5010, + ) + + # Start the proxy with signal handling + async def run_and_signal(): + """Run proxy and send SIGTERM after a short delay.""" + # Give the proxy time to start + await asyncio.sleep(STARTUP_DELAY) + # Send SIGTERM to trigger shutdown + os.kill(os.getpid(), signal.SIGTERM) + + # Run both tasks + signal_task = asyncio.create_task(run_and_signal()) + shutdown_task = asyncio.create_task(run_with_shutdown(proxy)) + + # Wait for shutdown with a timeout + try: + await asyncio.wait_for(shutdown_task, timeout=SHUTDOWN_TIMEOUT) + except asyncio.TimeoutError: + pytest.fail("Shutdown did not complete within timeout") + + await signal_task + + # Verify proxy stopped cleanly + assert not proxy._is_running + + +@pytest.mark.asyncio +@patch("meshcore_proxy.proxy.SerialConnection") +async def test_sigint_triggers_graceful_shutdown(mock_serial_connection): + """Test that SIGINT signal (Ctrl+C) triggers graceful shutdown.""" + mock_radio = MockRadio() + mock_serial_connection.return_value = mock_radio + + proxy = MeshCoreProxy( + serial_port="/dev/ttyUSB0", + event_log_level=EventLogLevel.OFF, + tcp_port=5011, + ) + + # Start the proxy with signal handling + async def run_and_signal(): + """Run proxy and send SIGINT after a short delay.""" + await asyncio.sleep(STARTUP_DELAY) + os.kill(os.getpid(), signal.SIGINT) + + # Run both tasks + signal_task = asyncio.create_task(run_and_signal()) + shutdown_task = asyncio.create_task(run_with_shutdown(proxy)) + + # Wait for shutdown with a timeout + try: + await asyncio.wait_for(shutdown_task, timeout=SHUTDOWN_TIMEOUT) + except asyncio.TimeoutError: + pytest.fail("Shutdown did not complete within timeout") + + await signal_task + + # Verify proxy stopped cleanly + assert not proxy._is_running