mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user