164 lines
6.3 KiB
Python
164 lines
6.3 KiB
Python
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()
|