@fuzdev/fuz_util 0.45.3 → 0.46.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/dist/array.d.ts +1 -1
- package/dist/array.js +1 -1
- package/dist/dom.d.ts +2 -2
- package/dist/dom.js +2 -2
- package/dist/fetch.d.ts +1 -1
- package/dist/fetch.js +1 -1
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +10 -10
- package/dist/path.d.ts +1 -1
- package/dist/path.js +2 -2
- package/dist/print.d.ts +1 -1
- package/dist/print.js +1 -1
- package/dist/process.d.ts +303 -43
- package/dist/process.d.ts.map +1 -1
- package/dist/process.js +475 -108
- package/dist/random.d.ts +1 -1
- package/dist/random.js +1 -1
- package/dist/regexp.d.ts +1 -1
- package/dist/regexp.js +1 -1
- package/package.json +6 -5
- package/src/lib/array.ts +1 -1
- package/src/lib/dom.ts +2 -2
- package/src/lib/fetch.ts +1 -1
- package/src/lib/git.ts +20 -10
- package/src/lib/path.ts +2 -2
- package/src/lib/print.ts +1 -1
- package/src/lib/process.ts +681 -135
- package/src/lib/random.ts +1 -1
- package/src/lib/regexp.ts +1 -1
package/dist/process.js
CHANGED
|
@@ -2,161 +2,528 @@ import { spawn as spawn_child_process, } from 'node:child_process';
|
|
|
2
2
|
import { styleText as st } from 'node:util';
|
|
3
3
|
import { Logger } from './log.js';
|
|
4
4
|
import { print_error, print_key_value } from './print.js';
|
|
5
|
+
import { noop } from './function.js';
|
|
5
6
|
const log = new Logger('process');
|
|
7
|
+
//
|
|
8
|
+
// Type Guards
|
|
9
|
+
//
|
|
6
10
|
/**
|
|
7
|
-
*
|
|
8
|
-
* intended for commands that have an end, not long running-processes like watchers.
|
|
9
|
-
* Any more advanced usage should use `spawn_process` directly for access to the `child` process.
|
|
11
|
+
* Type guard for spawn errors (process failed to start).
|
|
10
12
|
*/
|
|
11
|
-
export const
|
|
13
|
+
export const spawn_result_is_error = (result) => 'error' in result;
|
|
12
14
|
/**
|
|
13
|
-
*
|
|
15
|
+
* Type guard for signal termination.
|
|
14
16
|
*/
|
|
15
|
-
export const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
stderr = (stderr ?? '') + data.toString();
|
|
24
|
-
});
|
|
25
|
-
const result = await closed;
|
|
26
|
-
return { result, stdout, stderr };
|
|
27
|
-
};
|
|
17
|
+
export const spawn_result_is_signaled = (result) => 'signal' in result && result.signal !== null;
|
|
18
|
+
/**
|
|
19
|
+
* Type guard for normal exit with code.
|
|
20
|
+
*/
|
|
21
|
+
export const spawn_result_is_exited = (result) => 'code' in result && result.code !== null;
|
|
22
|
+
//
|
|
23
|
+
// Internal Helpers
|
|
24
|
+
//
|
|
28
25
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
26
|
+
* Creates a promise that resolves when the child process closes.
|
|
27
|
+
*
|
|
28
|
+
* Handles both 'error' and 'close' events with deduplication because Node.js
|
|
29
|
+
* can emit both for certain failures (e.g., spawn ENOENT emits 'error' then 'close').
|
|
30
|
+
* The `resolved` flag ensures we only resolve once with the first event's data.
|
|
33
31
|
*/
|
|
34
|
-
|
|
32
|
+
const create_closed_promise = (child) => {
|
|
35
33
|
let resolve;
|
|
34
|
+
let resolved = false;
|
|
36
35
|
const closed = new Promise((r) => (resolve = r));
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
child.once('error', (err) => {
|
|
37
|
+
if (resolved)
|
|
38
|
+
return;
|
|
39
|
+
resolved = true;
|
|
40
|
+
resolve({ ok: false, child, error: err });
|
|
41
|
+
});
|
|
39
42
|
child.once('close', (code, signal) => {
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
if (resolved)
|
|
44
|
+
return;
|
|
45
|
+
resolved = true;
|
|
46
|
+
if (signal !== null) {
|
|
47
|
+
resolve({ ok: false, child, code: null, signal });
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
resolve({ ok: code === 0, child, code: code ?? 0, signal: null });
|
|
51
|
+
}
|
|
42
52
|
});
|
|
43
|
-
return
|
|
53
|
+
return closed;
|
|
44
54
|
};
|
|
45
|
-
export const print_child_process = (child) => `${st('gray', 'pid(')}${child.pid}${st('gray', ')')} ← ${st('green', child.spawnargs.join(' '))}`;
|
|
46
55
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
56
|
+
* Sets up abort signal handling for a child process.
|
|
57
|
+
* @returns cleanup function to remove the listener
|
|
49
58
|
*/
|
|
50
|
-
|
|
59
|
+
const setup_abort_signal = (child, signal) => {
|
|
60
|
+
if (signal.aborted) {
|
|
61
|
+
child.kill();
|
|
62
|
+
return noop;
|
|
63
|
+
}
|
|
64
|
+
const on_abort = () => child.kill();
|
|
65
|
+
signal.addEventListener('abort', on_abort, { once: true });
|
|
66
|
+
return () => signal.removeEventListener('abort', on_abort);
|
|
67
|
+
};
|
|
51
68
|
/**
|
|
52
|
-
*
|
|
53
|
-
* @
|
|
54
|
-
* @returns cleanup function that removes the child from `global_spawn`
|
|
55
|
-
* @mutates global_spawn adds child to the module-level Set, and the returned function removes it
|
|
69
|
+
* Validates timeout_ms option.
|
|
70
|
+
* @throws if timeout_ms is negative
|
|
56
71
|
*/
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
72
|
+
const validate_timeout_ms = (timeout_ms) => {
|
|
73
|
+
if (timeout_ms !== undefined && timeout_ms < 0) {
|
|
74
|
+
throw new Error(`timeout_ms must be non-negative, got ${timeout_ms}`);
|
|
60
75
|
}
|
|
61
|
-
global_spawn.add(child);
|
|
62
|
-
return () => {
|
|
63
|
-
if (!global_spawn.has(child)) {
|
|
64
|
-
log.error(st('red', 'spawn not registered:'), print_child_process(child));
|
|
65
|
-
}
|
|
66
|
-
global_spawn.delete(child);
|
|
67
|
-
};
|
|
68
76
|
};
|
|
69
77
|
/**
|
|
70
|
-
*
|
|
78
|
+
* Sets up timeout handling for a child process.
|
|
79
|
+
* Note: timeout_ms of 0 triggers immediate SIGTERM (use with caution).
|
|
80
|
+
* @returns cleanup function to clear the timeout
|
|
71
81
|
*/
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
log.debug('despawning', print_child_process(child));
|
|
76
|
-
child.once('close', (code, signal) => {
|
|
77
|
-
resolve(code ? { ok: false, child, code, signal } : { ok: true, child, code, signal });
|
|
78
|
-
});
|
|
79
|
-
child.kill();
|
|
80
|
-
return closed;
|
|
82
|
+
const setup_timeout = (child, timeout_ms) => {
|
|
83
|
+
const timeout_id = setTimeout(() => child.kill('SIGTERM'), timeout_ms);
|
|
84
|
+
return () => clearTimeout(timeout_id);
|
|
81
85
|
};
|
|
86
|
+
//
|
|
87
|
+
// Process Registry
|
|
88
|
+
//
|
|
89
|
+
/**
|
|
90
|
+
* Manages a collection of spawned processes for lifecycle tracking and cleanup.
|
|
91
|
+
*
|
|
92
|
+
* The default instance `process_registry_default` is used by module-level functions.
|
|
93
|
+
* Create separate instances for isolated process groups or testing.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* // Use default registry via module functions
|
|
98
|
+
* const result = await spawn('echo', ['hello']);
|
|
99
|
+
*
|
|
100
|
+
* // Or create isolated registry for testing
|
|
101
|
+
* const registry = new ProcessRegistry();
|
|
102
|
+
* const {child, closed} = registry.spawn('node', ['server.js']);
|
|
103
|
+
* await registry.despawn_all();
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
export class ProcessRegistry {
|
|
107
|
+
/** All currently tracked child processes */
|
|
108
|
+
processes = new Set();
|
|
109
|
+
#error_handler = null;
|
|
110
|
+
/**
|
|
111
|
+
* Spawns a process and tracks it in this registry.
|
|
112
|
+
* The process is automatically unregistered when it exits.
|
|
113
|
+
*
|
|
114
|
+
* @param command - The command to run
|
|
115
|
+
* @param args - Arguments to pass to the command
|
|
116
|
+
* @param options - Spawn options including `signal` and `timeout_ms`
|
|
117
|
+
* @returns Handle with `child` process and `closed` promise
|
|
118
|
+
*/
|
|
119
|
+
spawn(command, args = [], options) {
|
|
120
|
+
const { signal, timeout_ms, ...spawn_options } = options ?? {};
|
|
121
|
+
validate_timeout_ms(timeout_ms);
|
|
122
|
+
const child = spawn_child_process(command, args, { stdio: 'inherit', ...spawn_options });
|
|
123
|
+
this.processes.add(child);
|
|
124
|
+
const closed = create_closed_promise(child);
|
|
125
|
+
let cleanup_abort;
|
|
126
|
+
if (signal) {
|
|
127
|
+
cleanup_abort = setup_abort_signal(child, signal);
|
|
128
|
+
}
|
|
129
|
+
let cleanup_timeout;
|
|
130
|
+
if (timeout_ms !== undefined) {
|
|
131
|
+
cleanup_timeout = setup_timeout(child, timeout_ms);
|
|
132
|
+
}
|
|
133
|
+
void closed.then(() => {
|
|
134
|
+
this.processes.delete(child);
|
|
135
|
+
cleanup_abort?.();
|
|
136
|
+
cleanup_timeout?.();
|
|
137
|
+
});
|
|
138
|
+
return { child, closed };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Spawns a process and captures stdout/stderr as strings.
|
|
142
|
+
* Sets `stdio: 'pipe'` automatically.
|
|
143
|
+
*
|
|
144
|
+
* @param command - The command to run
|
|
145
|
+
* @param args - Arguments to pass to the command
|
|
146
|
+
* @param options - Spawn options
|
|
147
|
+
* @returns Result with captured `stdout` and `stderr`.
|
|
148
|
+
* - `null` means spawn failed (ENOENT, etc.) or stream was unavailable
|
|
149
|
+
* - `''` (empty string) means process ran but produced no output
|
|
150
|
+
* - non-empty string contains the captured output
|
|
151
|
+
*/
|
|
152
|
+
async spawn_out(command, args = [], options) {
|
|
153
|
+
const { child, closed } = this.spawn(command, args, { ...options, stdio: 'pipe' });
|
|
154
|
+
const stdout_chunks = [];
|
|
155
|
+
const stderr_chunks = [];
|
|
156
|
+
// Track whether streams were available (not null)
|
|
157
|
+
const stdout_available = child.stdout !== null;
|
|
158
|
+
const stderr_available = child.stderr !== null;
|
|
159
|
+
const on_stdout = (data) => {
|
|
160
|
+
stdout_chunks.push(data.toString());
|
|
161
|
+
};
|
|
162
|
+
const on_stderr = (data) => {
|
|
163
|
+
stderr_chunks.push(data.toString());
|
|
164
|
+
};
|
|
165
|
+
child.stdout?.on('data', on_stdout);
|
|
166
|
+
child.stderr?.on('data', on_stderr);
|
|
167
|
+
const result = await closed;
|
|
168
|
+
// Clean up listeners explicitly
|
|
169
|
+
child.stdout?.off('data', on_stdout);
|
|
170
|
+
child.stderr?.off('data', on_stderr);
|
|
171
|
+
// If spawn failed (error result), streams are meaningless - return null
|
|
172
|
+
// Otherwise: '' = available but empty, string = has content
|
|
173
|
+
const spawn_failed = spawn_result_is_error(result);
|
|
174
|
+
const stdout = spawn_failed || !stdout_available ? null : stdout_chunks.join('');
|
|
175
|
+
const stderr = spawn_failed || !stderr_available ? null : stderr_chunks.join('');
|
|
176
|
+
return { result, stdout, stderr };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Kills a child process and waits for it to exit.
|
|
180
|
+
*
|
|
181
|
+
* @param child - The child process to kill
|
|
182
|
+
* @param options - Kill options including signal and timeout
|
|
183
|
+
* @returns The spawn result after the process exits
|
|
184
|
+
*/
|
|
185
|
+
async despawn(child, options) {
|
|
186
|
+
const { signal = 'SIGTERM', timeout_ms } = options ?? {};
|
|
187
|
+
validate_timeout_ms(timeout_ms);
|
|
188
|
+
// Already exited with code
|
|
189
|
+
if (child.exitCode !== null) {
|
|
190
|
+
return {
|
|
191
|
+
ok: child.exitCode === 0,
|
|
192
|
+
child,
|
|
193
|
+
code: child.exitCode,
|
|
194
|
+
signal: null,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
// Already terminated by signal
|
|
198
|
+
if (child.signalCode !== null) {
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
child,
|
|
202
|
+
code: null,
|
|
203
|
+
signal: child.signalCode,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
log.debug('despawning', print_child_process(child));
|
|
207
|
+
const closed = create_closed_promise(child);
|
|
208
|
+
// Escalate to SIGKILL after timeout
|
|
209
|
+
if (timeout_ms !== undefined) {
|
|
210
|
+
const timeout_id = setTimeout(() => child.kill('SIGKILL'), timeout_ms);
|
|
211
|
+
void closed.then(() => clearTimeout(timeout_id));
|
|
212
|
+
}
|
|
213
|
+
child.kill(signal);
|
|
214
|
+
return closed;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Kills all processes in this registry.
|
|
218
|
+
*
|
|
219
|
+
* @param options - Kill options applied to all processes
|
|
220
|
+
* @returns Array of spawn results
|
|
221
|
+
*/
|
|
222
|
+
async despawn_all(options) {
|
|
223
|
+
return Promise.all([...this.processes].map((child) => this.despawn(child, options)));
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Attaches an `uncaughtException` handler that kills all processes before exiting.
|
|
227
|
+
* Prevents zombie processes when the parent crashes.
|
|
228
|
+
*
|
|
229
|
+
* By default uses SIGKILL for immediate termination. Set `graceful_timeout_ms`
|
|
230
|
+
* to attempt SIGTERM first (allowing processes to clean up) before escalating
|
|
231
|
+
* to SIGKILL after the timeout.
|
|
232
|
+
*
|
|
233
|
+
* Note: Node's uncaughtException handler cannot await async operations, so
|
|
234
|
+
* graceful shutdown uses a blocking busy-wait. This may not be sufficient
|
|
235
|
+
* for processes that need significant cleanup time.
|
|
236
|
+
*
|
|
237
|
+
* @param options - Configuration options
|
|
238
|
+
* @param options.to_error_label - Customize error label, return `null` for default
|
|
239
|
+
* @param options.map_error_text - Customize error text, return `''` to silence
|
|
240
|
+
* @param options.handle_error - Called after cleanup, defaults to `process.exit(1)`
|
|
241
|
+
* @param options.graceful_timeout_ms - If set, sends SIGTERM first and waits this
|
|
242
|
+
* many ms before SIGKILL. Recommended: 100-500ms. If null/undefined, uses
|
|
243
|
+
* immediate SIGKILL (default).
|
|
244
|
+
* @returns Cleanup function to remove the handler
|
|
245
|
+
*/
|
|
246
|
+
attach_error_handler(options) {
|
|
247
|
+
if (this.#error_handler) {
|
|
248
|
+
throw new Error('Error handler already attached to this registry');
|
|
249
|
+
}
|
|
250
|
+
const { to_error_label, map_error_text, handle_error = () => process.exit(1), graceful_timeout_ms, } = options ?? {};
|
|
251
|
+
this.#error_handler = (err, origin) => {
|
|
252
|
+
const label = to_error_label?.(err, origin) ?? origin;
|
|
253
|
+
if (label) {
|
|
254
|
+
const error_text = map_error_text?.(err, origin) ?? print_error(err);
|
|
255
|
+
if (error_text) {
|
|
256
|
+
new Logger(label).error(error_text);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (graceful_timeout_ms != null && graceful_timeout_ms > 0) {
|
|
260
|
+
// Attempt graceful shutdown with SIGTERM first
|
|
261
|
+
for (const child of this.processes) {
|
|
262
|
+
child.kill('SIGTERM');
|
|
263
|
+
}
|
|
264
|
+
// Busy-wait (blocking) - only option in sync handler.
|
|
265
|
+
// Warning: This will peg the CPU during the wait period.
|
|
266
|
+
const deadline = Date.now() + graceful_timeout_ms;
|
|
267
|
+
while (Date.now() < deadline) {
|
|
268
|
+
// spin
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// Force kill all (including any that survived SIGTERM)
|
|
272
|
+
for (const child of this.processes) {
|
|
273
|
+
child.kill('SIGKILL');
|
|
274
|
+
}
|
|
275
|
+
this.processes.clear();
|
|
276
|
+
handle_error(err, origin);
|
|
277
|
+
};
|
|
278
|
+
process.on('uncaughtException', this.#error_handler);
|
|
279
|
+
return () => {
|
|
280
|
+
if (this.#error_handler) {
|
|
281
|
+
process.off('uncaughtException', this.#error_handler);
|
|
282
|
+
this.#error_handler = null;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
//
|
|
288
|
+
// Default Registry
|
|
289
|
+
//
|
|
82
290
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
291
|
+
* Default process registry used by module-level spawn functions.
|
|
292
|
+
* For testing or isolated process groups, create a new `ProcessRegistry` instance.
|
|
85
293
|
*/
|
|
86
|
-
export const
|
|
294
|
+
export const process_registry_default = new ProcessRegistry();
|
|
295
|
+
//
|
|
296
|
+
// Module-Level Spawn Functions
|
|
297
|
+
//
|
|
87
298
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* @
|
|
299
|
+
* Spawns a process with graceful shutdown behavior.
|
|
300
|
+
* Returns a handle with access to the `child` process and `closed` promise.
|
|
301
|
+
*
|
|
302
|
+
* @example
|
|
303
|
+
* ```ts
|
|
304
|
+
* const {child, closed} = spawn_process('node', ['server.js']);
|
|
305
|
+
* // Later...
|
|
306
|
+
* child.kill();
|
|
307
|
+
* const result = await closed;
|
|
308
|
+
* ```
|
|
92
309
|
*/
|
|
93
|
-
export const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
310
|
+
export const spawn_process = (command, args = [], options) => process_registry_default.spawn(command, args, options);
|
|
311
|
+
/**
|
|
312
|
+
* Spawns a process and returns a promise that resolves when it exits.
|
|
313
|
+
* Use this for commands that complete (not long-running processes).
|
|
314
|
+
*
|
|
315
|
+
* @example
|
|
316
|
+
* ```ts
|
|
317
|
+
* const result = await spawn('npm', ['install']);
|
|
318
|
+
* if (!result.ok) console.error('Install failed');
|
|
319
|
+
* ```
|
|
320
|
+
*/
|
|
321
|
+
export const spawn = (command, args = [], options) => spawn_process(command, args, options).closed;
|
|
322
|
+
/**
|
|
323
|
+
* Spawns a process and captures stdout/stderr as strings.
|
|
324
|
+
*
|
|
325
|
+
* @example
|
|
326
|
+
* ```ts
|
|
327
|
+
* const {result, stdout} = await spawn_out('git', ['status', '--porcelain']);
|
|
328
|
+
* if (result.ok && stdout) console.log(stdout);
|
|
329
|
+
* ```
|
|
330
|
+
*/
|
|
331
|
+
export const spawn_out = (command, args = [], options) => process_registry_default.spawn_out(command, args, options);
|
|
332
|
+
/**
|
|
333
|
+
* Kills a child process and returns the result.
|
|
334
|
+
*
|
|
335
|
+
* @example
|
|
336
|
+
* ```ts
|
|
337
|
+
* const result = await despawn(child, {timeout_ms: 5000});
|
|
338
|
+
* // If process ignores SIGTERM, SIGKILL sent after 5s
|
|
339
|
+
* ```
|
|
340
|
+
*/
|
|
341
|
+
export const despawn = (child, options) => process_registry_default.despawn(child, options);
|
|
106
342
|
/**
|
|
107
|
-
*
|
|
343
|
+
* Kills all processes in the default registry.
|
|
344
|
+
*/
|
|
345
|
+
export const despawn_all = (options) => process_registry_default.despawn_all(options);
|
|
346
|
+
/**
|
|
347
|
+
* Attaches an `uncaughtException` handler to the default registry.
|
|
348
|
+
*
|
|
349
|
+
* @see ProcessRegistry.attach_error_handler
|
|
350
|
+
*/
|
|
351
|
+
export const attach_process_error_handler = (options) => process_registry_default.attach_error_handler(options);
|
|
352
|
+
//
|
|
353
|
+
// Formatting Utilities
|
|
354
|
+
//
|
|
355
|
+
/**
|
|
356
|
+
* Formats a child process for display.
|
|
357
|
+
*
|
|
358
|
+
* @example `pid(1234) <- node server.js`
|
|
359
|
+
*/
|
|
360
|
+
export const print_child_process = (child) => `${st('gray', 'pid(')}${child.pid ?? 'none'}${st('gray', ')')} ← ${st('green', child.spawnargs.join(' '))}`;
|
|
361
|
+
/**
|
|
362
|
+
* Formats a spawn result for display.
|
|
363
|
+
* Returns `'ok'` for success, or the error/signal/code for failures.
|
|
108
364
|
*/
|
|
109
365
|
export const print_spawn_result = (result) => {
|
|
110
366
|
if (result.ok)
|
|
111
367
|
return 'ok';
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
368
|
+
if (spawn_result_is_error(result))
|
|
369
|
+
return result.error.message;
|
|
370
|
+
if (spawn_result_is_signaled(result))
|
|
371
|
+
return print_key_value('signal', result.signal);
|
|
372
|
+
return print_key_value('code', result.code);
|
|
116
373
|
};
|
|
117
374
|
/**
|
|
118
|
-
*
|
|
119
|
-
|
|
375
|
+
* Formats a spawn result for use in error messages.
|
|
376
|
+
*/
|
|
377
|
+
export const spawn_result_to_message = (result) => {
|
|
378
|
+
if (spawn_result_is_error(result))
|
|
379
|
+
return `error: ${result.error.message}`;
|
|
380
|
+
if (spawn_result_is_signaled(result))
|
|
381
|
+
return `signal ${result.signal}`;
|
|
382
|
+
return `code ${result.code}`;
|
|
383
|
+
};
|
|
384
|
+
/**
|
|
385
|
+
* Spawns a process that can be restarted.
|
|
386
|
+
* Handles concurrent restart calls gracefully.
|
|
387
|
+
*
|
|
388
|
+
* Note: The `signal` and `timeout_ms` options are reapplied on each restart.
|
|
389
|
+
* If the AbortSignal is already aborted when `restart()` is called, the new
|
|
390
|
+
* process will be killed immediately.
|
|
391
|
+
*
|
|
392
|
+
* @example Simple restart on crash
|
|
393
|
+
* ```ts
|
|
394
|
+
* const rp = spawn_restartable_process('node', ['server.js']);
|
|
395
|
+
*
|
|
396
|
+
* while (rp.active) {
|
|
397
|
+
* const result = await rp.closed;
|
|
398
|
+
* if (result.ok) break; // Clean exit
|
|
399
|
+
* await rp.restart();
|
|
400
|
+
* }
|
|
401
|
+
* ```
|
|
402
|
+
*
|
|
403
|
+
* @example Restart with backoff
|
|
404
|
+
* ```ts
|
|
405
|
+
* const rp = spawn_restartable_process('node', ['server.js']);
|
|
406
|
+
* let failures = 0;
|
|
407
|
+
*
|
|
408
|
+
* while (rp.active) {
|
|
409
|
+
* const result = await rp.closed;
|
|
410
|
+
* if (result.ok || ++failures > 5) break;
|
|
411
|
+
* await new Promise((r) => setTimeout(r, 1000 * failures));
|
|
412
|
+
* await rp.restart();
|
|
413
|
+
* }
|
|
414
|
+
* ```
|
|
120
415
|
*/
|
|
121
416
|
export const spawn_restartable_process = (command, args = [], options) => {
|
|
122
|
-
let
|
|
123
|
-
let
|
|
124
|
-
|
|
125
|
-
|
|
417
|
+
let spawned_process = null;
|
|
418
|
+
let pending_close = null;
|
|
419
|
+
let pending_restart = null;
|
|
420
|
+
let pending_kill = null;
|
|
421
|
+
// Deferred promise - resolves when first process spawns
|
|
422
|
+
let closed_promise;
|
|
423
|
+
let resolve_closed;
|
|
424
|
+
const reset_closed_promise = () => {
|
|
425
|
+
closed_promise = new Promise((r) => (resolve_closed = r));
|
|
426
|
+
};
|
|
427
|
+
reset_closed_promise();
|
|
428
|
+
// Resolve when first spawn completes to avoid race conditions
|
|
429
|
+
let resolve_spawned;
|
|
430
|
+
const spawned = new Promise((r) => (resolve_spawned = r));
|
|
431
|
+
const do_close = async () => {
|
|
432
|
+
if (!spawned_process)
|
|
126
433
|
return;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
await
|
|
131
|
-
|
|
434
|
+
pending_close = spawned_process.closed;
|
|
435
|
+
spawned_process.child.kill();
|
|
436
|
+
spawned_process = null;
|
|
437
|
+
await pending_close;
|
|
438
|
+
pending_close = null;
|
|
132
439
|
};
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
440
|
+
const do_restart = async () => {
|
|
441
|
+
// Wait for any in-progress kill or close before restarting
|
|
442
|
+
if (pending_kill)
|
|
443
|
+
await pending_kill;
|
|
444
|
+
if (pending_close)
|
|
445
|
+
await pending_close;
|
|
446
|
+
if (spawned_process)
|
|
447
|
+
await do_close();
|
|
448
|
+
spawned_process = spawn_process(command, args, { stdio: 'inherit', ...options });
|
|
449
|
+
// Forward the spawned process's closed promise to our exposed one
|
|
450
|
+
void spawned_process.closed.then((result) => {
|
|
451
|
+
resolve_closed(result);
|
|
452
|
+
});
|
|
453
|
+
};
|
|
454
|
+
// Coalesce concurrent restart calls - multiple calls share one restart
|
|
455
|
+
const restart = () => {
|
|
456
|
+
if (!pending_restart) {
|
|
457
|
+
// Reset the closed promise for the new process
|
|
458
|
+
reset_closed_promise();
|
|
459
|
+
pending_restart = do_restart().finally(() => {
|
|
460
|
+
pending_restart = null;
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
return pending_restart;
|
|
139
464
|
};
|
|
465
|
+
// Wait for any pending restart to complete first, ensuring we kill
|
|
466
|
+
// the newly spawned process rather than racing with it
|
|
140
467
|
const kill = async () => {
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
468
|
+
if (pending_kill)
|
|
469
|
+
return pending_kill;
|
|
470
|
+
pending_kill = (async () => {
|
|
471
|
+
if (pending_restart)
|
|
472
|
+
await pending_restart;
|
|
473
|
+
if (pending_close)
|
|
474
|
+
await pending_close;
|
|
475
|
+
await do_close();
|
|
476
|
+
})();
|
|
477
|
+
try {
|
|
478
|
+
await pending_kill;
|
|
479
|
+
}
|
|
480
|
+
finally {
|
|
481
|
+
pending_kill = null;
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
// Start immediately and resolve spawned promise when done
|
|
485
|
+
void restart().then(() => resolve_spawned());
|
|
486
|
+
return {
|
|
487
|
+
restart,
|
|
488
|
+
kill,
|
|
489
|
+
get active() {
|
|
490
|
+
return spawned_process !== null;
|
|
491
|
+
},
|
|
492
|
+
get child() {
|
|
493
|
+
return spawned_process?.child ?? null;
|
|
494
|
+
},
|
|
495
|
+
get closed() {
|
|
496
|
+
return closed_promise;
|
|
497
|
+
},
|
|
498
|
+
get spawned() {
|
|
499
|
+
return spawned;
|
|
500
|
+
},
|
|
144
501
|
};
|
|
145
|
-
// Start immediately -- it sychronously starts the process so there's no need to await.
|
|
146
|
-
void restart();
|
|
147
|
-
return { restart, kill };
|
|
148
502
|
};
|
|
503
|
+
//
|
|
504
|
+
// Utility Functions
|
|
505
|
+
//
|
|
149
506
|
/**
|
|
150
|
-
*
|
|
507
|
+
* Checks if a process with the given PID is running.
|
|
508
|
+
* Uses signal 0 which checks existence without sending a signal.
|
|
509
|
+
*
|
|
510
|
+
* @param pid - The process ID to check (must be a positive integer)
|
|
511
|
+
* @returns `true` if the process exists (even without permission to signal it),
|
|
512
|
+
* `false` if the process doesn't exist or if pid is invalid (non-positive, non-integer, NaN, Infinity)
|
|
151
513
|
*/
|
|
152
514
|
export const process_is_pid_running = (pid) => {
|
|
515
|
+
// Handle NaN, Infinity, negative, zero, non-integers, and fractional values
|
|
516
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
517
|
+
return false;
|
|
153
518
|
try {
|
|
154
|
-
// Sending signal 0 doesn't actually send a signal, just checks if process exists
|
|
155
519
|
process.kill(pid, 0);
|
|
156
520
|
return true;
|
|
157
521
|
}
|
|
158
522
|
catch (err) {
|
|
159
|
-
// ESRCH = no such process
|
|
160
|
-
|
|
523
|
+
// ESRCH = no such process
|
|
524
|
+
// EPERM = process exists but we lack permission to signal it
|
|
525
|
+
// Safely access .code in case of unexpected error types
|
|
526
|
+
const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
|
|
527
|
+
return code === 'EPERM';
|
|
161
528
|
}
|
|
162
529
|
};
|
package/dist/random.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export declare const random_boolean: (random?: () => number) => boolean;
|
|
|
19
19
|
export declare const random_item: <T extends ReadonlyArray<any>>(arr: T, random?: () => number) => ArrayElement<T>;
|
|
20
20
|
/**
|
|
21
21
|
* Mutates `array` with random ordering.
|
|
22
|
-
* @mutates array randomly reorders elements in place using Fisher-Yates shuffle
|
|
22
|
+
* @mutates array - randomly reorders elements in place using Fisher-Yates shuffle
|
|
23
23
|
*/
|
|
24
24
|
export declare const shuffle: <T extends Array<any>>(array: T, random?: typeof random_int) => T;
|
|
25
25
|
//# sourceMappingURL=random.d.ts.map
|
package/dist/random.js
CHANGED
|
@@ -18,7 +18,7 @@ export const random_boolean = (random = Math.random) => random() > 0.5;
|
|
|
18
18
|
export const random_item = (arr, random = Math.random) => arr[random_int(0, arr.length - 1, random)];
|
|
19
19
|
/**
|
|
20
20
|
* Mutates `array` with random ordering.
|
|
21
|
-
* @mutates array randomly reorders elements in place using Fisher-Yates shuffle
|
|
21
|
+
* @mutates array - randomly reorders elements in place using Fisher-Yates shuffle
|
|
22
22
|
*/
|
|
23
23
|
export const shuffle = (array, random = random_int) => {
|
|
24
24
|
const { length } = array;
|
package/dist/regexp.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export declare const escape_regexp: (str: string) => string;
|
|
|
6
6
|
/**
|
|
7
7
|
* Reset a RegExp's lastIndex to 0 for global and sticky patterns.
|
|
8
8
|
* Ensures consistent behavior by clearing state that affects subsequent matches.
|
|
9
|
-
* @mutates regexp sets lastIndex to 0 if regexp is global or sticky
|
|
9
|
+
* @mutates regexp - sets lastIndex to 0 if regexp is global or sticky
|
|
10
10
|
*/
|
|
11
11
|
export declare const reset_regexp: <T extends RegExp>(regexp: T) => T;
|
|
12
12
|
//# sourceMappingURL=regexp.d.ts.map
|