Files
hermes-agent/tests/test_desktop_update_windows_python_handoff.py
brooklyn! 2b1bff624e fix(update): Windows Desktop updates finish instead of parking on "Updating Hermes" (#90937)
* fix(update): bound the Windows update hand-off's step pipe drain

Invoke-HermesStep collected each step's output with ReadToEndAsync().Result.
That task does not complete when the step exits; it completes when the pipe
reaches EOF. On Windows the write end of a redirected pipe goes to the child as
an inheritable handle, so every descendant spawned without its own redirection
holds a duplicate and EOF waits for the last of them to close it. hermes update
deliberately runs its build steps with stdout inherited, so the tree under a
step is arbitrarily deep and not something this script can enumerate. When one
of those descendants is a resident gateway, the pipe stays open for the life of
the gateway and the hand-off blocks forever.

Everything the hand-off owes the Desktop is downstream of that call:
.hermes-update-result.json is never written, .hermes-update-in-progress is never
cleared, and the Desktop is never relaunched. The app sits on "Updating Hermes"
until the user kills the gateway by hand, and the stale marker then refuses the
next update too.

Read both pipes in chunks into a StringBuilder and bound the drain once the step
process itself has exited. The bound cannot truncate a slow step: the clock only
starts after the process is gone, at which point everything it wrote is already
in the pipe buffer waiting to be read, so the grace only has to cover the final
drain. Chunked reads are what make abandoning safe at all, since .Result cannot
hand back a partial read.

Also switch to the bounded WaitForExit overload. The argument-less one waits on
redirected streams as well, which is the same unbounded wait by another name.

An abandoned drain logs one line to logs/desktop-update-handoff.log naming the
cause, so a truncated step log is never mistaken for a step that printed
nothing.

Measured on Windows 11 / PowerShell 5.1 against a step whose grandchild
inherits its stdout and outlives it by 45s: 47.4s before, 4.3s after, with the
step's exit code and output preserved in both.

Fixes #90455

* test(update): prove the hand-off survives a step that leaks its pipe

Four source-level guards on Invoke-HermesStep, scoped to that function so the
legitimate WaitForExit and .Result uses elsewhere in the script cannot mask a
regression: no ReadToEndAsync, a drain bound keyed on the step having exited,
no argument-less WaitForExit, and a log line when a drain is abandoned. All
four fail against the previous drain. They are source-level for the same
reason the sibling python-handoff guard is: Linux CI cannot execute the
PowerShell hand-off.

Source-level is not enough for a deadlock, though, so the script also grows a
-SelfTestPipeDrain fixture alongside the existing -SelfTestUi one. It needs no
checkout, no install and no update: it starts a step that spawns a grandchild
with UseShellExecute = $false and no redirection, which is exactly the shape
that makes the grandchild inherit the step's stdout and stderr, then exits 7
while the grandchild sleeps on. The fixture asserts the grandchild was still
alive when Invoke-HermesStep returned, so a pass cannot be a timing
coincidence, and that the exit code and the step's output both survived the
abandonment. A windows_only test drives it, so the OS lane runs the real
drain rather than a text match.

Measured on Windows 11 / PowerShell 5.1: 4.3s with the fix, 47.4s (the
grandchild's full lifetime) with the previous drain restored.

The python-handoff guard now reads the script with its -SelfTest* blocks
removed. Those blocks exercise the machinery deliberately and exit before any
marker, venv or desktop work, so the "every step drives python.exe, never the
hermes.exe shim" rule does not apply to them. Scoping the source that way
rather than allow-listing a target keeps that rule absolute for every real
step.

Refs #90455

* fix(update): don't meter the step drain that #90455's bound introduced

Chunked reads make the bounded drain possible, but the loop idled 150ms
after every chunk it consumed, so a step's output moved at one 16 KiB
buffer per tick (~107 KB/s). The pipe then backs up, which is
backpressure on the *running* step rather than a slow read: a chatty
step blocks on write() waiting for the reader.

`hermes update` is exactly that shape -- the Electron/vite build alone
is megabytes -- so the layer that fixed "the hand-off waits forever"
would have shipped "the hand-off is slow" in its place.

Idle only when both pipes came up empty, and idle on the reads
themselves (WaitAny with the same 150ms cap) rather than on the clock:
a freshly issued ReadAsync is rarely complete by the very next pass, so
a bare `if (-not $moved)` still sleeps between chunks. WaitAny expires
on its own, so a silent step keeps the marquee animating and keeps the
abandon deadline advancing.

Measured against the drain as submitted, same harness, one variable:

  4 MiB of step stdout      38.99s -> 0.07s
  1 MiB stdout + 1 MiB err  18.22s -> 0.27s
  leaked grandchild (20s)    3.24s -> 3.20s, exit code + output kept
  quiet step, exits at 4s    4.29s -> 4.04s, 29 passes (not spinning)

* test(update): make the pipe-drain fixture cover metering, not just deadlock

The fixture proved the drain returns while a descendant holds the pipe.
It could not have caught the opposite failure -- a drain slow enough to
backpressure the step it is reading -- and that is the regression the
first version of this fix shipped.

Add a flood arm: a step that writes megabytes and holds nothing, with a
wall-clock budget far under what a sleep-per-chunk drain needs. The two
arms bracket the contract from both sides: bounded when a descendant
holds the pipe open, never slower than the step can write.

Few large lines rather than many small ones, deliberately --
Write-HandoffLog is one Add-Content per line and runs inside the
measured window, so line-heavy output would time the logger.

Also drops the four source-grep guards. Reading windows.ps1's text to
assert it contains `$abandonAt` tests the shape of the source, not its
behavior: it passes on a drain that is wired wrong but spelled right,
fails on a correct refactor, and blocks the extraction it should
survive. AGENTS.md bans the pattern outright, and all four pass on the
metered drain. The executable arms cover the same contract and actually
run the code -- the Windows lane is where this is verified either way.

---------

Co-authored-by: Jack Lau <72348727+jackulau@users.noreply.github.com>
2026-08-20 12:41:17 -05:00

124 lines
5.3 KiB
Python

"""Regression: the Windows Desktop update hand-off must run through python.exe.
`scripts/desktop-update/windows.ps1` drives `hermes update` for the in-app
Desktop updater. It used to invoke the update through the venv's
`venv\\Scripts\\hermes.exe` console-script launcher. On Windows that launcher is
a real process that keeps `hermes.exe` mapped as its running image and spawns
`python.exe` as a child. The update ends in `uv pip install -e .`, which rewrites
the console-script shims -- including the `hermes.exe` the launcher still has
mapped -- and Windows refuses to replace a file mapped as a running image
("os error 32"). The rename fallback then defers to next reboot via
`MOVEFILE_DELAY_UNTIL_REBOOT`, which needs elevation a Desktop-driven update
does not have, so `uv pip install -e .` exits non-zero, the ZIP fallback repeats
the same sequence, the desktop build stage is never reached, and the pre-build
clean has already removed `apps/desktop/release` -- leaving an install whose
Start Menu shortcut points at a `Hermes.exe` that no longer exists.
Driving the update as `python.exe -m hermes_cli.main update` puts the inherited
image handle on `python.exe`, which uv never has to replace, so the shim is an
ordinary unlocked file when uv rewrites it.
This test is source-level because Linux CI cannot execute the PowerShell
hand-off. The invariant it guards is that every `Invoke-HermesStep` call site
(the update, its retry, and the desktop rebuild) drives `$pythonExe`, never the
`$hermesExe` shim. `hermes.exe` may still be *named* in the file for the
step-2 unlock preflight -- that is a lock probe, not an invocation -- so we
assert against the invocation sites specifically.
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
WINDOWS_PS1 = REPO_ROOT / "scripts" / "desktop-update" / "windows.ps1"
def _read() -> str:
return WINDOWS_PS1.read_text(encoding="utf-8")
def _handoff_source() -> str:
"""The script with its ``-SelfTest*`` fixture blocks removed.
Those blocks exercise the hand-off machinery deliberately -- the pipe-drain
fixture runs a synthetic PowerShell step through ``Invoke-HermesStep`` to
prove the drain cannot deadlock (#90455) -- so they are not update steps
and the "must drive python.exe" rule does not apply to them. Each exits
before any marker/venv/desktop machinery runs.
Scoped here rather than allow-listing a target, so the rule stays absolute
for every real step. The non-greedy match ends at the first closing brace
in column 0; the blocks' own braces are all indented.
"""
return re.sub(r"\nif \(\$SelfTest\w+\) \{.*?\n\}\n", "\n", _read(), flags=re.S)
def test_invoke_hermes_step_calls_drive_python_not_the_shim() -> None:
source = _handoff_source()
invocations = re.findall(r"Invoke-HermesStep\s+(\$\w+)", source)
assert invocations, (
"Expected at least one Invoke-HermesStep call in "
"scripts/desktop-update/windows.ps1; the update hand-off structure "
"changed -- update this guard."
)
offenders = [exe for exe in invocations if exe != "$pythonExe"]
assert not offenders, (
"Every Invoke-HermesStep call in scripts/desktop-update/windows.ps1 "
"must drive $pythonExe, not the hermes.exe shim. Driving the update "
"through the shim keeps hermes.exe mapped as a running image, so uv's "
"final shim rewrite fails with os error 32 and the Desktop update can "
"never complete. Offending target(s): "
f"{sorted(set(offenders))}."
)
def test_update_invocation_uses_module_entrypoint() -> None:
source = _read()
assert '@("-m", "hermes_cli.main", "update"' in source, (
"The update step must invoke `python.exe -m hermes_cli.main update ...` "
"so the inherited image handle lands on python.exe, which uv never has "
"to replace."
)
assert (
'@("-m", "hermes_cli.main", "desktop", "--force-build", "--build-only")'
in source
), (
"The desktop rebuild step must also go through "
"`python.exe -m hermes_cli.main desktop ...` for the same reason."
)
def test_update_no_longer_invokes_the_hermes_exe_shim() -> None:
source = _read()
assert "Invoke-HermesStep $hermesExe" not in source, (
"scripts/desktop-update/windows.ps1 still invokes the update through "
"the hermes.exe shim (`Invoke-HermesStep $hermesExe`). That is the "
"exact self-lock this fix removes -- route it through $pythonExe "
"instead."
)
def test_desktop_relaunch_waits_for_an_in_place_rebuild() -> None:
source = _read()
relaunch = re.search(
r"function Start-DesktopRelaunch \{(?P<body>.*?)\n\}\n\nfunction Invoke-HermesStep",
source,
re.DOTALL,
)
assert relaunch, "Expected Start-DesktopRelaunch in the Windows hand-off script."
body = relaunch.group("body")
assert "if (-not $RelaunchExe) { return $false }" in body
assert "$relaunchDeadline = (Get-Date).AddSeconds(120)" in body
assert "while (-not (Test-Path -LiteralPath $RelaunchExe))" in body
assert "if ((Get-Date) -ge $relaunchDeadline)" in body
assert "Start-Sleep -Milliseconds 500" in body
assert "[System.Windows.Forms.Application]::DoEvents()" in body