Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/cli/src/telemetry/system.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { cpus, totalmem, freemem, platform, release } from "node:os";
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,
Expand Down Expand Up @@ -67,7 +68,7 @@ export function getSystemMeta(): SystemMeta {
cpu_count: cpuInfo.length,
cpu_model: firstCpu?.model?.trim() ?? null,
cpu_speed: firstCpu?.speed ?? null,
memory_total_mb: bytesToMb(totalmem()),
memory_total_mb: getSystemTotalMb(),
is_docker: detectDocker(),
is_ci: detectCI(),
ci_name: getCIName(),
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"paths": {
"@hyperframes/producer": ["../producer/src/index.ts"],
"@hyperframes/producer/distributed": ["../producer/src/distributed.ts"],
"@hyperframes/aws-lambda/sdk": ["../aws-lambda/src/sdk/index.ts"]
"@hyperframes/aws-lambda/sdk": ["../aws-lambda/src/sdk/index.ts"],
"@hyperframes/gcp-cloud-run/sdk": ["../gcp-cloud-run/src/sdk/index.ts"]
},
"strict": true,
"noUncheckedIndexedAccess": true,
Expand Down
7 changes: 4 additions & 3 deletions packages/engine/src/services/parallelCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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),
};
Expand Down
305 changes: 303 additions & 2 deletions packages/engine/src/services/systemMemory.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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);
},
);
});
});
Loading
Loading