
























@@ -20,6 +20,8 @@ import {
20202121const DEFAULT_OUTPUT = ".artifacts/test-perf/group-report.json";
2222const DEFAULT_COMPARE_OUTPUT = ".artifacts/test-perf/group-report-compare.json";
23+const DEFAULT_RUN_TIMEOUT_MS = 30 * 60 * 1000;
24+const DEFAULT_TIMEOUT_KILL_GRACE_MS = 10_000;
23252426function usage() {
2527return [
@@ -38,6 +40,8 @@ function usage() {
3840" --limit <count> Number of groups/configs to print (default: 25)",
3941" --top-files <count> Number of files to print (default: 25)",
4042" --max-test-ms <ms> Fail when any individual test exceeds this duration",
43+" --timeout-ms <ms> Per-config wall-clock timeout (default: 1800000)",
44+" --kill-grace-ms <ms> Grace after timeout before SIGKILL (default: 10000)",
4145" --concurrency <count> Run this many config reports at once (default: 2 for",
4246" repeated explicit configs, 1 for full-suite)",
4347" --allow-failures Write a report even when a Vitest run exits non-zero",
@@ -65,10 +69,12 @@ export function parseTestGroupReportArgs(argv) {
6569fullSuite: false,
6670groupBy: "area",
6771limit: 25,
72+killGraceMs: DEFAULT_TIMEOUT_KILL_GRACE_MS,
6873maxTestMs: null,
6974output: null,
7075reports: [],
7176rss: process.platform !== "win32",
77+timeoutMs: DEFAULT_RUN_TIMEOUT_MS,
7278topFiles: 25,
7379vitestArgs: [],
7480};
@@ -133,6 +139,16 @@ export function parseTestGroupReportArgs(argv) {
133139index += 1;
134140continue;
135141}
142+if (arg === "--timeout-ms") {
143+args.timeoutMs = parsePositiveInt(argv[index + 1], args.timeoutMs);
144+index += 1;
145+continue;
146+}
147+if (arg === "--kill-grace-ms") {
148+args.killGraceMs = parsePositiveInt(argv[index + 1], args.killGraceMs);
149+index += 1;
150+continue;
151+}
136152if (arg === "--concurrency") {
137153args.concurrency = parsePositiveInt(argv[index + 1], args.concurrency);
138154index += 1;
@@ -196,24 +212,123 @@ function parseMaxRssBytes(output) {
196212return null;
197213}
198214199-function spawnText(command, args, options) {
215+function formatSpawnError(error) {
216+return error instanceof Error ? error.message : String(error);
217+}
218+219+export function spawnText(command, args, options) {
200220const maxBuffer = 1024 * 1024 * 64;
221+const timeoutMs = options.timeoutMs ?? DEFAULT_RUN_TIMEOUT_MS;
222+const killGraceMs = options.killGraceMs ?? DEFAULT_TIMEOUT_KILL_GRACE_MS;
223+const useProcessGroup = process.platform !== "win32";
201224return new Promise((resolve) => {
202225const child = spawn(command, args, {
203226cwd: options.cwd,
227+detached: useProcessGroup,
204228env: options.env,
205229stdio: ["ignore", "pipe", "pipe"],
206230});
207231let output = "";
208232let outputExceeded = false;
233+let timedOut = false;
234+let settled = false;
235+let killTimer = null;
236+let childClosedResult = null;
237+let waitingForKillGrace = false;
238+const signalChild = (signal) => {
239+if (useProcessGroup && typeof child.pid === "number") {
240+try {
241+process.kill(-child.pid, signal);
242+return;
243+} catch (error) {
244+if (error && error.code !== "ESRCH") {
245+output += `[test-group-report] failed to send ${signal} to process group: ${formatSpawnError(error)}\n`;
246+}
247+}
248+}
249+child.kill(signal);
250+};
251+const parentSignalHandlers = [];
252+const cleanupParentSignalHandlers = () => {
253+for (const { signal, handler } of parentSignalHandlers) {
254+process.off(signal, handler);
255+}
256+parentSignalHandlers.length = 0;
257+};
258+const relayParentSignal = (signal) => {
259+const handler = () => {
260+signalChild(signal);
261+cleanupParentSignalHandlers();
262+process.kill(process.pid, signal);
263+};
264+parentSignalHandlers.push({ signal, handler });
265+process.once(signal, handler);
266+};
267+if (useProcessGroup) {
268+relayParentSignal("SIGINT");
269+relayParentSignal("SIGTERM");
270+relayParentSignal("SIGHUP");
271+}
272+const processGroupIsAlive = () => {
273+if (!useProcessGroup || typeof child.pid !== "number") {
274+return false;
275+}
276+try {
277+process.kill(-child.pid, 0);
278+return true;
279+} catch (error) {
280+return Boolean(error && error.code === "EPERM");
281+}
282+};
283+const scheduleKill = (message) => {
284+if (waitingForKillGrace) {
285+return;
286+}
287+waitingForKillGrace = true;
288+killTimer = setTimeout(() => {
289+waitingForKillGrace = false;
290+killTimer = null;
291+output += message;
292+signalChild("SIGKILL");
293+if (childClosedResult) {
294+finish(childClosedResult);
295+}
296+}, killGraceMs);
297+killTimer.unref?.();
298+};
299+const timeoutTimer = setTimeout(() => {
300+timedOut = true;
301+output += `\n[test-group-report] command timed out after ${String(timeoutMs)}ms\n`;
302+signalChild("SIGTERM");
303+scheduleKill(
304+`[test-group-report] command did not exit after ${String(killGraceMs)}ms grace; sending SIGKILL\n`,
305+);
306+}, timeoutMs);
307+timeoutTimer.unref?.();
308+const finish = (result) => {
309+if (settled) {
310+return;
311+}
312+settled = true;
313+clearTimeout(timeoutTimer);
314+cleanupParentSignalHandlers();
315+if (killTimer) {
316+clearTimeout(killTimer);
317+}
318+resolve(result);
319+};
209320const appendOutput = (chunk) => {
210321if (outputExceeded) {
211322return;
212323}
213324output += chunk.toString("utf8");
214325if (Buffer.byteLength(output) > maxBuffer) {
215326outputExceeded = true;
216-child.kill("SIGTERM");
327+output += `\n[test-group-report] output exceeded ${String(maxBuffer)} bytes\n`;
328+signalChild("SIGTERM");
329+scheduleKill(
330+"[test-group-report] command did not exit after output limit; sending SIGKILL\n",
331+);
217332}
218333};
219334child.stdout?.on("data", appendOutput);
@@ -222,14 +337,17 @@ function spawnText(command, args, options) {
222337output += `${String(error)}\n`;
223338});
224339child.on("close", (code, signal) => {
225-if (outputExceeded) {
226-output += `\n[test-group-report] output exceeded ${String(maxBuffer)} bytes\n`;
227-}
228-resolve({
229-status: outputExceeded ? 1 : (code ?? 1),
340+const result = {
341+status: outputExceeded || timedOut ? 1 : (code ?? 1),
230342 signal,
231343 output,
232-});
344+ timedOut,
345+};
346+if (waitingForKillGrace && processGroupIsAlive()) {
347+childClosedResult = result;
348+return;
349+}
350+finish(result);
233351});
234352});
235353}
@@ -267,6 +385,8 @@ async function runVitestJsonReport(params) {
267385.filter(Boolean)
268386.join(" "),
269387},
388+killGraceMs: params.killGraceMs,
389+timeoutMs: params.timeoutMs,
270390});
271391const elapsedMs = Number.parseFloat(String(process.hrtime.bigint() - startedAt)) / 1_000_000;
272392const output = result.output;
@@ -433,6 +553,8 @@ async function runReportPlans(params) {
433553logPath: path.join(params.logDir, `${slug}.log`),
434554reportPath: path.join(params.reportDir, `${slug}.json`),
435555rss: params.args.rss,
556+timeoutMs: params.args.timeoutMs,
557+killGraceMs: params.args.killGraceMs,
436558vitestArgs: params.args.vitestArgs,
437559});
438560printRunLine(run);
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。