# SPDX-License-Identifier: Apache-2.0 """ Unit tests for RuntimePluginLauncher. """ # Standard from pathlib import Path from unittest.mock import Mock, patch import tempfile # Third Party import pytest # First Party from lmcache.v1.plugin.runtime_plugin_launcher import RuntimePluginLauncher class MockConfig: """Mock configuration object.""" def __init__(self, runtime_plugin_locations=None): self.runtime_plugin_locations = runtime_plugin_locations or [] def to_json(self): return '{"test": "config"}' @pytest.fixture def temp_plugin_dir(): """Create a temporary directory for plugin files.""" with tempfile.TemporaryDirectory() as tmpdir: yield tmpdir @pytest.fixture def config_with_location(temp_plugin_dir): """Create a config with plugin location.""" return MockConfig([temp_plugin_dir]) @pytest.fixture def config_without_location(): """Create a config without plugin location.""" return MockConfig([]) def test_init(): """Test RuntimePluginLauncher initialization.""" config = MockConfig() role = "WORKER" worker_count = 4 worker_id = 1 launcher = RuntimePluginLauncher(config, role, worker_count, worker_id) assert launcher.config == config assert launcher.role == "WORKER" assert launcher.worker_count == worker_count assert launcher.worker_id == worker_id assert launcher.plugin_processes == [] def test_launch_plugins_no_locations(config_without_location): """Test launch_plugins when no locations are configured.""" launcher = RuntimePluginLauncher( config_without_location, "WORKER", worker_count=4, worker_id=1 ) # Should not raise any exceptions launcher.launch_plugins() assert len(launcher.plugin_processes) == 0 def test_launch_plugins_location_does_not_exist(): """Test launch_plugins when location does not exist.""" config = MockConfig(["/non/existent/path"]) launcher = RuntimePluginLauncher(config, "WORKER", worker_count=4, worker_id=1) with patch("lmcache.v1.plugin.runtime_plugin_launcher.logger") as mock_logger: launcher.launch_plugins() # Should log warning mock_logger.warning.assert_called_once() call_args = mock_logger.warning.call_args[0][0] assert "does not exist" in call_args assert len(launcher.plugin_processes) == 0 @patch("lmcache.v1.plugin.runtime_plugin_launcher.shutil.which") @patch("lmcache.v1.plugin.runtime_plugin_launcher.subprocess.Popen") def test_launch_plugins_python_file( mock_popen, mock_which, temp_plugin_dir, config_with_location ): """Test launching a Python plugin file.""" # Create a Python plugin file plugin_file = Path(temp_plugin_dir) / "worker_test.py" plugin_content = "#!/usr/bin/env python3\nprint('Hello from plugin')" plugin_file.write_text(plugin_content) # Mock subprocess mock_proc = Mock() mock_proc.stdout = Mock() mock_proc.stdout.readline = Mock(side_effect=["output line\n", ""]) mock_proc.poll = Mock(return_value=None) mock_proc.returncode = 0 mock_popen.return_value = mock_proc # Mock interpreter resolution mock_which.return_value = "/usr/bin/python3" launcher = RuntimePluginLauncher( config_with_location, "WORKER", worker_count=4, worker_id=1 ) with patch("threading.Thread") as mock_thread: launcher.launch_plugins() # Should create process assert mock_popen.called call_args = mock_popen.call_args[0][0] assert call_args[0] == "/usr/bin/python3" assert str(call_args[1]) == str(plugin_file) # Should start thread for output capture assert mock_thread.called # Should add process to list assert len(launcher.plugin_processes) == 1 assert launcher.plugin_processes[0] == mock_proc @patch("lmcache.v1.plugin.runtime_plugin_launcher.shutil.which") @patch("lmcache.v1.plugin.runtime_plugin_launcher.subprocess.Popen") def test_launch_plugins_role_filtering( mock_popen, mock_which, temp_plugin_dir, config_with_location, ): """Test plugin filtering based on role.""" # Create plugins for different roles worker_plugin = Path(temp_plugin_dir) / "worker_test.py" scheduler_plugin = Path(temp_plugin_dir) / "scheduler_test.py" all_plugin = Path(temp_plugin_dir) / "all_test.py" for plugin in [worker_plugin, scheduler_plugin, all_plugin]: plugin.write_text("#!/usr/bin/env python3\n") # Mock subprocess mock_proc = Mock() mock_proc.stdout = Mock() mock_proc.stdout.readline = Mock(return_value="") mock_proc.poll = Mock(return_value=None) mock_popen.return_value = mock_proc # Mock interpreter resolution mock_which.return_value = "/usr/bin/python3" # Test with worker role - should launch worker_plugin and all_plugin launcher = RuntimePluginLauncher( config_with_location, "WORKER", worker_count=4, worker_id=1 ) with ( patch("lmcache.v1.plugin.runtime_plugin_launcher.logger") as mock_logger, patch("threading.Thread"), ): launcher.launch_plugins() # Should log info about skipping scheduler plugin # New format: logger.info("Skipping %s: requires role %s", file, role) info_calls = [call[0] for call in mock_logger.info.call_args_list] scheduler_skipped = any( len(call) >= 3 and "requires role" in call[0] and "SCHEDULER" in str(call[2]) for call in info_calls ) assert scheduler_skipped # Should launch 2 plugins (worker_plugin and all_plugin) assert mock_popen.call_count == 2 @patch("lmcache.v1.plugin.runtime_plugin_launcher.shutil.which") @patch("lmcache.v1.plugin.runtime_plugin_launcher.subprocess.Popen") def test_launch_plugins_worker_id_filtering( mock_popen, mock_which, temp_plugin_dir, config_with_location ): """Test plugin filtering based on worker ID.""" # Create plugins for specific worker IDs worker0_plugin = Path(temp_plugin_dir) / "worker_0_specific.py" worker1_plugin = Path(temp_plugin_dir) / "worker_1_specific.py" generic_worker_plugin = Path(temp_plugin_dir) / "worker_generic.py" for plugin in [worker0_plugin, worker1_plugin, generic_worker_plugin]: plugin.write_text("#!/usr/bin/env python3\n") # Mock subprocess mock_proc = Mock() mock_proc.stdout = Mock() mock_proc.stdout.readline = Mock(return_value="") mock_proc.poll = Mock(return_value=None) mock_popen.return_value = mock_proc # Mock interpreter resolution mock_which.return_value = "/usr/bin/python3" # Test with worker_id=1 - should launch worker1_plugin and generic_worker_plugin launcher = RuntimePluginLauncher( config_with_location, "WORKER", worker_count=4, worker_id=1 ) with ( patch("lmcache.v1.plugin.runtime_plugin_launcher.logger") as mock_logger, patch("threading.Thread"), ): launcher.launch_plugins() # Should log info about skipping worker0 plugin info_calls = [call[0] for call in mock_logger.info.call_args_list] worker0_skipped = any( len(call) >= 4 and "is skipping" in call[0] and call[1] == 1 and call[3] == 0 for call in info_calls ) assert worker0_skipped # Should launch 2 plugins (worker1_plugin and generic_worker_plugin) assert mock_popen.call_count == 2 def test_get_interpreter_python(temp_plugin_dir): """Test interpreter detection for Python files.""" # Create a Python file with shebang python_file = Path(temp_plugin_dir) / "test.py" python_file.write_text("#!/usr/bin/env python3\nprint('test')") config = MockConfig() launcher = RuntimePluginLauncher(config, "WORKER", 4, 1) with patch("lmcache.v1.plugin.runtime_plugin_launcher.shutil.which") as mock_which: # Mock which to return the interpreter for shebang mock_which.return_value = "/usr/bin/python3" interpreter = launcher._get_interpreter(python_file) assert interpreter == "/usr/bin/python3" # Should try shebang interpreter first mock_which.assert_called_once_with("/usr/bin/env python3") # Should NOT try fallback interpreters since shebang interpreter is found def test_get_interpreter_bash(temp_plugin_dir): """Test interpreter detection for Bash files.""" # Create a Bash file bash_file = Path(temp_plugin_dir) / "test.sh" bash_file.write_text("#!/bin/bash\necho 'test'") config = MockConfig() launcher = RuntimePluginLauncher(config, "WORKER", 4, 1) with patch("lmcache.v1.plugin.runtime_plugin_launcher.shutil.which") as mock_which: # Mock which to return the interpreter for shebang mock_which.return_value = "/bin/bash" interpreter = launcher._get_interpreter(bash_file) assert interpreter == "/bin/bash" # Should try shebang interpreter first mock_which.assert_called_once_with("/bin/bash") # Should NOT try fallback bash since shebang interpreter is found def test_get_interpreter_no_shebang(temp_plugin_dir): """Test interpreter detection for file without shebang.""" # Create a Python file without shebang python_file = Path(temp_plugin_dir) / "test.py" python_file.write_text("print('test')") config = MockConfig() launcher = RuntimePluginLauncher(config, "WORKER", 4, 1) with patch("lmcache.v1.plugin.runtime_plugin_launcher.shutil.which") as mock_which: # Mock which to return interpreter for python mock_which.side_effect = { "python": "/usr/bin/python", "python3": "/usr/bin/python3", }.get interpreter = launcher._get_interpreter(python_file) # Should return python (first in the fallback list) assert interpreter == "/usr/bin/python" # Should try python first, then stop since it's found mock_which.assert_called_once_with("python") def test_get_interpreter_unsupported_type(temp_plugin_dir): """Test interpreter detection for unsupported file type.""" # Create an unsupported file type unsupported_file = Path(temp_plugin_dir) / "test.txt" unsupported_file.write_text("test content") config = MockConfig() launcher = RuntimePluginLauncher(config, "WORKER", 4, 1) with pytest.raises(ValueError) as exc_info: launcher._get_interpreter(unsupported_file) assert "not supported" in str(exc_info.value) @pytest.mark.parametrize( ("role_name", "worker_id", "filename_parts", "should_skip"), [ ("WORKER", 1, ["SCHEDULER", "test"], True), ("WORKER", 1, ["WORKER", "test"], False), ("WORKER", 1, ["ALL", "test"], False), ("WORKER", 1, ["WORKER", "0", "specific"], True), ("WORKER", 1, ["WORKER", "1", "specific"], False), ("WORKER", 1, ["WORKER", "generic"], False), ("SCHEDULER", 0, ["WORKER", "generic"], True), ], ) def test_should_skip_plugin(role_name, worker_id, filename_parts, should_skip): """Test plugin skipping logic.""" config = MockConfig() launcher = RuntimePluginLauncher(config, role_name, 4, worker_id) assert launcher._should_skip_plugin(Path("test.py"), filename_parts) is should_skip def test_stop_plugins(): """Test stopping plugin processes.""" config = MockConfig() launcher = RuntimePluginLauncher(config, "WORKER", 4, 1) # Create mock processes mock_proc1 = Mock() mock_proc1.poll = Mock(return_value=None) # Still running mock_proc2 = Mock() mock_proc2.poll = Mock(return_value=0) # Already exited launcher.plugin_processes = [mock_proc1, mock_proc2] launcher.stop_plugins() # Should terminate running process mock_proc1.terminate.assert_called_once() # Should not terminate exited process mock_proc2.terminate.assert_not_called() @patch("lmcache.v1.plugin.runtime_plugin_launcher.atexit.register") def test_atexit_registration(mock_register): """Test that cleanup handler is registered at initialization.""" config = MockConfig() launcher = RuntimePluginLauncher(config, "WORKER", worker_count=4, worker_id=1) # Should register stop_plugins with atexit mock_register.assert_called_once_with(launcher.stop_plugins)