fix(update): bound network git in hermes update and prune shallow grafts on apply
- _git_run(network=True) now carries a 300s timeout; a dead-stalled fetch (HTTP/2 to GitHub on some networks, black-holed proxy) becomes a failed run whose stderr names the stall instead of an update pinned on 'Fetching updates...' forever (#93759, #95777). Local git stays unbounded. - The apply path prunes stale .git/shallow grafts alongside its existing lock/tmp_pack cleanup, so installs that already accumulated grafts from past depth-1 checks (#105951: 57 entries) heal on their next update, not only on --check.
This commit is contained in:
@@ -147,13 +147,30 @@ def _record_update_step(step: str, ok: bool, detail: str = "") -> None:
|
||||
record_step(step, ok, detail)
|
||||
|
||||
|
||||
# A fetch whose transport dead-stalls (HTTP/2 to GitHub on some networks, a black-holed proxy)
|
||||
# otherwise leaves `hermes update` on "Fetching updates..." forever (#93759, #95777). Five
|
||||
# minutes is generous for a scoped single-branch fetch and still ends in a real error.
|
||||
NETWORK_GIT_TIMEOUT_SECONDS = 300
|
||||
|
||||
|
||||
def _git_run(git_cmd, args, cwd=None, *, check=False, network=False):
|
||||
"""Run git capturing utf-8 text (default cwd: checkout); ``network=True`` disables the
|
||||
terminal prompt so an HTTP 401 fails fast instead of hanging."""
|
||||
return subprocess.run(
|
||||
git_cmd + args, cwd=_m().PROJECT_ROOT if cwd is None else cwd, capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace", check=check,
|
||||
**(_no_prompt_git_kwargs() if network else {}))
|
||||
terminal prompt so an HTTP 401 fails fast instead of hanging, and bounds the wait."""
|
||||
try:
|
||||
return subprocess.run(
|
||||
git_cmd + args, cwd=_m().PROJECT_ROOT if cwd is None else cwd, capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace", check=check,
|
||||
**({"timeout": NETWORK_GIT_TIMEOUT_SECONDS, **_no_prompt_git_kwargs()} if network else {}))
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
# subprocess.run already killed the child; the checkout stays consistent because
|
||||
# fetch writes to tmp_pack_* and only renames on success. Report as a failed run
|
||||
# so every caller's existing stderr path prints one clear line.
|
||||
result = subprocess.CompletedProcess(
|
||||
exc.cmd, 124, stdout="",
|
||||
stderr=f"git {args[0]} timed out after {NETWORK_GIT_TIMEOUT_SECONDS}s with no response from the remote")
|
||||
if check:
|
||||
raise subprocess.CalledProcessError(124, exc.cmd, output="", stderr=result.stderr) from exc
|
||||
return result
|
||||
|
||||
|
||||
def _capture_head_sha(git_cmd, cwd) -> str | None:
|
||||
@@ -1321,6 +1338,12 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
swept = clear_stale_tmp_packs(_m().PROJECT_ROOT)
|
||||
if swept:
|
||||
print(" (removed %d aborted-fetch pack temp file(s))" % len(swept))
|
||||
# Shallow installer checkouts collect one `.git/shallow` graft per past depth-1 fetch
|
||||
# (#105951); stale grafts break merge-base and push this run into the divergence path.
|
||||
from hermes_cli.gitlock import prune_stale_shallow_grafts
|
||||
pruned = prune_stale_shallow_grafts(_m().PROJECT_ROOT)
|
||||
if pruned:
|
||||
print(f" (pruned {pruned} stale shallow graft(s) left by past depth-1 checks)")
|
||||
|
||||
# Surface autostashes left by earlier updates (--keep-stash, failed restores).
|
||||
# Surface autostash entries left behind by earlier updates (#63717 problem 6) — parked --keep-stash
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""A dead-stalled network fetch ends `hermes update` with an error, never a hang (#93759, #95777).
|
||||
|
||||
`_git_run(network=True)` bounds the wait; a `TimeoutExpired` becomes a failed
|
||||
CompletedProcess whose stderr names the stall, so every caller's existing
|
||||
fetch-failure path prints one clear line. Local git (network=False) is unbounded.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import hermes_cli.update_cmd as update_cmd
|
||||
|
||||
|
||||
def _timeout(cmd, **kwargs):
|
||||
if "timeout" in kwargs:
|
||||
raise subprocess.TimeoutExpired(cmd, kwargs["timeout"])
|
||||
return MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
|
||||
|
||||
def test_network_fetch_stall_becomes_a_failed_run_with_a_named_cause(monkeypatch):
|
||||
monkeypatch.setattr(update_cmd, "_m", lambda: MagicMock(PROJECT_ROOT="/repo"))
|
||||
with patch.object(update_cmd.subprocess, "run", side_effect=_timeout) as run:
|
||||
result = update_cmd._git_run(["git"], ["fetch", "origin", "main"], network=True)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "timed out" in result.stderr and "fetch" in result.stderr
|
||||
assert run.call_args.kwargs["timeout"] == update_cmd.NETWORK_GIT_TIMEOUT_SECONDS
|
||||
# The no-prompt guard still rides along with the bound.
|
||||
assert run.call_args.kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0"
|
||||
|
||||
|
||||
def test_local_git_stays_unbounded_and_check_true_raises(monkeypatch):
|
||||
monkeypatch.setattr(update_cmd, "_m", lambda: MagicMock(PROJECT_ROOT="/repo"))
|
||||
with patch.object(update_cmd.subprocess, "run", side_effect=_timeout) as run:
|
||||
assert update_cmd._git_run(["git"], ["rev-parse", "HEAD"]).returncode == 0
|
||||
assert "timeout" not in run.call_args.kwargs
|
||||
|
||||
try:
|
||||
update_cmd._git_run(["git"], ["fetch", "origin", "main"], network=True, check=True)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
assert exc.returncode == 124
|
||||
else:
|
||||
raise AssertionError("check=True must raise on a timed-out fetch")
|
||||
Reference in New Issue
Block a user