fix(cli): report available memory instead of free memory in doctor (#1204)

os.freemem() on macOS returns only truly free pages (~0.1 GB on a 24 GB
machine), ignoring inactive/purgeable/speculative pages the kernel
reclaims on demand. This caused a false "Low memory" warning on every
macOS machine.

Add getAvailableMemoryMb() that uses vm_stat on macOS and MemAvailable
from /proc/meminfo on Linux, falling back to os.freemem() elsewhere.

Also trim FFmpeg/FFprobe version strings to just "toolname X.Y.Z"
instead of the full copyright line.
This commit is contained in:
Miguel Ángel
2026-06-04 18:23:02 -04:00
committed by GitHub
parent 0870394d20
commit 9679503158
4 changed files with 217 additions and 13 deletions
+27 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { buildDoctorReport, redactHome, type CheckOutcome } from "./doctor.js";
import { buildDoctorReport, redactHome, parseToolVersion, type CheckOutcome } from "./doctor.js";
// ── Fixtures ────────────────────────────────────────────────────────────────
@@ -62,6 +62,32 @@ describe("redactHome", () => {
});
});
describe("parseToolVersion", () => {
it("extracts ffmpeg version from full copyright line", () => {
expect(
parseToolVersion("ffmpeg version 8.1.1 Copyright (c) 2000-2026 the FFmpeg developers"),
).toBe("ffmpeg 8.1.1");
});
it("extracts ffprobe version from full copyright line", () => {
expect(
parseToolVersion("ffprobe version 8.1.1 Copyright (c) 2007-2026 the FFmpeg developers"),
).toBe("ffprobe 8.1.1");
});
it("handles Windows gyan.dev builds with suffix", () => {
expect(
parseToolVersion(
"ffmpeg version 7.1.1-essentials_build-www.gyan.dev Copyright (c) 2000-2024",
),
).toBe("ffmpeg 7.1.1-essentials_build-www.gyan.dev");
});
it("returns trimmed input when pattern does not match", () => {
expect(parseToolVersion(" some unrecognized output ")).toBe("some unrecognized output");
});
});
describe("buildDoctorReport", () => {
it("emits the locked schema shape", () => {
const report = buildDoctorReport(OUTCOMES_ALL_OK);
+26 -11
View File
@@ -1,13 +1,18 @@
import { defineCommand } from "citty";
import { execSync } from "node:child_process";
import { freemem, platform } from "node:os";
import { platform } from "node:os";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import { findBrowser } from "../browser/manager.js";
import { findFFmpeg, getFFmpegInstallHint } from "../browser/ffmpeg.js";
import { VERSION } from "../version.js";
import { getUpdateMeta, withMeta } from "../utils/updateCheck.js";
import { getSystemMeta, getShmSizeMb, getFreeDiskMb, bytesToMb } from "../telemetry/system.js";
import {
getSystemMeta,
getShmSizeMb,
getFreeDiskMb,
getAvailableMemoryMb,
} from "../telemetry/system.js";
export const examples: Example[] = [
["Check system dependencies", "hyperframes doctor"],
@@ -25,13 +30,23 @@ interface CheckResult {
hint?: string;
}
/**
* Extract a clean "toolname X.Y.Z" from the verbose first line of
* `ffmpeg -version` / `ffprobe -version` output. Falls back to the
* trimmed input when the pattern doesn't match.
*/
export function parseToolVersion(raw: string): string {
const m = raw.match(/(ffmpeg|ffprobe)\s+version\s+([\d][\d.\-\w]*)/i);
return m ? `${m[1]} ${m[2]}` : raw.trim();
}
function checkFFmpeg(): CheckResult {
const path = findFFmpeg();
if (path) {
try {
const version =
const raw =
execSync("ffmpeg -version", { encoding: "utf-8", timeout: 5000 }).split("\n")[0] ?? "";
return { ok: true, detail: version.trim() };
return { ok: true, detail: parseToolVersion(raw) };
} catch {
return { ok: true, detail: path };
}
@@ -47,9 +62,9 @@ function checkFFprobe(): CheckResult {
// `ffprobe -version` works cross-platform if it's on PATH — no need for
// `which`/`where` shell detection, which differs by OS.
try {
const version =
const raw =
execSync("ffprobe -version", { encoding: "utf-8", timeout: 5000 }).split("\n")[0] ?? "";
return { ok: true, detail: version.trim() };
return { ok: true, detail: parseToolVersion(raw) };
} catch {
return {
ok: false,
@@ -124,18 +139,18 @@ function checkCPU(): CheckResult {
function checkMemory(): CheckResult {
const sys = getSystemMeta();
const freeMb = bytesToMb(freemem()); // fresh reading, not cached
const availMb = getAvailableMemoryMb();
const totalGb = (sys.memory_total_mb / 1024).toFixed(1);
const freeGb = (freeMb / 1024).toFixed(1);
const availGb = (availMb / 1024).toFixed(1);
if (freeMb < 2048) {
if (availMb < 2048) {
return {
ok: false,
detail: `${totalGb} GB total \u00B7 ${freeGb} GB free`,
detail: `${totalGb} GB total \u00B7 ${availGb} GB available`,
hint: "Low memory — renders may fail. Close other apps or increase RAM.",
};
}
return { ok: true, detail: `${totalGb} GB total \u00B7 ${freeGb} GB free` };
return { ok: true, detail: `${totalGb} GB total \u00B7 ${availGb} GB available` };
}
function checkShm(): CheckResult {
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("getAvailableMemoryMb", () => {
it("parses vm_stat on macOS to compute available memory", async () => {
vi.doMock("node:os", async () => ({
...(await vi.importActual<typeof import("node:os")>("node:os")),
platform: () => "darwin",
freemem: () => 100 * 1024 * 1024,
}));
vi.doMock("node:child_process", async () => ({
...(await vi.importActual<typeof import("node:child_process")>("node:child_process")),
execSync: (_cmd: string) =>
[
"Mach Virtual Memory Statistics: (page size of 16384 bytes)",
"Pages free: 5000.",
"Pages active: 200000.",
"Pages inactive: 100000.",
"Pages speculative: 2000.",
"Pages throttled: 0.",
"Pages wired down: 150000.",
"Pages purgeable: 3000.",
].join("\n"),
}));
const { getAvailableMemoryMb } = await import("./system.js");
const result = getAvailableMemoryMb();
// (5000 + 100000 + 3000 + 2000) * 16384 = 110000 * 16384 = 1,802,240,000 bytes
// 1,802,240,000 / (1024 * 1024) = ~1718 MB
expect(result).toBe(Math.trunc((110000 * 16384) / (1024 * 1024)));
});
it("falls back to freemem on macOS when vm_stat fails", async () => {
vi.doMock("node:os", async () => ({
...(await vi.importActual<typeof import("node:os")>("node:os")),
platform: () => "darwin",
freemem: () => 4 * 1024 * 1024 * 1024,
}));
vi.doMock("node:child_process", async () => ({
...(await vi.importActual<typeof import("node:child_process")>("node:child_process")),
execSync: () => {
throw new Error("vm_stat not found");
},
}));
const { getAvailableMemoryMb } = await import("./system.js");
expect(getAvailableMemoryMb()).toBe(4096);
});
it("reads MemAvailable from /proc/meminfo on Linux", async () => {
vi.doMock("node:os", async () => ({
...(await vi.importActual<typeof import("node:os")>("node:os")),
platform: () => "linux",
freemem: () => 100 * 1024 * 1024,
}));
vi.doMock("node:fs", async () => ({
...(await vi.importActual<typeof import("node:fs")>("node:fs")),
readFileSync: (path: string, _enc: string) => {
if (path === "/proc/meminfo") {
return [
"MemTotal: 16384000 kB",
"MemFree: 512000 kB",
"MemAvailable: 8192000 kB",
"Buffers: 256000 kB",
].join("\n");
}
throw new Error(`unexpected path: ${path}`);
},
}));
const { getAvailableMemoryMb } = await import("./system.js");
// 8192000 kB / 1024 = 8000 MB
expect(getAvailableMemoryMb()).toBe(8000);
});
it("falls back to freemem on Linux when /proc/meminfo is unreadable", async () => {
vi.doMock("node:os", async () => ({
...(await vi.importActual<typeof import("node:os")>("node:os")),
platform: () => "linux",
freemem: () => 2 * 1024 * 1024 * 1024,
}));
vi.doMock("node:fs", async () => ({
...(await vi.importActual<typeof import("node:fs")>("node:fs")),
readFileSync: (path: string) => {
if (path === "/proc/meminfo") throw new Error("ENOENT");
throw new Error(`unexpected path: ${path}`);
},
}));
const { getAvailableMemoryMb } = await import("./system.js");
expect(getAvailableMemoryMb()).toBe(2048);
});
it("falls back to freemem on unsupported platforms", async () => {
vi.doMock("node:os", async () => ({
...(await vi.importActual<typeof import("node:os")>("node:os")),
platform: () => "win32",
freemem: () => 6 * 1024 * 1024 * 1024,
}));
const { getAvailableMemoryMb } = await import("./system.js");
expect(getAvailableMemoryMb()).toBe(6144);
});
});
+52 -1
View File
@@ -1,5 +1,6 @@
import { cpus, totalmem, platform, release } from "node:os";
import { cpus, totalmem, freemem, platform, release } from "node:os";
import { existsSync, readFileSync, statfsSync } from "node:fs";
import { execSync } from "node:child_process";
import {
detectAgentRuntime,
detectSandboxRuntime,
@@ -158,3 +159,53 @@ export function getFreeDiskMb(path: string = "."): number | null {
return null;
}
}
/**
* Get available memory in MB, accounting for OS-level page caching.
*
* `os.freemem()` on macOS returns only truly free pages — ignoring
* inactive/purgeable/speculative pages that the kernel reclaims on demand.
* On a 24 GB Mac this reports ~0.1 GB "free" when ~5 GB is actually
* available. Linux has a similar (milder) issue; its kernel exposes the
* correct value via `MemAvailable` in /proc/meminfo.
*/
export function getAvailableMemoryMb(): number {
const fallback = bytesToMb(freemem());
if (platform() === "darwin") {
try {
const raw = execSync("vm_stat", { encoding: "utf-8", timeout: 5000 });
const pageSize = parseInt(raw.match(/page size of (\d+)/)?.[1] ?? "0", 10);
if (!pageSize) return fallback;
const pages = (key: string) =>
parseInt(raw.match(new RegExp(`${key}:\\s+(\\d+)`))?.[1] ?? "0", 10);
const available =
(pages("Pages free") +
pages("Pages inactive") +
pages("Pages purgeable") +
pages("Pages speculative")) *
pageSize;
return available > 0 ? bytesToMb(available) : fallback;
} catch {
return fallback;
}
}
if (platform() === "linux") {
try {
const meminfo = readFileSync("/proc/meminfo", "utf-8");
const match = meminfo.match(/MemAvailable:\s+(\d+)\s+kB/);
if (match) {
return Math.trunc(parseInt(match[1]!, 10) / 1024);
}
return fallback;
} catch {
return fallback;
}
}
return fallback;
}