Convert getAppMetrics to async and replace spawnSync with async Bun.spawn

spawnSync blocks the event loop while waiting for ps and du, which
stalls the SSE metrics stream and other requests. Running these
concurrently with async spawn (and Promise.all for du) keeps the
server responsive under load.
This commit is contained in:
Chris Wanstrath 2026-03-16 16:32:17 -07:00
parent 926e57e34e
commit 195be426f1

View File

@ -103,7 +103,7 @@ let _appDiskCache: Record<string, number> = {}
let _appDiskLastUpdate = 0
const DISK_CACHE_TTL = 30000
function getAppMetrics(): Record<string, AppMetrics> {
async function getAppMetrics(): Promise<Record<string, AppMetrics>> {
const apps = allApps()
const running = apps.filter(a => a.proc?.pid)
const result: Record<string, AppMetrics> = {}
@ -117,8 +117,10 @@ function getAppMetrics(): Record<string, AppMetrics> {
if (pidToName.size > 0) {
try {
const pids = [...pidToName.keys()].join(',')
const ps = Bun.spawnSync(['ps', '-o', 'pid=,%cpu=,rss=', '-p', pids])
for (const line of ps.stdout.toString().split('\n')) {
const proc = Bun.spawn(['ps', '-o', 'pid=,%cpu=,rss=', '-p', pids], { stdout: 'pipe', stderr: 'ignore' })
const output = await new Response(proc.stdout).text()
await proc.exited
for (const line of output.split('\n')) {
const parts = line.trim().split(/\s+/)
if (parts.length < 3) continue
const pid = parseInt(parts[0]!, 10)
@ -135,12 +137,21 @@ function getAppMetrics(): Record<string, AppMetrics> {
if (now - _appDiskLastUpdate > DISK_CACHE_TTL) {
_appDiskLastUpdate = now
_appDiskCache = {}
for (const app of apps) {
try {
const du = Bun.spawnSync(['du', '-sk', join(APPS_DIR, app.name)])
const kb = parseInt(du.stdout.toString().trim().split('\t')[0]!, 10)
if (kb) _appDiskCache[app.name] = kb * 1024
} catch {}
const duResults = await Promise.all(
apps.map(async app => {
try {
const proc = Bun.spawn(['du', '-sk', join(APPS_DIR, app.name)], { stdout: 'pipe', stderr: 'ignore' })
const output = await new Response(proc.stdout).text()
await proc.exited
const kb = parseInt(output.trim().split('\t')[0]!, 10)
return { name: app.name, bytes: kb ? kb * 1024 : 0 }
} catch {
return { name: app.name, bytes: 0 }
}
})
)
for (const { name, bytes } of duResults) {
if (bytes) _appDiskCache[name] = bytes
}
}
@ -154,26 +165,30 @@ function getAppMetrics(): Record<string, AppMetrics> {
}
// Get current system metrics
router.get('/metrics', c => {
router.get('/metrics', async c => {
const metrics: SystemMetrics = {
cpu: getCpuUsage(),
ram: getMemoryUsage(),
disk: getDiskUsage(),
apps: getAppMetrics(),
apps: await getAppMetrics(),
}
return c.json(metrics)
})
// SSE stream for real-time metrics (updates every 2s)
router.sse('/metrics/stream', (send) => {
let queue = Promise.resolve()
const sendMetrics = () => {
const metrics: SystemMetrics = {
cpu: getCpuUsage(),
ram: getMemoryUsage(),
disk: getDiskUsage(),
apps: getAppMetrics(),
}
send(metrics)
queue = queue.then(async () => {
const metrics: SystemMetrics = {
cpu: getCpuUsage(),
ram: getMemoryUsage(),
disk: getDiskUsage(),
apps: await getAppMetrics(),
}
await send(metrics)
})
}
// Initial send