From 967950315876bfbdd792a152089c7de699acaef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 4 Jun 2026 18:23:02 -0400 Subject: [PATCH] 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. --- packages/cli/src/commands/doctor.test.ts | 28 +++++- packages/cli/src/commands/doctor.ts | 37 ++++--- packages/cli/src/telemetry/system.test.ts | 112 ++++++++++++++++++++++ packages/cli/src/telemetry/system.ts | 53 +++++++++- 4 files changed, 217 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/telemetry/system.test.ts diff --git a/packages/cli/src/commands/doctor.test.ts b/packages/cli/src/commands/doctor.test.ts index 30f0a07e9..5b087c664 100644 --- a/packages/cli/src/commands/doctor.test.ts +++ b/packages/cli/src/commands/doctor.test.ts @@ -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); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index cf84f1ea3..a79a264e2 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -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 { diff --git a/packages/cli/src/telemetry/system.test.ts b/packages/cli/src/telemetry/system.test.ts new file mode 100644 index 000000000..375ca1538 --- /dev/null +++ b/packages/cli/src/telemetry/system.test.ts @@ -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("node:os")), + platform: () => "darwin", + freemem: () => 100 * 1024 * 1024, + })); + vi.doMock("node:child_process", async () => ({ + ...(await vi.importActual("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("node:os")), + platform: () => "darwin", + freemem: () => 4 * 1024 * 1024 * 1024, + })); + vi.doMock("node:child_process", async () => ({ + ...(await vi.importActual("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("node:os")), + platform: () => "linux", + freemem: () => 100 * 1024 * 1024, + })); + vi.doMock("node:fs", async () => ({ + ...(await vi.importActual("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("node:os")), + platform: () => "linux", + freemem: () => 2 * 1024 * 1024 * 1024, + })); + vi.doMock("node:fs", async () => ({ + ...(await vi.importActual("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("node:os")), + platform: () => "win32", + freemem: () => 6 * 1024 * 1024 * 1024, + })); + + const { getAvailableMemoryMb } = await import("./system.js"); + expect(getAvailableMemoryMb()).toBe(6144); + }); +}); diff --git a/packages/cli/src/telemetry/system.ts b/packages/cli/src/telemetry/system.ts index 53660ebbe..3d54db85f 100644 --- a/packages/cli/src/telemetry/system.ts +++ b/packages/cli/src/telemetry/system.ts @@ -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; +}