@hellcoder/companion 0.109.1 → 0.110.0
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
package/server/claude-adapter.ts
CHANGED
|
@@ -46,6 +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
50
|
import { parseNDJSON, isDuplicateCLIMessage } from "./ws-bridge-cli-ingest.js";
|
|
50
51
|
import type { CLIDedupState } from "./ws-bridge-cli-ingest.js";
|
|
51
52
|
import { reportProtocolDrift } from "./protocol-monitor.js";
|
|
@@ -332,10 +333,14 @@ export class ClaudeAdapter implements IBackendAdapter {
|
|
|
332
333
|
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), STDOUT_CLOSE_RESULT_GRACE_MS)),
|
|
333
334
|
]);
|
|
334
335
|
if (!exitedOnOwn && proc.exitCode === null && !proc.killed) {
|
|
336
|
+
// Capture kernel state BEFORE the kill: once we SIGTERM, the
|
|
337
|
+
// evidence is gone. A wedged CLI writes nothing to stderr, so this
|
|
338
|
+
// is the only signal about why it is still alive.
|
|
335
339
|
log.warn("claude-adapter", "stdout closed after result but process did not exit within grace; killing wedged process", {
|
|
336
340
|
sessionId: this.sessionId,
|
|
337
341
|
pid: proc.pid,
|
|
338
342
|
graceMs: STDOUT_CLOSE_RESULT_GRACE_MS,
|
|
343
|
+
proc: captureProcState(proc.pid),
|
|
339
344
|
});
|
|
340
345
|
try {
|
|
341
346
|
proc.kill();
|
|
@@ -349,10 +354,15 @@ export class ClaudeAdapter implements IBackendAdapter {
|
|
|
349
354
|
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), STDOUT_CLOSE_GRACE_MS)),
|
|
350
355
|
]);
|
|
351
356
|
if (!exitedOnOwn && proc.exitCode === null && !proc.killed) {
|
|
357
|
+
// Same rationale as the after-result branch above: snapshot kernel
|
|
358
|
+
// state before the kill destroys it. This is the dominant wedge
|
|
359
|
+
// variant (23 of 29 observed), so it is the one most likely to
|
|
360
|
+
// carry the answer.
|
|
352
361
|
log.warn("claude-adapter", "stdout closed mid-stream and process did not exit within grace; killing wedged process", {
|
|
353
362
|
sessionId: this.sessionId,
|
|
354
363
|
pid: proc.pid,
|
|
355
364
|
graceMs: STDOUT_CLOSE_GRACE_MS,
|
|
365
|
+
proc: captureProcState(proc.pid),
|
|
356
366
|
});
|
|
357
367
|
try {
|
|
358
368
|
proc.kill();
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { captureProcState, isProcAvailable } from "./proc-diagnostics.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* These tests cover the diagnostic that runs on the wedge-kill recovery path in
|
|
6
|
+
* claude-adapter.ts. The overriding requirement is that it NEVER throws — it
|
|
7
|
+
* executes immediately before a SIGTERM that unblocks a stuck session, so a
|
|
8
|
+
* diagnostic that raises would turn a recoverable wedge into a dead session.
|
|
9
|
+
*
|
|
10
|
+
* CI runs on both ubuntu-latest and macos-latest, and macOS has no /proc at
|
|
11
|
+
* all, so every test here must pass on a platform where the feature is inert.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const isLinux = process.platform === "linux";
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("isProcAvailable", () => {
|
|
21
|
+
// /proc is a Linux-only filesystem; on macOS the whole capture must no-op.
|
|
22
|
+
it("reports availability based on platform", () => {
|
|
23
|
+
expect(isProcAvailable()).toBe(process.platform === "linux");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("captureProcState", () => {
|
|
28
|
+
it("returns no_pid when the pid is undefined", () => {
|
|
29
|
+
// Bun's Subprocess.pid is optional, so undefined is reachable in practice.
|
|
30
|
+
expect(captureProcState(undefined)).toEqual({ error: "no_pid" });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("never throws for a pid that does not exist", () => {
|
|
34
|
+
// The common race: the process exits between the liveness check and the
|
|
35
|
+
// capture. This must degrade to a marker, not an exception.
|
|
36
|
+
const snapshot = captureProcState(2_147_483_646);
|
|
37
|
+
expect(() => snapshot).not.toThrow();
|
|
38
|
+
if (isLinux) {
|
|
39
|
+
expect(snapshot.error).toBe("process_gone_or_unreadable");
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("degrades cleanly on platforms without /proc", () => {
|
|
44
|
+
// Simulate macOS regardless of the host we are running on, so this
|
|
45
|
+
// assertion is meaningful in both CI matrix legs.
|
|
46
|
+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
|
47
|
+
expect(captureProcState(1)).toEqual({ error: "unsupported_platform:darwin" });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it.runIf(isLinux)("captures live kernel state for the current process", () => {
|
|
51
|
+
// Uses our own pid, which is guaranteed to exist and be readable. This is
|
|
52
|
+
// the field set the wedge investigation actually needs: `state` and `wchan`
|
|
53
|
+
// together distinguish a process blocked on a full pipe from one parked in
|
|
54
|
+
// a futex or spinning in userspace.
|
|
55
|
+
const snapshot = captureProcState(process.pid);
|
|
56
|
+
|
|
57
|
+
expect(snapshot.error).toBeUndefined();
|
|
58
|
+
// e.g. "R (running)" or "S (sleeping)" — always parenthesised on Linux.
|
|
59
|
+
expect(snapshot.state).toMatch(/^[A-Z] \(/);
|
|
60
|
+
expect(snapshot.threads).toBeGreaterThan(0);
|
|
61
|
+
expect(snapshot.fdCount).toBeGreaterThan(0);
|
|
62
|
+
// VmRSS is reported in kB by /proc; absent only for kernel threads.
|
|
63
|
+
expect(snapshot.vmRSS).toMatch(/kB$/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it.runIf(isLinux)("returns a plain object safe to embed in a log payload", () => {
|
|
67
|
+
// The snapshot is passed straight into log.warn, which JSON-serialises it.
|
|
68
|
+
const snapshot = captureProcState(process.pid);
|
|
69
|
+
expect(() => JSON.stringify(snapshot)).not.toThrow();
|
|
70
|
+
expect(JSON.stringify(snapshot)).toContain("state");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Kernel-level process diagnostics for wedged CLI processes.
|
|
2
|
+
//
|
|
3
|
+
// When a CLI wedges, its stdout is closed and it emits nothing on stderr (see
|
|
4
|
+
// claude-adapter's wedge handling). With both output channels gone, the only
|
|
5
|
+
// remaining signal about *why* it is still alive is kernel state, so we read it
|
|
6
|
+
// from /proc immediately before killing the process.
|
|
7
|
+
//
|
|
8
|
+
// Everything here is best-effort and must never throw: this runs on a recovery
|
|
9
|
+
// path, and a diagnostic that breaks recovery is worse than no diagnostic.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
12
|
+
|
|
13
|
+
/** A point-in-time snapshot of kernel state for a process. */
|
|
14
|
+
export interface ProcSnapshot {
|
|
15
|
+
/** Scheduler state, e.g. "S (sleeping)", "R (running)", "Z (zombie)", "D (disk sleep)". */
|
|
16
|
+
state?: string;
|
|
17
|
+
/** Thread count. A live-threads-but-no-output process looks different from a zombie. */
|
|
18
|
+
threads?: number;
|
|
19
|
+
/** Resident set size as reported by /proc (e.g. "123456 kB"). */
|
|
20
|
+
vmRSS?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Kernel wait channel — the syscall the process is blocked in. This is the
|
|
23
|
+
* discriminating field: a process blocked writing to a full pipe, parked in a
|
|
24
|
+
* futex, and spinning in userspace are three different bugs.
|
|
25
|
+
*/
|
|
26
|
+
wchan?: string;
|
|
27
|
+
/** Open file descriptor count for this process. */
|
|
28
|
+
fdCount?: number;
|
|
29
|
+
/** Why the snapshot is incomplete, when it is. */
|
|
30
|
+
error?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** True when /proc-based introspection is available (Linux only). */
|
|
34
|
+
export function isProcAvailable(): boolean {
|
|
35
|
+
return process.platform === "linux";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Capture kernel state for a pid. Returns `{ error }` rather than throwing when
|
|
40
|
+
* the platform has no /proc or the process has already exited (the common race:
|
|
41
|
+
* it exits between the liveness check and this call).
|
|
42
|
+
*/
|
|
43
|
+
export function captureProcState(pid: number | undefined): ProcSnapshot {
|
|
44
|
+
if (pid === undefined) return { error: "no_pid" };
|
|
45
|
+
if (!isProcAvailable()) return { error: `unsupported_platform:${process.platform}` };
|
|
46
|
+
|
|
47
|
+
const snapshot: ProcSnapshot = {};
|
|
48
|
+
let readAnything = false;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const status = readFileSync(`/proc/${pid}/status`, "utf8");
|
|
52
|
+
readAnything = true;
|
|
53
|
+
for (const line of status.split("\n")) {
|
|
54
|
+
const [rawKey, ...rest] = line.split(":");
|
|
55
|
+
const value = rest.join(":").trim();
|
|
56
|
+
if (!value) continue;
|
|
57
|
+
if (rawKey === "State") snapshot.state = value;
|
|
58
|
+
else if (rawKey === "Threads") snapshot.threads = Number(value) || undefined;
|
|
59
|
+
else if (rawKey === "VmRSS") snapshot.vmRSS = value;
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// Process exited, or /proc entry vanished mid-read.
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
// wchan has no trailing newline and reads as "0" for a running process.
|
|
67
|
+
const wchan = readFileSync(`/proc/${pid}/wchan`, "utf8").trim();
|
|
68
|
+
readAnything = true;
|
|
69
|
+
if (wchan) snapshot.wchan = wchan;
|
|
70
|
+
} catch {
|
|
71
|
+
// Requires the process to still exist; ignore if it does not.
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
snapshot.fdCount = readdirSync(`/proc/${pid}/fd`).length;
|
|
76
|
+
readAnything = true;
|
|
77
|
+
} catch {
|
|
78
|
+
// Reading another process's fd dir can fail on permissions; not fatal.
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!readAnything) snapshot.error = "process_gone_or_unreadable";
|
|
82
|
+
return snapshot;
|
|
83
|
+
}
|