openclaw/scripts/lib/vitest-process-env.mts
RoboClaw 7510c4bd52
fix(ci): packed test groups reject different scheduling limits (#160290)
Treat only OPENCLAW_TEST_PROJECTS_PARALLEL as a per-group test setting when intersecting shared compiler environments. Preserve each group's admitted overlap, test selection and worker count; retain native thread-budget intersection and rejection of differing compiler inputs.

Both packed bundle plans from PR137092 run36396014412 reproduced the pre-execution failure on main5362ba0. The repaired real scheduling/shared-context boundary preserves parallelism[2,1] and workers[2,2]; test-child execution is substituted in that boundary proof, not claimed as a full extension-suite run.

The runner file passes201tests (4.22s one-worker wall; new order cases7ms each), plus400siblingtests (54.95s). Scripts/root-test types, targeted lint/format, line-cap/diff checks and independent P2 review pass. Local Node26.8.2 differs from hosted24.19.0.

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
2026-09-28 08:53:12 +00:00

288 lines
11 KiB
TypeScript

// Vitest child-process environment and native worker budget policy.
import path from "node:path";
import { embeddedAgentVitestProjectOwners } from "../../test/vitest/vitest.agents-paths.mjs";
import { parsePermissiveBooleanToken } from "./arg-utils.mts";
import { resolveExplicitVitestMode } from "./vitest-cli-mode.mts";
import { resolveLocalVitestEnv } from "./vitest-local-scheduling.mts";
function parsePositiveInt(value: string | undefined): number | null {
const text = value?.trim();
if (!text || !/^\d+$/u.test(text)) {
return null;
}
const parsed = Number(text);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
}
function resolveExplicitVitestWorkerBudget(env: NodeJS.ProcessEnv): number | null {
return parsePositiveInt(env.OPENCLAW_VITEST_MAX_WORKERS ?? env.OPENCLAW_TEST_WORKERS);
}
function shouldApplyNativeWorkerBudget(env: NodeJS.ProcessEnv): boolean {
if (env.RAYON_NUM_THREADS?.trim() && env.TOKIO_WORKER_THREADS?.trim()) {
return false;
}
return (
env.OPENCLAW_TEST_PROJECTS_SERIAL === "1" || resolveExplicitVitestWorkerBudget(env) !== null
);
}
function resolveNativeWorkerCount(env: NodeJS.ProcessEnv): number {
return Math.min(resolveExplicitVitestWorkerBudget(env) ?? 1, 4);
}
/** Applies local Vitest scheduling and native worker budget env. */
export function resolveVitestProcessEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const baseEnv = resolveLocalVitestEnv(env);
if (!shouldApplyNativeWorkerBudget(baseEnv)) {
return baseEnv;
}
const nativeWorkerCount = String(resolveNativeWorkerCount(baseEnv));
return {
...baseEnv,
RAYON_NUM_THREADS: baseEnv.RAYON_NUM_THREADS?.trim() || nativeWorkerCount,
TOKIO_WORKER_THREADS: baseEnv.TOKIO_WORKER_THREADS?.trim() || nativeWorkerCount,
};
}
/** Intersects resolved group budgets without changing any group's environment. */
export function resolveSharedVitestCompilerEnv(
environments: NodeJS.ProcessEnv[],
): NodeJS.ProcessEnv {
const resolved = environments.map((env) => resolveVitestProcessEnv(env));
const shared = { ...resolved[0] };
const testOnlyKeys = new Set([
// test-projects owns group overlap; the worker compiler does not schedule tests.
"OPENCLAW_TEST_PROJECTS_PARALLEL",
"OPENCLAW_VITEST_MAX_WORKERS",
"OPENCLAW_TEST_WORKERS",
"OPENCLAW_VITEST_SHARD_NAME",
"OPENCLAW_NODE_TEST_VITEST_ARGS_JSON",
"OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS",
"OPENCLAW_VITEST_NO_OUTPUT_HEARTBEAT_MS",
]);
// Compilation has one owner before any group requests it. Only scheduling
// facts may differ; loaders, Node flags and source-build inputs must agree.
for (const key of new Set(resolved.flatMap((env) => Object.keys(env)))) {
const values = resolved.map((env) => env[key]);
if (values.every((value) => value === shared[key])) {
continue;
}
if (testOnlyKeys.has(key)) {
delete shared[key];
continue;
}
if (key === "RAYON_NUM_THREADS" || key === "TOKIO_WORKER_THREADS") {
const limits = values.filter((value) => value?.trim()).map(parsePositiveInt);
if (limits.every((limit) => limit !== null)) {
if (limits.length > 0) {
shared[key] = String(Math.min(...limits));
} else {
delete shared[key];
}
continue;
}
}
throw new Error(`CI groups cannot share a compiler with differing ${key}`);
}
return shared;
}
/** Default watchdog timeout for Vitest runs that stop producing output. */
const DEFAULT_VITEST_NO_OUTPUT_TIMEOUT_MS = 120_000;
/** Default heartbeat interval while waiting on silent Vitest output. */
export const DEFAULT_VITEST_NO_OUTPUT_HEARTBEAT_MS = 30_000;
/** Longer watchdog timeout for known long-running Vitest configs. */
export const DEFAULT_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS = 300_000;
/** Extra-long watchdog timeout for broad configs that can stay silent on macOS. */
export const DEFAULT_EXTRA_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS = 2_400_000;
const VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS";
const VITEST_NO_OUTPUT_HEARTBEAT_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_HEARTBEAT_MS";
const GATEWAY_VITEST_CONFIG = "test/vitest/vitest.gateway.config.ts";
export const VITEST_CONFIG_NO_OUTPUT_TIMEOUT_MS = new Map([
["test/vitest/vitest.e2e.config.ts", DEFAULT_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS],
// Keep the SDK fixture's E2E silence window while it builds packages with captured output.
[
"test/vitest/vitest.package-contract.config.ts",
DEFAULT_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS,
],
["test/vitest/vitest.tui-pty.config.ts", DEFAULT_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS],
[GATEWAY_VITEST_CONFIG, DEFAULT_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS],
["test/vitest/vitest.ui-e2e.config.ts", DEFAULT_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS],
[
"test/vitest/vitest.ui-e2e-prebuilt.config.ts",
DEFAULT_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS,
],
["test/vitest/vitest.full-agentic.config.ts", DEFAULT_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS],
[
"test/vitest/vitest.full-core-contracts.config.ts",
DEFAULT_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS,
],
// The package-acceptance high-cardinality admission test can remain silent
// for ~165s under Bun while staying inside its declared 420s test budget.
[
"test/vitest/vitest.full-core-tooling.config.ts",
DEFAULT_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS,
],
[
"test/vitest/vitest.contracts-plugin.config.ts",
DEFAULT_EXTRA_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS,
],
["test/vitest/vitest.infra.config.ts", DEFAULT_EXTRA_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS],
// Largest extension shard: silent transform/import startup was measured at
// ~210s on a loaded macOS host, so the 120s default kills healthy runs (#123025).
[
"test/vitest/vitest.extension-discord.config.ts",
DEFAULT_EXTRA_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS,
],
// Codex extension shard: 168 serial files run ~6min total with silent
// stretches beyond 300s under the default reporter (measured 61s import +
// 293s testing while the worker burned ~95% CPU); the 300s CI window kills
// healthy runs and flips with incidental flake output (#125825).
[
"test/vitest/vitest.extension-codex.config.ts",
DEFAULT_EXTRA_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS,
],
[
"test/vitest/vitest.gateway-core.config.ts",
DEFAULT_EXTRA_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS,
],
[
"test/vitest/vitest.gateway-server.config.ts",
DEFAULT_EXTRA_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS,
],
[
"test/vitest/vitest.gateway-database-workers.config.ts",
DEFAULT_EXTRA_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS,
],
]);
for (const owner of embeddedAgentVitestProjectOwners) {
VITEST_CONFIG_NO_OUTPUT_TIMEOUT_MS.set(
owner.config,
DEFAULT_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS,
);
}
/**
* Resolves default Node flags for Vitest, including the local Maglev opt-in.
*/
export function resolveVitestNodeArgs(env: NodeJS.ProcessEnv = process.env): string[] {
// Node 24 can join a Sparkplug compiler at process.exit while that compiler
// waits for main-thread GC. Keep baseline compilation on the main thread.
return [
...(parsePermissiveBooleanToken(env.OPENCLAW_VITEST_ENABLE_MAGLEV) === true
? []
: ["--no-maglev"]),
"--no-concurrent-sparkplug",
];
}
/**
* Reads the explicit no-output watchdog timeout, if configured.
*/
export function resolveVitestNoOutputTimeoutMs(
env: NodeJS.ProcessEnv = process.env,
): number | null {
return parsePositiveInt(env[VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY]);
}
/**
* Reads the explicit no-output heartbeat interval, if configured.
*/
export function resolveVitestNoOutputHeartbeatMs(
env: NodeJS.ProcessEnv = process.env,
): number | null {
return parsePositiveInt(env[VITEST_NO_OUTPUT_HEARTBEAT_ENV_KEY]);
}
/**
* Adds default watchdog env for non-watch Vitest runs.
*/
export function resolveRunVitestSpawnEnv(
env: NodeJS.ProcessEnv = process.env,
argv: string[] = [],
): NodeJS.ProcessEnv {
const explicitMode = resolveExplicitVitestMode(argv);
if (explicitMode === "watch") {
return env;
}
if (explicitMode !== "run" && parsePermissiveBooleanToken(env.CI) !== true) {
return env;
}
const defaultTimeoutMs = resolveDefaultVitestNoOutputTimeoutMs(argv);
const hasTimeout = Object.hasOwn(env, VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY);
const envTimeoutMs = hasTimeout ? parsePositiveInt(env[VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY]) : null;
// Per-config entries in VITEST_CONFIG_NO_OUTPUT_TIMEOUT_MS are measured
// silence floors for healthy lanes; a global env value (CI sets one for
// every shard) may widen a mapped lane's window but must not shrink it
// below its floor, or the watchdog kills legitimately quiet runs
// (#125825). Unmapped configs keep the env value verbatim.
const configArg = resolveVitestConfigArg(argv);
const configFloorMs = configArg === null ? null : resolveVitestConfigNoOutputTimeoutMs(configArg);
// An explicitly disabled or unparsable env value (e.g. "0") stays verbatim.
const timeoutMs = hasTimeout
? envTimeoutMs === null || configFloorMs === null
? envTimeoutMs
: Math.max(envTimeoutMs, configFloorMs)
: defaultTimeoutMs;
const hasHeartbeat = Object.hasOwn(env, VITEST_NO_OUTPUT_HEARTBEAT_ENV_KEY);
return {
...env,
...(timeoutMs !== null && timeoutMs !== envTimeoutMs
? { [VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY]: String(timeoutMs) }
: {}),
...(!hasHeartbeat && timeoutMs !== null && DEFAULT_VITEST_NO_OUTPUT_HEARTBEAT_MS < timeoutMs
? { [VITEST_NO_OUTPUT_HEARTBEAT_ENV_KEY]: String(DEFAULT_VITEST_NO_OUTPUT_HEARTBEAT_MS) }
: {}),
};
}
/**
* Chooses the default watchdog timeout from the selected Vitest config.
*/
export function resolveDefaultVitestNoOutputTimeoutMs(argv: string[] = []): number {
const config = resolveVitestConfigArg(argv);
return config === null
? DEFAULT_VITEST_NO_OUTPUT_TIMEOUT_MS
: (resolveVitestConfigNoOutputTimeoutMs(config) ?? DEFAULT_VITEST_NO_OUTPUT_TIMEOUT_MS);
}
export function resolveVitestConfigArg(argv: string[]): string | null {
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === undefined) {
break;
}
if (arg === "--") {
return null;
}
for (const option of ["--config", "-c"]) {
// Native empty-inline values own the next token; missing values stay with the child.
if (arg === option || arg === `${option}=`) {
return argv[index + 1] ?? null;
}
if (arg.startsWith(`${option}=`)) {
return arg.slice(option.length + 1);
}
}
}
return null;
}
function resolveVitestConfigNoOutputTimeoutMs(config: string): number | null {
const normalized = normalizeVitestConfigPath(config);
for (const [candidate, timeoutMs] of VITEST_CONFIG_NO_OUTPUT_TIMEOUT_MS) {
if (matchesVitestConfigPath(normalized, candidate)) {
return timeoutMs;
}
}
return null;
}
export function normalizeVitestConfigPath(config: string): string {
return path.normalize(config).replaceAll(path.sep, "/").replace(/^\.\//u, "");
}
export function matchesVitestConfigPath(normalized: string, candidate: string): boolean {
return normalized === candidate || normalized.endsWith("/" + candidate);
}