

















@@ -2,6 +2,10 @@
22import { spawn } from "node:child_process";
33import { performance } from "node:perf_hooks";
445+const DEFAULT_CHECK_TIMEOUT_MS = 10 * 60 * 1000;
6+const DEFAULT_OUTPUT_MAX_BYTES = 512 * 1024;
7+const TIMEOUT_KILL_GRACE_MS = 5_000;
8+59export const BOUNDARY_CHECKS = [
610["prompt:snapshots:check", "pnpm", ["prompt:snapshots:check"]],
711["plugin-extension-boundary", "pnpm", ["run", "lint:plugins:no-extension-imports"]],
@@ -70,6 +74,14 @@ export function resolveConcurrency(value, fallback = 4) {
7074return parsed;
7175}
727677+export function resolvePositiveInteger(value, fallback) {
78+const parsed = Number.parseInt(String(value ?? ""), 10);
79+if (!Number.isFinite(parsed) || parsed < 1) {
80+return fallback;
81+}
82+return parsed;
83+}
84+7385export function parseShardSpec(value) {
7486if (!value) {
7587return null;
@@ -130,40 +142,178 @@ export function formatCommand({ command, args }) {
130142return [command, ...args].join(" ");
131143}
132144133-function runSingleCheck(check, { cwd, env }) {
145+export function createBoundedOutputBuffer(maxBytes = DEFAULT_OUTPUT_MAX_BYTES) {
146+const limit = Math.max(1, maxBytes);
147+const chunks = [];
148+let bytes = 0;
149+let truncated = false;
150+151+const append = (value) => {
152+const text = String(value);
153+let textBytes = Buffer.byteLength(text);
154+if (textBytes >= limit) {
155+const buffer = Buffer.from(text);
156+const tail = buffer.subarray(buffer.length - limit).toString("utf8");
157+chunks.splice(0, chunks.length, tail);
158+bytes = Buffer.byteLength(tail);
159+truncated = true;
160+return;
161+}
162+163+chunks.push(text);
164+bytes += textBytes;
165+while (bytes > limit && chunks.length > 0) {
166+const first = chunks[0];
167+const firstBytes = Buffer.byteLength(first);
168+const overflow = bytes - limit;
169+if (firstBytes <= overflow) {
170+chunks.shift();
171+bytes -= firstBytes;
172+truncated = true;
173+continue;
174+}
175+176+const buffer = Buffer.from(first);
177+const tail = buffer.subarray(overflow).toString("utf8");
178+chunks[0] = tail;
179+bytes = chunks.reduce((total, chunk) => total + Buffer.byteLength(chunk), 0);
180+truncated = true;
181+}
182+};
183+184+return {
185+ append,
186+read() {
187+const output = chunks.join("");
188+return truncated
189+ ? `[output truncated to last ${limit} bytes]\n${output}`
190+ : output;
191+},
192+};
193+}
194+195+function terminateChild(child, signal) {
196+if (process.platform !== "win32" && child.pid) {
197+try {
198+process.kill(-child.pid, signal);
199+return;
200+} catch {}
201+}
202+child.kill(signal);
203+}
204+205+function terminateActiveChildren(activeChildren, signal) {
206+for (const child of activeChildren) {
207+terminateChild(child, signal);
208+}
209+}
210+211+function installActiveChildCleanup(activeChildren) {
212+let active = true;
213+const removeHandlers = () => {
214+for (const [signal, handler] of signalHandlers) {
215+process.off(signal, handler);
216+}
217+process.off("exit", exitHandler);
218+};
219+const cleanup = (signal) => {
220+if (!active) {
221+return;
222+}
223+active = false;
224+terminateActiveChildren(activeChildren, signal);
225+};
226+const signalHandlers = new Map();
227+const signals =
228+process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"];
229+for (const signal of signals) {
230+const handler = () => {
231+cleanup(signal);
232+removeHandlers();
233+process.kill(process.pid, signal);
234+};
235+signalHandlers.set(signal, handler);
236+process.once(signal, handler);
237+}
238+const exitHandler = () => {
239+cleanup("SIGTERM");
240+};
241+process.once("exit", exitHandler);
242+243+return () => {
244+active = false;
245+removeHandlers();
246+};
247+}
248+249+export function runSingleCheck(
250+check,
251+{
252+ activeChildren,
253+ checkTimeoutMs = DEFAULT_CHECK_TIMEOUT_MS,
254+ cwd,
255+ env,
256+ outputMaxBytes = DEFAULT_OUTPUT_MAX_BYTES,
257+},
258+) {
134259return new Promise((resolve) => {
135260const startedAt = performance.now();
136261const child = spawn(check.command, check.args, {
137262 cwd,
138263 env,
264+detached: process.platform !== "win32",
139265shell: false,
140266stdio: ["ignore", "pipe", "pipe"],
141267});
142-const chunks = [];
143-144-child.stdout.setEncoding("utf8");
145-child.stderr.setEncoding("utf8");
146-child.stdout.on("data", (chunk) => chunks.push(chunk));
147-child.stderr.on("data", (chunk) => chunks.push(chunk));
148-child.on("error", (error) => {
149-chunks.push(`${error.stack ?? error.message}\n`);
150-resolve({
151- check,
152-code: 1,
153-durationMs: Math.round(performance.now() - startedAt),
154-signal: null,
155-output: chunks.join(""),
156-});
157-});
158-child.on("close", (code, signal) => {
268+activeChildren?.add(child);
269+const output = createBoundedOutputBuffer(outputMaxBytes);
270+let settled = false;
271+let timedOut = false;
272+let forceKillTimer = null;
273+const finish = (code, signal) => {
274+if (settled) {
275+return;
276+}
277+settled = true;
278+clearTimeout(timeout);
279+if (forceKillTimer) {
280+clearTimeout(forceKillTimer);
281+}
282+activeChildren?.delete(child);
159283resolve({
160284 check,
161-code: code ?? 1,
285+code: timedOut ? 1 : (code ?? 1),
162286durationMs: Math.round(performance.now() - startedAt),
163287 signal,
164-output: chunks.join(""),
288+ timedOut,
289+output: output.read(),
165290});
291+};
292+const timeout = setTimeout(() => {
293+timedOut = true;
294+output.append(
295+`\n[boundary-check] ${check.label} timed out after ${formatDuration(checkTimeoutMs)}; terminating process group\n`,
296+);
297+terminateChild(child, "SIGTERM");
298+forceKillTimer = setTimeout(() => {
299+output.append(
300+`[boundary-check] ${check.label} still running after ${formatDuration(TIMEOUT_KILL_GRACE_MS)}; sending SIGKILL\n`,
301+);
302+terminateChild(child, "SIGKILL");
303+}, TIMEOUT_KILL_GRACE_MS);
304+forceKillTimer.unref?.();
305+}, checkTimeoutMs);
306+timeout.unref?.();
307+308+child.stdout.setEncoding("utf8");
309+child.stderr.setEncoding("utf8");
310+child.stdout.on("data", (chunk) => output.append(chunk));
311+child.stderr.on("data", (chunk) => output.append(chunk));
312+child.on("error", (error) => {
313+output.append(`${error.stack ?? error.message}\n`);
314+finish(1, null);
166315});
316+child.on("close", (code, signal) => finish(code, signal));
167317});
168318}
169319@@ -187,7 +337,11 @@ function writeGroupedResult(result, output) {
187337if (success) {
188338output.write(`[ok] ${result.check.label} in ${formatDuration(result.durationMs)}\n`);
189339} else {
190-const suffix = result.signal ? ` (signal ${result.signal})` : ` (exit ${result.code})`;
340+const suffix = result.timedOut
341+ ? " (timeout)"
342+ : result.signal
343+ ? ` (signal ${result.signal})`
344+ : ` (exit ${result.code})`;
191345output.write(
192346`::error title=${result.check.label} failed::${result.check.label} failed${suffix} after ${formatDuration(result.durationMs)}\n`,
193347);
@@ -206,36 +360,55 @@ function writeTimingSummary(results, output) {
206360207361export async function runChecks(
208362checks = BOUNDARY_CHECKS,
209-{ concurrency = 4, cwd = process.cwd(), env = process.env, output = process.stdout } = {},
363+{
364+ checkTimeoutMs = DEFAULT_CHECK_TIMEOUT_MS,
365+ concurrency = 4,
366+ cwd = process.cwd(),
367+ env = process.env,
368+ output = process.stdout,
369+ outputMaxBytes = DEFAULT_OUTPUT_MAX_BYTES,
370+} = {},
210371) {
211372const results = Array.from({ length: checks.length });
373+const activeChildren = new Set();
374+const removeActiveChildCleanup = installActiveChildCleanup(activeChildren);
212375let nextIndex = 0;
213376let active = 0;
214377215-await new Promise((resolve) => {
216-const launch = () => {
217-if (nextIndex >= checks.length && active === 0) {
218-resolve();
219-return;
220-}
378+try {
379+await new Promise((resolve) => {
380+const launch = () => {
381+if (nextIndex >= checks.length && active === 0) {
382+resolve();
383+return;
384+}
221385222-while (active < concurrency && nextIndex < checks.length) {
223-const index = nextIndex;
224-const check = checks[nextIndex++];
225-active += 1;
226-void runSingleCheck(check, { cwd, env })
227-.then((result) => {
228-results[index] = result;
386+while (active < concurrency && nextIndex < checks.length) {
387+const index = nextIndex;
388+const check = checks[nextIndex++];
389+active += 1;
390+void runSingleCheck(check, {
391+ activeChildren,
392+ checkTimeoutMs,
393+ cwd,
394+ env,
395+ outputMaxBytes,
229396})
230-.finally(() => {
231-active -= 1;
232-launch();
233-});
234-}
235-};
397+.then((result) => {
398+results[index] = result;
399+})
400+.finally(() => {
401+active -= 1;
402+launch();
403+});
404+}
405+};
236406237-launch();
238-});
407+launch();
408+});
409+} finally {
410+removeActiveChildCleanup();
411+}
239412240413let failures = 0;
241414for (const result of results) {
@@ -265,13 +438,21 @@ if (import.meta.url === `file://${process.argv[1]}`) {
265438process.env.OPENCLAW_ADDITIONAL_BOUNDARY_CONCURRENCY ??
266439process.env.OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY,
267440);
441+const checkTimeoutMs = resolvePositiveInteger(
442+process.env.OPENCLAW_ADDITIONAL_BOUNDARY_TIMEOUT_MS,
443+DEFAULT_CHECK_TIMEOUT_MS,
444+);
445+const outputMaxBytes = resolvePositiveInteger(
446+process.env.OPENCLAW_ADDITIONAL_BOUNDARY_OUTPUT_MAX_BYTES,
447+DEFAULT_OUTPUT_MAX_BYTES,
448+);
268449const shards = parseShardSelection(resolveCliShardSpec(process.argv.slice(2), process.env));
269450const checks = selectChecksForShard(BOUNDARY_CHECKS, shards);
270451if (shards) {
271452process.stdout.write(
272453`Running ${checks.length}/${BOUNDARY_CHECKS.length} additional boundary checks (shard ${shards.map((shard) => shard.label).join(",")})\n`,
273454);
274455}
275-const failures = await runChecks(checks, { concurrency });
456+const failures = await runChecks(checks, { checkTimeoutMs, concurrency, outputMaxBytes });
276457process.exitCode = failures === 0 ? 0 : 1;
277458}
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。