@cocorograph/hub-agent 0.6.85 → 0.6.87
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/README.md +2 -2
- package/package.json +2 -2
- package/scripts/fix-node-pty-perms.mjs +45 -11
- package/scripts/install.sh +6 -4
- package/src/main.mjs +21 -2
- package/src/pty-bridge.mjs +207 -39
- package/src/ws-client.mjs +82 -5
package/README.md
CHANGED
|
@@ -86,7 +86,7 @@ hub-agent/
|
|
|
86
86
|
│ ├── config.mjs # ~/.hub/agent.json 管理
|
|
87
87
|
│ ├── enroll.mjs # enrollment フロー
|
|
88
88
|
│ ├── ws-client.mjs # outbound WSS + reconnect with jitter
|
|
89
|
-
│ ├── pty-bridge.mjs # node-pty 多重化
|
|
89
|
+
│ ├── pty-bridge.mjs # @lydell/node-pty 多重化 (fd leak 回避フォーク)
|
|
90
90
|
│ ├── tmux.mjs # tmux exec/list/create/kill
|
|
91
91
|
│ ├── state.mjs # session status / context_pct 検知
|
|
92
92
|
│ ├── skills.mjs # ~/.claude/skills 集計
|
|
@@ -98,7 +98,7 @@ hub-agent/
|
|
|
98
98
|
│ ├── co.cocorograph.hub-agent.plist
|
|
99
99
|
│ └── hub-agent.service
|
|
100
100
|
├── scripts/
|
|
101
|
-
│ └── fix-node-pty-perms.mjs # node-pty spawn-helper +x 修復 (postinstall)
|
|
101
|
+
│ └── fix-node-pty-perms.mjs # @lydell/node-pty spawn-helper +x 修復 (postinstall)
|
|
102
102
|
└── test/
|
|
103
103
|
```
|
|
104
104
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cocorograph/hub-agent",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.87",
|
|
4
4
|
"description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@anthropic-ai/claude-agent-sdk": "^0.3.158",
|
|
37
|
+
"@lydell/node-pty": "1.2.0-beta.12",
|
|
37
38
|
"commander": "^12.1.0",
|
|
38
|
-
"node-pty": "^1.0.0",
|
|
39
39
|
"pino": "^9.0.0",
|
|
40
40
|
"ws": "^8.18.0"
|
|
41
41
|
},
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// pnpm 経由インストールだと
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// pnpm 経由インストールだと prebuilds/*/spawn-helper の実行権限が剥がれて
|
|
3
|
+
// posix_spawnp が失敗する事象への workaround. postinstall で確実に +x を付与する。
|
|
4
|
+
//
|
|
5
|
+
// PTY backend は @lydell/node-pty (Microsoft 公式の fd-leak 修正フォーク) に移行済み。
|
|
6
|
+
// prebuild は @lydell/node-pty-<platform>/prebuilds/<platform>/spawn-helper に入る
|
|
7
|
+
// (npm install では +x 付きで展開されるが、pnpm 等で剥がれるケースに備えて修復する)。
|
|
8
|
+
// 旧 node-pty の配置も後方互換で走査する。
|
|
5
9
|
import { readdirSync, statSync, chmodSync } from "node:fs";
|
|
6
10
|
import { join } from "node:path";
|
|
7
11
|
|
|
8
12
|
const PNPM_GLOB = "node_modules/.pnpm";
|
|
9
|
-
|
|
13
|
+
// prebuilds ディレクトリの候補 (node-pty 旧配置 + @lydell platform パッケージ)。
|
|
14
|
+
const FALLBACK_DIRS = ["node_modules/node-pty/prebuilds"];
|
|
10
15
|
|
|
11
16
|
function fixDir(dir) {
|
|
12
17
|
let count = 0;
|
|
@@ -26,12 +31,39 @@ function fixDir(dir) {
|
|
|
26
31
|
return count;
|
|
27
32
|
}
|
|
28
33
|
|
|
34
|
+
// @lydell の platform パッケージ (@lydell/node-pty-darwin-arm64 等) を走査する。
|
|
35
|
+
function findLydellPrebuilds() {
|
|
36
|
+
const out = [];
|
|
37
|
+
const scopeDir = "node_modules/@lydell";
|
|
38
|
+
try {
|
|
39
|
+
for (const entry of readdirSync(scopeDir)) {
|
|
40
|
+
if (!entry.startsWith("node-pty-")) continue; // platform バイナリパッケージのみ
|
|
41
|
+
const p = join(scopeDir, entry, "prebuilds");
|
|
42
|
+
try {
|
|
43
|
+
statSync(p);
|
|
44
|
+
out.push(p);
|
|
45
|
+
} catch { /* prebuilds 無し */ }
|
|
46
|
+
}
|
|
47
|
+
} catch { /* @lydell scope 無し */ }
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
29
51
|
function findPnpmPrebuilds() {
|
|
30
52
|
const out = [];
|
|
31
53
|
try {
|
|
32
54
|
for (const entry of readdirSync(PNPM_GLOB)) {
|
|
33
|
-
|
|
34
|
-
|
|
55
|
+
let inner = null;
|
|
56
|
+
if (entry.startsWith("node-pty@")) {
|
|
57
|
+
// 旧 node-pty: node_modules/.pnpm/node-pty@x/node_modules/node-pty/prebuilds
|
|
58
|
+
inner = "node_modules/node-pty/prebuilds";
|
|
59
|
+
} else if (entry.startsWith("@lydell+node-pty-")) {
|
|
60
|
+
// @lydell platform: @lydell+node-pty-darwin-arm64@x → pkg 名 = node-pty-darwin-arm64
|
|
61
|
+
const pkg = entry.slice("@lydell+".length).split("@")[0];
|
|
62
|
+
inner = join("node_modules/@lydell", pkg, "prebuilds");
|
|
63
|
+
} else {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const p = join(PNPM_GLOB, entry, inner);
|
|
35
67
|
try {
|
|
36
68
|
statSync(p);
|
|
37
69
|
out.push(p);
|
|
@@ -42,13 +74,15 @@ function findPnpmPrebuilds() {
|
|
|
42
74
|
}
|
|
43
75
|
|
|
44
76
|
let fixed = 0;
|
|
45
|
-
for (const dir of findPnpmPrebuilds()) {
|
|
77
|
+
for (const dir of [...findPnpmPrebuilds(), ...findLydellPrebuilds()]) {
|
|
46
78
|
fixed += fixDir(dir);
|
|
47
79
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
80
|
+
for (const fallback of FALLBACK_DIRS) {
|
|
81
|
+
try {
|
|
82
|
+
statSync(fallback);
|
|
83
|
+
fixed += fixDir(fallback);
|
|
84
|
+
} catch { /* not hoisted */ }
|
|
85
|
+
}
|
|
52
86
|
|
|
53
87
|
if (fixed === 0) {
|
|
54
88
|
// 何も修正しなかった = 既に OK or node-pty 未インストール。サイレント終了。
|
package/scripts/install.sh
CHANGED
|
@@ -512,10 +512,12 @@ _install_node_lts_linux() {
|
|
|
512
512
|
color_ok "node $(node --version 2>/dev/null || echo '?') を導入"
|
|
513
513
|
}
|
|
514
514
|
|
|
515
|
-
#
|
|
516
|
-
# node-pty
|
|
517
|
-
#
|
|
518
|
-
#
|
|
515
|
+
# ネイティブアドオンのビルドに必要な make / g++ / python3 を導入する。
|
|
516
|
+
# PTY backend は @lydell/node-pty に移行済みで全 OS の prebuild を同梱するため通常は
|
|
517
|
+
# native ビルド不要だが、prebuild が当たらない環境では node-gyp rebuild に
|
|
518
|
+
# フォールバックする。クリーンな Ubuntu には build-essential が無く
|
|
519
|
+
# `gyp ERR! not found: make` で失敗する (WSL クリーン Ubuntu で発覚) ため保険で導入する。
|
|
520
|
+
# macOS は Xcode CLT 前提なので何もしない。
|
|
519
521
|
ensure_build_tools() {
|
|
520
522
|
[[ "$(uname)" == "Darwin" ]] && return 0
|
|
521
523
|
if present make && present g++; then
|
package/src/main.mjs
CHANGED
|
@@ -383,7 +383,12 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
383
383
|
|
|
384
384
|
await runHookBroadcast(plugins, "onAgentStart", ctx)
|
|
385
385
|
|
|
386
|
-
|
|
386
|
+
// node-pty (Microsoft 公式) は macOS arm64 で PTY master fd を spawn ごとに leak し
|
|
387
|
+
// (kill/destroy でも解放されず)、kern.tty.ptmx_max=511 到達で「新規ターミナルが
|
|
388
|
+
// 開けない」障害を起こした (2026-06-16)。node 20/22/24 全てで再現。API 互換の保守
|
|
389
|
+
// フォーク @lydell/node-pty は同ケースで leak ゼロ + 全 OS prebuilt 同梱なので
|
|
390
|
+
// 差し替えた。WHY 詳細: Hub log/2026-06-16/0041_cockpit。
|
|
391
|
+
const resolvedPty = ptyModule || (await import("@lydell/node-pty"))
|
|
387
392
|
const ptyBridge = new PtyBridge({ ptyModule: resolvedPty, logger, plugins })
|
|
388
393
|
|
|
389
394
|
// Claude SDK は optional dep 扱い (未インストール時は stream モードのみ無効化)。
|
|
@@ -1132,6 +1137,20 @@ export function handleTrackedPtyData(msg, ctx) {
|
|
|
1132
1137
|
}
|
|
1133
1138
|
}
|
|
1134
1139
|
|
|
1140
|
+
/**
|
|
1141
|
+
* 無印 (input_id 無し / fire-and-forget) pty.data の処理。raw 打鍵 (term.onData) が
|
|
1142
|
+
* これを通る。stream 不在 (reap 済み等) なら pty.error を返して browser を再 attach →
|
|
1143
|
+
* 自己修復させる (T-6)。従来は write の戻り値を見ずに捨てていたため、reap 済み stream
|
|
1144
|
+
* への打鍵が無言ドロップされていた。tracked 経路 (handleTrackedPtyData) と対称。
|
|
1145
|
+
* テストから直接呼べるよう dispatch から切り出して export する。
|
|
1146
|
+
*/
|
|
1147
|
+
export function handleUntrackedPtyData(msg, ctx) {
|
|
1148
|
+
const { stream_id } = msg
|
|
1149
|
+
if (!ctx.ptyBridge.write({ stream_id, data: msg.data })) {
|
|
1150
|
+
ctx.client.send({ type: "pty.error", stream_id, error: "stream_missing" })
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1135
1154
|
async function dispatch(msg, ctx) {
|
|
1136
1155
|
const t = msg?.type || ""
|
|
1137
1156
|
try {
|
|
@@ -1198,7 +1217,7 @@ async function dispatch(msg, ctx) {
|
|
|
1198
1217
|
handleTrackedPtyData(msg, ctx)
|
|
1199
1218
|
return
|
|
1200
1219
|
}
|
|
1201
|
-
|
|
1220
|
+
handleUntrackedPtyData(msg, ctx)
|
|
1202
1221
|
return
|
|
1203
1222
|
case "pty.resize":
|
|
1204
1223
|
ctx.ptyBridge.resize({
|
package/src/pty-bridge.mjs
CHANGED
|
@@ -40,6 +40,12 @@ const DEFAULT_COALESCE_MS = 12
|
|
|
40
40
|
const DEFAULT_GC_INTERVAL_MS = 60_000
|
|
41
41
|
const DEFAULT_GC_STALE_MS = 10 * 60_000
|
|
42
42
|
|
|
43
|
+
// 安全弁: 同時 attach できる pty の上限本数。万一リークが残っても macOS の
|
|
44
|
+
// `kern.tty.ptmx_max` (=511) に到達する前に最古の stream を強制 reap して枯渇を
|
|
45
|
+
// 防ぐ (504/511 まで溜めて新規ターミナルが開けなくなった 2026-06-16 障害の再発防止)。
|
|
46
|
+
// 0 以下で無効化 (テスト用)。
|
|
47
|
+
const DEFAULT_MAX_STREAMS = 256
|
|
48
|
+
|
|
43
49
|
function resolveBin(name) {
|
|
44
50
|
try {
|
|
45
51
|
return execFileSync("/usr/bin/which", [name], { encoding: "utf-8" }).trim()
|
|
@@ -80,9 +86,11 @@ export class PtyBridge extends EventEmitter {
|
|
|
80
86
|
* posix_spawnp が `EAGAIN` で失敗する (= "posix_spawnp failed" の真因)。
|
|
81
87
|
*
|
|
82
88
|
* 対処: 各 stream に `lastSeenAt` を持たせ、`attach` / `write` / `resize`
|
|
83
|
-
* のたびに touch する。`gcIntervalMs` 周期で
|
|
84
|
-
*
|
|
85
|
-
* pty.data / pty.resize
|
|
89
|
+
* および **pty 出力 (onData)** のたびに touch する。`gcIntervalMs` 周期で
|
|
90
|
+
* `gcStaleMs` を超えた stream を自動 detach する (= orphan GC)。**アクティブな
|
|
91
|
+
* stream は browser からの pty.data / pty.resize、または claude の出力で常に
|
|
92
|
+
* touch されるため絶対に kill されない** (生成中で出力は流れるが入力が間遠い
|
|
93
|
+
* 閲覧中の TUI チャットが reap される A-4 を出力 touch で塞ぐ)。
|
|
86
94
|
* リロード時の短時間切断 (秒〜十数秒) も `gcStaleMs` (デフォルト 10 分)
|
|
87
95
|
* 未満なので無傷。何時間も放置された孤児だけ掃除される。
|
|
88
96
|
*
|
|
@@ -90,6 +98,11 @@ export class PtyBridge extends EventEmitter {
|
|
|
90
98
|
*/
|
|
91
99
|
gcIntervalMs,
|
|
92
100
|
gcStaleMs,
|
|
101
|
+
/**
|
|
102
|
+
* 同時 attach 上限 (安全弁)。超過すると新規 attach 前に最古の stream を強制
|
|
103
|
+
* reap する。リーク回帰があっても PTY 枯渇に至らせないためのハードリミット。
|
|
104
|
+
*/
|
|
105
|
+
maxStreams,
|
|
93
106
|
} = {}) {
|
|
94
107
|
super()
|
|
95
108
|
if (!ptyModule || typeof ptyModule.spawn !== "function") {
|
|
@@ -106,10 +119,21 @@ export class PtyBridge extends EventEmitter {
|
|
|
106
119
|
env: process.env,
|
|
107
120
|
}))
|
|
108
121
|
this.coalesceMs = coalesceMs ?? DEFAULT_COALESCE_MS
|
|
122
|
+
this.maxStreams = maxStreams ?? DEFAULT_MAX_STREAMS
|
|
109
123
|
/** @type {Map<string, import('node-pty').IPty>} */
|
|
110
124
|
this.streams = new Map()
|
|
111
125
|
/** @type {Map<string, {buf: string, timer: ReturnType<typeof setTimeout>|null}>} */
|
|
112
126
|
this.coalesceState = new Map()
|
|
127
|
+
/**
|
|
128
|
+
* stream_id → onData/onExit の IDisposable[]。
|
|
129
|
+
* reap 時に dispose() しないと listener が IPty を参照し続け、master fd が
|
|
130
|
+
* close されず leak する (= ユーザー指摘「リスナ経由の参照残り」)。
|
|
131
|
+
* @type {Map<string, Array<{dispose: () => void}>>}
|
|
132
|
+
*/
|
|
133
|
+
this.disposables = new Map()
|
|
134
|
+
// 可観測性: 起動からの累計 spawn / reap 数。差分 (= held) が単調増加したら leak。
|
|
135
|
+
this._spawnedTotal = 0
|
|
136
|
+
this._reapedTotal = 0
|
|
113
137
|
|
|
114
138
|
// orphan GC 設定。デフォルトは 1 分周期で 10 分間 idle の stream を kill。
|
|
115
139
|
this.gcIntervalMs = gcIntervalMs ?? DEFAULT_GC_INTERVAL_MS
|
|
@@ -119,7 +143,10 @@ export class PtyBridge extends EventEmitter {
|
|
|
119
143
|
/** @type {ReturnType<typeof setInterval>|null} */
|
|
120
144
|
this._gcTimer = null
|
|
121
145
|
if (this.gcStaleMs > 0 && this.gcIntervalMs > 0) {
|
|
122
|
-
this._gcTimer = setInterval(() =>
|
|
146
|
+
this._gcTimer = setInterval(() => {
|
|
147
|
+
this._gcStaleStreams()
|
|
148
|
+
this._logStats()
|
|
149
|
+
}, this.gcIntervalMs)
|
|
123
150
|
// unref で Node の event loop を止めない (test 環境で hang しないため)。
|
|
124
151
|
if (typeof this._gcTimer.unref === "function") this._gcTimer.unref()
|
|
125
152
|
}
|
|
@@ -143,28 +170,127 @@ export class PtyBridge extends EventEmitter {
|
|
|
143
170
|
}
|
|
144
171
|
|
|
145
172
|
/**
|
|
146
|
-
* `gcStaleMs` を超えて idle な stream を一括
|
|
173
|
+
* `gcStaleMs` を超えて idle な stream を一括 reap する。
|
|
147
174
|
*
|
|
148
175
|
* `_gcTimer` から `gcIntervalMs` 周期で呼ばれる。手動でも呼べる (テスト用)。
|
|
149
|
-
*
|
|
176
|
+
*
|
|
177
|
+
* ⚠️ 走査対象は `this.streams` (= 実際に fd を保持している正本)。旧実装は
|
|
178
|
+
* `lastSeenAt` を走査していたが、`detach` が kill 前に `lastSeenAt` を消す一方で
|
|
179
|
+
* `streams` からの削除を `onExit` 頼みにしていたため、`onExit` が取りこぼされると
|
|
180
|
+
* 「streams に残る・lastSeenAt は無い」孤児が GC から不可視になり永久 leak した
|
|
181
|
+
* (2026-06-16 障害の主因)。streams を正本に走査することでこの不可視孤児を根絶する。
|
|
182
|
+
* @returns {string[]} reap した stream_id 一覧 (debug / test 用)
|
|
150
183
|
*/
|
|
151
184
|
_gcStaleStreams() {
|
|
152
185
|
if (!(this.gcStaleMs > 0)) return []
|
|
153
186
|
const cutoff = this._now() - this.gcStaleMs
|
|
154
187
|
const killed = []
|
|
155
|
-
for (const
|
|
156
|
-
|
|
188
|
+
for (const stream_id of Array.from(this.streams.keys())) {
|
|
189
|
+
// lastSeenAt 欠落 (= 不可視孤児) は 0 扱い = 即 stale 判定で確実に回収する。
|
|
190
|
+
const ts = this.lastSeenAt.get(stream_id) ?? 0
|
|
191
|
+
if (ts < cutoff) {
|
|
157
192
|
this.logger?.warn(
|
|
158
193
|
{ stream_id, stale_ms: this._now() - ts, gc_stale_ms: this.gcStaleMs },
|
|
159
194
|
"pty GC: orphaned stream killed",
|
|
160
195
|
)
|
|
161
|
-
this.
|
|
196
|
+
this._reap(stream_id, { emitExit: true, code: null })
|
|
162
197
|
killed.push(stream_id)
|
|
163
198
|
}
|
|
164
199
|
}
|
|
165
200
|
return killed
|
|
166
201
|
}
|
|
167
202
|
|
|
203
|
+
/**
|
|
204
|
+
* 1 つの stream の後始末を一元化する (= 全リーク経路の単一出口)。
|
|
205
|
+
*
|
|
206
|
+
* 正常終了 (onExit) / browser detach / GC / 上限超過 / shutdown の全経路がここを
|
|
207
|
+
* 通る。冪等: 既に reap 済みなら何もしない (false を返す)。
|
|
208
|
+
*
|
|
209
|
+
* 順序が重要:
|
|
210
|
+
* 1. `streams` から **最初に** 削除する。`pty.kill()` が onExit を同期発火させる
|
|
211
|
+
* 実装 (テスト stub / 一部の node-pty 経路) で onExit ハンドラが再入しても、
|
|
212
|
+
* その時点で stream が消えているので二重 emit / 二重カウントを防げる。
|
|
213
|
+
* 2. 残バッファを flush (取りこぼし防止)。
|
|
214
|
+
* 3. onData/onExit listener を dispose (参照残りによる fd 保持を断つ)。
|
|
215
|
+
* 4. 補助 Map をクリアし、最後に `pty.kill()` で master fd を解放する。
|
|
216
|
+
*
|
|
217
|
+
* @param {string} stream_id
|
|
218
|
+
* @param {{emitExit?: boolean, code?: number|null}} [opts]
|
|
219
|
+
* @returns {boolean} 実際に reap したら true
|
|
220
|
+
*/
|
|
221
|
+
_reap(stream_id, { emitExit = false, code = null } = {}) {
|
|
222
|
+
const existed = this.streams.has(stream_id)
|
|
223
|
+
const pty = this.streams.get(stream_id)
|
|
224
|
+
// (1) streams から最初に外す → 以降の同期 onExit 再入は no-op。
|
|
225
|
+
this.streams.delete(stream_id)
|
|
226
|
+
// (2) 残バッファを取りこぼさない。
|
|
227
|
+
this._flushCoalesce(stream_id)
|
|
228
|
+
// (3) listener を dispose (node-pty の onData/onExit は IDisposable を返す)。
|
|
229
|
+
const disps = this.disposables.get(stream_id)
|
|
230
|
+
if (disps) {
|
|
231
|
+
for (const d of disps) {
|
|
232
|
+
try {
|
|
233
|
+
d?.dispose?.()
|
|
234
|
+
} catch {
|
|
235
|
+
/* ignore */
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
this.disposables.delete(stream_id)
|
|
240
|
+
this.coalesceState.delete(stream_id)
|
|
241
|
+
this.lastSeenAt.delete(stream_id)
|
|
242
|
+
// (4) master fd を解放。
|
|
243
|
+
// ⚠️ `pty.kill()` は子プロセスに SIGHUP を送るだけで、master fd を握る
|
|
244
|
+
// 内部 socket を破棄しない。macOS では子終了後に socket の 'close' が
|
|
245
|
+
// 発火せず fd が残り続ける → これが 2026-06-16 の PTY 枯渇 (504/511) の真因。
|
|
246
|
+
// node-pty UnixTerminal の `destroy()` は `_socket.destroy()` で fd を即時
|
|
247
|
+
// close してから SIGHUP を送るので、こちらを使う (公開 typings に無い内部 API
|
|
248
|
+
// のため feature-detect。Windows 等で未定義なら kill にフォールバック)。
|
|
249
|
+
if (pty) {
|
|
250
|
+
try {
|
|
251
|
+
if (typeof pty.destroy === "function") pty.destroy()
|
|
252
|
+
else pty.kill()
|
|
253
|
+
} catch {
|
|
254
|
+
/* already dead / socket already closed */
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (existed) {
|
|
258
|
+
this._reapedTotal += 1
|
|
259
|
+
if (emitExit) this.emit("exit", { stream_id, code })
|
|
260
|
+
}
|
|
261
|
+
return existed
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 現在保持している pty 本数と累計 spawn/reap をログ出力する (可観測性)。
|
|
266
|
+
* `leak_delta` は理論上常に 0 (held === spawned - reaped)。非 0 が続いたら異常。
|
|
267
|
+
*/
|
|
268
|
+
_logStats() {
|
|
269
|
+
const held = this.streams.size
|
|
270
|
+
this.logger?.info(
|
|
271
|
+
{
|
|
272
|
+
held,
|
|
273
|
+
spawned_total: this._spawnedTotal,
|
|
274
|
+
reaped_total: this._reapedTotal,
|
|
275
|
+
leak_delta: this._spawnedTotal - this._reapedTotal - held,
|
|
276
|
+
max_streams: this.maxStreams,
|
|
277
|
+
},
|
|
278
|
+
"pty stats",
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 現在の保持本数・累計カウンタを返す (テスト / debug 用)。
|
|
284
|
+
* @returns {{held: number, spawnedTotal: number, reapedTotal: number}}
|
|
285
|
+
*/
|
|
286
|
+
stats() {
|
|
287
|
+
return {
|
|
288
|
+
held: this.streams.size,
|
|
289
|
+
spawnedTotal: this._spawnedTotal,
|
|
290
|
+
reapedTotal: this._reapedTotal,
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
168
294
|
/**
|
|
169
295
|
* stream の coalesce buffer を即時 flush して emit する。
|
|
170
296
|
* detach / exit / shutdown のタイミングで残バッファの取りこぼしを防ぐ。
|
|
@@ -195,6 +321,26 @@ export class PtyBridge extends EventEmitter {
|
|
|
195
321
|
if (this.streams.has(stream_id)) {
|
|
196
322
|
throw new Error(`stream_id "${stream_id}" は既に attach 済みです`)
|
|
197
323
|
}
|
|
324
|
+
// 安全弁: 上限到達時は最古 (lastSeenAt 最小) の stream を強制 reap してから
|
|
325
|
+
// spawn する。リークが残っても PTY 枯渇に至らせないハードリミット。
|
|
326
|
+
if (this.maxStreams > 0 && this.streams.size >= this.maxStreams) {
|
|
327
|
+
let oldestId = null
|
|
328
|
+
let oldestTs = Infinity
|
|
329
|
+
for (const sid of this.streams.keys()) {
|
|
330
|
+
const ts = this.lastSeenAt.get(sid) ?? 0
|
|
331
|
+
if (ts < oldestTs) {
|
|
332
|
+
oldestTs = ts
|
|
333
|
+
oldestId = sid
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (oldestId) {
|
|
337
|
+
this.logger?.warn(
|
|
338
|
+
{ stream_id, evicted: oldestId, held: this.streams.size, max_streams: this.maxStreams },
|
|
339
|
+
"pty cap exceeded: reaping oldest stream",
|
|
340
|
+
)
|
|
341
|
+
this._reap(oldestId, { emitExit: true, code: null })
|
|
342
|
+
}
|
|
343
|
+
}
|
|
198
344
|
const ctx = {
|
|
199
345
|
logger: this.logger,
|
|
200
346
|
sessionName,
|
|
@@ -224,40 +370,58 @@ export class PtyBridge extends EventEmitter {
|
|
|
224
370
|
}
|
|
225
371
|
|
|
226
372
|
this.streams.set(stream_id, pty)
|
|
373
|
+
this._spawnedTotal += 1
|
|
227
374
|
this._touch(stream_id)
|
|
228
375
|
this.logger?.info(
|
|
229
376
|
{ stream_id, sessionName, command: spec.command, plugin: hookResult?.plugin || null },
|
|
230
377
|
"pty attached",
|
|
231
378
|
)
|
|
232
379
|
|
|
380
|
+
// onData/onExit は IDisposable を返す。reap 時に dispose() するため保持する。
|
|
381
|
+
const disposables = []
|
|
233
382
|
if (this.coalesceMs > 0) {
|
|
234
383
|
const state = { buf: "", timer: null }
|
|
235
384
|
this.coalesceState.set(stream_id, state)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
385
|
+
disposables.push(
|
|
386
|
+
pty.onData((data) => {
|
|
387
|
+
// 出力でも lastSeenAt を touch する (A-4 修正)。orphan GC は lastSeenAt が
|
|
388
|
+
// gcStaleMs 古い stream を reap するが、従来は write/resize/attach でしか
|
|
389
|
+
// touch していなかったため「生成中で出力は流れているが入力が間遠い閲覧中の
|
|
390
|
+
// TUI チャット」が 10 分で reap され、以後の送信が stream_missing で落ちていた
|
|
391
|
+
// (本番 agent.log で reaped_total 多数 + 同 input_id の再送 dedup を観測)。
|
|
392
|
+
// 出力が流れている = まだ生きて使われている stream なので touch して延命する。
|
|
393
|
+
// 真にブラウザが去った stream は claude がターン完了後に redraw を止めて出力が
|
|
394
|
+
// 止まり、gcStaleMs 経過で reap される。万一の runaway 出力 orphan は maxStreams
|
|
395
|
+
// 上限の最古 reap が backstop になる。
|
|
396
|
+
this._touch(stream_id)
|
|
397
|
+
state.buf += data
|
|
398
|
+
if (!state.timer) {
|
|
399
|
+
state.timer = setTimeout(() => {
|
|
400
|
+
state.timer = null
|
|
401
|
+
const out = state.buf
|
|
402
|
+
if (!out) return
|
|
403
|
+
state.buf = ""
|
|
404
|
+
this.emit("output", { stream_id, data: out })
|
|
405
|
+
}, this.coalesceMs)
|
|
406
|
+
}
|
|
407
|
+
}),
|
|
408
|
+
)
|
|
248
409
|
} else {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
410
|
+
disposables.push(
|
|
411
|
+
pty.onData((data) => {
|
|
412
|
+
// 出力でも touch (A-4 修正)。理由は上の coalesce 経路のコメント参照。
|
|
413
|
+
this._touch(stream_id)
|
|
414
|
+
this.emit("output", { stream_id, data })
|
|
415
|
+
}),
|
|
416
|
+
)
|
|
252
417
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
})
|
|
418
|
+
// 正常終了経路。全後始末を _reap に一元化 (flush → dispose → fd 解放 → exit emit)。
|
|
419
|
+
disposables.push(
|
|
420
|
+
pty.onExit(({ exitCode }) => {
|
|
421
|
+
this._reap(stream_id, { emitExit: true, code: exitCode })
|
|
422
|
+
}),
|
|
423
|
+
)
|
|
424
|
+
this.disposables.set(stream_id, disposables)
|
|
261
425
|
|
|
262
426
|
return {
|
|
263
427
|
plugin: hookResult?.plugin || null,
|
|
@@ -299,16 +463,20 @@ export class PtyBridge extends EventEmitter {
|
|
|
299
463
|
detach({ stream_id }) {
|
|
300
464
|
const pty = this.streams.get(stream_id)
|
|
301
465
|
if (!pty) return false
|
|
302
|
-
// kill 前に残バッファを emit しておく (
|
|
303
|
-
//
|
|
466
|
+
// kill 前に残バッファを emit しておく (detach は browser 側都合なので最新を
|
|
467
|
+
// 確実に届けたい)。
|
|
304
468
|
this._flushCoalesce(stream_id)
|
|
305
|
-
// GC tracking もここでクリア。pty.onExit でも消すが、onExit が来る前に
|
|
306
|
-
// 同じ stream_id が再 attach されると古い lastSeenAt が残るリスクを避ける。
|
|
307
|
-
this.lastSeenAt.delete(stream_id)
|
|
308
469
|
try {
|
|
309
470
|
pty.kill()
|
|
310
471
|
} catch {
|
|
311
|
-
/* ignore
|
|
472
|
+
/* ignore */
|
|
473
|
+
}
|
|
474
|
+
// kill が onExit を同期発火する実装 (stub 等) では上の kill 内で _reap 済み。
|
|
475
|
+
// 実 node-pty は onExit が非同期なので、onExit を待たず即 reap して master fd を
|
|
476
|
+
// 解放する (= onExit 取りこぼし時の永久 leak を防ぐ核心)。_reap は冪等なので、
|
|
477
|
+
// 後から非同期 onExit が来ても no-op になる。
|
|
478
|
+
if (this.streams.has(stream_id)) {
|
|
479
|
+
this._reap(stream_id, { emitExit: true, code: null })
|
|
312
480
|
}
|
|
313
481
|
return true
|
|
314
482
|
}
|
|
@@ -320,7 +488,7 @@ export class PtyBridge extends EventEmitter {
|
|
|
320
488
|
this._gcTimer = null
|
|
321
489
|
}
|
|
322
490
|
for (const stream_id of Array.from(this.streams.keys())) {
|
|
323
|
-
this.
|
|
491
|
+
this._reap(stream_id, { emitExit: true, code: null })
|
|
324
492
|
}
|
|
325
493
|
}
|
|
326
494
|
|
package/src/ws-client.mjs
CHANGED
|
@@ -58,6 +58,31 @@ const PTY_BUFFER_MAX_FRAMES = 500
|
|
|
58
58
|
// 30 秒以上経過した pty.data は flush 時に破棄する。
|
|
59
59
|
const PTY_BUFFER_MAX_AGE_MS = 30_000
|
|
60
60
|
|
|
61
|
+
// agent→browser の制御/イベント系メッセージのうち、WS not OPEN 中に捨てると
|
|
62
|
+
// Cockpit の挙動が壊れるものを reconnect 越しに保持する (機構③: 本番 agent.log で
|
|
63
|
+
// `claude.jsonl.event ... "ws send skipped (not open)"` のドロップを観測)。
|
|
64
|
+
// - pty.input.ack: 喪失すると browser の pendingAcks が永久滞留し「送信待ち(未配信)」が
|
|
65
|
+
// 消えない (S1/S3)。再接続 flush で遅れて届けば即解消する。
|
|
66
|
+
// - claude.jsonl.event / claude.event / session.event: 喪失すると新規セッションの
|
|
67
|
+
// 楽観バブルが本物へ昇格できず「生成は始まったがバブルが出ない」(S5)。
|
|
68
|
+
// - pty.exit / pty.ready / pty.error: stream ライフサイクルの節目。落とすと browser の
|
|
69
|
+
// 再 attach/復帰判断が狂う。
|
|
70
|
+
// pty.data とは別バッファにするのは、高頻度な pty.data の洪水で低頻度な ack/event が
|
|
71
|
+
// リング evict されるのを防ぐため (pty.data は冪等で最新フレームだけ届けば良いが、
|
|
72
|
+
// ack/event は 1 件ずつ意味を持つ)。
|
|
73
|
+
const BUFFERED_CTRL_TYPES = new Set([
|
|
74
|
+
"pty.input.ack",
|
|
75
|
+
"pty.exit",
|
|
76
|
+
"pty.ready",
|
|
77
|
+
"pty.error",
|
|
78
|
+
"claude.jsonl.event",
|
|
79
|
+
"claude.event",
|
|
80
|
+
"session.event",
|
|
81
|
+
])
|
|
82
|
+
// 制御/イベントバッファの上限。低頻度なので frames は小さめ、age は pty と揃える。
|
|
83
|
+
const CTRL_BUFFER_MAX_FRAMES = 200
|
|
84
|
+
const CTRL_BUFFER_MAX_AGE_MS = 30_000
|
|
85
|
+
|
|
61
86
|
export class WsClient extends EventEmitter {
|
|
62
87
|
/**
|
|
63
88
|
* @param {{ hub_url: string, agent_id: string, agent_token: string }} config
|
|
@@ -94,6 +119,9 @@ export class WsClient extends EventEmitter {
|
|
|
94
119
|
// pty.data の outbound buffer (WS not OPEN 中に積み、open 後 flush)。
|
|
95
120
|
// entry: { obj, ts }
|
|
96
121
|
this.ptyOutboundBuffer = []
|
|
122
|
+
// 制御/イベント系 (ack / jsonl.event 等) の outbound buffer。pty.data とは別管理で、
|
|
123
|
+
// 高頻度 pty.data の洪水に evict されないようにする (機構③)。entry: { obj, ts }
|
|
124
|
+
this.ctrlOutboundBuffer = []
|
|
97
125
|
// 直前の close 原因が CF/origin の 5xx か。次回 backoff の下限を引き上げる。
|
|
98
126
|
this.lastCloseWas5xx = false
|
|
99
127
|
// 接続が STABLE_CONNECTION_MS 維持できたら backoff/5xx フラグをリセットする
|
|
@@ -203,23 +231,30 @@ export class WsClient extends EventEmitter {
|
|
|
203
231
|
request_id: randomUUID(),
|
|
204
232
|
})
|
|
205
233
|
this._flushPtyBuffer()
|
|
234
|
+
this._flushCtrlBuffer()
|
|
206
235
|
this._startHeartbeat()
|
|
207
236
|
this._startBundleWatcher()
|
|
208
237
|
this.emit("open")
|
|
209
238
|
}
|
|
210
239
|
|
|
211
|
-
/** メッセージを送る。未接続時は pty.data
|
|
240
|
+
/** メッセージを送る。未接続時は pty.data と制御/イベント系 (ack / jsonl.event 等) を
|
|
241
|
+
* buffer に積み、reconnect 後に flush する。
|
|
212
242
|
*
|
|
213
|
-
* heartbeat / hello / agent.streams.sync.*
|
|
214
|
-
* buffer しない (warn のみ)。
|
|
243
|
+
* heartbeat / hello / agent.streams.sync.* など「古くなると意味が無い」制御系は
|
|
244
|
+
* buffer しない (warn のみ)。ack / event 系は 1 件ずつ意味を持つので別バッファに保持する。
|
|
215
245
|
*/
|
|
216
246
|
send(obj) {
|
|
217
247
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
218
|
-
|
|
248
|
+
const type = obj?.type
|
|
249
|
+
if (type === "pty.data") {
|
|
219
250
|
this._bufferPtyData(obj)
|
|
220
251
|
return false
|
|
221
252
|
}
|
|
222
|
-
|
|
253
|
+
if (BUFFERED_CTRL_TYPES.has(type)) {
|
|
254
|
+
this._bufferCtrl(obj)
|
|
255
|
+
return false
|
|
256
|
+
}
|
|
257
|
+
this.logger?.warn({ type }, "ws send skipped (not open)")
|
|
223
258
|
return false
|
|
224
259
|
}
|
|
225
260
|
return this._sendJson(obj)
|
|
@@ -269,6 +304,48 @@ export class WsClient extends EventEmitter {
|
|
|
269
304
|
)
|
|
270
305
|
}
|
|
271
306
|
|
|
307
|
+
/** 制御/イベント系 (ack / jsonl.event 等) を outbound buffer に積む。リング (drop oldest)。 */
|
|
308
|
+
_bufferCtrl(obj) {
|
|
309
|
+
this.ctrlOutboundBuffer.push({ obj, ts: Date.now() })
|
|
310
|
+
if (this.ctrlOutboundBuffer.length > CTRL_BUFFER_MAX_FRAMES) {
|
|
311
|
+
const dropped = this.ctrlOutboundBuffer.length - CTRL_BUFFER_MAX_FRAMES
|
|
312
|
+
this.ctrlOutboundBuffer.splice(0, dropped)
|
|
313
|
+
this.logger?.warn(
|
|
314
|
+
{ dropped, kept: this.ctrlOutboundBuffer.length },
|
|
315
|
+
"ctrl outbound buffer overflow (oldest dropped)"
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** open 直後に制御/イベントバッファを flush。古すぎる entry は破棄する。
|
|
321
|
+
* 構造は _flushPtyBuffer と同じ (退避 → 期限切れ skip → 送信失敗で残りを順序保持で戻す)。
|
|
322
|
+
*/
|
|
323
|
+
_flushCtrlBuffer() {
|
|
324
|
+
if (this.ctrlOutboundBuffer.length === 0) return
|
|
325
|
+
const now = Date.now()
|
|
326
|
+
const buf = this.ctrlOutboundBuffer
|
|
327
|
+
this.ctrlOutboundBuffer = []
|
|
328
|
+
let sent = 0
|
|
329
|
+
let expired = 0
|
|
330
|
+
for (let i = 0; i < buf.length; i++) {
|
|
331
|
+
const entry = buf[i]
|
|
332
|
+
if (now - entry.ts > CTRL_BUFFER_MAX_AGE_MS) {
|
|
333
|
+
expired += 1
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
const ok = this._sendJson(entry.obj)
|
|
337
|
+
if (!ok) {
|
|
338
|
+
this.ctrlOutboundBuffer = buf.slice(i).concat(this.ctrlOutboundBuffer)
|
|
339
|
+
break
|
|
340
|
+
}
|
|
341
|
+
sent += 1
|
|
342
|
+
}
|
|
343
|
+
this.logger?.info(
|
|
344
|
+
{ sent, expired, total: buf.length, requeued: this.ctrlOutboundBuffer.length },
|
|
345
|
+
"ctrl outbound buffer flushed"
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
272
349
|
/** Reconnect を止めて切断する。 */
|
|
273
350
|
stop() {
|
|
274
351
|
this.stopped = true
|