Skip to content

fix(update): bound the seven unbounded git network calls in hermes update - #79192

Open
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/update-git-network-timeouts-49181
Open

fix(update): bound the seven unbounded git network calls in hermes update#79192
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/update-git-network-timeouts-49181

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

What does this PR do?

Supersedes #49181 (rblume32120), which started this fix in June and has been CONFLICTING/DIRTY ever since — no force-push, no commit since 2026-06-19, and it patches hermes_cli/main.py, a file the entire update implementation has since been mechanically extracted out of (into hermes_cli/update_cmd.py). #50391 (Morad37) is a strictly narrower duplicate of the same idea — the apply-path fetch only, no test — and is also CONFLICTING with no commit since 2026-06-21. Both have to be rewritten against current main regardless of who does it; this is that rewrite, plus the coverage the maintainer asked for on #49181.

The user symptom. Run hermes update or hermes update --check on a network that blackholes packets — captive-portal Wi-Fi, a half-up VPN, a filtering corporate proxy, a throttled forge — and the remote completes the TCP handshake and then goes silent. The CLI prints → Fetching updates... (or → Fetching from upstream...) and blocks forever: no timeout, no error, no way out but Ctrl-C. hermes update is a universal command and the fork-sync path fires automatically inside it, so any user on a bad link is exposed.

Root cause. Every git subprocess in the update flow that talks to a remote is called without timeout=, so subprocess.run waits indefinitely on a stalled socket.

The maintainer's acceptance checklist on #49181

Suggested changes

  • Cover those _cmd_update_check fetches with bounded timeouts, user-facing timeout handling, and regression tests alongside the apply-path cases.
  • _cmd_update_check upstream fetch — bounded
  • _cmd_update_check origin fallback fetch — bounded
  • _cmd_update_check non-default-branch origin fetch — bounded
  • apply-path fetch (_cmd_update_impl) — bounded
  • upstream fork-sync fetch + ff-only pull (_sync_with_upstream_if_needed) — bounded
  • user-facing timeout handling at every site (no traceback, no bare hang)
  • regression tests covering both the check path and the apply path

Sibling sweep — a seventh site the original review did not list

Grepping every subprocess.run in the hermes update flow whose argv carries a remote-talking verb turns up seven unbounded calls, not six. The extra one is _sync_fork_with_upstream's git push origin main --force-with-lease, which runs at the end of the same fork-sync path — a stalled push hangs the update just as hard as a stalled fetch. It is included here.

The sweep's boundary is deliberate, and these are explicitly not touched:

  • hermes_cli/mcp_catalog.py and hermes_cli/profile_distribution.py also run unbounded git clone. Different commands, different user flows, different concern — bundling them would make this PR two ideas instead of one.
  • update_cmd.py's git stash push and git merge --ff-only origin/<branch> match on the words push/pull but never leave the machine. Correctly excluded; the tests encode that exclusion so a future refactor cannot quietly widen it.

Why 300s and not banner.py's 10s

hermes_cli/banner.py bounds its update probe at timeout=10. That probe is a background, non-blocking check whose result is discardable, so a tight bound costs nothing. These seven are foreground, user-initiated operations, and a first fetch over a slow or metered link can legitimately run for minutes — a 10s bound would break setups that work today. The defect here is the unbounded wait, so a generous finite ceiling is a complete fix with no regression risk.

No new config surface: update_cmd.py has no env-override convention for tunables, so this adds a single module constant and does not invent one.

The error handling is deliberately asymmetric

path on TimeoutExpired why
_cmd_update_impl apply fetch diagnostic + sys.exit(1) a stalled origin means there is nothing to update against
_cmd_update_check origin fetches diagnostic + sys.exit(1) same — no fallback left
_cmd_update_check upstream fetch warn, fall through to the origin fallback mirrors the existing "upstream fetch failed → use origin" branch; origin may still be reachable
_sync_with_upstream_if_needed fetch/pull message + return (degrade) mirrors its existing CalledProcessError branches — the upstream sync is a convenience on top of the user's actual update and must not block it
_sync_fork_with_upstream push existing catch-all → return False caller already prints "couldn't push to fork"

Preserving that asymmetry is the point: unifying it would either abort updates over a slow upstream or silently swallow a dead origin.

Related Issue

No filed issue — the demand is stated directly in the maintainer review on #49181, quoted above.

Supersedes #49181. Narrower duplicate: #50391.

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

  • hermes_cli/update_cmd.py
    • New module constant _GIT_NETWORK_TIMEOUT_SECONDS = 300, with the rationale for 300-vs-10 in the comment.
    • New helper _print_git_network_timeout(remote) — one diagnostic, in the same style as the existing ✗ Network error — cannot reach the remote repository. branch, used by the three fatal sites.
    • _sync_fork_with_upstream — bound the git push origin main --force-with-lease.
    • _sync_with_upstream_if_needed — bound git fetch upstream main and git pull --ff-only upstream main; added a TimeoutExpired branch to each that degrades and returns.
    • _cmd_update_check — bound all three git fetch calls; upstream stall falls back to origin, origin stall exits 1.
    • _cmd_update_impl — bound git fetch origin <branch>; stall exits 1.
  • tests/hermes_cli/test_update_network_timeout.py (new, 12 tests)
    • Pins that every remote-talking invocation carries a positive timeout kwarg, on all three entry paths.
    • Pins that a TimeoutExpired never propagates: fatal paths exit 1 with a diagnostic and no traceback; the upstream-sync path prints and returns without aborting; the upstream stall in --check still reaches the origin fallback.
    • The helper that classifies "network call" excludes git stash and local merge, so the tests fail if a future change bounds only some sites.

How to Test

  1. Reproduce the hang. Point the repo's origin at an address that accepts connections and never answers, e.g. git remote set-url origin https://10.255.255.1/x.git (an unroutable host behind a stateful firewall), then run hermes update --check. Before this change the CLI prints → Fetching from origin... and hangs indefinitely. After it, the fetch is bounded and you get:
    ✗ Timed out after 300s fetching from origin — the remote accepted the connection but stopped responding.
      Check your network (VPN, proxy, captive portal) and try again.
    
    and exit status 1.
  2. Headless regression suite (no network, no git server — subprocess.run is mocked):
    pytest tests/hermes_cli/test_update_network_timeout.py -v
    
    12 passed.
  3. Before/after evidence. Restoring hermes_cli/update_cmd.py from main and re-running the new file turns 11 of the 12 red. The twelfth, test_fork_push_timeout_degrades_to_false, is labelled in its docstring as a contract test rather than a regression test — _sync_fork_with_upstream already had a catch-all except, so it is green either way; its companion test_fork_push_is_bounded is the one that fails without the fix.
  4. No collateral damage. pytest tests/hermes_cli/ -q -p no:randomly produces a byte-identical failure set on this branch and on clean main (82c6aca) — 142 pre-existing failures on both, zero new and zero fixed. Adjacent update suites (test_cmd_update.py, test_update_check.py, test_update_yes_flag.py, test_update_hangup_protection.py, test_update_fleet_restart_timeout.py, test_update_autostash.py) are all green: 55 passed.
  5. ruff check hermes_cli/update_cmd.py tests/hermes_cli/test_update_network_timeout.py — clean. Source parses under ast.parse(..., feature_version=(3, 9)).

Related / Positioning

#73751 (Frowtek) touches the same functions but fixes a different hang: it adds stdin=DEVNULL + a non-interactive git env so a credential prompt can't block. That is orthogonal and complementary to bounding the network wait — neither change subsumes the other. It is also CONFLICTING against hermes_cli/main.py. This PR does not touch stdin or the git env, so the two remain independently applicable.

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
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15 (Darwin 25.4), Python 3.11

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

…date

`hermes update` and `hermes update --check` shell out to git for seven
operations that talk to a remote, and not one of them passed `timeout=`.
On a network that blackholes packets — captive-portal Wi-Fi, a half-up
VPN, a filtering corporate proxy, a throttled forge — the remote completes
the TCP handshake and then goes silent, and the CLI prints
"-> Fetching updates..." and blocks forever. No timeout, no error, no way
out but Ctrl-C. `hermes update` is a universal command and the fork-sync
path fires automatically inside it, so this reaches any user on a bad link.

Bound all seven with a single module constant and handle
`subprocess.TimeoutExpired` at each site:

- `_cmd_update_check`: upstream fetch, origin fallback fetch, non-default
  branch fetch.
- `_cmd_update_impl`: the apply-path fetch.
- `_sync_with_upstream_if_needed`: the upstream fetch and the ff-only pull.
- `_sync_fork_with_upstream`: the `git push` back to the fork.

The error handling is deliberately asymmetric. The user-initiated paths
(`update`, `--check`) print a diagnostic and exit non-zero, because a
stalled origin means there is nothing to update against. The upstream/fork
sync degrades and returns, mirroring its existing `CalledProcessError`
branches, because it is a convenience layered on top of the user's actual
update and must not block it. A stalled upstream fetch in `--check` falls
through to the existing origin fallback rather than aborting, matching how
a failed upstream fetch is already handled there.

The bound is 300s, not the 10s used by the background probe in
`hermes_cli/banner.py`. That probe is non-blocking and its result is
discardable; these are foreground operations where a first fetch over a
slow link can legitimately run for minutes. The defect being fixed is the
unbounded wait, so a generous finite ceiling closes it without regressing
any setup that works today.
Copilot AI lite review requested due to automatic review settings August 5, 2026 08:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a long-standing hang in hermes update / hermes update --check by ensuring every git subprocess that can block on a remote network operation is executed with a finite timeout, and by handling subprocess.TimeoutExpired in a user-friendly way.

Changes:

  • Add a single _GIT_NETWORK_TIMEOUT_SECONDS = 300 ceiling and apply it to all remote-talking git operations in the update flow (fetch/pull/push), with explicit TimeoutExpired handling per path.
  • Add regression tests that assert (a) all remote-talking invocations are bounded and (b) timeouts are handled without propagating exceptions/tracebacks.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
hermes_cli/update_cmd.py Introduces a shared git network timeout constant + helper, and applies bounded timeouts + timeout handling across update check/apply and upstream fork-sync paths.
tests/hermes_cli/test_update_network_timeout.py Adds coverage to ensure all remote git calls in hermes update are bounded and timeouts are handled (exit/degrade behavior).
Suppressed comments (4)

tests/hermes_cli/test_update_network_timeout.py:170

  • This test only checks stdout (capsys.readouterr().out). To ensure no traceback / exception details leak to the user, capture and assert against both stdout and stderr.
        out = capsys.readouterr().out

tests/hermes_cli/test_update_network_timeout.py:216

  • The test currently inspects only stdout, but tracebacks are written to stderr. Combine stdout+stderr when checking that no traceback/exception details were printed.
        out = capsys.readouterr().out

tests/hermes_cli/test_update_network_timeout.py:282

  • This assertion block reads only stdout from capsys; stderr should be included as well so the test reliably fails if a traceback is printed.
        out = capsys.readouterr().out

tests/hermes_cli/test_update_network_timeout.py:295

  • This test checks only stdout for traceback text. Since tracebacks are emitted to stderr, combine both streams to accurately enforce the “no traceback” contract.
        out = capsys.readouterr().out

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


cmd_update(SimpleNamespace(check=True, branch=None))

out = capsys.readouterr().out

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in commit 3069653cc.

You are right that the assertion was false confidence: capsys.readouterr().out is stdout only, and a leaked traceback or unhandled-exception output surfaces on stderr, so the check could never have caught the failure mode it was written to guard.

All five sites now go through a _drain(capsys) helper in tests/hermes_cli/test_update_network_timeout.py (defined just above _check_side_effect) that returns (stdout, stdout + stderr). The no-traceback assertions run against the combined stream, while the positive content assertions ("Timed out" in out, "Skipping upstream sync" in out) stay on stdout, which is where those diagnostics are actually printed. I also extended the same combined-stream coverage to "TimeoutExpired" not in ... at every site — previously only two of the five checked for it at all.

Affected tests (by name, so this survives a rebase): TestUpdateCheckTimeoutHandling::test_upstream_timeout_falls_back_to_origin, TestUpdateCheckTimeoutHandling::test_origin_timeout_exits_cleanly, TestUpdateApplyFetch::test_apply_fetch_timeout_exits_cleanly, TestUpstreamSyncDegradesOnTimeout::test_upstream_fetch_timeout_skips_sync, TestUpstreamSyncDegradesOnTimeout::test_upstream_pull_timeout_skips_sync. All 12 still pass; ruff clean.

The timeout tests asserted `"Traceback" not in capsys.readouterr().out`,
but tracebacks and unhandled-exception output go to stderr, so a leak
would have slipped past. Route every no-traceback assertion through a
`_drain` helper that checks stdout+stderr combined, and extend the same
coverage to "TimeoutExpired" at all five sites.
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/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants