diff --git a/scripts/lib/vitest-local-scheduling.mjs b/scripts/lib/vitest-local-scheduling.mjs index 12434c3b650..4ef453839a8 100644 --- a/scripts/lib/vitest-local-scheduling.mjs +++ b/scripts/lib/vitest-local-scheduling.mjs @@ -1,4 +1,4 @@ -/** @typedef {{ cpuCount?: number, loadAverage1m?: number, totalMemoryBytes?: number }} VitestHostInfo */ +/** @typedef {{ cpuCount?: number, loadAverage1m?: number, totalMemoryBytes?: number, freeMemoryBytes?: number }} VitestHostInfo */ /** @typedef {{ maxWorkers: number, fileParallelism: boolean, throttledBySystem: boolean }} LocalVitestScheduling */ import os from "node:os"; @@ -30,9 +30,24 @@ export function detectVitestHostInfo() { typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length, loadAverage1m: os.loadavg()[0] ?? 0, totalMemoryBytes: os.totalmem(), + freeMemoryBytes: os.freemem(), }; } +function resolveMemoryPressureWorkerLimit(system) { + const freeMemoryGb = (system.freeMemoryBytes ?? 0) / 1024 ** 3; + if (!Number.isFinite(freeMemoryGb) || freeMemoryGb <= 0) { + return null; + } + if (freeMemoryGb <= 4) { + return 1; + } + if (freeMemoryGb <= 8) { + return 2; + } + return null; +} + export function resolveLocalVitestMaxWorkers( env = process.env, system = detectVitestHostInfo(), @@ -112,6 +127,16 @@ export function resolveLocalVitestScheduling( }; } + const memoryPressureLimit = resolveMemoryPressureWorkerLimit(system); + if (memoryPressureLimit !== null && inferred > memoryPressureLimit) { + const maxWorkers = memoryPressureLimit; + return { + maxWorkers, + fileParallelism: maxWorkers > 1, + throttledBySystem: true, + }; + } + if (loadRatio >= 1) { const maxWorkers = Math.max(1, Math.floor(inferred / 2)); return { @@ -149,6 +174,22 @@ export function shouldUseLargeLocalFullSuiteProfile( } export function resolveLocalFullSuiteProfile(env = process.env, system = detectVitestHostInfo()) { + if (!isSystemThrottleDisabled(env)) { + const memoryPressureLimit = resolveMemoryPressureWorkerLimit(system); + if (memoryPressureLimit === 1) { + return { + shardParallelism: 1, + vitestMaxWorkers: 1, + }; + } + if (memoryPressureLimit === 2) { + return { + shardParallelism: 2, + vitestMaxWorkers: 1, + }; + } + } + if (shouldUseLargeLocalFullSuiteProfile(env, system)) { return { shardParallelism: LARGE_LOCAL_FULL_SUITE_PARALLELISM, diff --git a/test/scripts/vitest-local-scheduling.test.ts b/test/scripts/vitest-local-scheduling.test.ts index 70de3289a9f..3dacf7ea7be 100644 --- a/test/scripts/vitest-local-scheduling.test.ts +++ b/test/scripts/vitest-local-scheduling.test.ts @@ -31,6 +31,7 @@ describe("vitest local full-suite profile", () => { cpuCount: 14, loadAverage1m: 14, totalMemoryBytes: 48 * 1024 ** 3, + freeMemoryBytes: 32 * 1024 ** 3, }; expect(shouldUseLargeLocalFullSuiteProfile({}, hostInfo)).toBe(false); @@ -53,4 +54,62 @@ describe("vitest local full-suite profile", () => { vitestMaxWorkers: 1, }); }); + + it("serializes local full-suite shards under critical memory pressure", () => { + const hostInfo = { + cpuCount: 10, + loadAverage1m: 0, + totalMemoryBytes: 24 * 1024 ** 3, + freeMemoryBytes: 3 * 1024 ** 3, + }; + + expect(resolveLocalVitestScheduling({}, hostInfo, "threads")).toEqual({ + maxWorkers: 1, + fileParallelism: false, + throttledBySystem: true, + }); + expect(resolveLocalFullSuiteProfile({}, hostInfo)).toEqual({ + shardParallelism: 1, + vitestMaxWorkers: 1, + }); + }); + + it("limits local full-suite shards when memory is tight", () => { + const hostInfo = { + cpuCount: 10, + loadAverage1m: 0, + totalMemoryBytes: 24 * 1024 ** 3, + freeMemoryBytes: 6 * 1024 ** 3, + }; + + expect(resolveLocalVitestScheduling({}, hostInfo, "threads")).toEqual({ + maxWorkers: 2, + fileParallelism: true, + throttledBySystem: true, + }); + expect(resolveLocalFullSuiteProfile({}, hostInfo)).toEqual({ + shardParallelism: 2, + vitestMaxWorkers: 1, + }); + }); + + it("lets explicit system throttle opt-out ignore memory pressure", () => { + const env = { OPENCLAW_VITEST_DISABLE_SYSTEM_THROTTLE: "1" }; + const hostInfo = { + cpuCount: 10, + loadAverage1m: 0, + totalMemoryBytes: 24 * 1024 ** 3, + freeMemoryBytes: 3 * 1024 ** 3, + }; + + expect(resolveLocalVitestScheduling(env, hostInfo, "threads")).toEqual({ + maxWorkers: 4, + fileParallelism: true, + throttledBySystem: false, + }); + expect(resolveLocalFullSuiteProfile(env, hostInfo)).toEqual({ + shardParallelism: 4, + vitestMaxWorkers: 1, + }); + }); });