@osdlc/command-seam 0.0.0-stage → 0.0.1-stage
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 +328 -0
- package/CONTRIBUTING.md +58 -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/groups.d.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
* Options for {@link installGroupSignalForwarding}.
|
|
24
|
+
*
|
|
25
|
+
* @experimental
|
|
26
|
+
*/
|
|
27
|
+
export interface GroupSignalForwardingOptions {
|
|
28
|
+
/** Signals to forward. Default `SIGINT`, `SIGTERM` and `SIGHUP`. */
|
|
29
|
+
signals?: readonly NodeJS.Signals[] | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* How long the groups have between `SIGTERM` and `SIGKILL`, in ms. Default
|
|
32
|
+
* 1000: long enough for git to remove its lock files.
|
|
33
|
+
*/
|
|
34
|
+
graceMs?: number | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Called to end the process, with `128 + signal number`. Default: set
|
|
37
|
+
* `process.exitCode` and let the event loop drain, so piped stdout is not
|
|
38
|
+
* truncated, then `process.exit` after 500 ms if handles still hold the loop.
|
|
39
|
+
* Tests replace it.
|
|
40
|
+
*/
|
|
41
|
+
exit?: ((code: number) => void) | undefined;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Register a group leader. The returned function unregisters it; call it once
|
|
45
|
+
* the group is gone so a reused pid is never signalled. Registering the first
|
|
46
|
+
* group arms the signal listeners (when forwarding is installed); removing the
|
|
47
|
+
* last disarms them.
|
|
48
|
+
*
|
|
49
|
+
* @internal
|
|
50
|
+
*/
|
|
51
|
+
export declare function trackGroup(pid: number): () => void;
|
|
52
|
+
/**
|
|
53
|
+
* Start a group leader with `start` and register it. The listeners are armed
|
|
54
|
+
* before `start` runs: the child can print, and the host be signalled, before
|
|
55
|
+
* `spawn` returns, and a signal that comes before the listeners would kill the
|
|
56
|
+
* host by default and orphan the group. `untrack` is `undefined` when the
|
|
57
|
+
* child got no pid.
|
|
58
|
+
*
|
|
59
|
+
* @internal
|
|
60
|
+
*/
|
|
61
|
+
export declare function spawnTrackedGroup<C extends {
|
|
62
|
+
readonly pid?: number | undefined;
|
|
63
|
+
}>(start: () => C): {
|
|
64
|
+
child: C;
|
|
65
|
+
untrack: (() => void) | undefined;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Mark the run of the group led by `pid` as settled: the host's exit no longer
|
|
69
|
+
* kills what is left of the group. A forwarded signal still does. A runner
|
|
70
|
+
* does not call this for a group it is stopping itself.
|
|
71
|
+
*
|
|
72
|
+
* @internal
|
|
73
|
+
*/
|
|
74
|
+
export declare function settleGroup(pid: number): void;
|
|
75
|
+
/**
|
|
76
|
+
* Why a new group must not start, or `undefined` when it may. The runners call
|
|
77
|
+
* this before they spawn with `processGroup: true`.
|
|
78
|
+
*
|
|
79
|
+
* @internal
|
|
80
|
+
*/
|
|
81
|
+
export declare function groupStartRefusal(): string | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* Run `release` once the group led by `pid` has no member: now if it is
|
|
84
|
+
* already empty, else polled on an unref'd timer.
|
|
85
|
+
*
|
|
86
|
+
* Unref'd on purpose: a ref'd poll would keep the host alive for as long as a
|
|
87
|
+
* member the group left behind. On a Bun version where an unref'd timer does
|
|
88
|
+
* not fire (see `async.ts`), the entry stays registered. The listeners then
|
|
89
|
+
* stay armed and a signal still kills that member, which is the safe side.
|
|
90
|
+
*
|
|
91
|
+
* @internal
|
|
92
|
+
*/
|
|
93
|
+
export declare function releaseWhenEmpty(pid: number, release: () => void): void;
|
|
94
|
+
/**
|
|
95
|
+
* True while the group led by `pid` still has a member.
|
|
96
|
+
*
|
|
97
|
+
* @internal
|
|
98
|
+
*/
|
|
99
|
+
export declare function groupExists(pid: number): boolean;
|
|
100
|
+
/**
|
|
101
|
+
* The pids of the live process groups started with `processGroup: true`.
|
|
102
|
+
*
|
|
103
|
+
* @experimental
|
|
104
|
+
*/
|
|
105
|
+
export declare function liveProcessGroups(): number[];
|
|
106
|
+
/**
|
|
107
|
+
* The signal the host was sent and forwarded to its groups, or `undefined`
|
|
108
|
+
* while none has been. Set only when forwarding owns the exit, that is, the
|
|
109
|
+
* host has no listener of its own for that signal. Once set, the host is
|
|
110
|
+
* stopping: a loop that runs one command after another should check it and
|
|
111
|
+
* stop, and the runners refuse to start a new `processGroup` run. The
|
|
112
|
+
* uninstall function clears it.
|
|
113
|
+
*
|
|
114
|
+
* @experimental
|
|
115
|
+
*/
|
|
116
|
+
export declare function forwardedSignal(): NodeJS.Signals | undefined;
|
|
117
|
+
/**
|
|
118
|
+
* The exit code for {@link forwardedSignal}: `128 + signal number`, or
|
|
119
|
+
* `undefined` while no signal has been forwarded. A host that sets its own
|
|
120
|
+
* exit code after its work returns keeps this one over it:
|
|
121
|
+
* `process.exitCode = forwardedExitCode() ?? code`.
|
|
122
|
+
*
|
|
123
|
+
* @experimental
|
|
124
|
+
*/
|
|
125
|
+
export declare function forwardedExitCode(): number | undefined;
|
|
126
|
+
/**
|
|
127
|
+
* Enable forwarding of termination signals to live process groups.
|
|
128
|
+
*
|
|
129
|
+
* While at least one group started with `processGroup: true` is live, `SIGINT`,
|
|
130
|
+
* `SIGTERM` and `SIGHUP` send `SIGTERM` to every registered group, then
|
|
131
|
+
* `SIGKILL` to those still registered after `graceMs`, then end the process
|
|
132
|
+
* with `128 + signal number`, the shell convention. A second signal during the
|
|
133
|
+
* grace, or any later one, skips straight to `SIGKILL` and the exit. From the
|
|
134
|
+
* first signal on, {@link forwardedSignal} returns it and the runners refuse
|
|
135
|
+
* to start a new `processGroup` run, until the uninstall function resets
|
|
136
|
+
* that. A `process` `exit` handler, armed with the listeners, also `SIGKILL`s
|
|
137
|
+
* every group whose run is still in flight, so a host that exits first does
|
|
138
|
+
* not leave one behind. A member that a settled run left behind, such as a
|
|
139
|
+
* daemon, outlives the host's exit; a forwarded signal still kills it.
|
|
140
|
+
*
|
|
141
|
+
* The listeners are attached just before the first group starts and removed
|
|
142
|
+
* when the last one goes, so a host with no live group keeps each signal's default
|
|
143
|
+
* action. If the host has its own listener for the signal (`process.once`
|
|
144
|
+
* counts too), the groups still
|
|
145
|
+
* get `SIGTERM` at once and `SIGKILL` after `graceMs` (or on `exit`, if
|
|
146
|
+
* sooner and the run is in flight), but that listener keeps the exit, and later runs are not refused.
|
|
147
|
+
* Calling it again while installed is a no-op returning the same
|
|
148
|
+
* uninstall function. `SIGKILL` of the host cannot be caught and still orphans
|
|
149
|
+
* its groups. POSIX only; a no-op on Windows.
|
|
150
|
+
*
|
|
151
|
+
* @returns A function that removes the forwarding.
|
|
152
|
+
* @experimental
|
|
153
|
+
*/
|
|
154
|
+
export declare function installGroupSignalForwarding(opts?: GroupSignalForwardingOptions): () => void;
|
|
155
|
+
//# sourceMappingURL=groups.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"groups.d.ts","sourceRoot":"","sources":["../groups.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAWH;;;;GAIG;AACH,MAAM,WAAW,4BAA4B;IAC3C,oEAAoE;IACpE,OAAO,CAAC,EAAE,SAAS,MAAM,CAAC,OAAO,EAAE,GAAG,SAAS,CAAA;IAC/C;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAC5B;;;;;OAKG;IACH,IAAI,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC,GAAG,SAAS,CAAA;CAC5C;AAyBD;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,IAAI,CAOlD;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,SAAS;IAAE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,EAC/E,KAAK,EAAE,MAAM,CAAC,GACb;IAAE,KAAK,EAAE,CAAC,CAAC;IAAC,OAAO,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAA;CAAE,CAUjD;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAG7C;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,GAAG,SAAS,CAGtD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI,CAWvE;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAOhD;AAYD;;;;GAIG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,EAAE,CAE5C;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,IAAI,MAAM,CAAC,OAAO,GAAG,SAAS,CAE5D;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,GAAG,SAAS,CAEtD;AA4DD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,4BAA4B,CAAC,IAAI,GAAE,4BAAiC,GAAG,MAAM,IAAI,CAUhG"}
|
package/dist/groups.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
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
|
+
import { constants } from 'node:os';
|
|
23
|
+
const osSignals = constants.signals;
|
|
24
|
+
/** Live group leaders, in start order, and whether each one's run has settled. */
|
|
25
|
+
const live = new Map();
|
|
26
|
+
/** Group spawns in progress: the listeners are armed before their pid is known. */
|
|
27
|
+
let spawning = 0;
|
|
28
|
+
const DEFAULT_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
29
|
+
const POLL_MS = 25;
|
|
30
|
+
/** Set while forwarding is installed. */
|
|
31
|
+
let config;
|
|
32
|
+
/** Set while the listeners are attached: forwarding installed and a group starting or live. */
|
|
33
|
+
let armed;
|
|
34
|
+
/**
|
|
35
|
+
* The first signal forwarded since install whose exit this module owns (the
|
|
36
|
+
* host has no listener of its own for it). Cleared only by uninstall.
|
|
37
|
+
*/
|
|
38
|
+
let forwarded;
|
|
39
|
+
/** Set while a graceful kill is waiting out its grace. */
|
|
40
|
+
let killing = false;
|
|
41
|
+
/** A repeat signal already sent `SIGKILL`: the graceful kill stops waiting. */
|
|
42
|
+
let escalated = false;
|
|
43
|
+
/**
|
|
44
|
+
* Register a group leader. The returned function unregisters it; call it once
|
|
45
|
+
* the group is gone so a reused pid is never signalled. Registering the first
|
|
46
|
+
* group arms the signal listeners (when forwarding is installed); removing the
|
|
47
|
+
* last disarms them.
|
|
48
|
+
*
|
|
49
|
+
* @internal
|
|
50
|
+
*/
|
|
51
|
+
export function trackGroup(pid) {
|
|
52
|
+
live.set(pid, { settled: false });
|
|
53
|
+
arm();
|
|
54
|
+
return () => {
|
|
55
|
+
live.delete(pid);
|
|
56
|
+
disarmIfIdle();
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Start a group leader with `start` and register it. The listeners are armed
|
|
61
|
+
* before `start` runs: the child can print, and the host be signalled, before
|
|
62
|
+
* `spawn` returns, and a signal that comes before the listeners would kill the
|
|
63
|
+
* host by default and orphan the group. `untrack` is `undefined` when the
|
|
64
|
+
* child got no pid.
|
|
65
|
+
*
|
|
66
|
+
* @internal
|
|
67
|
+
*/
|
|
68
|
+
export function spawnTrackedGroup(start) {
|
|
69
|
+
spawning += 1;
|
|
70
|
+
arm();
|
|
71
|
+
try {
|
|
72
|
+
const child = start();
|
|
73
|
+
return { child, untrack: child.pid === undefined ? undefined : trackGroup(child.pid) };
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
spawning -= 1;
|
|
77
|
+
disarmIfIdle();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Mark the run of the group led by `pid` as settled: the host's exit no longer
|
|
82
|
+
* kills what is left of the group. A forwarded signal still does. A runner
|
|
83
|
+
* does not call this for a group it is stopping itself.
|
|
84
|
+
*
|
|
85
|
+
* @internal
|
|
86
|
+
*/
|
|
87
|
+
export function settleGroup(pid) {
|
|
88
|
+
const entry = live.get(pid);
|
|
89
|
+
if (entry !== undefined)
|
|
90
|
+
entry.settled = true;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Why a new group must not start, or `undefined` when it may. The runners call
|
|
94
|
+
* this before they spawn with `processGroup: true`.
|
|
95
|
+
*
|
|
96
|
+
* @internal
|
|
97
|
+
*/
|
|
98
|
+
export function groupStartRefusal() {
|
|
99
|
+
if (forwarded === undefined)
|
|
100
|
+
return undefined;
|
|
101
|
+
return `not started: the host got ${forwarded} and is stopping`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Run `release` once the group led by `pid` has no member: now if it is
|
|
105
|
+
* already empty, else polled on an unref'd timer.
|
|
106
|
+
*
|
|
107
|
+
* Unref'd on purpose: a ref'd poll would keep the host alive for as long as a
|
|
108
|
+
* member the group left behind. On a Bun version where an unref'd timer does
|
|
109
|
+
* not fire (see `async.ts`), the entry stays registered. The listeners then
|
|
110
|
+
* stay armed and a signal still kills that member, which is the safe side.
|
|
111
|
+
*
|
|
112
|
+
* @internal
|
|
113
|
+
*/
|
|
114
|
+
export function releaseWhenEmpty(pid, release) {
|
|
115
|
+
if (!groupExists(pid)) {
|
|
116
|
+
release();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const timer = setInterval(() => {
|
|
120
|
+
if (groupExists(pid))
|
|
121
|
+
return;
|
|
122
|
+
clearInterval(timer);
|
|
123
|
+
release();
|
|
124
|
+
}, 250);
|
|
125
|
+
timer.unref();
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* True while the group led by `pid` still has a member.
|
|
129
|
+
*
|
|
130
|
+
* @internal
|
|
131
|
+
*/
|
|
132
|
+
export function groupExists(pid) {
|
|
133
|
+
try {
|
|
134
|
+
process.kill(-pid, 0);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Send `sig` to the group led by `pid`, only while it is registered. */
|
|
142
|
+
function signalGroup(pid, sig) {
|
|
143
|
+
if (!live.has(pid))
|
|
144
|
+
return;
|
|
145
|
+
try {
|
|
146
|
+
process.kill(-pid, sig);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Already gone.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The pids of the live process groups started with `processGroup: true`.
|
|
154
|
+
*
|
|
155
|
+
* @experimental
|
|
156
|
+
*/
|
|
157
|
+
export function liveProcessGroups() {
|
|
158
|
+
return [...live.keys()];
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The signal the host was sent and forwarded to its groups, or `undefined`
|
|
162
|
+
* while none has been. Set only when forwarding owns the exit, that is, the
|
|
163
|
+
* host has no listener of its own for that signal. Once set, the host is
|
|
164
|
+
* stopping: a loop that runs one command after another should check it and
|
|
165
|
+
* stop, and the runners refuse to start a new `processGroup` run. The
|
|
166
|
+
* uninstall function clears it.
|
|
167
|
+
*
|
|
168
|
+
* @experimental
|
|
169
|
+
*/
|
|
170
|
+
export function forwardedSignal() {
|
|
171
|
+
return forwarded;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The exit code for {@link forwardedSignal}: `128 + signal number`, or
|
|
175
|
+
* `undefined` while no signal has been forwarded. A host that sets its own
|
|
176
|
+
* exit code after its work returns keeps this one over it:
|
|
177
|
+
* `process.exitCode = forwardedExitCode() ?? code`.
|
|
178
|
+
*
|
|
179
|
+
* @experimental
|
|
180
|
+
*/
|
|
181
|
+
export function forwardedExitCode() {
|
|
182
|
+
return forwarded === undefined ? undefined : 128 + signalNumber(forwarded);
|
|
183
|
+
}
|
|
184
|
+
function arm() {
|
|
185
|
+
if (config === undefined || armed !== undefined || (live.size === 0 && spawning === 0))
|
|
186
|
+
return;
|
|
187
|
+
const { signals, graceMs, exit } = config;
|
|
188
|
+
const onExit = () => {
|
|
189
|
+
// The host is going away: nothing async runs after this, so end every group
|
|
190
|
+
// whose run is still in flight now. A settled run's leftovers stay.
|
|
191
|
+
for (const [pid, { settled }] of live)
|
|
192
|
+
if (!settled)
|
|
193
|
+
signalGroup(pid, 'SIGKILL');
|
|
194
|
+
};
|
|
195
|
+
const handlers = signals.map((sig) => {
|
|
196
|
+
const handler = () => {
|
|
197
|
+
const code = 128 + signalNumber(sig);
|
|
198
|
+
if (forwarded !== undefined || killing) {
|
|
199
|
+
// A repeat signal: stop waiting.
|
|
200
|
+
escalated = true;
|
|
201
|
+
for (const pid of live.keys())
|
|
202
|
+
signalGroup(pid, 'SIGKILL');
|
|
203
|
+
if (!othersListen(sig))
|
|
204
|
+
exit(code);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
// A host listener owns the exit and may keep running (a REPL cancelling
|
|
208
|
+
// a job, a daemon reloading on SIGHUP): kill the groups, but do not
|
|
209
|
+
// refuse later runs.
|
|
210
|
+
const othersOwnExit = othersListen(sig);
|
|
211
|
+
if (!othersOwnExit)
|
|
212
|
+
forwarded = sig;
|
|
213
|
+
killing = true;
|
|
214
|
+
escalated = false;
|
|
215
|
+
void killAll(graceMs).then(() => {
|
|
216
|
+
killing = false;
|
|
217
|
+
if (!escalated && !othersOwnExit)
|
|
218
|
+
exit(code);
|
|
219
|
+
});
|
|
220
|
+
};
|
|
221
|
+
// First in line: Node and Bun drop a host's `process.once` wrapper before
|
|
222
|
+
// calling it, so a handler that ran later would not count that listener.
|
|
223
|
+
process.prependListener(sig, handler);
|
|
224
|
+
return [sig, handler];
|
|
225
|
+
});
|
|
226
|
+
process.on('exit', onExit);
|
|
227
|
+
armed = {
|
|
228
|
+
off: () => {
|
|
229
|
+
for (const [sig, handler] of handlers)
|
|
230
|
+
process.off(sig, handler);
|
|
231
|
+
process.off('exit', onExit);
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function disarm() {
|
|
236
|
+
armed?.off();
|
|
237
|
+
armed = undefined;
|
|
238
|
+
}
|
|
239
|
+
function disarmIfIdle() {
|
|
240
|
+
if (live.size === 0 && spawning === 0)
|
|
241
|
+
disarm();
|
|
242
|
+
}
|
|
243
|
+
/** Another listener than ours is attached for `sig`: it owns the exit. */
|
|
244
|
+
function othersListen(sig) {
|
|
245
|
+
return process.listenerCount(sig) > 1;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Enable forwarding of termination signals to live process groups.
|
|
249
|
+
*
|
|
250
|
+
* While at least one group started with `processGroup: true` is live, `SIGINT`,
|
|
251
|
+
* `SIGTERM` and `SIGHUP` send `SIGTERM` to every registered group, then
|
|
252
|
+
* `SIGKILL` to those still registered after `graceMs`, then end the process
|
|
253
|
+
* with `128 + signal number`, the shell convention. A second signal during the
|
|
254
|
+
* grace, or any later one, skips straight to `SIGKILL` and the exit. From the
|
|
255
|
+
* first signal on, {@link forwardedSignal} returns it and the runners refuse
|
|
256
|
+
* to start a new `processGroup` run, until the uninstall function resets
|
|
257
|
+
* that. A `process` `exit` handler, armed with the listeners, also `SIGKILL`s
|
|
258
|
+
* every group whose run is still in flight, so a host that exits first does
|
|
259
|
+
* not leave one behind. A member that a settled run left behind, such as a
|
|
260
|
+
* daemon, outlives the host's exit; a forwarded signal still kills it.
|
|
261
|
+
*
|
|
262
|
+
* The listeners are attached just before the first group starts and removed
|
|
263
|
+
* when the last one goes, so a host with no live group keeps each signal's default
|
|
264
|
+
* action. If the host has its own listener for the signal (`process.once`
|
|
265
|
+
* counts too), the groups still
|
|
266
|
+
* get `SIGTERM` at once and `SIGKILL` after `graceMs` (or on `exit`, if
|
|
267
|
+
* sooner and the run is in flight), but that listener keeps the exit, and later runs are not refused.
|
|
268
|
+
* Calling it again while installed is a no-op returning the same
|
|
269
|
+
* uninstall function. `SIGKILL` of the host cannot be caught and still orphans
|
|
270
|
+
* its groups. POSIX only; a no-op on Windows.
|
|
271
|
+
*
|
|
272
|
+
* @returns A function that removes the forwarding.
|
|
273
|
+
* @experimental
|
|
274
|
+
*/
|
|
275
|
+
export function installGroupSignalForwarding(opts = {}) {
|
|
276
|
+
if (process.platform === 'win32')
|
|
277
|
+
return () => { };
|
|
278
|
+
if (config !== undefined)
|
|
279
|
+
return uninstall;
|
|
280
|
+
config = {
|
|
281
|
+
signals: opts.signals ?? DEFAULT_SIGNALS,
|
|
282
|
+
graceMs: opts.graceMs ?? 1000,
|
|
283
|
+
exit: opts.exit ?? defaultExit,
|
|
284
|
+
};
|
|
285
|
+
arm();
|
|
286
|
+
return uninstall;
|
|
287
|
+
}
|
|
288
|
+
function uninstall() {
|
|
289
|
+
disarm();
|
|
290
|
+
config = undefined;
|
|
291
|
+
forwarded = undefined;
|
|
292
|
+
killing = false;
|
|
293
|
+
escalated = false;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Set the exit code and let output drain; force the exit after 500 ms. Not
|
|
297
|
+
* unref'd: an unref'd timer does not reliably fire on every Bun version, and
|
|
298
|
+
* holding the loop for 500 ms after a signal is cheap.
|
|
299
|
+
*/
|
|
300
|
+
function defaultExit(code) {
|
|
301
|
+
process.exitCode = code;
|
|
302
|
+
setTimeout(() => process.exit(code), 500);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* `SIGTERM` every registered group, wait up to `graceMs` for them to go, then
|
|
306
|
+
* `SIGKILL` those still registered. Stops early when `escalated` says a repeat
|
|
307
|
+
* signal already did the kill.
|
|
308
|
+
*/
|
|
309
|
+
async function killAll(graceMs) {
|
|
310
|
+
// Drop entries whose group is already empty, so a stale pid is never signalled.
|
|
311
|
+
for (const pid of liveProcessGroups())
|
|
312
|
+
if (!groupExists(pid))
|
|
313
|
+
live.delete(pid);
|
|
314
|
+
for (const pid of liveProcessGroups())
|
|
315
|
+
signalGroup(pid, 'SIGTERM');
|
|
316
|
+
const deadline = performance.now() + graceMs;
|
|
317
|
+
while (!escalated && performance.now() < deadline) {
|
|
318
|
+
if (!liveProcessGroups().some(groupExists))
|
|
319
|
+
return;
|
|
320
|
+
await new Promise((res) => setTimeout(res, POLL_MS));
|
|
321
|
+
}
|
|
322
|
+
if (escalated)
|
|
323
|
+
return;
|
|
324
|
+
for (const pid of liveProcessGroups())
|
|
325
|
+
if (groupExists(pid))
|
|
326
|
+
signalGroup(pid, 'SIGKILL');
|
|
327
|
+
}
|
|
328
|
+
function signalNumber(sig) {
|
|
329
|
+
const n = osSignals[sig];
|
|
330
|
+
return typeof n === 'number' ? n : 15;
|
|
331
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
export { catchFailed, CommandFailed, mapFailed, resolveRunner, runChecked, spawnRunner, type CommandResult, type CommandRunner, type CommandRunOptions, type EnvOverrides, type NeverExitedReason, } from './sync.ts';
|
|
22
|
+
export { asAsync, catchFailedAsync, mapFailedAsync, resolveRunnerAsync, runCheckedAsync, spawnRunnerAsync, type AsyncCommandRunner, type AsyncCommandRunOptions, } from './async.ts';
|
|
23
|
+
export { failureText, firstLine, parseJsonResult, ran, runJson, runJsonAsync, stderrOrError, type FailureTextOptions, type JsonRun, type RunJsonOptions, } from './result.ts';
|
|
24
|
+
export { shellJoin, shellQuote } from './quote.ts';
|
|
25
|
+
export { runShell, runShellAsync, shellRunner, shellRunnerAsync, type AsyncShellRunner, type AsyncShellRunOptions, type ShellRunner, type ShellPositionals, type ShellRunOptions, } from './shell.ts';
|
|
26
|
+
export { streamRunner, type StreamRunner, type StreamRunOptions, type StreamResult, } from './stream.ts';
|
|
27
|
+
export { managedProcessRunner, stopProcess, type ManagedProcessRunner, type ManagedProcessHandle, type ManagedProcessRunOptions, type ManagedProcessExit, } from './process.ts';
|
|
28
|
+
export { forwardedExitCode, forwardedSignal, installGroupSignalForwarding, liveProcessGroups, type GroupSignalForwardingOptions, } from './groups.ts';
|
|
29
|
+
export { withDefaults, type DefaultedRunner } from './defaults.ts';
|
|
30
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EACL,WAAW,EACX,aAAa,EACb,SAAS,EACT,aAAa,EACb,UAAU,EACV,WAAW,EACX,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,iBAAiB,GACvB,MAAM,WAAW,CAAA;AAElB,OAAO,EACL,OAAO,EACP,gBAAgB,EAChB,cAAc,EACd,kBAAkB,EAClB,eAAe,EACf,gBAAgB,EAChB,KAAK,kBAAkB,EACvB,KAAK,sBAAsB,GAC5B,MAAM,YAAY,CAAA;AAEnB,OAAO,EACL,WAAW,EACX,SAAS,EACT,eAAe,EACf,GAAG,EACH,OAAO,EACP,YAAY,EACZ,aAAa,EACb,KAAK,kBAAkB,EACvB,KAAK,OAAO,EACZ,KAAK,cAAc,GACpB,MAAM,aAAa,CAAA;AAEpB,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAElD,OAAO,EACL,QAAQ,EACR,aAAa,EACb,WAAW,EACX,gBAAgB,EAChB,KAAK,gBAAgB,EACrB,KAAK,oBAAoB,EACzB,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,eAAe,GACrB,MAAM,YAAY,CAAA;AAEnB,OAAO,EACL,YAAY,EACZ,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,YAAY,GAClB,MAAM,aAAa,CAAA;AAEpB,OAAO,EACL,oBAAoB,EACpB,WAAW,EACX,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,wBAAwB,EAC7B,KAAK,kBAAkB,GACxB,MAAM,cAAc,CAAA;AAErB,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,4BAA4B,EAC5B,iBAAiB,EACjB,KAAK,4BAA4B,GAClC,MAAM,aAAa,CAAA;AAEpB,OAAO,EAAE,YAAY,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
export { catchFailed, CommandFailed, mapFailed, resolveRunner, runChecked, spawnRunner, } from "./sync.js";
|
|
22
|
+
export { asAsync, catchFailedAsync, mapFailedAsync, resolveRunnerAsync, runCheckedAsync, spawnRunnerAsync, } from "./async.js";
|
|
23
|
+
export { failureText, firstLine, parseJsonResult, ran, runJson, runJsonAsync, stderrOrError, } from "./result.js";
|
|
24
|
+
export { shellJoin, shellQuote } from "./quote.js";
|
|
25
|
+
export { runShell, runShellAsync, shellRunner, shellRunnerAsync, } from "./shell.js";
|
|
26
|
+
export { streamRunner, } from "./stream.js";
|
|
27
|
+
export { managedProcessRunner, stopProcess, } from "./process.js";
|
|
28
|
+
export { forwardedExitCode, forwardedSignal, installGroupSignalForwarding, liveProcessGroups, } from "./groups.js";
|
|
29
|
+
export { withDefaults } from "./defaults.js";
|