fix(cli): unwedge cua-driver installer timeouts — group-kill, stale-lock pre-clear, 660s ceiling (#58767)

* fix(cli): unwedge cua-driver installer timeouts — group-kill on timeout, stale-lock pre-clear, 660s ceiling

The cua-driver refresh in hermes update could wedge permanently:
subprocess timeout (300s) killed only the outer shell, orphaning the
curl|bash grandchildren and the upstream installer's concurrent-install
lock (~/.cua-driver/packages/.install.lock.d). The installer only
reclaims a stale lock after 600s of waiting — longer than our old
ceiling — so every subsequent run was killed before recovery could
fire: 'always times out'.

- Run the installer in its own process group (start_new_session) and
  SIGKILL the whole group on timeout, so no lock-holding orphans survive.
- Pre-clear a provably-stale lock (dead holder pid, or pid-less and
  older than the upstream 600s window) before invoking the installer.
- Raise the ceiling to 660s (> upstream LOCK_STALE_AFTER_SECONDS=600).
- Timeout message now names the lock path and the manual re-run command.

Fixes #58762

* chore: suppress windows-footgun lint on platform-gated kill calls

Both sites are POSIX-only: _clear_stale_cua_install_lock early-returns
on win32, and os.killpg sits in the 'not is_windows' branch.
This commit is contained in:
Teknium 2026-07-05 03:16:06 -07:00 committed by GitHub
parent d537d29a6f
commit 7fde19afcc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 273 additions and 5 deletions

View file

@ -862,6 +862,87 @@ def install_cua_driver(upgrade: bool = False) -> bool:
return ok
# Ceiling for one upstream-installer run. Must exceed the installer's own
# stale-lock recovery window: _install-rust.sh serializes concurrent installs
# with a lock dir at ~/.cua-driver/packages/.install.lock.d and only
# force-releases a dead holder's lock after LOCK_STALE_AFTER_SECONDS=600 of
# waiting. With a shorter Python-side timeout, a stale lock means every run
# gets killed before the installer's recovery can fire — a permanent
# "always times out" wedge (issue #58762). 660s = 600s lock window + 60s
# headroom for the actual download/swap.
_CUA_INSTALLER_TIMEOUT = 660
# Upstream installer's stale-lock threshold (LOCK_STALE_AFTER_SECONDS in
# _install-rust.sh). Used by the pre-clear below to avoid yanking a lock
# that a live-but-slow install still holds.
_CUA_LOCK_STALE_AFTER = 600
def _cua_install_lock_dir() -> "Path":
"""Path of the upstream installer's concurrent-install lock dir."""
home = os.environ.get("CUA_DRIVER_RS_HOME") or str(Path.home() / ".cua-driver")
return Path(home) / "packages" / ".install.lock.d"
def _clear_stale_cua_install_lock() -> None:
"""Best-effort: remove a stale installer lock left by a dead holder.
A previous timed-out/killed install can orphan
``~/.cua-driver/packages/.install.lock.d`` (the holder's pid is stamped
into its ``info`` file). The upstream installer only reclaims it after
waiting 600s longer than our old subprocess timeout so an orphaned
lock wedged every subsequent refresh. Clear it up front when the holder
is provably dead; leave it alone when the holder is alive (a slow
concurrent install) or liveness can't be determined.
POSIX-only: the lock protocol lives in the bash installer; install.ps1
does not use it.
"""
if sys.platform == "win32":
return
lock_dir = _cua_install_lock_dir()
try:
if not lock_dir.is_dir():
return
holder_pid = None
info = lock_dir / "info"
try:
for line in info.read_text(encoding="utf-8", errors="replace").splitlines():
if line.startswith("pid="):
holder_pid = int(line.split("=", 1)[1].strip())
break
except (OSError, ValueError):
holder_pid = None
if holder_pid is not None:
try:
os.kill(holder_pid, 0) # windows-footgun: ok — function early-returns on win32
# Holder alive → a concurrent install is running; don't touch.
return
except ProcessLookupError:
pass # dead holder → stale, clear below
except PermissionError:
# Alive but owned by someone else — treat as live.
return
else:
# No readable pid. Only clear if the lock is old enough that the
# upstream installer itself would consider it reclaimable.
import time as _time
try:
age = _time.time() - lock_dir.stat().st_mtime
except OSError:
return
if age < _CUA_LOCK_STALE_AFTER:
return
import shutil as _shutil
_shutil.rmtree(lock_dir, ignore_errors=True)
logger.info("Cleared stale cua-driver install lock at %s", lock_dir)
_print_info(f" Cleared stale cua-driver install lock ({lock_dir}).")
except Exception as e:
logger.debug("stale cua install lock check failed: %s", e)
def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) -> bool:
"""Run the upstream cua-driver installer for this platform.
@ -909,6 +990,30 @@ def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) -
else:
_print_info(f" {label} cua-driver...")
driver_cmd = _cua_driver_cmd()
# A previous timed-out install can leave the upstream installer's
# concurrent-install lock behind; clear it when provably stale so the
# refresh doesn't wedge waiting on a dead holder (issue #58762).
_clear_stale_cua_install_lock()
# POSIX: run the installer in its own process group so a timeout kill
# takes out the whole `curl | bash` pipeline (and the exec'd
# _install-rust.sh), not just the outer shell. Otherwise the surviving
# grandchildren keep holding the install lock, wedging every later run.
popen_kwargs = {}
if not is_windows:
popen_kwargs["start_new_session"] = True
def _kill_installer_tree(proc):
import signal as _signal
try:
if not is_windows:
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL) # windows-footgun: ok — POSIX branch only
else:
proc.kill()
except (OSError, ProcessLookupError):
proc.kill()
try:
# When not verbose (e.g. `hermes update`'s refresh), capture the
# installer's chatty "Next steps" wall instead of dumping it to the
@ -916,12 +1021,32 @@ def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) -
# debuggable. Verbose installs (interactive `computer-use install`)
# keep streaming live.
if verbose:
result = subprocess.run(install_cmd, shell=use_shell, timeout=300, env=_cua_driver_env())
proc = subprocess.Popen(
install_cmd, shell=use_shell, env=_cua_driver_env(), **popen_kwargs
)
try:
proc.communicate(timeout=_CUA_INSTALLER_TIMEOUT)
except subprocess.TimeoutExpired:
_kill_installer_tree(proc)
proc.communicate()
raise
result = subprocess.CompletedProcess(
install_cmd, proc.returncode, stdout=None, stderr=None
)
else:
result = subprocess.run(
install_cmd, shell=use_shell, timeout=300, env=_cua_driver_env(),
proc = subprocess.Popen(
install_cmd, shell=use_shell, env=_cua_driver_env(),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace",
text=True, encoding="utf-8", errors="replace", **popen_kwargs
)
try:
out, _ = proc.communicate(timeout=_CUA_INSTALLER_TIMEOUT)
except subprocess.TimeoutExpired:
_kill_installer_tree(proc)
proc.communicate()
raise
result = subprocess.CompletedProcess(
install_cmd, proc.returncode, stdout=out, stderr=None
)
# Preserve the full installer output. During `hermes update`,
# sys.stdout is the mirroring _UpdateOutputStream whose `_log`
@ -960,7 +1085,16 @@ def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) -
_print_info(f" {manual_hint}")
return False
except subprocess.TimeoutExpired:
_print_warning(f" cua-driver {label.lower()} timed out. Re-run manually.")
_print_warning(
f" cua-driver {label.lower()} timed out after "
f"{_CUA_INSTALLER_TIMEOUT}s."
)
if not is_windows:
_print_info(
" If this repeats, a stale installer lock may be present — "
f"check {_cua_install_lock_dir()}"
)
_print_info(f" Re-run manually: {manual_hint}")
return False
except Exception as e:
_print_warning(f" cua-driver {label.lower()} failed: {e}")

View file

@ -192,3 +192,137 @@ class TestArchProbeRemoval:
runner.assert_called_once()
# Probe deleted — no direct GitHub API call from Python.
urlopen.assert_not_called()
class TestStaleInstallLockClear:
"""_clear_stale_cua_install_lock: pre-clears the upstream installer's
concurrent-install lock only when the holder is provably dead (or the
lock is old and pid-less). Issue #58762."""
def _make_lock(self, tmp_path, pid=None):
import os
home = tmp_path / ".cua-driver"
lock = home / "packages" / ".install.lock.d"
lock.mkdir(parents=True)
if pid is not None:
(lock / "info").write_text(f"pid={pid}\n")
os.environ["CUA_DRIVER_RS_HOME"] = str(home)
return lock
def teardown_method(self):
import os
os.environ.pop("CUA_DRIVER_RS_HOME", None)
def test_dead_holder_lock_is_cleared(self, tmp_path):
from hermes_cli import tools_config
dead_pid = 4194000 # above default pid_max on most systems
lock = self._make_lock(tmp_path, pid=dead_pid)
with patch.object(tools_config, "_print_info"):
tools_config._clear_stale_cua_install_lock()
assert not lock.exists()
def test_live_holder_lock_is_kept(self, tmp_path):
import os
from hermes_cli import tools_config
lock = self._make_lock(tmp_path, pid=os.getpid())
tools_config._clear_stale_cua_install_lock()
assert lock.exists()
def test_pidless_fresh_lock_is_kept(self, tmp_path):
from hermes_cli import tools_config
lock = self._make_lock(tmp_path, pid=None)
tools_config._clear_stale_cua_install_lock()
assert lock.exists()
def test_pidless_old_lock_is_cleared(self, tmp_path):
import os
import time
from hermes_cli import tools_config
lock = self._make_lock(tmp_path, pid=None)
old = time.time() - (tools_config._CUA_LOCK_STALE_AFTER + 60)
os.utime(lock, (old, old))
with patch.object(tools_config, "_print_info"):
tools_config._clear_stale_cua_install_lock()
assert not lock.exists()
def test_no_lock_is_noop(self, tmp_path):
import os
os.environ["CUA_DRIVER_RS_HOME"] = str(tmp_path / ".cua-driver")
from hermes_cli import tools_config
tools_config._clear_stale_cua_install_lock() # must not raise
class TestInstallerTimeoutKillsProcessGroup:
"""On timeout the whole installer process group must be killed, so the
`curl | bash` grandchildren can't survive holding the install lock."""
def test_timeout_kills_process_group_and_returns_false(self, tmp_path):
import os
import signal
import subprocess
import sys as _sys
from unittest.mock import MagicMock
from hermes_cli import tools_config
killed = {}
fake_proc = MagicMock()
fake_proc.pid = 12345
# First communicate() raises TimeoutExpired, second (post-kill) returns.
fake_proc.communicate.side_effect = [
subprocess.TimeoutExpired(cmd="x", timeout=1),
("", None),
]
def fake_killpg(pgid, sig):
killed["pgid"] = pgid
killed["sig"] = sig
with patch("platform.system", return_value="Linux"), \
patch("subprocess.Popen", return_value=fake_proc), \
patch.object(tools_config.os, "getpgid", return_value=99999), \
patch.object(tools_config.os, "killpg", side_effect=fake_killpg), \
patch.object(tools_config, "_clear_stale_cua_install_lock"), \
patch.object(tools_config, "_print_warning"), \
patch.object(tools_config, "_print_info"):
ok = tools_config._run_cua_driver_installer(label="Refreshing", verbose=False)
assert ok is False
assert killed.get("pgid") == 99999
assert killed.get("sig") == signal.SIGKILL
# Post-kill reap happened.
assert fake_proc.communicate.call_count == 2
def test_timeout_ceiling_exceeds_upstream_lock_window(self):
from hermes_cli import tools_config
# The upstream installer waits up to 600s before reclaiming a stale
# lock; our ceiling must give that window room to complete.
assert tools_config._CUA_INSTALLER_TIMEOUT > tools_config._CUA_LOCK_STALE_AFTER
def test_installer_runs_in_new_session_on_posix(self, tmp_path):
import subprocess
from unittest.mock import MagicMock
from hermes_cli import tools_config
captured = {}
fake_proc = MagicMock()
fake_proc.pid = 1
fake_proc.returncode = 1
fake_proc.communicate.return_value = ("", None)
def fake_popen(*args, **kwargs):
captured.update(kwargs)
return fake_proc
with patch("platform.system", return_value="Linux"), \
patch("subprocess.Popen", side_effect=fake_popen), \
patch.object(tools_config, "_clear_stale_cua_install_lock"), \
patch.object(tools_config, "_print_warning"), \
patch.object(tools_config, "_print_info"):
tools_config._run_cua_driver_installer(label="Refreshing", verbose=False)
assert captured.get("start_new_session") is True