@j-o-r/sh 1.1.31 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/SH.js +174 -149
- package/lib/SHDispatch.js +29 -26
- package/lib/SHExecute.js +300 -235
- package/lib/internal.js +179 -0
- package/package.json +3 -3
- package/types/SH.d.ts +74 -34
- package/types/SHDispatch.d.ts +14 -30
- package/types/SHExecute.d.ts +41 -13
- package/types/internal.d.ts +91 -0
- package/TODO.md +0 -8
package/lib/SHExecute.js
CHANGED
|
@@ -1,58 +1,16 @@
|
|
|
1
1
|
import { spawnSync, spawn } from 'node:child_process';
|
|
2
|
+
import { DEFAULT_MAX_BUFFER, parseDuration } from './internal.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* @param {unknown} n - Value to check.
|
|
7
|
-
* @returns {boolean} True if finite non-negative integer.
|
|
8
|
-
*/
|
|
9
|
-
const isFinitePosInt = (n) => Number.isFinite(n) && n >= 0;
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Parses a human-readable duration into milliseconds.
|
|
13
|
-
*
|
|
14
|
-
* Supports: numbers (ms), '250ms', '2s'. null/undefined → 0; invalid → throws.
|
|
15
|
-
*
|
|
16
|
-
* @param {number|string|null|undefined} d - Duration input.
|
|
17
|
-
* @returns {number} ms.
|
|
18
|
-
* @throws {Error} Invalid format/type.
|
|
19
|
-
*/
|
|
20
|
-
const parseDuration = (d) => {
|
|
21
|
-
if (typeof d === 'number') return isFinitePosInt(d) ? d : (() => { throw new Error('Invalid duration'); })();
|
|
22
|
-
if (typeof d === 'string') {
|
|
23
|
-
const m = d.match(/^(\d+)(ms|s)?$/);
|
|
24
|
-
if (!m) throw new Error('Invalid duration string');
|
|
25
|
-
const n = Number(m[1]);
|
|
26
|
-
return m[2] === 's' ? n * 1000 : n;
|
|
27
|
-
}
|
|
28
|
-
if (d == null) return 0;
|
|
29
|
-
throw new Error('Invalid duration type');
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Retrieves child PIDs of a given parent PID using pgrep -P.
|
|
34
|
-
*
|
|
35
|
-
* @param {number} pid - Parent PID.
|
|
36
|
-
* @returns {Promise<number[]>} Array of child PIDs.
|
|
5
|
+
* Grace period (ms) before escalating SIGTERM → SIGKILL for a process group
|
|
6
|
+
* that is still alive after a graceful SIGTERM (i.e. it trapped/ignored it).
|
|
37
7
|
*/
|
|
38
|
-
const
|
|
39
|
-
const p = spawn('pgrep', ['-P', String(pid)], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
40
|
-
const out = [];
|
|
41
|
-
p.stdout.on('data', (chunk) => out.push(chunk));
|
|
42
|
-
p.on('close', (code) => {
|
|
43
|
-
if (code === 0) {
|
|
44
|
-
const ids = Buffer.concat(out).toString('utf8').trim().split(/\s+/).map(Number).filter(Boolean);
|
|
45
|
-
resolve(ids);
|
|
46
|
-
} else if (code === 1) {
|
|
47
|
-
resolve([]); // no children or error
|
|
48
|
-
}
|
|
49
|
-
});
|
|
50
|
-
p.on('error', reject);
|
|
51
|
-
});
|
|
8
|
+
const ESCALATION_GRACE_MS = 1000;
|
|
52
9
|
|
|
53
10
|
/**
|
|
54
|
-
* @typedef {import('
|
|
55
|
-
* @description
|
|
11
|
+
* @typedef {import('./SHDispatch.js').SHOptions} SHExecuteOptions
|
|
12
|
+
* @description Options accepted by SHExecute — the shared SHOptions; `maxBuffer`
|
|
13
|
+
* (bytes per stream) defaults to 512000 (500 KiB).
|
|
56
14
|
*/
|
|
57
15
|
|
|
58
16
|
/**
|
|
@@ -63,214 +21,321 @@ const childrenOf = (pid) => new Promise((resolve, reject) => {
|
|
|
63
21
|
* Key features:
|
|
64
22
|
* - **Shell mode**: If `options.shell` is string/true, runs `${prefix}; ${command}` via shell ('bash' default).
|
|
65
23
|
* - **No-shell mode**: Uses `/usr/bin/env -S ${command}` for direct exec (ignores prefix).
|
|
66
|
-
* - **
|
|
67
|
-
*
|
|
24
|
+
* - **Timeouts**: `options.timeout` (ms/'2s') is an absolute wall-clock timeout —
|
|
25
|
+
* the process is killed after the full duration regardless of output, in both
|
|
26
|
+
* `run()` and `runSync()`. It is stripped from the spawn options so Node's
|
|
27
|
+
* native absolute spawn timeout never interferes with the custom timer (which
|
|
28
|
+
* reports a clear `Process timed out after <N>ms.` error and tears down the
|
|
29
|
+
* whole process group via `kill()`). `runSync()` passes it to `spawnSync`, whose
|
|
30
|
+
* native semantics are the same (absolute).
|
|
31
|
+
* - **Process-group teardown**: The child is always spawned as a new process-group
|
|
32
|
+
* leader (`detached: true` in the spawn options, independent of the public
|
|
33
|
+
* `detached` option). `kill()` sends the signal to the whole group via a
|
|
34
|
+
* negative PID, tearing down the entire tree (children, grandchildren, …) in
|
|
35
|
+
* one shot — no `pgrep` discovery needed. A graceful SIGTERM is escalated to
|
|
36
|
+
* SIGKILL after {@link ESCALATION_GRACE_MS} for processes that ignore SIGTERM.
|
|
37
|
+
* - **Buffering**: Captures stdout/stderr up to `maxBuffer` (512000 bytes / 500 KiB default); appends truncation markers.
|
|
68
38
|
* - **Payload**: `run(payload)` writes string to stdin (forces pipe).
|
|
69
|
-
* - **Detached**: If `options.detached
|
|
70
|
-
*
|
|
39
|
+
* - **Detached**: If the public `options.detached` is set, resolves early (~1s)
|
|
40
|
+
* and unrefs. Unless the caller explicitly set `stdio`, SHDispatch forces
|
|
41
|
+
* `stdio: 'ignore'` for detached runs, because open pipes keep the parent's
|
|
42
|
+
* event loop alive and defeat detachment.
|
|
43
|
+
* - **Kill**: Terminates the whole process group (negative-PID kill) and
|
|
44
|
+
* escalates SIGTERM → SIGKILL after a grace period.
|
|
71
45
|
*
|
|
72
46
|
* @example
|
|
73
47
|
* const exec = new SHExecute('ls', 'set -euo pipefail', { timeout: '5s' });
|
|
74
48
|
* const out = await exec.run();
|
|
75
49
|
*/
|
|
76
50
|
class SHExecute {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
51
|
+
#forcedKill = false;
|
|
52
|
+
/** @type {import('child_process').ChildProcess | null} */
|
|
53
|
+
#proc = null;
|
|
54
|
+
#prefix = '';
|
|
55
|
+
#command = '';
|
|
56
|
+
/** @type {import('child_process').SpawnOptions | import('child_process').SpawnSyncOptions} */
|
|
57
|
+
#options = {};
|
|
58
|
+
#stdoutChunks = [];
|
|
59
|
+
#stderrChunks = [];
|
|
60
|
+
#stdoutLen = 0;
|
|
61
|
+
#stderrLen = 0;
|
|
62
|
+
#maxBuffer = DEFAULT_MAX_BUFFER; // 512000 bytes (500 KiB) per stream
|
|
63
|
+
#truncated = { stdout: false, stderr: false };
|
|
64
|
+
#timedOut = false;
|
|
65
|
+
/** Absolute wall-clock timeout in ms; 0 disables. Never resets on output. */
|
|
66
|
+
#timeout = 0;
|
|
67
|
+
/** Pending SIGTERM→SIGKILL escalation timer (set by `kill('SIGTERM')`). */
|
|
68
|
+
#escalationTimer = null;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} command - Command to execute.
|
|
72
|
+
* @param {string} prefix - Shell prelude (e.g., 'set -euo pipefail'); ignored in no-shell.
|
|
73
|
+
* @param {SHExecuteOptions} [options] - Spawn options + maxBuffer/timeout.
|
|
74
|
+
*/
|
|
75
|
+
constructor(command, prefix, options = {}) {
|
|
76
|
+
this.#prefix = prefix;
|
|
77
|
+
this.#command = command;
|
|
78
|
+
// maxBuffer and timeout are SH-level options, not spawn options.
|
|
79
|
+
// timeout in particular must not reach spawn(): since Node 15.5 it triggers a
|
|
80
|
+
// native absolute timeout that would defeat the custom timer in run().
|
|
81
|
+
const { maxBuffer, timeout, ...spawnOpts } = options ?? {};
|
|
82
|
+
this.#maxBuffer = Number.isFinite(maxBuffer) && maxBuffer > 0 ? maxBuffer : this.#maxBuffer;
|
|
83
|
+
this.#timeout = parseDuration(timeout ?? 0);
|
|
84
|
+
this.#options = spawnOpts;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Synchronous execution.
|
|
89
|
+
*
|
|
90
|
+
* Process-group teardown does not apply here: `spawnSync` blocks until the
|
|
91
|
+
* process exits or its native absolute `timeout` fires, so there is no async
|
|
92
|
+
* kill to escalate. Grandchildren may survive a `spawnSync` timeout.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} [payload] - Stdin data (forces pipe).
|
|
95
|
+
* @returns {import('child_process').SpawnSyncReturns<Buffer>}
|
|
96
|
+
* @throws {Error} Invalid payload type.
|
|
97
|
+
*/
|
|
98
|
+
runSync(payload) {
|
|
99
|
+
this.#forcedKill = false;
|
|
100
|
+
if (payload && typeof payload !== 'string') {
|
|
101
|
+
throw new Error('Argument is not a string');
|
|
102
|
+
}
|
|
103
|
+
/** @type {import('node:child_process').SpawnSyncOptions} */
|
|
104
|
+
const options = { ...this.#options, shell: false };
|
|
105
|
+
// spawnSync supports timeout natively; semantics are absolute (no rolling
|
|
106
|
+
// reset), matching the async run().
|
|
107
|
+
if (this.#timeout > 0) {
|
|
108
|
+
options.timeout = this.#timeout;
|
|
109
|
+
}
|
|
110
|
+
if (payload) {
|
|
111
|
+
const stdio = Array.isArray(options.stdio) ? [...options.stdio] : ['pipe', 'pipe', 'pipe'];
|
|
112
|
+
stdio[0] = 'pipe';
|
|
113
|
+
// @ts-ignore — stdio widens to string[] via the ['pipe', 'pipe', 'pipe'] literal; runtime values are valid StdioOptions.
|
|
114
|
+
options.stdio = stdio;
|
|
115
|
+
options.input = payload;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const shellOpt = this.#options?.shell;
|
|
119
|
+
if (shellOpt) {
|
|
120
|
+
const sh = typeof shellOpt === 'string' ? shellOpt : 'bash';
|
|
121
|
+
const cmd = this.#prefix ? `${this.#prefix}; ${this.#command}` : this.#command;
|
|
122
|
+
return spawnSync(sh, ['-c', cmd], options);
|
|
123
|
+
}
|
|
124
|
+
// no-shell mode
|
|
125
|
+
return spawnSync('/usr/bin/env', ['-S', this.#command], options);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Asynchronous execution with buffering/timeout/kill.
|
|
130
|
+
*
|
|
131
|
+
* Resolves stdout (trimmed) on success.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} [payload] - Stdin data (forces pipe).
|
|
134
|
+
* @returns {Promise<string>} Trimmed UTF-8 stdout (+ truncation marker if exceeded).
|
|
135
|
+
* Rejects with an Error on command failure (message includes the exit code,
|
|
136
|
+
* or the signal name for signal kills, plus any already-received stdout and
|
|
137
|
+
* stderr), timeout expiry, or forced kill. On timeout/forced-kill the
|
|
138
|
+
* already-received stdout/stderr is preserved in the error message.
|
|
139
|
+
*/
|
|
140
|
+
run(payload) {
|
|
141
|
+
this.#forcedKill = false;
|
|
142
|
+
if (payload && typeof payload !== 'string') {
|
|
143
|
+
throw new Error('Argument is not a string');
|
|
144
|
+
}
|
|
145
|
+
// The public `detached` option only controls the early-resolve (~1s)
|
|
146
|
+
// behavior below. The spawn flag is always forced to `detached: true` so
|
|
147
|
+
// the child becomes a new process-group leader and the whole tree can be
|
|
148
|
+
// torn down via a negative-PID kill.
|
|
149
|
+
const publicDetached = this.#options.detached;
|
|
150
|
+
/** @type {import('child_process').SpawnOptions} */
|
|
151
|
+
const options = { ...this.#options, shell: false, detached: true };
|
|
152
|
+
if (payload) {
|
|
153
|
+
const stdio = Array.isArray(options.stdio) ? [...options.stdio] : ['pipe', 'pipe', 'pipe'];
|
|
154
|
+
stdio[0] = 'pipe';
|
|
155
|
+
// @ts-ignore — stdio widens to string[] via the ['pipe', 'pipe', 'pipe'] literal; runtime values are valid StdioOptions.
|
|
156
|
+
options.stdio = stdio;
|
|
157
|
+
}
|
|
91
158
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const { maxBuffer, ...spawnOpts } = options ?? {};
|
|
101
|
-
this.#maxBuffer = Number.isFinite(maxBuffer) && maxBuffer > 0 ? maxBuffer : this.#maxBuffer;
|
|
102
|
-
this.#options = spawnOpts;
|
|
103
|
-
}
|
|
159
|
+
// reset buffers for each run
|
|
160
|
+
this.#stdoutChunks = [];
|
|
161
|
+
this.#stderrChunks = [];
|
|
162
|
+
this.#stdoutLen = 0;
|
|
163
|
+
this.#stderrLen = 0;
|
|
164
|
+
this.#truncated = { stdout: false, stderr: false };
|
|
165
|
+
this.#timedOut = false;
|
|
166
|
+
this.#escalationTimer = null;
|
|
104
167
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
this.#forcedKill = false;
|
|
114
|
-
if (payload && typeof payload !== 'string') {
|
|
115
|
-
throw new Error('Argument is not a string');
|
|
116
|
-
}
|
|
117
|
-
/** @type {import('node:child_process').SpawnSyncOptions} */
|
|
118
|
-
const options = { ...this.#options, shell: false };
|
|
119
|
-
if (payload) {
|
|
120
|
-
const stdio = Array.isArray(options.stdio) ? [...options.stdio] : ['pipe', 'pipe', 'pipe'];
|
|
121
|
-
stdio[0] = 'pipe';
|
|
122
|
-
// @ts-ignore
|
|
123
|
-
options.stdio = stdio;
|
|
124
|
-
options.input = payload;
|
|
125
|
-
}
|
|
168
|
+
const shellOpt = this.#options?.shell;
|
|
169
|
+
if (shellOpt) {
|
|
170
|
+
const sh = typeof shellOpt === 'string' ? shellOpt : 'bash';
|
|
171
|
+
const cmd = this.#prefix ? `${this.#prefix}; ${this.#command}` : this.#command;
|
|
172
|
+
this.#proc = spawn(sh, ['-c', cmd], options);
|
|
173
|
+
} else {
|
|
174
|
+
this.#proc = spawn('/usr/bin/env', ['-S', this.#command], options);
|
|
175
|
+
}
|
|
126
176
|
|
|
127
|
-
|
|
128
|
-
if (shellOpt) {
|
|
129
|
-
const sh = typeof shellOpt === 'string' ? shellOpt : 'bash';
|
|
130
|
-
const cmd = this.#prefix ? `${this.#prefix}; ${this.#command}` : this.#command;
|
|
131
|
-
return spawnSync(sh, ['-c', cmd], options);
|
|
132
|
-
}
|
|
133
|
-
// no-shell mode
|
|
134
|
-
return spawnSync('/usr/bin/env', ['-S', this.#command], options);
|
|
135
|
-
}
|
|
177
|
+
if (payload) this.#proc.stdin?.end(payload);
|
|
136
178
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
*
|
|
142
|
-
* @param {string} [payload] - Stdin data (forces pipe).
|
|
143
|
-
* @returns {Promise<string>} Trimmed UTF-8 stdout (+ truncation marker if exceeded).
|
|
144
|
-
* @throws {Error} Command failure (incl. code, stderr), timeout, kill, spawn error.
|
|
145
|
-
*/
|
|
146
|
-
run(payload) {
|
|
147
|
-
this.#forcedKill = false;
|
|
148
|
-
if (payload && typeof payload !== 'string') {
|
|
149
|
-
throw new Error('Argument is not a string');
|
|
150
|
-
}
|
|
151
|
-
/** @type {import('child_process').SpawnOptions} */
|
|
152
|
-
const options = { ...this.#options, shell: false };
|
|
153
|
-
if (payload) {
|
|
154
|
-
const stdio = Array.isArray(options.stdio) ? [...options.stdio] : ['pipe', 'pipe', 'pipe'];
|
|
155
|
-
stdio[0] = 'pipe';
|
|
156
|
-
// @ts-ignore
|
|
157
|
-
options.stdio = stdio;
|
|
158
|
-
}
|
|
179
|
+
const timeoutMs = this.#timeout;
|
|
180
|
+
let timeoutTimer = null;
|
|
181
|
+
/** @type {number} The timeout (ms) that actually fired, for the error message. */
|
|
182
|
+
let firedMs = 0;
|
|
159
183
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
this.#truncated = { stdout: false, stderr: false };
|
|
166
|
-
this.#timedOut = false;
|
|
184
|
+
const killOnTimeout = (ms) => {
|
|
185
|
+
firedMs = ms;
|
|
186
|
+
this.#timedOut = true;
|
|
187
|
+
this.kill('SIGTERM').catch(() => {});
|
|
188
|
+
};
|
|
167
189
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const sh = typeof shellOpt === 'string' ? shellOpt : 'bash';
|
|
171
|
-
const cmd = this.#prefix ? `${this.#prefix}; ${this.#command}` : this.#command;
|
|
172
|
-
this.#proc = spawn(sh, ['-c', cmd], options);
|
|
173
|
-
} else {
|
|
174
|
-
this.#proc = spawn('/usr/bin/env', ['-S', this.#command], options);
|
|
175
|
-
}
|
|
190
|
+
// Timeout is absolute: never reset on output.
|
|
191
|
+
if (timeoutMs > 0) timeoutTimer = setTimeout(() => killOnTimeout(timeoutMs), timeoutMs);
|
|
176
192
|
|
|
177
|
-
|
|
193
|
+
this.#proc.stdout?.on('data', (chunk) => {
|
|
194
|
+
this.#stdoutLen += chunk.length;
|
|
195
|
+
// prevent TOKEN bombs
|
|
196
|
+
if (this.#stdoutLen <= this.#maxBuffer) {
|
|
197
|
+
this.#stdoutChunks.push(chunk);
|
|
198
|
+
} else {
|
|
199
|
+
this.#truncated.stdout = true;
|
|
200
|
+
}
|
|
201
|
+
});
|
|
178
202
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}, ms);
|
|
188
|
-
}
|
|
189
|
-
};
|
|
203
|
+
this.#proc.stderr?.on('data', (chunk) => {
|
|
204
|
+
this.#stderrLen += chunk.length;
|
|
205
|
+
if (this.#stderrLen <= this.#maxBuffer) {
|
|
206
|
+
this.#stderrChunks.push(chunk);
|
|
207
|
+
} else {
|
|
208
|
+
this.#truncated.stderr = true;
|
|
209
|
+
}
|
|
210
|
+
});
|
|
190
211
|
|
|
191
|
-
|
|
212
|
+
return new Promise((resolve, reject) => {
|
|
213
|
+
if (publicDetached) {
|
|
214
|
+
setTimeout(() => {
|
|
215
|
+
if (timeoutTimer !== null) clearTimeout(timeoutTimer);
|
|
216
|
+
resolve('');
|
|
217
|
+
// #proc may already be null when kill() ran within the 1s window.
|
|
218
|
+
this.#proc?.unref();
|
|
219
|
+
}, 1000);
|
|
220
|
+
}
|
|
192
221
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
222
|
+
this.#proc.on('close', (code, signal) => {
|
|
223
|
+
if (timeoutTimer !== null) clearTimeout(timeoutTimer);
|
|
224
|
+
if (this.#escalationTimer !== null) {
|
|
225
|
+
clearTimeout(this.#escalationTimer);
|
|
226
|
+
this.#escalationTimer = null;
|
|
227
|
+
}
|
|
228
|
+
// Check timeout first: the timeout path kills with SIGTERM, which sets
|
|
229
|
+
// #forcedKill in the same tick; #forcedKill must not mask the timeout.
|
|
230
|
+
if (this.#timedOut) {
|
|
231
|
+
reject(new Error(this.#errorMessage(`Process timed out after ${firedMs}ms.`)));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (this.#forcedKill) {
|
|
235
|
+
reject(new Error(this.#errorMessage('Process killed (forced).')));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (code === 0) {
|
|
239
|
+
const stdoutBuf = this.#stdoutChunks.length ? Buffer.concat(this.#stdoutChunks) : Buffer.alloc(0);
|
|
240
|
+
let stdout = stdoutBuf.toString('utf8').trim();
|
|
241
|
+
if (this.#truncated.stdout) stdout += '\n[stdout truncated]\n';
|
|
242
|
+
resolve(stdout);
|
|
243
|
+
} else {
|
|
244
|
+
const reason = code === null ? `signal ${signal}` : `code ${code}`;
|
|
245
|
+
reject(new Error(this.#errorMessage(`Command failed with ${reason}.`)));
|
|
246
|
+
}
|
|
247
|
+
});
|
|
203
248
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
this.#stderrChunks.push(chunk);
|
|
208
|
-
} else {
|
|
209
|
-
this.#truncated.stderr = true;
|
|
210
|
-
}
|
|
211
|
-
resetTimeout();
|
|
212
|
-
});
|
|
249
|
+
this.#proc.on('error', (err) => reject(err));
|
|
250
|
+
});
|
|
251
|
+
}
|
|
213
252
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
253
|
+
/**
|
|
254
|
+
* Terminates the whole process group (negative-PID kill).
|
|
255
|
+
*
|
|
256
|
+
* The child is spawned as a process-group leader, so a negative-PID kill
|
|
257
|
+
* tears down the entire tree (children, grandchildren, …) in one shot — no
|
|
258
|
+
* `pgrep` discovery required. A graceful SIGTERM is escalated to SIGKILL
|
|
259
|
+
* after {@link ESCALATION_GRACE_MS} for processes that ignore SIGTERM.
|
|
260
|
+
* Best-effort: an already-exited group (ESRCH) is treated as a no-op.
|
|
261
|
+
*
|
|
262
|
+
* @param {number | string} [signal='SIGTERM'] - Signal to send.
|
|
263
|
+
* @returns {Promise<number[]>} Killed PIDs (the group-leader PID).
|
|
264
|
+
* @throws {Error} No process/PID.
|
|
265
|
+
*/
|
|
266
|
+
async kill(signal = 'SIGTERM') {
|
|
267
|
+
if (!this.#proc) throw new Error('Trying to kill a process without creating one.');
|
|
268
|
+
if (!this.#proc.pid) throw new Error('The process pid is undefined.');
|
|
269
|
+
this.#forcedKill = true;
|
|
270
|
+
const pid = this.#proc.pid;
|
|
271
|
+
const killed = [];
|
|
272
|
+
try {
|
|
273
|
+
// Negative PID = the whole process group.
|
|
274
|
+
try { process.kill(-pid, signal); killed.push(pid); } catch (e) { if (!e || e.code !== 'ESRCH') throw e; }
|
|
275
|
+
// Escalate SIGTERM → SIGKILL after a grace period for processes that ignore SIGTERM.
|
|
276
|
+
if (signal === 'SIGTERM') {
|
|
277
|
+
this.#scheduleEscalation(pid);
|
|
278
|
+
}
|
|
279
|
+
} finally {
|
|
280
|
+
this.#proc = null;
|
|
281
|
+
}
|
|
282
|
+
return killed;
|
|
283
|
+
}
|
|
222
284
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
});
|
|
285
|
+
/**
|
|
286
|
+
* Schedules a SIGKILL for the process group after {@link ESCALATION_GRACE_MS}
|
|
287
|
+
* if it is still alive (i.e. it ignored the earlier SIGTERM).
|
|
288
|
+
*
|
|
289
|
+
* @private
|
|
290
|
+
* @param {number} pid - Process-group leader PID.
|
|
291
|
+
*/
|
|
292
|
+
#scheduleEscalation(pid) {
|
|
293
|
+
this.#escalationTimer = setTimeout(() => {
|
|
294
|
+
this.#escalationTimer = null;
|
|
295
|
+
try {
|
|
296
|
+
// Signal 0 probes whether the group still exists (ESRCH = gone).
|
|
297
|
+
process.kill(-pid, 0);
|
|
298
|
+
// Still alive — it ignored SIGTERM; force-kill the whole group.
|
|
299
|
+
process.kill(-pid, 'SIGKILL');
|
|
300
|
+
} catch (e) {
|
|
301
|
+
// ESRCH: the group already died from SIGTERM — nothing to escalate.
|
|
302
|
+
// Other errors (e.g. EPERM) are best-effort; teardown stays non-fatal.
|
|
303
|
+
}
|
|
304
|
+
}, ESCALATION_GRACE_MS);
|
|
305
|
+
}
|
|
245
306
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
307
|
+
/**
|
|
308
|
+
* Builds the already-received stdout/stderr (with truncation markers) as a
|
|
309
|
+
* readable `[stdout]`/`[stderr]` block, so content received before a
|
|
310
|
+
* timeout/kill/failure is preserved in the error message.
|
|
311
|
+
*
|
|
312
|
+
* @private
|
|
313
|
+
* @returns {string} Formatted output block, or '' when nothing was received.
|
|
314
|
+
*/
|
|
315
|
+
#formatOutput() {
|
|
316
|
+
const parts = [];
|
|
317
|
+
const stdoutBuf = this.#stdoutChunks.length ? Buffer.concat(this.#stdoutChunks) : Buffer.alloc(0);
|
|
318
|
+
let stdout = stdoutBuf.toString('utf8');
|
|
319
|
+
if (this.#truncated.stdout) stdout += '\n[stdout truncated]\n';
|
|
320
|
+
if (stdout) parts.push(`[stdout]\n${stdout}`);
|
|
321
|
+
const stderrBuf = this.#stderrChunks.length ? Buffer.concat(this.#stderrChunks) : Buffer.alloc(0);
|
|
322
|
+
let stderr = stderrBuf.toString('utf8');
|
|
323
|
+
if (this.#truncated.stderr) stderr += '\n[stderr truncated]\n';
|
|
324
|
+
if (stderr) parts.push(`[stderr]\n${stderr}`);
|
|
325
|
+
return parts.join('\n');
|
|
326
|
+
}
|
|
249
327
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const pid = this.#proc.pid;
|
|
262
|
-
let killed = [];
|
|
263
|
-
try {
|
|
264
|
-
const kids = await childrenOf(pid).catch(() => []);
|
|
265
|
-
for (const k of kids) {
|
|
266
|
-
try { process.kill(k, signal); killed.push(k); } catch (e) { if (!e || e.code !== 'ESRCH') throw e; }
|
|
267
|
-
}
|
|
268
|
-
try { process.kill(pid, signal); killed.push(pid); } catch (e) { if (!e || e.code !== 'ESRCH') throw e; }
|
|
269
|
-
} finally {
|
|
270
|
-
this.#proc = null;
|
|
271
|
-
}
|
|
272
|
-
return killed;
|
|
273
|
-
}
|
|
328
|
+
/**
|
|
329
|
+
* Prefixes an error message with the received-output block when present.
|
|
330
|
+
*
|
|
331
|
+
* @private
|
|
332
|
+
* @param {string} prefix - The primary error message (e.g. 'Process timed out after 500ms.').
|
|
333
|
+
* @returns {string} `prefix` alone, or `prefix` + the formatted output block.
|
|
334
|
+
*/
|
|
335
|
+
#errorMessage(prefix) {
|
|
336
|
+
const output = this.#formatOutput();
|
|
337
|
+
return output ? `${prefix}\n${output}` : prefix;
|
|
338
|
+
}
|
|
274
339
|
}
|
|
275
340
|
|
|
276
341
|
export default SHExecute;
|