magus-cli 7.11.6 → 7.11.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magus-cli",
3
- "version": "7.11.6",
3
+ "version": "7.11.7",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -65,8 +65,8 @@
65
65
  "typescript": "^5.6.3"
66
66
  },
67
67
  "optionalDependencies": {
68
- "magus-cli-darwin-arm64": "7.11.6",
69
- "magus-cli-darwin-x64": "7.11.6",
70
- "magus-cli-linux-x64": "7.11.6"
68
+ "magus-cli-darwin-arm64": "7.11.7",
69
+ "magus-cli-darwin-x64": "7.11.7",
70
+ "magus-cli-linux-x64": "7.11.7"
71
71
  }
72
72
  }
@@ -344,3 +344,116 @@ describe("fastForwardCloneIfPresent — single-clone helper (shared with claude-
344
344
  expect(readPin(dir).pin).toBe("x@dirty");
345
345
  });
346
346
  });
347
+
348
+ /**
349
+ * Point the clone's origin at a fake ssh that hangs. git pull -> git fetch ->
350
+ * ssh: the fake ssh is NOT runBounded's direct child, so killing `git pull`
351
+ * leaves it holding the inherited stdout/stderr open. It records its own pid and
352
+ * its grandparent's (the `git pull` runBounded spawned) so the case can check
353
+ * them and the cleanup can kill them by pid, never by pattern.
354
+ */
355
+ function hangSshTransport(dir: string): { sshPid: string; pullPid: string } {
356
+ const sshPid = path.join(tmp, "fake-ssh.pid");
357
+ const pullPid = path.join(tmp, "git-pull.pid");
358
+ const fakeSsh = path.join(tmp, "fake-ssh.sh");
359
+ fs.writeFileSync(
360
+ fakeSsh,
361
+ [
362
+ "#!/bin/sh",
363
+ `echo $$ > '${sshPid}'`,
364
+ `ps -o ppid= -p $PPID | tr -d ' ' > '${pullPid}'`,
365
+ `ps -o command= -p "$(cat '${pullPid}')" > '${pullPid}.cmd'`,
366
+ "exec sleep 60",
367
+ "",
368
+ ].join("\n"),
369
+ { mode: 0o755 },
370
+ );
371
+ git(dir, "remote", "set-url", "origin", "ssh://example.invalid/repo.git");
372
+ git(dir, "config", "core.sshCommand", fakeSsh);
373
+ hungPidFiles = [sshPid, pullPid];
374
+ return { sshPid, pullPid };
375
+ }
376
+
377
+ function readPid(file: string): number {
378
+ return Number(fs.readFileSync(file, "utf8").trim());
379
+ }
380
+
381
+ /** True once `pid` no longer exists, polling briefly to let the runtime reap it. */
382
+ async function goneWithin(pid: number, ms: number): Promise<boolean> {
383
+ const until = Date.now() + ms;
384
+ for (;;) {
385
+ try {
386
+ process.kill(pid, 0);
387
+ } catch {
388
+ return true;
389
+ }
390
+ if (Date.now() > until) return false;
391
+ await Bun.sleep(20);
392
+ }
393
+ }
394
+
395
+ // pid files written by hangSshTransport; killed by pid after each case.
396
+ let hungPidFiles: string[] = [];
397
+
398
+ afterEach(() => {
399
+ for (const file of hungPidFiles) {
400
+ if (!fs.existsSync(file)) continue;
401
+ const pid = readPid(file);
402
+ if (pid > 0) {
403
+ try {
404
+ process.kill(pid, "SIGKILL");
405
+ } catch {
406
+ // already gone
407
+ }
408
+ }
409
+ }
410
+ hungPidFiles = [];
411
+ });
412
+
413
+ describe("fastForwardCloneIfPresent — a hung ssh transport", () => {
414
+ // REGRESSION: magus update stalls on the marketplaces step past the 30 s git timeout (ssh grandchild holds the pipes) — Fixed in /dev:fix session dev-fix-20261102-160435-22278 (MTL-365)
415
+ it("settles as 'failed' near the 30 s git timeout when ssh (a grandchild of git pull) holds the pipes", async () => {
416
+ const dir = installClone("magus");
417
+ hangSshTransport(dir);
418
+
419
+ const started = Date.now();
420
+ const status = await fastForwardCloneIfPresent("magus");
421
+ const elapsed = Date.now() - started;
422
+
423
+ expect(status).toBe("failed");
424
+ // 30 s timeout + grace. On the bug it settles only when ssh exits (~60 s).
425
+ expect(elapsed).toBeLessThan(40_000);
426
+ expect(fs.existsSync(dir)).toBe(true);
427
+ }, 45_000);
428
+ });
429
+
430
+ describe("fastForwardCloneIfPresent — per-clone deadline", () => {
431
+ // REGRESSION: the marketplaces step ran 56 min — a per-clone deadline bounds it whatever hangs (MTL-365)
432
+ it("returns 'failed' at the deadline while ssh still hangs, clone intact", async () => {
433
+ const dir = installClone("magus");
434
+ hangSshTransport(dir);
435
+
436
+ const started = Date.now();
437
+ const status = await fastForwardCloneIfPresent("magus", 1_000);
438
+ const elapsed = Date.now() - started;
439
+
440
+ expect(status).toBe("failed");
441
+ // Deadline 1 s plus slack. Without the deadline it settles at the 30 s git timeout.
442
+ expect(elapsed).toBeLessThan(3_000);
443
+ expect(fs.existsSync(dir)).toBe(true);
444
+ }, 10_000);
445
+
446
+ it("kills the in-flight git pull when the deadline wins, rather than leaving it running", async () => {
447
+ const dir = installClone("magus");
448
+ const { pullPid } = hangSshTransport(dir);
449
+
450
+ const status = await fastForwardCloneIfPresent("magus", 1_000);
451
+
452
+ expect(status).toBe("failed");
453
+ // The fake ssh got far enough to record its grandparent, the git pull.
454
+ expect(fs.existsSync(pullPid)).toBe(true);
455
+ expect(fs.readFileSync(`${pullPid}.cmd`, "utf8")).toContain("pull");
456
+ // Without the abort, git pull lives on until its own 30 s timeout.
457
+ expect(await goneWithin(readPid(pullPid), 1_000)).toBe(true);
458
+ }, 10_000);
459
+ });
@@ -0,0 +1,211 @@
1
+ /**
2
+ * runBounded settles on its deadline even when a descendant of the child keeps
3
+ * the stdout/stderr pipes open — the shape of `git pull` -> `git fetch` -> `ssh`.
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { runBounded } from "../utils/spawn-bounded.js";
11
+
12
+ let tmp: string;
13
+ let pidFile: string;
14
+
15
+ beforeEach(() => {
16
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "spawn-bounded-"));
17
+ pidFile = path.join(tmp, "grandchild.pid");
18
+ });
19
+
20
+ afterEach(() => {
21
+ // Kill the leftover grandchild by pid, never by pattern.
22
+ if (fs.existsSync(pidFile)) {
23
+ const pid = Number(fs.readFileSync(pidFile, "utf8").trim());
24
+ if (pid > 0) {
25
+ try {
26
+ process.kill(pid, "SIGKILL");
27
+ } catch {
28
+ // already gone
29
+ }
30
+ }
31
+ }
32
+ fs.rmSync(tmp, { recursive: true, force: true });
33
+ });
34
+
35
+ function writeScript(name: string, body: string): string {
36
+ const file = path.join(tmp, name);
37
+ fs.writeFileSync(file, `#!/bin/sh\n${body}`, { mode: 0o755 });
38
+ return file;
39
+ }
40
+
41
+ /**
42
+ * `sh -c` argv: background a `sleep 30` that inherits stdout, then exit 0.
43
+ * Run through /bin/sh rather than a freshly written script: macOS's first exec
44
+ * of a new file can take ~300 ms, which would push the exit past a 200 ms
45
+ * deadline and turn a normal exit into a timeout.
46
+ */
47
+ function exitLeavingHolder(): string[] {
48
+ return ["-c", `sleep 30 & echo $! > '${pidFile}'; exit 0`];
49
+ }
50
+
51
+ describe("runBounded", () => {
52
+ it("settles at the deadline while a grandchild still holds the pipes", async () => {
53
+ const script = writeScript(
54
+ "holder.sh",
55
+ `sleep 30 &\necho $! > '${pidFile}'\nwait\n`,
56
+ );
57
+
58
+ const started = Date.now();
59
+ const r = await runBounded(script, [], { timeoutMs: 300 });
60
+ const elapsed = Date.now() - started;
61
+
62
+ expect(r.timedOut).toBe(true);
63
+ expect(r.signal).toBe("SIGKILL");
64
+ expect(r.code).not.toBe(0);
65
+ expect(elapsed).toBeLessThan(2_000);
66
+ }, 5_000);
67
+
68
+ it("settles at the deadline with stdout-only pipes, too", async () => {
69
+ const script = writeScript(
70
+ "holder.sh",
71
+ `sleep 30 &\necho $! > '${pidFile}'\nwait\n`,
72
+ );
73
+
74
+ const started = Date.now();
75
+ const r = await runBounded(script, [], {
76
+ timeoutMs: 300,
77
+ stdio: ["ignore", "pipe", "ignore"],
78
+ });
79
+
80
+ expect(r.timedOut).toBe(true);
81
+ expect(r.signal).toBe("SIGKILL");
82
+ expect(Date.now() - started).toBeLessThan(2_000);
83
+ }, 5_000);
84
+
85
+ it("returns the full stdout of a child that exits normally", async () => {
86
+ const lines = Array.from({ length: 2000 }, (_, i) => `line ${i}`);
87
+ const script = writeScript(
88
+ "talker.sh",
89
+ `i=0\nwhile [ $i -lt 2000 ]; do echo "line $i"; i=$((i+1)); done\necho oops >&2\n`,
90
+ );
91
+
92
+ const r = await runBounded(script, [], { timeoutMs: 5_000 });
93
+
94
+ expect(r.code).toBe(0);
95
+ expect(r.signal).toBeNull();
96
+ expect(r.timedOut).toBe(false);
97
+ expect(r.spawnError).toBeUndefined();
98
+ expect(r.stdout).toBe(`${lines.join("\n")}\n`);
99
+ expect(r.stderr.trim()).toBe("oops");
100
+ }, 5_000);
101
+
102
+ it("reports a non-zero exit code without timing out", async () => {
103
+ const script = writeScript("fail.sh", "exit 3\n");
104
+
105
+ const r = await runBounded(script, [], { timeoutMs: 5_000 });
106
+
107
+ expect(r.code).toBe(3);
108
+ expect(r.timedOut).toBe(false);
109
+ }, 5_000);
110
+
111
+ it("after a normal exit with a background descendant holding stdout, settles after the close grace", async () => {
112
+ const started = Date.now();
113
+ const r = await runBounded("/bin/sh", exitLeavingHolder(), {
114
+ timeoutMs: 20_000,
115
+ });
116
+ const elapsed = Date.now() - started;
117
+
118
+ expect(r.timedOut).toBe(false);
119
+ expect(r.code).toBe(0);
120
+ // CLOSE_GRACE_MS is 1_500: not before it, and not much after it.
121
+ expect(elapsed).toBeGreaterThanOrEqual(1_400);
122
+ expect(elapsed).toBeLessThan(2_500);
123
+ }, 5_000);
124
+
125
+ it("never extends a normal exit's close grace past the deadline", async () => {
126
+ const started = Date.now();
127
+ const r = await runBounded("/bin/sh", exitLeavingHolder(), {
128
+ timeoutMs: 200,
129
+ });
130
+ const elapsed = Date.now() - started;
131
+
132
+ // The child exited by itself, so this is not a timeout.
133
+ expect(r.timedOut).toBe(false);
134
+ expect(r.code).toBe(0);
135
+ // Deadline 200 ms; the 1_500 ms grace must not outlive it.
136
+ expect(elapsed).toBeLessThan(700);
137
+ }, 5_000);
138
+
139
+ it("an abort mid-run kills the child and settles at once", async () => {
140
+ const script = writeScript(
141
+ "sleeper.sh",
142
+ `echo $$ > '${pidFile}'\nexec sleep 30\n`,
143
+ );
144
+ const controller = new AbortController();
145
+
146
+ const started = Date.now();
147
+ const pending = runBounded(script, [], {
148
+ timeoutMs: 20_000,
149
+ signal: controller.signal,
150
+ });
151
+ // Abort once the child is really running (it has written its pid).
152
+ while (!fs.existsSync(pidFile) || !fs.readFileSync(pidFile, "utf8")) {
153
+ await Bun.sleep(10);
154
+ }
155
+ const pid = Number(fs.readFileSync(pidFile, "utf8").trim());
156
+ const abortedAt = Date.now();
157
+ controller.abort();
158
+ const r = await pending;
159
+ const settledAfterAbort = Date.now() - abortedAt;
160
+
161
+ expect(r.aborted).toBe(true);
162
+ expect(r.code).not.toBe(0);
163
+ expect(settledAfterAbort).toBeLessThan(300);
164
+ expect(Date.now() - started).toBeLessThan(2_000);
165
+
166
+ // The child is dead. Poll briefly: a SIGKILLed child is a zombie until the
167
+ // runtime reaps it, and signal 0 still succeeds on a zombie.
168
+ const dead = async (): Promise<boolean> => {
169
+ for (let i = 0; i < 50; i++) {
170
+ try {
171
+ process.kill(pid, 0);
172
+ } catch {
173
+ return true;
174
+ }
175
+ await Bun.sleep(20);
176
+ }
177
+ return false;
178
+ };
179
+ expect(await dead()).toBe(true);
180
+ }, 5_000);
181
+
182
+ it("an already-aborted signal settles at once and spawns nothing", async () => {
183
+ const marker = path.join(tmp, "ran");
184
+ const script = writeScript("marker.sh", `touch '${marker}'\n`);
185
+ const controller = new AbortController();
186
+ controller.abort();
187
+
188
+ const started = Date.now();
189
+ const r = await runBounded(script, [], {
190
+ timeoutMs: 5_000,
191
+ signal: controller.signal,
192
+ });
193
+ const elapsed = Date.now() - started;
194
+
195
+ expect(r.aborted).toBe(true);
196
+ expect(r.code).toBeNull();
197
+ expect(elapsed).toBeLessThan(100);
198
+ // Give a spawned child every chance to run before checking it did not.
199
+ await Bun.sleep(300);
200
+ expect(fs.existsSync(marker)).toBe(false);
201
+ }, 5_000);
202
+
203
+ it("settles with spawnError, never throws, for a command that does not exist", async () => {
204
+ const r = await runBounded(path.join(tmp, "no-such-command"), [], {
205
+ timeoutMs: 5_000,
206
+ });
207
+
208
+ expect(r.spawnError).toBeInstanceOf(Error);
209
+ expect(r.timedOut).toBe(false);
210
+ }, 5_000);
211
+ });
@@ -27,10 +27,10 @@
27
27
  * working precisely when the HTTP catalog cannot.
28
28
  */
29
29
 
30
- import { spawn } from "node:child_process";
31
30
  import fs from "node:fs";
32
31
  import path from "node:path";
33
32
  import { claudeConfigDirOrNull } from "../utils/config-dir.js";
33
+ import { runBounded } from "../utils/spawn-bounded.js";
34
34
  import {
35
35
  type MarketplacePlugin,
36
36
  normalizeCatalogJson,
@@ -55,36 +55,27 @@ function cloneDir(name: string): string | null {
55
55
  return path.join(configDir, "plugins", "marketplaces", name);
56
56
  }
57
57
 
58
- function git(
58
+ async function git(
59
59
  cwd: string,
60
60
  args: string[],
61
61
  timeoutMs: number,
62
62
  ): Promise<{ ok: boolean; out: string }> {
63
- return new Promise((resolve) => {
64
- const child = spawn("git", args, {
65
- cwd,
66
- stdio: ["ignore", "pipe", "ignore"],
67
- timeout: timeoutMs,
68
- killSignal: "SIGKILL",
69
- env: {
70
- ...process.env,
71
- // Never let git stop for credentials: this runs inside a TUI with no
72
- // way to answer a prompt, and a private marketplace we cannot read
73
- // should degrade to "unknown", not hang the screen forever.
74
- GIT_TERMINAL_PROMPT: "0",
75
- GIT_ASKPASS: "echo",
76
- SSH_ASKPASS: "echo",
77
- },
78
- });
79
- let out = "";
80
- child.stdout.on("data", (d: Buffer | string) => {
81
- out += String(d);
82
- });
83
- child.on("error", () => resolve({ ok: false, out: "" }));
84
- child.on("close", (code: number | null) =>
85
- resolve({ ok: code === 0, out }),
86
- );
63
+ const r = await runBounded("git", args, {
64
+ cwd,
65
+ stdio: ["ignore", "pipe", "ignore"],
66
+ timeoutMs,
67
+ env: {
68
+ ...process.env,
69
+ // Never let git stop for credentials: this runs inside a TUI with no
70
+ // way to answer a prompt, and a private marketplace we cannot read
71
+ // should degrade to "unknown", not hang the screen forever.
72
+ GIT_TERMINAL_PROMPT: "0",
73
+ GIT_ASKPASS: "echo",
74
+ SSH_ASKPASS: "echo",
75
+ },
87
76
  });
77
+ if (r.spawnError) return { ok: false, out: "" };
78
+ return { ok: r.code === 0, out: r.stdout };
88
79
  }
89
80
 
90
81
  /**
@@ -17,9 +17,9 @@
17
17
  * A failed `git pull --ff-only` leaves the clone exactly as it was: stale, but intact.
18
18
  */
19
19
 
20
- import { spawn } from "node:child_process";
21
20
  import fs from "node:fs";
22
21
  import path from "node:path";
22
+ import { runBounded } from "../utils/spawn-bounded.js";
23
23
  import {
24
24
  getConfiguredMarketplaces,
25
25
  getGlobalClaudeDir,
@@ -29,6 +29,9 @@ import {
29
29
  /** Per-git-invocation timeout. A hung pull must never block launching Claude. */
30
30
  const GIT_TIMEOUT_MS = 30_000;
31
31
 
32
+ /** Hard cap per clone, whatever hangs: `git status` then `git pull`, each GIT_TIMEOUT_MS, plus slack. */
33
+ const CLONE_DEADLINE_MS = 2 * GIT_TIMEOUT_MS + 5_000;
34
+
32
35
  export interface RefreshResult {
33
36
  /** Clones fast-forwarded to a newer commit (or already current). */
34
37
  refreshed: string[];
@@ -42,46 +45,36 @@ function cloneDir(name: string): string {
42
45
  return path.join(getGlobalClaudeDir(), "plugins", "marketplaces", name);
43
46
  }
44
47
 
45
- /** Run git in `cwd`, resolving stdout on exit 0. Killed after `timeoutMs`. */
46
- function runGit(
48
+ /**
49
+ * Run git in `cwd`, resolving stdout on exit 0. Killed after `timeoutMs`, or as
50
+ * soon as `signal` aborts (an already-aborted signal runs nothing). Throws on
51
+ * anything but exit 0.
52
+ */
53
+ async function runGit(
47
54
  cwd: string,
48
55
  args: string[],
49
56
  timeoutMs: number,
57
+ signal?: AbortSignal,
50
58
  ): Promise<string> {
51
- return new Promise((resolve, reject) => {
52
- const proc = spawn("git", args, {
53
- cwd,
54
- timeout: timeoutMs,
55
- killSignal: "SIGKILL",
56
- });
57
- let stdout = "";
58
- let stderr = "";
59
- proc.stdout?.on("data", (b) => {
60
- stdout += b.toString();
61
- });
62
- proc.stderr?.on("data", (b) => {
63
- stderr += b.toString();
64
- });
65
- proc.on("error", reject);
66
- proc.on("close", (code, signal) => {
67
- if (code === 0) {
68
- resolve(stdout);
69
- } else {
70
- reject(
71
- new Error(
72
- `git ${args.join(" ")} ${
73
- signal ? `killed (${signal})` : `exited ${code}`
74
- }: ${stderr.trim()}`,
75
- ),
76
- );
77
- }
78
- });
79
- });
59
+ const r = await runBounded("git", args, { cwd, timeoutMs, signal });
60
+ if (r.spawnError) throw r.spawnError;
61
+ if (r.code === 0 && !r.aborted) return r.stdout;
62
+ const how = r.aborted
63
+ ? "aborted"
64
+ : r.signal
65
+ ? `killed (${r.signal})`
66
+ : `exited ${r.code}`;
67
+ throw new Error(`git ${args.join(" ")} ${how}: ${r.stderr.trim()}`);
80
68
  }
81
69
 
82
70
  /** True if the clone has uncommitted changes we must not clobber. */
83
- async function isDirty(dir: string): Promise<boolean> {
84
- const out = await runGit(dir, ["status", "--porcelain"], GIT_TIMEOUT_MS);
71
+ async function isDirty(dir: string, signal?: AbortSignal): Promise<boolean> {
72
+ const out = await runGit(
73
+ dir,
74
+ ["status", "--porcelain"],
75
+ GIT_TIMEOUT_MS,
76
+ signal,
77
+ );
85
78
  return out.trim().length > 0;
86
79
  }
87
80
 
@@ -99,23 +92,50 @@ export type CloneRefresh = "absent" | "skipped" | "refreshed" | "failed";
99
92
  * deletes: the worst case is `failed` (stale clone left intact). Shared by the
100
93
  * batch refresh below and by claude-cli's "plugin not found" recovery, so both
101
94
  * paths get the same safe semantics instead of the old `marketplace update`.
95
+ *
96
+ * Bounded by `deadlineMs`: when it passes, the result is `failed` and the git
97
+ * command then in flight (`status` or `pull`) is SIGKILLed; no further git
98
+ * command starts. Only that direct child is killed: a `git fetch` or `ssh` it
99
+ * spawned may linger until it exits on its own, holding nothing we wait on.
100
+ * The clone directory is never deleted, but a pull killed while it updates the
101
+ * working tree can leave that update part-way and a `.git/index.lock` behind;
102
+ * later pulls then return `failed` until the lock is removed. The deadline sits
103
+ * well above a healthy pull, so that is the price of never hanging, not a
104
+ * normal outcome.
102
105
  */
103
106
  export async function fastForwardCloneIfPresent(
104
107
  name: string,
108
+ deadlineMs: number = CLONE_DEADLINE_MS,
105
109
  ): Promise<CloneRefresh> {
106
110
  const dir = cloneDir(name);
107
111
  // No clone on disk (claude never installed it) or not a git repo.
108
112
  if (!fs.existsSync(path.join(dir, ".git"))) return "absent";
113
+ const abort = new AbortController();
114
+ const work = (async (): Promise<CloneRefresh> => {
115
+ try {
116
+ // Never clobber a user's local edits to the clone.
117
+ if (await isDirty(dir, abort.signal)) return "skipped";
118
+ // --ff-only is the safety guarantee: it fetches and fast-forwards, and
119
+ // aborts without mutating the tree if it can't. It never deletes the dir,
120
+ // so a failure degrades to "stale clone", never "deleted marketplace".
121
+ await runGit(dir, ["pull", "--ff-only"], GIT_TIMEOUT_MS, abort.signal);
122
+ return "refreshed";
123
+ } catch {
124
+ return "failed";
125
+ }
126
+ })();
127
+ let timer: ReturnType<typeof setTimeout> | undefined;
128
+ const deadline = new Promise<CloneRefresh>((resolve) => {
129
+ timer = setTimeout(() => {
130
+ // Kill the in-flight git child rather than leave it running.
131
+ abort.abort();
132
+ resolve("failed");
133
+ }, deadlineMs);
134
+ });
109
135
  try {
110
- // Never clobber a user's local edits to the clone.
111
- if (await isDirty(dir)) return "skipped";
112
- // --ff-only is the safety guarantee: it fetches and fast-forwards, and
113
- // aborts without mutating the tree if it can't. It never deletes the dir,
114
- // so a failure degrades to "stale clone", never "deleted marketplace".
115
- await runGit(dir, ["pull", "--ff-only"], GIT_TIMEOUT_MS);
116
- return "refreshed";
117
- } catch {
118
- return "failed";
136
+ return await Promise.race([work, deadline]);
137
+ } finally {
138
+ clearTimeout(timer);
119
139
  }
120
140
  }
121
141
 
@@ -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
+ }