@cocorograph/hub-agent 0.6.38 → 0.6.40
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/main.mjs +9 -0
- package/src/shell-env.mjs +326 -0
- package/src/ws-client.mjs +41 -2
package/package.json
CHANGED
package/src/main.mjs
CHANGED
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
recordChatRateLimit,
|
|
57
57
|
} from "./usage.mjs"
|
|
58
58
|
import { getChatSignal, recordChatActivity } from "./chat-signals.mjs"
|
|
59
|
+
import { hydrateProcessEnvFromShell } from "./shell-env.mjs"
|
|
59
60
|
|
|
60
61
|
const logger = pino({ name: "hub-agent" })
|
|
61
62
|
|
|
@@ -204,6 +205,14 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
204
205
|
)
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
// launchd / systemd 起動では .zshrc 等の対話シェル設定が読まれず、~/.claude.json の
|
|
209
|
+
// MCP 定義が参照する環境変数 (例: Authorization: Bearer ${WRI_MCP_TOKEN}) が解決
|
|
210
|
+
// できない。ユーザーのログインシェル env + dotenv 層を 1 回解決して process.env を
|
|
211
|
+
// 補完する。spawn される全 Claude query が継承し、MCP トークンを出どころ
|
|
212
|
+
// (Keychain / シェル変数 / .env) を問わず引き継げる。
|
|
213
|
+
// WHY: ナレッジ/インフラ/cockpit-mcp-reauth
|
|
214
|
+
await hydrateProcessEnvFromShell({ logger })
|
|
215
|
+
|
|
207
216
|
await runHookBroadcast(plugins, "onAgentStart", ctx)
|
|
208
217
|
|
|
209
218
|
const resolvedPty = ptyModule || (await import("node-pty"))
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ユーザーのログイン + 対話シェル環境を解決して、hub-agent の `process.env` を
|
|
3
|
+
* 補完するモジュール。
|
|
4
|
+
*
|
|
5
|
+
* # なぜ必要か (WHY は Hub: ナレッジ/インフラ/cockpit-mcp-reauth)
|
|
6
|
+
* hub-agent は launchd (macOS) / systemd (Linux) から起動されるため、ユーザーの
|
|
7
|
+
* `.zshrc` / `.bashrc` 等の **対話シェル設定が一切読み込まれない**。一方 `~/.claude.json`
|
|
8
|
+
* の MCP サーバー定義は `Authorization: Bearer ${WRI_MCP_TOKEN}` のように環境変数を
|
|
9
|
+
* 参照することがあり、その値は多くの場合ユーザーの対話シェル (`.zshrc` の `export`、
|
|
10
|
+
* Keychain から解決する `export X=$(security ...)`、あるいは `.env` の source) でしか
|
|
11
|
+
* 定義されていない。結果、ターミナルで起動した Claude Code では動く MCP が、Cockpit
|
|
12
|
+
* 経由 (hub-agent が spawn する query) では `${VAR}` が空に展開され 401 → failed になる。
|
|
13
|
+
*
|
|
14
|
+
* このモジュールは「ユーザーのターミナルで使える環境変数」を 1 回だけ解決して
|
|
15
|
+
* `process.env` にマージする。spawn される全 query (control / resident / per-message) は
|
|
16
|
+
* `process.env` を継承するため、トークンの出どころ (Keychain / シェル変数 / .env) を
|
|
17
|
+
* 問わず一律にカバーできる。
|
|
18
|
+
*
|
|
19
|
+
* # 2 つの解決層
|
|
20
|
+
* 1. ログインシェル env: `$SHELL -lic 'env -0'` を実行し、NUL 区切りで env を回収する。
|
|
21
|
+
* `-i` (interactive) が必須 — `.zshrc` は interactive shell でしか読まれないため
|
|
22
|
+
* (今回の `WRI_MCP_TOKEN` がまさにこのケース)。
|
|
23
|
+
* 2. dotenv 層: `~/.claude/hub-agent.env` を読む。`.zshrc` を触りたくない人や、
|
|
24
|
+
* シェルに乗っていない値を明示したい場合の受け皿。
|
|
25
|
+
*
|
|
26
|
+
* # マージ方針
|
|
27
|
+
* - 既に `process.env` に存在するキーは上書きしない (plist / systemd で意図的に
|
|
28
|
+
* セットした PATH 等を壊さないため)。= 「欠損キーのみ補完」。
|
|
29
|
+
* - シェル固有のノイズ変数 (PWD / SHLVL / OLDPWD / _ 等) はマージしない。
|
|
30
|
+
* - 値はログに出さない (秘匿情報を含むため)。マージしたキー名のみ debug ログに出す。
|
|
31
|
+
*/
|
|
32
|
+
import { spawn } from "node:child_process"
|
|
33
|
+
import { readFile } from "node:fs/promises"
|
|
34
|
+
import os from "node:os"
|
|
35
|
+
import path from "node:path"
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* ログインシェルを起動する際に渡す最小シード env を組み立てる。
|
|
39
|
+
*
|
|
40
|
+
* rc ファイルは `$USER` / `$HOME` を前提に書かれていることが多い (例:
|
|
41
|
+
* `export TOKEN="$(security find-generic-password -a "$USER" ...)"`)。launchd /
|
|
42
|
+
* systemd 起動の hub-agent はこれらを持たないことがあるため、`os.userInfo()` から
|
|
43
|
+
* 確実に補う。親の env も渡し、launchd が与えた基礎 (PATH 等) を維持する。
|
|
44
|
+
*
|
|
45
|
+
* @returns {Record<string, string>}
|
|
46
|
+
*/
|
|
47
|
+
function buildSeedEnv() {
|
|
48
|
+
/** @type {Record<string, string>} */
|
|
49
|
+
const seed = {}
|
|
50
|
+
// 親 env を土台にする (PATH 等)。値が string のものだけ。
|
|
51
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
52
|
+
if (typeof v === "string") seed[k] = v
|
|
53
|
+
}
|
|
54
|
+
let username = ""
|
|
55
|
+
let home = process.env.HOME || ""
|
|
56
|
+
try {
|
|
57
|
+
const info = os.userInfo()
|
|
58
|
+
username = info.username || ""
|
|
59
|
+
if (!home && info.homedir) home = info.homedir
|
|
60
|
+
} catch {
|
|
61
|
+
/* ignore — fall back to env */
|
|
62
|
+
}
|
|
63
|
+
if (home) seed.HOME = home
|
|
64
|
+
if (username) {
|
|
65
|
+
if (!seed.USER) seed.USER = username
|
|
66
|
+
if (!seed.LOGNAME) seed.LOGNAME = username
|
|
67
|
+
}
|
|
68
|
+
return seed
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** シェルが必ず持つ揮発・無意味な変数。hub-agent には注入しない。 */
|
|
72
|
+
const SHELL_NOISE_KEYS = new Set([
|
|
73
|
+
"_",
|
|
74
|
+
"PWD",
|
|
75
|
+
"OLDPWD",
|
|
76
|
+
"SHLVL",
|
|
77
|
+
"SHELL",
|
|
78
|
+
"TERM",
|
|
79
|
+
"TERM_PROGRAM",
|
|
80
|
+
"TERM_PROGRAM_VERSION",
|
|
81
|
+
"TERM_SESSION_ID",
|
|
82
|
+
"COLORTERM",
|
|
83
|
+
"LINES",
|
|
84
|
+
"COLUMNS",
|
|
85
|
+
"TMPDIR",
|
|
86
|
+
"XPC_FLAGS",
|
|
87
|
+
"XPC_SERVICE_NAME",
|
|
88
|
+
"__CF_USER_TEXT_ENCODING",
|
|
89
|
+
"SECURITYSESSIONID",
|
|
90
|
+
"ZSH",
|
|
91
|
+
"ZDOTDIR",
|
|
92
|
+
])
|
|
93
|
+
|
|
94
|
+
/** ログインシェル env 解決のデフォルトタイムアウト (rc がプロンプト等で固まる保険)。 */
|
|
95
|
+
const DEFAULT_TIMEOUT_MS = Number(process.env.HUB_AGENT_SHELL_ENV_TIMEOUT_MS) || 8000
|
|
96
|
+
|
|
97
|
+
/** dotenv 層のデフォルトパス。`$HUB_AGENT_ENV_FILE` で上書き可。 */
|
|
98
|
+
function defaultDotenvPath() {
|
|
99
|
+
return (
|
|
100
|
+
process.env.HUB_AGENT_ENV_FILE ||
|
|
101
|
+
path.join(os.homedir(), ".claude", "hub-agent.env")
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `env -0` の NUL 区切り出力を `{ KEY: VALUE }` にパースする。
|
|
107
|
+
*
|
|
108
|
+
* `env -0` は各エントリを `KEY=VALUE\0` で出すため、値に改行が含まれていても安全に
|
|
109
|
+
* 分割できる (Keychain のトークンや複数行の値を壊さない)。
|
|
110
|
+
*
|
|
111
|
+
* @param {string} raw - `env -0` の標準出力
|
|
112
|
+
* @returns {Record<string, string>}
|
|
113
|
+
*/
|
|
114
|
+
export function parseNulEnv(raw) {
|
|
115
|
+
/** @type {Record<string, string>} */
|
|
116
|
+
const out = {}
|
|
117
|
+
if (!raw) return out
|
|
118
|
+
for (const entry of raw.split("\0")) {
|
|
119
|
+
if (!entry) continue
|
|
120
|
+
const eq = entry.indexOf("=")
|
|
121
|
+
if (eq <= 0) continue
|
|
122
|
+
const key = entry.slice(0, eq)
|
|
123
|
+
out[key] = entry.slice(eq + 1)
|
|
124
|
+
}
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 単純な dotenv パーサ (`KEY=VALUE` / `export KEY=VALUE` / `#` コメント / 前後空白)。
|
|
130
|
+
* クォートは前後の `'` / `"` を 1 組だけ剥がす。外部依存を持たない最小実装。
|
|
131
|
+
*
|
|
132
|
+
* @param {string} text
|
|
133
|
+
* @returns {Record<string, string>}
|
|
134
|
+
*/
|
|
135
|
+
export function parseDotenv(text) {
|
|
136
|
+
/** @type {Record<string, string>} */
|
|
137
|
+
const out = {}
|
|
138
|
+
if (!text) return out
|
|
139
|
+
for (const line of text.split(/\r?\n/)) {
|
|
140
|
+
const trimmed = line.trim()
|
|
141
|
+
if (!trimmed || trimmed.startsWith("#")) continue
|
|
142
|
+
const withoutExport = trimmed.startsWith("export ")
|
|
143
|
+
? trimmed.slice("export ".length)
|
|
144
|
+
: trimmed
|
|
145
|
+
const eq = withoutExport.indexOf("=")
|
|
146
|
+
if (eq <= 0) continue
|
|
147
|
+
const key = withoutExport.slice(0, eq).trim()
|
|
148
|
+
if (!key) continue
|
|
149
|
+
let value = withoutExport.slice(eq + 1).trim()
|
|
150
|
+
if (
|
|
151
|
+
value.length >= 2 &&
|
|
152
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
153
|
+
(value.startsWith("'") && value.endsWith("'")))
|
|
154
|
+
) {
|
|
155
|
+
value = value.slice(1, -1)
|
|
156
|
+
}
|
|
157
|
+
out[key] = value
|
|
158
|
+
}
|
|
159
|
+
return out
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* ユーザーのログイン + 対話シェルを起動して env を回収する。
|
|
164
|
+
*
|
|
165
|
+
* 失敗 (シェル不在 / 非 0 終了 / タイムアウト) 時は空オブジェクトを返す
|
|
166
|
+
* (hub-agent の起動を妨げない)。
|
|
167
|
+
*
|
|
168
|
+
* @param {object} [opts]
|
|
169
|
+
* @param {string} [opts.shell] - 使用するシェル (既定: `$SHELL` か `/bin/zsh`)
|
|
170
|
+
* @param {number} [opts.timeoutMs]
|
|
171
|
+
* @param {import('pino').Logger} [opts.logger]
|
|
172
|
+
* @returns {Promise<Record<string, string>>}
|
|
173
|
+
*/
|
|
174
|
+
export function resolveLoginShellEnv(opts = {}) {
|
|
175
|
+
const shell = opts.shell || process.env.SHELL || "/bin/zsh"
|
|
176
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
177
|
+
const logger = opts.logger
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
let child
|
|
180
|
+
try {
|
|
181
|
+
// -l (login) で .zprofile/.zlogin、-i (interactive) で .zshrc を読む。
|
|
182
|
+
// -c で env を NUL 区切り出力。`command env` で alias 干渉を避ける。
|
|
183
|
+
child = spawn(shell, ["-lic", "command env -0"], {
|
|
184
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
185
|
+
// rc 解決に必要な identity 変数 (HOME / USER / LOGNAME) を確実に渡しつつ、
|
|
186
|
+
// 親 env (launchd 由来の PATH 等) も土台として渡す。シェルは rc を評価して
|
|
187
|
+
// この上に export を積み上げる。
|
|
188
|
+
env: buildSeedEnv(),
|
|
189
|
+
})
|
|
190
|
+
} catch (err) {
|
|
191
|
+
logger?.warn?.({ err: err?.message, shell }, "login shell env spawn failed")
|
|
192
|
+
resolve({})
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let stdout = ""
|
|
197
|
+
let settled = false
|
|
198
|
+
const done = (result) => {
|
|
199
|
+
if (settled) return
|
|
200
|
+
settled = true
|
|
201
|
+
clearTimeout(timer)
|
|
202
|
+
resolve(result)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const timer = setTimeout(() => {
|
|
206
|
+
try {
|
|
207
|
+
child.kill("SIGKILL")
|
|
208
|
+
} catch {
|
|
209
|
+
/* ignore */
|
|
210
|
+
}
|
|
211
|
+
logger?.warn?.({ shell, timeoutMs }, "login shell env resolution timed out")
|
|
212
|
+
done({})
|
|
213
|
+
}, timeoutMs)
|
|
214
|
+
// hub-agent のイベントループを引き止めない。
|
|
215
|
+
timer.unref?.()
|
|
216
|
+
|
|
217
|
+
child.stdout.on("data", (chunk) => {
|
|
218
|
+
stdout += chunk.toString("utf8")
|
|
219
|
+
})
|
|
220
|
+
child.on("error", (err) => {
|
|
221
|
+
logger?.warn?.({ err: err?.message, shell }, "login shell env process error")
|
|
222
|
+
done({})
|
|
223
|
+
})
|
|
224
|
+
child.on("close", (code) => {
|
|
225
|
+
if (code !== 0) {
|
|
226
|
+
logger?.warn?.({ shell, code }, "login shell env exited non-zero")
|
|
227
|
+
done({})
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
done(parseNulEnv(stdout))
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* `~/.claude/hub-agent.env` (dotenv) を読む。存在しなければ空オブジェクト。
|
|
237
|
+
*
|
|
238
|
+
* @param {object} [opts]
|
|
239
|
+
* @param {string} [opts.file]
|
|
240
|
+
* @param {import('pino').Logger} [opts.logger]
|
|
241
|
+
* @returns {Promise<Record<string, string>>}
|
|
242
|
+
*/
|
|
243
|
+
export async function loadDotenvLayer(opts = {}) {
|
|
244
|
+
const file = opts.file || defaultDotenvPath()
|
|
245
|
+
try {
|
|
246
|
+
const text = await readFile(file, "utf-8")
|
|
247
|
+
return parseDotenv(text)
|
|
248
|
+
} catch (err) {
|
|
249
|
+
if (err?.code !== "ENOENT") {
|
|
250
|
+
opts.logger?.warn?.({ err: err?.message, file }, "hub-agent.env read failed")
|
|
251
|
+
}
|
|
252
|
+
return {}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 解決済み env を `target` (既定 `process.env`) へ「欠損キーのみ」マージする。
|
|
258
|
+
* ノイズ変数と、既に存在するキーはスキップする。
|
|
259
|
+
*
|
|
260
|
+
* @param {Record<string, string>} resolved
|
|
261
|
+
* @param {object} [opts]
|
|
262
|
+
* @param {NodeJS.ProcessEnv} [opts.target]
|
|
263
|
+
* @returns {string[]} 実際にマージしたキー名一覧
|
|
264
|
+
*/
|
|
265
|
+
export function mergeMissingEnv(resolved, opts = {}) {
|
|
266
|
+
const target = opts.target || process.env
|
|
267
|
+
/** @type {string[]} */
|
|
268
|
+
const added = []
|
|
269
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
270
|
+
if (SHELL_NOISE_KEYS.has(key)) continue
|
|
271
|
+
if (typeof value !== "string" || value === "") continue
|
|
272
|
+
// 既に存在する値は壊さない (plist/systemd で意図設定した PATH 等を保護)。
|
|
273
|
+
if (Object.prototype.hasOwnProperty.call(target, key) && target[key] != null) {
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
target[key] = value
|
|
277
|
+
added.push(key)
|
|
278
|
+
}
|
|
279
|
+
return added
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* hub-agent 起動時に 1 回だけ呼ぶエントリ。ログインシェル env と dotenv 層を解決し、
|
|
284
|
+
* `process.env` に欠損キーを補完する。これにより spawn される全 Claude query が
|
|
285
|
+
* MCP トークン (`${WRI_MCP_TOKEN}` 等) を継承できる。
|
|
286
|
+
*
|
|
287
|
+
* dotenv 層 (明示) はログインシェル (暗黙) より優先する: 両方に同じキーがあれば
|
|
288
|
+
* dotenv の値を採用する。ただし「既に process.env にある値」は両者とも上書きしない。
|
|
289
|
+
*
|
|
290
|
+
* 例外は飲み込み、起動を止めない (MCP トークンが無くても他機能は動くため)。
|
|
291
|
+
*
|
|
292
|
+
* @param {object} [opts]
|
|
293
|
+
* @param {import('pino').Logger} [opts.logger]
|
|
294
|
+
* @param {string} [opts.shell]
|
|
295
|
+
* @param {string} [opts.dotenvFile]
|
|
296
|
+
* @param {NodeJS.ProcessEnv} [opts.target]
|
|
297
|
+
* @returns {Promise<{added: string[], shellKeys: number, dotenvKeys: number}>}
|
|
298
|
+
*/
|
|
299
|
+
export async function hydrateProcessEnvFromShell(opts = {}) {
|
|
300
|
+
const logger = opts.logger
|
|
301
|
+
try {
|
|
302
|
+
const [shellEnv, dotenvEnv] = await Promise.all([
|
|
303
|
+
resolveLoginShellEnv({ shell: opts.shell, logger }),
|
|
304
|
+
loadDotenvLayer({ file: opts.dotenvFile, logger }),
|
|
305
|
+
])
|
|
306
|
+
// dotenv 層をログインシェルより優先 (明示 > 暗黙)。
|
|
307
|
+
const resolved = { ...shellEnv, ...dotenvEnv }
|
|
308
|
+
const added = mergeMissingEnv(resolved, { target: opts.target })
|
|
309
|
+
logger?.info?.(
|
|
310
|
+
{
|
|
311
|
+
added_keys: added,
|
|
312
|
+
shell_keys: Object.keys(shellEnv).length,
|
|
313
|
+
dotenv_keys: Object.keys(dotenvEnv).length,
|
|
314
|
+
},
|
|
315
|
+
"hydrated process.env from login shell + dotenv (for MCP token resolution)",
|
|
316
|
+
)
|
|
317
|
+
return {
|
|
318
|
+
added,
|
|
319
|
+
shellKeys: Object.keys(shellEnv).length,
|
|
320
|
+
dotenvKeys: Object.keys(dotenvEnv).length,
|
|
321
|
+
}
|
|
322
|
+
} catch (err) {
|
|
323
|
+
logger?.warn?.({ err: err?.message }, "hydrateProcessEnvFromShell failed (non-fatal)")
|
|
324
|
+
return { added: [], shellKeys: 0, dotenvKeys: 0 }
|
|
325
|
+
}
|
|
326
|
+
}
|
package/src/ws-client.mjs
CHANGED
|
@@ -23,6 +23,12 @@ const MIN_BACKOFF_MS = 1_000
|
|
|
23
23
|
const MAX_BACKOFF_MS = 30_000
|
|
24
24
|
const BUNDLE_WATCH_DEBOUNCE_MS = 500
|
|
25
25
|
|
|
26
|
+
// 接続が安定したとみなすまでの猶予。open 直後に即 backoff をリセットすると、
|
|
27
|
+
// open→即 close のフラッピング時に毎回 backoff が最小 (1s) へ戻り、1〜2 秒間隔の
|
|
28
|
+
// 再接続ストロボに陥る (CF/backend 側の瞬断時に hub-agent↔backend WS で観測)。
|
|
29
|
+
// この時間だけ接続を維持できて初めて backoff をリセットする。
|
|
30
|
+
const STABLE_CONNECTION_MS = 10_000
|
|
31
|
+
|
|
26
32
|
// CF / origin が 5xx を返した直後の再接続は短い backoff だと 5xx キャッシュに
|
|
27
33
|
// 当たって連発失敗するため、最低 5s 待つ。30s リトライまで段階的に伸びる。
|
|
28
34
|
const MIN_BACKOFF_AFTER_5XX_MS = 5_000
|
|
@@ -76,6 +82,9 @@ export class WsClient extends EventEmitter {
|
|
|
76
82
|
this.ptyOutboundBuffer = []
|
|
77
83
|
// 直前の close 原因が CF/origin の 5xx か。次回 backoff の下限を引き上げる。
|
|
78
84
|
this.lastCloseWas5xx = false
|
|
85
|
+
// 接続が STABLE_CONNECTION_MS 維持できたら backoff/5xx フラグをリセットする
|
|
86
|
+
// タイマー。close (_clearStableReset) でキャンセルされる。
|
|
87
|
+
this.stableResetTimer = null
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
/** WSS 接続を開始する。`stop()` まで自動で reconnect 続行。 */
|
|
@@ -104,6 +113,7 @@ export class WsClient extends EventEmitter {
|
|
|
104
113
|
})
|
|
105
114
|
|
|
106
115
|
ws.on("close", (code, reason) => {
|
|
116
|
+
this._clearStableReset()
|
|
107
117
|
this._stopHeartbeat()
|
|
108
118
|
this._stopBundleWatcher()
|
|
109
119
|
this.logger?.info({ code, reason: reason?.toString() }, "ws close")
|
|
@@ -136,8 +146,11 @@ export class WsClient extends EventEmitter {
|
|
|
136
146
|
* のクロージャだと spy しづらい)。
|
|
137
147
|
*/
|
|
138
148
|
_onOpen() {
|
|
139
|
-
|
|
140
|
-
|
|
149
|
+
// backoff の即時リセットはしない。接続が STABLE_CONNECTION_MS 維持できてから
|
|
150
|
+
// _armStableReset() でリセットする (open→即 close フラッピング時の再接続
|
|
151
|
+
// ストロボ対策)。すぐ切れた接続では backoff がリセットされず指数バックオフが
|
|
152
|
+
// 効き続けるため、1〜2 秒間隔の再接続ループに陥らない。
|
|
153
|
+
this._armStableReset()
|
|
141
154
|
this.logger?.info("ws open")
|
|
142
155
|
// 切断中に積んだ pty.data を flush。hello より先に送ると backend で stream
|
|
143
156
|
// 未登録のため unknown_stream error になる可能性があるが、hello の応答待ち
|
|
@@ -232,6 +245,7 @@ export class WsClient extends EventEmitter {
|
|
|
232
245
|
/** Reconnect を止めて切断する。 */
|
|
233
246
|
stop() {
|
|
234
247
|
this.stopped = true
|
|
248
|
+
this._clearStableReset()
|
|
235
249
|
this._stopHeartbeat()
|
|
236
250
|
this._stopBundleWatcher()
|
|
237
251
|
if (this.reconnectTimer) {
|
|
@@ -428,4 +442,29 @@ export class WsClient extends EventEmitter {
|
|
|
428
442
|
}, delay)
|
|
429
443
|
this.reconnectTimer.unref?.()
|
|
430
444
|
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* 接続が STABLE_CONNECTION_MS 維持できたら backoff と 5xx フラグをリセットする
|
|
448
|
+
* タイマーを張る (open 直後に呼ぶ)。close で _clearStableReset によりキャンセル
|
|
449
|
+
* されるため、安定しなかった (= すぐ切れた) 接続では backoff がリセットされず
|
|
450
|
+
* 指数バックオフが効き続け、再接続ストロボを防ぐ。
|
|
451
|
+
*/
|
|
452
|
+
_armStableReset() {
|
|
453
|
+
this._clearStableReset()
|
|
454
|
+
this.stableResetTimer = setTimeout(() => {
|
|
455
|
+
this.stableResetTimer = null
|
|
456
|
+
this.backoff = MIN_BACKOFF_MS
|
|
457
|
+
this.lastCloseWas5xx = false
|
|
458
|
+
this.logger?.debug("ws connection stable, backoff reset")
|
|
459
|
+
}, STABLE_CONNECTION_MS)
|
|
460
|
+
this.stableResetTimer.unref?.()
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** 安定リセットタイマーをキャンセルする (close / stop 時)。 */
|
|
464
|
+
_clearStableReset() {
|
|
465
|
+
if (this.stableResetTimer) {
|
|
466
|
+
clearTimeout(this.stableResetTimer)
|
|
467
|
+
this.stableResetTimer = null
|
|
468
|
+
}
|
|
469
|
+
}
|
|
431
470
|
}
|