fix(cli): compute macOS machine-health memory from vm_stat (#990)

* fix(cli): add darwin vm_stat memory percent parser

Add readDarwinMemoryUsedPercent, a pure parser that computes macOS used
memory as App Memory + Wired + Compressed (anonymous + wired-down +
occupied-by-compressor pages), matching Activity Monitor's "Memory Used"
figure. Page size is parsed from the vm_stat header rather than hardcoded,
since it differs between Apple Silicon (16KB) and Intel (4KB) Macs.

Not wired up yet; covered by unit tests, including a verbatim vm_stat
capture from a 16GB Mac mini where the pre-fix total - freemem() path
reported 99% (counting reclaimable cache as used) while App+Wired+
Compressed is 79% — the number a user sees in Activity Monitor.

* fix(cli): wire darwin memory percent into computeMemoryPercent

Add a platform() === 'darwin' branch that shells out to vm_stat
(1s timeout, guarded by try/catch) and feeds its output to
readDarwinMemoryUsedPercent. On any failure or undefined result it
falls through to the existing total - freemem() fallback, matching
the Linux branch's structure.

This fixes the Machine capacity tooltip showing a stuck ~99% "High
pressure" warning on macOS runners: os.freemem() there counts
reclaimable file cache as used, so it reports near-total usage.
Summing only App Memory + Wired + Compressed reports the same figure
Activity Monitor shows.
This commit is contained in:
Junmo Kim
2026-07-11 10:40:06 +08:00
committed by GitHub
parent e45fde51e9
commit 43e7b6bef7
2 changed files with 178 additions and 1 deletions
+107 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { collectMachineHealth, readLinuxMemoryUsedPercent, resetMachineHealthSamplerForTests } from './machineHealth' import {
collectMachineHealth,
readDarwinMemoryUsedPercent,
readLinuxMemoryUsedPercent,
resetMachineHealthSamplerForTests
} from './machineHealth'
describe('readLinuxMemoryUsedPercent', () => { describe('readLinuxMemoryUsedPercent', () => {
it('uses MemAvailable, not MemFree, so page cache does not read as pressure', () => { it('uses MemAvailable, not MemFree, so page cache does not read as pressure', () => {
@@ -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', () => { describe('collectMachineHealth', () => {
it('returns schema-valid health with memory, uptime, and cpu count', () => { it('returns schema-valid health with memory, uptime, and cpu count', () => {
resetMachineHealthSamplerForTests() resetMachineHealthSamplerForTests()
+71
View File
@@ -1,3 +1,4 @@
import { execSync } from 'node:child_process'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { availableParallelism, cpus, freemem, loadavg, platform, totalmem, uptime } from 'node:os' import { availableParallelism, cpus, freemem, loadavg, platform, totalmem, uptime } from 'node:os'
import type { MachineHealth } from '@hapi/protocol/types' import type { MachineHealth } from '@hapi/protocol/types'
@@ -72,6 +73,64 @@ export function readLinuxMemoryUsedPercent(meminfo: string): number | undefined
return Math.max(0, Math.min(100, Math.round(((total - approxAvailable) / total) * 100))) 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 { function computeMemoryPercent(): number | undefined {
if (platform() === 'linux') { if (platform() === 'linux') {
try { try {
@@ -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() const total = totalmem()
if (total <= 0) { if (total <= 0) {
return undefined return undefined