mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 13:27:13 +00:00
getSystemTotalMb returned os.totalmem() — the host's physical RAM — so a 4GB Docker container on a 32GB host never auto-flagged as low-memory and the low-memory render profile didn't activate exactly where it's needed most. Read the cgroup v2 limit (/sys/fs/cgroup/memory.max, with the v1 fallback and its no-limit sentinel handled) and use min(host, cgroup). The probe is best-effort and non-Linux platforms never touch /sys. Review follow-ups: worker sizing (calculateOptimalWorkers) and the getSystemResources diagnostics previously read os.totalmem() directly and now use getSystemTotalMb(), so container limits actually govern parallel spawn decisions; CLI telemetry reports the effective total as well. The cgroup probe result is cached for the process lifetime (the limit is immutable per process) with a test reset hook; a detected limit logs once so operators can see which source governs, and a present-but-unreadable cgroup file warns once instead of failing silently — absence stays silent. The root-path-vs-/proc/self/cgroup trade-off is documented at the path constants. cli/tsconfig.json gains the gcp-cloud-run/sdk source alias (matching the existing producer and aws-lambda entries) so the cli typecheck resolves from source in a fresh checkout. Refs #1193, #1194, #1195, #1236
214 lines
6.9 KiB
TypeScript
214 lines
6.9 KiB
TypeScript
import { cpus, freemem, platform, release } from "node:os";
|
|
import { existsSync, readFileSync, statfsSync } from "node:fs";
|
|
import { execSync } from "node:child_process";
|
|
import { getSystemTotalMb } from "@hyperframes/engine";
|
|
import {
|
|
detectAgentRuntime,
|
|
detectSandboxRuntime,
|
|
type AgentRuntime,
|
|
type SandboxRuntime,
|
|
} from "./agent_runtime.js";
|
|
import { detectWSL } from "./platform.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// System metadata collected once per CLI session and attached to all events.
|
|
// Follows the same patterns as Next.js, Turborepo, and Gatsby telemetry.
|
|
// No PII — only hardware/environment characteristics useful for debugging.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Convert bytes to whole megabytes. */
|
|
export function bytesToMb(bytes: number): number {
|
|
return Math.trunc(bytes / (1024 * 1024));
|
|
}
|
|
|
|
export interface SystemMeta {
|
|
os_release: string;
|
|
cpu_count: number;
|
|
cpu_model: string | null;
|
|
cpu_speed: number | null;
|
|
memory_total_mb: number;
|
|
is_docker: boolean;
|
|
is_ci: boolean;
|
|
ci_name: string | null;
|
|
is_wsl: boolean;
|
|
is_tty: boolean;
|
|
/**
|
|
* Managed sandbox runtime hosting this invocation, when one is detectable
|
|
* (gvisor / firecracker / docker / kvm / wsl). null on a normal dev
|
|
* machine. Lets us distinguish "real laptop" from "ephemeral cloud
|
|
* sandbox driving the CLI" without geo guesswork.
|
|
*/
|
|
sandbox_runtime: SandboxRuntime;
|
|
/**
|
|
* Coding-agent vendor that spawned this process, if any (see the
|
|
* `AgentRuntime` union in agent_runtime.ts for the full, current set).
|
|
* Most rules check env-var existence only — values are never read; a few
|
|
* use filesystem/kernel markers (e.g. the Gemini managed-agent mount).
|
|
* Every rule keys on a marker with a source citation in agent_runtime.ts;
|
|
* unverified guesses are deliberately omitted (false-negative > guess).
|
|
* null when no agent is detected.
|
|
*/
|
|
agent_runtime: AgentRuntime;
|
|
}
|
|
|
|
let cached: SystemMeta | null = null;
|
|
|
|
/**
|
|
* Collect system metadata. Cached after first call.
|
|
* Only includes static values — use `freemem()` directly for volatile readings.
|
|
*/
|
|
export function getSystemMeta(): SystemMeta {
|
|
if (cached) return cached;
|
|
|
|
const cpuInfo = cpus();
|
|
const firstCpu = cpuInfo[0] ?? null;
|
|
|
|
cached = {
|
|
os_release: release(),
|
|
cpu_count: cpuInfo.length,
|
|
cpu_model: firstCpu?.model?.trim() ?? null,
|
|
cpu_speed: firstCpu?.speed ?? null,
|
|
memory_total_mb: getSystemTotalMb(),
|
|
is_docker: detectDocker(),
|
|
is_ci: detectCI(),
|
|
ci_name: getCIName(),
|
|
is_wsl: detectWSL(),
|
|
is_tty: Boolean(process.stdout?.isTTY),
|
|
sandbox_runtime: detectSandboxRuntime(),
|
|
agent_runtime: detectAgentRuntime(),
|
|
};
|
|
return cached;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Environment detectors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function detectDocker(): boolean {
|
|
// Standard detection: /.dockerenv file or "docker" in /proc/1/cgroup
|
|
try {
|
|
if (existsSync("/.dockerenv")) return true;
|
|
if (platform() === "linux") {
|
|
const cgroup = readFileSync("/proc/1/cgroup", "utf-8");
|
|
if (cgroup.includes("docker") || cgroup.includes("containerd")) return true;
|
|
}
|
|
} catch {
|
|
// Ignore — not in Docker
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Named providers come first so getCIName() picks the most specific match.
|
|
// `truthy` accepts 'true' or '1'; `presence` matches any non-null value.
|
|
type CIProvider =
|
|
| { name: string | null; envVar: string; mode: "truthy" }
|
|
| { name: string | null; envVar: string; mode: "presence" };
|
|
|
|
const CI_PROVIDERS: CIProvider[] = [
|
|
{ name: "github_actions", envVar: "GITHUB_ACTIONS", mode: "truthy" },
|
|
{ name: "gitlab_ci", envVar: "GITLAB_CI", mode: "truthy" },
|
|
{ name: "circleci", envVar: "CIRCLECI", mode: "truthy" },
|
|
{ name: "jenkins", envVar: "JENKINS_URL", mode: "presence" },
|
|
{ name: "buildkite", envVar: "BUILDKITE", mode: "truthy" },
|
|
{ name: "travis", envVar: "TRAVIS", mode: "truthy" },
|
|
{ name: null, envVar: "CONTINUOUS_INTEGRATION", mode: "truthy" },
|
|
{ name: null, envVar: "CI", mode: "truthy" },
|
|
];
|
|
|
|
function matchesProvider(p: CIProvider): boolean {
|
|
const v = process.env[p.envVar];
|
|
if (p.mode === "presence") return v != null;
|
|
return v === "true" || v === "1";
|
|
}
|
|
|
|
function detectCI(): boolean {
|
|
return CI_PROVIDERS.some(matchesProvider);
|
|
}
|
|
|
|
function getCIName(): string | null {
|
|
for (const provider of CI_PROVIDERS) {
|
|
if (provider.name && matchesProvider(provider)) return provider.name;
|
|
}
|
|
return detectCI() ? "unknown" : null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Extended hardware checks (for doctor command and detailed render events)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get /dev/shm size in MB (Linux only). Chrome uses shared memory heavily;
|
|
* Docker's default 64MB limit causes crashes.
|
|
*/
|
|
export function getShmSizeMb(): number | null {
|
|
if (platform() !== "linux") return null;
|
|
try {
|
|
const stats = statfsSync("/dev/shm");
|
|
return bytesToMb(stats.bsize * stats.blocks);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get available disk space in MB at a given path.
|
|
*/
|
|
export function getFreeDiskMb(path: string = "."): number | null {
|
|
try {
|
|
const stats = statfsSync(path);
|
|
return bytesToMb(stats.bsize * stats.bavail);
|
|
} catch {
|
|
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;
|
|
}
|