From ed17c1cea323bb7bb4e4181c0adf4c9dd43a4f35 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:43:14 -0700 Subject: [PATCH] fix(desktop): the hand-off waits long enough for a Desktop quit that joins a managed update The hand-off scripts gave the Desktop a fixed 30 s (windows.ps1) or 60 s (posix.sh) to exit, but the Desktop's quit can first join a managed SSH update it is running, which legitimately takes longer. The hand-off then recorded a failed update although the Desktop exited safely moments later (review H2). Both twins now share one 150 s ceiling, measured on the clock rather than as a loop count, and still refuse with "Nothing was changed" if the Desktop is alive at the ceiling, so nothing is ever replaced under a live Desktop. The ceiling is overridable through HERMES_UPDATE_DESKTOP_EXIT_SECONDS only so the self-tests need not sit it out, like the existing pipe-drain and step-idle overrides. Tests: a Desktop still alive at the ceiling refuses with exit 4 and never runs the update (red on the start sha, which waited out its hardcoded 60 s); a Desktop that exits inside the ceiling lets the update run (control). --- scripts/desktop-update/posix.sh | 12 ++++++-- scripts/desktop-update/windows.ps1 | 16 ++++++++-- .../test_desktop_update_posix_marker.py | 30 +++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/scripts/desktop-update/posix.sh b/scripts/desktop-update/posix.sh index a913a5b49e..8658996abd 100755 --- a/scripts/desktop-update/posix.sh +++ b/scripts/desktop-update/posix.sh @@ -1032,11 +1032,17 @@ for _stale in "$INSTALL_ROOT"/venv/bin/*.tcc-heal-old.* "$INSTALL_ROOT"/venv/bin done # Wait out the Desktop (FAIL CLOSED: updating under live backends bricks). -# Zombie-aware and identity-checked: a reused pid is not the Desktop. +# Zombie-aware and identity-checked: a reused pid is not the Desktop. Its quit +# can first join a managed SSH update it is running, which legitimately outlasts +# a short fixed wait; past the ceiling the hand-off still refuses. Overridable +# only so tests need not sit it out. +DESKTOP_EXIT_SECONDS="${HERMES_UPDATE_DESKTOP_EXIT_SECONDS:-150}" +case "$DESKTOP_EXIT_SECONDS" in ''|*[!0-9]*|0) DESKTOP_EXIT_SECONDS=150 ;; esac if [ "$DESKTOP_PID" -gt 0 ] 2>/dev/null; then - for _ in $(seq 1 200); do ident_alive "$DESKTOP_PID" "$DESKTOP_CT" || break; sleep 0.3; done + _exit_deadline=$(( $(date +%s) + DESKTOP_EXIT_SECONDS )) + while ident_alive "$DESKTOP_PID" "$DESKTOP_CT" && [ "$(date +%s)" -lt "$_exit_deadline" ]; do sleep 0.3; done if ident_alive "$DESKTOP_PID" "$DESKTOP_CT"; then - FINAL_CODE=4 FINAL_MSG="Update aborted: the Hermes window (pid $DESKTOP_PID) did not exit within 60s. Nothing was changed. Close Hermes fully and try again." + FINAL_CODE=4 FINAL_MSG="Update aborted: the Hermes window (pid $DESKTOP_PID) did not exit within ${DESKTOP_EXIT_SECONDS}s. Nothing was changed. Close Hermes fully and try again." log "$FINAL_MSG"; exit "$FINAL_CODE" fi fi diff --git a/scripts/desktop-update/windows.ps1 b/scripts/desktop-update/windows.ps1 index e3cfe2d2c9..2e010d3211 100644 --- a/scripts/desktop-update/windows.ps1 +++ b/scripts/desktop-update/windows.ps1 @@ -918,6 +918,18 @@ if ($env:HERMES_UPDATE_STEP_IDLE_SECONDS) { } } +# The Desktop's quit can first join a managed SSH update it is running, which +# legitimately outlasts a fixed 30 s. Past this ceiling the hand-off still +# refuses, so nothing is replaced under a live Desktop. Overridable so the +# self-tests need not sit it out; not documented as a user knob. +$script:DesktopExitSeconds = 150 +if ($env:HERMES_UPDATE_DESKTOP_EXIT_SECONDS) { + $parsedExit = 0 + if ([int]::TryParse($env:HERMES_UPDATE_DESKTOP_EXIT_SECONDS, [ref]$parsedExit) -and $parsedExit -gt 0) { + $script:DesktopExitSeconds = $parsedExit + } +} + # Silence on the pipes is NOT silence in the update. `hermes update` captures # the (very loud) Electron/vite build into logs/update.log instead of its own # stdout (hermes_cli/update_cmd.py, the update-log tee), so a real update is @@ -1532,10 +1544,10 @@ try { Publish-UiProgress "Waiting for Hermes to close" if ($DesktopPid -gt 0) { # Identity, not pid: the Desktop's creation time was pinned at start. - if (-not (Wait-DesktopExit 30)) { + if (-not (Wait-DesktopExit $script:DesktopExitSeconds)) { # The running Desktop still owns application outputs being replaced. $finalCode = 4 - $finalMsg = "Update aborted: the Hermes window (pid $DesktopPid) did not exit within 30s. Nothing was changed. Close Hermes fully and try again." + $finalMsg = "Update aborted: the Hermes window (pid $DesktopPid) did not exit within $($script:DesktopExitSeconds)s. Nothing was changed. Close Hermes fully and try again." Write-HandoffLog $finalMsg exit $finalCode } diff --git a/tests/scripts/desktop_update/test_desktop_update_posix_marker.py b/tests/scripts/desktop_update/test_desktop_update_posix_marker.py index 5f065b0906..c84516a9ca 100644 --- a/tests/scripts/desktop_update/test_desktop_update_posix_marker.py +++ b/tests/scripts/desktop_update/test_desktop_update_posix_marker.py @@ -376,3 +376,33 @@ def test_script_killed_right_after_spawning_the_update_leaves_a_live_marker(tmp_ if script.poll() is None: script.kill() script.wait() + + +def _write_bridge(home: Path, desktop_pid: int) -> None: + marker = home / ".hermes-update-in-progress" + marker.write_text(f"{desktop_pid}\n{int(time.time())}\nct:{_ct(desktop_pid)}\n", encoding="utf-8") + + +def test_desktop_still_alive_at_the_exit_ceiling_refuses_without_updating(tmp_path, sleeper): + home, install = _install(tmp_path, legacy=True) + desktop = sleeper() + _write_bridge(home, desktop.pid) + started = time.monotonic() + + result = _run(tmp_path, home, install, "--desktop-pid", str(desktop.pid), HERMES_UPDATE_DESKTOP_EXIT_SECONDS="2") + + assert result.returncode == 4, result.stdout + result.stderr + assert time.monotonic() - started < 30 + assert not [c for c in _calls(tmp_path) if c.startswith("update")] + + +def test_desktop_that_exits_inside_the_ceiling_lets_the_update_run(tmp_path): + home, install = _install(tmp_path, legacy=True) + desktop = subprocess.Popen(["sleep", "3"]) + _write_bridge(home, desktop.pid) + + result = _run(tmp_path, home, install, "--desktop-pid", str(desktop.pid), HERMES_UPDATE_DESKTOP_EXIT_SECONDS="20") + desktop.wait() + + assert result.returncode == 0, result.stdout + result.stderr + assert any(c.startswith("update") for c in _calls(tmp_path))