Skip to content

fix(update): bound the Windows update hand-off's step pipe drain - #90564

Closed
jackulau wants to merge 2 commits into
NousResearch:mainfrom
jackulau:fix/desktop-update-windows-pipe-drain
Closed

fix(update): bound the Windows update hand-off's step pipe drain#90564
jackulau wants to merge 2 commits into
NousResearch:mainfrom
jackulau:fix/desktop-update-windows-pipe-drain

Conversation

@jackulau

Copy link
Copy Markdown
Contributor

What does this PR do?

Invoke-HermesStep in scripts/desktop-update/windows.ps1 collected each update 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 is handed 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 (the tee-stderr runner in hermes_cli/main.py), so the process 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.

Everything the hand-off owes the Desktop sits downstream of that one 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.

The fix: drain both pipes in chunks into a StringBuilder, and bound the drain after the step process has exited.

The bound cannot truncate a slow step. The clock only starts once the process is gone, at which point everything it wrote is already sitting in the pipe buffer ready to read, so the grace only has to cover the final drain — a 40-minute uv pip install is untouched. Chunked reads are what make abandoning safe at all: ReadToEndAsync().Result cannot hand back a partial read, so there is no way to give up on it without losing the whole step's log.

Why this layer, and one honest correction to the issue

The issue's suggested fix 1 is to stop the gateway inheriting the pipes. I went looking for that spawn site first and could not find it — worth saying plainly rather than leaving it implied. Every Windows gateway spawn on the update path already redirects:

site redirection
gateway_windows._spawn_detached stdin=DEVNULL, stdout/stderrlogs/gateway-stdio.log, close_fds=True
gateway._spawn_gateway_restart_watcher (the watcher) stdout/stderr =DEVNULL
the same watcher's inner respawn stdout/stderr =DEVNULL

So I cannot name the descendant that holds the handle in the reporter's tree, and I have not claimed to. What I can show is that the hand-off's assumption — "the step exited, therefore its pipes are closed" — is unsound by construction on Windows, and that the shim is the layer where that matters, because it is the layer holding the marker and the result file. A fix at this layer is correct for whichever descendant it turns out to be, including ones that do not exist yet.

Issue suggestion 3 (a stale-marker watchdog) is a real second layer of defence but a different change; not bundled here.

The abandonment is visible

An abandoned drain writes one line to logs/desktop-update-handoff.log naming the cause. A silently truncated step log is indistinguishable from a step that printed nothing, and that log is what hermes debug share collects for update reports.

Also switched to the bounded WaitForExit(ms) overload: the argument-less one waits on redirected streams too, which is the same unbounded wait by another name.

Related Issue

Fixes #90455

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

scripts/desktop-update/windows.ps1

  • Invoke-HermesStep: chunked ReadAsync into a StringBuilder for both pipes, with an abandon deadline armed at the step's exit ($script:StepDrainGraceSeconds, default 20s, overridable via HERMES_UPDATE_PIPE_DRAIN_SECONDS — used by the self-test, not documented as a user knob).
  • New Step-PipeDrain helper: advances one pipe by whatever has already arrived, never blocks, reports EOF. A faulted read is treated as EOF rather than retried forever.
  • Bounded WaitForExit(5000) in place of the argument-less overload.
  • New -SelfTestPipeDrain switch beside the existing -SelfTestUi, exiting before any marker/venv/desktop machinery.

tests/test_desktop_update_windows_pipe_drain.py (new) — four source-level guards scoped to the Invoke-HermesStep body, plus the windows_only test that drives -SelfTestPipeDrain.

tests/test_desktop_update_windows_python_handoff.py — its "every step drives python.exe, never the hermes.exe shim" guard now reads the script with -SelfTest* blocks stripped. The pipe-drain fixture runs a synthetic PowerShell step through Invoke-HermesStep, and it is not an update step. Scoping the source that way rather than allow-listing a target keeps the rule absolute for every real step.

How to Test

The deadlock needs no update, no checkout and no Hermes install — only a step whose grandchild outlives it holding the inherited write end. That is what -SelfTestPipeDrain builds:

$env:HERMES_UPDATE_PIPE_DRAIN_SECONDS = "3"   # grace
$env:HERMES_SELFTEST_HOLD_SECONDS     = "45"  # how long the leaking grandchild lives
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\desktop-update\windows.ps1 -SelfTestPipeDrain

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 (7) and the step's output both survived.

On Windows 11 / PowerShell 5.1:

# with this PR
pipedrain!| pipe drain abandoned after 3s: 'pipedrain' exited but a surviving
           descendant still holds its stdout/stderr handles. Continuing the
           hand-off with the output captured so far (#90455).
pipedrain| pipe-drain step output
PIPE-DRAIN SELF-TEST: PASS elapsed=4.31s budget=33s code=7 grandchildAlive=True

# same fixture, previous drain restored
pipedrain| pipe-drain step output
PIPE-DRAIN SELF-TEST: FAIL elapsed=47.44s budget=33s code=7 grandchildAlive=False
  -- handle-holding grandchild was not alive on return; returned in 47.44s

47.4s is the grandchild's full 45s lifetime plus startup: the pre-fix drain waited out the leak exactly, which is the bug at 45-second scale. Note both runs preserve the exit code and the step's output — the fix costs nothing on the happy path.

Then:

pytest tests/test_desktop_update_windows_pipe_drain.py -q      # 5 passed on Windows
pytest tests/test_desktop_update_*.py -q                       # 14 passed, 2 skipped

All four source guards fail against the previous drain, and the windows_only test fails with the FAIL line above, so none of them are decoration.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — partially: I ran the affected suite (tests/test_desktop_update_*.py, 14 passed / 2 skipped) rather than the whole tree, which has a pre-existing Windows-local failure baseline unrelated to this change. Linux CI is the authority for the full run; the windows_only lane is the authority for the new executable test.
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Windows 11 Pro 26200, Windows PowerShell 5.1

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — the reasoning lives in comments in the touched function and in the new test's module docstring; no user-facing docs change (the env var is a test seam, not a knob)
  • N/A — cli-config.yaml.example
  • N/A — CONTRIBUTING.md / AGENTS.md
  • I've considered cross-platform impact: the file is Windows-only and unreferenced elsewhere; scripts/desktop-update/posix.sh is untouched. The new test's behavioural half is windows_only; its source-level half runs everywhere and only reads the script.
  • N/A — tool descriptions/schemas

Screenshots / Logs

Included inline under How to Test above.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/desktop Electron desktop app (apps/desktop/*) platform/windows Native Windows-specific behavior or breakage area/install-update Installer, updater, packaging, wheels, doctor sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows labels Aug 20, 2026
@jackulau
jackulau force-pushed the fix/desktop-update-windows-pipe-drain branch from 9c56f31 to 5ab801f Compare August 20, 2026 06:19
@jackulau

Copy link
Copy Markdown
Contributor Author

Note on the first CI run, since a red All required checks pass is worth explaining rather than leaving for a reviewer to dig into.

The only failure was:

tests/e2e/test_platform_commands.py::TestSlashCommands::test_plaintext_restart_gateway_in_group_stays_plain_text[telegram]
E   AssertionError: Expected 'mock' to have been called once. Called 0 times.
1 failed, 60 passed, 7 skipped in 14.68s

That is a Telegram slash-command test, and this branch cannot reach it — not "is unlikely to", cannot:

  • the diff is scripts/desktop-update/windows.ps1 plus two files under tests/. There is no Python runtime change in it at all.
  • both test files live in tests/, not tests/e2e/, so the e2e lane does not even collect them.
  • neither imports anything from the package; between them the imports are os, re, subprocess, pathlib and pytest, and both only read windows.ps1 off disk.

I could not re-run the job (fork PR, no admin rights on the repo), so I rebased onto 45f11263bd and force-pushed to retrigger. If it reproduces on the new run I will chase it rather than assume, but the reachability argument above is what I would want a reviewer to weigh first.

Nothing else changed in the force-push: same two commits, clean rebase, tests/test_desktop_update_*.py still 14 passed / 2 skipped locally on Windows 11 / PowerShell 5.1.

@jackulau
jackulau force-pushed the fix/desktop-update-windows-pipe-drain branch from 5ab801f to 12c6184 Compare August 20, 2026 06:26
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 NousResearch#90455
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 NousResearch#90455
@jackulau
jackulau force-pushed the fix/desktop-update-windows-pipe-drain branch from 12c6184 to 7cb8b8b Compare August 20, 2026 06:49
@jackulau

Copy link
Copy Markdown
Contributor Author

Follow-up on the CI history here, so the red runs above are not left as an exercise for the reader.

Run 1 — Python tests / e2e. Cleared on the next run without any change to the diff, so it was a flake, and the reachability argument in my previous comment held.

Run 2 — OS-specific tests / Windows-only tests. Not the new test. It failed in actions/checkout, before pytest existed:

##[error]error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429
##[error]fatal: expected 'packfile'
The process 'C:\Program Files\Git\bin\git.exe' failed with exit code 128

github.com was rate-limiting at the time — my own git fetch upstream was getting 429 in the same window. Also worth noting from that run: the CI workflow initially registered as a startup failure (recorded under .github/workflows/ci.yaml rather than its name:, zero jobs, contributing nothing to the rollup), which is the same transient. A fresh head sha re-registered it normally.

I can't re-run jobs on a fork PR, so I've rebased onto 9ed06ca2b6 and force-pushed to retrigger. Same two commits, clean rebase, no content change; tests/test_desktop_update_*.py still 14 passed / 2 skipped locally.

The windows_only test is the one I most want a real runner's verdict on, since it executes the drain rather than reading the script, so I'll keep watching until that lane actually runs.

@OutThisLife

Copy link
Copy Markdown
Collaborator

Superseded by #90937, which keeps this diff as its first two commits — your diagnosis and layer choice were right, and the authorship is intact in history.

Two changes on top.

The drain was metering itself. Step-PipeDrain advances one 16 KiB chunk per pass and the loop slept 150ms unconditionally at the bottom, so step output moved at ~107 KB/s. Because the pipe then backs up that is backpressure on the running step, not just a slow read — a chatty step blocks on write() waiting for the reader. Measured with your Step-PipeDrain and loop lifted verbatim, one variable:

4 MiB of step stdout        38.99s -> 0.07s
1 MiB stdout + 1 MiB stderr 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

hermes update is exactly the loud shape — the Electron/vite build alone is megabytes. The fix idles only when both pipes came up empty, and idles on the reads (WaitAny, same 150ms cap) rather than on the clock; a freshly issued ReadAsync is rarely complete by the next pass, so the bare if (-not $moved) variant still sleeps between chunks (0.53s vs 0.07s on 4 MiB).

The four source-grep guards are gone. They are what let this through — all four pass on the submitted drain, because asserting the file's text contains $abandonAt tests the spelling of the source, not its behavior. AGENTS.md rules that pattern out. Your -SelfTestPipeDrain is the right instinct and it stays; it grows a flood arm so the two bracket the contract from both sides — bounded when a descendant holds the pipe open, never slower than the step can write. _handoff_source() stays as you wrote it; verified it still resolves all three real steps to $pythonExe with the second fixture present.

Thanks for this one. The correction to the issue's root-cause section — going after suggestion 1, not finding the leaking spawn, and saying so instead of implying it — is the part that made the layer argument reviewable, and it's carried into the new PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/install-update Installer, updater, packaging, wheels, doctor comp/desktop Electron desktop app (apps/desktop/*) P2 Medium — degraded but workaround exists platform/windows Native Windows-specific behavior or breakage sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows: Desktop update hand-off hangs forever when gateway stays resident (windows.ps1 waits on ReadToEndAsync that never sees EOF)

3 participants