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.
162 lines
7.3 KiB
JavaScript
162 lines
7.3 KiB
JavaScript
#!/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
|
||
}
|
||
}
|