Node.js child_process spawn 에러 처리
Properly handling errors, stdio streams, and process exits when spawning child processes in Node.js.
Node.js child_process spawn 에러 처리
Introduction
Child processes allow Node.js to execute system commands, but improper error handling can lead to zombie processes, unhandled exceptions, and security vulnerabilities. This post covers the complete lifecycle of child process error handling.
Environment
node --version
# v20.11.0
# System commands available
which ffmpeg
# /usr/bin/ffmpeg
which convert
# /usr/bin/convert (ImageMagick)Problem
A build script using child_process was failing silently:
const { spawn } = require('child_process');
const build = spawn('npm', ['run', 'build']);
build.stdout.on('data', (data) => {
console.log(`stdout: ${data}`);
});
build.stderr.on('data', (data) => {
console.error(`stderr: ${data}`);
});
// No error handling!
// Process may exit with non-zero code but script continuesThe build process was failing but the script continued, deploying broken code.
Analysis
Child process errors occur at several points:
- Process spawn failure - Command not found, permission denied
- Runtime errors - Process crashes, out of memory
- Exit code - Process exits with non-zero status
- Signal termination - Process killed by SIGTERM, SIGKILL
The event flow:
spawn() → 'error' event (spawn failure)
→ 'close' event (process exited)
→ exit code or signalSolution
Solution 1: Proper spawn with error handling
const { spawn } = require('child_process');
function runCommand(command, args = [], options = {}) {
return new Promise((resolve, reject) => {
const process = spawn(command, args, {
stdio: ['pipe', 'pipe', 'pipe'],
shell: options.shell || false,
cwd: options.cwd || process.cwd(),
env: { ...process.env, ...options.env }
});
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
if (options.onStdout) options.onStdout(data);
});
process.stderr.on('data', (data) => {
stderr += data.toString();
if (options.onStderr) options.onStderr(data);
});
process.on('error', (err) => {
reject(new Error(`Failed to start ${command}: ${err.message}`));
});
process.on('close', (code, signal) => {
if (code === 0) {
resolve({ stdout, stderr, code });
} else {
const error = new Error(`Command failed with code ${code}`);
error.code = code;
error.signal = signal;
error.stdout = stdout;
error.stderr = stderr;
reject(error);
}
});
process.on('exit', (code, signal) => {
if (signal) {
reject(new Error(`Process killed by signal ${signal}`));
}
});
});
}
// Usage
async function build() {
try {
const result = await runCommand('npm', ['run', 'build'], {
onStdout: (data) => process.stdout.write(data),
onStderr: (data) => process.stderr.write(data)
});
console.log('Build completed successfully');
} catch (err) {
console.error('Build failed:', err.message);
console.error('stderr:', err.stderr);
process.exit(1);
}
}Solution 2: Timeout handling
function runWithTimeout(command, args, timeout = 60000) {
return new Promise((resolve, reject) => {
const child = spawn(command, args);
let killed = false;
const timer = setTimeout(() => {
killed = true;
child.kill('SIGTERM');
// Force kill after 5 seconds
setTimeout(() => {
if (!child.killed) {
child.kill('SIGKILL');
}
}, 5000);
reject(new Error(`Command timed out after ${timeout}ms`));
}, timeout);
child.on('close', (code) => {
if (!killed) {
clearTimeout(timer);
if (code === 0) {
resolve();
} else {
reject(new Error(`Exit code: ${code}`));
}
}
});
child.on('error', (err) => {
if (!killed) {
clearTimeout(timer);
reject(err);
}
});
});
}
// Usage
runWithTimeout('ffmpeg', ['-i', 'input.mp4', 'output.webm'], 30000)
.then(() => console.log('Conversion complete'))
.catch((err) => console.error('Conversion failed:', err.message));Solution 3: Handling shell injection safely
// DANGEROUS: Shell injection vulnerability
const userInput = 'file.txt; rm -rf /';
spawn('cat', [userInput], { shell: true }); // Executes rm command!
// SAFE: No shell interpretation
spawn('cat', [userInput], { shell: false });
// SAFE: Use execFile for known commands
const { execFile } = require('child_process');
execFile('ffmpeg', ['-i', 'input.mp4', 'output.webm'], (err, stdout, stderr) => {
if (err) {
console.error('Error:', err.message);
return;
}
console.log('Output:', stdout);
});Solution 4: Pipeline of commands
const { spawn } = require('child_process');
function pipeCommands(commands) {
return new Promise((resolve, reject) => {
let current = null;
commands.forEach((cmd, index) => {
const child = spawn(cmd.command, cmd.args, {
stdio: index === 0 ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe']
});
if (current) {
current.stdout.pipe(child.stdin);
}
current = child;
child.on('error', reject);
});
current.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`Pipeline failed with code ${code}`));
});
});
}
// Usage: pipe data through commands
pipeCommands([
{ command: 'cat', args: ['input.txt'] },
{ command: 'grep', args: ['error'] },
{ command: 'sort' },
{ command: 'uniq' }
]).catch(console.error);Lessons Learned
- Always handle the 'error' event - Unhandled errors crash the process
- Check exit codes - Non-zero means failure
- Use timeouts - Prevent zombie processes from hanging forever
- Never use shell: true with user input - It is a security vulnerability
- Use promises - Wrapping callbacks in promises makes error handling cleaner
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.