Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 107 additions & 1 deletion cli/src/utils/machineHealth.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { collectMachineHealth, readLinuxMemoryUsedPercent, resetMachineHealthSamplerForTests } from './machineHealth'
import {
collectMachineHealth,
readDarwinMemoryUsedPercent,
readLinuxMemoryUsedPercent,
resetMachineHealthSamplerForTests
} from './machineHealth'

describe('readLinuxMemoryUsedPercent', () => {
it('uses MemAvailable, not MemFree, so page cache does not read as pressure', () => {
Expand All @@ -15,6 +20,107 @@ Cached: 9758076 kB
})
})

describe('readDarwinMemoryUsedPercent', () => {
it('reports App+Wired+Compressed used memory, not total-minus-free (real Apple Silicon capture)', () => {
// Verbatim `vm_stat` capture from a 16GB Mac mini (M-series, macOS 26.4, hw.memsize
// 17179869184). `top` reported the same moment as "15G used (1952M wired, 5204M
// compressor)"; anonymous 370327 pages ≈ 5.65GB App Memory. Activity Monitor's
// "Memory Used" = App + Wired + Compressed ≈ 12.6GB → 79%.
// The pre-fix path (`total - os.freemem()`) reported 99% here and drove the tooltip to
// "High pressure — avoid spawning more here", because os.freemem() counts reclaimable
// file cache as used. Summing only anonymous + wired + compressor yields 79%, matching
// the number a user sees in Activity Monitor.
const vmStat = `
Mach Virtual Memory Statistics: (page size of 16384 bytes)
Pages free: 15078.
Pages active: 269344.
Pages inactive: 269559.
Pages speculative: 1623.
Pages throttled: 0.
Pages wired down: 124928.
Pages purgeable: 6245.
"Translation faults": 6794924000.
Pages copy-on-write: 151784964.
Pages zero filled: 6137372590.
Pages reactivated: 57500663.
Pages purged: 11350044.
File-backed pages: 170199.
Anonymous pages: 370327.
Pages stored in compressor: 785407.
Pages occupied by compressor: 333030.
Decompressions: 49937944.
Compressions: 60303007.
Pageins: 30419969.
Pageouts: 124211.
Swapins: 288.
Swapouts: 12228.
`.trim()

const totalBytes = 17179869184
expect(readDarwinMemoryUsedPercent(vmStat, totalBytes)).toBe(79)
})

it('parses page size from the header instead of hardcoding it (Intel, 4KB pages)', () => {
const vmStat = `
Mach Virtual Memory Statistics: (page size of 4096 bytes)
Pages free: 48576.
Pages active: 900000.
Pages inactive: 900000.
Pages speculative: 100000.
Pages throttled: 0.
Pages wired down: 200000.
Pages purgeable: 1000.
Anonymous pages: 800000.
Pages occupied by compressor: 48576.
`.trim()

// 8GB total (2097152 pages of 4096B), chosen so anonymous+wired+compressor pages ==
// exactly half of total pages — verifies the header page size is used, not hardcoded.
const totalBytes = 8589934592
expect(readDarwinMemoryUsedPercent(vmStat, totalBytes)).toBe(50)
})

it('returns undefined when a required page count is missing, so callers fall back', () => {
// Valid header and two of the three required counts present, but "Anonymous pages" is
// absent — must fail safe to the caller's os.freemem() path, not compute from partial data.
const vmStat = `
Mach Virtual Memory Statistics: (page size of 16384 bytes)
Pages wired down: 124928.
Pages occupied by compressor: 333030.
`.trim()

expect(readDarwinMemoryUsedPercent(vmStat, 17179869184)).toBeUndefined()
})

it('returns undefined for entirely unparseable input', () => {
expect(readDarwinMemoryUsedPercent('not a vm_stat output at all', 17179869184)).toBeUndefined()
})

it('returns undefined when totalBytes is not positive', () => {
const vmStat = `
Mach Virtual Memory Statistics: (page size of 16384 bytes)
Pages free: 10000.
Pages inactive: 150000.
Pages speculative: 9125.
`.trim()

expect(readDarwinMemoryUsedPercent(vmStat, 0)).toBeUndefined()
})

it('falls back (undefined) when the page-size header is missing instead of guessing 4096', () => {
// Required page counts are all present, but no "page size of N bytes" header. Guessing
// 4096 would 4x-underestimate used bytes on Apple Silicon and re-introduce the
// false-high-pressure bug, so this must fail safe to the caller's os.freemem() path.
const vmStat = `
Pages wired down: 124928.
Anonymous pages: 370327.
Pages occupied by compressor: 333030.
`.trim()

expect(readDarwinMemoryUsedPercent(vmStat, 17179869184)).toBeUndefined()
})
})

describe('collectMachineHealth', () => {
it('returns schema-valid health with memory, uptime, and cpu count', () => {
resetMachineHealthSamplerForTests()
Expand Down
71 changes: 71 additions & 0 deletions cli/src/utils/machineHealth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { execSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { availableParallelism, cpus, freemem, loadavg, platform, totalmem, uptime } from 'node:os'
import type { MachineHealth } from '@hapi/protocol/types'
Expand Down Expand Up @@ -72,6 +73,64 @@ export function readLinuxMemoryUsedPercent(meminfo: string): number | undefined
return Math.max(0, Math.min(100, Math.round(((total - approxAvailable) / total) * 100)))
}

function parseVmStatPagesValue(vmStat: string, label: string): number | undefined {
for (const line of vmStat.split('\n')) {
const trimmed = line.trim()
if (!trimmed.startsWith(`${label}:`)) {
continue
}
const match = trimmed.match(/(\d+)\.?\s*$/)
if (!match) {
return undefined
}
const pages = Number(match[1])
return Number.isFinite(pages) ? pages : undefined
}
return undefined
}

function parseVmStatPageSize(vmStat: string): number | undefined {
const match = vmStat.match(/page size of (\d+) bytes/)
if (!match) {
return undefined
}
const size = Number(match[1])
return Number.isFinite(size) && size > 0 ? size : undefined
}

/**
* Darwin used-memory percent, matching Activity Monitor's "Memory Used" figure:
* App Memory + Wired + Compressed. It sums the anonymous (non-file-backed) resident
* pages, the wired-down pages, and the pages occupied by the compressor — the
* genuinely-occupied memory. Free and reclaimable file cache (inactive/speculative
* file-backed pages) are simply not part of that sum.
*
* os.freemem() (total - free) instead counts reclaimable file cache as used, which is why
* the pre-fix path reported a stuck ~99% "High pressure" on macOS. Summing occupied pages
* keeps parity with the Linux branch, whose total - MemAvailable likewise treats reclaimable
* cache as available and anonymous/wired memory as used. The three fields were validated on a
* real Mac mini against `top`'s wired/compressor byte figures.
*
* Any unparseable required field — including the page-size header — returns undefined so the
* caller falls back to os.freemem() rather than reporting a wrong-but-plausible percent.
*/
export function readDarwinMemoryUsedPercent(vmStat: string, totalBytes: number): number | undefined {
if (!totalBytes || totalBytes <= 0) {
return undefined
}

const pageSize = parseVmStatPageSize(vmStat)
const anonymous = parseVmStatPagesValue(vmStat, 'Anonymous pages')
const wired = parseVmStatPagesValue(vmStat, 'Pages wired down')
const compressor = parseVmStatPagesValue(vmStat, 'Pages occupied by compressor')
if (pageSize === undefined || anonymous === undefined || wired === undefined || compressor === undefined) {
return undefined
}

const used = (anonymous + wired + compressor) * pageSize
return Math.max(0, Math.min(100, Math.round((used / totalBytes) * 100)))
}

function computeMemoryPercent(): number | undefined {
if (platform() === 'linux') {
try {
Expand All @@ -84,6 +143,18 @@ function computeMemoryPercent(): number | undefined {
}
}

if (platform() === 'darwin') {
try {
const vmStat = execSync('vm_stat', { encoding: 'utf8', timeout: 1000 })
const fromVmStat = readDarwinMemoryUsedPercent(vmStat, totalmem())
if (fromVmStat !== undefined) {
return fromVmStat
}
} catch {
// fall through to os.freemem()
}
}

const total = totalmem()
if (total <= 0) {
return undefined
Expand Down
Loading