mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user