diff options
Diffstat (limited to 'app/backends')
| -rw-r--r-- | app/backends/common.py | 38 | ||||
| -rw-r--r-- | app/backends/envs.py | 70 |
2 files changed, 102 insertions, 6 deletions
diff --git a/app/backends/common.py b/app/backends/common.py index b5d6576..a12a366 100644 --- a/app/backends/common.py +++ b/app/backends/common.py @@ -372,7 +372,8 @@ def write_prompt_text(wav_dir: Path, def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None, *, emit=None, cancel=None, on_cancel=None, stall_timeout: Optional[float] = None, - env: Optional[Dict[str, str]] = None) -> int: + env: Optional[Dict[str, str]] = None, + on_chunk=None) -> int: """Run a subprocess, streaming output to the console or to EMIT. With EMIT None the child inherits the real terminal and its output @@ -382,6 +383,15 @@ def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None, updates like git's or tqdm's surface as lines), and each line is passed to EMIT — the in-TUI task view path. + ON_CHUNK (a ``callable(bytes)``) asks for the child's output *and* a + look at its raw bytes: the run then takes the piped path even without + EMIT, forwarding every chunk verbatim to the real terminal (so + carriage-return progress still animates) and handing each chunk to + ON_CHUNK — the hook pip_install uses to sniff pip's output for a + corrupted-wheel failure while the user watches it live. ON_CHUNK must + not raise and must not write to the terminal (the forwarding here + already does). + CANCEL is an optional ``threading.Event``: once set, ON_CANCEL (if given) is called (e.g. to touch a ``--cancel-file``), then the child's process group is terminated (SIGTERM, escalating to SIGKILL after a grace @@ -401,7 +411,7 @@ def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None, Returns the process exit code. """ import subprocess - if emit is None: + if emit is None and on_chunk is None: try: result = subprocess.run( argv, cwd=str(cwd) if cwd is not None else None, env=env) @@ -423,7 +433,11 @@ def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None, try: proc = subprocess.Popen(argv, **popen_kwargs) except OSError as exc: - emit(f"[ERROR] Could not run {' '.join(argv)}: {exc}") + message = f"[ERROR] Could not run {' '.join(argv)}: {exc}" + if emit is not None: + emit(message) + else: + print(message) return 1 cancelled = False @@ -448,6 +462,16 @@ def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None, if not chunk: break last_output = time.monotonic() + if on_chunk is not None: + on_chunk(chunk) + if emit is None: + # The on_chunk console path: forward each chunk + # verbatim so carriage-return progress bars still + # animate exactly as they did when the child owned + # the terminal. + sys.stdout.write(chunk.decode("utf-8", errors="replace")) + sys.stdout.flush() + continue pending += chunk.decode("utf-8", errors="replace") parts = re.split(r"[\r\n]+", pending) pending = parts.pop() @@ -483,8 +507,12 @@ def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None, if (stall_timeout is not None and time.monotonic() - last_output > stall_timeout): stalled = True - emit(f"[ERROR] No output for {int(stall_timeout)}s — assuming " - "the process hung; stopping it.") + message = (f"[ERROR] No output for {int(stall_timeout)}s — " + "assuming the process hung; stopping it.") + if emit is not None: + emit(message) + else: + print(message) _terminate_process_group(proc) break time.sleep(0.1) 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, |
