fix(engine): respect cgroup memory limits in low-memory detection (#1373)

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
This commit is contained in:
Miguel Ángel
2026-06-12 12:21:43 -04:00
committed by GitHub
parent c609850b41
commit 2ce5b421f1
5 changed files with 449 additions and 17 deletions
@@ -5,7 +5,7 @@
* Auto-detects optimal worker count based on CPU/memory.
*/
import { cpus, freemem, totalmem } from "os";
import { cpus, freemem } from "os";
import { existsSync, mkdirSync, readdirSync } from "fs";
import { copyFile, rename } from "fs/promises";
import { join } from "path";
@@ -26,6 +26,7 @@ import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { assertSwiftShader } from "../utils/assertSwiftShader.js";
import { readWebGlVendorInfoFromCanvas } from "../utils/readWebGlVendorInfoFromCanvas.js";
import { resolveHeadlessShellPath } from "./browserManager.js";
import { getSystemTotalMb } from "./systemMemory.js";
export interface WorkerTask {
workerId: number;
@@ -153,7 +154,7 @@ export function calculateOptimalWorkers(
// Use total memory instead of free memory — macOS reports misleadingly low
// freemem() because it aggressively caches files in "inactive" memory that
// is immediately reclaimable.
const totalMemoryMB = Math.round(totalmem() / (1024 * 1024));
const totalMemoryMB = getSystemTotalMb();
const memoryBasedWorkers = Math.max(1, Math.floor((totalMemoryMB * 0.5) / MEMORY_PER_WORKER_MB));
const frameBasedWorkers = Math.floor(totalFrames / MIN_FRAMES_PER_WORKER);
@@ -429,7 +430,7 @@ export function getSystemResources(): {
} {
return {
cpuCores: cpus().length,
totalMemoryMB: Math.round(totalmem() / (1024 * 1024)),
totalMemoryMB: getSystemTotalMb(),
freeMemoryMB: Math.round(freemem() / (1024 * 1024)),
recommendedWorkers: calculateOptimalWorkers(1000),
};
@@ -1,5 +1,95 @@
import { describe, it, expect } from "vitest";
import { isLowMemorySystem, LOW_MEMORY_TOTAL_MB_THRESHOLD } from "./systemMemory.js";
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
import {
_resetCgroupLimitCacheForTests,
isLowMemorySystem,
LOW_MEMORY_TOTAL_MB_THRESHOLD,
parseCgroupLimitMb,
} from "./systemMemory.js";
const BYTES_PER_MIB = 1024 * 1024;
const CGROUP_V2_MEMORY_MAX_PATH = "/sys/fs/cgroup/memory.max";
const CGROUP_V1_MEMORY_LIMIT_PATH = "/sys/fs/cgroup/memory/memory.limit_in_bytes";
type SystemMemoryModule = typeof import("./systemMemory.js");
type MockSystemMemoryOptions = {
files?: Record<string, string>;
hostTotalMb?: number;
platform?: NodeJS.Platform;
readErrors?: Record<string, NodeJS.ErrnoException>;
onRead?: (path: string) => void;
throwOnFileRead?: boolean;
};
function stubPlatform(platform: NodeJS.Platform): () => void {
const descriptor = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: platform });
return () => {
if (descriptor) {
Object.defineProperty(process, "platform", descriptor);
}
};
}
async function withSystemMemoryMocks(
options: MockSystemMemoryOptions,
run: (systemMemory: SystemMemoryModule) => void | Promise<void>,
): Promise<void> {
const {
files = {},
hostTotalMb = 32768,
platform = "linux",
readErrors = {},
onRead,
throwOnFileRead = false,
} = options;
const restorePlatform = stubPlatform(platform);
vi.resetModules();
vi.doMock("os", () => ({
totalmem: () => hostTotalMb * BYTES_PER_MIB,
}));
vi.doMock("fs", () => ({
readFileSync: (path: string) => {
onRead?.(path);
if (throwOnFileRead) {
throw new Error(`/sys read should not happen: ${path}`);
}
if (path in readErrors) {
throw readErrors[path];
}
if (path in files) {
return files[path];
}
throw Object.assign(new Error("missing cgroup file"), { code: "ENOENT" });
},
}));
try {
const systemMemory = await import("./systemMemory.js");
systemMemory._resetCgroupLimitCacheForTests();
await run(systemMemory);
} finally {
vi.doUnmock("fs");
vi.doUnmock("os");
vi.resetModules();
restorePlatform();
}
}
beforeEach(() => {
_resetCgroupLimitCacheForTests();
});
afterEach(() => {
_resetCgroupLimitCacheForTests();
vi.restoreAllMocks();
});
describe("isLowMemorySystem", () => {
it("treats sub-threshold RAM as low-memory", () => {
@@ -20,4 +110,215 @@ describe("isLowMemorySystem", () => {
expect(isLowMemorySystem(16384)).toBe(false);
expect(isLowMemorySystem(65536)).toBe(false);
});
it("treats a 4 GiB cgroup v2 limit on a 32 GiB host as low-memory", async () => {
await withSystemMemoryMocks(
{
files: {
[CGROUP_V2_MEMORY_MAX_PATH]: `${4096 * BYTES_PER_MIB}`,
},
},
({ getSystemTotalMb, isLowMemorySystem }) => {
expect(getSystemTotalMb()).toBe(4096);
expect(isLowMemorySystem()).toBe(true);
},
);
});
});
describe("parseCgroupLimitMb", () => {
it("parses cgroup v2 numeric limits", () => {
expect(parseCgroupLimitMb(`${4096 * BYTES_PER_MIB}`, null)).toBe(4096);
});
it('ignores cgroup v2 "max" limits', () => {
expect(parseCgroupLimitMb("max", null)).toBeNull();
});
it("parses cgroup v1 numeric limits and ignores no-limit sentinels", () => {
expect(parseCgroupLimitMb(null, `${6144 * BYTES_PER_MIB}`)).toBe(6144);
expect(parseCgroupLimitMb(null, "9223372036854771712")).toBeNull();
});
it("ignores absent and malformed limits", () => {
expect(parseCgroupLimitMb(null, null)).toBeNull();
for (const content of ["", "garbage", "-1", "0"]) {
expect(parseCgroupLimitMb(content, null)).toBeNull();
expect(parseCgroupLimitMb(null, content)).toBeNull();
}
});
it("uses cgroup v2 when both v2 and v1 contents are present", () => {
expect(parseCgroupLimitMb(`${4096 * BYTES_PER_MIB}`, `${2048 * BYTES_PER_MIB}`)).toBe(4096);
});
});
describe("getSystemTotalMb", () => {
it("caches cgroup probes until the test reset hook clears the cache", async () => {
const readCalls: string[] = [];
const files = {
[CGROUP_V2_MEMORY_MAX_PATH]: `${4096 * BYTES_PER_MIB}`,
};
await withSystemMemoryMocks(
{
files,
onRead: (path) => readCalls.push(path),
},
({ _resetCgroupLimitCacheForTests, getSystemTotalMb }) => {
expect(getSystemTotalMb()).toBe(4096);
expect(getSystemTotalMb()).toBe(4096);
expect(readCalls).toEqual([CGROUP_V2_MEMORY_MAX_PATH]);
files[CGROUP_V2_MEMORY_MAX_PATH] = `${2048 * BYTES_PER_MIB}`;
_resetCgroupLimitCacheForTests();
expect(getSystemTotalMb()).toBe(2048);
expect(readCalls).toEqual([CGROUP_V2_MEMORY_MAX_PATH, CGROUP_V2_MEMORY_MAX_PATH]);
},
);
});
it('uses the host total when cgroup v2 reports "max"', async () => {
await withSystemMemoryMocks(
{
files: {
[CGROUP_V2_MEMORY_MAX_PATH]: "max",
},
},
({ getSystemTotalMb, isLowMemorySystem }) => {
expect(getSystemTotalMb()).toBe(32768);
expect(isLowMemorySystem()).toBe(false);
},
);
});
it("honors cgroup v1 numeric limits when cgroup v2 is absent", async () => {
await withSystemMemoryMocks(
{
files: {
[CGROUP_V1_MEMORY_LIMIT_PATH]: `${6144 * BYTES_PER_MIB}`,
},
},
({ getSystemTotalMb, isLowMemorySystem }) => {
expect(getSystemTotalMb()).toBe(6144);
expect(isLowMemorySystem()).toBe(true);
},
);
});
it("uses the host total when cgroup v1 reports a no-limit sentinel", async () => {
await withSystemMemoryMocks(
{
files: {
[CGROUP_V1_MEMORY_LIMIT_PATH]: "9223372036854771712",
},
},
({ getSystemTotalMb, isLowMemorySystem }) => {
expect(getSystemTotalMb()).toBe(32768);
expect(isLowMemorySystem()).toBe(false);
},
);
});
it("uses the host total when cgroup files are absent", async () => {
await withSystemMemoryMocks({}, ({ getSystemTotalMb, isLowMemorySystem }) => {
expect(getSystemTotalMb()).toBe(32768);
expect(isLowMemorySystem()).toBe(false);
});
});
it("warns once and uses the host total when a cgroup file is unreadable", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await withSystemMemoryMocks(
{
readErrors: {
[CGROUP_V2_MEMORY_MAX_PATH]: Object.assign(new Error("permission denied"), {
code: "EACCES",
}),
},
},
({ getSystemTotalMb, isLowMemorySystem }) => {
expect(getSystemTotalMb()).toBe(32768);
expect(getSystemTotalMb()).toBe(32768);
expect(isLowMemorySystem()).toBe(false);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0]?.[0]).toContain(
"[SystemMemory] Unable to read cgroup memory limit",
);
expect(warn.mock.calls[0]?.[0]).toContain("EACCES");
},
);
});
it("stays silent when cgroup files are absent", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await withSystemMemoryMocks({}, ({ getSystemTotalMb }) => {
expect(getSystemTotalMb()).toBe(32768);
expect(getSystemTotalMb()).toBe(32768);
expect(warn).not.toHaveBeenCalled();
});
});
it.each(["", "garbage", "-1", "0"])(
"uses the host total for malformed cgroup v2 content %j",
async (content) => {
await withSystemMemoryMocks(
{
files: {
[CGROUP_V2_MEMORY_MAX_PATH]: content,
},
},
({ getSystemTotalMb, isLowMemorySystem }) => {
expect(getSystemTotalMb()).toBe(32768);
expect(isLowMemorySystem()).toBe(false);
},
);
},
);
it.each(["", "garbage", "-1", "0"])(
"uses the host total for malformed cgroup v1 content %j",
async (content) => {
await withSystemMemoryMocks(
{
files: {
[CGROUP_V1_MEMORY_LIMIT_PATH]: content,
},
},
({ getSystemTotalMb, isLowMemorySystem }) => {
expect(getSystemTotalMb()).toBe(32768);
expect(isLowMemorySystem()).toBe(false);
},
);
},
);
it("uses the host total when a cgroup limit is larger than host RAM", async () => {
await withSystemMemoryMocks(
{
files: {
[CGROUP_V2_MEMORY_MAX_PATH]: `${65536 * BYTES_PER_MIB}`,
},
},
({ getSystemTotalMb, isLowMemorySystem }) => {
expect(getSystemTotalMb()).toBe(32768);
expect(isLowMemorySystem()).toBe(false);
},
);
});
it("does not read cgroup files on non-Linux platforms", async () => {
await withSystemMemoryMocks(
{
platform: "darwin",
throwOnFileRead: true,
},
({ getSystemTotalMb, isLowMemorySystem }) => {
expect(getSystemTotalMb()).toBe(32768);
expect(isLowMemorySystem()).toBe(false);
},
);
});
});
+137 -9
View File
@@ -8,11 +8,140 @@
* it lives here once instead of being re-derived inline.
*/
import { readFileSync } from "fs";
import { totalmem } from "os";
const BYTES_PER_MIB = 1024 * 1024;
const BYTES_PER_MIB_BIGINT = BigInt(BYTES_PER_MIB);
// These are the paths as seen from INSIDE a container, where the runtime
// mounts the container's own cgroup at the namespace root — the case this
// probe exists for. They are deliberately not resolved via /proc/self/cgroup:
// on a bare host under systemd the process's real limit may live in a nested
// slice (e.g. /sys/fs/cgroup/user.slice/.../memory.max) that these root paths
// don't see, and that's acceptable — bare hosts are covered by total-RAM
// detection, and chasing nested slices adds fragility for no container gain.
const CGROUP_V2_MEMORY_MAX_PATH = "/sys/fs/cgroup/memory.max";
const CGROUP_V1_MEMORY_LIMIT_PATH = "/sys/fs/cgroup/memory/memory.limit_in_bytes";
// Kernel no-limit sentinel is page-rounded 2^63-1 (~9223372036854771712); >= 2^60 is implausible as a real limit.
const CGROUP_V1_NO_LIMIT_CUTOFF_BYTES = 2n ** 60n;
let _cachedCgroupLimitMb: number | null | undefined;
let _warnedCgroupReadFailure = false;
/** Parse cgroup v2/v1 memory limits from sysfs file contents into MiB. */
export function parseCgroupLimitMb(
v2Content: string | null,
v1Content: string | null,
): number | null {
if (v2Content !== null) {
return parseCgroupV2LimitMb(v2Content);
}
return parseCgroupV1LimitMb(v1Content);
}
function parseCgroupV2LimitMb(content: string): number | null {
const trimmed = content.trim();
if (trimmed === "max") {
return null;
}
return parsePositiveByteLimitMb(trimmed);
}
function parseCgroupV1LimitMb(content: string | null): number | null {
if (content === null) {
return null;
}
return parsePositiveByteLimitMb(content.trim(), CGROUP_V1_NO_LIMIT_CUTOFF_BYTES);
}
function parsePositiveByteLimitMb(content: string, noLimitCutoffBytes?: bigint): number | null {
if (!/^\d+$/.test(content)) {
return null;
}
const bytes = BigInt(content);
if (bytes <= 0n) {
return null;
}
if (noLimitCutoffBytes !== undefined && bytes >= noLimitCutoffBytes) {
return null;
}
return Number(bytes / BYTES_PER_MIB_BIGINT);
}
/** Test-only: reset the cached cgroup memory probe. */
export function _resetCgroupLimitCacheForTests(): void {
_cachedCgroupLimitMb = undefined;
_warnedCgroupReadFailure = false;
}
function getCgroupLimitMb(): number | null {
if (_cachedCgroupLimitMb !== undefined) return _cachedCgroupLimitMb;
if (process.platform !== "linux") {
_cachedCgroupLimitMb = null;
return null;
}
const v2Content = readCgroupFile(CGROUP_V2_MEMORY_MAX_PATH);
const v1Content = v2Content === null ? readCgroupFile(CGROUP_V1_MEMORY_LIMIT_PATH) : null;
_cachedCgroupLimitMb = parseCgroupLimitMb(v2Content, v1Content);
if (_cachedCgroupLimitMb !== null) {
console.info(
`[SystemMemory] cgroup memory limit detected: ${_cachedCgroupLimitMb} MiB — ` +
`it governs memory-adaptive render behaviour instead of host RAM.`,
);
}
return _cachedCgroupLimitMb;
}
function readCgroupFile(path: string): string | null {
try {
return readFileSync(path, "utf8");
} catch (error) {
const code = getErrorCode(error);
if (code !== "ENOENT" && code !== "ENOTDIR") {
warnCgroupReadFailure(path, error);
}
return null;
}
}
function getErrorCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null || !("code" in error)) {
return undefined;
}
return typeof error.code === "string" ? error.code : undefined;
}
function formatCgroupReadError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
const code = getErrorCode(error);
return code ? `${code}: ${message}` : message;
}
function warnCgroupReadFailure(path: string, error: unknown): void {
if (_warnedCgroupReadFailure) return;
_warnedCgroupReadFailure = true;
console.warn(
`[SystemMemory] Unable to read cgroup memory limit at ${path} ` +
`(${formatCgroupReadError(error)}); falling back to host RAM.`,
);
}
/** Total physical RAM in MiB. */
export function getSystemTotalMb(): number {
return Math.floor(totalmem() / (1024 * 1024));
const hostTotalMb = Math.floor(totalmem() / BYTES_PER_MIB);
const cgroupLimitMb = getCgroupLimitMb();
return cgroupLimitMb === null ? hostTotalMb : Math.min(hostTotalMb, cgroupLimitMb);
}
/**
@@ -38,14 +167,13 @@ export const LOW_MEMORY_TOTAL_MB_THRESHOLD = 8192;
* survive". Accepts an explicit `totalMb` so callers (and tests) can pass
* a known value instead of re-probing.
*
* Caveat: `os.totalmem()` reports the *host's* physical RAM, not a
* cgroup/container memory limit. A 4 GB container on a 32 GB host will not
* auto-flag as low-memory, and an 8 GB container on a 64 GB host won't
* either. Containerised and serverless callers (Docker `--docker` renders,
* Lambda) that want a specific profile should set `PRODUCER_LOW_MEMORY_MODE`
* explicitly rather than relying on auto-detection. Hosts whose *total* RAM
* is genuinely <= the threshold (laptops, small VMs, small Lambda tiers) are
* detected correctly regardless of container nesting.
* Caveat: Linux cgroup v1/v2 memory limits are consulted when readable, so
* Docker and serverless runtimes, including Lambda tiers with readable cgroup
* ceilings, inherit the tighter container limit instead of the host's physical
* RAM. Environments that hide cgroup files should set
* `PRODUCER_LOW_MEMORY_MODE` explicitly rather than relying on auto-detection.
* Hosts whose *effective* total RAM is genuinely <= the threshold (laptops,
* small VMs, small Lambda tiers, small containers) are detected correctly.
*/
export function isLowMemorySystem(totalMb: number = getSystemTotalMb()): boolean {
return totalMb <= LOW_MEMORY_TOTAL_MB_THRESHOLD;