mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-24 22:10:59 +00:00
* 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.
214 lines
7.2 KiB
TypeScript
214 lines
7.2 KiB
TypeScript
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'
|
|
import { MachineHealthSchema } from '@hapi/protocol/schemas'
|
|
|
|
type CpuTimesSnapshot = {
|
|
idle: number
|
|
total: number
|
|
}
|
|
|
|
let previousCpuSnapshot: CpuTimesSnapshot | null = null
|
|
|
|
function sumCpuTimes(): CpuTimesSnapshot | null {
|
|
const cores = cpus()
|
|
if (cores.length === 0) {
|
|
return null
|
|
}
|
|
|
|
let idle = 0
|
|
let total = 0
|
|
for (const core of cores) {
|
|
const times = core.times
|
|
idle += times.idle
|
|
total += times.user + times.nice + times.sys + times.idle + times.irq
|
|
}
|
|
|
|
return { idle, total }
|
|
}
|
|
|
|
function computeCpuPercent(current: CpuTimesSnapshot, previous: CpuTimesSnapshot): number | undefined {
|
|
const idleDelta = current.idle - previous.idle
|
|
const totalDelta = current.total - previous.total
|
|
if (totalDelta <= 0) {
|
|
return undefined
|
|
}
|
|
|
|
const usage = 1 - idleDelta / totalDelta
|
|
return Math.max(0, Math.min(100, Math.round(usage * 100)))
|
|
}
|
|
|
|
function parseMeminfoKbValue(meminfo: string, key: string): number | undefined {
|
|
for (const line of meminfo.split('\n')) {
|
|
if (!line.startsWith(`${key}:`)) {
|
|
continue
|
|
}
|
|
const kb = Number(line.split(/\s+/)[1])
|
|
return Number.isFinite(kb) ? kb * 1024 : undefined
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/** Linux pressure percent: (MemTotal - MemAvailable) / MemTotal. Testable without /proc. */
|
|
export function readLinuxMemoryUsedPercent(meminfo: string): number | undefined {
|
|
const total = parseMeminfoKbValue(meminfo, 'MemTotal')
|
|
if (!total || total <= 0) {
|
|
return undefined
|
|
}
|
|
|
|
const available = parseMeminfoKbValue(meminfo, 'MemAvailable')
|
|
if (available !== undefined) {
|
|
return Math.max(0, Math.min(100, Math.round(((total - available) / total) * 100)))
|
|
}
|
|
|
|
// Pre-3.14 kernels: approximate available as free + reclaimable cache.
|
|
const free = parseMeminfoKbValue(meminfo, 'MemFree')
|
|
if (free === undefined) {
|
|
return undefined
|
|
}
|
|
const buffers = parseMeminfoKbValue(meminfo, 'Buffers') ?? 0
|
|
const cached = parseMeminfoKbValue(meminfo, 'Cached') ?? 0
|
|
const approxAvailable = free + buffers + cached
|
|
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 {
|
|
const fromProc = readLinuxMemoryUsedPercent(readFileSync('/proc/meminfo', 'utf8'))
|
|
if (fromProc !== undefined) {
|
|
return fromProc
|
|
}
|
|
} catch {
|
|
// fall through to os.freemem()
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
const used = total - freemem()
|
|
return Math.max(0, Math.min(100, Math.round((used / total) * 100)))
|
|
}
|
|
|
|
function isUnixLikeLoadPlatform(): boolean {
|
|
return platform() !== 'win32'
|
|
}
|
|
|
|
function computeUptimeSeconds(): number | undefined {
|
|
const seconds = uptime()
|
|
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
return undefined
|
|
}
|
|
return Math.floor(seconds)
|
|
}
|
|
|
|
export function collectMachineHealth(now: number = Date.now()): MachineHealth {
|
|
const cpuCount = availableParallelism()
|
|
const memoryPercent = computeMemoryPercent()
|
|
const uptimeSeconds = computeUptimeSeconds()
|
|
const load1m = isUnixLikeLoadPlatform() ? loadavg()[0] : undefined
|
|
|
|
const cpuSnapshot = sumCpuTimes()
|
|
let cpuPercent: number | undefined
|
|
if (cpuSnapshot && previousCpuSnapshot) {
|
|
cpuPercent = computeCpuPercent(cpuSnapshot, previousCpuSnapshot)
|
|
}
|
|
if (cpuSnapshot) {
|
|
previousCpuSnapshot = cpuSnapshot
|
|
}
|
|
|
|
const health = {
|
|
collectedAt: now,
|
|
cpuCount,
|
|
...(load1m !== undefined ? { load1m } : {}),
|
|
...(cpuPercent !== undefined ? { cpuPercent } : {}),
|
|
...(memoryPercent !== undefined ? { memoryPercent } : {}),
|
|
...(uptimeSeconds !== undefined ? { uptimeSeconds } : {})
|
|
}
|
|
|
|
const parsed = MachineHealthSchema.safeParse(health)
|
|
if (!parsed.success) {
|
|
return { collectedAt: now }
|
|
}
|
|
return parsed.data
|
|
}
|
|
|
|
/** Test helper */
|
|
export function resetMachineHealthSamplerForTests(): void {
|
|
previousCpuSnapshot = null
|
|
}
|