677 lines
28 KiB
Python
677 lines
28 KiB
Python
import pytest
|
|
|
|
from browser_harness import admin
|
|
|
|
|
|
class FakeSocket:
|
|
def __init__(self, response=b'{"target_id":"target-1","session_id":"session-1","page":null}\n'):
|
|
self.response = response
|
|
self.closed = False
|
|
self.sent = b""
|
|
|
|
def sendall(self, data):
|
|
self.sent += data
|
|
|
|
def recv(self, _size):
|
|
out, self.response = self.response, b""
|
|
return out
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
def test_local_chrome_mode_is_false_when_env_provides_remote_cdp():
|
|
assert not admin._is_local_chrome_mode({"BU_CDP_WS": "ws://example.test/devtools/browser/1"})
|
|
|
|
|
|
def test_local_chrome_mode_is_false_when_process_env_provides_remote_cdp(monkeypatch):
|
|
monkeypatch.setenv("BU_CDP_WS", "ws://example.test/devtools/browser/1")
|
|
|
|
assert not admin._is_local_chrome_mode()
|
|
|
|
|
|
def test_handshake_timeout_needs_chrome_remote_debugging_prompt():
|
|
msg = "CDP WS handshake failed: timed out during opening handshake"
|
|
|
|
assert admin._needs_chrome_remote_debugging_prompt(msg)
|
|
|
|
|
|
def test_handshake_403_needs_chrome_remote_debugging_prompt():
|
|
msg = "CDP WS handshake failed: server rejected WebSocket connection: HTTP 403"
|
|
|
|
assert admin._needs_chrome_remote_debugging_prompt(msg)
|
|
|
|
|
|
def test_stale_websocket_does_not_open_chrome_inspect():
|
|
msg = "no close frame received or sent"
|
|
|
|
assert not admin._needs_chrome_remote_debugging_prompt(msg)
|
|
|
|
|
|
def test_daemon_endpoint_names_discovers_valid_socket_names(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(admin.ipc, "IS_WINDOWS", False)
|
|
monkeypatch.setattr(admin.ipc, "BH_RUNTIME_DIR", None) # shared-tmpdir mode
|
|
monkeypatch.setattr(admin.ipc, "_RUNTIME", tmp_path)
|
|
(tmp_path / "bu-default.sock").touch()
|
|
(tmp_path / "bu-remote_1.sock").touch()
|
|
(tmp_path / "bu-invalid.name.sock").touch()
|
|
(tmp_path / "not-bu-default.sock").touch()
|
|
|
|
assert admin._daemon_endpoint_names() == ["default", "remote_1"]
|
|
|
|
|
|
def test_daemon_endpoint_names_with_bh_runtime_dir_returns_local_name_when_sock_exists(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(admin.ipc, "IS_WINDOWS", False)
|
|
monkeypatch.setattr(admin.ipc, "BH_RUNTIME_DIR", str(tmp_path))
|
|
monkeypatch.setattr(admin.ipc, "BH_RUNTIME_DIR_SHARED", False)
|
|
monkeypatch.setattr(admin.ipc, "_RUNTIME", tmp_path)
|
|
monkeypatch.setattr(admin, "NAME", "session-xyz")
|
|
(tmp_path / "bu.sock").touch()
|
|
|
|
assert admin._daemon_endpoint_names() == ["session-xyz"]
|
|
|
|
|
|
def test_daemon_endpoint_names_with_bh_runtime_dir_returns_empty_when_sock_missing(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(admin.ipc, "IS_WINDOWS", False)
|
|
monkeypatch.setattr(admin.ipc, "BH_RUNTIME_DIR", str(tmp_path))
|
|
monkeypatch.setattr(admin.ipc, "BH_RUNTIME_DIR_SHARED", False)
|
|
monkeypatch.setattr(admin.ipc, "_RUNTIME", tmp_path)
|
|
monkeypatch.setattr(admin, "NAME", "session-xyz")
|
|
|
|
assert admin._daemon_endpoint_names() == []
|
|
|
|
|
|
def test_daemon_endpoint_names_with_shared_bh_runtime_dir_discovers_named_sockets(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(admin.ipc, "IS_WINDOWS", False)
|
|
monkeypatch.setattr(admin.ipc, "BH_RUNTIME_DIR", str(tmp_path))
|
|
monkeypatch.setattr(admin.ipc, "BH_RUNTIME_DIR_SHARED", True)
|
|
monkeypatch.setattr(admin.ipc, "_RUNTIME", tmp_path)
|
|
(tmp_path / "bu-default.sock").touch()
|
|
(tmp_path / "bu-work.sock").touch()
|
|
(tmp_path / "bu-invalid.name.sock").touch()
|
|
(tmp_path / "bu.sock").touch() # stale isolated-runtime endpoint
|
|
|
|
assert admin._daemon_endpoint_names() == ["default", "work"]
|
|
|
|
|
|
def test_active_browser_connections_counts_only_healthy_daemons(monkeypatch):
|
|
monkeypatch.setattr(admin, "_daemon_endpoint_names", lambda: ["default", "stale", "remote"])
|
|
|
|
def fake_connect(name, timeout=1.0):
|
|
if name == "stale":
|
|
raise ConnectionRefusedError()
|
|
if name == "remote":
|
|
return FakeSocket(b'{"error":"no close frame received or sent"}\n'), None
|
|
return FakeSocket(), None
|
|
|
|
monkeypatch.setattr(admin.ipc, "connect", fake_connect)
|
|
|
|
assert admin.active_browser_connections() == 1
|
|
|
|
|
|
def test_active_browser_connections_skips_daemons_reporting_cdp_disconnected(monkeypatch):
|
|
monkeypatch.setattr(admin, "_daemon_endpoint_names", lambda: ["default", "stale"])
|
|
|
|
def fake_connect(name, timeout=1.0):
|
|
if name == "stale":
|
|
return FakeSocket(b'{"error":"cdp_disconnected"}\n'), None
|
|
return FakeSocket(), None
|
|
|
|
monkeypatch.setattr(admin.ipc, "connect", fake_connect)
|
|
|
|
assert admin.active_browser_connections() == 1
|
|
|
|
|
|
def test_browser_connections_returns_attached_page(monkeypatch):
|
|
monkeypatch.setattr(admin, "_daemon_endpoint_names", lambda: ["default"])
|
|
response = (
|
|
b'{"target_id":"target-1","session_id":"session-1",'
|
|
b'"page":{"targetId":"target-1","title":"Cat - Wikipedia","url":"https://en.wikipedia.org/wiki/Cat"}}\n'
|
|
)
|
|
monkeypatch.setattr(admin.ipc, "connect", lambda name, timeout=1.0: (FakeSocket(response), None))
|
|
|
|
assert admin.browser_connections() == [
|
|
{
|
|
"name": "default",
|
|
"page": {"title": "Cat - Wikipedia", "url": "https://en.wikipedia.org/wiki/Cat"},
|
|
}
|
|
]
|
|
|
|
|
|
def test_chrome_running_detects_helium_on_linux(monkeypatch):
|
|
monkeypatch.setattr("platform.system", lambda: "Linux")
|
|
monkeypatch.setattr(
|
|
"subprocess.check_output",
|
|
lambda *args, **kwargs: "systemd\nhelium\nxdg-desktop-portal\n",
|
|
)
|
|
|
|
assert admin._chrome_running()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"path, expected",
|
|
[
|
|
("/snap/chromium/1234/usr/lib/chromium-browser/chromium-browser", True),
|
|
("/SNAP/foo", True),
|
|
("/usr/bin/google-chrome-stable", False),
|
|
("", False),
|
|
],
|
|
)
|
|
def test_is_snap_browser(path, expected):
|
|
assert admin._is_snap_browser(path) == expected
|
|
|
|
|
|
def test_doctor_probe_preserves_snap_bin_env_symlink(monkeypatch, tmp_path):
|
|
target = tmp_path / "usr" / "bin" / "snap"
|
|
target.parent.mkdir(parents=True)
|
|
target.write_text("#!/bin/sh\n")
|
|
snap_bin = tmp_path / "snap" / "bin"
|
|
snap_bin.mkdir(parents=True)
|
|
chromium = snap_bin / "chromium"
|
|
chromium.symlink_to(target)
|
|
|
|
monkeypatch.setenv("BH_CHROME_PATH", str(chromium))
|
|
monkeypatch.delenv("CHROME_PATH", raising=False)
|
|
|
|
name, path = admin._doctor_probe_chrome_binary_for_snap()
|
|
|
|
assert name == "chromium"
|
|
assert path == str(chromium)
|
|
assert admin._is_snap_browser(path)
|
|
|
|
|
|
def test_doctor_probe_preserves_snap_bin_path_symlink(monkeypatch, tmp_path):
|
|
target = tmp_path / "usr" / "bin" / "snap"
|
|
target.parent.mkdir(parents=True)
|
|
target.write_text("#!/bin/sh\n")
|
|
snap_bin = tmp_path / "snap" / "bin"
|
|
snap_bin.mkdir(parents=True)
|
|
chromium = snap_bin / "chromium"
|
|
chromium.symlink_to(target)
|
|
|
|
monkeypatch.delenv("BH_CHROME_PATH", raising=False)
|
|
monkeypatch.delenv("CHROME_PATH", raising=False)
|
|
|
|
def fake_which(cmd):
|
|
return str(chromium) if cmd == "chromium" else None
|
|
|
|
monkeypatch.setattr("shutil.which", fake_which)
|
|
|
|
name, path = admin._doctor_probe_chrome_binary_for_snap()
|
|
|
|
assert name == "chromium"
|
|
assert path == str(chromium)
|
|
assert admin._is_snap_browser(path)
|
|
|
|
|
|
def test_run_doctor_prints_snap_detect_on_linux_when_probe_is_snap(monkeypatch, capsys):
|
|
monkeypatch.setattr(admin, "_version", lambda: "0.1.0")
|
|
monkeypatch.setattr(admin, "_install_mode", lambda: "git")
|
|
monkeypatch.setattr(admin, "_chrome_running", lambda: False)
|
|
monkeypatch.setattr(admin, "daemon_alive", lambda: False)
|
|
monkeypatch.setattr(admin, "browser_connections", lambda: [])
|
|
monkeypatch.setattr(admin, "_latest_release_tag", lambda: "0.1.0")
|
|
monkeypatch.setattr(admin, "_doctor_probe_chrome_binary_for_snap", lambda: ("chromium", "/snap/chromium/1/usr/bin/chromium"))
|
|
monkeypatch.setattr("platform.system", lambda: "Linux")
|
|
monkeypatch.setattr("shutil.which", lambda _cmd: None)
|
|
monkeypatch.delenv("BROWSER_USE_API_KEY", raising=False)
|
|
|
|
assert admin.run_doctor() == 1
|
|
|
|
out = capsys.readouterr().out
|
|
assert "[snap-detect]" in out
|
|
assert "Browser: chromium (snap)" in out
|
|
assert "Snap confinement prevents CDP binding" in out
|
|
assert "docs/snap-linux-headless.md" in out
|
|
|
|
|
|
def test_run_doctor_skips_snap_detect_on_non_linux(monkeypatch, capsys):
|
|
monkeypatch.setattr(admin, "_version", lambda: "0.1.0")
|
|
monkeypatch.setattr(admin, "_install_mode", lambda: "git")
|
|
monkeypatch.setattr(admin, "_chrome_running", lambda: True)
|
|
monkeypatch.setattr(admin, "daemon_alive", lambda: True)
|
|
monkeypatch.setattr(admin, "browser_connections", lambda: [])
|
|
monkeypatch.setattr(admin, "_latest_release_tag", lambda: "0.1.0")
|
|
monkeypatch.setattr(admin, "_doctor_probe_chrome_binary_for_snap", lambda: ("chromium", "/snap/chromium/1/usr/bin/chromium"))
|
|
monkeypatch.setattr("platform.system", lambda: "Darwin")
|
|
monkeypatch.setattr("shutil.which", lambda _cmd: None)
|
|
monkeypatch.delenv("BROWSER_USE_API_KEY", raising=False)
|
|
|
|
assert admin.run_doctor() == 0
|
|
|
|
out = capsys.readouterr().out
|
|
assert "[snap-detect]" not in out
|
|
|
|
|
|
def test_run_doctor_reports_bad_stored_cloud_auth_without_crashing(monkeypatch, capsys):
|
|
monkeypatch.setattr(admin, "_version", lambda: "0.1.0")
|
|
monkeypatch.setattr(admin, "_install_mode", lambda: "git")
|
|
monkeypatch.setattr(admin, "_chrome_running", lambda: True)
|
|
monkeypatch.setattr(admin, "daemon_alive", lambda: True)
|
|
monkeypatch.setattr(admin, "browser_connections", lambda: [])
|
|
monkeypatch.setattr(admin, "_latest_release_tag", lambda: "0.1.0")
|
|
monkeypatch.setattr(admin, "_doctor_probe_chrome_binary_for_snap", lambda: (None, None))
|
|
monkeypatch.setattr("platform.system", lambda: "Darwin")
|
|
monkeypatch.setattr(admin.auth, "auth_status", lambda: (_ for _ in ()).throw(admin.auth.AuthError("auth file is not valid JSON")))
|
|
|
|
assert admin.run_doctor() == 0
|
|
|
|
out = capsys.readouterr().out
|
|
assert "Browser Use cloud auth" in out
|
|
assert "auth file is not valid JSON" in out
|
|
|
|
|
|
def test_run_doctor_fix_snap_prints_steps(capsys):
|
|
assert admin.run_doctor_fix_snap() == 0
|
|
out = capsys.readouterr().out
|
|
assert "browser-harness doctor --fix-snap" in out
|
|
assert "BH_CHROME_PATH" in out
|
|
assert "google-chrome-stable_current_amd64.deb" in out
|
|
assert "browser-harness --doctor" in out
|
|
|
|
|
|
def test_run_doctor_prints_active_browser_connections_and_active_pages(monkeypatch, capsys):
|
|
monkeypatch.setattr(admin, "_version", lambda: "0.1.0")
|
|
monkeypatch.setattr(admin, "_install_mode", lambda: "git")
|
|
monkeypatch.setattr(admin, "_chrome_running", lambda: True)
|
|
monkeypatch.setattr(admin, "daemon_alive", lambda: True)
|
|
monkeypatch.setattr(admin, "browser_connections", lambda: [
|
|
{
|
|
"name": "default",
|
|
"page": {"title": "Example", "url": "https://example.test"},
|
|
},
|
|
{
|
|
"name": "cats",
|
|
"page": {"title": "Cat - Wikipedia", "url": "https://en.wikipedia.org/wiki/Cat"},
|
|
},
|
|
])
|
|
monkeypatch.setattr(admin, "_latest_release_tag", lambda: "0.1.0")
|
|
monkeypatch.setattr("shutil.which", lambda _cmd: None)
|
|
monkeypatch.delenv("BROWSER_USE_API_KEY", raising=False)
|
|
|
|
assert admin.run_doctor() == 0
|
|
|
|
out = capsys.readouterr().out
|
|
assert "[ok ] active browser connections — 2" in out
|
|
assert " default — active page: Example — https://example.test" in out
|
|
assert " cats — active page: Cat - Wikipedia — https://en.wikipedia.org/wiki/Cat" in out
|
|
|
|
|
|
def test_doctor_page_output_truncates_long_text(monkeypatch, capsys):
|
|
monkeypatch.setattr(admin, "_version", lambda: "0.1.0")
|
|
monkeypatch.setattr(admin, "_install_mode", lambda: "git")
|
|
monkeypatch.setattr(admin, "_chrome_running", lambda: True)
|
|
monkeypatch.setattr(admin, "daemon_alive", lambda: True)
|
|
monkeypatch.setattr(admin, "DOCTOR_TEXT_LIMIT", 20)
|
|
monkeypatch.setattr(admin, "browser_connections", lambda: [
|
|
{
|
|
"name": "default",
|
|
"page": {"title": "A very long page title", "url": "https://example.test/very/long/path"},
|
|
}
|
|
])
|
|
monkeypatch.setattr(admin, "_latest_release_tag", lambda: "0.1.0")
|
|
monkeypatch.setattr("shutil.which", lambda _cmd: None)
|
|
monkeypatch.delenv("BROWSER_USE_API_KEY", raising=False)
|
|
|
|
assert admin.run_doctor() == 0
|
|
|
|
out = capsys.readouterr().out
|
|
assert "A very long page ..." in out
|
|
assert "https://example.t..." in out
|
|
|
|
|
|
def test_start_remote_daemon_stops_created_browser_when_daemon_start_fails(monkeypatch):
|
|
calls = []
|
|
browser = {"id": "browser-123", "cdpUrl": "http://127.0.0.1:9333", "liveUrl": "https://live.example"}
|
|
|
|
def fake_browser_use(path, method, body=None):
|
|
calls.append((path, method, body))
|
|
if (path, method) == ("/browsers", "POST"):
|
|
return browser
|
|
if (path, method) == ("/browsers/browser-123", "PATCH"):
|
|
return {}
|
|
raise AssertionError((path, method, body))
|
|
|
|
monkeypatch.setattr(admin, "daemon_alive", lambda name: False)
|
|
monkeypatch.setattr(admin, "_browser_use", fake_browser_use)
|
|
monkeypatch.setattr(admin, "_cdp_ws_from_url", lambda url: "ws://example.test/devtools/browser/1")
|
|
monkeypatch.setattr(admin, "ensure_daemon", lambda **kwargs: (_ for _ in ()).throw(RuntimeError("boom")))
|
|
|
|
with pytest.raises(RuntimeError, match="boom"):
|
|
admin.start_remote_daemon()
|
|
|
|
assert calls == [
|
|
("/browsers", "POST", {}),
|
|
("/browsers/browser-123", "PATCH", {"action": "stop"}),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("exc_type", [KeyboardInterrupt, SystemExit])
|
|
def test_start_remote_daemon_stops_created_browser_when_daemon_start_is_interrupted(monkeypatch, exc_type):
|
|
calls = []
|
|
browser = {"id": "browser-123", "cdpUrl": "http://127.0.0.1:9333", "liveUrl": "https://live.example"}
|
|
|
|
def fake_browser_use(path, method, body=None):
|
|
calls.append((path, method, body))
|
|
if (path, method) == ("/browsers", "POST"):
|
|
return browser
|
|
if (path, method) == ("/browsers/browser-123", "PATCH"):
|
|
return {}
|
|
raise AssertionError((path, method, body))
|
|
|
|
monkeypatch.setattr(admin, "daemon_alive", lambda name: False)
|
|
monkeypatch.setattr(admin, "_browser_use", fake_browser_use)
|
|
monkeypatch.setattr(admin, "_cdp_ws_from_url", lambda url: "ws://example.test/devtools/browser/1")
|
|
monkeypatch.setattr(admin, "ensure_daemon", lambda **kwargs: (_ for _ in ()).throw(exc_type()))
|
|
|
|
with pytest.raises(exc_type):
|
|
admin.start_remote_daemon()
|
|
|
|
assert calls == [
|
|
("/browsers", "POST", {}),
|
|
("/browsers/browser-123", "PATCH", {"action": "stop"}),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("exc_type", [KeyboardInterrupt, SystemExit])
|
|
def test_stop_cloud_browser_swallows_baseexception_from_stop_request(monkeypatch, exc_type):
|
|
monkeypatch.setattr(admin, "_browser_use", lambda *args, **kwargs: (_ for _ in ()).throw(exc_type()))
|
|
|
|
admin._stop_cloud_browser("browser-123")
|
|
|
|
def test_start_remote_daemon_does_not_stop_created_browser_on_success(monkeypatch):
|
|
calls = []
|
|
browser = {"id": "browser-123", "cdpUrl": "http://127.0.0.1:9333", "liveUrl": "https://live.example"}
|
|
|
|
def fake_browser_use(path, method, body=None):
|
|
calls.append((path, method, body))
|
|
if (path, method) == ("/browsers", "POST"):
|
|
return browser
|
|
raise AssertionError((path, method, body))
|
|
|
|
monkeypatch.setattr(admin, "daemon_alive", lambda name: False)
|
|
monkeypatch.setattr(admin, "_browser_use", fake_browser_use)
|
|
monkeypatch.setattr(admin, "_cdp_ws_from_url", lambda url: "ws://example.test/devtools/browser/1")
|
|
monkeypatch.setattr(admin, "ensure_daemon", lambda **kwargs: None)
|
|
monkeypatch.setattr(admin, "_show_live_url", lambda url: None)
|
|
|
|
assert admin.start_remote_daemon() == browser
|
|
assert calls == [
|
|
("/browsers", "POST", {}),
|
|
]
|
|
|
|
|
|
# --- restart_daemon: PID-reuse safety ---
|
|
|
|
def test_restart_daemon_does_not_signal_when_daemon_unreachable(monkeypatch, tmp_path):
|
|
"""If ipc.identify() returns None (daemon gone), restart_daemon must NOT
|
|
fall back to reading the pid file and SIGTERMing whatever owns that PID —
|
|
that's the PID-reuse hazard. It should only clean up files."""
|
|
pid_path = tmp_path / "default.pid"
|
|
# A pid file with a PID that, if signaled, would hit an unrelated process.
|
|
# The whole point is that we don't read or trust this number.
|
|
pid_path.write_text("99999")
|
|
|
|
kill_calls = []
|
|
monkeypatch.setattr(admin.os, "kill", lambda pid, sig: kill_calls.append((pid, sig)))
|
|
monkeypatch.setattr(admin.ipc, "identify", lambda name, timeout=5.0: None)
|
|
monkeypatch.setattr(admin.ipc, "ping", lambda name, timeout=1.0: False)
|
|
monkeypatch.setattr(admin.ipc, "pid_path", lambda name: pid_path)
|
|
monkeypatch.setattr(admin.ipc, "cleanup_endpoint", lambda name: None)
|
|
|
|
# Should not raise, should not signal, should still clean up the pid file.
|
|
admin.restart_daemon("default")
|
|
|
|
assert kill_calls == [], (
|
|
f"restart_daemon SIGTERM'd a PID despite identify() returning None — "
|
|
f"this is the PID-reuse hazard the function is meant to avoid. Calls: {kill_calls}"
|
|
)
|
|
assert not pid_path.exists(), "stale pid file should be cleaned up"
|
|
|
|
|
|
def test_restart_daemon_signals_pid_returned_by_identify_not_pid_file(monkeypatch, tmp_path):
|
|
"""The PID we signal must come from the live daemon's self-report, never
|
|
from the pid file. If a stale pid file disagrees, the live daemon's PID wins."""
|
|
import signal
|
|
|
|
pid_path = tmp_path / "default.pid"
|
|
pid_path.write_text("99999") # bogus stale value — must be ignored
|
|
|
|
live_pid = 4242
|
|
|
|
kill_calls = []
|
|
def fake_kill(pid, sig):
|
|
kill_calls.append((pid, sig))
|
|
# First os.kill(pid, 0) probe: report process is gone so we exit the loop
|
|
# without escalating. We just want to see WHICH pid was probed.
|
|
if sig == 0:
|
|
raise ProcessLookupError
|
|
|
|
class FakeIPC:
|
|
def __init__(self):
|
|
self.shutdown_sent = False
|
|
def identify(self, name, timeout=5.0):
|
|
return live_pid
|
|
def connect(self, name, timeout):
|
|
return ("conn", "tok")
|
|
def request(self, conn, tok, msg):
|
|
if msg.get("meta") == "shutdown":
|
|
self.shutdown_sent = True
|
|
return {"ok": True}
|
|
def pid_path(self, name):
|
|
return pid_path
|
|
def cleanup_endpoint(self, name):
|
|
pass
|
|
|
|
fake = FakeIPC()
|
|
monkeypatch.setattr(admin.os, "kill", fake_kill)
|
|
monkeypatch.setattr(admin.ipc, "identify", fake.identify)
|
|
monkeypatch.setattr(admin.ipc, "ping", lambda name, timeout=1.0: True)
|
|
monkeypatch.setattr(admin.ipc, "connect", fake.connect)
|
|
monkeypatch.setattr(admin.ipc, "request", fake.request)
|
|
monkeypatch.setattr(admin.ipc, "pid_path", fake.pid_path)
|
|
monkeypatch.setattr(admin.ipc, "cleanup_endpoint", fake.cleanup_endpoint)
|
|
|
|
admin.restart_daemon("default")
|
|
|
|
assert fake.shutdown_sent, "expected shutdown IPC to be sent"
|
|
assert kill_calls, "expected at least one os.kill probe"
|
|
pids_signaled = {pid for pid, _ in kill_calls}
|
|
assert pids_signaled == {live_pid}, (
|
|
f"restart_daemon must only signal the PID returned by identify(); "
|
|
f"signaled pids: {pids_signaled}, expected {{{live_pid}}} (and NOT 99999)"
|
|
)
|
|
assert not pid_path.exists()
|
|
|
|
|
|
def test_restart_daemon_sends_shutdown_to_pre_upgrade_daemon_without_pid_in_ping(monkeypatch, tmp_path):
|
|
"""Backward compat: a pre-upgrade daemon's ping reply has {pong:True} but
|
|
no `pid` field, so identify() returns None. The shutdown IPC must STILL be
|
|
sent (so the daemon exits cleanly), but no os.kill happens (we have no
|
|
verified PID to safely signal)."""
|
|
pid_path = tmp_path / "default.pid"
|
|
pid_path.write_text("99999") # bogus stale value
|
|
|
|
kill_calls = []
|
|
shutdown_calls = []
|
|
|
|
def fake_request(conn, tok, msg):
|
|
if msg.get("meta") == "shutdown":
|
|
shutdown_calls.append(msg)
|
|
return {"ok": True}
|
|
|
|
monkeypatch.setattr(admin.os, "kill", lambda pid, sig: kill_calls.append((pid, sig)))
|
|
monkeypatch.setattr(admin.ipc, "identify", lambda name, timeout=5.0: None)
|
|
monkeypatch.setattr(admin.ipc, "ping", lambda name, timeout=1.0: True) # old daemon: alive but no pid
|
|
monkeypatch.setattr(admin.ipc, "connect", lambda name, timeout: ("conn", "tok"))
|
|
monkeypatch.setattr(admin.ipc, "request", fake_request)
|
|
monkeypatch.setattr(admin.ipc, "pid_path", lambda name: pid_path)
|
|
monkeypatch.setattr(admin.ipc, "cleanup_endpoint", lambda name: None)
|
|
|
|
admin.restart_daemon("default")
|
|
|
|
assert shutdown_calls, (
|
|
"restart_daemon must send shutdown IPC to a pre-upgrade daemon even "
|
|
"when identify() can't return a PID — otherwise upgrades orphan the "
|
|
"old daemon while deleting its socket and pid file."
|
|
)
|
|
assert kill_calls == [], (
|
|
f"no os.kill should fire when we don't have a verified PID, "
|
|
f"but got: {kill_calls}"
|
|
)
|
|
assert not pid_path.exists()
|
|
|
|
|
|
def test_restart_daemon_skips_sigterm_if_pid_was_reused_during_wait(monkeypatch, tmp_path):
|
|
"""A second identify() runs immediately before the SIGTERM. If the daemon
|
|
exited and the PID was reused mid-wait, identify() will return None (or a
|
|
different PID) and we must NOT signal — that's the PID-reuse race during
|
|
the 15s wait window."""
|
|
import signal
|
|
|
|
pid_path = tmp_path / "default.pid"
|
|
pid_path.write_text("99999")
|
|
live_pid = 4242
|
|
|
|
kill_calls = []
|
|
|
|
def fake_kill(pid, sig):
|
|
kill_calls.append((pid, sig))
|
|
# All os.kill(pid, 0) probes succeed → loop exhausts → reaches the
|
|
# SIGTERM branch. (We're simulating a "wedged" daemon that the wait
|
|
# loop can't tell apart from a daemon whose PID got reused.)
|
|
|
|
# First identify() call (top of restart_daemon) returns the live PID.
|
|
# Second identify() call (right before SIGTERM) returns None — simulating
|
|
# the daemon having exited and its PID having been reused by an unrelated
|
|
# process. The function must NOT escalate to SIGTERM in that state.
|
|
identify_responses = iter([live_pid, None])
|
|
monkeypatch.setattr(admin.os, "kill", fake_kill)
|
|
monkeypatch.setattr(admin.ipc, "identify", lambda name, timeout=5.0: next(identify_responses))
|
|
monkeypatch.setattr(admin.ipc, "ping", lambda name, timeout=1.0: True)
|
|
monkeypatch.setattr(admin.ipc, "connect", lambda name, timeout: ("conn", "tok"))
|
|
monkeypatch.setattr(admin.ipc, "request", lambda conn, tok, msg: {"ok": True})
|
|
monkeypatch.setattr(admin.ipc, "pid_path", lambda name: pid_path)
|
|
monkeypatch.setattr(admin.ipc, "cleanup_endpoint", lambda name: None)
|
|
# Speed up the wait loop so the test finishes quickly. The loop polls 75
|
|
# times at 0.2s = 15s; with sleep neutralized it runs in microseconds.
|
|
monkeypatch.setattr(admin.time, "sleep", lambda _s: None)
|
|
|
|
admin.restart_daemon("default")
|
|
|
|
sigterms = [(pid, sig) for pid, sig in kill_calls if sig == signal.SIGTERM]
|
|
assert sigterms == [], (
|
|
f"restart_daemon issued SIGTERM despite the re-verify identify() "
|
|
f"returning None (PID was reused during the 15s wait). Calls: {kill_calls}"
|
|
)
|
|
assert not pid_path.exists()
|
|
|
|
|
|
def test_restart_daemon_sigterms_via_start_time_fingerprint_when_socket_gone(monkeypatch, tmp_path):
|
|
"""Slow-shutdown recovery: the daemon's serve() tears down the IPC socket
|
|
BEFORE the process exits (the daemon then runs slow cleanup like remote
|
|
`stop` PATCH calls that can hang). In that window, identify() returns None
|
|
even though the process is still our daemon. SIGTERM must still fire when
|
|
the PID's start-time fingerprint hasn't changed since we first identified
|
|
it — that's strong evidence of "same process, just slow to exit."
|
|
"""
|
|
import signal
|
|
|
|
pid_path = tmp_path / "default.pid"
|
|
pid_path.write_text("99999")
|
|
live_pid = 4242
|
|
|
|
kill_calls = []
|
|
|
|
def fake_kill(pid, sig):
|
|
kill_calls.append((pid, sig))
|
|
# All os.kill(pid, 0) probes succeed; loop exhausts → SIGTERM gate runs.
|
|
|
|
# First identify() returns live_pid. Second identify() returns None — the
|
|
# daemon has torn down its IPC during shutdown but the process is still
|
|
# finishing up cleanup work, so the start-time fingerprint is unchanged.
|
|
identify_responses = iter([live_pid, None])
|
|
# Both _process_start_time() calls return the same fingerprint, signaling
|
|
# "still the same process." This is the legitimate-slow-shutdown case.
|
|
monkeypatch.setattr(admin, "_process_start_time", lambda pid: "STARTED_AT_X")
|
|
monkeypatch.setattr(admin.os, "kill", fake_kill)
|
|
monkeypatch.setattr(admin.ipc, "identify", lambda name, timeout=5.0: next(identify_responses))
|
|
monkeypatch.setattr(admin.ipc, "ping", lambda name, timeout=1.0: True)
|
|
monkeypatch.setattr(admin.ipc, "connect", lambda name, timeout: ("conn", "tok"))
|
|
monkeypatch.setattr(admin.ipc, "request", lambda conn, tok, msg: {"ok": True})
|
|
monkeypatch.setattr(admin.ipc, "pid_path", lambda name: pid_path)
|
|
monkeypatch.setattr(admin.ipc, "cleanup_endpoint", lambda name: None)
|
|
monkeypatch.setattr(admin.time, "sleep", lambda _s: None)
|
|
|
|
admin.restart_daemon("default")
|
|
|
|
sigterms = [(pid, sig) for pid, sig in kill_calls if sig == signal.SIGTERM]
|
|
assert sigterms == [(live_pid, signal.SIGTERM)], (
|
|
f"slow-shutdown daemon (identify=None but unchanged start-time) must "
|
|
f"still receive SIGTERM. signal calls: {kill_calls}"
|
|
)
|
|
|
|
|
|
def test_restart_daemon_skips_sigterm_when_start_time_changed_during_wait(monkeypatch, tmp_path):
|
|
"""If the start-time fingerprint of the original PID has CHANGED, the PID
|
|
was reused by another process. Even though identify() also returns None,
|
|
we must skip SIGTERM — start-time mismatch is the signal that protects
|
|
against killing an unrelated reused-PID process."""
|
|
import signal
|
|
|
|
pid_path = tmp_path / "default.pid"
|
|
pid_path.write_text("99999")
|
|
live_pid = 4242
|
|
|
|
kill_calls = []
|
|
monkeypatch.setattr(admin.os, "kill", lambda pid, sig: kill_calls.append((pid, sig)))
|
|
|
|
identify_responses = iter([live_pid, None])
|
|
# First start-time read at top of restart_daemon: "ORIGINAL".
|
|
# Second start-time read in the safety gate: "DIFFERENT" — proof of reuse.
|
|
start_time_responses = iter(["ORIGINAL", "DIFFERENT"])
|
|
monkeypatch.setattr(admin, "_process_start_time", lambda pid: next(start_time_responses))
|
|
monkeypatch.setattr(admin.ipc, "identify", lambda name, timeout=5.0: next(identify_responses))
|
|
monkeypatch.setattr(admin.ipc, "ping", lambda name, timeout=1.0: True)
|
|
monkeypatch.setattr(admin.ipc, "connect", lambda name, timeout: ("conn", "tok"))
|
|
monkeypatch.setattr(admin.ipc, "request", lambda conn, tok, msg: {"ok": True})
|
|
monkeypatch.setattr(admin.ipc, "pid_path", lambda name: pid_path)
|
|
monkeypatch.setattr(admin.ipc, "cleanup_endpoint", lambda name: None)
|
|
monkeypatch.setattr(admin.time, "sleep", lambda _s: None)
|
|
|
|
admin.restart_daemon("default")
|
|
|
|
sigterms = [(pid, sig) for pid, sig in kill_calls if sig == signal.SIGTERM]
|
|
assert sigterms == [], (
|
|
f"start-time mismatch indicates PID reuse — restart_daemon must NOT "
|
|
f"SIGTERM. signal calls: {kill_calls}"
|
|
)
|
|
|
|
|
|
# --- _process_start_time helper ---
|
|
|
|
def test_process_start_time_returns_stable_fingerprint_for_self():
|
|
"""The start-time of the current process should be readable on Linux,
|
|
macOS, and Windows, and stable across two reads."""
|
|
import os as _os, sys
|
|
if sys.platform.startswith("linux") or sys.platform == "darwin" or sys.platform == "win32":
|
|
pid = _os.getpid()
|
|
first = admin._process_start_time(pid)
|
|
second = admin._process_start_time(pid)
|
|
assert first is not None, "expected a fingerprint for the current PID"
|
|
assert first == second, (
|
|
f"two reads of the same PID should return the same fingerprint; "
|
|
f"got {first!r} vs {second!r}"
|
|
)
|
|
|
|
|
|
def test_process_start_time_returns_none_for_invalid_pid():
|
|
"""Bad inputs (None, 0, negatives, non-int) and PIDs with no live process
|
|
must return None rather than raising."""
|
|
for bad in (None, 0, -1, -42, "not-an-int", 1.5, True, False):
|
|
assert admin._process_start_time(bad) is None, (
|
|
f"expected None for invalid pid {bad!r}"
|
|
)
|
|
# 2**31 - 1 is the largest pid_t; in practice no live process at that PID.
|
|
assert admin._process_start_time((1 << 31) - 1) is None
|