magus-cli 7.11.6 → 7.11.8

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.
@@ -0,0 +1,202 @@
1
+ /**
2
+ * spawn-bounded.ts — run an argv (never a shell) with a hard deadline that
3
+ * actually holds.
4
+ *
5
+ * Guarantee: the returned promise settles, exactly once, no later than
6
+ * `timeoutMs + KILL_SETTLE_MS`, whatever the child or its descendants do — even
7
+ * if the child never delivers "exit" at all (stuck in uninterruptible I/O). It
8
+ * never rejects: a spawn failure (ENOENT etc.) comes back as `spawnError`.
9
+ *
10
+ * The gotcha this exists for: a timeout kill reaches only the DIRECT child,
11
+ * while "close" waits for EVERY holder of the stdout/stderr pipes. `git pull`
12
+ * runs `git fetch`, which runs `ssh`; kill `git pull` and ssh still holds the
13
+ * inherited pipes, so a promise that settles on "close" waits for ssh to give up
14
+ * on its own (122 s observed, against a 30 s timeout). So:
15
+ * - normal path: settle on "close", so all output is read;
16
+ * - after a normal "exit" with no "close": settle after a short grace, or at
17
+ * the deadline, whichever comes first — the deadline is never extended;
18
+ * - deadline with the child still running: SIGKILL it, settle on its "exit",
19
+ * and if that never comes, settle anyway KILL_SETTLE_MS later;
20
+ * - `signal` aborted: SIGKILL the child and settle at once; an already-aborted
21
+ * signal spawns nothing;
22
+ * - on settle: destroy the pipe ends, so an orphan's copies cannot pin the
23
+ * event loop.
24
+ *
25
+ * No `detached` / process-group kill: on POSIX that is setsid(), which drops the
26
+ * controlling terminal, and ssh then cannot reach /dev/tty for a key passphrase
27
+ * or a security-key PIN. The cost is that an orphaned descendant may outlive the
28
+ * call until it exits or hits SIGPIPE; it blocks nothing.
29
+ */
30
+
31
+ import {
32
+ type ChildProcess,
33
+ type StdioOptions,
34
+ spawn,
35
+ } from "node:child_process";
36
+
37
+ /** How long to wait for "close" after a normal "exit" before settling anyway. */
38
+ const CLOSE_GRACE_MS = 1_500;
39
+
40
+ /** How long after our SIGKILL to wait for "exit" before settling without it. */
41
+ const KILL_SETTLE_MS = 1_000;
42
+
43
+ export interface BoundedOptions {
44
+ cwd?: string;
45
+ env?: NodeJS.ProcessEnv;
46
+ /** Defaults to ["pipe", "pipe", "pipe"]. */
47
+ stdio?: StdioOptions;
48
+ timeoutMs: number;
49
+ /** Abort: SIGKILL the child and settle at once. Already aborted: spawn nothing. */
50
+ signal?: AbortSignal;
51
+ }
52
+
53
+ export interface BoundedResult {
54
+ code: number | null;
55
+ signal: NodeJS.Signals | null;
56
+ stdout: string;
57
+ stderr: string;
58
+ /** True when our deadline fired while the child was still running, and we SIGKILLed it. */
59
+ timedOut: boolean;
60
+ /** True when `options.signal` aborted the call (the child, if any, was SIGKILLed). */
61
+ aborted: boolean;
62
+ /** Set when the process could not be spawned at all (ENOENT, EACCES, ...). */
63
+ spawnError?: Error;
64
+ }
65
+
66
+ function isRunning(child: ChildProcess): boolean {
67
+ return child.exitCode === null && child.signalCode === null;
68
+ }
69
+
70
+ export function runBounded(
71
+ cmd: string,
72
+ args: string[],
73
+ options: BoundedOptions,
74
+ ): Promise<BoundedResult> {
75
+ return new Promise((resolve) => {
76
+ const abortSignal = options.signal;
77
+ if (abortSignal?.aborted) {
78
+ resolve({
79
+ code: null,
80
+ signal: null,
81
+ stdout: "",
82
+ stderr: "",
83
+ timedOut: false,
84
+ aborted: true,
85
+ });
86
+ return;
87
+ }
88
+
89
+ let stdout = "";
90
+ let stderr = "";
91
+ let timedOut = false;
92
+ let aborted = false;
93
+ let settled = false;
94
+ /** Set by "exit"; the child is gone even if "close" never comes. */
95
+ let exited: { code: number | null; signal: NodeJS.Signals | null } | null =
96
+ null;
97
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
98
+ let killSettleTimer: ReturnType<typeof setTimeout> | undefined;
99
+
100
+ let child: ChildProcess;
101
+ try {
102
+ child = spawn(cmd, args, {
103
+ cwd: options.cwd,
104
+ env: options.env,
105
+ stdio: options.stdio ?? ["pipe", "pipe", "pipe"],
106
+ });
107
+ } catch (err) {
108
+ resolve({
109
+ code: null,
110
+ signal: null,
111
+ stdout: "",
112
+ stderr: "",
113
+ timedOut: false,
114
+ aborted: false,
115
+ spawnError: err instanceof Error ? err : new Error(String(err)),
116
+ });
117
+ return;
118
+ }
119
+
120
+ const settle = (
121
+ code: number | null,
122
+ signal: NodeJS.Signals | null,
123
+ spawnError?: Error,
124
+ ) => {
125
+ if (settled) return;
126
+ settled = true;
127
+ clearTimeout(deadlineTimer);
128
+ clearTimeout(graceTimer);
129
+ clearTimeout(killSettleTimer);
130
+ abortSignal?.removeEventListener("abort", onAbort);
131
+ child.stdout?.destroy();
132
+ child.stderr?.destroy();
133
+ child.stdin?.destroy();
134
+ resolve({
135
+ code,
136
+ signal,
137
+ stdout,
138
+ stderr,
139
+ timedOut,
140
+ aborted,
141
+ spawnError,
142
+ });
143
+ };
144
+
145
+ /** SIGKILL the child, then settle on its "exit" — or without it, soon. */
146
+ const killAndBoundSettle = () => {
147
+ child.kill("SIGKILL");
148
+ killSettleTimer = setTimeout(
149
+ () => settle(null, "SIGKILL"),
150
+ KILL_SETTLE_MS,
151
+ );
152
+ };
153
+
154
+ // An explicit timer, not spawn's `timeout` option, so we know the kill was
155
+ // ours. It stays armed until settle: it is also what caps the close grace.
156
+ // `settle` above reads it, but no child event or abort can fire before this
157
+ // line runs, so the reference is always initialised by then.
158
+ const deadlineTimer = setTimeout(() => {
159
+ if (exited) {
160
+ // Exited on its own, still waiting for "close": the grace ends here.
161
+ settle(exited.code, exited.signal);
162
+ return;
163
+ }
164
+ if (!isRunning(child)) {
165
+ // Exited, but the "exit" event has not been delivered yet.
166
+ settle(child.exitCode, child.signalCode);
167
+ return;
168
+ }
169
+ timedOut = true;
170
+ killAndBoundSettle();
171
+ }, options.timeoutMs);
172
+
173
+ function onAbort() {
174
+ aborted = true;
175
+ if (exited) {
176
+ settle(exited.code, exited.signal);
177
+ return;
178
+ }
179
+ if (isRunning(child)) child.kill("SIGKILL");
180
+ settle(child.exitCode, child.signalCode ?? "SIGKILL");
181
+ }
182
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
183
+
184
+ child.stdout?.on("data", (b: Buffer | string) => {
185
+ stdout += b.toString();
186
+ });
187
+ child.stderr?.on("data", (b: Buffer | string) => {
188
+ stderr += b.toString();
189
+ });
190
+
191
+ child.on("error", (err) => settle(null, null, err));
192
+ child.on("exit", (code, signal) => {
193
+ exited = { code, signal };
194
+ if (timedOut) {
195
+ settle(code, signal);
196
+ return;
197
+ }
198
+ graceTimer = setTimeout(() => settle(code, signal), CLOSE_GRACE_MS);
199
+ });
200
+ child.on("close", (code, signal) => settle(code, signal));
201
+ });
202
+ }