Nothing Special   »   [go: up one dir, main page]

Skip to content

Latest commit

 

History

History
358 lines (250 loc) · 13.5 KB

termination.md

File metadata and controls

358 lines (250 loc) · 13.5 KB
execa logo

🏁 Termination

Alternatives

Terminating a subprocess ends it abruptly. This prevents rolling back the subprocess' operations and leaves them incomplete.

Ideally subprocesses should end on their own. If that's not possible, graceful termination should be preferred.

Canceling

The cancelSignal option can be used to cancel a subprocess. When it is aborted, a SIGTERM signal is sent to the subprocess.

import {execaNode} from 'execa';

const controller = new AbortController();
const cancelSignal = controller.signal;

setTimeout(() => {
	controller.abort();
}, 5000);

try {
	await execaNode({cancelSignal})`build.js`;
} catch (error) {
	if (error.isCanceled) {
		console.error('Canceled by cancelSignal.');
	}

	throw error;
}

Graceful termination

Share a cancelSignal

When the gracefulCancel option is true, the cancelSignal option does not send any SIGTERM. Instead, the subprocess calls getCancelSignal() to retrieve and handle the AbortSignal. This allows the subprocess to properly clean up and abort operations.

This option only works with Node.js files.

This is cross-platform. If you do not need to support Windows, signal handlers can also be used.

// main.js
import {execaNode} from 'execa';

const controller = new AbortController();
const cancelSignal = controller.signal;

setTimeout(() => {
	controller.abort();
}, 5000);

try {
	await execaNode({cancelSignal, gracefulCancel: true})`build.js`;
} catch (error) {
	if (error.isGracefullyCanceled) {
		console.error('Cancelled gracefully.');
	}

	throw error;
}
// build.js
import {getCancelSignal} from 'execa';

const cancelSignal = await getCancelSignal();

Abort operations

The AbortSignal returned by getCancelSignal() can be passed to most long-running Node.js methods: setTimeout(), setInterval(), events, streams, REPL, HTTP/TCP requests or servers, reading / writing / watching files, or spawning another subprocess.

When aborted, those methods throw the Error instance which was passed to abortController.abort(error). Since those methods keep the subprocess alive, aborting them makes the subprocess end on its own.

import {getCancelSignal} from 'execa';
import {watch} from 'node:fs/promises';

const cancelSignal = await getCancelSignal();

try {
	for await (const fileChange of watch('./src', {signal: cancelSignal})) {
		onFileChange(fileChange);
	}
} catch (error) {
	if (error.isGracefullyCanceled) {
		console.log(error.cause === cancelSignal.reason); // true
	}
}

Cleanup logic

For other kinds of operations, the abort event should be listened to. Although cancelSignal.addEventListener('abort') can be used, events.addAbortListener(cancelSignal) is preferred since it works even if the cancelSignal is already aborted.

Graceful exit

We recommend explicitly stopping each pending operation when the subprocess is aborted. This allows it to end on its own.

import {getCancelSignal} from 'execa';
import {addAbortListener} from 'node:events';

const cancelSignal = await getCancelSignal();
addAbortListener(cancelSignal, async () => {
	await cleanup();
	process.exitCode = 1;
});

However, if any operation is still ongoing, the subprocess will keep running. It can be forcefully ended using process.exit(exitCode) instead of process.exitCode.

If the subprocess is still alive after 5 seconds, it is forcefully terminated with SIGKILL. This can be configured or disabled using the forceKillAfterDelay option.

Timeout

Execution timeout

If the subprocess lasts longer than the timeout option, a SIGTERM signal is sent to it.

try {
	await execa({timeout: 5000})`npm run build`;
} catch (error) {
	if (error.timedOut) {
		console.error('Timed out.');
	}

	throw error;
}

Inactivity timeout

To terminate a subprocess when it becomes inactive, the cancelSignal option can be combined with transforms and some debouncing logic. The following example terminates the subprocess if it has not printed to stdout/stderr in the last minute.

import {execa} from 'execa';
import debounceFn from 'debounce-fn';

// 1 minute
const wait = 60_000;

const getInactivityOptions = () => {
	const controller = new AbortController();
	const cancelSignal = controller.signal;

	// Delay and debounce `cancelSignal` each time `controller.abort()` is called
	const scheduleAbort = debounceFn(controller.abort.bind(controller), {wait});

	const onOutput = {
		* transform(data) {
			// When anything is printed, debounce `controller.abort()`
			scheduleAbort();

			// Keep the output as is
			yield data;
		},
		// Debounce even if the output does not include any newline
		binary: true,
	};

	// Start debouncing
	scheduleAbort();

	return {
		cancelSignal,
		stdout: onOutput,
		stderr: onOutput,
	};
};

const options = getInactivityOptions();

await execa(options)`npm run build`;

Current process exit

If the current process exits, the subprocess is automatically terminated unless either:

  • The cleanup option is false.
  • The subprocess is run in the background using the detached option.
  • The current process was terminated abruptly, for example, with SIGKILL as opposed to SIGTERM or a successful exit.

Signal termination

subprocess.kill() sends a signal to the subprocess. This is an inter-process message handled by the OS. Most (but not all) signals terminate the subprocess.

More info.

SIGTERM

SIGTERM is the default signal. It terminates the subprocess. On Unix, it can be handled to run some cleanup logic.

const subprocess = execa`npm run build`;
subprocess.kill();
// Is the same as:
subprocess.kill('SIGTERM');

SIGINT

SIGINT terminates the process. Its handler is triggered on CTRL-C.

subprocess.kill('SIGINT');

SIGKILL

SIGKILL forcefully terminates the subprocess. It cannot be handled.

subprocess.kill('SIGKILL');

SIGQUIT

SIGQUIT terminates the process. On Unix, it creates a core dump.

subprocess.kill('SIGQUIT');

Other signals

Other signals can be passed as argument. However, most other signals do not fully work on Windows.

Default signal

The killSignal option sets the default signal used by subprocess.kill() and the following options: cancelSignal, timeout, maxBuffer and cleanup. It is SIGTERM by default.

const subprocess = execa({killSignal: 'SIGKILL'})`npm run build`;
subprocess.kill(); // Forceful termination

Handling signals

On Unix, most signals (not SIGKILL) can be intercepted to perform a graceful exit.

process.on('SIGTERM', () => {
	cleanup();
	process.exit(1);
})

Unfortunately this usually does not work on Windows. The only signal that is somewhat cross-platform is SIGINT: on Windows, its handler is triggered when the user types CTRL-C in the terminal. However subprocess.kill('SIGINT') is only handled on Unix.

Execa provides the gracefulCancel option as a cross-platform alternative to signal handlers.

Signal name and description

When a subprocess was terminated by a signal, error.isTerminated is true.

Also, error.signal and error.signalDescription indicate the signal's name and human-friendly description. On Windows, those are only set if the current process terminated the subprocess, as opposed to another process.

try {
	await execa`npm run build`;
} catch (error) {
	if (error.isTerminated) {
		console.error(error.signal); // SIGFPE
		console.error(error.signalDescription); // 'Floating point arithmetic error'
	}

	throw error;
}

Forceful termination

If the subprocess is terminated but does not exit, SIGKILL is automatically sent to forcefully terminate it.

The grace period is set by the forceKillAfterDelay option, which is 5 seconds by default. This feature can be disabled with false.

The error.isForcefullyTerminated boolean property can be used to check whether a subprocess was forcefully terminated by the forceKillAfterDelay option.

This works when the subprocess is terminated by either:

This does not work when the subprocess is terminated by either:

Also, this does not work on Windows, because Windows doesn't support signals: SIGKILL and SIGTERM both terminate the subprocess immediately. Other packages (such as taskkill) can be used to achieve fail-safe termination on Windows.

// No forceful termination
const subprocess = execa({forceKillAfterDelay: false})`npm run build`;
subprocess.kill();

Inter-process termination

subprocess.kill() only works when the current process terminates the subprocess. To terminate the subprocess from a different process, its subprocess.pid can be used instead.

const subprocess = execa`npm run build`;
console.log('PID:', subprocess.pid); // PID: 6513
await subprocess;

For example, from a terminal:

$ kill -SIGTERM 6513

Or from a different Node.js process:

import process from 'node:process';

process.kill(subprocessPid);

Error message and stack trace

When terminating a subprocess, it is possible to include an error message and stack trace by using subprocess.kill(error). The error argument will be available at error.cause.

try {
	const subprocess = execa`npm run build`;
	setTimeout(() => {
		subprocess.kill(new Error('Timed out after 5 seconds.'));
	}, 5000);
	await subprocess;
} catch (error) {
	if (error.isTerminated) {
		console.error(error.cause); // new Error('Timed out after 5 seconds.')
		console.error(error.cause.stack); // Stack trace from `error.cause`
		console.error(error.originalMessage); // 'Timed out after 5 seconds.'
	}

	throw error;
}

Next: 🎹 Input
Previous: ❌ Errors
Top: Table of contents