troubleshooting2025-04-15·8·117/348

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 continues

The build process was failing but the script continued, deploying broken code.

Analysis

Child process errors occur at several points:

  1. Process spawn failure - Command not found, permission denied
  2. Runtime errors - Process crashes, out of memory
  3. Exit code - Process exits with non-zero status
  4. Signal termination - Process killed by SIGTERM, SIGKILL

The event flow:

spawn() → 'error' event (spawn failure)
       → 'close' event (process exited)
       → exit code or signal

Solution

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

  1. Always handle the 'error' event - Unhandled errors crash the process
  2. Check exit codes - Non-zero means failure
  3. Use timeouts - Prevent zombie processes from hanging forever
  4. Never use shell: true with user input - It is a security vulnerability
  5. Use promises - Wrapping callbacks in promises makes error handling cleaner

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.