Skip to content
academia.sh

Lesson 11 / 20

Child Processes

Two ways to run an external command, the risk brought by the shell layer, the distinction between an exit code and a terminating signal, and passing messages between runtime processes.

Contents

The previous lesson set up the service in a single process. What a single process can do is limited, and some jobs it should not do at all: rewriting the work of a tool that already exists on the system means trying to reproduce that tool’s years of tested behavior.

This lesson covers moving work outside the process. There are two separate needs: running an external command, and starting a second process from the same runtime and talking to it.

Running an External Command

spawn in the node:child_process module starts a program as a separate process and gives its three standard streams as an object. The stream model from the Shell Programming course finds its exact counterpart here.

// child-process.mjs
import { spawn } from 'node:child_process';
import { once } from 'node:events';

function run(command, args) {
  const child = spawn(command, args);      // no shell: args pass as an array
  const output = [];
  const errorOutput = [];
  child.stdout.on('data', (c) => output.push(c));
  child.stderr.on('data', (c) => errorOutput.push(c));
  return once(child, 'close').then(([code, signal]) => ({
    code,
    signal,
    output: Buffer.concat(output).toString('utf8').trim(),
    error: Buffer.concat(errorOutput).toString('utf8').trim(),
  }));
}

console.log('wc -l    :', await run('wc', ['-l', 'measurements.ndjson']));
console.log('missing  :', await run('cat', ['missing.txt']));
node child-process.mjs
wc -l    : { code: 0, signal: null, output: '12 measurements.ndjson', error: '' }
missing  : {
  code: 1,
  signal: null,
  output: '',
  error: 'cat: missing.txt: No such file or directory'
}

The difference between the two calls is in the exit code. Zero reports success, one failure; the rule from the Shell Programming course has not changed. The diagnostic message came from the second stream and did not get mixed with the data — this is the runtime-side counterpart of that same distinction.

Chunks are collected as buffers and joined at the end. Converting each chunk to a string separately and concatenating would have produced the boundary corruption shown in the buffers lesson.

The Shell Layer and Argument Passing

The same module has another function called exec. The difference is that it hands the command to a shell process as text. The shell interprets that text with its own rules: variable expansion, path expansion, pipelines, semicolon-separated commands.

This interpretation becomes a problem when a piece coming from outside is inserted into the command text:

// shell-injection.mjs
import { execFile, exec } from 'node:child_process';
import { promisify } from 'node:util';

const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);

// An untrusted file name coming from outside
const fileName = 'measurements.ndjson; echo INJECTED';

// 1. Call that inserts a shell: the text is interpreted by the shell
const { stdout: shellOutput } = await execAsync(`wc -l ${fileName}`);
console.log('exec     :', JSON.stringify(shellOutput.trim()));

// 2. Call without a shell: the name passes as a single argument
try {
  await execFileAsync('wc', ['-l', fileName]);
} catch (error) {
  console.log('execFile :', error.stderr.trim());
}
node shell-injection.mjs
exec     : "12 measurements.ndjson\nINJECTED"
execFile : wc: measurements.ndjson; echo INJECTED: open: No such file or directory

In the first call, the command after the semicolon also ran. Had the text substituted for the file name been an account-deleting or data-exfiltrating command, the result would have been run in the exact same way. In the second call, the entire text passed as a single argument; wc looked for a file by that name and did not find one.

Rule: do not build a command’s text by string concatenation. When the program name and argument array are given separately, no shell sits in between and no text is ever formed for interpretation. If a shell feature is genuinely needed — a pipeline or redirection, say — a fixed command text is written and the variable parts are passed as environment variables.

promisify is the built-in helper that converts a callback-style signature into a promise-returning one. Most of the runtime’s callback-based interfaces support this conversion.

exec and execFile also accumulate output in memory and terminate the process once it crosses a limit. spawn is used for commands producing large output, and the output is processed through the stream.

Exit Code and Terminating Signal

A child process can end in two different ways: exiting by its own request, or being terminated by a signal. The close event reports these two cases in separate fields.

// signal.mjs
import { spawn } from 'node:child_process';
import { once } from 'node:events';

// A long-running job; we will not wait for it to finish on its own
const child = spawn('sleep', ['30']);
console.log('child started, separate process:', child.pid !== process.pid);

setTimeout(() => child.kill('SIGTERM'), 100);

const [code, signal] = await once(child, 'close');
console.log('exit code:', code, '| terminating signal:', signal);
node signal.mjs
child started, separate process: true
exit code: null | terminating signal: SIGTERM

A process terminated by a signal has no exit code; the field comes as null. A check that looks only at the code cannot pass a code === 0 test, but it could also misread a code !== 0 test. The correct check reads both fields together: success is a code of zero and no signal.

The signal name itself was introduced in the Shell Programming course; SIGTERM is a polite termination request, and the process can catch it and clean up. A forceful terminating signal cannot be caught and gives no chance to clean up; for this reason it is used only when the polite request goes unanswered. A process’s own shutdown behavior is the subject of the Process Management and Robustness lesson.

A Second Process From the Same Runtime

The measurement collector’s expensive work is its own code, not an external tool. fork is used for this: it starts a new instance of the same runtime and sets up a message channel between the two processes.

// summary-child.mjs
import { readFile } from 'node:fs/promises';

process.on('message', async ({ file }) => {
  const text = await readFile(file, 'utf8');
  const buckets = new Map();
  for (const line of text.split('\n')) {
    if (line.trim() === '') continue;
    const { node, metric, value } = JSON.parse(line);
    const key = `${node}/${metric}`;
    const bucket = buckets.get(key) ?? { count: 0, total: 0 };
    bucket.count += 1;
    bucket.total += value;
    buckets.set(key, bucket);
  }
  process.send({ pidIsNumber: typeof process.pid === 'number', bucketCount: buckets.size });
  process.disconnect();
});
// summary-main.mjs
import { fork } from 'node:child_process';
import { once } from 'node:events';

const child = fork('./summary-child.mjs');
console.log('separate process:', child.pid !== process.pid);

child.send({ file: 'measurements.ndjson' });
const [message] = await once(child, 'message');
console.log('from child:', message);

const [code] = await once(child, 'exit');
console.log('child exit code:', code);
node summary-main.mjs
separate process: true
from child: { pidIsNumber: true, bucketCount: 6 }
child exit code: 0

No memory is shared between the two processes. The value given to send is serialized, passed through the channel, and reconstructed on the other side; only serializable values can be carried — a function, a class instance, and a file descriptor do not pass directly. This copying is a measurable cost for large data: rather than sending millions of records to the child, sending the file name and leaving the reading to it is preferred. The example above does exactly that.

The disconnect call closes the channel. As long as the channel stays open, the two processes keep each other alive; if a child has finished its work but does not exit, this is usually why.

Isolation has one more benefit: a crash in the child process does not bring down the main process. The main process sees the exit event and makes the decision itself — it can restart, answer the request with an error, or do the work itself.

Summary

  • spawn starts a program as a separate process and gives its three standard streams as an object; large output is processed through the stream.
  • Running through a shell interprets the command text; when a piece coming from outside is inserted into that text, an extra command can run. The program name and argument array are given separately.
  • The exit code and terminating signal are separate fields; a process terminated by a signal has no code, and a success check reads both fields together.
  • fork starts a second instance of the same runtime and sets up a message channel; messages are copied through serialization, no memory is shared.
  • Process isolation turns a child’s crash into a decision the main process makes.

Next Step

A child process is a way to move work out of the main thread, but a heavy one: each process carries its own memory and its own runtime instance, and every message between them is copied. The next lesson covers two alternatives — a set of processes sharing the same port, and threads that can share memory within the same process — and determines which fits which kind of work.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close