@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 +1 -1
- package/server/claude-adapter.test.ts +10 -1
- package/server/claude-adapter.ts +104 -22
- package/server/proc-diagnostics.test.ts +43 -0
- package/server/proc-diagnostics.ts +32 -0
- package/server/ws-bridge.test.ts +40 -0
- package/server/ws-bridge.ts +12 -0
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
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);
|
package/server/claude-adapter.ts
CHANGED
|
@@ -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
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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) ||
|
|
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
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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";
|
package/server/ws-bridge.test.ts
CHANGED
|
@@ -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",
|
package/server/ws-bridge.ts
CHANGED
|
@@ -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);
|