aboutsummaryrefslogtreecommitdiff
path: root/app/backends/envs.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-03 17:26:14 -0400
committerhistoria <historiavg@proton.me>2026-09-03 17:26:14 -0400
commit9dd66997033f7d306718c67745b41c16dd034865 (patch)
tree201385090c547d83cf9c60f6865b28ef1c67a927 /app/backends/envs.py
parent976f12a72ebbd330e0328991afed3524c06a35ad (diff)
downloadtts-audiobook-generator-9dd66997033f7d306718c67745b41c16dd034865.tar.gz
feat: pip install checks for bad crc errors and purges cache automatically
Diffstat (limited to 'app/backends/envs.py')
-rw-r--r--app/backends/envs.py70
1 files changed, 69 insertions, 1 deletions
diff --git a/app/backends/envs.py b/app/backends/envs.py
index 98f1cc1..7e2b12d 100644
--- a/app/backends/envs.py
+++ b/app/backends/envs.py
@@ -211,6 +211,57 @@ def install_requirements(skip_optional: bool = False) -> int:
[str(env_python()), "-m", "pip", "install", *installable])
+# Substrings of pip's output that identify a wheel corrupted inside pip's
+# HTTP cache (a truncated download from an interrupted install or a disk
+# that filled mid-write — pip faithfully re-serves the bad bytes from
+# ~/.cache/pip on every later run). Unpacking dies deep in the install
+# phase with ``zipfile.BadZipFile`` / "Bad CRC-32", long after resolution
+# looked healthy. Matching the traceback markers rather than pip's exit
+# code (a generic 2) is what lets pip_install retry *only* this failure
+# mode instead of re-downloading gigabytes on every ordinary pip failure.
+_WHEEL_CORRUPTION_MARKERS = (b"BadZipFile", b"Bad CRC-32")
+
+
+def _corruption_scanner(state: Dict[str, bool]):
+ """A text/bytes callback that flags wheel-corruption markers in STATE.
+
+ One closure serves both streaming paths: raw chunk bytes from
+ run_console_subprocess's on_chunk console hook and decoded lines from
+ a wrapped EMIT. Sets ``state["corrupt"]`` once and stays silent
+ otherwise (it must never print — the forwarding caller already shows
+ the output — and never raise).
+ """
+ def _scan(text) -> None:
+ if state["corrupt"]:
+ return
+ blob = text if isinstance(text, bytes) else \
+ text.encode("utf-8", errors="replace")
+ for marker in _WHEEL_CORRUPTION_MARKERS:
+ if marker in blob:
+ state["corrupt"] = True
+ return
+ return _scan
+
+
+def _pip_install_once(argv: List[str], *, emit, cancel) -> Tuple[int, bool]:
+ """One pip attempt; returns (exit code, wheel-corruption-seen).
+
+ The console path (no EMIT) passes an on_chunk scanner so pip's output
+ still streams verbatim to the terminal while being sniffed; the EMIT
+ path wraps EMIT so the task view receives every line unchanged.
+ """
+ state: Dict[str, bool] = {"corrupt": False}
+ scan = _corruption_scanner(state)
+ if emit is None:
+ rc = common.run_console_subprocess(argv, cancel=cancel, on_chunk=scan)
+ else:
+ def wrapped(line: str) -> None:
+ scan(line)
+ emit(line)
+ rc = common.run_console_subprocess(argv, emit=wrapped, cancel=cancel)
+ return rc, state["corrupt"]
+
+
def pip_install(packages: List[str], *, emit=None, cancel=None,
env_dir: Optional[Path] = None,
upgrade: bool = False,
@@ -231,6 +282,13 @@ def pip_install(packages: List[str], *, emit=None, cancel=None,
launching one. Returns pip's exit code. With EMIT given (the in-TUI task
view) pip runs with ``--progress-bar off`` so its output is clean status
lines rather than carriage-return progress spam.
+
+ A failed attempt whose output shows a wheel corrupted inside pip's HTTP
+ cache (zipfile.BadZipFile / Bad CRC-32 — a truncated download pip keeps
+ re-serving; the cache lives in the user's home, so it survives repo
+ re-clones and venv deletes) gets exactly one self-heal retry: the pip
+ cache is purged and the same install reruns, re-downloading every wheel
+ afresh. Ordinary failures (no corruption markers) return immediately.
"""
if not env_exists(env_dir) and create_env(env_dir, interpreter) != 0:
return 1
@@ -244,7 +302,17 @@ def pip_install(packages: List[str], *, emit=None, cancel=None,
argv.append("off")
argv.extend(extra_args or [])
argv.extend(packages)
- return common.run_console_subprocess(argv, emit=emit, cancel=cancel)
+ rc, corrupted = _pip_install_once(argv, emit=emit, cancel=cancel)
+ if rc != 0 and corrupted:
+ print(f"[WARNING] pip failed unpacking a wheel corrupted in its "
+ f"cache (~/.cache/pip — typically a truncated download; it "
+ f"survives re-cloning this project). Purging the pip cache "
+ f"and retrying once...")
+ common.run_console_subprocess_quiet(
+ [str(env_python(env_dir)), "-m", "pip", "cache", "purge"])
+ print("[INFO] pip cache purged; re-running the install...")
+ rc, _corrupted = _pip_install_once(argv, emit=emit, cancel=cancel)
+ return rc
def pip_uninstall(packages: List[str], *, emit=None,