@cocorograph/hub-agent 0.5.27 → 0.5.28

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/pty-bridge.mjs +102 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.5.27",
3
+ "version": "0.5.28",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -28,6 +28,18 @@ const DEFAULT_ROWS = 32
28
28
  // に見える (1 フレーム 16.7ms より短いので体感レイテンシは増えない)。
29
29
  const DEFAULT_COALESCE_MS = 12
30
30
 
31
+ // orphan stream GC 設定。
32
+ // - DEFAULT_GC_INTERVAL_MS: GC スキャン周期 (60s)。短すぎても CPU 負担、長すぎても
33
+ // pty 枯渇に到達してから掃除されるまでに時間がかかる。1 分は妥当な balance。
34
+ // - DEFAULT_GC_STALE_MS: stream idle 判定の閾値 (10 min)。Cockpit window が
35
+ // 通常使用中は 5s 周期で session.state 等で touch されるので絶対に達しない。
36
+ // リロード時の切断 (秒〜十数秒) も無傷。何時間も放置された孤児だけ掃除される。
37
+ // ※ アクティブ stream が「10 分間まったく操作されない」= ユーザー席離れ等。
38
+ // その場合のみ kill されるが、tmux session 自体は別 process なので作業内容
39
+ // は無傷、リロードで再 attach できる。
40
+ const DEFAULT_GC_INTERVAL_MS = 60_000
41
+ const DEFAULT_GC_STALE_MS = 10 * 60_000
42
+
31
43
  function resolveBin(name) {
32
44
  try {
33
45
  return execFileSync("/usr/bin/which", [name], { encoding: "utf-8" }).trim()
@@ -53,7 +65,32 @@ export class PtyBridge extends EventEmitter {
53
65
  * `/bin/sh -c "exec tmux attach -t <sessionName>"`
54
66
  * @param {number} [opts.coalesceMs] - pty 出力を coalesce する間隔 (ms)。0 で無効。
55
67
  */
56
- constructor({ ptyModule, logger, plugins = [], defaultSpawnCommand, coalesceMs } = {}) {
68
+ constructor({
69
+ ptyModule,
70
+ logger,
71
+ plugins = [],
72
+ defaultSpawnCommand,
73
+ coalesceMs,
74
+ /**
75
+ * 孤児 stream の GC 設定。
76
+ *
77
+ * 背景: 旧設計では WS reconnect / Cockpit window リロード / タブ閉じ等で
78
+ * `pty.detach` が agent に届かないケースがあり、pty fd が leak していた。
79
+ * macOS の `kern.tty.ptmx_max` は 511 個でその上限に到達すると新規
80
+ * posix_spawnp が `EAGAIN` で失敗する (= "posix_spawnp failed" の真因)。
81
+ *
82
+ * 対処: 各 stream に `lastSeenAt` を持たせ、`attach` / `write` / `resize`
83
+ * のたびに touch する。`gcIntervalMs` 周期で `gcStaleMs` を超えた stream を
84
+ * 自動 detach する (= orphan GC)。**アクティブな stream は browser からの
85
+ * pty.data / pty.resize で常に touch されるため絶対に kill されない**。
86
+ * リロード時の短時間切断 (秒〜十数秒) も `gcStaleMs` (デフォルト 10 分)
87
+ * 未満なので無傷。何時間も放置された孤児だけ掃除される。
88
+ *
89
+ * gcStaleMs を 0 以下にすると GC を無効化する (旧挙動)。テスト・特殊用途向け。
90
+ */
91
+ gcIntervalMs,
92
+ gcStaleMs,
93
+ } = {}) {
57
94
  super()
58
95
  if (!ptyModule || typeof ptyModule.spawn !== "function") {
59
96
  throw new TypeError("PtyBridge requires { ptyModule: { spawn } }")
@@ -73,6 +110,59 @@ export class PtyBridge extends EventEmitter {
73
110
  this.streams = new Map()
74
111
  /** @type {Map<string, {buf: string, timer: ReturnType<typeof setTimeout>|null}>} */
75
112
  this.coalesceState = new Map()
113
+
114
+ // orphan GC 設定。デフォルトは 1 分周期で 10 分間 idle の stream を kill。
115
+ this.gcIntervalMs = gcIntervalMs ?? DEFAULT_GC_INTERVAL_MS
116
+ this.gcStaleMs = gcStaleMs ?? DEFAULT_GC_STALE_MS
117
+ /** @type {Map<string, number>} stream_id → lastSeenAt (ms epoch) */
118
+ this.lastSeenAt = new Map()
119
+ /** @type {ReturnType<typeof setInterval>|null} */
120
+ this._gcTimer = null
121
+ if (this.gcStaleMs > 0 && this.gcIntervalMs > 0) {
122
+ this._gcTimer = setInterval(() => this._gcStaleStreams(), this.gcIntervalMs)
123
+ // unref で Node の event loop を止めない (test 環境で hang しないため)。
124
+ if (typeof this._gcTimer.unref === "function") this._gcTimer.unref()
125
+ }
126
+ }
127
+
128
+ /**
129
+ * 現在時刻を ms epoch で返す。テストで now を差し替えるため override 可能に。
130
+ * @returns {number}
131
+ */
132
+ _now() {
133
+ return Date.now()
134
+ }
135
+
136
+ /**
137
+ * stream_id を「最近 browser からアクセスがあった」と record する。
138
+ * attach / write / resize の各 entry point から呼ぶ。
139
+ * @param {string} stream_id
140
+ */
141
+ _touch(stream_id) {
142
+ this.lastSeenAt.set(stream_id, this._now())
143
+ }
144
+
145
+ /**
146
+ * `gcStaleMs` を超えて idle な stream を一括 detach する。
147
+ *
148
+ * `_gcTimer` から `gcIntervalMs` 周期で呼ばれる。手動でも呼べる (テスト用)。
149
+ * @returns {string[]} kill した stream_id 一覧 (debug / test 用)
150
+ */
151
+ _gcStaleStreams() {
152
+ if (!(this.gcStaleMs > 0)) return []
153
+ const cutoff = this._now() - this.gcStaleMs
154
+ const killed = []
155
+ for (const [stream_id, ts] of this.lastSeenAt) {
156
+ if (ts < cutoff && this.streams.has(stream_id)) {
157
+ this.logger?.warn(
158
+ { stream_id, stale_ms: this._now() - ts, gc_stale_ms: this.gcStaleMs },
159
+ "pty GC: orphaned stream killed",
160
+ )
161
+ this.detach({ stream_id })
162
+ killed.push(stream_id)
163
+ }
164
+ }
165
+ return killed
76
166
  }
77
167
 
78
168
  /**
@@ -134,6 +224,7 @@ export class PtyBridge extends EventEmitter {
134
224
  }
135
225
 
136
226
  this.streams.set(stream_id, pty)
227
+ this._touch(stream_id)
137
228
  this.logger?.info(
138
229
  { stream_id, sessionName, command: spec.command, plugin: hookResult?.plugin || null },
139
230
  "pty attached",
@@ -164,6 +255,7 @@ export class PtyBridge extends EventEmitter {
164
255
  this._flushCoalesce(stream_id)
165
256
  this.coalesceState.delete(stream_id)
166
257
  this.streams.delete(stream_id)
258
+ this.lastSeenAt.delete(stream_id)
167
259
  this.emit("exit", { stream_id, code: exitCode })
168
260
  })
169
261
 
@@ -181,6 +273,7 @@ export class PtyBridge extends EventEmitter {
181
273
  this.logger?.warn({ stream_id }, "pty.write but stream missing")
182
274
  return false
183
275
  }
276
+ this._touch(stream_id)
184
277
  pty.write(data)
185
278
  return true
186
279
  }
@@ -192,6 +285,7 @@ export class PtyBridge extends EventEmitter {
192
285
  this.logger?.warn({ stream_id }, "pty.resize but stream missing")
193
286
  return false
194
287
  }
288
+ this._touch(stream_id)
195
289
  try {
196
290
  pty.resize(cols, rows)
197
291
  return true
@@ -208,6 +302,9 @@ export class PtyBridge extends EventEmitter {
208
302
  // kill 前に残バッファを emit しておく (onExit にも flush はあるが、
209
303
  // detach は browser 側都合なので最新を確実に届けたい)。
210
304
  this._flushCoalesce(stream_id)
305
+ // GC tracking もここでクリア。pty.onExit でも消すが、onExit が来る前に
306
+ // 同じ stream_id が再 attach されると古い lastSeenAt が残るリスクを避ける。
307
+ this.lastSeenAt.delete(stream_id)
211
308
  try {
212
309
  pty.kill()
213
310
  } catch {
@@ -218,6 +315,10 @@ export class PtyBridge extends EventEmitter {
218
315
 
219
316
  /** 全 pty を即時 kill (agent shutdown 用)。 */
220
317
  shutdown() {
318
+ if (this._gcTimer) {
319
+ clearInterval(this._gcTimer)
320
+ this._gcTimer = null
321
+ }
221
322
  for (const stream_id of Array.from(this.streams.keys())) {
222
323
  this.detach({ stream_id })
223
324
  }