mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-04 02:00:10 +00:00
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>
288 lines
11 KiB
TypeScript
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);
|
|
}
|