Child Processes¶
Your Node app runs on one thread and that one thread better not block But sometimes you need to run a Python script , execute a shell command , or offload work to another process without frozen Event Loop Child processes give you OS-level process isolation - separate memory , separate V8 instances , separate everything
The child_process Module¶
Four ways to spawn children , each for a different scenario
const { spawn, exec, execFile, fork } = require('child_process')
spawn - streaming I/O , best for long-running processes and large data exec - buffered output , best for short commands where you want stdout as one string execFile - like exec but no shell , directly executes a binary (safer) fork - spawns a Node.js process with IPC channel built in
spawn vs exec - Streaming vs Buffered¶
// spawn - streams stdout as data arrives
const { spawn } = require('child_process')
const child = spawn('find', ['/', '-name', '*.log'])
child.stdout.on('data', (data) => {
console.log(`stdout chunk: ${data.length} bytes`)
})
child.stderr.on('data', (data) => {
console.error(`stderr: ${data}`)
})
child.on('exit', (code, signal) => {
console.log(`child exited with code ${code}, signal ${signal}`)
})
spawn is your default choice. It streams output in chunks as the child produces them so memory stays flat even if the child outputs gigabytes
// exec - buffers everything into one string
const { exec } = require('child_process')
exec('ls -la', (error, stdout, stderr) => {
if (error) {
console.error(`exec error: ${error.message}`)
return
}
console.log(`stdout:\n${stdout}`)
if (stderr) console.error(`stderr:\n${stderr}`)
})
exec buffers up to maxBuffer (default 1MB) then kills the child if output exceeds it Great for "run this quick command and give me the output" - terrible for large data
// execFile - no shell , direct binary execution
const { execFile } = require('child_process')
execFile('/usr/bin/node', ['-e', 'console.log("hello")'], (error, stdout) => {
console.log(stdout)
})
No shell expansion , no pipe operators , no command chaining This is the safest option when you control the binary path and arguments
fork() - IPC with the Child¶
// parent.js
const { fork } = require('child_process')
const child = fork('./worker.js')
child.send({ cmd: 'process', data: { id: 42, payload: 'analyze this' } })
child.on('message', (msg) => {
console.log('from child:', msg)
child.disconnect()
})
child.on('exit', (code) => {
console.log(`child exited: ${code}`)
})
// worker.js
process.on('message', (msg) => {
console.log('received:', msg)
// do CPU work
const result = msg.data.payload.toUpperCase()
process.send({ status: 'done', result })
})
fork creates a new Node process with a built-in IPC channel Messages are serialized with JSON.stringify/parse - no function passing , no prototypes
Error Handling - Exit Codes and Stderr¶
const { spawn } = require('child_process')
function runCommand(cmd, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
timeout: options.timeout || 30000,
shell: false,
...options
})
let stdout = ''
let stderr = ''
child.stdout.on('data', (data) => { stdout += data })
child.stderr.on('data', (data) => { stderr += data })
child.on('error', (err) => {
reject(new Error(`Failed to spawn: ${err.message}`))
})
child.on('exit', (code, signal) => {
const metadata = { code, signal, stdout, stderr }
if (code !== 0) {
if (signal === 'SIGTERM') {
return reject(new Error(`Process timed out after ${options.timeout}ms`))
}
return reject(Object.assign(new Error(`Exit code ${code}`), metadata))
}
resolve(metadata)
})
})
}
// Usage
try {
const result = await runCommand('node', ['-e', 'process.exit(1)'], { timeout: 5000 })
console.log(result.stdout)
} catch (err) {
console.error(`Command failed: ${err.message}`)
console.error(`stderr: ${err.stderr}`)
}
Always handle: error event (failed to spawn), exit event (process finished), timeout, and stderr A non-zero exit code does NOT trigger the 'error' event - check code !== 0 in the exit handler
Security - Command Injection via Shell¶
// DANGER - shell injection vulnerability
const { exec } = require('child_process')
const userInput = req.query.filename // attacker sends: "; rm -rf / ;"
exec(`cat ${userInput}`, (err, stdout) => {
console.log(stdout)
})
When shell: true (default for exec), the command string is passed to /bin/sh -c If user input contains ; , | , $() , or backticks - attacker gets arbitrary command execution
// SAFE - pass arguments as array , shell: false
const { spawn } = require('child_process')
spawn('cat', [userInput], { shell: false })
// userInput is passed as a literal argument to cat, not interpreted by shell
Rules: - NEVER use shell: true with user input in the command string - Use spawn with argument arrays instead of exec when possible - If you must use exec , validate and sanitize every input character - Whitelist allowed characters: ^[a-zA-Z0-9_\\-\\.\\/]+$ - execFile is inherently safer than exec (no shell by default)
// Input validation for exec
function sanitizeShellArg(input) {
// Only allow safe characters
if (!/^[a-zA-Z0-9_\-\.\/]+$/.test(input)) {
throw new Error('Invalid characters in input')
}
return input
}
const safeFile = sanitizeShellArg(userInput)
exec(`cat ${safeFile}`, (err, stdout) => {
// safer but still prefer spawn
})
Another attack vector: Argument injection with -- flags If user passes --eval=require('child_process').execSync('rm -rf /') to a Node process , you're fucked
Prerequisites¶
- deploy_05_monitoring.md - understand process monitoring for child process lifecycle
next -> adv_02_worker_threads.md