fix(cli): restart managed dashboard service after update (#39166)

* Keep systemd dashboard alive after update

* fix: restart managed dashboard in owning systemd scope
This commit is contained in:
Andy 2026-07-22 21:01:19 +08:00 committed by GitHub
parent 9acc4b47f5
commit 8967e73e67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 260 additions and 20 deletions

View file

@ -23,6 +23,7 @@ import pytest
from hermes_cli.main import (
_find_stale_dashboard_pids,
_kill_stale_dashboard_processes,
_restart_managed_dashboard_service,
_warn_stale_dashboard_processes, # back-compat alias
)
@ -47,6 +48,7 @@ def _refresh_bindings_against_live_module():
"""
global _find_stale_dashboard_pids
global _kill_stale_dashboard_processes
global _restart_managed_dashboard_service
global _warn_stale_dashboard_processes
live = sys.modules.get("hermes_cli.main")
@ -55,6 +57,7 @@ def _refresh_bindings_against_live_module():
_find_stale_dashboard_pids = live._find_stale_dashboard_pids
_kill_stale_dashboard_processes = live._kill_stale_dashboard_processes
_restart_managed_dashboard_service = live._restart_managed_dashboard_service
_warn_stale_dashboard_processes = live._warn_stale_dashboard_processes
yield
@ -332,6 +335,131 @@ class TestKillStaleDashboardPosix:
assert "✓ stopped PID 12345" in out
assert "failed to stop" not in out
def test_update_path_restarts_managed_dashboard_instead_of_killing(self, capsys):
"""A systemd-managed dashboard must be restarted through systemd.
Raw-killing the unit's main PID makes systemd record a clean stop, so
Restart=on-failure does not recover the Cloudflare origin.
"""
calls: list[list[str]] = []
def fake_run(args, *a, **kw):
calls.append(list(args))
if args == ["systemctl", "--user", "list-unit-files", "hermes-dashboard.service", "--no-legend", "--no-pager"]:
return MagicMock(returncode=0, stdout="", stderr="")
if args[:2] == ["systemctl", "list-unit-files"]:
return MagicMock(returncode=0, stdout="hermes-dashboard.service enabled enabled\n", stderr="")
if args[:2] == ["systemctl", "is-active"]:
return MagicMock(returncode=0, stdout="active\n", stderr="")
if args[:2] == ["systemctl", "is-enabled"]:
return MagicMock(returncode=0, stdout="enabled\n", stderr="")
if args == ["systemctl", "restart", "hermes-dashboard.service"]:
return MagicMock(returncode=0, stdout="", stderr="")
raise AssertionError(f"unexpected subprocess.run call: {args}")
with patch("subprocess.run", side_effect=fake_run), \
patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[12345]) as find_pids, \
patch("os.kill") as kill:
_kill_stale_dashboard_processes(restart_managed=True)
assert ["systemctl", "restart", "hermes-dashboard.service"] in calls
find_pids.assert_not_called()
kill.assert_not_called()
out = capsys.readouterr().out
assert "Restarting managed dashboard service" in out
assert "✓ restarted hermes-dashboard.service" in out
def test_user_scope_restart_never_falls_back_to_system_or_sudo(self, capsys):
"""A user unit is discovered and restarted through ``systemctl --user``."""
calls: list[list[str]] = []
def fake_run(args, *a, **kw):
calls.append(list(args))
if args == ["systemctl", "--user", "list-unit-files", "hermes-dashboard.service", "--no-legend", "--no-pager"]:
return MagicMock(returncode=0, stdout="hermes-dashboard.service enabled enabled\n", stderr="")
if args == ["systemctl", "--user", "is-active", "hermes-dashboard.service"]:
return MagicMock(returncode=0, stdout="active\n", stderr="")
if args == ["systemctl", "--user", "is-enabled", "hermes-dashboard.service"]:
return MagicMock(returncode=0, stdout="enabled\n", stderr="")
if args == ["systemctl", "--user", "restart", "hermes-dashboard.service"]:
return MagicMock(returncode=0, stdout="", stderr="")
raise AssertionError(f"unexpected subprocess.run call: {args}")
with patch("subprocess.run", side_effect=fake_run), \
patch("hermes_cli.main._find_stale_dashboard_pids", return_value=[12345]) as find_pids, \
patch("os.kill") as kill:
_kill_stale_dashboard_processes(restart_managed=True)
assert calls == [
["systemctl", "--user", "list-unit-files", "hermes-dashboard.service", "--no-legend", "--no-pager"],
["systemctl", "--user", "is-active", "hermes-dashboard.service"],
["systemctl", "--user", "is-enabled", "hermes-dashboard.service"],
["systemctl", "--user", "restart", "hermes-dashboard.service"],
]
assert all(call[:1] != ["sudo"] and call[:2] != ["systemctl"] for call in calls)
find_pids.assert_not_called()
kill.assert_not_called()
assert "✓ restarted hermes-dashboard.service" in capsys.readouterr().out
def test_user_scope_restart_failure_does_not_try_system_or_sudo(self):
"""A failed user-manager restart remains fail-closed and never raw-kills."""
calls: list[list[str]] = []
def fake_run(args, *a, **kw):
calls.append(list(args))
if args == ["systemctl", "--user", "list-unit-files", "hermes-dashboard.service", "--no-legend", "--no-pager"]:
return MagicMock(returncode=0, stdout="hermes-dashboard.service enabled enabled\n", stderr="")
if args[-2:] == ["is-active", "hermes-dashboard.service"]:
return MagicMock(returncode=0, stdout="active\n", stderr="")
if args[-2:] == ["is-enabled", "hermes-dashboard.service"]:
return MagicMock(returncode=0, stdout="enabled\n", stderr="")
if args[-2:] == ["restart", "hermes-dashboard.service"]:
return MagicMock(returncode=1, stdout="", stderr="user manager unavailable")
raise AssertionError(f"unexpected subprocess.run call: {args}")
with patch("subprocess.run", side_effect=fake_run), \
patch("hermes_cli.main._find_stale_dashboard_pids") as find_pids, \
patch("os.kill") as kill:
_kill_stale_dashboard_processes(restart_managed=True)
assert calls[-1] == ["systemctl", "--user", "restart", "hermes-dashboard.service"]
assert not any(call[:1] == ["sudo"] or call == ["systemctl", "restart", "hermes-dashboard.service"] for call in calls)
find_pids.assert_not_called()
kill.assert_not_called()
def test_managed_dashboard_restart_failure_does_not_raw_kill(self, capsys):
"""If systemd restart cannot run, print the fix and do not kill the PID."""
def fake_run(args, *a, **kw):
if args == ["systemctl", "--user", "list-unit-files", "hermes-dashboard.service", "--no-legend", "--no-pager"]:
return MagicMock(returncode=0, stdout="", stderr="")
if args[:2] == ["systemctl", "list-unit-files"]:
return MagicMock(returncode=0, stdout="hermes-dashboard.service enabled enabled\n", stderr="")
if args[:2] == ["systemctl", "is-active"]:
return MagicMock(returncode=0, stdout="active\n", stderr="")
if args[:2] == ["systemctl", "is-enabled"]:
return MagicMock(returncode=0, stdout="enabled\n", stderr="")
if args == ["systemctl", "restart", "hermes-dashboard.service"]:
return MagicMock(returncode=1, stdout="", stderr="Interactive authentication required.\n")
if args == ["sudo", "-n", "systemctl", "restart", "hermes-dashboard.service"]:
return MagicMock(returncode=1, stdout="", stderr="a password is required\n")
raise AssertionError(f"unexpected subprocess.run call: {args}")
with patch("subprocess.run", side_effect=fake_run), \
patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[12345]) as find_pids, \
patch("os.kill") as kill:
_kill_stale_dashboard_processes(restart_managed=True)
find_pids.assert_not_called()
kill.assert_not_called()
out = capsys.readouterr().out
assert "failed to restart hermes-dashboard.service" in out
assert "not raw-killing its PID" in out
assert "sudo systemctl restart hermes-dashboard.service" in out
class TestKillStaleDashboardWindows:
"""Kill path on Windows: taskkill /F."""