"""Tests for core.tool_manager — Subprocess lifecycle manager.""" import os import time import signal from unittest.mock import MagicMock import pytest from core.tool_manager import ToolManager, ManagedTool from core.bus import EventBus class TestToolManager: def test_register_tool(self, mock_bus): """register() adds a tool to the internal registry.""" tm = ToolManager(bus=mock_bus) tm.register( name="test_tool", binary="/bin/echo", args=["hello"], max_restarts=2, ) assert "test_tool" in tm._tools tool = tm._tools["test_tool"] assert tool.name == "test_tool" assert tool.binary == "/bin/echo" assert tool.args == ["hello"] assert tool.max_restarts == 2 def test_start_stop_tool(self, mock_bus): """start() launches a subprocess and stop() terminates it.""" tm = ToolManager(bus=mock_bus) tm.register( name="sleeper", binary="/bin/sleep", args=["999"], ) success = tm.start("sleeper") assert success is True status = tm.get_status("sleeper") assert status["running"] is True assert status["pid"] is not None pid = status["pid"] # Verify the process actually exists try: os.kill(pid, 0) alive = True except OSError: alive = False assert alive is True # Stop tm.stop("sleeper") time.sleep(0.5) status_after = tm.get_status("sleeper") assert status_after["running"] is False def test_restart_on_crash(self, mock_bus): """A tool that exits immediately triggers restart when monitor detects it.""" tm = ToolManager(bus=mock_bus) # Register a tool that exits immediately (exit code 0) tm.register( name="crasher", binary="/bin/false", # exits with code 1 immediately args=[], max_restarts=2, ) success = tm.start("crasher") # The start itself may succeed (process launched) even though it exits fast # On some systems /bin/false exits so fast that poll() already shows it dead. # Either way, the tool should have been created. assert "crasher" in tm._tools tool = tm._tools["crasher"] # Wait briefly for the process to exit time.sleep(0.5) if tool.process is not None: rc = tool.process.poll() # /bin/false exits with 1 assert rc is not None # process already exited def test_max_restarts(self, mock_bus): """After max_restarts, monitor gives up and leaves the tool stopped.""" tm = ToolManager(bus=mock_bus) tm.register( name="failbot", binary="/bin/false", args=[], max_restarts=1, ) # Start monitoring tm.start_monitoring() # Start the tool — it will exit immediately tm.start("failbot") # Wait for monitor to detect crash and exhaust restarts # Monitor loop runs every 5 seconds, but we simulate faster time.sleep(1.0) tool = tm._tools["failbot"] # Manually simulate what the monitor does if tool.process and tool.process.poll() is not None: tool.restart_count += 1 if tool.restart_count >= tool.max_restarts: tool.process = None tool.pid = None # After exceeding max_restarts, process should be None assert tool.restart_count >= tool.max_restarts tm.stop_monitoring() tm.stop_all() def test_health_check(self, mock_bus): """Health check callback is stored and can be called.""" health_called = {"count": 0} def my_health_check(): health_called["count"] += 1 return True tm = ToolManager(bus=mock_bus) tm.register( name="healthy_tool", binary="/bin/sleep", args=["999"], health_check=my_health_check, ) tool = tm._tools["healthy_tool"] assert tool.health_check is not None # Call the health check directly result = tool.health_check() assert result is True assert health_called["count"] == 1 # Start the tool and verify health check still works tm.start("healthy_tool") result2 = tool.health_check() assert result2 is True assert health_called["count"] == 2 tm.stop("healthy_tool") def test_get_status_unregistered(self, mock_bus): """get_status() for an unregistered tool returns an error dict.""" tm = ToolManager(bus=mock_bus) status = tm.get_status("nonexistent") assert "error" in status