Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ The options object can have the following properties:
- `signal` - an `AbortSignal` to allow aborting of the execution
- `timeout` - time in milliseconds at which the process will be forcibly killed
- `persist` - if `true`, the process will continue after the host exits
- `killDescendants` - if `true`, terminating the process will also terminate
its descendants (defaults to `false`)
- `stdin` - `string` or another `Result` that will be used as the input to the process
- `nodeOptions` - any valid options to node's underlying `spawn` function
- `throwOnError` - if true, non-zero exit codes will throw an error
Expand Down Expand Up @@ -111,6 +113,37 @@ proc.kill();
proc.kill('SIGHUP');
```

### Killing descendant processes

By default, terminating a process only terminates the direct child. Any
processes spawned by that child can continue running.

Pass `killDescendants: true` to terminate the process tree when `kill()` is
called, a timeout expires, or an `AbortSignal` aborts:

```ts
const proc = x('node', ['./server.mjs'], {
killDescendants: true
});

proc.kill();
await proc;
```

On Unix, tinyexec starts the subprocess in its own process group and signals
that group. On Windows, it uses `taskkill /T /F`. This is best-effort:
descendants that create their own process group or session are not terminated,
and Windows falls back to terminating only the direct child if `taskkill`
cannot be used.

On Unix, this option needs the subprocess to lead its own process group, so it
implies `detached: true`. This has the same effects as `persist`: the
subprocess keeps running after the host exits, and terminal signals such as
`CTRL-C` are not forwarded to it automatically. Any `nodeOptions.detached`
value you set is overridden.

Like `persist`, this option is not supported by the synchronous API.

### Node modules/binaries

By default, node's available binaries from `node_modules` will be accessible
Expand Down Expand Up @@ -197,6 +230,7 @@ Since the synchronous API blocks the event loop, there are some features that ar

- `signal`
- `persist`
- `killDescendants`
- `kill()` method
- `stdin` piping
- `pipe()` method
Expand Down
24 changes: 24 additions & 0 deletions THIRD_PARTY_LICENSES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,27 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

# execa

MIT License

Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
102 changes: 102 additions & 0 deletions src/kill-descendants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {
type ChildProcess,
execFile,
type SpawnOptions
} from 'node:child_process';
import {
join as joinWindowsPath,
parse as parseWindowsPath
} from 'node:path/win32';

type KillSignal = Parameters<ChildProcess['kill']>[0];
type KillFunction = (signal?: KillSignal) => boolean;

const isWindows = process.platform === 'win32';
const isWindowsDriveRootRegExp = /^[a-z]:[\\/]/i;

/**
* Makes the subprocess lead its own process group, so a negative PID can
* later signal the whole group. Windows has no process groups, so the
* options come back untouched there.
*/
export function detachProcessGroup(options: SpawnOptions): SpawnOptions {
return isWindows ? options : {...options, detached: true};
}

/**
* Creates a replacement for `subprocess.kill` which takes the descendants
* down too. It's best-effort: descendants which start their own group or
* session survive, and Windows falls back to killing the direct child.
*/
export function createKillFunction(
subprocess: ChildProcess,
env: NodeJS.ProcessEnv = process.env
): KillFunction {
// Capture the original method before the caller replaces it
const kill = subprocess.kill.bind(subprocess);

return (signal): boolean => {
// Signal 0 is a liveness probe, not a termination.
if (signal === 0) {
return kill(signal);
}

if (subprocess.pid === undefined) {
return false;
}

return isWindows
? killWindowsTree(subprocess.pid, signal, kill, env)
: killProcessGroup(subprocess.pid, signal, kill);
};
}

function killWindowsTree(
pid: number,
signal: KillSignal,
kill: KillFunction,
env: NodeJS.ProcessEnv
): boolean {
const taskkillFile = resolveTaskkillPath(env);

if (taskkillFile === undefined) {
return kill(signal);
}

// taskkill must run before the direct child exits, or its descendants
// might be orphaned before Windows can enumerate the process tree.
execFile(taskkillFile, ['/pid', String(pid), '/T', '/F'], (error) => {
if (error) {
kill(signal);
}
});

return true;
}

function killProcessGroup(
pid: number,
signal: KillSignal,
kill: KillFunction
): boolean {
try {
// A negative PID signals the whole process group on Unix.
return process.kill(-pid, signal);
} catch {
// group's gone, or we're not allowed to signal it
return kill(signal);
}
}

function resolveTaskkillPath(env: NodeJS.ProcessEnv): string | undefined {
// Resolve the system binary directly instead of relying on PATH.
const windowsDirectory = [env.SystemRoot, env.windir].find(
(directory): directory is string =>
directory !== undefined &&
isWindowsDriveRootRegExp.test(parseWindowsPath(directory).root)
);

return windowsDirectory === undefined
? undefined
: joinWindowsPath(windowsDirectory, 'System32', 'taskkill.exe');
}
26 changes: 23 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {combineStreams} from './stream.js';
import readline from 'node:readline';
import {normalizeSpawnCommand} from './normalize.js';
import {NonZeroExitError} from './non-zero-exit-error.js';
import {createKillFunction, detachProcessGroup} from './kill-descendants.js';

export {NonZeroExitError, normalizeSpawnCommand};

Expand Down Expand Up @@ -64,6 +65,7 @@ export interface Options extends CommonOptions {
nodeOptions: SpawnOptions;
persist: boolean;
stdin: Result | ExecProcess | string;
killDescendants: boolean;
}

export interface SyncOptions extends CommonOptions {
Expand All @@ -80,7 +82,8 @@ export interface TinyExec {

const defaultOptions = {
timeout: undefined,
persist: false
persist: false,
killDescendants: false
} satisfies Partial<Options>;

const defaultSyncOptions = {
Expand Down Expand Up @@ -128,6 +131,7 @@ async function readStream(stream: Readable): Promise<string> {
export class ExecProcess implements Result {
protected _process?: ChildProcess;
protected _aborted: boolean = false;
protected _killed: boolean = false;
protected _options: Partial<Options>;
protected _command: string;
protected _args: readonly string[];
Expand Down Expand Up @@ -179,7 +183,7 @@ export class ExecProcess implements Result {
}

public get killed(): boolean {
return this._process?.killed === true;
return this._killed || this._process?.killed === true;
}

public pipe(
Expand Down Expand Up @@ -318,10 +322,11 @@ export class ExecProcess implements Result {

nodeOptions.env = computeEnv(cwd, nodeOptions.env, options.nodePath);

const killDescendants = options.killDescendants === true;
const crossResult = normalizeSpawnCommand(
this._command,
this._args,
nodeOptions
killDescendants ? detachProcessGroup(nodeOptions) : nodeOptions
);

const handle = spawn(
Expand All @@ -330,6 +335,20 @@ export class ExecProcess implements Result {
crossResult.options
);

if (killDescendants) {
// node's timeout and abort handling calls the public kill method
const kill = createKillFunction(handle);
handle.kill = (signal): boolean => {
const killed = kill(signal);

if (killed) {
this._killed = true;
}

return killed;
};
}

if (handle.stderr) {
this._streamErr = handle.stderr;
}
Expand All @@ -354,6 +373,7 @@ export class ExecProcess implements Result {

protected _resetState(): void {
this._aborted = false;
this._killed = false;
this._processClosed = new Promise<void>((resolve) => {
this._resolveClose = resolve;
});
Expand Down
Loading