Compare commits

...
Author SHA1 Message Date
byrongamatosandBret Mogilefsky 7d50a87493 fix(plugins): don't block startup on plugin pip-install; don't retry failures forever
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_<plugin> 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 <noreply@anthropic.com>
(cherry picked from commit 3fa776c6cd1a569bb4206c91fd89cedf4b00f17d)
2026-06-17 23:41:55 -07:00
+50 -2
View File
@@ -934,6 +934,16 @@ def _install_requirements(plugin_dir: Path, plugin_id: str):
if not req_file.exists(): if not req_file.exists():
return True 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.mkdir(parents=True, exist_ok=True)
pip_target = str(_PIP_TARGET) 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 # (PYTHONHASHSEED), so the marker would never match on restart and
# pip would re-resolve every plugin's requirements on every boot. # pip would re-resolve every plugin's requirements on every boot.
marker = _PIP_TARGET / f".installed_{plugin_id}" marker = _PIP_TARGET / f".installed_{plugin_id}"
fail_marker = _PIP_TARGET / f".failed_{plugin_id}"
req_hash = hashlib.sha256(req_file.read_bytes()).hexdigest() 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 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) log.info("Installing requirements for plugin %r (this can take a while for large deps)...", plugin_id)
try: try:
@@ -960,7 +993,20 @@ def _install_requirements(plugin_dir: Path, plugin_id: str):
capture_output=True, text=True, timeout=1800, capture_output=True, text=True, timeout=1800,
) )
if result.returncode == 0: 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) log.info("Requirements installed for plugin %r", plugin_id)
return True return True
else: else:
@@ -974,6 +1020,7 @@ def _install_requirements(plugin_dir: Path, plugin_id: str):
) )
else: else:
log.warning("Plugin %r: failed to install requirements: %s", plugin_id, result.stderr[:300]) log.warning("Plugin %r: failed to install requirements: %s", plugin_id, result.stderr[:300])
_record_failure()
return False return False
except Exception as e: except Exception as e:
err_lower = str(e).lower() err_lower = str(e).lower()
@@ -986,6 +1033,7 @@ def _install_requirements(plugin_dir: Path, plugin_id: str):
) )
else: else:
log.warning("Plugin %r: error installing requirements: %s", plugin_id, e) log.warning("Plugin %r: error installing requirements: %s", plugin_id, e)
_record_failure()
return False return False