@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/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
- * Validates if a number is a finite positive integer (including 0).
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 childrenOf = (pid) => new Promise((resolve, reject) => {
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('../SH.js').SHOptions & { maxBuffer?: number }} SHExecuteOptions
55
- * @description Extended options for SHExecute: adds `maxBuffer` (bytes per stream, default 1MB).
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
- * - **Rolling timeout**: `options.timeout` (ms/'2s'); resets on stdout/stderr data. SIGTERM on expiry.
67
- * - **Buffering**: Captures stdout/stderr up to `maxBuffer` (1MB default); appends truncation markers.
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`, resolves early (~1s) and unrefs.
70
- * - **Kill**: Terminates process + children via pgrep.
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
- #forcedKill = false;
78
- /** @type {import('child_process').ChildProcess | null} */
79
- #proc = null;
80
- #prefix = '';
81
- #command = '';
82
- /** @type {import('child_process').SpawnOptions | import('child_process').SpawnSyncOptions} */
83
- #options = {};
84
- #stdoutChunks = [];
85
- #stderrChunks = [];
86
- #stdoutLen = 0;
87
- #stderrLen = 0;
88
- #maxBuffer = 500 * 1024; // 500KB per stream
89
- #truncated = { stdout: false, stderr: false };
90
- #timedOut = false;
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
- * @param {string} command - Command to execute.
94
- * @param {string} prefix - Shell prelude (e.g., 'set -euo pipefail'); ignored in no-shell.
95
- * @param {SHExecuteOptions} [options] - Spawn options + maxBuffer/timeout.
96
- */
97
- constructor(command, prefix, options = {}) {
98
- this.#prefix = prefix;
99
- this.#command = command;
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
- * Synchronous execution.
107
- *
108
- * @param {string} [payload] - Stdin data (forces pipe).
109
- * @returns {import('child_process').SpawnSyncReturns<Buffer>}
110
- * @throws {Error} Invalid payload type.
111
- */
112
- runSync(payload) {
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
- const shellOpt = this.#options?.shell;
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
- * Asynchronous execution with buffering/timeout/kill.
139
- *
140
- * Resolves stdout (trimmed) on success; rejects on error/timeout/kill.
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
- // reset buffers for each run
161
- this.#stdoutChunks = [];
162
- this.#stderrChunks = [];
163
- this.#stdoutLen = 0;
164
- this.#stderrLen = 0;
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
- 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
- }
190
+ // Timeout is absolute: never reset on output.
191
+ if (timeoutMs > 0) timeoutTimer = setTimeout(() => killOnTimeout(timeoutMs), timeoutMs);
176
192
 
177
- if (payload) this.#proc.stdin?.end(payload);
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
- const ms = parseDuration(this.#options?.timeout ?? 0);
180
- let timeoutId = null;
181
- const resetTimeout = () => {
182
- if (timeoutId !== null) clearTimeout(timeoutId);
183
- if (ms > 0) {
184
- timeoutId = setTimeout(() => {
185
- this.#timedOut = true;
186
- this.kill('SIGTERM').catch(() => {});
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
- if (ms > 0) resetTimeout();
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
- 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
- resetTimeout();
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
- this.#proc.stderr?.on('data', (chunk) => {
205
- this.#stderrLen += chunk.length;
206
- if (this.#stderrLen <= this.#maxBuffer) {
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
- return new Promise((resolve, reject) => {
215
- if (options.detached) {
216
- setTimeout(() => {
217
- if (timeoutId !== null) clearTimeout(timeoutId);
218
- resolve('');
219
- this.#proc.unref();
220
- }, 1000);
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
- this.#proc.on('close', (code, signal) => {
224
- if (timeoutId !== null) clearTimeout(timeoutId);
225
- if (this.#forcedKill) {
226
- reject(new Error('Process killed (forced).'));
227
- return;
228
- }
229
- if (this.#timedOut) {
230
- reject(new Error(`Process timed out after ${ms}ms.`));
231
- return;
232
- }
233
- if (code === 0) {
234
- const stdoutBuf = this.#stdoutChunks.length ? Buffer.concat(this.#stdoutChunks) : Buffer.alloc(0);
235
- let stdout = stdoutBuf.toString('utf8').trim();
236
- if (this.#truncated.stdout) stdout += '\n[stdout truncated]\n';
237
- resolve(stdout);
238
- } else {
239
- const stderrBuf = this.#stderrChunks.length ? Buffer.concat(this.#stderrChunks) : Buffer.alloc(0);
240
- let stderr = stderrBuf.toString('utf8');
241
- if (this.#truncated.stderr) stderr += '\n[stderr truncated]\n';
242
- reject(new Error(`Command failed with code ${code}: ${stderr}`));
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
- this.#proc.on('error', (err) => reject(err));
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
- * Terminates process and its children (via pgrep).
252
- *
253
- * @param {number | string} [signal='SIGTERM'] - Signal to send.
254
- * @returns {Promise<number[]>} Killed PIDs.
255
- * @throws {Error} No process/PID.
256
- */
257
- async kill(signal = 'SIGTERM') {
258
- if (!this.#proc) throw new Error('Trying to kill a process without creating one.');
259
- if (!this.#proc.pid) throw new Error('The process pid is undefined.');
260
- this.#forcedKill = true;
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;