





















11#!/usr/bin/env node
2-// Runs an E2E command under a pseudo-terminal.
2+import { spawnSync } from "node:child_process";
33import fs from "node:fs";
44import process from "node:process";
5-import { spawn } from "@lydell/node-pty";
5+import { spawn as spawnPty } from "@lydell/node-pty";
66import { readPositiveIntEnv } from "./env-limits.mjs";
7788const [logPath, command, ...args] = process.argv.slice(2);
@@ -17,6 +17,9 @@ if (!logPath || !command) {
1717let exiting = false;
1818let forwardedSignal = null;
1919let forceKillTimer = null;
20+let terminationDrainTimer = null;
21+let terminationPids = [];
22+let pendingExitCode = null;
2023let logFailed = false;
2124const outputLimitMarker = `\n[run-with-pty output truncated after ${OUTPUT_MAX_BYTES} bytes]\n`;
2225const outputState = {
@@ -25,7 +28,7 @@ const outputState = {
2528};
26292730const log = fs.createWriteStream(logPath, { flags: "w" });
28-const pty = spawn(command, args, {
31+const pty = spawnPty(command, args, {
2932name: process.env.TERM || "xterm-256color",
3033cols: readPositiveIntEnv("COLUMNS", 120),
3134rows: readPositiveIntEnv("LINES", 40),
@@ -43,11 +46,7 @@ log.on("error", (error) => {
4346process.exit(1);
4447}
4548if (!exiting) {
46-pty.kill("SIGTERM");
47-forceKillTimer ??= setTimeout(() => {
48-pty.kill("SIGKILL");
49-}, FORCE_KILL_MS);
50-forceKillTimer.unref?.();
49+terminatePtyTree("SIGTERM");
5150}
5251});
5352@@ -86,18 +85,23 @@ pty.onData((data) => {
86858786pty.onExit(({ exitCode, signal }) => {
8887exiting = true;
89-clearTimeout(forceKillTimer);
88+if (terminationPids.length === 0) {
89+clearTerminationTimers();
90+}
9091if (logFailed) {
91-process.exit(1);
92+exitWhenTerminationDrains(1);
93+return;
9294}
9395log.end(() => {
9496if (forwardedSignal) {
95-process.exit(signalExitCode(forwardedSignal));
97+exitWhenTerminationDrains(signalExitCode(forwardedSignal));
98+return;
9699}
97100if (typeof exitCode === "number") {
98-process.exit(exitCode);
101+exitWhenTerminationDrains(exitCode);
102+return;
99103}
100-process.exit(signal ? 128 + signal : 1);
104+exitWhenTerminationDrains(signal ? 128 + signal : 1);
101105});
102106});
103107@@ -109,15 +113,108 @@ for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
109113process.on(signal, () => {
110114if (!exiting) {
111115forwardedSignal ??= signal;
112-pty.kill(signal);
113-forceKillTimer ??= setTimeout(() => {
114-pty.kill("SIGKILL");
115-}, FORCE_KILL_MS);
116-forceKillTimer.unref?.();
116+terminatePtyTree(signal);
117+}
118+});
119+}
120+121+function terminatePtyTree(signal) {
122+// node-pty kill() targets only pty.pid on Unix; wrapper-owned shutdowns
123+// keep the captured child tree alive until ignored descendants drain.
124+if (terminationPids.length === 0) {
125+terminationPids = collectPtyProcessTreePids();
126+}
127+signalPtyProcessTree(signal);
128+forceKillTimer ??= setTimeout(() => {
129+signalPtyProcessTree("SIGKILL");
130+}, FORCE_KILL_MS);
131+forceKillTimer.unref?.();
132+}
133+134+function exitWhenTerminationDrains(exitCode) {
135+pendingExitCode = exitCode;
136+if (processTreeIsAlive(terminationPids)) {
137+terminationDrainTimer ??= setInterval(finishIfTerminationDrained, 25);
138+return;
139+}
140+finishIfTerminationDrained();
141+}
142+143+function finishIfTerminationDrained() {
144+if (processTreeIsAlive(terminationPids)) {
145+return;
146+}
147+clearTerminationTimers();
148+process.exit(pendingExitCode ?? 1);
149+}
150+151+function clearTerminationTimers() {
152+if (forceKillTimer) {
153+clearTimeout(forceKillTimer);
154+forceKillTimer = null;
155+}
156+if (terminationDrainTimer) {
157+clearInterval(terminationDrainTimer);
158+terminationDrainTimer = null;
159+}
160+}
161+162+function collectPtyProcessTreePids() {
163+if (process.platform === "win32" || typeof pty.pid !== "number") {
164+return typeof pty.pid === "number" ? [pty.pid] : [];
165+}
166+const ps = spawnSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" });
167+if (ps.status !== 0) {
168+return [pty.pid];
169+}
170+const childrenByParent = new Map();
171+for (const line of ps.stdout.split("\n")) {
172+const match = line.trim().match(/^(\d+)\s+(\d+)$/u);
173+if (!match) {
174+continue;
175+}
176+const pid = Number(match[1]);
177+const ppid = Number(match[2]);
178+const siblings = childrenByParent.get(ppid) ?? [];
179+siblings.push(pid);
180+childrenByParent.set(ppid, siblings);
181+}
182+const pids = [pty.pid];
183+for (const parentPid of pids) {
184+for (const pid of childrenByParent.get(parentPid) ?? []) {
185+pids.push(pid);
186+}
187+}
188+return [...new Set(pids)];
189+}
190+191+function processTreeIsAlive(pids) {
192+return pids.some((pid) => {
193+try {
194+process.kill(pid, 0);
195+return true;
196+} catch (error) {
197+return error?.code === "EPERM";
117198}
118199});
119200}
120201202+function signalPtyProcessTree(signal) {
203+if (process.platform === "win32" || terminationPids.length === 0) {
204+pty.kill(signal);
205+return;
206+}
207+for (const pid of terminationPids.toReversed()) {
208+try {
209+process.kill(pid, signal);
210+} catch (error) {
211+if (error?.code !== "ESRCH") {
212+throw error;
213+}
214+}
215+}
216+}
217+121218function signalExitCode(signal) {
122219switch (signal) {
123220case "SIGHUP":
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。