From ec8fc4b7c02bfeb27d46772976a9364aa1d13502 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 22 Jun 2026 02:51:30 -0700 Subject: [PATCH] [eric] webapp-cache: validate-or-nuke half-installed warm cache, heal off the hot path --- .../apps/outputs/view_builder_templates.py | 18 ++++++++-- .../outputs/webapp_template/frontend/run.sh | 17 ++++----- .../tests/test_bundled_extracted_modules.py | 35 +++++++++++++++++++ 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/backend/apps/outputs/view_builder_templates.py b/backend/apps/outputs/view_builder_templates.py index 281f68ec..27147474 100644 --- a/backend/apps/outputs/view_builder_templates.py +++ b/backend/apps/outputs/view_builder_templates.py @@ -319,13 +319,21 @@ def _ensure_warm_cache() -> str | None: with _warm_cache_lock: if _warm_cache_is_complete(cache_modules): return cache_modules + # A node_modules that exists but flunks the completeness check is a + # half-finished install; wipe it so the rebuild below starts on clean + # ground instead of layering onto a broken tree. + if os.path.isdir(cache_modules): + shutil.rmtree(cache_modules, ignore_errors=True) # Fast path: pre-built archive shipped inside the release. The # build script generates this so users hitting OpenSwarm for the # first time skip the ~22 s live `npm install`. Falls through on # any failure so dev installs (no archive) keep working. if _try_extract_bundled_archive(cache_dir, _warm_cache_digest()): - logger.info("webapp-template: warm cache ready from bundled archive") - return cache_modules + if _warm_cache_is_complete(cache_modules): + logger.info("webapp-template: warm cache ready from bundled archive") + return cache_modules + # Archive unpacked a tree without the launch bin; don't trust it. + shutil.rmtree(cache_modules, ignore_errors=True) try: os.makedirs(cache_dir, exist_ok=True) # Copy package.json + lockfile (if it exists) into the cache @@ -370,6 +378,10 @@ def _ensure_warm_cache() -> str | None: (result.stderr or "")[-1500:], ) return None + # Never hand back a tree the workspace can't actually launch from. + if not _warm_cache_is_complete(cache_modules): + logger.warning("webapp-template: warm-cache install left no .bin/vite; not caching") + return None return cache_modules except Exception as exc: logger.warning("webapp-template warm-cache failed: %s", exc) @@ -549,7 +561,7 @@ def warm_cache_in_background() -> None: global _warm_cache_thread if _warm_cache_thread is not None and _warm_cache_thread.is_alive(): return - node_done = os.path.isdir(os.path.join(_warm_cache_dir(), "node_modules")) + node_done = _warm_cache_is_complete(os.path.join(_warm_cache_dir(), "node_modules")) venv_done = os.path.isfile(os.path.join(_warm_venv_dir(), ".populated")) if node_done and venv_done: return diff --git a/backend/apps/outputs/webapp_template/frontend/run.sh b/backend/apps/outputs/webapp_template/frontend/run.sh index 8c4e611c..20a79bcc 100755 --- a/backend/apps/outputs/webapp_template/frontend/run.sh +++ b/backend/apps/outputs/webapp_template/frontend/run.sh @@ -40,17 +40,18 @@ fi # Fast path: the seeder usually symlinks node_modules to a shared warm # cache (~/.openswarm/cache/webapp_template_node_modules/), so the # dependency install has already been done once and we can skip straight -# to vite. Only run npm install when node_modules is genuinely missing -# or empty — e.g. a workspace seeded before the warm-cache existed, or -# the user's cache was cleared. -# A non-empty node_modules is NOT proof of a finished install. -# non-empty -> skip" check then trusted that, and `npm run dev` died with -# `vite: command not found`. Gate on the bin we actually launch with so a -# broken/partial tree self-heals via install instead of being skipped. -#So thats why i explicitly have "/.bin/vite" +# to vite. A non-empty node_modules is NOT proof of a finished install +# (npm links .bin/* last, so a killed install leaves trees but no bin and +# vite dies with "command not found"); gate on the bin we actually launch. if [ -e node_modules/.bin/vite ]; then echo "Dependencies already present - skipping install." else + # Incomplete tree. If node_modules is a SYMLINK to the shared warm cache, + # never install through it: that writes into the cache every other app + # shares (corruption) and stampedes when several apps boot at once. Drop + # the link and install a private tree so this app heals alone while the + # backend's background warmer rebuilds the shared cache for everyone else. + [ -L node_modules ] && rm -f node_modules echo "Installing dependencies..." "$NPM" install --prefer-offline --no-audit --no-fund fi diff --git a/backend/tests/test_bundled_extracted_modules.py b/backend/tests/test_bundled_extracted_modules.py index 6d3b1e22..6639cf01 100644 --- a/backend/tests/test_bundled_extracted_modules.py +++ b/backend/tests/test_bundled_extracted_modules.py @@ -31,3 +31,38 @@ def test_no_bundled_tree_returns_none(monkeypatch, tmp_path): # caller falls through to the .tar.gz extract or live npm. monkeypatch.setattr(vt, "_BUNDLED_ARCHIVE_DIR", str(tmp_path / "empty")) assert vt._bundled_extracted_modules() is None + + +def test_warm_cache_is_complete_requires_launch_bin(tmp_path): + # A package tree on disk is NOT a finished install; the .bin/vite launch + # shim is what proves npm finished its bin-linking phase. + nm = tmp_path / "node_modules" + (nm / "vite" / "bin").mkdir(parents=True) + (nm / "vite" / "bin" / "vite.js").write_text("// vite") + assert vt._warm_cache_is_complete(str(nm)) is False + bindir = nm / ".bin" + bindir.mkdir() + (bindir / "vite").symlink_to("../vite/bin/vite.js") + assert vt._warm_cache_is_complete(str(nm)) is True + + +def test_ensure_warm_cache_wipes_partial_and_never_returns_incomplete(monkeypatch, tmp_path): + # A half-finished cache (package tree present, .bin/vite missing) must be + # WIPED and never handed back, so no workspace symlinks to an unlaunchable + # tree and run.sh is never pushed into installing through the shared cache. + digest = vt._warm_cache_digest() + home = tmp_path / "home" + monkeypatch.setenv("OPENSWARM_WEBAPP_CACHE_DIR", str(home)) + cache_modules = home / digest / "node_modules" + (cache_modules / "vite" / "bin").mkdir(parents=True) + (cache_modules / "vite" / "bin" / "vite.js").write_text("// vite") + assert vt._warm_cache_is_complete(str(cache_modules)) is False + + # No bundled tree, no archive, no npm: the only honest answer is "not ready" + # (None), and the broken tree must be gone, not cached for the next caller. + monkeypatch.setattr(vt, "_BUNDLED_ARCHIVE_DIR", str(tmp_path / "noresources")) + monkeypatch.setattr(vt, "_try_extract_bundled_archive", lambda *a, **k: False) + monkeypatch.setattr(vt, "_resolve_npm", lambda: None) + + assert vt._ensure_warm_cache() is None + assert not cache_modules.exists()