@hellcoder/companion 0.110.1 → 0.110.2

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": "@hellcoder/companion",
3
- "version": "0.110.1",
3
+ "version": "0.110.2",
4
4
  "type": "module",
5
5
  "description": "Web UI for launching and interacting with Claude Code agents — Moritz Edition (fork of the-companion)",
6
6
  "license": "MIT",
@@ -1574,8 +1574,17 @@ describe("stdio transport disconnect propagation", () => {
1574
1574
  await vi.advanceTimersByTimeAsync(0);
1575
1575
  expect(proc.kill).not.toHaveBeenCalled();
1576
1576
 
1577
- // Past the grace window with no self-exit treated as wedged and killed.
1577
+ // The old flat 2s grace used to kill here. It no longer does: measured
1578
+ // over 22 production kills that timer never caught a genuinely hung
1579
+ // process — all 22 were healthy CLIs that had finished reaping and simply
1580
+ // had not exited yet, and each kill destroyed the user's in-flight turn.
1581
+ // The window is now 10s (COMPANION_STDOUT_CLOSE_GRACE_MS).
1578
1582
  await vi.advanceTimersByTimeAsync(2100);
1583
+ expect(proc.kill).not.toHaveBeenCalled();
1584
+
1585
+ // Past the stall window with no self-exit → treated as wedged and killed,
1586
+ // so the relaunch path's PID-liveness guard is still cleared.
1587
+ await vi.advanceTimersByTimeAsync(9000);
1579
1588
  expect(proc.kill).toHaveBeenCalledTimes(1);
1580
1589
  expect(adapter.isConnected()).toBe(false);
1581
1590
  expect(disconnectCb).toHaveBeenCalledTimes(1);
@@ -46,7 +46,7 @@ import type {
46
46
  import type { SocketData } from "./ws-bridge-types.js";
47
47
  import type { PendingControlRequest } from "./ws-bridge-types.js";
48
48
  import type { RecorderManager } from "./recorder.js";
49
- import { captureProcState, hasLiveDescendants } from "./proc-diagnostics.js";
49
+ import { captureProcState, hasLiveDescendants, countDescendants } from "./proc-diagnostics.js";
50
50
  import { parseNDJSON, isDuplicateCLIMessage } from "./ws-bridge-cli-ingest.js";
51
51
  import type { CLIDedupState } from "./ws-bridge-cli-ingest.js";
52
52
  import { reportProtocolDrift } from "./protocol-monitor.js";
@@ -57,14 +57,19 @@ import { reportProtocolDrift } from "./protocol-monitor.js";
57
57
  const CLI_DEDUP_WINDOW = 2000;
58
58
 
59
59
  /**
60
- * Grace period after stdout closes before we kill a still-alive CLI process.
61
- * The CLI's stdout often EOFs a beat *before* the process exits cleanly (e.g.
62
- * right after emitting a `result`). Killing immediately turns that code-0 exit
63
- * into a SIGTERM (143) and forces a needless relaunch. We wait this long for the
64
- * process to exit on its own; only a process that is still alive afterwards is
65
- * treated as genuinely wedged and killed. Overridable for tests/tuning.
60
+ * How long a process with no remaining descendants may sit without exiting
61
+ * before we treat it as wedged and kill it.
62
+ *
63
+ * This was a flat 2s. Measured over 22 kills that timer never once caught a
64
+ * genuinely hung process all 22 were healthy (21 sleeping, 1 running),
65
+ * typically S/ep_poll with 12 threads: CLIs that had finished reaping and had
66
+ * simply not exited yet. Each kill destroyed the user's in-flight turn.
67
+ *
68
+ * Raised to 10s to match the descendants path. A real wedge is still caught,
69
+ * just 8s later — a trade that costs recovery latency in the rare case and
70
+ * saves a user's answer in the common one.
66
71
  */
67
- const STDOUT_CLOSE_GRACE_MS = Number(process.env.COMPANION_STDOUT_CLOSE_GRACE_MS) || 2000;
72
+ const STDOUT_CLOSE_GRACE_MS = Number(process.env.COMPANION_STDOUT_CLOSE_GRACE_MS) || 10_000;
68
73
 
69
74
  /**
70
75
  * Longer grace used when stdout EOFs right after a terminal `result` (a clean
@@ -84,6 +89,21 @@ const STDOUT_CLOSE_RESULT_GRACE_MS = Number(process.env.COMPANION_STDOUT_CLOSE_R
84
89
  */
85
90
  const SIGKILL_ESCALATION_MS = Number(process.env.COMPANION_SIGKILL_ESCALATION_MS) || 2000;
86
91
 
92
+ /**
93
+ * Absolute ceiling on waiting for MCP teardown, however well it is progressing.
94
+ *
95
+ * The flat 10s grace was not enough: 8 of 12 observed kills were processes
96
+ * correctly identified as still reaping children, waited the full 10s for, and
97
+ * killed anyway while perfectly healthy (S sleeping, 311-351MB). `npm exec`
98
+ * -wrapped MCP servers plus headless chromium routinely exceed 10s, especially
99
+ * with ~70 CLIs contending for I/O.
100
+ */
101
+ const TEARDOWN_MAX_MS = Number(process.env.COMPANION_TEARDOWN_MAX_MS) || 120_000;
102
+
103
+ /** How often to re-check the descendant count while waiting for teardown. */
104
+ const TEARDOWN_POLL_MS = Number(process.env.COMPANION_TEARDOWN_POLL_MS) || 500;
105
+
106
+
87
107
  // --- Claude Code Adapter ------------------------------------------------------
88
108
 
89
109
  export class ClaudeAdapter implements IBackendAdapter {
@@ -348,15 +368,15 @@ export class ClaudeAdapter implements IBackendAdapter {
348
368
  // mid-exit.
349
369
  const teardownInProgress = hasLiveDescendants(proc.pid);
350
370
  if (this.lastInboundWasResult || teardownInProgress) {
351
- // Clean end-of-turn shutdown: let the process flush teardown (MCP
352
- // servers, etc.) and exit code-0 instead of SIGTERM-ing a code-0 exit.
353
- // Use a generous grace so we don't clobber a slow-but-clean exit, but
354
- // keep it bounded a process that wedges *after* a result must still
355
- // be killed so recovery isn't blocked indefinitely.
356
- const exitedOnOwn = await Promise.race([
357
- proc.exited.then(() => true),
358
- new Promise<boolean>((resolve) => setTimeout(() => resolve(false), STDOUT_CLOSE_RESULT_GRACE_MS)),
359
- ]);
371
+ // Wait for teardown ADAPTIVELY rather than on a flat timer. A fixed
372
+ // 10s grace still killed 8 of 12 healthy processes: it cannot know
373
+ // how long `npm exec`-wrapped MCP servers plus headless chromium will
374
+ // take, especially with many CLIs contending for I/O.
375
+ //
376
+ // Instead, keep waiting as long as the descendant count is dropping —
377
+ // teardown is demonstrably working, so killing would be wrong at any
378
+ // deadline. Give up only when progress stalls, or at a hard ceiling.
379
+ const exitedOnOwn = await this.awaitTeardown(proc, STDOUT_CLOSE_RESULT_GRACE_MS);
360
380
  if (!exitedOnOwn && proc.exitCode === null && !proc.killed) {
361
381
  // Capture kernel state BEFORE the kill: once we SIGTERM, the
362
382
  // evidence is gone. A wedged CLI writes nothing to stderr, so this
@@ -364,17 +384,25 @@ export class ClaudeAdapter implements IBackendAdapter {
364
384
  log.warn("claude-adapter", "stdout closed after result but process did not exit within grace; killing wedged process", {
365
385
  sessionId: this.sessionId,
366
386
  pid: proc.pid,
367
- graceMs: STDOUT_CLOSE_RESULT_GRACE_MS,
368
387
  graceReason: this.lastInboundWasResult ? "result" : "descendants_alive",
388
+ teardownOutcome: this.lastTeardownOutcome,
369
389
  proc: captureProcState(proc.pid),
370
390
  });
371
391
  await this.killWithEscalation(proc);
372
392
  }
373
393
  } else {
374
- const exitedOnOwn = await Promise.race([
375
- proc.exited.then(() => true),
376
- new Promise<boolean>((resolve) => setTimeout(() => resolve(false), STDOUT_CLOSE_GRACE_MS)),
377
- ]);
394
+ // No descendants left to reap. This used to take a flat 2s timer, but
395
+ // measured over 22 kills that timer never once caught a genuinely
396
+ // hung process: every single one was healthy (21 sleeping, 1 running,
397
+ // 0 hung), typically S/ep_poll with 12 threads — a CLI that had
398
+ // finished reaping and simply had not exited yet.
399
+ //
400
+ // A CLI in that state is indistinguishable from a real wedge by any
401
+ // signal available here, so the tie is broken on cost instead:
402
+ // killing a healthy process destroys the user's in-flight turn, while
403
+ // waiting longer on a truly wedged one only delays recovery. So wait
404
+ // the same way, bounded by the stall window rather than a 2s guess.
405
+ const exitedOnOwn = await this.awaitTeardown(proc, STDOUT_CLOSE_GRACE_MS);
378
406
  if (!exitedOnOwn && proc.exitCode === null && !proc.killed) {
379
407
  // Reaching here now means a genuine wedge: stdout closed, no live
380
408
  // descendants to reap, and still not exited. Snapshot kernel state
@@ -394,6 +422,60 @@ export class ClaudeAdapter implements IBackendAdapter {
394
422
  }
395
423
  }
396
424
 
425
+ /** Why the last teardown wait ended, for the kill warning. */
426
+ private lastTeardownOutcome: string | undefined;
427
+
428
+ /**
429
+ * Wait for a process to exit while its descendants are still being reaped.
430
+ *
431
+ * Returns true if it exited on its own. The wait is adaptive: as long as the
432
+ * descendant count keeps dropping, teardown is demonstrably working and we
433
+ * keep waiting regardless of elapsed time. We give up only when the count
434
+ * sits unchanged for TEARDOWN_STALL_MS (nothing is happening) or the hard
435
+ * TEARDOWN_MAX_MS ceiling is hit.
436
+ *
437
+ * This replaces a flat 10s grace that killed 8 of 12 healthy processes: a
438
+ * fixed deadline cannot know how long `npm exec`-wrapped MCP servers plus
439
+ * headless chromium need, particularly under contention.
440
+ */
441
+ private async awaitTeardown(proc: Subprocess, stallMs: number): Promise<boolean> {
442
+ const started = Date.now();
443
+ let lastCount = countDescendants(proc.pid);
444
+ let lastProgressAt = started;
445
+ this.lastTeardownOutcome = undefined;
446
+
447
+ while (true) {
448
+ const exited = await Promise.race([
449
+ proc.exited.then(() => true),
450
+ new Promise<boolean>((resolve) => setTimeout(() => resolve(false), TEARDOWN_POLL_MS)),
451
+ ]);
452
+ if (exited || proc.exitCode !== null) {
453
+ this.lastTeardownOutcome = `exited_after_${Date.now() - started}ms`;
454
+ return true;
455
+ }
456
+
457
+ const now = Date.now();
458
+ const count = countDescendants(proc.pid);
459
+ if (count < lastCount) {
460
+ // Progress: children are being reaped. Reset the stall window — a slow
461
+ // but advancing teardown must never be killed.
462
+ lastCount = count;
463
+ lastProgressAt = now;
464
+ }
465
+
466
+ if (now - lastProgressAt >= stallMs) {
467
+ this.lastTeardownOutcome =
468
+ `stalled_at_${count}_descendants_after_${now - started}ms`;
469
+ return false;
470
+ }
471
+ if (now - started >= TEARDOWN_MAX_MS) {
472
+ this.lastTeardownOutcome =
473
+ `ceiling_${TEARDOWN_MAX_MS}ms_with_${count}_descendants`;
474
+ return false;
475
+ }
476
+ }
477
+ }
478
+
397
479
  /**
398
480
  * SIGTERM a process, then SIGKILL it if it has not exited.
399
481
  *
@@ -5,6 +5,7 @@ import {
5
5
  isProcAvailable,
6
6
  getDescendants,
7
7
  hasLiveDescendants,
8
+ countDescendants,
8
9
  } from "./proc-diagnostics.js";
9
10
 
10
11
  /**
@@ -129,6 +130,48 @@ describe("getDescendants / hasLiveDescendants", () => {
129
130
  expect(getDescendants(1, 2).length).toBeLessThanOrEqual(2);
130
131
  });
131
132
 
133
+ /**
134
+ * countDescendants drives the adaptive teardown wait in claude-adapter: a
135
+ * falling count means teardown is progressing and the process must NOT be
136
+ * killed, however long it takes. A count that stops falling is what ends the
137
+ * wait. So the critical property is that the count actually tracks reality.
138
+ */
139
+ it("countDescendants returns 0 for undefined pid and non-Linux", () => {
140
+ expect(countDescendants(undefined)).toBe(0);
141
+ vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
142
+ expect(countDescendants(1)).toBe(0);
143
+ });
144
+
145
+ it.runIf(isLinux)("countDescendants drops as children exit", async () => {
146
+ // This is the exact signal the adaptive wait keys on. If the count did not
147
+ // fall as children are reaped, a healthy teardown would look like a stall
148
+ // and get killed — the bug this replaces.
149
+ const a = spawn("sleep", ["30"], { stdio: "ignore" });
150
+ const b = spawn("sleep", ["30"], { stdio: "ignore" });
151
+ await new Promise((r) => setTimeout(r, 150));
152
+
153
+ const withBoth = countDescendants(process.pid);
154
+ expect(withBoth).toBeGreaterThanOrEqual(2);
155
+
156
+ a.kill("SIGKILL");
157
+ await new Promise((r) => a.on("exit", r));
158
+ await new Promise((r) => setTimeout(r, 150));
159
+
160
+ const withOne = countDescendants(process.pid);
161
+ expect(withOne).toBeLessThan(withBoth);
162
+
163
+ b.kill("SIGKILL");
164
+ await new Promise((r) => b.on("exit", r));
165
+ await new Promise((r) => setTimeout(r, 150));
166
+
167
+ expect(countDescendants(process.pid)).toBeLessThan(withOne);
168
+ });
169
+
170
+ it.runIf(isLinux)("countDescendants is bounded by maxNodes", () => {
171
+ // Bounds the poll-loop cost: this runs every 500ms during teardown.
172
+ expect(countDescendants(1, 3)).toBeLessThanOrEqual(3);
173
+ });
174
+
132
175
  it.runIf(isLinux)("includes descendants in the captured snapshot", async () => {
133
176
  const child = spawn("sleep", ["30"], { stdio: "ignore" });
134
177
  await new Promise((r) => setTimeout(r, 150));
@@ -125,6 +125,38 @@ export function hasLiveDescendants(pid: number | undefined): boolean {
125
125
  return readChildren(pid).length > 0;
126
126
  }
127
127
 
128
+ /**
129
+ * Count live descendants without reading per-process detail.
130
+ *
131
+ * Cheaper than getDescendants() because it skips the comm/status reads, so it
132
+ * is safe to call on a poll loop. Used to tell *progress* (the count is
133
+ * dropping, teardown is working) from a *stall* (the count is stuck, nothing is
134
+ * happening) — which is what decides whether waiting longer is worthwhile.
135
+ */
136
+ export function countDescendants(
137
+ pid: number | undefined,
138
+ maxNodes = 256,
139
+ ): number {
140
+ if (pid === undefined || !isProcAvailable()) return 0;
141
+
142
+ let count = 0;
143
+ const seen = new Set<number>([pid]);
144
+ let frontier = readChildren(pid);
145
+
146
+ while (frontier.length > 0 && count < maxNodes) {
147
+ const next: number[] = [];
148
+ for (const child of frontier) {
149
+ if (seen.has(child) || count >= maxNodes) continue;
150
+ seen.add(child);
151
+ count++;
152
+ next.push(...readChildren(child));
153
+ }
154
+ frontier = next;
155
+ }
156
+
157
+ return count;
158
+ }
159
+
128
160
  /** True when /proc-based introspection is available (Linux only). */
129
161
  export function isProcAvailable(): boolean {
130
162
  return process.platform === "linux";
@@ -1923,6 +1923,46 @@ describe("CLI message routing", () => {
1923
1923
  expect(permBroadcast.request.request_id).toBe("req-reattach");
1924
1924
  });
1925
1925
 
1926
+ /**
1927
+ * Regression: typing into a session whose CLI has already been killed used to
1928
+ * queue the message with no process to drain it. The transition
1929
+ * terminated -> streaming is (correctly) illegal, so the turn never started;
1930
+ * the message only moved when some unrelated event happened to relaunch the
1931
+ * session. From the user's side the session looked alive but silently
1932
+ * swallowed input, then replayed the turn later from the top.
1933
+ *
1934
+ * Typing into a terminated session is an explicit request to resume it, so it
1935
+ * must request the relaunch itself. terminated -> starting is legal, and the
1936
+ * adapter queue flushes on attach.
1937
+ */
1938
+ it("user_message in a terminated session requests a relaunch", async () => {
1939
+ const relaunchCb = vi.fn();
1940
+ companionBus.on("session:relaunch-needed", ({ sessionId }) => relaunchCb(sessionId));
1941
+
1942
+ const cli = makeCliSocket("s1");
1943
+ bridge.handleCLIOpen(cli, "s1");
1944
+ await bridge.handleCLIMessage(cli, makeInitMsg());
1945
+ const session = bridge.getSession("s1")!;
1946
+
1947
+ // Drive the session to terminated, as a wedge kill would.
1948
+ session.stateMachine.transition("terminated", "test_kill");
1949
+ expect(session.stateMachine.phase).toBe("terminated");
1950
+
1951
+ const browser = makeBrowserSocket("s1");
1952
+ bridge.handleBrowserOpen(browser, "s1");
1953
+ relaunchCb.mockClear();
1954
+
1955
+ await bridge.handleBrowserMessage(browser, JSON.stringify({
1956
+ type: "user_message",
1957
+ content: "still working?",
1958
+ }));
1959
+
1960
+ expect(relaunchCb).toHaveBeenCalledWith("s1");
1961
+ // The message must still be preserved for replay, not dropped in favour of
1962
+ // the relaunch.
1963
+ expect(session.inFlightUserTurn).toBeTruthy();
1964
+ });
1965
+
1926
1966
  it("tool_progress: broadcasts", async () => {
1927
1967
  const msg = JSON.stringify({
1928
1968
  type: "tool_progress",
@@ -1216,6 +1216,18 @@ export class WsBridge {
1216
1216
  sessionId: session.id,
1217
1217
  phase: session.stateMachine.phase,
1218
1218
  });
1219
+ // A terminated session has no process to drain that queue, so the
1220
+ // message would sit there until some unrelated event (proactive
1221
+ // keepalive) happened to relaunch — the user sees a dead session that
1222
+ // silently swallows input. Typing into a terminated session is an
1223
+ // explicit request to resume it, so ask for the relaunch here:
1224
+ // terminated -> starting is legal, and the queue flushes on attach.
1225
+ if (session.stateMachine.phase === "terminated") {
1226
+ log.info("ws-bridge", "User message in terminated session; requesting relaunch", {
1227
+ sessionId: session.id,
1228
+ });
1229
+ companionBus.emit("session:relaunch-needed", { sessionId: session.id });
1230
+ }
1219
1231
  }
1220
1232
  this.persistSession(session);
1221
1233
  this.broadcastToBrowsers(session, userMessage);