


























@@ -67,6 +67,12 @@ interface UpdateJobContext {
6767signal: AbortSignal;
6868}
696970+interface SpawnLoggedOptions {
71+timeoutKillGraceMs?: number;
72+timeoutLabel?: string;
73+timeoutMs?: number;
74+}
75+7076interface NpmUpdateSummary {
7177packageSpec: string;
7278updateTarget: string;
@@ -104,6 +110,173 @@ const windowsVm = "Windows 11";
104110const linuxVmDefault = "Ubuntu 26.04";
105111const updateTimeoutSeconds = readPositiveIntEnv("OPENCLAW_PARALLELS_NPM_UPDATE_TIMEOUT_S", 1200);
106112const updateCleanupBackstopMs = 60_000;
113+const freshLaneTimeoutKillGraceMs = readPositiveIntEnv(
114+"OPENCLAW_PARALLELS_NPM_UPDATE_FRESH_TIMEOUT_KILL_GRACE_MS",
115+2_000,
116+);
117+const activeLoggedChildren = new Set<ReturnType<typeof spawn>>();
118+const loggedParentSignalHandlers = new Map<NodeJS.Signals, () => void>();
119+let loggedExitCleanupInstalled = false;
120+121+export function freshLaneTimeoutMs(platform: Platform): number {
122+const defaultSeconds = platform === "windows" ? 90 * 60 : 75 * 60;
123+return readPositiveIntEnv("OPENCLAW_PARALLELS_NPM_UPDATE_FRESH_TIMEOUT_S", defaultSeconds) * 1000;
124+}
125+126+export function spawnLoggedCommand(
127+command: string,
128+args: string[],
129+logPath: string,
130+env: NodeJS.ProcessEnv = {},
131+onOutput: (text: string) => void = () => undefined,
132+options: SpawnLoggedOptions = {},
133+): Promise<number> {
134+return new Promise((resolve, reject) => {
135+writeFileSync(logPath, "", "utf8");
136+const child = spawn(command, args, {
137+cwd: repoRoot,
138+detached: process.platform !== "win32",
139+env: { ...process.env, ...env },
140+stdio: ["ignore", "pipe", "pipe"],
141+});
142+trackLoggedChild(child);
143+let timedOut = false;
144+let settled = false;
145+let forceKillTimer: NodeJS.Timeout | undefined;
146+const append = (text: string) => {
147+appendFileSync(logPath, text, "utf8");
148+onOutput(text);
149+};
150+const timeoutMs = options.timeoutMs ?? 0;
151+const timeoutTimer =
152+timeoutMs > 0
153+ ? setTimeout(() => {
154+timedOut = true;
155+append(
156+`\n[${options.timeoutLabel ?? `${command} ${args.join(" ")}`} timed out after ${timeoutMs}ms]\n`,
157+);
158+signalLoggedChild(child, "SIGTERM");
159+forceKillTimer = setTimeout(
160+() => signalLoggedChild(child, "SIGKILL"),
161+options.timeoutKillGraceMs ?? freshLaneTimeoutKillGraceMs,
162+);
163+}, timeoutMs)
164+ : undefined;
165+child.stdout.on("data", (chunk: Buffer) => {
166+append(chunk.toString("utf8"));
167+});
168+child.stderr.on("data", (chunk: Buffer) => {
169+append(chunk.toString("utf8"));
170+});
171+child.on("error", (error) => {
172+if (settled) {
173+return;
174+}
175+settled = true;
176+clearTimeout(timeoutTimer);
177+clearTimeout(forceKillTimer);
178+untrackLoggedChild(child);
179+reject(error);
180+});
181+child.on("close", (code) => {
182+if (settled) {
183+return;
184+}
185+settled = true;
186+clearTimeout(timeoutTimer);
187+clearTimeout(forceKillTimer);
188+if (timedOut && loggedProcessTreeIsAlive(child)) {
189+signalLoggedChild(child, "SIGKILL");
190+}
191+untrackLoggedChild(child);
192+resolve(timedOut ? 124 : (code ?? 1));
193+});
194+});
195+}
196+197+function trackLoggedChild(child: ReturnType<typeof spawn>) {
198+activeLoggedChildren.add(child);
199+child.once("close", () => {
200+if (!loggedProcessTreeIsAlive(child)) {
201+activeLoggedChildren.delete(child);
202+}
203+});
204+child.once("error", () => {
205+if (!loggedProcessTreeIsAlive(child)) {
206+activeLoggedChildren.delete(child);
207+}
208+});
209+installLoggedParentCleanup();
210+}
211+212+function untrackLoggedChild(child: ReturnType<typeof spawn>) {
213+if (!loggedProcessTreeIsAlive(child)) {
214+activeLoggedChildren.delete(child);
215+}
216+}
217+218+function installLoggedParentCleanup() {
219+if (!loggedExitCleanupInstalled) {
220+loggedExitCleanupInstalled = true;
221+process.once("exit", () => cleanupActiveLoggedChildren("SIGTERM"));
222+}
223+for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] as const) {
224+if (loggedParentSignalHandlers.has(signal)) {
225+continue;
226+}
227+const handler = () => {
228+cleanupActiveLoggedChildren(signal);
229+for (const [registeredSignal, registeredHandler] of loggedParentSignalHandlers) {
230+process.off(registeredSignal, registeredHandler);
231+}
232+loggedParentSignalHandlers.clear();
233+process.kill(process.pid, signal);
234+};
235+loggedParentSignalHandlers.set(signal, handler);
236+process.once(signal, handler);
237+}
238+}
239+240+function cleanupActiveLoggedChildren(signal: NodeJS.Signals) {
241+for (const child of activeLoggedChildren) {
242+signalLoggedChild(child, signal);
243+if (process.platform !== "win32") {
244+signalLoggedChild(child, "SIGKILL");
245+}
246+}
247+}
248+249+function loggedProcessTreeIsAlive(child: ReturnType<typeof spawn>): boolean {
250+if (process.platform === "win32" || typeof child.pid !== "number") {
251+return child.exitCode === null && child.signalCode === null;
252+}
253+try {
254+process.kill(-child.pid, 0);
255+return true;
256+} catch (error) {
257+return error instanceof Error && "code" in error && error.code === "EPERM";
258+}
259+}
260+261+function signalLoggedChild(child: ReturnType<typeof spawn>, signal: NodeJS.Signals) {
262+if (process.platform !== "win32" && typeof child.pid === "number") {
263+try {
264+process.kill(-child.pid, signal);
265+return;
266+} catch (error) {
267+if (error instanceof Error && "code" in error && error.code === "ESRCH") {
268+return;
269+}
270+}
271+}
272+try {
273+child.kill(signal);
274+} catch (error) {
275+if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) {
276+throw error;
277+}
278+}
279+}
107280108281function usage(): string {
109282return `Usage: bash scripts/e2e/parallels-npm-update-smoke.sh [options]
@@ -432,8 +605,16 @@ class NpmUpdateSmoke {
432605rerunCommand: this.formatRerun("bash", args, env),
433606 startedAt,
434607};
435-job.promise = this.spawnLogged("bash", args, logPath, env, (text) =>
436-this.noteJobOutput(job, text),
608+job.promise = this.spawnLogged(
609+"bash",
610+args,
611+logPath,
612+env,
613+(text) => this.noteJobOutput(job, text),
614+{
615+timeoutLabel: `${label} ${phase}`,
616+timeoutMs: freshLaneTimeoutMs(platform),
617+},
437618).finally(() => {
438619job.durationMs = Date.now() - job.startedAt;
439620job.done = true;
@@ -636,29 +817,9 @@ class NpmUpdateSmoke {
636817logPath: string,
637818env: NodeJS.ProcessEnv = {},
638819onOutput: (text: string) => void = () => undefined,
820+options: SpawnLoggedOptions = {},
639821): Promise<number> {
640-return new Promise((resolve, reject) => {
641-writeFileSync(logPath, "", "utf8");
642-const child = spawn(command, args, {
643-cwd: repoRoot,
644-env: { ...process.env, ...env },
645-stdio: ["ignore", "pipe", "pipe"],
646-});
647-child.stdout.on("data", (chunk: Buffer) => {
648-const text = chunk.toString("utf8");
649-appendFileSync(logPath, text, "utf8");
650-onOutput(text);
651-});
652-child.stderr.on("data", (chunk: Buffer) => {
653-const text = chunk.toString("utf8");
654-appendFileSync(logPath, text, "utf8");
655-onOutput(text);
656-});
657-child.on("error", reject);
658-child.on("close", (code) => {
659-resolve(code ?? 1);
660-});
661-});
822+return spawnLoggedCommand(command, args, logPath, env, onOutput, options);
662823}
663824664825private async monitorJobs(label: string, jobs: Job[]): Promise<void> {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。