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).
This commit is contained in:
Teknium
2026-10-05 06:43:14 -07:00
parent e065426cde
commit ed17c1cea3
3 changed files with 53 additions and 5 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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))