| | | 1 | | import type { ChildProcess } from 'node:child_process'; |
| | | 2 | | import spawn from 'cross-spawn'; |
| | | 3 | | import { |
| | | 4 | | CommandNotExecutableError, |
| | | 5 | | CommandNotFoundError, |
| | | 6 | | CommandStartError, |
| | | 7 | | EnvironmentTooLargeError, |
| | | 8 | | } from '../../domain/errors/DomainErrors.js'; |
| | | 9 | | import type { |
| | | 10 | | IProcessRunner, |
| | | 11 | | ProcessExit, |
| | | 12 | | ProcessRequest, |
| | | 13 | | } from '../../domain/ports/IProcessRunner.js'; |
| | | 14 | | |
| | 10 | 15 | | const FORWARDED_SIGNALS = ['SIGTERM', 'SIGHUP'] as const; |
| | | 16 | | |
| | | 17 | | /** |
| | | 18 | | * Starts a child process with `cross-spawn`: argv-based, no shell, and with |
| | | 19 | | * Windows `.cmd`/`.bat` shims (npm, npx) resolved safely. |
| | | 20 | | * |
| | | 21 | | * While the child runs, termination signals received by Envilder are |
| | | 22 | | * forwarded to it and Envilder waits for it to exit, with no timeout of its |
| | | 23 | | * own: escalation belongs to the process manager. `SIGINT` is not forwarded |
| | | 24 | | * when attached to a terminal, because the terminal already delivers it to |
| | | 25 | | * the whole process group; Envilder only stays alive until the child exits. |
| | | 26 | | */ |
| | | 27 | | export class CrossSpawnProcessRunner implements IProcessRunner { |
| | | 28 | | run(request: ProcessRequest): Promise<ProcessExit> { |
| | 33 | 29 | | return new Promise((resolve, reject) => { |
| | | 30 | | let child: ChildProcess; |
| | 33 | 31 | | try { |
| | 33 | 32 | | child = spawn(request.command, request.args, { |
| | | 33 | | env: request.env, |
| | | 34 | | stdio: 'inherit', |
| | | 35 | | shell: false, |
| | | 36 | | }); |
| | | 37 | | } catch (error) { |
| | 10 | 38 | | reject(toSpawnError(error, request.command)); |
| | 10 | 39 | | return; |
| | | 40 | | } |
| | | 41 | | |
| | 23 | 42 | | const removeSignalForwarding = forwardSignals(child); |
| | 23 | 43 | | let settled = false; |
| | 23 | 44 | | const settle = (finish: () => void) => { |
| | 37 | 45 | | if (settled) { |
| | 14 | 46 | | return; |
| | | 47 | | } |
| | 23 | 48 | | settled = true; |
| | 23 | 49 | | removeSignalForwarding(); |
| | 23 | 50 | | finish(); |
| | | 51 | | }; |
| | | 52 | | |
| | 23 | 53 | | let spawned = false; |
| | 23 | 54 | | child.once('spawn', () => { |
| | 14 | 55 | | spawned = true; |
| | | 56 | | }); |
| | | 57 | | // Kept attached for the child's whole life: a later 'error' with no |
| | | 58 | | // listener would crash Envilder while the child is still running. |
| | 23 | 59 | | child.on('error', (error) => { |
| | 12 | 60 | | if (spawned && !isSpawnFailure(error)) { |
| | | 61 | | // For example a failed signal delivery (EPERM from kill): the child |
| | | 62 | | // is still running, so keep forwarding signals and wait for it. |
| | 3 | 63 | | return; |
| | | 64 | | } |
| | 9 | 65 | | settle(() => reject(toSpawnError(error, request.command))); |
| | | 66 | | }); |
| | 23 | 67 | | const onExit = (code: number | null, signal: NodeJS.Signals | null) => { |
| | 28 | 68 | | settle(() => resolve({ code, signal })); |
| | | 69 | | }; |
| | 23 | 70 | | child.once('exit', onExit); |
| | 23 | 71 | | child.once('close', onExit); |
| | | 72 | | }); |
| | | 73 | | } |
| | | 74 | | } |
| | | 75 | | |
| | | 76 | | function forwardSignals(child: ChildProcess): () => void { |
| | 23 | 77 | | const listeners = new Map<NodeJS.Signals, () => void>(); |
| | 69 | 78 | | const forward = (signal: NodeJS.Signals) => () => { |
| | 5 | 79 | | if (child.exitCode === null && child.signalCode === null) { |
| | 5 | 80 | | child.kill(signal); |
| | | 81 | | } |
| | | 82 | | }; |
| | | 83 | | |
| | 23 | 84 | | for (const signal of FORWARDED_SIGNALS) { |
| | 46 | 85 | | listeners.set(signal, forward(signal)); |
| | | 86 | | } |
| | | 87 | | // Listening to SIGINT also keeps Envilder alive until the child exits. |
| | 23 | 88 | | const forwardInterrupt = forward('SIGINT'); |
| | 23 | 89 | | listeners.set('SIGINT', () => { |
| | 2 | 90 | | if (!process.stdin.isTTY) { |
| | 1 | 91 | | forwardInterrupt(); |
| | | 92 | | } |
| | | 93 | | }); |
| | | 94 | | |
| | 23 | 95 | | for (const [signal, listener] of listeners) { |
| | 69 | 96 | | process.on(signal, listener); |
| | | 97 | | } |
| | 23 | 98 | | return () => { |
| | 23 | 99 | | for (const [signal, listener] of listeners) { |
| | 69 | 100 | | process.removeListener(signal, listener); |
| | | 101 | | } |
| | | 102 | | }; |
| | | 103 | | } |
| | | 104 | | |
| | | 105 | | /** |
| | | 106 | | * Whether an error reports that the command could not be started. cross-spawn |
| | | 107 | | * reports an unresolvable command on Windows after the `cmd.exe` wrapper has |
| | | 108 | | * spawned, with a `spawn <command>` syscall, so the syscall is checked rather |
| | | 109 | | * than only the moment the error arrives. |
| | | 110 | | */ |
| | | 111 | | function isSpawnFailure(error: unknown): boolean { |
| | 3 | 112 | | const syscall = (error as NodeJS.ErrnoException | undefined)?.syscall; |
| | 3 | 113 | | return typeof syscall === 'string' && syscall.startsWith('spawn'); |
| | | 114 | | } |
| | | 115 | | |
| | | 116 | | /** |
| | | 117 | | * Known reasons a command exists but cannot be executed (exit code 126). |
| | | 118 | | * Any other error code gets a generic reason with the code. |
| | | 119 | | */ |
| | 10 | 120 | | const NOT_EXECUTABLE_REASONS: Record<string, string> = { |
| | | 121 | | ETXTBSY: 'text file busy', |
| | | 122 | | ELOOP: 'too many levels of symbolic links', |
| | | 123 | | ENOEXEC: 'exec format error', |
| | | 124 | | EISDIR: 'is a directory', |
| | | 125 | | ENOTDIR: 'a path component is not a directory', |
| | | 126 | | ENAMETOOLONG: 'file name too long', |
| | | 127 | | }; |
| | 10 | 128 | | const GENERIC_NOT_EXECUTABLE_REASON = 'the operating system refused to run it'; |
| | | 129 | | |
| | | 130 | | /** The system could not provide what starting a process needs (exit 125). */ |
| | 10 | 131 | | const RESOURCE_ERRORS = new Set(['EAGAIN', 'EMFILE', 'ENFILE', 'ENOMEM']); |
| | | 132 | | |
| | | 133 | | /** |
| | | 134 | | * Maps a spawn failure to a domain error. Node throws some failures |
| | | 135 | | * synchronously and emits others on the child, so both paths use this. |
| | | 136 | | * |
| | | 137 | | * - ENOENT: the command was not found (127). |
| | | 138 | | * - E2BIG, resource exhaustion and failures without a code: Envilder could |
| | | 139 | | * not start the command (125). |
| | | 140 | | * - EACCES and every other code: the command cannot be executed (126). |
| | | 141 | | * |
| | | 142 | | * Messages name the command only, never its arguments. |
| | | 143 | | */ |
| | | 144 | | function toSpawnError(error: unknown, command: string): Error { |
| | 19 | 145 | | const code = (error as NodeJS.ErrnoException | undefined)?.code; |
| | 19 | 146 | | if (typeof code !== 'string' || code === '') { |
| | 1 | 147 | | return new CommandStartError(command, 'the process could not be created'); |
| | | 148 | | } |
| | 18 | 149 | | if (code === 'ENOENT') { |
| | 2 | 150 | | return new CommandNotFoundError(command); |
| | | 151 | | } |
| | 16 | 152 | | if (code === 'E2BIG') { |
| | 1 | 153 | | return new EnvironmentTooLargeError(); |
| | | 154 | | } |
| | 15 | 155 | | if (RESOURCE_ERRORS.has(code)) { |
| | 4 | 156 | | return new CommandStartError( |
| | | 157 | | command, |
| | | 158 | | `system resources unavailable (${code})`, |
| | | 159 | | ); |
| | | 160 | | } |
| | 11 | 161 | | if (code === 'EACCES') { |
| | 3 | 162 | | return new CommandNotExecutableError(command); |
| | | 163 | | } |
| | 8 | 164 | | const reason = Object.hasOwn(NOT_EXECUTABLE_REASONS, code) |
| | | 165 | | ? NOT_EXECUTABLE_REASONS[code] |
| | | 166 | | : GENERIC_NOT_EXECUTABLE_REASON; |
| | 19 | 167 | | return new CommandNotExecutableError(command, `${reason} (${code})`); |
| | | 168 | | } |