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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 @@
|
||||
<div id="loader" role="status" aria-label="Updating"></div>
|
||||
<div id="glyph"></div>
|
||||
<h2 id="title">Updating Hermes</h2>
|
||||
<p id="line">Preparing update<br>0s elapsed</p>
|
||||
<p id="line">Hermes will open once done.</p>
|
||||
</div>
|
||||
<script>
|
||||
/* ── Fourier Flow loader, ported verbatim from loader.tsx ─────────────── */
|
||||
@@ -199,9 +203,12 @@
|
||||
const titleEl = document.getElementById('title')
|
||||
const lineEl = document.getElementById('line')
|
||||
const glyphEl = document.getElementById('glyph')
|
||||
const clientStartedAt = Date.now()
|
||||
const defaultLine = lineEl.textContent /* what a stage-less run says */
|
||||
let settled = false
|
||||
|
||||
const elapsedText = s =>
|
||||
s < 60 ? `${s}s elapsed` : `${Math.floor(s / 60)}m ${s % 60}s elapsed`
|
||||
|
||||
function settle(state) {
|
||||
settled = true
|
||||
window.cancelAnimationFrame(frame)
|
||||
@@ -211,15 +218,11 @@
|
||||
function apply(state) {
|
||||
if (settled) return
|
||||
if (state.status === 'running') {
|
||||
const reported = Number(state.elapsed_seconds)
|
||||
const elapsed = Number.isFinite(reported) && reported >= 0
|
||||
? Math.floor(reported)
|
||||
: Math.floor((Date.now() - clientStartedAt) / 1000)
|
||||
const elapsedText = elapsed < 60
|
||||
? `${elapsed}s elapsed`
|
||||
: `${Math.floor(elapsed / 60)}m ${elapsed % 60}s elapsed`
|
||||
lineEl.textContent = `${state.message || 'Updating Hermes'}\n${elapsedText}`
|
||||
lineEl.style.whiteSpace = 'pre-line'
|
||||
const stage = state.message || defaultLine
|
||||
const elapsed = Number(state.elapsed_seconds)
|
||||
lineEl.textContent = Number.isFinite(elapsed) && elapsed >= 0
|
||||
? `${stage}\n${elapsedText(Math.floor(elapsed))}`
|
||||
: stage
|
||||
} else if (state.status === 'done') {
|
||||
settle('done')
|
||||
glyphEl.textContent = '\u2713'
|
||||
|
||||
@@ -83,7 +83,7 @@ $LogDir = Join-Path $HermesHome "logs"
|
||||
$LogPath = Join-Path $LogDir "desktop-update-handoff.log"
|
||||
$ResultPath = Join-Path $HermesHome ".hermes-update-result.json"
|
||||
$script:Ui = $null
|
||||
$script:UiStage = "Preparing update"
|
||||
$script:UiStage = "Hermes will open once done." # until the first gate; matches ui.html
|
||||
$script:UiStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
function Write-HandoffLog([string]$Message) {
|
||||
|
||||
Reference in New Issue
Block a user