import json import tempfile import unittest from pathlib import Path from unittest import mock from couchd.launcher import ( LaunchRequest, ProcessRegistry, RunningProcess, default_kill, default_terminate, ) class DuplicateLaunchTests(unittest.TestCase): def test_refuses_second_launch_when_pid_is_still_alive(self) -> None: launches: list[LaunchRequest] = [] registry = ProcessRegistry( is_pid_running=lambda pid: pid == 4321, spawn=lambda request: launches.append(request) or 9876, process_probe=lambda: None, process_identity=lambda pid: "proc-4321" if pid == 4321 else None, ) registry.record_running(pid=4321, state="melee") result = registry.launch(LaunchRequest(kind="melee", argv=["/bin/true"])) self.assertEqual(result["state"], "already_running") self.assertEqual(result["pid"], 4321) self.assertEqual(launches, []) def test_stop_escalates_from_term_to_kill_after_timeout(self) -> None: signals: list[tuple[str, int]] = [] checks = iter([True, True, True, False]) registry = ProcessRegistry( is_pid_running=lambda pid: next(checks), spawn=lambda request: 9876, terminate=lambda pid: signals.append(("TERM", pid)), kill=lambda pid: signals.append(("KILL", pid)), sleep=lambda seconds: None, process_probe=lambda: None, process_identity=lambda pid: "proc-4321" if pid == 4321 else None, ) registry.record_running(pid=4321, state="melee") result = registry.stop(timeout_seconds=0.0) self.assertEqual(result, {"state": "stopped", "pid": None}) self.assertEqual(signals, [("TERM", 4321), ("KILL", 4321)]) def test_adopts_running_process_from_probe_when_state_file_is_stale(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: state_file = Path(temp_dir) / "state.json" state_file.write_text(json.dumps({"pid": 9999, "state": "melee"}), encoding="utf-8") launches: list[LaunchRequest] = [] registry = ProcessRegistry( is_pid_running=lambda pid: False, spawn=lambda request: launches.append(request) or 7777, state_file=state_file, process_probe=lambda: RunningProcess(pid=4321, state="melee", identity="proc-a"), process_identity=lambda pid: "proc-a" if pid == 4321 else None, ) result = registry.launch(LaunchRequest(kind="melee", argv=["/bin/true"])) self.assertEqual(result, {"state": "already_running", "pid": 4321}) self.assertEqual(launches, []) def test_ignores_corrupt_state_file_and_recovers(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: state_file = Path(temp_dir) / "state.json" state_file.write_text("{not-json", encoding="utf-8") registry = ProcessRegistry( is_pid_running=lambda pid: False, spawn=lambda request: 9876, state_file=state_file, process_probe=lambda: None, process_identity=lambda pid: None, ) self.assertEqual(registry.current_state(), {"state": "idle", "pid": None}) def test_refuses_to_signal_reused_pid_with_different_identity(self) -> None: signals: list[tuple[str, int]] = [] with tempfile.TemporaryDirectory() as temp_dir: state_file = Path(temp_dir) / "state.json" state_file.write_text( json.dumps({"pid": 4321, "state": "melee", "identity": "old-proc"}), encoding="utf-8", ) registry = ProcessRegistry( is_pid_running=lambda pid: True, spawn=lambda request: 9876, terminate=lambda pid: signals.append(("TERM", pid)), kill=lambda pid: signals.append(("KILL", pid)), state_file=state_file, process_probe=lambda: None, process_identity=lambda pid: "new-proc", ) result = registry.stop(timeout_seconds=0.0) self.assertEqual(result, {"state": "stopped", "pid": None}) self.assertEqual(signals, []) def test_refuses_to_signal_same_executable_pid_reuse_when_start_time_changes(self) -> None: signals: list[tuple[str, int]] = [] with tempfile.TemporaryDirectory() as temp_dir: state_file = Path(temp_dir) / "state.json" state_file.write_text( json.dumps( { "pid": 4321, "state": "melee", "identity": "1000:/home/couch/Applications/Dolphin-x86_64.AppImage:111", } ), encoding="utf-8", ) registry = ProcessRegistry( is_pid_running=lambda pid: True, spawn=lambda request: 9876, terminate=lambda pid: signals.append(("TERM", pid)), kill=lambda pid: signals.append(("KILL", pid)), state_file=state_file, process_probe=lambda: None, process_identity=lambda pid: "1000:/home/couch/Applications/Dolphin-x86_64.AppImage:222", ) result = registry.stop(timeout_seconds=0.0) self.assertEqual(result, {"state": "stopped", "pid": None}) self.assertEqual(signals, []) class DefaultSignalTests(unittest.TestCase): def test_terminate_signals_non_group_leader_process_directly(self) -> None: with mock.patch("couchd.launcher.os.getpgid", return_value=9999), mock.patch( "couchd.launcher.os.kill" ) as kill, mock.patch("couchd.launcher.os.killpg") as killpg: default_terminate(4321) kill.assert_called_once() self.assertEqual(kill.call_args.args[0], 4321) killpg.assert_not_called() def test_kill_signals_group_leader_process_group(self) -> None: with mock.patch("couchd.launcher.os.getpgid", return_value=4321), mock.patch( "couchd.launcher.os.kill" ) as kill, mock.patch("couchd.launcher.os.killpg") as killpg: default_kill(4321) kill.assert_not_called() killpg.assert_called_once()