1. 为什么需要自己的执行器?
Node.js 自带 child_process.spawn(),为什么还要封装?
- 超时自动杀:一条命令如果跑飞了(死循环、等待网络),需要自动杀掉
- 进程树清理 :
spawn出来的子进程可能再 fork 出孙进程,杀子进程时孙进程也要一起清理 - 统一 IO 回调:stdout/stderr 的数据需要流式地传给上层,不能等命令跑完再读
2. spawnAndWait------核心执行函数
typescript
export function spawnAndWait(
command: string,
args: string[],
timeoutMs: number,
options?: SpawnOptions,
io?: SpawnIoHooks,
): Promise<number> {
return new Promise((resolve) => {
const child: ChildProcess = spawn(command, args, {
stdio: ['ignore', 'pipe', 'pipe'],
detached: process.platform !== 'win32',
...options,
});
const timer = setTimeout(() => {
io?.onTimeout?.();
killProcessTree(child);
}, timeoutMs);
child.stdout?.on('data', (chunk: Buffer) => {
io?.onStdout?.(chunk);
});
child.stderr?.on('data', (chunk: Buffer) => {
io?.onStderr?.(chunk);
});
child.on('error', (error) => {
clearTimeout(timer);
io?.onSpawnError?.(error.message);
resolve(1);
});
child.on('close', (code) => {
clearTimeout(timer);
resolve(typeof code === 'number' ? code : 1);
});
});
}
2.1 逐步拆解
第一步:启动子进程
typescript
const child = spawn(command, args, {
stdio: ['ignore', 'pipe', 'pipe'], // stdin 忽略,stdout/stderr 用管道
detached: process.platform !== 'win32', // 非 Windows 用进程组
...options,
});
stdio: ['ignore', 'pipe', 'pipe']:不需要向子进程输入,但需要读它的输出detached: true(非 Windows):让子进程成为新进程组的组长。这样process.kill(-child.pid)可以杀掉整个进程组
第二步:设超时定时器
typescript
const timer = setTimeout(() => {
io?.onTimeout?.(); // 通知上层"超时了"
killProcessTree(child); // 杀掉整个进程树
}, timeoutMs);
第三步:监听 stdout/stderr
typescript
child.stdout?.on('data', (chunk: Buffer) => {
io?.onStdout?.(chunk); // 流式传给上层(outputCapture 会接住)
});
第四步:监听退出
typescript
child.on('close', (code) => {
clearTimeout(timer); // 命令正常结束了,取消定时器
resolve(typeof code === 'number' ? code : 1);
});
2.2 关键设计决策
为什么 on('error') 不抛异常而是 resolve(1)?
typescript
child.on('error', (error) => {
clearTimeout(timer);
io?.onSpawnError?.(error.message);
resolve(1); // 不抛异常!
});
因为这样可以方便上层订立统一的错误处理流程------无论命令是"跑完但退出码非0"还是"根本没跑起来",都走同一条审计路径。抛异常会打断这个流程。
3. killProcessTree------杀进程树
typescript
function killProcessTree(child: ChildProcess): void {
if (!child.pid) return;
if (process.platform !== 'win32') {
try {
process.kill(-child.pid, 'SIGKILL'); // 杀整个进程组
return;
} catch {
// ignore and fallback
}
}
try {
child.kill('SIGKILL'); // fallback:只杀子进程本身
} catch {
// ignore
}
}
3.1 为什么需要杀进程树?
假设子进程是 bash -c "sleep 1000 & sleep 2000":
bashfork 出了两个sleep子进程- 如果你只杀
bash,两个sleep会变成孤儿进程继续跑 process.kill(-child.pid, 'SIGKILL')会杀掉整个进程组,包括所有孙进程
3.2 为什么用 detached + 负 PID?
typescript
detached: process.platform !== 'win32'
在 macOS/Linux 上,detached: true 会让子进程成为新进程组的组长。进程组有一个组 ID,等于组长进程的 PID。用 process.kill(-pid, 'SIGKILL')(注意负号)可以杀掉这个进程组里的所有进程。
Windows 不支持这种机制,所以 detached 设为 false,fallback 到只杀子进程本身。
4. spawnShellUnsafe------Shell 执行
typescript
export function spawnShellUnsafe(
shellCommand: string,
cwd: string,
timeoutMs: number,
spawnEnv?: SpawnOptions['env'],
io?: SpawnIoHooks,
): Promise<number> {
const shellSpec =
process.platform === 'win32'
? { command: 'cmd.exe', args: ['/d', '/s', '/c', shellCommand] }
: { command: '/bin/bash', args: ['-lc', shellCommand] };
return spawnAndWait(
shellSpec.command,
shellSpec.args,
timeoutMs,
{
cwd,
env: spawnEnv ?? { ...process.env, PATH: process.env.PATH ?? '' },
},
io,
);
}
4.1 为什么叫 "Unsafe"?
因为 shell 命令可以解释管道 |、重定向 >、变量展开 $VAR、命令替换 cmd 等。这些特性使得命令的实际行为比字符串看起来复杂得多。
命名上就提醒调用方:用这个函数要小心,命令可能干出你意料之外的事。
4.2 跨平台 Shell
| 平台 | Shell | 参数 |
|---|---|---|
| Windows | cmd.exe |
/d /c "command" |
| macOS/Linux | /bin/bash |
-lc "command" |
5. SpawnIoHooks 回调
typescript
export interface SpawnIoHooks {
onStdout?: (chunk: Buffer) => void; // 收到一块 stdout
onStderr?: (chunk: Buffer) => void; // 收到一块 stderr
onTimeout?: () => void; // 命令超时了
onSpawnError?: (message: string) => void; // spawn 失败(比如命令不存在)
}
这些回调让上层可以:
- 流式落盘 :每收到一块数据就写到文件(
outputCapture) - 超时感知:知道命令是因为超时被杀的
- spawn 错误感知:知道命令根本没跑起来
6. 小结
| 函数 | 作用 |
|---|---|
spawnAndWait |
底层 spawn:启动子进程、超时杀、流式 IO |
spawnShellUnsafe |
Shell 执行的便捷封装 |
killProcessTree |
杀整个进程组(不只是子进程) |
关键设计点:
- 不抛异常,只返回退出码------让上层统一处理
- detached + 负 PID------杀进程组而非单个进程
- Shell 执行标记 "Unsafe"------提醒调用方注意安全风险
- 流式 IO 回调------数据不等命令跑完,来一块传一块