Files
hermes-agent/tests/hermes_cli/test_web_build_limits.py
Austin Pickett af9c7137c8 fix(web): bound the dashboard build's CPU and heap (#63338)
The dashboard build (Vite 8 + Rolldown + Tailwind v4) saturates every CPU
on small hosts (200%+ top readings, OOMs and frozen SSH sessions on 2–4
vCPU VPS boxes). Rolldown exposes no JS-level worker knob, but its native
binding parallelizes with rayon, which honours RAYON_NUM_THREADS.

Cap both sides of the build, everywhere it runs:

- scripts/build/web.mjs (the npm run build / packaging entry): limit the
  native bundler's rayon pool to half the available cores before vite is
  imported, and re-exec once with a --max-old-space-size cap (sized from
  the cgroup memory limit, 75%, floored at 1 GB, ceiling 4 GB) unless
  the user already set one.
- hermes_cli/web_build_limits.py + build_source_web(): the same caps for
  the install/update/dashboard-triggered source build path.

User env always wins (existing NODE_OPTIONS heap flag or
RAYON_NUM_THREADS is preserved); HERMES_WEB_BUILD_MAX_OLD_SPACE_SIZE /
HERMES_WEB_BUILD_THREADS tailor the caps, and HERMES_WEB_BUILD_LIGHT=1
tightens them (1 thread, 1 GB heap) for hosts that cannot spare full CPU
during the build — the light-build idea from the closed PR #78131.
2026-10-07 17:13:11 -04:00

132 lines
5.7 KiB
Python

"""Web dashboard build resource caps (issue #63338).
``npm run build`` under ``web/`` runs Vite 8's Rust-native bundler (Rolldown) plus
the TypeScript solution builder; on small VPS hosts it saturates every CPU and can
OOM the machine. These tests cover the env capping helper and its propagation into
the install/update/dashboard-triggered source build path.
"""
from __future__ import annotations
import pytest
from hermes_cli.web_build_limits import (
apply_web_build_limits,
web_build_limits,
)
class TestWebBuildLimits:
def test_heap_and_thread_caps_applied(self, monkeypatch):
monkeypatch.delenv("NODE_OPTIONS", raising=False)
monkeypatch.delenv("RAYON_NUM_THREADS", raising=False)
monkeypatch.delenv("HERMES_WEB_BUILD_MAX_OLD_SPACE_SIZE", raising=False)
monkeypatch.delenv("HERMES_WEB_BUILD_THREADS", raising=False)
env = web_build_limits({})
assert "--max-old-space-size=" in env["NODE_OPTIONS"]
assert int(env["RAYON_NUM_THREADS"]) >= 1
def test_existing_user_node_options_preserved(self, monkeypatch):
monkeypatch.delenv("RAYON_NUM_THREADS", raising=False)
env = web_build_limits({"NODE_OPTIONS": "--max-old-space-size=8192"})
assert env["NODE_OPTIONS"].count("--max-old-space-size=") == 1
assert "8192" in env["NODE_OPTIONS"]
def test_existing_user_rayon_threads_preserved(self, monkeypatch):
monkeypatch.delenv("NODE_OPTIONS", raising=False)
env = web_build_limits({"RAYON_NUM_THREADS": "6"})
assert env["RAYON_NUM_THREADS"] == "6"
# heap cap still applied
assert "--max-old-space-size=" in env["NODE_OPTIONS"]
def test_explicit_env_overrides_win(self, monkeypatch):
monkeypatch.delenv("NODE_OPTIONS", raising=False)
monkeypatch.delenv("RAYON_NUM_THREADS", raising=False)
env = web_build_limits({
"HERMES_WEB_BUILD_MAX_OLD_SPACE_SIZE": "3072",
"HERMES_WEB_BUILD_THREADS": "1",
})
assert "--max-old-space-size=3072" in env["NODE_OPTIONS"]
assert env["RAYON_NUM_THREADS"] == "1"
def test_cgroup_limit_shrinks_heap_floor(self, monkeypatch):
monkeypatch.delenv("NODE_OPTIONS", raising=False)
monkeypatch.setattr(
"hermes_cli.web_build_limits._cgroup_memory_limit_mb", lambda: 2048
)
env = web_build_limits({})
# 75% of a 2GB container, above the GC-thrash floor but below the
# unconstrained ceiling.
assert "--max-old-space-size=1536" in env["NODE_OPTIONS"]
def test_bounded_threads_half_cores(self, monkeypatch):
monkeypatch.setattr("hermes_cli.web_build_limits._available_cores", lambda: 16)
env = web_build_limits({})
assert env["RAYON_NUM_THREADS"] == "8" # clamped ceiling
monkeypatch.setattr("hermes_cli.web_build_limits._available_cores", lambda: 4)
env = web_build_limits({})
assert env["RAYON_NUM_THREADS"] == "2" # half of 4 — the issue's host
monkeypatch.setattr("hermes_cli.web_build_limits._available_cores", lambda: 1)
env = web_build_limits({})
assert env["RAYON_NUM_THREADS"] == "1" # single-core host never gets 0
def test_apply_updates_env_in_place(self, monkeypatch):
monkeypatch.delenv("NODE_OPTIONS", raising=False)
monkeypatch.delenv("RAYON_NUM_THREADS", raising=False)
env = {"PATH": "/bin"}
apply_web_build_limits(env)
assert "--max-old-space-size=" in env["NODE_OPTIONS"]
assert env["RAYON_NUM_THREADS"].isdigit()
assert env["PATH"] == "/bin"
def test_light_mode_tightens_caps(self, monkeypatch):
monkeypatch.delenv("NODE_OPTIONS", raising=False)
monkeypatch.delenv("RAYON_NUM_THREADS", raising=False)
monkeypatch.delenv("HERMES_WEB_BUILD_MAX_OLD_SPACE_SIZE", raising=False)
monkeypatch.delenv("HERMES_WEB_BUILD_THREADS", raising=False)
monkeypatch.setattr("hermes_cli.web_build_limits._available_cores", lambda: 8)
env = web_build_limits({"HERMES_WEB_BUILD_LIGHT": "1"})
assert "--max-old-space-size=1024" in env["NODE_OPTIONS"]
assert env["RAYON_NUM_THREADS"] == "1"
class TestBuildSourceWebPropagatesLimits:
"""The install/update/``hermes dashboard`` build path must apply the caps."""
def test_build_source_web_applies_limits(self, tmp_path, monkeypatch):
from hermes_cli import source_build
captured: dict = {}
def fake_run_source_script(project_root, script, *args, env, label):
captured["env"] = env
captured["label"] = label
monkeypatch.setattr(source_build, "run_source_script", fake_run_source_script)
monkeypatch.delenv("NODE_OPTIONS", raising=False)
monkeypatch.delenv("RAYON_NUM_THREADS", raising=False)
env = {"PATH": "/bin"}
source_build.build_source_web(tmp_path, env=env)
assert "--max-old-space-size=" in captured["env"]["NODE_OPTIONS"]
assert captured["env"]["RAYON_NUM_THREADS"].isdigit()
# original env keys untouched
assert captured["env"]["PATH"] == "/bin"
assert captured["label"] == "Building the web UI"
class TestPackageJsonBuildScriptCapped:
"""``scripts/build/web.mjs`` must carry the caps for direct npm runs."""
def test_build_script_has_heap_and_thread_caps(self):
from pathlib import Path
repo_root = Path(__file__).resolve().parents[2]
build_script = (repo_root / "scripts/build/web.mjs").read_text(encoding="utf-8")
assert "--max-old-space-size=" in build_script
assert "RAYON_NUM_THREADS" in build_script
# caps apply on direct invocation before vite is imported
assert "isMain(import.meta.url)" in build_script