fix: cap pip detection subprocess with a 10s timeout #12

Merged
JMR-dev merged 2 commits from claude/bootstrap-infinite-loop-arm64-VU64t into main 2026-05-22 19:14:53 +00:00
+129 -57
View File
@@ -93,6 +93,12 @@ def print_notices() -> None:
# ── subprocess helpers ────────────────────────────────────────────────────────
# Default per-call cap for run()/shell(). Generous enough for heavy installs
# (apt, brew, large downloads) but bounded so a stuck command can't hang the
# bootstrap forever. Override per-call for genuinely longer operations
# (e.g. pyenv compiles).
DEFAULT_SUBPROCESS_TIMEOUT: float = 1800
def run(
cmd: list,
*,
@@ -101,6 +107,7 @@ def run(
input: Optional[bytes] = None,
capture_output: bool = False,
cwd: Optional[str] = None,
timeout: Optional[float] = DEFAULT_SUBPROCESS_TIMEOUT,
) -> subprocess.CompletedProcess:
if as_sudo and os.geteuid() != 0:
cmd = ["sudo"] + cmd
@@ -108,8 +115,13 @@ def run(
try:
return subprocess.run(
cmd, check=check, input=input,
capture_output=capture_output, cwd=cwd,
capture_output=capture_output, cwd=cwd, timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
warn(f"{cmd[0]!r} timed out after {exc.timeout}s")
if check:
raise
return subprocess.CompletedProcess(cmd, returncode=124)
except OSError as exc:
if check:
raise
@@ -122,13 +134,19 @@ def shell(
check: bool = True,
capture_output: bool = False,
text: bool = False,
timeout: Optional[float] = DEFAULT_SUBPROCESS_TIMEOUT,
) -> subprocess.CompletedProcess:
print(f" $ {cmd}")
try:
return subprocess.run(
cmd, shell=True, check=check,
capture_output=capture_output, text=text,
capture_output=capture_output, text=text, timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
warn(f"shell command timed out after {exc.timeout}s")
if check:
raise
return subprocess.CompletedProcess(cmd, returncode=124)
except OSError as exc:
if check:
raise
@@ -219,7 +237,10 @@ def check_sudo() -> None:
if not has_cmd("sudo"):
sys.exit("sudo is required but not installed.")
print("Validating sudo access ...")
result = subprocess.run(["sudo", "-v"], check=False)
try:
result = subprocess.run(["sudo", "-v"], check=False, timeout=120)
except subprocess.TimeoutExpired:
sys.exit("sudo authentication timed out.")
if result.returncode != 0:
sys.exit("sudo authentication failed.")
@@ -229,15 +250,19 @@ def ensure_xcode_clt() -> None:
"""Install the Xcode Command Line Tools if missing. macOS only."""
if not IS_MACOS:
return
result = subprocess.run(["xcode-select", "-p"], capture_output=True, check=False)
result = subprocess.run(
["xcode-select", "-p"], capture_output=True, check=False, timeout=10,
)
if result.returncode == 0:
print(f"[Xcode CLT] Already installed at {result.stdout.decode().strip()}")
return
print("[Xcode CLT] Installing Xcode Command Line Tools ...")
print(" A GUI dialog will appear — click 'Install' to proceed.")
subprocess.run(["xcode-select", "--install"], check=False)
subprocess.run(["xcode-select", "--install"], check=False, timeout=30)
print(" Waiting for installation to complete ...")
while subprocess.run(["xcode-select", "-p"], capture_output=True).returncode != 0:
while subprocess.run(
["xcode-select", "-p"], capture_output=True, timeout=10,
).returncode != 0:
time.sleep(5)
print("[Xcode CLT] Installation complete.")
@@ -350,31 +375,39 @@ def _fetch_text(url: str) -> Optional[str]:
# ── installation checks ───────────────────────────────────────────────────────
def _probe(cmd: list, *, timeout: float = 30) -> Optional[subprocess.CompletedProcess]:
"""Run a short read-only probe. Returns None on timeout or launch failure."""
try:
return subprocess.run(
cmd, capture_output=True, text=True, check=False, timeout=timeout,
)
except (subprocess.TimeoutExpired, OSError) as exc:
warn(f"{cmd[0]!r} probe failed: {exc}")
return None
def is_system_pkg_installed(pkg: str) -> bool:
if PKG_MGR == "dnf":
return subprocess.run(["rpm", "-q", pkg], capture_output=True).returncode == 0
r = _probe(["rpm", "-q", pkg])
return r is not None and r.returncode == 0
elif PKG_MGR == "apt-get":
result = subprocess.run(
["dpkg-query", "-W", "-f=${Status}", pkg],
capture_output=True, text=True, check=False,
)
return "install ok installed" in result.stdout
r = _probe(["dpkg-query", "-W", "-f=${Status}", pkg])
return r is not None and "install ok installed" in r.stdout
elif PKG_MGR == "brew":
if not has_cmd("brew"):
return False
if subprocess.run(["brew", "list", "--formula", pkg],
capture_output=True, check=False).returncode == 0:
return True
if subprocess.run(["brew", "list", "--cask", pkg],
capture_output=True, check=False).returncode == 0:
return True
for kind in ("--formula", "--cask"):
r = _probe(["brew", "list", kind, pkg], timeout=60)
if r is not None and r.returncode == 0:
return True
return False
return False
def is_flatpak_installed(app_id: str) -> bool:
if not has_cmd("flatpak"):
return False
return subprocess.run(["flatpak", "info", app_id], capture_output=True).returncode == 0
r = _probe(["flatpak", "info", app_id])
return r is not None and r.returncode == 0
def is_special_pkg_installed(pkg: str) -> bool:
if pkg == "obsidian":
@@ -1041,10 +1074,14 @@ def _default_install_path(pkg: CustomPackage) -> Optional[Path]:
def _pip_installed() -> bool:
if not has_cmd("python3"):
return False
return subprocess.run(
["python3", "-m", "pip", "--version"],
capture_output=True, check=False,
).returncode == 0
try:
return subprocess.run(
["python3", "-m", "pip", "--version"],
capture_output=True, check=False, timeout=10,
).returncode == 0
except subprocess.TimeoutExpired:
warn("pip detection timed out — treating as not installed")
return False
def is_custom_pkg_installed(pkg: CustomPackage) -> tuple[bool, Optional[Path]]:
@@ -1409,10 +1446,14 @@ def ensure_node_lts() -> None:
def _latest_stable_python(pyenv_bin: Path) -> Optional[str]:
"""Return the latest stable CPython 3.x version string from `pyenv install --list`."""
result = subprocess.run(
[str(pyenv_bin), "install", "--list"],
capture_output=True, text=True, check=False,
)
try:
result = subprocess.run(
[str(pyenv_bin), "install", "--list"],
capture_output=True, text=True, check=False, timeout=120,
)
except subprocess.TimeoutExpired:
err("pyenv install --list timed out")
return None
if result.returncode != 0:
err("pyenv install --list failed")
return None
@@ -1451,10 +1492,14 @@ def ensure_python_latest() -> Optional[threading.Thread]:
err("Could not determine latest stable Python from pyenv")
return None
installed = subprocess.run(
[str(pyenv_bin), "versions", "--bare"],
capture_output=True, text=True, check=False,
).stdout.split()
try:
installed = subprocess.run(
[str(pyenv_bin), "versions", "--bare"],
capture_output=True, text=True, check=False, timeout=30,
).stdout.split()
except subprocess.TimeoutExpired:
err("pyenv versions --bare timed out")
return None
if latest in installed:
# Fast path — no compile needed, just set global synchronously.
@@ -1470,7 +1515,15 @@ def ensure_python_latest() -> Optional[threading.Thread]:
def _worker():
install_cmd = [str(pyenv_bin), "install", "--skip-existing", latest]
p1 = subprocess.run(install_cmd, capture_output=True, text=True, check=False)
try:
p1 = subprocess.run(
install_cmd, capture_output=True, text=True, check=False,
timeout=3600,
)
except subprocess.TimeoutExpired:
elapsed = int(time.monotonic() - start)
err(f"pyenv install {latest} timed out after {elapsed}s")
return
elapsed = int(time.monotonic() - start)
if p1.returncode != 0:
err(f"pyenv install {latest} failed after {elapsed}s")
@@ -1478,10 +1531,14 @@ def ensure_python_latest() -> Optional[threading.Thread]:
if tail:
print(f"\n[pyenv stderr tail]\n{tail}")
return
p2 = subprocess.run(
[str(pyenv_bin), "global", latest],
capture_output=True, text=True, check=False,
)
try:
p2 = subprocess.run(
[str(pyenv_bin), "global", latest],
capture_output=True, text=True, check=False, timeout=60,
)
except subprocess.TimeoutExpired:
err(f"pyenv global {latest} timed out")
return
if p2.returncode != 0:
err(f"pyenv global {latest} failed")
return
@@ -1755,9 +1812,13 @@ def _yn(prompt: str) -> bool:
def _gh_logged_in() -> bool:
return subprocess.run(
["gh", "auth", "status"], capture_output=True, check=False
).returncode == 0
try:
return subprocess.run(
["gh", "auth", "status"], capture_output=True, check=False, timeout=30,
).returncode == 0
except subprocess.TimeoutExpired:
warn("gh auth status timed out — treating as not logged in")
return False
def _offer_github_upload(pub_keys: list[Path]) -> None:
@@ -1788,7 +1849,11 @@ def check_and_setup_ssh() -> None:
if not _yn("\n[GitHub CLI] Would you like to authenticate the GitHub CLI? [y/N] "):
return
result = subprocess.run(["gh", "auth", "login"], check=False)
try:
result = subprocess.run(["gh", "auth", "login"], check=False, timeout=900)
except subprocess.TimeoutExpired:
err("gh auth login timed out — skipping key upload.")
return
if result.returncode != 0:
err("gh auth login failed — skipping key upload.")
return
@@ -1977,17 +2042,20 @@ rm -f {_VM_PID_NAME}
def _ssh_to_vm(priv_key: Path, *remote: str, timeout: int = 3) -> subprocess.CompletedProcess:
return subprocess.run(
["ssh", "-q",
"-i", str(priv_key),
"-p", str(_VM_SSH_PORT),
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", f"ConnectTimeout={timeout}",
"-o", "LogLevel=ERROR",
f"{_VM_USER}@127.0.0.1", *remote],
capture_output=True, check=False,
)
try:
return subprocess.run(
["ssh", "-q",
"-i", str(priv_key),
"-p", str(_VM_SSH_PORT),
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", f"ConnectTimeout={timeout}",
"-o", "LogLevel=ERROR",
f"{_VM_USER}@127.0.0.1", *remote],
capture_output=True, check=False, timeout=timeout + 30,
)
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(["ssh"], returncode=124, stdout=b"", stderr=b"")
def _wait_for_vm_ssh(priv_key: Path, timeout_s: int = 300) -> bool:
@@ -2077,9 +2145,9 @@ def _apple_silicon_generation() -> Optional[int]:
try:
brand = subprocess.run(
["sysctl", "-n", "machdep.cpu.brand_string"],
capture_output=True, text=True, check=False,
capture_output=True, text=True, check=False, timeout=10,
).stdout.strip()
except OSError:
except (OSError, subprocess.TimeoutExpired):
return None
m = re.search(r"Apple M(\d+)", brand)
return int(m.group(1)) if m else None
@@ -2146,10 +2214,14 @@ def _provision_virtualbox_vm(qcow2: Path, seed_iso: Path) -> Optional[Path]:
vbox_base = _VM_DIR / "vbox"
vdi = _VM_DIR / "fedora.vdi"
exists = subprocess.run(
["VBoxManage", "showvminfo", vm_name],
capture_output=True, check=False,
).returncode == 0
try:
exists = subprocess.run(
["VBoxManage", "showvminfo", vm_name],
capture_output=True, check=False, timeout=30,
).returncode == 0
except subprocess.TimeoutExpired:
warn("VBoxManage showvminfo timed out — assuming VM does not exist")
exists = False
if not exists:
if not vdi.exists():