From 9113b138f1dfe6b2369cc6f2eaa3a507ba5bdf21 Mon Sep 17 00:00:00 2001 From: Jarrod Watts Date: Sat, 4 Apr 2026 14:29:25 +1100 Subject: [PATCH] fix: use vm_stat for accurate macOS memory percentage --- src/memory.ts | 44 +++++++++++++++++++++++++++++++++----- tests/memory.test.js | 51 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/memory.ts b/src/memory.ts index af322e7..c5827af 100644 --- a/src/memory.ts +++ b/src/memory.ts @@ -1,13 +1,50 @@ import os from 'node:os'; +import { execFileSync } from 'node:child_process'; import type { MemoryInfo } from './types.js'; type MemoryReader = () => { totalBytes: number; freeBytes: number }; -let readMemory: MemoryReader = () => ({ +export function parseVmStat( + output: string, +): { pageSize: number; active: number; wired: number } | null { + const pageSizeMatch = output.match(/page size of (\d+) bytes/); + if (!pageSizeMatch) return null; + + const activeMatch = output.match(/Pages active:\s+(\d+)/); + const wiredMatch = output.match(/Pages wired down:\s+(\d+)/); + if (!activeMatch || !wiredMatch) return null; + + return { + pageSize: Number(pageSizeMatch[1]), + active: Number(activeMatch[1]), + wired: Number(wiredMatch[1]), + }; +} + +const readDefaultMemory: MemoryReader = () => ({ totalBytes: os.totalmem(), freeBytes: os.freemem(), }); +const readMacOSMemory: MemoryReader = () => { + try { + const output = execFileSync('/usr/bin/vm_stat', { + encoding: 'utf8', + timeout: 5000, + }); + const parsed = parseVmStat(output); + if (!parsed) return readDefaultMemory(); + const totalBytes = os.totalmem(); + const usedBytes = (parsed.active + parsed.wired) * parsed.pageSize; + return { totalBytes, freeBytes: totalBytes - usedBytes }; + } catch { + return readDefaultMemory(); + } +}; + +let readMemory: MemoryReader = + process.platform === 'darwin' ? readMacOSMemory : readDefaultMemory; + export async function getMemoryUsage(): Promise { try { const { totalBytes, freeBytes } = readMemory(); @@ -51,8 +88,5 @@ export function formatBytes(bytes: number): string { } export function _setMemoryReaderForTests(reader: MemoryReader | null): void { - readMemory = reader ?? (() => ({ - totalBytes: os.totalmem(), - freeBytes: os.freemem(), - })); + readMemory = reader ?? (process.platform === 'darwin' ? readMacOSMemory : readDefaultMemory); } diff --git a/tests/memory.test.js b/tests/memory.test.js index 6d5f60b..5e70871 100644 --- a/tests/memory.test.js +++ b/tests/memory.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { _setMemoryReaderForTests, formatBytes, getMemoryUsage } from '../dist/memory.js'; +import { _setMemoryReaderForTests, formatBytes, getMemoryUsage, parseVmStat } from '../dist/memory.js'; test('getMemoryUsage returns coarse system RAM usage with clamped values', async () => { _setMemoryReaderForTests(() => ({ @@ -28,6 +28,55 @@ test('getMemoryUsage returns null when memory lookup fails', async () => { assert.equal(memoryUsage, null); }); +test('parseVmStat parses vm_stat output correctly', () => { + const output = `Mach Virtual Memory Statistics: (page size of 16384 bytes) +Pages free: 2588951. +Pages active: 253775. +Pages inactive: 246498. +Pages speculative: 35498. +Pages throttled: 0. +Pages wired down: 195488. +Pages purgeable: 14994. +"Translation faults": 894498389. +Pages copy-on-write: 26696671. +Pages zero filled: 416498538. +Pages reactivated: 13299498. +Pages purged: 5765498. +File-backed pages: 156498. +Anonymous pages: 343775. +Pages stored in compressor: 498775. +Pages occupied by compressor: 115488. +Decompressions: 42498775. +Compressions: 58498775. +Pageins: 84498775. +Pageouts: 1498775. +Swapins: 0. +Swapouts: 0.`; + const result = parseVmStat(output); + assert.deepEqual(result, { pageSize: 16384, active: 253775, wired: 195488 }); +}); + +test('parseVmStat returns null for empty string', () => { + assert.equal(parseVmStat(''), null); +}); + +test('parseVmStat returns null for malformed output', () => { + assert.equal(parseVmStat('not valid vm_stat output'), null); +}); + +test('macOS memory calculation: 16GB total, active+wired via vm_stat → 43%', async () => { + const totalBytes = 16 * 1024 ** 3; + const usedBytes = (253775 + 195488) * 16384; + _setMemoryReaderForTests(() => ({ + totalBytes, + freeBytes: totalBytes - usedBytes, + })); + + const memoryUsage = await getMemoryUsage(); + assert.equal(memoryUsage.usedPercent, 43); + assert.equal(memoryUsage.usedBytes, usedBytes); +}); + test('formatBytes formats human-readable units for memory line display', () => { assert.equal(formatBytes(0), '0 B'); assert.equal(formatBytes(512), '512 B');