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 { 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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user