@osdlc/command-seam 0.0.0-stage → 0.4.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/CHANGELOG.md +315 -0
- package/CONTRIBUTING.md +61 -0
- package/LICENSE +21 -0
- package/README.md +741 -2
- package/SECURITY.md +23 -0
- package/async.ts +466 -0
- package/defaults.ts +83 -0
- package/dist/async.d.ts +146 -0
- package/dist/async.d.ts.map +1 -0
- package/dist/async.js +368 -0
- package/dist/defaults.d.ts +52 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +51 -0
- package/dist/groups.d.ts +155 -0
- package/dist/groups.d.ts.map +1 -0
- package/dist/groups.js +331 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/process.d.ts +146 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +175 -0
- package/dist/process.test.d.ts +1 -0
- package/dist/process.test.d.ts.map +1 -0
- package/dist/process.test.js +1 -0
- package/dist/quote.d.ts +18 -0
- package/dist/quote.d.ts.map +1 -0
- package/dist/quote.js +28 -0
- package/dist/result.d.ts +211 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +219 -0
- package/dist/shell.d.ts +84 -0
- package/dist/shell.d.ts.map +1 -0
- package/dist/shell.js +83 -0
- package/dist/stream.d.ts +90 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +73 -0
- package/dist/sync.d.ts +311 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +406 -0
- package/dist/testing.d.ts +158 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +173 -0
- package/groups.ts +360 -0
- package/index.ts +98 -0
- package/package.json +94 -3
- package/process.ts +270 -0
- package/quote.ts +31 -0
- package/result.ts +403 -0
- package/shell.ts +131 -0
- package/smoke.mjs +61 -0
- package/stream.ts +134 -0
- package/sync.ts +604 -0
- package/testing.ts +263 -0
package/dist/testing.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@osdlc/command-seam/testing`: fakes for tests, kept out of the main
|
|
3
|
+
* entry so production code never imports them.
|
|
4
|
+
*
|
|
5
|
+
* - `fakeRunner` answers capture-runner calls from a table and records them.
|
|
6
|
+
* - `fakeNeverExited` builds the result of a run that never exited.
|
|
7
|
+
* - `fakeProcess` is a managed-process handle the test ends by hand.
|
|
8
|
+
*/
|
|
9
|
+
import { signalName, } from "./process.js";
|
|
10
|
+
import { asAsync } from "./async.js";
|
|
11
|
+
import { shellQuote } from "./quote.js";
|
|
12
|
+
/**
|
|
13
|
+
* A fake {@link CommandRunner} that answers from a table and records every
|
|
14
|
+
* call.
|
|
15
|
+
*
|
|
16
|
+
* `answers` maps an argv to the result fields that differ from a clean
|
|
17
|
+
* empty run: `{ exitCode: 0, stdout: '', stderr: '' }`. The key is each
|
|
18
|
+
* argument quoted with `shellQuote`, joined with single spaces: a plain
|
|
19
|
+
* argument stays as it is, and one with a space or a shell character is
|
|
20
|
+
* single-quoted, so `['-m', 'a b']` is `-m 'a b'` and cannot match
|
|
21
|
+
* `['-m', 'a', 'b']`. It can also be a function of the call; return
|
|
22
|
+
* `undefined` for no answer.
|
|
23
|
+
*
|
|
24
|
+
* A call with no answer reports a failed spawn, as a missing binary would:
|
|
25
|
+
* `exitCode: 1`, `neverExited: true`, `reason: 'spawn'`, and
|
|
26
|
+
* `fakeRunner: no answer for: <key>` in `error`, where `<key>` is the table
|
|
27
|
+
* key that would answer it. So `ran()` is false and `runChecked` throws.
|
|
28
|
+
* Code that reads a failed run as an answer may still go on, so assert on
|
|
29
|
+
* `calls` to be sure every call was expected.
|
|
30
|
+
*
|
|
31
|
+
* Through `asyncRunner`, `signal` and `processGroup` are recorded but do
|
|
32
|
+
* nothing, and `onSpawn` is never called.
|
|
33
|
+
*
|
|
34
|
+
* @param answers - Results by argv, or a function that picks one.
|
|
35
|
+
* @returns The runner, the same fake as an async runner, and the list of
|
|
36
|
+
* calls they received.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* const { runner, calls } = fakeRunner({ 'rev-parse --short HEAD': { stdout: 'abc\n' } })
|
|
41
|
+
* runner.run(['rev-parse', '--short', 'HEAD'], { cwd: '/repo' }).stdout // 'abc\n'
|
|
42
|
+
* calls[0]?.opts.cwd // '/repo'
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* @experimental
|
|
46
|
+
*/
|
|
47
|
+
export function fakeRunner(answers) {
|
|
48
|
+
const calls = [];
|
|
49
|
+
const lookup = typeof answers === 'function'
|
|
50
|
+
? answers
|
|
51
|
+
: (argv) => {
|
|
52
|
+
const key = keyOf(argv);
|
|
53
|
+
return Object.hasOwn(answers, key) ? answers[key] : undefined;
|
|
54
|
+
};
|
|
55
|
+
const runner = {
|
|
56
|
+
run(argv, opts) {
|
|
57
|
+
calls.push({ argv: [...argv], opts: { ...opts } });
|
|
58
|
+
const answer = lookup(argv, opts);
|
|
59
|
+
if (answer !== undefined)
|
|
60
|
+
return { exitCode: 0, stdout: '', stderr: '', ...answer };
|
|
61
|
+
const error = `fakeRunner: no answer for: ${keyOf(argv)}`;
|
|
62
|
+
return { exitCode: 1, stdout: '', stderr: '', neverExited: true, reason: 'spawn', error };
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
return { runner, asyncRunner: asAsync(runner), calls };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A `fakeRunner` table key. Not `shellJoin`: argv here has no binary name, so
|
|
69
|
+
* a first argument with `=` (such as `--git-dir=/r`) is not a command name
|
|
70
|
+
* and needs no quotes.
|
|
71
|
+
*/
|
|
72
|
+
function keyOf(argv) {
|
|
73
|
+
return argv.map(shellQuote).join(' ');
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A fake answer for a run that never exited, with the fields the real
|
|
77
|
+
* runners set together for `reason`: `exitCode: 1`, `neverExited: true`,
|
|
78
|
+
* `reason`, `error: 'fake: <reason>'`, and `signal` where the sync runner
|
|
79
|
+
* sets one (`SIGKILL` for `timeout` and `output`, `SIGTERM` for `signal`).
|
|
80
|
+
* A death by a signal from elsewhere has no `error`, as on the real runners.
|
|
81
|
+
* `fields` override any of these, such as the `signal` or the `error`.
|
|
82
|
+
*
|
|
83
|
+
* @param reason - Why the run never exited.
|
|
84
|
+
* @param fields - Fields to set over the defaults.
|
|
85
|
+
* @returns An answer for `fakeRunner`.
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```ts
|
|
89
|
+
* const { runner } = fakeRunner({ 'fetch origin': fakeNeverExited('timeout') })
|
|
90
|
+
* runner.run(['fetch', 'origin'], { cwd: '/repo' }).reason // 'timeout'
|
|
91
|
+
* ```
|
|
92
|
+
*
|
|
93
|
+
* @experimental
|
|
94
|
+
*/
|
|
95
|
+
export function fakeNeverExited(reason, fields = {}) {
|
|
96
|
+
const signal = NEVER_EXITED_SIGNAL[reason];
|
|
97
|
+
return {
|
|
98
|
+
exitCode: 1,
|
|
99
|
+
neverExited: true,
|
|
100
|
+
reason,
|
|
101
|
+
...(signal === undefined ? {} : { signal }),
|
|
102
|
+
...(reason === 'signal' ? {} : { error: `fake: ${reason}` }),
|
|
103
|
+
...fields,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** The signal the sync runner reports for each reason, where it reports one. */
|
|
107
|
+
const NEVER_EXITED_SIGNAL = {
|
|
108
|
+
spawn: undefined,
|
|
109
|
+
timeout: 'SIGKILL',
|
|
110
|
+
abort: undefined,
|
|
111
|
+
signal: 'SIGTERM',
|
|
112
|
+
output: 'SIGKILL',
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* A fake {@link ManagedProcessHandle}: it starts nothing, and `exited`
|
|
116
|
+
* settles when the test calls `exit()`, or when the code under test calls
|
|
117
|
+
* `kill()` (see `exitOnKill`).
|
|
118
|
+
*
|
|
119
|
+
* @param opts - The pid, and which signals sent through `kill()` end the process.
|
|
120
|
+
* @returns The handle, with `exit()`, the signals sent and a runner that
|
|
121
|
+
* serves it.
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* ```ts
|
|
125
|
+
* const proc = fakeProcess()
|
|
126
|
+
* const server = proc.runner.run(['server.js'], { cwd: '.' })
|
|
127
|
+
* proc.exit({ exitCode: 0 })
|
|
128
|
+
* await server.exited // { exitCode: 0, signal: null }
|
|
129
|
+
* ```
|
|
130
|
+
*
|
|
131
|
+
* @experimental
|
|
132
|
+
*/
|
|
133
|
+
export function fakeProcess(opts = {}) {
|
|
134
|
+
const pid = opts.pid ?? 4242;
|
|
135
|
+
const exitOnKill = opts.exitOnKill ?? true;
|
|
136
|
+
let settle = () => { };
|
|
137
|
+
let settled = false;
|
|
138
|
+
const exited = new Promise((resolve) => (settle = resolve));
|
|
139
|
+
const exit = (result = {}) => {
|
|
140
|
+
if (settled)
|
|
141
|
+
return;
|
|
142
|
+
settled = true;
|
|
143
|
+
settle({ exitCode: 0, signal: null, ...result });
|
|
144
|
+
};
|
|
145
|
+
const signals = [];
|
|
146
|
+
const calls = [];
|
|
147
|
+
const proc = {
|
|
148
|
+
pid,
|
|
149
|
+
exited,
|
|
150
|
+
kill(signal = 'SIGTERM') {
|
|
151
|
+
signals.push(signal);
|
|
152
|
+
const name = typeof signal === 'number' ? signalName(signal) : signal;
|
|
153
|
+
// Signal 0 (a probe) and unknown numbers end nothing.
|
|
154
|
+
if (name === undefined)
|
|
155
|
+
return;
|
|
156
|
+
if (exitOnKill === false)
|
|
157
|
+
return;
|
|
158
|
+
if (exitOnKill !== true && !exitOnKill.includes(name))
|
|
159
|
+
return;
|
|
160
|
+
exit({ exitCode: 1, signal: name, neverExited: true, reason: 'abort' });
|
|
161
|
+
},
|
|
162
|
+
exit,
|
|
163
|
+
signals,
|
|
164
|
+
calls,
|
|
165
|
+
runner: {
|
|
166
|
+
run(argv, runOpts) {
|
|
167
|
+
calls.push({ argv: [...argv], opts: { ...runOpts } });
|
|
168
|
+
return proc;
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
return proc;
|
|
173
|
+
}
|
package/groups.ts
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live process groups, and forwarding of termination signals to them.
|
|
3
|
+
*
|
|
4
|
+
* A child started with `processGroup: true` leads its own group and no longer
|
|
5
|
+
* receives the terminal's Ctrl-C. When the host is then killed by `SIGINT`,
|
|
6
|
+
* `SIGTERM` or `SIGHUP`, the child and everything it started would be orphaned
|
|
7
|
+
* (ppid 1). The runners register each such group here, and
|
|
8
|
+
* {@link installGroupSignalForwarding} makes the host kill them when it is
|
|
9
|
+
* signalled. A group stays registered until it is empty. Once its run has
|
|
10
|
+
* settled, the host's normal exit leaves what is left of it running, such as a
|
|
11
|
+
* daemon; a forwarded signal still kills it.
|
|
12
|
+
*
|
|
13
|
+
* Once a signal has been forwarded and this module owns the exit, the host is
|
|
14
|
+
* stopping: the runners refuse to start a new group, and
|
|
15
|
+
* {@link forwardedSignal} tells the host's own code to stop.
|
|
16
|
+
*
|
|
17
|
+
* The signal listeners exist only while a group is starting or registered. A
|
|
18
|
+
* JS listener replaces the default "terminate" action and runs only when the
|
|
19
|
+
* event loop turns, so keeping one armed would make a host blocked in a sync
|
|
20
|
+
* spawn ignore a signal it would otherwise die from.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { constants } from 'node:os'
|
|
24
|
+
|
|
25
|
+
const osSignals = constants.signals as Record<string, number>
|
|
26
|
+
|
|
27
|
+
/** Live group leaders, in start order, and whether each one's run has settled. */
|
|
28
|
+
const live = new Map<number, { settled: boolean }>()
|
|
29
|
+
/** Group spawns in progress: the listeners are armed before their pid is known. */
|
|
30
|
+
let spawning = 0
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Options for {@link installGroupSignalForwarding}.
|
|
34
|
+
*
|
|
35
|
+
* @experimental
|
|
36
|
+
*/
|
|
37
|
+
export interface GroupSignalForwardingOptions {
|
|
38
|
+
/** Signals to forward. Default `SIGINT`, `SIGTERM` and `SIGHUP`. */
|
|
39
|
+
signals?: readonly NodeJS.Signals[] | undefined
|
|
40
|
+
/**
|
|
41
|
+
* How long the groups have between `SIGTERM` and `SIGKILL`, in ms. Default
|
|
42
|
+
* 1000: long enough for git to remove its lock files.
|
|
43
|
+
*/
|
|
44
|
+
graceMs?: number | undefined
|
|
45
|
+
/**
|
|
46
|
+
* Called to end the process, with `128 + signal number`. Default: set
|
|
47
|
+
* `process.exitCode` and let the event loop drain, so piped stdout is not
|
|
48
|
+
* truncated, then `process.exit` after 500 ms if handles still hold the loop.
|
|
49
|
+
* Tests replace it.
|
|
50
|
+
*/
|
|
51
|
+
exit?: ((code: number) => void) | undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEFAULT_SIGNALS: readonly NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP']
|
|
55
|
+
const POLL_MS = 25
|
|
56
|
+
|
|
57
|
+
interface Config {
|
|
58
|
+
signals: readonly NodeJS.Signals[]
|
|
59
|
+
graceMs: number
|
|
60
|
+
exit: (code: number) => void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Set while forwarding is installed. */
|
|
64
|
+
let config: Config | undefined
|
|
65
|
+
/** Set while the listeners are attached: forwarding installed and a group starting or live. */
|
|
66
|
+
let armed: { off: () => void } | undefined
|
|
67
|
+
/**
|
|
68
|
+
* The first signal forwarded since install whose exit this module owns (the
|
|
69
|
+
* host has no listener of its own for it). Cleared only by uninstall.
|
|
70
|
+
*/
|
|
71
|
+
let forwarded: NodeJS.Signals | undefined
|
|
72
|
+
/** Set while a graceful kill is waiting out its grace. */
|
|
73
|
+
let killing = false
|
|
74
|
+
/** A repeat signal already sent `SIGKILL`: the graceful kill stops waiting. */
|
|
75
|
+
let escalated = false
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Register a group leader. The returned function unregisters it; call it once
|
|
79
|
+
* the group is gone so a reused pid is never signalled. Registering the first
|
|
80
|
+
* group arms the signal listeners (when forwarding is installed); removing the
|
|
81
|
+
* last disarms them.
|
|
82
|
+
*
|
|
83
|
+
* @internal
|
|
84
|
+
*/
|
|
85
|
+
export function trackGroup(pid: number): () => void {
|
|
86
|
+
live.set(pid, { settled: false })
|
|
87
|
+
arm()
|
|
88
|
+
return () => {
|
|
89
|
+
live.delete(pid)
|
|
90
|
+
disarmIfIdle()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Start a group leader with `start` and register it. The listeners are armed
|
|
96
|
+
* before `start` runs: the child can print, and the host be signalled, before
|
|
97
|
+
* `spawn` returns, and a signal that comes before the listeners would kill the
|
|
98
|
+
* host by default and orphan the group. `untrack` is `undefined` when the
|
|
99
|
+
* child got no pid.
|
|
100
|
+
*
|
|
101
|
+
* @internal
|
|
102
|
+
*/
|
|
103
|
+
export function spawnTrackedGroup<C extends { readonly pid?: number | undefined }>(
|
|
104
|
+
start: () => C,
|
|
105
|
+
): { child: C; untrack: (() => void) | undefined } {
|
|
106
|
+
spawning += 1
|
|
107
|
+
arm()
|
|
108
|
+
try {
|
|
109
|
+
const child = start()
|
|
110
|
+
return { child, untrack: child.pid === undefined ? undefined : trackGroup(child.pid) }
|
|
111
|
+
} finally {
|
|
112
|
+
spawning -= 1
|
|
113
|
+
disarmIfIdle()
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Mark the run of the group led by `pid` as settled: the host's exit no longer
|
|
119
|
+
* kills what is left of the group. A forwarded signal still does. A runner
|
|
120
|
+
* does not call this for a group it is stopping itself.
|
|
121
|
+
*
|
|
122
|
+
* @internal
|
|
123
|
+
*/
|
|
124
|
+
export function settleGroup(pid: number): void {
|
|
125
|
+
const entry = live.get(pid)
|
|
126
|
+
if (entry !== undefined) entry.settled = true
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Why a new group must not start, or `undefined` when it may. The runners call
|
|
131
|
+
* this before they spawn with `processGroup: true`.
|
|
132
|
+
*
|
|
133
|
+
* @internal
|
|
134
|
+
*/
|
|
135
|
+
export function groupStartRefusal(): string | undefined {
|
|
136
|
+
if (forwarded === undefined) return undefined
|
|
137
|
+
return `not started: the host got ${forwarded} and is stopping`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Run `release` once the group led by `pid` has no member: now if it is
|
|
142
|
+
* already empty, else polled on an unref'd timer.
|
|
143
|
+
*
|
|
144
|
+
* Unref'd on purpose: a ref'd poll would keep the host alive for as long as a
|
|
145
|
+
* member the group left behind. On a Bun version where an unref'd timer does
|
|
146
|
+
* not fire (see `async.ts`), the entry stays registered. The listeners then
|
|
147
|
+
* stay armed and a signal still kills that member, which is the safe side.
|
|
148
|
+
*
|
|
149
|
+
* @internal
|
|
150
|
+
*/
|
|
151
|
+
export function releaseWhenEmpty(pid: number, release: () => void): void {
|
|
152
|
+
if (!groupExists(pid)) {
|
|
153
|
+
release()
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
const timer = setInterval(() => {
|
|
157
|
+
if (groupExists(pid)) return
|
|
158
|
+
clearInterval(timer)
|
|
159
|
+
release()
|
|
160
|
+
}, 250)
|
|
161
|
+
timer.unref()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* True while the group led by `pid` still has a member.
|
|
166
|
+
*
|
|
167
|
+
* @internal
|
|
168
|
+
*/
|
|
169
|
+
export function groupExists(pid: number): boolean {
|
|
170
|
+
try {
|
|
171
|
+
process.kill(-pid, 0)
|
|
172
|
+
return true
|
|
173
|
+
} catch {
|
|
174
|
+
return false
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Send `sig` to the group led by `pid`, only while it is registered. */
|
|
179
|
+
function signalGroup(pid: number, sig: NodeJS.Signals): void {
|
|
180
|
+
if (!live.has(pid)) return
|
|
181
|
+
try {
|
|
182
|
+
process.kill(-pid, sig)
|
|
183
|
+
} catch {
|
|
184
|
+
// Already gone.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The pids of the live process groups started with `processGroup: true`.
|
|
190
|
+
*
|
|
191
|
+
* @experimental
|
|
192
|
+
*/
|
|
193
|
+
export function liveProcessGroups(): number[] {
|
|
194
|
+
return [...live.keys()]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The signal the host was sent and forwarded to its groups, or `undefined`
|
|
199
|
+
* while none has been. Set only when forwarding owns the exit, that is, the
|
|
200
|
+
* host has no listener of its own for that signal. Once set, the host is
|
|
201
|
+
* stopping: a loop that runs one command after another should check it and
|
|
202
|
+
* stop, and the runners refuse to start a new `processGroup` run. The
|
|
203
|
+
* uninstall function clears it.
|
|
204
|
+
*
|
|
205
|
+
* @experimental
|
|
206
|
+
*/
|
|
207
|
+
export function forwardedSignal(): NodeJS.Signals | undefined {
|
|
208
|
+
return forwarded
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The exit code for {@link forwardedSignal}: `128 + signal number`, or
|
|
213
|
+
* `undefined` while no signal has been forwarded. A host that sets its own
|
|
214
|
+
* exit code after its work returns keeps this one over it:
|
|
215
|
+
* `process.exitCode = forwardedExitCode() ?? code`.
|
|
216
|
+
*
|
|
217
|
+
* @experimental
|
|
218
|
+
*/
|
|
219
|
+
export function forwardedExitCode(): number | undefined {
|
|
220
|
+
return forwarded === undefined ? undefined : 128 + signalNumber(forwarded)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function arm(): void {
|
|
224
|
+
if (config === undefined || armed !== undefined || (live.size === 0 && spawning === 0)) return
|
|
225
|
+
const { signals, graceMs, exit } = config
|
|
226
|
+
const onExit = () => {
|
|
227
|
+
// The host is going away: nothing async runs after this, so end every group
|
|
228
|
+
// whose run is still in flight now. A settled run's leftovers stay.
|
|
229
|
+
for (const [pid, { settled }] of live) if (!settled) signalGroup(pid, 'SIGKILL')
|
|
230
|
+
}
|
|
231
|
+
const handlers = signals.map((sig) => {
|
|
232
|
+
const handler = () => {
|
|
233
|
+
const code = 128 + signalNumber(sig)
|
|
234
|
+
if (forwarded !== undefined || killing) {
|
|
235
|
+
// A repeat signal: stop waiting.
|
|
236
|
+
escalated = true
|
|
237
|
+
for (const pid of live.keys()) signalGroup(pid, 'SIGKILL')
|
|
238
|
+
if (!othersListen(sig)) exit(code)
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
// A host listener owns the exit and may keep running (a REPL cancelling
|
|
242
|
+
// a job, a daemon reloading on SIGHUP): kill the groups, but do not
|
|
243
|
+
// refuse later runs.
|
|
244
|
+
const othersOwnExit = othersListen(sig)
|
|
245
|
+
if (!othersOwnExit) forwarded = sig
|
|
246
|
+
killing = true
|
|
247
|
+
escalated = false
|
|
248
|
+
void killAll(graceMs).then(() => {
|
|
249
|
+
killing = false
|
|
250
|
+
if (!escalated && !othersOwnExit) exit(code)
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
// First in line: Node and Bun drop a host's `process.once` wrapper before
|
|
254
|
+
// calling it, so a handler that ran later would not count that listener.
|
|
255
|
+
process.prependListener(sig, handler)
|
|
256
|
+
return [sig, handler] as const
|
|
257
|
+
})
|
|
258
|
+
process.on('exit', onExit)
|
|
259
|
+
armed = {
|
|
260
|
+
off: () => {
|
|
261
|
+
for (const [sig, handler] of handlers) process.off(sig, handler)
|
|
262
|
+
process.off('exit', onExit)
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function disarm(): void {
|
|
268
|
+
armed?.off()
|
|
269
|
+
armed = undefined
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function disarmIfIdle(): void {
|
|
273
|
+
if (live.size === 0 && spawning === 0) disarm()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Another listener than ours is attached for `sig`: it owns the exit. */
|
|
277
|
+
function othersListen(sig: NodeJS.Signals): boolean {
|
|
278
|
+
return process.listenerCount(sig) > 1
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Enable forwarding of termination signals to live process groups.
|
|
283
|
+
*
|
|
284
|
+
* While at least one group started with `processGroup: true` is live, `SIGINT`,
|
|
285
|
+
* `SIGTERM` and `SIGHUP` send `SIGTERM` to every registered group, then
|
|
286
|
+
* `SIGKILL` to those still registered after `graceMs`, then end the process
|
|
287
|
+
* with `128 + signal number`, the shell convention. A second signal during the
|
|
288
|
+
* grace, or any later one, skips straight to `SIGKILL` and the exit. From the
|
|
289
|
+
* first signal on, {@link forwardedSignal} returns it and the runners refuse
|
|
290
|
+
* to start a new `processGroup` run, until the uninstall function resets
|
|
291
|
+
* that. A `process` `exit` handler, armed with the listeners, also `SIGKILL`s
|
|
292
|
+
* every group whose run is still in flight, so a host that exits first does
|
|
293
|
+
* not leave one behind. A member that a settled run left behind, such as a
|
|
294
|
+
* daemon, outlives the host's exit; a forwarded signal still kills it.
|
|
295
|
+
*
|
|
296
|
+
* The listeners are attached just before the first group starts and removed
|
|
297
|
+
* when the last one goes, so a host with no live group keeps each signal's default
|
|
298
|
+
* action. If the host has its own listener for the signal (`process.once`
|
|
299
|
+
* counts too), the groups still
|
|
300
|
+
* get `SIGTERM` at once and `SIGKILL` after `graceMs` (or on `exit`, if
|
|
301
|
+
* sooner and the run is in flight), but that listener keeps the exit, and later runs are not refused.
|
|
302
|
+
* Calling it again while installed is a no-op returning the same
|
|
303
|
+
* uninstall function. `SIGKILL` of the host cannot be caught and still orphans
|
|
304
|
+
* its groups. POSIX only; a no-op on Windows.
|
|
305
|
+
*
|
|
306
|
+
* @returns A function that removes the forwarding.
|
|
307
|
+
* @experimental
|
|
308
|
+
*/
|
|
309
|
+
export function installGroupSignalForwarding(opts: GroupSignalForwardingOptions = {}): () => void {
|
|
310
|
+
if (process.platform === 'win32') return () => {}
|
|
311
|
+
if (config !== undefined) return uninstall
|
|
312
|
+
config = {
|
|
313
|
+
signals: opts.signals ?? DEFAULT_SIGNALS,
|
|
314
|
+
graceMs: opts.graceMs ?? 1000,
|
|
315
|
+
exit: opts.exit ?? defaultExit,
|
|
316
|
+
}
|
|
317
|
+
arm()
|
|
318
|
+
return uninstall
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function uninstall(): void {
|
|
322
|
+
disarm()
|
|
323
|
+
config = undefined
|
|
324
|
+
forwarded = undefined
|
|
325
|
+
killing = false
|
|
326
|
+
escalated = false
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Set the exit code and let output drain; force the exit after 500 ms. Not
|
|
331
|
+
* unref'd: an unref'd timer does not reliably fire on every Bun version, and
|
|
332
|
+
* holding the loop for 500 ms after a signal is cheap.
|
|
333
|
+
*/
|
|
334
|
+
function defaultExit(code: number): void {
|
|
335
|
+
process.exitCode = code
|
|
336
|
+
setTimeout(() => process.exit(code), 500)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* `SIGTERM` every registered group, wait up to `graceMs` for them to go, then
|
|
341
|
+
* `SIGKILL` those still registered. Stops early when `escalated` says a repeat
|
|
342
|
+
* signal already did the kill.
|
|
343
|
+
*/
|
|
344
|
+
async function killAll(graceMs: number): Promise<void> {
|
|
345
|
+
// Drop entries whose group is already empty, so a stale pid is never signalled.
|
|
346
|
+
for (const pid of liveProcessGroups()) if (!groupExists(pid)) live.delete(pid)
|
|
347
|
+
for (const pid of liveProcessGroups()) signalGroup(pid, 'SIGTERM')
|
|
348
|
+
const deadline = performance.now() + graceMs
|
|
349
|
+
while (!escalated && performance.now() < deadline) {
|
|
350
|
+
if (!liveProcessGroups().some(groupExists)) return
|
|
351
|
+
await new Promise((res) => setTimeout(res, POLL_MS))
|
|
352
|
+
}
|
|
353
|
+
if (escalated) return
|
|
354
|
+
for (const pid of liveProcessGroups()) if (groupExists(pid)) signalGroup(pid, 'SIGKILL')
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function signalNumber(sig: NodeJS.Signals): number {
|
|
358
|
+
const n = osSignals[sig]
|
|
359
|
+
return typeof n === 'number' ? n : 15
|
|
360
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@osdlc/command-seam`: run external commands through a small interface
|
|
3
|
+
* that tests can replace.
|
|
4
|
+
*
|
|
5
|
+
* A runner is an object with a `run` method. Code that needs a command takes
|
|
6
|
+
* a runner as a parameter. Production passes a real one; tests pass a fake.
|
|
7
|
+
*
|
|
8
|
+
* - `spawnRunner` / `spawnRunnerAsync` capture a finished run's output
|
|
9
|
+
* ({@link CommandResult}).
|
|
10
|
+
* - `runShell` / `shellRunner` run a shell string through `sh -c --`.
|
|
11
|
+
* - `streamRunner` runs with inherited stdio, so output appears live.
|
|
12
|
+
* - `managedProcessRunner` starts a process and returns a handle to it
|
|
13
|
+
* without waiting for it to finish. `stopProcess` stops it with a grace
|
|
14
|
+
* period.
|
|
15
|
+
* - `withDefaults` binds default options to any of them.
|
|
16
|
+
*
|
|
17
|
+
* The capture runners share {@link CommandResult}, {@link CommandRunOptions}
|
|
18
|
+
* and {@link CommandFailed}, so one `instanceof CommandFailed` check covers
|
|
19
|
+
* the sync and async forms.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
catchFailed,
|
|
24
|
+
CommandFailed,
|
|
25
|
+
mapFailed,
|
|
26
|
+
resolveRunner,
|
|
27
|
+
runChecked,
|
|
28
|
+
spawnRunner,
|
|
29
|
+
type CommandResult,
|
|
30
|
+
type CommandRunner,
|
|
31
|
+
type CommandRunOptions,
|
|
32
|
+
type EnvOverrides,
|
|
33
|
+
type NeverExitedReason,
|
|
34
|
+
} from './sync.ts'
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
asAsync,
|
|
38
|
+
catchFailedAsync,
|
|
39
|
+
mapFailedAsync,
|
|
40
|
+
resolveRunnerAsync,
|
|
41
|
+
runCheckedAsync,
|
|
42
|
+
spawnRunnerAsync,
|
|
43
|
+
type AsyncCommandRunner,
|
|
44
|
+
type AsyncCommandRunOptions,
|
|
45
|
+
} from './async.ts'
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
failureText,
|
|
49
|
+
firstLine,
|
|
50
|
+
parseJsonResult,
|
|
51
|
+
ran,
|
|
52
|
+
runJson,
|
|
53
|
+
runJsonAsync,
|
|
54
|
+
stderrOrError,
|
|
55
|
+
type FailureTextOptions,
|
|
56
|
+
type JsonRun,
|
|
57
|
+
type RunJsonOptions,
|
|
58
|
+
} from './result.ts'
|
|
59
|
+
|
|
60
|
+
export { shellJoin, shellQuote } from './quote.ts'
|
|
61
|
+
|
|
62
|
+
export {
|
|
63
|
+
runShell,
|
|
64
|
+
runShellAsync,
|
|
65
|
+
shellRunner,
|
|
66
|
+
shellRunnerAsync,
|
|
67
|
+
type AsyncShellRunner,
|
|
68
|
+
type AsyncShellRunOptions,
|
|
69
|
+
type ShellRunner,
|
|
70
|
+
type ShellPositionals,
|
|
71
|
+
type ShellRunOptions,
|
|
72
|
+
} from './shell.ts'
|
|
73
|
+
|
|
74
|
+
export {
|
|
75
|
+
streamRunner,
|
|
76
|
+
type StreamRunner,
|
|
77
|
+
type StreamRunOptions,
|
|
78
|
+
type StreamResult,
|
|
79
|
+
} from './stream.ts'
|
|
80
|
+
|
|
81
|
+
export {
|
|
82
|
+
managedProcessRunner,
|
|
83
|
+
stopProcess,
|
|
84
|
+
type ManagedProcessRunner,
|
|
85
|
+
type ManagedProcessHandle,
|
|
86
|
+
type ManagedProcessRunOptions,
|
|
87
|
+
type ManagedProcessExit,
|
|
88
|
+
} from './process.ts'
|
|
89
|
+
|
|
90
|
+
export {
|
|
91
|
+
forwardedExitCode,
|
|
92
|
+
forwardedSignal,
|
|
93
|
+
installGroupSignalForwarding,
|
|
94
|
+
liveProcessGroups,
|
|
95
|
+
type GroupSignalForwardingOptions,
|
|
96
|
+
} from './groups.ts'
|
|
97
|
+
|
|
98
|
+
export { withDefaults, type DefaultedRunner } from './defaults.ts'
|