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

@ -6199,11 +6199,10 @@ def _find_stale_dashboard_pids(
disk is updated, causing a silent frontend/backend mismatch (e.g. new
auth headers the old backend doesn't recognise → every API call 401s).
The dashboard has no service manager (systemd / launchd), no PID file,
and we can't know the original launch args — so the only sane action
after an update is to kill the stale process and let the user restart
it. This helper is just the detection step; see
``_kill_stale_dashboard_processes`` for the kill.
The dashboard may be manually started or managed by the optional
``hermes-dashboard.service`` systemd unit. Managed units are restarted
through their owning systemd scope; only manually-started processes use
the kill path because we can't know their original launch args.
*exclude_pids* is an optional set of PIDs that must never be returned.
This is used by the Hermes Desktop Electron app to protect its own
@ -6543,8 +6542,121 @@ def _format_time_ago(iso_ts: str) -> str:
return "recently"
_DASHBOARD_SYSTEMD_UNIT = "hermes-dashboard.service"
def _restart_managed_dashboard_service(
reason: str,
unit: str = _DASHBOARD_SYSTEMD_UNIT,
) -> bool:
"""Restart a systemd-managed dashboard instead of raw-killing its PID.
Returns True when a dashboard unit was found and handled (successfully or
with a printed actionable failure). Returning True deliberately prevents
the caller from falling back to ``os.kill``: systemd treats a direct
SIGTERM of the service's main PID as a clean stop, so ``Restart=on-failure``
will not bring the dashboard back.
"""
if sys.platform == "win32":
return False
def _systemctl(*args: str, timeout: int = 10) -> subprocess.CompletedProcess:
return subprocess.run(
["systemctl", *args],
capture_output=True,
text=True,
timeout=timeout,
)
# Probe the user manager first: Hermes installs Linux services in the
# user's systemd scope by default. Only fall back to the system manager
# when the unit is not present there, preserving root/system deployments.
# Crucially, keep the selected scope for *all* probes and the restart — a
# user unit must never be restarted through the system manager (or raw-killed).
scope: tuple[str, ...] | None = None
listed: subprocess.CompletedProcess | None = None
for candidate in (("--user",), ()):
try:
result = _systemctl(
*candidate, "list-unit-files", unit, "--no-legend", "--no-pager"
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
continue
if result.returncode != 0:
continue
unit_rows = (result.stdout or "").splitlines()
if any(row.split()[0:1] == [unit] for row in unit_rows if row.split()):
scope = candidate
listed = result
break
if scope is None or listed is None:
return False
try:
active = _systemctl(*scope, "is-active", unit)
enabled = _systemctl(*scope, "is-enabled", unit)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return False
active_state = (active.stdout or "").strip()
enabled_state = (enabled.stdout or "").strip()
if active_state != "active" and enabled_state not in {
"enabled",
"enabled-runtime",
"linked",
"linked-runtime",
"static",
"generated",
}:
return False
print()
print(f"⟲ Restarting managed dashboard service ({reason})")
scope_label = "systemctl --user" if scope else "sudo systemctl"
restart = ("systemctl", *scope, "restart", unit)
commands = [restart]
if not scope:
# System units may require privilege escalation; user units must use
# the user manager directly and never prompt for sudo.
commands.append(("sudo", "-n", "systemctl", "restart", unit))
errors: list[str] = []
for command in commands:
try:
result = subprocess.run(
list(command),
capture_output=True,
text=True,
timeout=60,
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e:
errors.append(f"{' '.join(command)}: {e}")
continue
if result.returncode == 0:
print(f" ✓ restarted {unit}")
return True
errors.append(
f"{' '.join(command)}: {(result.stderr or result.stdout or '').strip()}"
)
print(f" ✗ failed to restart {unit}")
for err in errors:
if err.strip():
print(f" {err}")
print(
" Dashboard is managed by systemd; not raw-killing its PID because "
"systemd would treat that as a clean stop."
)
print(f" Restart manually: {scope_label} restart {unit}")
return True
def _kill_stale_dashboard_processes(
reason: str = "the running backend no longer matches the updated frontend",
*,
restart_managed: bool = False,
) -> None:
"""Kill running ``hermes dashboard`` processes.
@ -6560,10 +6672,15 @@ def _kill_stale_dashboard_processes(
Windows: ``taskkill /PID <pid> /F`` since there's no clean SIGTERM
equivalent for background console apps.
The dashboard isn't auto-restarted because we don't know the original
launch args (--host, --port, --insecure, --tui, --no-open). The user
restarts it manually; a hint is printed.
Manually-started dashboards are not auto-restarted because we don't know
the original launch args (--host, --port, --insecure, --tui, --no-open).
When ``restart_managed`` is true (the ``hermes update`` path), a detected
``hermes-dashboard.service`` is restarted through systemd instead of
raw-killing its main PID.
"""
if restart_managed and _restart_managed_dashboard_service(reason):
return
# When the Hermes Desktop Electron app spawns this dashboard as a
# backend child, it sets HERMES_DESKTOP_CHILD_PID so that the update
# path can skip killing the desktop-managed process. (#37532)
@ -6915,7 +7032,7 @@ def _update_via_zip(args):
print(" Leaving running dashboard process(es) untouched because the")
print(" Node.js dependency refresh did not complete.")
else:
_kill_stale_dashboard_processes()
_kill_stale_dashboard_processes(restart_managed=True)
def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[str]:
@ -11864,22 +11981,17 @@ def _cmd_update_impl(args, gateway_mode: bool):
except Exception as e:
logger.debug("Legacy unit check during update failed: %s", e)
# Kill stale dashboard processes — the dashboard has no service
# manager, so leaving it alive after a code update produces a
# silent frontend/backend mismatch. We can't auto-restart it
# (no saved launch args) but we can stop it, and a hint is
# printed for the user to re-launch.
#
# Exception: if the Node dependency refresh failed, the rebuilt
# frontend the new backend expects may not exist, so stopping a
# working dashboard would leave the user with nothing running
# rather than a usable (if mixed) state (#30271). Leave it alone.
# Restart a managed dashboard through systemd, or stop stale manual
# dashboard processes. Raw-killing a systemd-owned dashboard PID makes
# systemd treat it as a clean stop, leaving the Cloudflare origin dead.
# Preserve the safety rule above: a failed Node refresh leaves the
# currently running dashboard untouched.
if node_failures:
print()
print(" Leaving running dashboard process(es) untouched because the")
print(" Node.js dependency refresh did not complete.")
else:
_kill_stale_dashboard_processes()
_kill_stale_dashboard_processes(restart_managed=True)
print()
print("Tip: You can now select a provider and model:")