Files
hermes-agent/scripts/build/web.mjs
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

162 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// Pure dashboard compilation; icon/dependency preparation belongs to callers.
import { cpSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { frontendArgs, isMain, productOutput, repoRoot, withProduct, workspaceTool } from './frontend-common.mjs'
import { recordProduct, buildInputs } from './freshness.mjs'
// --- Bounded build (#63338) -------------------------------------------------
// `npm run build` drives Vite 8's Rust-native bundler (Rolldown), whose rayon
// thread pool saturates every CPU on small hosts (200%+ `top` readings, OOMs
// and frozen SSH sessions on 2–4 vCPU VPS boxes). Vite exposes no worker knob,
// but Rolldown's native binding honours RAYON_NUM_THREADS, and V8's heap is
// capped with --max-old-space-size. `--max-old-space-size` cannot affect an
// already-running process, so the default heap cap is enforced with a single
// re-exec (spawnSync → exit); the thread cap is set before vite is imported.
// User-set env (NODE_OPTIONS heap flag, RAYON_NUM_THREADS) always wins, and
// HERMES_WEB_BUILD_MAX_OLD_SPACE_SIZE / HERMES_WEB_BUILD_THREADS tailor the
// caps; HERMES_WEB_BUILD_LIGHT=1 tightens them (1 thread, minimum heap) for
// hosts that cannot spare full CPU during the build.
const MIN_HEAP_MB = 1024
const MAX_HEAP_MB = 4096
const MAX_THREADS = 8
const TRUTHY = new Set(['1', 'true', 'yes', 'on'])
const webBuildLight = () => TRUTHY.has(String(process.env.HERMES_WEB_BUILD_LIGHT ?? '').toLowerCase())
function availableCores() {
try {
return Math.max(1, os.availableParallelism?.() ?? (os.cpus().length || 1))
} catch {
return 1
}
}
// Half the cores (the dashboard graph is small), clamped to [1, MAX_THREADS].
function boundedThreads() {
return Math.max(1, Math.min(MAX_THREADS, Math.ceil(availableCores() / 2)))
}
// cgroup v2/v1 memory limit in MB, or null when unconstrained.
function readCgroupLimitMb() {
for (const cgroupPath of ['/sys/fs/cgroup/memory.max', '/sys/fs/cgroup/memory/memory.limit_in_bytes']) {
try {
const raw = String(readFileSync(cgroupPath, 'utf-8')).trim()
if (raw === 'max' || !/^\d+$/.test(raw)) continue
const value = Number(raw)
if (value <= 0 || value >= 2 ** 50) return null // the v1 "unlimited" sentinel
return Math.floor(value / (1024 * 1024))
} catch { /* unconstrained or unsupported */ }
}
return null
}
// 75% of the cgroup limit when constrained (mirrors the TUI launcher's heap
// sizing), else the conservative ceiling. Never below the GC-thrash floor.
function boundedHeapMb() {
const limitMb = readCgroupLimitMb()
const sized = limitMb ? Math.floor(limitMb * 0.75) : MAX_HEAP_MB
return Math.max(MIN_HEAP_MB, Math.min(MAX_HEAP_MB, sized))
}
function applyThreadCap() {
if (process.env.RAYON_NUM_THREADS) return
const explicit = Number.parseInt(process.env.HERMES_WEB_BUILD_THREADS ?? '', 10)
const threads = Number.isFinite(explicit) && explicit > 0 ? explicit
: (webBuildLight() ? 1 : boundedThreads())
process.env.RAYON_NUM_THREADS = String(Math.max(1, threads))
}
// Re-exec with the heap cap appended to NODE_OPTIONS unless the user already
// set one. Runs only for direct invocation (isMain), before vite is imported.
async function ensureHeapCapForFreshProcess() {
const tokens = (process.env.NODE_OPTIONS ?? '').split(/\s+/).filter(Boolean)
if (tokens.some(token => token.startsWith('--max-old-space-size='))) return
const explicit = Number.parseInt(process.env.HERMES_WEB_BUILD_MAX_OLD_SPACE_SIZE ?? '', 10)
const heapMb = Number.isFinite(explicit) && explicit > 0
? Math.max(MIN_HEAP_MB, explicit)
: (webBuildLight() ? MIN_HEAP_MB : boundedHeapMb())
tokens.push(`--max-old-space-size=${heapMb}`)
const { spawnSync } = await import('node:child_process')
const result = spawnSync(process.execPath, process.argv.slice(1), {
stdio: 'inherit',
env: { ...process.env, NODE_OPTIONS: tokens.join(' ') },
})
process.exit(result.status ?? 1)
}
if (isMain(import.meta.url)) {
applyThreadCap()
await ensureHeapCapForFreshProcess()
}
function typecheck(ts, root, scratch) {
const diagnostics = []
const host = ts.createSolutionBuilderHost(ts.sys, undefined, diagnostic => diagnostics.push(diagnostic))
// Use the existing tsc -b project graph, with build state isolated from the
// prepared source. force also prevents stale source buildinfo skipping checks.
const buildInfo = new Map()
host.writeFile = (file, data) => {
if (!file.endsWith('.tsbuildinfo')) throw new Error(`Unexpected TypeScript emit: ${file}`)
if (!buildInfo.has(file)) buildInfo.set(file, path.join(scratch, `ts-${buildInfo.size}.tsbuildinfo`))
ts.sys.writeFile(buildInfo.get(file), data)
}
const status = ts.createSolutionBuilder(host, [path.join(root, 'tsconfig.json')], { force: true }).build()
if (status !== 0) {
const message = ts.formatDiagnosticsWithColorAndContext(diagnostics, {
getCurrentDirectory: () => root,
getCanonicalFileName: file => file,
getNewLine: () => '\n'
})
throw new Error(`TypeScript build failed (${status})\n${message}`)
}
}
export async function buildWeb(options) {
const { source, out } = productOutput(options.source, options.out, ['web', 'apps/shared', 'node_modules'])
if (!options.icons) throw new Error('Prepared icons are required (--icons)')
const publicIcons = path.resolve(options.icons, 'web/public')
const favicon = path.join(publicIcons, 'favicon.ico')
if (!existsSync(favicon) || !statSync(favicon).isFile()) throw new Error(`Missing prepared icon: ${favicon}`)
// Icon inputs can be outside source but are still read-only build inputs.
productOutput(options.icons, out, ['web/public'])
const root = path.join(source, 'web')
const inputs = buildInputs(source, 'web', { icons: publicIcons })
const tsModule = await import(pathToFileURL(workspaceTool(source, 'web', 'typescript')).href)
const { build } = await import(pathToFileURL(workspaceTool(source, 'web', 'vite')).href)
await withProduct(out, async (product, scratch) => {
typecheck(tsModule.default ?? tsModule, root, scratch)
const publicDir = path.join(scratch, 'public')
mkdirSync(publicDir)
if (existsSync(path.join(root, 'public'))) cpSync(path.join(root, 'public'), publicDir, { recursive: true })
cpSync(publicIcons, publicDir, { recursive: true })
await build({
root,
configFile: path.join(root, 'vite.config.ts'),
// Vite's default config bundler writes into source node_modules/.vite-temp.
configLoader: 'runner',
cacheDir: path.join(scratch, 'vite-cache'),
publicDir,
build: { outDir: product, emptyOutDir: true }
})
if (!existsSync(path.join(product, 'index.html'))) throw new Error('Web build did not produce index.html')
recordProduct({ source, product: 'web', out: product, inputs })
}, { source })
return { out, index: path.join(out, 'index.html') }
}
if (isMain(import.meta.url)) {
try {
const options = process.argv.length === 2
? { source: repoRoot, icons: repoRoot, out: path.join(repoRoot, 'hermes_cli/web_dist') }
: frontendArgs(process.argv.slice(2), { icons: { type: 'string' } })
const result = await buildWeb(options)
console.log(`built ${result.index}`)
} catch (error) {
console.error(error)
process.exitCode = 1
}
}