From 7d50a874931f8ed0adbcc57f1e600f82d20a2b64 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Wed, 3 Jun 2026 10:26:36 +0200 Subject: [PATCH] fix(plugins): don't block startup on plugin pip-install; don't retry failures forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin loader runs a synchronous 'pip install' for each plugin's requirements.txt at startup (1800s timeout each), and only wrote a success marker — so a plugin whose install fails (heavy optional deps like torch/whisperx/demucs on a machine that can't build them, or offline) re-attempts and re-blocks startup on EVERY boot. On a distributed host (slopsmith-desktop) this hangs the backend past the app's readiness window. - Honour SLOPSMITH_SKIP_PLUGIN_INSTALL: hosts that bundle their own runtime (e.g. the desktop app) set it to skip the blocking startup install entirely. The plugin still loads and degrades gracefully when its optional deps are absent. - Write a .failed_ marker (req-hash keyed) on a failed install and skip re-attempting the same failing requirements on subsequent boots — retry only when requirements.txt changes or the marker is cleared. Marker reads are guarded (unreadable marker -> treat as no-match, never raises out); the success path writes the success marker and clears any stale failure marker in independent best-effort blocks so a successful install can never be recorded as a sticky failure. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 3fa776c6cd1a569bb4206c91fd89cedf4b00f17d) --- plugins/__init__.py | 52 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/plugins/__init__.py b/plugins/__init__.py index 93097ea..fd88ca3 100644 --- a/plugins/__init__.py +++ b/plugins/__init__.py @@ -934,6 +934,16 @@ def _install_requirements(plugin_dir: Path, plugin_id: str): if not req_file.exists(): return True + # Packaged/distributed hosts (e.g. slopsmith-desktop) set + # SLOPSMITH_SKIP_PLUGIN_INSTALL to skip the blocking startup pip install: + # heavy optional deps (torch/whisperx/demucs) would otherwise download for + # minutes and hang the backend past the host app's readiness window. The + # plugin still loads and degrades gracefully when its optional deps are + # absent. + if os.environ.get("SLOPSMITH_SKIP_PLUGIN_INSTALL", "").strip().lower() not in ("", "0", "false", "no"): + log.info("Skipping requirement install for plugin %r (SLOPSMITH_SKIP_PLUGIN_INSTALL set)", plugin_id) + return True + _PIP_TARGET.mkdir(parents=True, exist_ok=True) pip_target = str(_PIP_TARGET) @@ -946,9 +956,32 @@ def _install_requirements(plugin_dir: Path, plugin_id: str): # (PYTHONHASHSEED), so the marker would never match on restart and # pip would re-resolve every plugin's requirements on every boot. marker = _PIP_TARGET / f".installed_{plugin_id}" + fail_marker = _PIP_TARGET / f".failed_{plugin_id}" req_hash = hashlib.sha256(req_file.read_bytes()).hexdigest() - if marker.exists() and marker.read_text().strip() == req_hash: + + def _marker_matches(m): + # Tolerate an unreadable/transiently-broken marker (permissions, I/O): + # treat it as "no match" and fall through to a normal install attempt + # rather than letting read_text() raise out of this function. + try: + return m.exists() and m.read_text().strip() == req_hash + except OSError: + return False + + if _marker_matches(marker): return True # Already installed, same requirements + # A previous install of these exact requirements already failed. Don't + # re-attempt on every boot: that re-blocks startup for the full pip timeout + # each launch. Retry only when requirements.txt changes (new hash) or the + # .failed_ marker is cleared. + if _marker_matches(fail_marker): + return False + + def _record_failure(): + try: + fail_marker.write_text(req_hash) + except OSError: + pass # read-only target: nothing to persist log.info("Installing requirements for plugin %r (this can take a while for large deps)...", plugin_id) try: @@ -960,7 +993,20 @@ def _install_requirements(plugin_dir: Path, plugin_id: str): capture_output=True, text=True, timeout=1800, ) if result.returncode == 0: - marker.write_text(req_hash) + # Persisting markers is best-effort and must NOT fall through to the + # outer `except` (which would call _record_failure() and make a + # SUCCESSFUL install look like a sticky failure). The two writes are + # independent: clearing a stale .failed_ marker must still happen + # even if writing the success marker fails — otherwise a real + # success would stay recorded as a failure on the next boot. + try: + marker.write_text(req_hash) + except OSError: + pass + try: + fail_marker.unlink() # clear any stale failure record + except OSError: + pass log.info("Requirements installed for plugin %r", plugin_id) return True else: @@ -974,6 +1020,7 @@ def _install_requirements(plugin_dir: Path, plugin_id: str): ) else: log.warning("Plugin %r: failed to install requirements: %s", plugin_id, result.stderr[:300]) + _record_failure() return False except Exception as e: err_lower = str(e).lower() @@ -986,6 +1033,7 @@ def _install_requirements(plugin_dir: Path, plugin_id: str): ) else: log.warning("Plugin %r: error installing requirements: %s", plugin_id, e) + _record_failure() return False