Files
browser-use--browser-harness/tests/unit/test_admin.py
T
2026-07-13 12:45:10 +08:00

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