[eric] webapp-cache: validate-or-nuke half-installed warm cache, heal off the hot path

This commit is contained in:
ciregenz
2026-06-22 02:51:30 -07:00
parent 936f33033a
commit ec8fc4b7c0
3 changed files with 59 additions and 11 deletions
+15 -3
View File
@@ -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
@@ -40,17 +40,18 @@ fi
# Fast path: the seeder usually symlinks node_modules to a shared warm
# cache (~/.openswarm/cache/webapp_template_node_modules/<hash>), 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
@@ -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()