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
32 changes: 31 additions & 1 deletion scripts/lib/local-heavy-check-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,15 @@ export function acquireLocalHeavyCheckLockSync(params) {
let waitingLogged = false;
let lastProgressAt = 0;

fs.mkdirSync(locksDir, { recursive: true });
try {
fs.mkdirSync(locksDir, { recursive: true });
} catch (error) {
if (isPermissionDeniedLockError(error)) {
logLockUnavailable(params, locksDir, error);
return () => {};
}
throw error;
}
if (!params.lockName) {
cleanupLegacyLockDirs(locksDir, staleLockMs);
}
Expand All @@ -212,6 +220,15 @@ export function acquireLocalHeavyCheckLockSync(params) {
fs.rmSync(lockDir, { recursive: true, force: true });
};
} catch (error) {
if (isPermissionDeniedLockError(error)) {
try {
fs.rmSync(lockDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup only.
}
logLockUnavailable(params, lockDir, error);
return () => {};
}
if (!isAlreadyExistsError(error)) {
throw error;
}
Expand Down Expand Up @@ -257,6 +274,19 @@ export function acquireLocalHeavyCheckLockSync(params) {
}
}

function logLockUnavailable(params, lockPath, error) {
const code =
typeof error === "object" && error !== null && "code" in error ? String(error.code) : "ERR";
console.error(
`[${params.toolName}] local heavy-check lock unavailable at ${lockPath} (${code}); continuing without the lock.`,
);
}

function isPermissionDeniedLockError(error) {
const code = error?.code;
return code === "EACCES" || code === "EPERM" || code === "EROFS";
}

export function resolveGitCommonDir(cwd) {
const result = spawnSync("git", ["rev-parse", "--git-common-dir"], {
cwd,
Expand Down
10 changes: 8 additions & 2 deletions scripts/lib/vitest-local-scheduling.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@

import os from "node:os";

export const DEFAULT_LOCAL_FULL_SUITE_PARALLELISM = 4;
export const DEFAULT_LOCAL_FULL_SUITE_PARALLELISM = 1;
export const LARGE_LOCAL_FULL_SUITE_PARALLELISM = 10;
export const DEFAULT_LOCAL_FULL_SUITE_VITEST_WORKERS = 1;
export const LARGE_LOCAL_FULL_SUITE_VITEST_WORKERS = 2;
const MIN_LARGE_LOCAL_FULL_SUITE_MEMORY_GB = 64;

const clamp = (value, min, max) => Math.max(min, Math.min(max, value));

Expand Down Expand Up @@ -145,7 +146,12 @@ export function shouldUseLargeLocalFullSuiteProfile(
return false;
}
const scheduling = resolveLocalVitestScheduling(env, system, "threads");
return scheduling.maxWorkers >= 5 && !scheduling.throttledBySystem;
const totalMemoryGb = (system.totalMemoryBytes ?? 0) / 1024 ** 3;
return (
scheduling.maxWorkers >= 5 &&
!scheduling.throttledBySystem &&
totalMemoryGb >= MIN_LARGE_LOCAL_FULL_SUITE_MEMORY_GB
);
}

export function resolveLocalFullSuiteProfile(env = process.env, system = detectVitestHostInfo()) {
Expand Down
30 changes: 30 additions & 0 deletions scripts/test-projects.test-support.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ const UI_VITEST_CONFIG = "test/vitest/vitest.ui.config.ts";
const UTILS_VITEST_CONFIG = "test/vitest/vitest.utils.config.ts";
const WIZARD_VITEST_CONFIG = "test/vitest/vitest.wizard.config.ts";
const INCLUDE_FILE_ENV_KEY = "OPENCLAW_VITEST_INCLUDE_FILE";
const FS_MODULE_CACHE_ENV_KEY = "OPENCLAW_VITEST_FS_MODULE_CACHE";
const FS_MODULE_CACHE_PATH_ENV_KEY = "OPENCLAW_VITEST_FS_MODULE_CACHE_PATH";
const PARALLEL_FS_MODULE_CACHE_PATHS_ENV_KEY = "OPENCLAW_VITEST_PARALLEL_FS_CACHE_PATHS";
const CHANGED_ARGS_PATTERN = /^--changed(?:=(.+))?$/u;
const VITEST_CONFIG_BY_KIND = {
acp: ACP_VITEST_CONFIG,
Expand Down Expand Up @@ -663,6 +665,11 @@ function parsePositiveInt(value) {
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}

function isTruthyEnvValue(value) {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true";
}
Comment on lines +668 to +671
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for checking truthy environment variable values is duplicated here and in scripts/lib/vitest-local-scheduling.mjs (within isSystemThrottleDisabled). Consider moving this to a shared utility function in scripts/lib/vitest-local-scheduling.mjs and exporting it to improve maintainability and consistency across the codebase.


function hasConservativeVitestWorkerBudget(env) {
const workerBudget = parsePositiveInt(
env.OPENCLAW_VITEST_MAX_WORKERS ?? env.OPENCLAW_TEST_WORKERS,
Expand Down Expand Up @@ -704,14 +711,37 @@ function sanitizeVitestCachePathSegment(value) {

export function applyParallelVitestCachePaths(specs, params = {}) {
const baseEnv = params.env ?? process.env;
const enableParallelFsCachePaths = isTruthyEnvValue(
baseEnv[PARALLEL_FS_MODULE_CACHE_PATHS_ENV_KEY],
);
// Per-shard fs-module cache paths currently trip a Node/Vitest loader bug in
// some non-isolated suites (for example src/commands/agent.acp.test.ts), and
// the shared experimental cache is unstable across concurrent full-suite
// shards. Disable the experimental fs cache for parallel shard runs unless a
// caller explicitly opts into sharded cache paths.
if (baseEnv[FS_MODULE_CACHE_PATH_ENV_KEY]?.trim()) {
return specs;
}
if (!enableParallelFsCachePaths && baseEnv[FS_MODULE_CACHE_ENV_KEY]?.trim()) {
return specs;
}
const cwd = params.cwd ?? process.cwd();
return specs.map((spec, index) => {
if (spec.env?.[FS_MODULE_CACHE_PATH_ENV_KEY]?.trim()) {
return spec;
}
if (!enableParallelFsCachePaths) {
if (spec.env?.[FS_MODULE_CACHE_ENV_KEY]?.trim()) {
return spec;
}
return {
...spec,
env: {
...spec.env,
[FS_MODULE_CACHE_ENV_KEY]: "0",
},
};
}
const cacheSegment = sanitizeVitestCachePathSegment(`${index}-${spec.config}`);
return {
...spec,
Expand Down
12 changes: 10 additions & 2 deletions src/agents/pi-bundle-mcp-test-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import http from "node:http";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { canListenOnLoopbackForTests } from "../test-utils/ports.js";
import {
writeBundleProbeMcpServer,
writeClaudeBundle,
Expand Down Expand Up @@ -46,6 +47,9 @@ export async function waitForFileText(filePath: string, timeoutMs = 5_000): Prom
export async function startSseProbeServer(
probeText = "FROM-SSE",
): Promise<{ port: number; close: () => Promise<void> }> {
if (!canListenOnLoopbackForTests()) {
throw new Error("loopback listen unavailable for SSE MCP probe server");
}
const { McpServer } = await import(SDK_SERVER_MCP_PATH);
const { SSEServerTransport } = await import(SDK_SERVER_SSE_PATH);

Expand Down Expand Up @@ -76,8 +80,12 @@ export async function startSseProbeServer(
}
});

await new Promise<void>((resolve) => {
httpServer.listen(0, "127.0.0.1", resolve);
await new Promise<void>((resolve, reject) => {
httpServer.once("error", reject);
httpServer.listen(0, "127.0.0.1", () => {
httpServer.off("error", reject);
resolve();
});
});
const address = httpServer.address();
const port = typeof address === "object" && address ? address.port : 0;
Expand Down
4 changes: 3 additions & 1 deletion src/agents/pi-bundle-mcp-tools.materialize.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createRequire } from "node:module";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { canListenOnLoopbackForTests } from "../test-utils/ports.js";
import {
cleanupBundleMcpHarness,
makeTempDir,
Expand All @@ -14,6 +15,7 @@ import { createBundleMcpToolRuntime } from "./pi-bundle-mcp-tools.js";
const require = createRequire(import.meta.url);
const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
const CAN_RUN_LOOPBACK_TESTS = canListenOnLoopbackForTests();

afterEach(async () => {
await cleanupBundleMcpHarness();
Expand Down Expand Up @@ -114,7 +116,7 @@ describe("createBundleMcpToolRuntime", () => {
}
});

it("loads configured SSE MCP tools via url", async () => {
it.skipIf(!CAN_RUN_LOOPBACK_TESTS)("loads configured SSE MCP tools via url", async () => {
vi.useRealTimers();
const sseServer = await startSseProbeServer();

Expand Down
4 changes: 1 addition & 3 deletions src/agents/pi-embedded-runner/run/images.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,9 +234,7 @@ describe("modelSupportsImages", () => {

describe("loadImageFromRef", () => {
it("allows sandbox-validated host paths outside default media roots", async () => {
const homeDir = os.homedir();
await fs.mkdir(homeDir, { recursive: true });
const sandboxParent = await fs.mkdtemp(path.join(homeDir, "openclaw-sandbox-image-"));
const sandboxParent = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sandbox-image-"));
try {
const sandboxRoot = path.join(sandboxParent, "sandbox");
await fs.mkdir(sandboxRoot, { recursive: true });
Expand Down
2 changes: 1 addition & 1 deletion src/config/schema.base.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27891,6 +27891,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
tags: ["advanced", "url-secret"],
},
},
version: "2026.4.14",
version: "2026.4.15",
generatedAt: "2026-03-22T21:17:33.302Z",
};
126 changes: 67 additions & 59 deletions src/gateway/client.watchdog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createServer } from "node:net";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
import { rawDataToString } from "../infra/ws.js";
import { canListenOnLoopbackForTests } from "../test-utils/ports.js";
import { GatewayClient, resolveGatewayClientConnectChallengeTimeoutMs } from "./client.js";
import {
DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS,
Expand Down Expand Up @@ -61,22 +62,25 @@ function trackSettlement(promise: Promise<unknown>): () => boolean {
describe("GatewayClient", () => {
let wss: WebSocketServer | null = null;
let httpsServer: ReturnType<typeof createHttpsServer> | null = null;
const CAN_RUN_LOOPBACK_TESTS = canListenOnLoopbackForTests();

afterEach(async () => {
if (wss) {
for (const client of wss.clients) {
client.terminate();
if (CAN_RUN_LOOPBACK_TESTS) {
afterEach(async () => {
if (wss) {
for (const client of wss.clients) {
client.terminate();
}
await new Promise<void>((resolve) => wss?.close(() => resolve()));
wss = null;
}
await new Promise<void>((resolve) => wss?.close(() => resolve()));
wss = null;
}
if (httpsServer) {
httpsServer.closeAllConnections?.();
httpsServer.closeIdleConnections?.();
await new Promise<void>((resolve) => httpsServer?.close(() => resolve()));
httpsServer = null;
}
});
if (httpsServer) {
httpsServer.closeAllConnections?.();
httpsServer.closeIdleConnections?.();
await new Promise<void>((resolve) => httpsServer?.close(() => resolve()));
httpsServer = null;
}
});
}

test("prefers connectChallengeTimeoutMs and still honors the legacy alias", () => {
expect(resolveGatewayClientConnectChallengeTimeoutMs({})).toBe(
Expand All @@ -96,54 +100,58 @@ describe("GatewayClient", () => {
).toBe(5_000);
});

test("closes on missing ticks", async () => {
const port = await getFreePort();
wss = new WebSocketServer({ port, host: "127.0.0.1" });

wss.on("connection", (socket) => {
socket.once("message", (data) => {
const first = JSON.parse(rawDataToString(data)) as { id?: string };
const id = first.id ?? "connect";
// Respond with tiny tick interval to trigger watchdog quickly.
const helloOk = {
type: "hello-ok",
protocol: 2,
server: { version: "dev", connId: "c1" },
features: { methods: [], events: [] },
snapshot: {
presence: [],
health: {},
stateVersion: { presence: 1, health: 1 },
uptimeMs: 1,
},
policy: {
maxPayload: 512 * 1024,
maxBufferedBytes: 1024 * 1024,
tickIntervalMs: 5,
},
};
socket.send(JSON.stringify({ type: "res", id, ok: true, payload: helloOk }));
test.skipIf(!CAN_RUN_LOOPBACK_TESTS)(
"closes on missing ticks",
async () => {
const port = await getFreePort();
wss = new WebSocketServer({ port, host: "127.0.0.1" });

wss.on("connection", (socket) => {
socket.once("message", (data) => {
const first = JSON.parse(rawDataToString(data)) as { id?: string };
const id = first.id ?? "connect";
// Respond with tiny tick interval to trigger watchdog quickly.
const helloOk = {
type: "hello-ok",
protocol: 2,
server: { version: "dev", connId: "c1" },
features: { methods: [], events: [] },
snapshot: {
presence: [],
health: {},
stateVersion: { presence: 1, health: 1 },
uptimeMs: 1,
},
policy: {
maxPayload: 512 * 1024,
maxBufferedBytes: 1024 * 1024,
tickIntervalMs: 5,
},
};
socket.send(JSON.stringify({ type: "res", id, ok: true, payload: helloOk }));
});
});
});

const closed = new Promise<{ code: number; reason: string }>((resolve) => {
const client = new GatewayClient({
url: `ws://127.0.0.1:${port}`,
connectChallengeTimeoutMs: 0,
tickWatchMinIntervalMs: 5,
onClose: (code, reason) => resolve({ code, reason }),
const closed = new Promise<{ code: number; reason: string }>((resolve) => {
const client = new GatewayClient({
url: `ws://127.0.0.1:${port}`,
connectChallengeTimeoutMs: 0,
tickWatchMinIntervalMs: 5,
onClose: (code, reason) => resolve({ code, reason }),
});
client.start();
});
client.start();
});

const res = await closed;
// Depending on auth/challenge timing in the harness, the client can either
// hit the tick watchdog (4000) or close with policy violation (1008).
expect([4000, 1008]).toContain(res.code);
if (res.code === 4000) {
expect(res.reason).toContain("tick timeout");
}
}, 4000);
const res = await closed;
// Depending on auth/challenge timing in the harness, the client can either
// hit the tick watchdog (4000) or close with policy violation (1008).
expect([4000, 1008]).toContain(res.code);
if (res.code === 4000) {
expect(res.reason).toContain("tick timeout");
}
},
4000,
);

test("times out unresolved requests and clears pending state", async () => {
vi.useFakeTimers();
Expand Down Expand Up @@ -227,7 +235,7 @@ describe("GatewayClient", () => {
}
});

test("rejects mismatched tls fingerprint", async () => {
test.skipIf(!CAN_RUN_LOOPBACK_TESTS)("rejects mismatched tls fingerprint", async () => {
const key = [
"-----BEGIN PRIVATE KEY-----", // pragma: allowlist secret
"MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDrur5CWp4psMMb",
Expand Down
Loading
Loading