@hellcoder/companion 0.110.0 → 0.110.1-preview.20260726083620.ff8e630
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.ts +68 -16
- package/server/proc-diagnostics.test.ts +72 -1
- package/server/proc-diagnostics.ts +101 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hellcoder/companion",
|
|
3
|
-
"version": "0.110.
|
|
3
|
+
"version": "0.110.1-preview.20260726083620.ff8e630",
|
|
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",
|
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 } from "./proc-diagnostics.js";
|
|
49
|
+
import { captureProcState, hasLiveDescendants } 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";
|
|
@@ -75,6 +75,15 @@ const STDOUT_CLOSE_GRACE_MS = Number(process.env.COMPANION_STDOUT_CLOSE_GRACE_MS
|
|
|
75
75
|
*/
|
|
76
76
|
const STDOUT_CLOSE_RESULT_GRACE_MS = Number(process.env.COMPANION_STDOUT_CLOSE_RESULT_GRACE_MS) || 10000;
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* How long to wait after SIGTERM before escalating to SIGKILL.
|
|
80
|
+
*
|
|
81
|
+
* A CLI that ignores SIGTERM because it is itself blocked reaping children used
|
|
82
|
+
* to be left behind by the old flat kill, so `handleAutoRelaunch`'s PID-liveness
|
|
83
|
+
* guard could still see it alive. Escalating guarantees the pid is gone.
|
|
84
|
+
*/
|
|
85
|
+
const SIGKILL_ESCALATION_MS = Number(process.env.COMPANION_SIGKILL_ESCALATION_MS) || 2000;
|
|
86
|
+
|
|
78
87
|
// --- Claude Code Adapter ------------------------------------------------------
|
|
79
88
|
|
|
80
89
|
export class ClaudeAdapter implements IBackendAdapter {
|
|
@@ -322,7 +331,23 @@ export class ClaudeAdapter implements IBackendAdapter {
|
|
|
322
331
|
// is STILL alive afterwards, so a true wedge can't block recovery.
|
|
323
332
|
const proc = this.stdioProc;
|
|
324
333
|
if (proc && proc.exitCode === null && !proc.killed) {
|
|
325
|
-
|
|
334
|
+
// Which grace applies is decided by whether teardown is actually in
|
|
335
|
+
// progress, NOT by whether the last message happened to be a `result`.
|
|
336
|
+
//
|
|
337
|
+
// Every CLI spawns 2-3 MCP stdio servers, and npm-exec-wrapped servers
|
|
338
|
+
// plus headless chromium do not reap inside the short grace. The old
|
|
339
|
+
// `lastInboundWasResult` discriminator was a proxy for "clean shutdown"
|
|
340
|
+
// that has nothing to do with teardown cost, so a healthy CLI landed on
|
|
341
|
+
// the short grace two ways: any non-result/keep_alive/system message
|
|
342
|
+
// trailing the turn, or a fresh adapter (the flag initialises false).
|
|
343
|
+
// It was then SIGTERMed mid-teardown, exiting 143 and taking the user's
|
|
344
|
+
// turn with it.
|
|
345
|
+
//
|
|
346
|
+
// Live descendants are the direct signal: a genuine wedge is idle with
|
|
347
|
+
// no children, whereas a CLI still reaping MCP servers has children
|
|
348
|
+
// mid-exit.
|
|
349
|
+
const teardownInProgress = hasLiveDescendants(proc.pid);
|
|
350
|
+
if (this.lastInboundWasResult || teardownInProgress) {
|
|
326
351
|
// Clean end-of-turn shutdown: let the process flush teardown (MCP
|
|
327
352
|
// servers, etc.) and exit code-0 instead of SIGTERM-ing a code-0 exit.
|
|
328
353
|
// Use a generous grace so we don't clobber a slow-but-clean exit, but
|
|
@@ -340,13 +365,10 @@ export class ClaudeAdapter implements IBackendAdapter {
|
|
|
340
365
|
sessionId: this.sessionId,
|
|
341
366
|
pid: proc.pid,
|
|
342
367
|
graceMs: STDOUT_CLOSE_RESULT_GRACE_MS,
|
|
368
|
+
graceReason: this.lastInboundWasResult ? "result" : "descendants_alive",
|
|
343
369
|
proc: captureProcState(proc.pid),
|
|
344
370
|
});
|
|
345
|
-
|
|
346
|
-
proc.kill();
|
|
347
|
-
} catch {
|
|
348
|
-
// Process may have exited between the check and the kill.
|
|
349
|
-
}
|
|
371
|
+
await this.killWithEscalation(proc);
|
|
350
372
|
}
|
|
351
373
|
} else {
|
|
352
374
|
const exitedOnOwn = await Promise.race([
|
|
@@ -354,21 +376,17 @@ export class ClaudeAdapter implements IBackendAdapter {
|
|
|
354
376
|
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), STDOUT_CLOSE_GRACE_MS)),
|
|
355
377
|
]);
|
|
356
378
|
if (!exitedOnOwn && proc.exitCode === null && !proc.killed) {
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
// carry the answer.
|
|
379
|
+
// Reaching here now means a genuine wedge: stdout closed, no live
|
|
380
|
+
// descendants to reap, and still not exited. Snapshot kernel state
|
|
381
|
+
// before the kill destroys it.
|
|
361
382
|
log.warn("claude-adapter", "stdout closed mid-stream and process did not exit within grace; killing wedged process", {
|
|
362
383
|
sessionId: this.sessionId,
|
|
363
384
|
pid: proc.pid,
|
|
364
385
|
graceMs: STDOUT_CLOSE_GRACE_MS,
|
|
386
|
+
graceReason: "no_descendants",
|
|
365
387
|
proc: captureProcState(proc.pid),
|
|
366
388
|
});
|
|
367
|
-
|
|
368
|
-
proc.kill();
|
|
369
|
-
} catch {
|
|
370
|
-
// Process may have exited between the check and the kill.
|
|
371
|
-
}
|
|
389
|
+
await this.killWithEscalation(proc);
|
|
372
390
|
}
|
|
373
391
|
}
|
|
374
392
|
}
|
|
@@ -376,6 +394,40 @@ export class ClaudeAdapter implements IBackendAdapter {
|
|
|
376
394
|
}
|
|
377
395
|
}
|
|
378
396
|
|
|
397
|
+
/**
|
|
398
|
+
* SIGTERM a process, then SIGKILL it if it has not exited.
|
|
399
|
+
*
|
|
400
|
+
* A flat SIGTERM is not enough for the case this path exists to handle: a CLI
|
|
401
|
+
* blocked reaping its own children may never act on the signal, staying alive
|
|
402
|
+
* and satisfying `handleAutoRelaunch`'s PID-liveness guard, which blocks
|
|
403
|
+
* recovery indefinitely — the exact failure the kill was meant to prevent.
|
|
404
|
+
* Never throws; the process may exit between any two steps here.
|
|
405
|
+
*/
|
|
406
|
+
private async killWithEscalation(proc: Subprocess): Promise<void> {
|
|
407
|
+
try {
|
|
408
|
+
proc.kill();
|
|
409
|
+
} catch {
|
|
410
|
+
return; // Already gone.
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const exited = await Promise.race([
|
|
414
|
+
proc.exited.then(() => true),
|
|
415
|
+
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), SIGKILL_ESCALATION_MS)),
|
|
416
|
+
]);
|
|
417
|
+
if (exited || proc.exitCode !== null) return;
|
|
418
|
+
|
|
419
|
+
log.warn("claude-adapter", "process ignored SIGTERM; escalating to SIGKILL", {
|
|
420
|
+
sessionId: this.sessionId,
|
|
421
|
+
pid: proc.pid,
|
|
422
|
+
afterMs: SIGKILL_ESCALATION_MS,
|
|
423
|
+
});
|
|
424
|
+
try {
|
|
425
|
+
proc.kill("SIGKILL");
|
|
426
|
+
} catch {
|
|
427
|
+
// Exited between the check and the escalation.
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
379
431
|
/**
|
|
380
432
|
* Send the one-time `initialize` control_request before the first outbound
|
|
381
433
|
* message in stdio mode. This registers the canUseTool capability so the CLI
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
captureProcState,
|
|
5
|
+
isProcAvailable,
|
|
6
|
+
getDescendants,
|
|
7
|
+
hasLiveDescendants,
|
|
8
|
+
} from "./proc-diagnostics.js";
|
|
3
9
|
|
|
4
10
|
/**
|
|
5
11
|
* These tests cover the diagnostic that runs on the wedge-kill recovery path in
|
|
@@ -70,3 +76,68 @@ describe("captureProcState", () => {
|
|
|
70
76
|
expect(JSON.stringify(snapshot)).toContain("state");
|
|
71
77
|
});
|
|
72
78
|
});
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Descendant detection is what replaced `lastInboundWasResult` as the grace
|
|
82
|
+
* discriminator in claude-adapter. Its correctness decides whether a CLI that
|
|
83
|
+
* is mid-teardown gets the generous grace or gets SIGTERMed at 2s — the latter
|
|
84
|
+
* being the bug that killed users' turns.
|
|
85
|
+
*/
|
|
86
|
+
describe("getDescendants / hasLiveDescendants", () => {
|
|
87
|
+
it("returns empty for undefined pid without throwing", () => {
|
|
88
|
+
expect(getDescendants(undefined)).toEqual([]);
|
|
89
|
+
expect(hasLiveDescendants(undefined)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns empty on platforms without /proc", () => {
|
|
93
|
+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
|
94
|
+
expect(getDescendants(1)).toEqual([]);
|
|
95
|
+
expect(hasLiveDescendants(1)).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns empty for a pid that does not exist", () => {
|
|
99
|
+
expect(getDescendants(2_147_483_646)).toEqual([]);
|
|
100
|
+
expect(hasLiveDescendants(2_147_483_646)).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it.runIf(isLinux)("detects a live child and reports it gone after exit", async () => {
|
|
104
|
+
// Mirrors the real shape: a parent holding a child that has not yet reaped.
|
|
105
|
+
// `sleep` stands in for an MCP stdio server still shutting down.
|
|
106
|
+
const child = spawn("sleep", ["30"], { stdio: "ignore" });
|
|
107
|
+
await new Promise((r) => setTimeout(r, 150)); // let the fork land in /proc
|
|
108
|
+
|
|
109
|
+
expect(hasLiveDescendants(process.pid)).toBe(true);
|
|
110
|
+
const descendants = getDescendants(process.pid);
|
|
111
|
+
const found = descendants.find((d) => d.pid === child.pid);
|
|
112
|
+
expect(found).toBeDefined();
|
|
113
|
+
expect(found?.comm).toBe("sleep");
|
|
114
|
+
// A running-but-idle child reports S (sleeping), not Z (reaped).
|
|
115
|
+
expect(found?.state).toBe("S");
|
|
116
|
+
|
|
117
|
+
// After the child exits and is reaped, it must no longer count — otherwise
|
|
118
|
+
// a genuinely wedged process would be granted the long grace forever.
|
|
119
|
+
child.kill("SIGKILL");
|
|
120
|
+
await new Promise((r) => child.on("exit", r));
|
|
121
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
122
|
+
|
|
123
|
+
expect(getDescendants(process.pid).some((d) => d.pid === child.pid)).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it.runIf(isLinux)("bounds the walk so a large tree cannot stall the kill path", () => {
|
|
127
|
+
// maxNodes is a safety bound: this runs immediately before a kill that
|
|
128
|
+
// unblocks a stuck session, so it must terminate regardless of tree size.
|
|
129
|
+
expect(getDescendants(1, 2).length).toBeLessThanOrEqual(2);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it.runIf(isLinux)("includes descendants in the captured snapshot", async () => {
|
|
133
|
+
const child = spawn("sleep", ["30"], { stdio: "ignore" });
|
|
134
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
135
|
+
|
|
136
|
+
const snapshot = captureProcState(process.pid);
|
|
137
|
+
expect(snapshot.descendants?.some((d) => d.pid === child.pid)).toBe(true);
|
|
138
|
+
expect(() => JSON.stringify(snapshot)).not.toThrow();
|
|
139
|
+
|
|
140
|
+
child.kill("SIGKILL");
|
|
141
|
+
await new Promise((r) => child.on("exit", r));
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -26,10 +26,105 @@ export interface ProcSnapshot {
|
|
|
26
26
|
wchan?: string;
|
|
27
27
|
/** Open file descriptor count for this process. */
|
|
28
28
|
fdCount?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Live descendants at capture time. This is the field that separates the two
|
|
31
|
+
* reasons a process sits in `ep_poll` with stdout closed: a genuine wedge is
|
|
32
|
+
* idle with NO children, whereas a CLI still reaping MCP stdio servers has
|
|
33
|
+
* children mid-exit. Without it, both look identical.
|
|
34
|
+
*/
|
|
35
|
+
descendants?: ProcDescendant[];
|
|
29
36
|
/** Why the snapshot is incomplete, when it is. */
|
|
30
37
|
error?: string;
|
|
31
38
|
}
|
|
32
39
|
|
|
40
|
+
/** A live descendant process, as seen from /proc. */
|
|
41
|
+
export interface ProcDescendant {
|
|
42
|
+
pid: number;
|
|
43
|
+
/** Command name from /proc/<pid>/comm, e.g. "npm exec", "node", "chrome". */
|
|
44
|
+
comm?: string;
|
|
45
|
+
/** Scheduler state letter, e.g. "S", "R", "Z". "Z" means mid-reap. */
|
|
46
|
+
state?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Read the direct children of a pid via /proc/<pid>/task/<tid>/children. */
|
|
50
|
+
function readChildren(pid: number): number[] {
|
|
51
|
+
const kids: number[] = [];
|
|
52
|
+
try {
|
|
53
|
+
for (const tid of readdirSync(`/proc/${pid}/task`)) {
|
|
54
|
+
try {
|
|
55
|
+
const raw = readFileSync(`/proc/${pid}/task/${tid}/children`, "utf8").trim();
|
|
56
|
+
if (!raw) continue;
|
|
57
|
+
for (const part of raw.split(/\s+/)) {
|
|
58
|
+
const child = Number(part);
|
|
59
|
+
if (Number.isInteger(child) && child > 0) kids.push(child);
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// Thread exited between readdir and read.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// Process gone, or kernel built without CONFIG_PROC_CHILDREN.
|
|
67
|
+
}
|
|
68
|
+
return kids;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Walk the descendant tree of a pid, breadth-first.
|
|
73
|
+
*
|
|
74
|
+
* Bounded by `maxNodes` because this runs on the kill path — a runaway tree
|
|
75
|
+
* must not stall recovery. MCP stdio servers are the expected population here
|
|
76
|
+
* (2-3 per CLI, sometimes with their own children, e.g. headless chromium).
|
|
77
|
+
*/
|
|
78
|
+
export function getDescendants(
|
|
79
|
+
pid: number | undefined,
|
|
80
|
+
maxNodes = 32,
|
|
81
|
+
): ProcDescendant[] {
|
|
82
|
+
if (pid === undefined || !isProcAvailable()) return [];
|
|
83
|
+
|
|
84
|
+
const found: ProcDescendant[] = [];
|
|
85
|
+
const seen = new Set<number>([pid]);
|
|
86
|
+
let frontier = readChildren(pid);
|
|
87
|
+
|
|
88
|
+
while (frontier.length > 0 && found.length < maxNodes) {
|
|
89
|
+
const next: number[] = [];
|
|
90
|
+
for (const child of frontier) {
|
|
91
|
+
if (seen.has(child) || found.length >= maxNodes) continue;
|
|
92
|
+
seen.add(child);
|
|
93
|
+
|
|
94
|
+
const entry: ProcDescendant = { pid: child };
|
|
95
|
+
try {
|
|
96
|
+
entry.comm = readFileSync(`/proc/${child}/comm`, "utf8").trim();
|
|
97
|
+
} catch {
|
|
98
|
+
// Already reaped — still worth reporting the pid.
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const status = readFileSync(`/proc/${child}/status`, "utf8");
|
|
102
|
+
const line = status.split("\n").find((l) => l.startsWith("State:"));
|
|
103
|
+
if (line) entry.state = line.split(":")[1]?.trim().split(" ")[0];
|
|
104
|
+
} catch {
|
|
105
|
+
// Same.
|
|
106
|
+
}
|
|
107
|
+
found.push(entry);
|
|
108
|
+
next.push(...readChildren(child));
|
|
109
|
+
}
|
|
110
|
+
frontier = next;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return found;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether a process still has live descendants.
|
|
118
|
+
*
|
|
119
|
+
* Used to decide how long to wait for a CLI to shut down: one that is still
|
|
120
|
+
* reaping MCP subprocesses deserves the generous grace, regardless of whether
|
|
121
|
+
* the last message it sent happened to be a `result`.
|
|
122
|
+
*/
|
|
123
|
+
export function hasLiveDescendants(pid: number | undefined): boolean {
|
|
124
|
+
if (pid === undefined || !isProcAvailable()) return false;
|
|
125
|
+
return readChildren(pid).length > 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
33
128
|
/** True when /proc-based introspection is available (Linux only). */
|
|
34
129
|
export function isProcAvailable(): boolean {
|
|
35
130
|
return process.platform === "linux";
|
|
@@ -78,6 +173,12 @@ export function captureProcState(pid: number | undefined): ProcSnapshot {
|
|
|
78
173
|
// Reading another process's fd dir can fail on permissions; not fatal.
|
|
79
174
|
}
|
|
80
175
|
|
|
176
|
+
const descendants = getDescendants(pid);
|
|
177
|
+
if (descendants.length > 0) {
|
|
178
|
+
snapshot.descendants = descendants;
|
|
179
|
+
readAnything = true;
|
|
180
|
+
}
|
|
181
|
+
|
|
81
182
|
if (!readAnything) snapshot.error = "process_gone_or_unreadable";
|
|
82
183
|
return snapshot;
|
|
83
184
|
}
|