From cf2ab8522ad957b011ea51465a159592ec015167 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 19 Aug 2026 13:13:36 -0500 Subject: [PATCH] fix(update): one elapsed clock, served to the shim by both orchestrators The shim is a shared page, but only windows.ps1 publishes a stage and an elapsed count. On mac and Linux posix.sh publishes `running` with an empty message and no clock, so the running branch rendered the h2 back into the muted line ("Updating Hermes" twice) and started a clock in the browser, losing "Hermes will open once done." on both platforms. A clock started in the page measures when the window painted, not how long the update has been running -- on posix that is the only clock there is, and it reads zero after the desktop-exit wait has already burned 30s. That is the hardcoded-milestone problem #75895 removed, in a new costume. So: elapsed comes from the orchestrator or is not shown. serve-ui.py stamps it per request from the hand-off start (a value written into the status file would freeze between publishes, which are minutes apart -- exactly the stall the line exists to disprove), matching what Windows' in-process listener already does. posix.sh gets the stages it was missing, at the four gates it genuinely waits on. Absent a stage the page keeps the settled copy, and an old orchestrator that sends no clock simply shows no clock. --- scripts/desktop-update/posix.sh | 21 +++++++++++++++++--- scripts/desktop-update/serve-ui.py | 27 ++++++++++++++++++++------ scripts/desktop-update/ui.html | 31 ++++++++++++++++-------------- scripts/desktop-update/windows.ps1 | 2 +- 4 files changed, 57 insertions(+), 24 deletions(-) diff --git a/scripts/desktop-update/posix.sh b/scripts/desktop-update/posix.sh index 1459fe6aa7..714d8a1ad5 100755 --- a/scripts/desktop-update/posix.sh +++ b/scripts/desktop-update/posix.sh @@ -64,6 +64,7 @@ LOG_DIR="$HERMES_HOME/logs"; mkdir -p "$LOG_DIR" 2>/dev/null || true LOG="$LOG_DIR/desktop-update-handoff.log" RESULT="$HERMES_HOME/.hermes-update-result.json" STATUS="${TMPDIR:-/tmp}/hermes-update-status.$$" +STARTED_AT="$(date +%s)" # the shim's elapsed clock; see serve-ui.py UI_SERVER_PID="" UI_BROWSER_PID="" FINAL_CODE=1 FINAL_MSG="update did not complete" @@ -147,9 +148,19 @@ notify_fallback() { # status message — renderer-free recovery surface. log "NOTICE: no notification surface accepted; outcome reaches the user via the result dialog on next launch: $2" } -publish() { # status message -- atomic replace; the server reads per poll +write_status() { # status message -- atomic replace; the server reads per poll printf '{"status":"%s","message":"%s"}' "$(json_escape "$1")" "$(json_escape "$2")" > "$STATUS.tmp" \ && mv -f "$STATUS.tmp" "$STATUS" 2>/dev/null || true +} + +publish_stage() { # a long wait the orchestrator is already gating on. No poll + # beat (that would add a second per stage to every update) and no + # notification fallback (there is nothing here for the user to act on). + write_status "running" "$1" +} + +publish() { # terminal event -- the page must render it before teardown + write_status "$1" "$2" [ -n "$UI_SERVER_PID" ] && sleep 1 # one poll beat to render the state [ -z "$UI_SERVER_PID" ] && notify_fallback "$1" "$2" } @@ -178,7 +189,7 @@ start_ui() { browser="$(find_browser)" { [ -f "$html" ] && [ -n "$py" ] && [ -n "$browser" ]; } || { log "shim: no renderer; skipping UI"; return; } - publish "running" "" + publish_stage "" # The Desktop's final teardown targets the updater process group. Put both # UI processes in their own sessions so neither the HTTP server nor a Chrome # renderer becomes collateral damage (Chrome surfaces that renderer death as @@ -190,7 +201,7 @@ start_ui() { # window showed ERR_CONNECTION_REFUSED for the whole run; upstream #66753). # stop_ui ends the server with SIGKILL instead — it is stateless HTTP. "$py" -c 'import os, signal, sys; os.setsid(); signal.signal(signal.SIGTERM, signal.SIG_IGN); signal.signal(signal.SIGHUP, signal.SIG_IGN); os.execv(sys.argv[1], sys.argv[1:])' \ - "$py" "$SCRIPT_DIR/serve-ui.py" "$html" "$STATUS" > "$LOG_DIR/desktop-update-ui-port" 2>>"$LOG" & + "$py" "$SCRIPT_DIR/serve-ui.py" "$html" "$STATUS" "$STARTED_AT" > "$LOG_DIR/desktop-update-ui-port" 2>>"$LOG" & UI_SERVER_PID=$! for i in $(seq 1 10); do port="$(tr -cd '0-9' < "$LOG_DIR/desktop-update-ui-port" 2>/dev/null)" @@ -264,6 +275,7 @@ mac_swap() { # the copy in. Every step checked; a failed final move ROLLS BACK so the # user always has a launchable app, and the result file tells the truth. if [ "$FINAL_CODE" -eq 0 ] && [ -n "$rebuilt" ] && [ -d "$RELAUNCH_TARGET" ] && [ "$rebuilt" != "$RELAUNCH_TARGET" ]; then + publish_stage "Installing the new app" rm -rf "$RELAUNCH_TARGET.new" "$RELAUNCH_TARGET.old" 2>/dev/null || true if ! /usr/bin/ditto "$rebuilt" "$RELAUNCH_TARGET.new"; then rm -rf "$RELAUNCH_TARGET.new" 2>/dev/null || true @@ -486,6 +498,7 @@ cd "$INSTALL_ROOT" || { } export PYTHONUNBUFFERED=1 log "running: hermes update --yes --gateway --branch $BRANCH" +publish_stage "Updating code and dependencies" OUT="$("$HERMES_BIN" update --yes --gateway --branch "$BRANCH" 2>&1)"; CODE=$? printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null log "hermes update exit code: $CODE" @@ -494,6 +507,7 @@ if [ "$CODE" -ne 0 ] && [ "$CODE" -ne 2 ]; then # Retry once: update-boundary class (fresh code on disk, stale in memory). # Exit 2 ("close all Hermes windows") is not retryable. log "retrying once (freshly pulled fix loads on the second run)" + publish_stage "Retrying update" OUT="$("$HERMES_BIN" update --yes --gateway --branch "$BRANCH" 2>&1)"; CODE=$? printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null log "retry exit code: $CODE" @@ -505,6 +519,7 @@ trap 'on_signal TERM' TERM # and call it success -- retry the build once, propagate honestly. if [ "$CODE" -eq 0 ] && printf '%s' "$OUT" | grep -q "Desktop build failed"; then log "desktop build failed inside hermes update; retrying build" + publish_stage "Rebuilding Desktop" "$HERMES_BIN" desktop --force-build --build-only >> "$LOG" 2>&1 || { FINAL_CODE=6 FINAL_MSG="Code and dependencies updated, but the Desktop app rebuild failed - you are running the previous build. Run hermes desktop --force-build from a terminal to retry." exit 6 diff --git a/scripts/desktop-update/serve-ui.py b/scripts/desktop-update/serve-ui.py index 8d0a6bfe61..87801df099 100644 --- a/scripts/desktop-update/serve-ui.py +++ b/scripts/desktop-update/serve-ui.py @@ -4,30 +4,45 @@ Two GET routes: / serves ui.html, /progress serves the status file the orchestrator script writes ({"status": "running"|"done"|"error", ...}). Exists because a file:// page cannot receive events from a detached process. Prints the chosen ephemeral port on stdout, serves until killed. + +`elapsed_seconds` is stamped per request, not read from the status file: +stages are minutes apart, so a value frozen at the last publish would sit +still through the longest waits -- exactly the stall the page exists to +disprove. Windows' in-process listener computes it the same way. """ import http.server import json import socketserver import sys +import time html_path, status_path = sys.argv[1], sys.argv[2] +started_at = float(sys.argv[3]) if len(sys.argv) > 3 else time.time() with open(html_path, "rb") as f: HTML = f.read() +def progress_body(): + try: + with open(status_path, "rb") as f: + state = json.loads(f.read()) + if not isinstance(state, dict): + raise ValueError(state) + except Exception: + state = {"status": "running", "message": ""} + state["elapsed_seconds"] = max(0, int(time.time() - started_at)) + + return json.dumps(state).encode("utf-8") + + class Handler(http.server.BaseHTTPRequestHandler): def log_message(self, format, *args): # noqa: A002 - base class signature pass def do_GET(self): if self.path.startswith("/progress"): - try: - with open(status_path, "rb") as f: - body = f.read() - json.loads(body) - except Exception: - body = b'{"status":"running","message":""}' + body = progress_body() ctype = "application/json; charset=utf-8" elif self.path == "/": body, ctype = HTML, "text/html; charset=utf-8" diff --git a/scripts/desktop-update/ui.html b/scripts/desktop-update/ui.html index 2b36bf6403..6e55cb694c 100644 --- a/scripts/desktop-update/ui.html +++ b/scripts/desktop-update/ui.html @@ -4,8 +4,9 @@ Served over loopback by the orchestrator (windows.ps1 / posix.sh) into a chromeless browser app window. Pure veneer: polls /progress for the current - hand-off stage or a terminal event and reacts; owns nothing (relaunch, result file, marker hygiene - all live in the orchestrator, which runs identically with no UI at all). + hand-off stage or a terminal event and reacts; owns nothing (relaunch, + result file, marker hygiene all live in the orchestrator, which runs + identically with no UI at all). The visual is PR #75895's update hand-off screen, ported verbatim: - Loader: the desktop's "Fourier Flow" curve. Math + tuning lifted from @@ -13,7 +14,9 @@ apps/desktop/src/components/ui/loader.tsx 'fourier-flow'). Keep the constants in sync if the desktop's curve is retuned. - Layout: loader (size-20) + one title + one muted stage/elapsed line. No - progress bar, stage list, log pane, or cancel (see #75895 for the arguments). + progress bar, stage list, log pane, or cancel (see #75895 for the + arguments). Elapsed comes from the orchestrator or is omitted -- a clock + started here would measure when this window painted, not the update. - Appearance follows the OS. Dark seeds are the installer's neutral charcoal (#232323 base, foreground #d6d6d6) — never brand blue. --> @@ -89,6 +92,7 @@ font-size: 12px; line-height: 1.5; color: var(--muted-foreground); + white-space: pre-line; } p code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; @@ -104,7 +108,7 @@

Updating Hermes

-

Preparing update
0s elapsed

+

Hermes will open once done.