diff --git a/cli/src/utils/machineHealth.test.ts b/cli/src/utils/machineHealth.test.ts index bdfb2070..b3fab5bc 100644 --- a/cli/src/utils/machineHealth.test.ts +++ b/cli/src/utils/machineHealth.test.ts @@ -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', () => { @@ -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() diff --git a/cli/src/utils/machineHealth.ts b/cli/src/utils/machineHealth.ts index 53d837e2..94c98051 100644 --- a/cli/src/utils/machineHealth.ts +++ b/cli/src/utils/machineHealth.ts @@ -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' @@ -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 { @@ -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