@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.
- package/package.json +1 -1
- package/src/pty-bridge.mjs +102 -1
package/package.json
CHANGED
package/src/pty-bridge.mjs
CHANGED
|
@@ -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({
|
|
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
|
}
|