@cocorograph/hub-agent 0.6.65 → 0.6.67
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/chat-signals.mjs +29 -3
- package/src/main.mjs +72 -3
- package/src/state.mjs +60 -2
- package/src/ws-client.mjs +53 -0
package/package.json
CHANGED
package/src/chat-signals.mjs
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* プロセス)、サイドバー行 (= tmux セッション) とは cwd で対応するため。
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
// cwd → { status, context_pct, turnAt, updatedAtMs }
|
|
26
|
+
// cwd → { status, context_pct, statusAt, inputPending, turnAt, updatedAtMs }
|
|
27
27
|
const _byCwd = new Map()
|
|
28
28
|
|
|
29
29
|
// チャット信号を「生きている」と見なす最大経過時間 (ms)。これを過ぎたら tmux
|
|
@@ -48,23 +48,32 @@ function _now() {
|
|
|
48
48
|
*
|
|
49
49
|
* @param {string} cwd セッションの作業ディレクトリ
|
|
50
50
|
* @param {{ status?: string, contextPct?: number|null, inputPending?: boolean }} patch
|
|
51
|
+
* @param {number} [now] テスト用の時刻注入 (省略時は Date.now())
|
|
51
52
|
*/
|
|
52
|
-
export function recordChatActivity(cwd, patch = {}) {
|
|
53
|
+
export function recordChatActivity(cwd, patch = {}, now = _now()) {
|
|
53
54
|
if (!cwd || typeof cwd !== "string") return
|
|
54
|
-
const now = _now()
|
|
55
55
|
const prev = _byCwd.get(cwd) || {
|
|
56
56
|
status: null,
|
|
57
57
|
context_pct: null,
|
|
58
|
+
statusAt: 0,
|
|
58
59
|
inputPending: false,
|
|
59
60
|
turnAt: 0,
|
|
60
61
|
updatedAtMs: 0,
|
|
61
62
|
}
|
|
62
63
|
const next = { ...prev, turnAt: now, updatedAtMs: now }
|
|
64
|
+
// statusAt: status / context_pct を「実際に SDK イベントが更新した」時刻。
|
|
65
|
+
// inputPending だけの記録 (TUI 権限ブリッジ) では更新しない。これを分けないと、
|
|
66
|
+
// TUI モードの承認カードが届くたびに「過去の SDK チャットが残した status (多くは
|
|
67
|
+
// waiting)」の鮮度だけが蘇生され、state loop が capture-pane の実 processing を
|
|
68
|
+
// 15 分間 waiting で上書きし続ける (TUI 生成中なのにステータスドット/三点リーダーが
|
|
69
|
+
// 消える事故の真因, 2026-06-12)。
|
|
63
70
|
if (typeof patch.status === "string" && VALID_STATUS.has(patch.status)) {
|
|
64
71
|
next.status = patch.status
|
|
72
|
+
next.statusAt = now
|
|
65
73
|
}
|
|
66
74
|
if (typeof patch.contextPct === "number" && Number.isFinite(patch.contextPct)) {
|
|
67
75
|
next.context_pct = Math.max(0, Math.min(100, patch.contextPct))
|
|
76
|
+
next.statusAt = now
|
|
68
77
|
}
|
|
69
78
|
if (typeof patch.inputPending === "boolean") {
|
|
70
79
|
next.inputPending = patch.inputPending
|
|
@@ -84,9 +93,26 @@ export function getChatSignal(cwd, now = _now()) {
|
|
|
84
93
|
const sig = _byCwd.get(cwd)
|
|
85
94
|
if (!sig) return null
|
|
86
95
|
if (now - sig.updatedAtMs > CHAT_SIGNAL_STALE_MS) return null
|
|
96
|
+
// status / context_pct 自体の鮮度は statusAt で判定する。inputPending だけが
|
|
97
|
+
// 信号を延命している間 (= TUI モードで承認カードだけが届く状況) は、stale な
|
|
98
|
+
// SDK 由来の status / context_pct を返さず capture-pane の実値に委ねる。
|
|
99
|
+
if (now - (sig.statusAt || 0) > CHAT_SIGNAL_STALE_MS) {
|
|
100
|
+
return { ...sig, status: null, context_pct: null }
|
|
101
|
+
}
|
|
87
102
|
return sig
|
|
88
103
|
}
|
|
89
104
|
|
|
105
|
+
/**
|
|
106
|
+
* cwd のチャット信号を破棄する。claude.tui.bind (= SDK 停止 → TUI へ載せ替え) の
|
|
107
|
+
* 時点で呼び、以降の status / context% はペイン実状態 (capture-pane) に一本化する。
|
|
108
|
+
*
|
|
109
|
+
* @param {string} cwd
|
|
110
|
+
*/
|
|
111
|
+
export function clearChatSignal(cwd) {
|
|
112
|
+
if (!cwd || typeof cwd !== "string") return
|
|
113
|
+
_byCwd.delete(cwd)
|
|
114
|
+
}
|
|
115
|
+
|
|
90
116
|
/** テスト用: ストアを空にする。 */
|
|
91
117
|
export function _resetChatSignals() {
|
|
92
118
|
_byCwd.clear()
|
package/src/main.mjs
CHANGED
|
@@ -81,7 +81,7 @@ import {
|
|
|
81
81
|
getUsage,
|
|
82
82
|
recordChatRateLimit,
|
|
83
83
|
} from "./usage.mjs"
|
|
84
|
-
import { getChatSignal, recordChatActivity } from "./chat-signals.mjs"
|
|
84
|
+
import { clearChatSignal, getChatSignal, recordChatActivity } from "./chat-signals.mjs"
|
|
85
85
|
import { hydrateProcessEnvFromShell } from "./shell-env.mjs"
|
|
86
86
|
|
|
87
87
|
const logger = pino({ name: "hub-agent" })
|
|
@@ -901,8 +901,15 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
|
|
|
901
901
|
// 上書きする (Hub ホスト型 Cockpit のステータスドット/各行ドーナツ用)。
|
|
902
902
|
const chat = s.cwd ? getChatSignal(s.cwd) : null
|
|
903
903
|
if (chat) {
|
|
904
|
-
|
|
905
|
-
|
|
904
|
+
// capture-pane が processing (= ペインに "esc to interrupt" が実在 = 本物の
|
|
905
|
+
// TUI が生成中) のときはチャット信号で上書きしない。素のシェル (SDK チャット
|
|
906
|
+
// のペイン) は processing を返せないため、SDK チャットの補完は従来どおり効く。
|
|
907
|
+
// TUI 生成中に stale な SDK status (waiting) が勝ってステータスドット/
|
|
908
|
+
// 三点リーダーが消える事故の防止 (2026-06-12)。
|
|
909
|
+
if (chat.status && status !== "processing") status = chat.status
|
|
910
|
+
if (typeof chat.context_pct === "number" && status !== "processing") {
|
|
911
|
+
contextPct = chat.context_pct
|
|
912
|
+
}
|
|
906
913
|
// チャットのターン境界 (turnAt 前進) を sort 用 session-event に橋渡し。
|
|
907
914
|
// tmux ペインが動かず bundle hook が発火しないため、ここで代替発火する。
|
|
908
915
|
const prevTurnAt = lastTurnAtByName.get(s.session_name) || 0
|
|
@@ -1044,6 +1051,53 @@ export function handleStreamsSyncResponse(msg, ctx) {
|
|
|
1044
1051
|
}
|
|
1045
1052
|
}
|
|
1046
1053
|
|
|
1054
|
+
/**
|
|
1055
|
+
* end-to-end 配達確認 (tracked pty.data) の冪等化レジストリ。
|
|
1056
|
+
*
|
|
1057
|
+
* browser はチャット送信 (paste / 確定 CR) に input_id を付け、agent からの
|
|
1058
|
+
* pty.input.ack が来ない限り再送する (zombie WS / 切断窓 / backend group_send の
|
|
1059
|
+
* 無言喪失への対策)。「実際は届いていたが ack 側が消えた」再送で同じ本文が
|
|
1060
|
+
* 二重 paste されないよう、処理済み input_id を記憶して 2 回目以降は write せず
|
|
1061
|
+
* ack だけ返す。Set は挿入順 iterate できるので最古から間引く。
|
|
1062
|
+
*/
|
|
1063
|
+
const SEEN_INPUT_IDS_MAX = 1_000
|
|
1064
|
+
const seenInputIds = new Set()
|
|
1065
|
+
function rememberInputId(input_id) {
|
|
1066
|
+
seenInputIds.add(input_id)
|
|
1067
|
+
while (seenInputIds.size > SEEN_INPUT_IDS_MAX) {
|
|
1068
|
+
seenInputIds.delete(seenInputIds.values().next().value)
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* input_id 付き pty.data (tracked 入力) の処理。write 成功で pty.input.ack を返し、
|
|
1074
|
+
* stream 不在なら pty.error を返して browser の再 attach → 再送に繋げる。
|
|
1075
|
+
* テストから直接呼べるよう dispatch から切り出して export する。
|
|
1076
|
+
*/
|
|
1077
|
+
export function handleTrackedPtyData(msg, ctx) {
|
|
1078
|
+
const { stream_id, input_id } = msg
|
|
1079
|
+
if (seenInputIds.has(input_id)) {
|
|
1080
|
+
// 再送 (ack 喪失 or 再接続 flush)。書き込み済みなので ack だけ返す。
|
|
1081
|
+
ctx.logger?.info({ stream_id, input_id }, "tracked pty.data dedup (ack only)")
|
|
1082
|
+
ctx.client.send({ type: "pty.input.ack", stream_id, input_id })
|
|
1083
|
+
return
|
|
1084
|
+
}
|
|
1085
|
+
const ok = ctx.ptyBridge.write({ stream_id, data: msg.data })
|
|
1086
|
+
if (ok) {
|
|
1087
|
+
rememberInputId(input_id)
|
|
1088
|
+
ctx.client.send({ type: "pty.input.ack", stream_id, input_id })
|
|
1089
|
+
} else {
|
|
1090
|
+
// stream 不在 (reap 済み等)。browser は pty.error で再 attach し、未 ack の
|
|
1091
|
+
// tracked 入力は ack ウォッチドッグが再送する。
|
|
1092
|
+
ctx.client.send({
|
|
1093
|
+
type: "pty.error",
|
|
1094
|
+
stream_id,
|
|
1095
|
+
error: "stream_missing",
|
|
1096
|
+
input_id,
|
|
1097
|
+
})
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1047
1101
|
async function dispatch(msg, ctx) {
|
|
1048
1102
|
const t = msg?.type || ""
|
|
1049
1103
|
try {
|
|
@@ -1073,6 +1127,10 @@ async function dispatch(msg, ctx) {
|
|
|
1073
1127
|
stream_id,
|
|
1074
1128
|
session_name: msg.session_name || "",
|
|
1075
1129
|
plugin: info.plugin,
|
|
1130
|
+
// end-to-end 配達確認 (input_id 付き pty.data → pty.input.ack) に対応
|
|
1131
|
+
// していることを browser へ通知する capability フラグ。旧 agent には
|
|
1132
|
+
// このフラグが無く、browser 側は tracked 送信を自動で無効化する。
|
|
1133
|
+
supports_input_ack: true,
|
|
1076
1134
|
})
|
|
1077
1135
|
} catch (err) {
|
|
1078
1136
|
ctx.client.send({
|
|
@@ -1085,6 +1143,12 @@ async function dispatch(msg, ctx) {
|
|
|
1085
1143
|
return
|
|
1086
1144
|
}
|
|
1087
1145
|
case "pty.data":
|
|
1146
|
+
// input_id 付き = tracked 入力 (チャット送信)。冪等化 + ack 返却で
|
|
1147
|
+
// end-to-end 配達確認に応える。無印は従来どおり fire-and-forget。
|
|
1148
|
+
if (typeof msg.input_id === "string" && msg.input_id) {
|
|
1149
|
+
handleTrackedPtyData(msg, ctx)
|
|
1150
|
+
return
|
|
1151
|
+
}
|
|
1088
1152
|
ctx.ptyBridge.write({ stream_id: msg.stream_id, data: msg.data })
|
|
1089
1153
|
return
|
|
1090
1154
|
case "pty.resize":
|
|
@@ -1601,6 +1665,11 @@ async function dispatch(msg, ctx) {
|
|
|
1601
1665
|
session_id: targetId || undefined,
|
|
1602
1666
|
})
|
|
1603
1667
|
}
|
|
1668
|
+
// SDK を止めて TUI へ載せ替えた時点で、cwd のチャット信号 (SDK 用の
|
|
1669
|
+
// status/context% 補完) は役目を終える。残すと TUI 権限カードの
|
|
1670
|
+
// inputPending 記録が stale status の鮮度を延命し、state loop が
|
|
1671
|
+
// ペイン実状態を waiting で上書きし続ける (2026-06-12 真因修正)。
|
|
1672
|
+
if (cwd) clearChatSignal(cwd)
|
|
1604
1673
|
// 3) tmux claude を起動し直す (resume or fresh)。
|
|
1605
1674
|
// 冪等性: 同じ session を同じキーへ既に載せ替え済みなら respawn しない
|
|
1606
1675
|
// (TUI ビューは remount のたびに bind を送るため、毎回 respawn すると
|
package/src/state.mjs
CHANGED
|
@@ -45,10 +45,65 @@ export function invalidateSessionCache(sessionName) {
|
|
|
45
45
|
if (sessionName == null) {
|
|
46
46
|
_captureCache.clear()
|
|
47
47
|
_cwdCache.clear()
|
|
48
|
+
_statusGate.clear()
|
|
48
49
|
return
|
|
49
50
|
}
|
|
50
51
|
_captureCache.delete(sessionName)
|
|
51
52
|
_cwdCache.delete(sessionName)
|
|
53
|
+
_statusGate.delete(sessionName)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* processing → waiting/idle の「降格」だけ独立 2 サンプルで確定するヒステリシス。
|
|
58
|
+
*
|
|
59
|
+
* capture-pane は claude TUI の再描画途中 (フッター消去〜再描画の間) を拾うことが
|
|
60
|
+
* あり、生成中なのに一瞬 waiting/idle と誤読する。この単発の誤読が frontend の
|
|
61
|
+
* ターン表示 (三点リーダー) やステータスドットを消す方向に直撃するため、降格は
|
|
62
|
+
* 「同じ非 processing 値が STATUS_DOWNGRADE_CONFIRM_MS 以上あけて 2 回読めた」
|
|
63
|
+
* ときだけ確定し、それまでは processing を維持する。
|
|
64
|
+
* - 昇格 (→ processing) は即時 (誤読で点く分には次サンプルで自然回復し、実害が小さい)
|
|
65
|
+
* - 確定窓は capture キャッシュ TTL (CAPTURE_TTL_MS=2.5s) より長く取り、同一
|
|
66
|
+
* キャッシュサンプルの 2 回読みで誤確定しないようにする
|
|
67
|
+
* - 実ターン終了の体感は frontend の hook 由来 session.event 'stop' が即時に担う
|
|
68
|
+
* ため、ここで数秒遅れてもサイドバードットが ~5s 遅れるだけで UX 影響は無い
|
|
69
|
+
*/
|
|
70
|
+
const STATUS_DOWNGRADE_CONFIRM_MS = Number(
|
|
71
|
+
process.env.HUB_AGENT_STATUS_DOWNGRADE_MS ?? 3000,
|
|
72
|
+
)
|
|
73
|
+
/** @type {Map<string, {lastConfirmed: string|null, pending: {status: string, at: number}|null}>} */
|
|
74
|
+
const _statusGate = new Map()
|
|
75
|
+
|
|
76
|
+
export function debounceStatusDowngrade(sessionName, rawStatus, now = Date.now()) {
|
|
77
|
+
if (!sessionName) return rawStatus
|
|
78
|
+
const g = _statusGate.get(sessionName) || { lastConfirmed: null, pending: null }
|
|
79
|
+
// processing への昇格 / processing 以外からの遷移は即時確定。
|
|
80
|
+
if (rawStatus === "processing" || g.lastConfirmed !== "processing") {
|
|
81
|
+
g.lastConfirmed = rawStatus
|
|
82
|
+
g.pending = null
|
|
83
|
+
_statusGate.set(sessionName, g)
|
|
84
|
+
return rawStatus
|
|
85
|
+
}
|
|
86
|
+
// lastConfirmed=processing からの降格要求: 同値の 2 サンプル目 (確定窓経過後) で確定。
|
|
87
|
+
if (
|
|
88
|
+
g.pending &&
|
|
89
|
+
g.pending.status === rawStatus &&
|
|
90
|
+
now - g.pending.at >= STATUS_DOWNGRADE_CONFIRM_MS
|
|
91
|
+
) {
|
|
92
|
+
g.lastConfirmed = rawStatus
|
|
93
|
+
g.pending = null
|
|
94
|
+
_statusGate.set(sessionName, g)
|
|
95
|
+
return rawStatus
|
|
96
|
+
}
|
|
97
|
+
if (!g.pending || g.pending.status !== rawStatus) {
|
|
98
|
+
g.pending = { status: rawStatus, at: now }
|
|
99
|
+
}
|
|
100
|
+
_statusGate.set(sessionName, g)
|
|
101
|
+
return "processing"
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** テスト用: 降格ヒステリシスの状態を空にする。 */
|
|
105
|
+
export function _resetStatusGate() {
|
|
106
|
+
_statusGate.clear()
|
|
52
107
|
}
|
|
53
108
|
|
|
54
109
|
const CONTEXT_PATTERNS = [
|
|
@@ -288,7 +343,10 @@ export async function detectSessionState(sessionName, opts = {}) {
|
|
|
288
343
|
})
|
|
289
344
|
if (hookResult?.result) {
|
|
290
345
|
return {
|
|
291
|
-
status:
|
|
346
|
+
status: debounceStatusDowngrade(
|
|
347
|
+
sessionName,
|
|
348
|
+
hookResult.result.status || defaultStatus,
|
|
349
|
+
),
|
|
292
350
|
context_pct: hookResult.result.context_pct ?? defaultContextPct,
|
|
293
351
|
permission_mode:
|
|
294
352
|
hookResult.result.permission_mode ?? defaultPermissionMode,
|
|
@@ -297,7 +355,7 @@ export async function detectSessionState(sessionName, opts = {}) {
|
|
|
297
355
|
}
|
|
298
356
|
|
|
299
357
|
return {
|
|
300
|
-
status: defaultStatus,
|
|
358
|
+
status: debounceStatusDowngrade(sessionName, defaultStatus),
|
|
301
359
|
context_pct: defaultContextPct,
|
|
302
360
|
permission_mode: defaultPermissionMode,
|
|
303
361
|
}
|
package/src/ws-client.mjs
CHANGED
|
@@ -23,6 +23,19 @@ 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
|
+
// zombie WS (half-open TCP) 検知のしきい値。backend は heartbeat (30s) のたびに
|
|
27
|
+
// ack を返すため、健全な接続なら inbound frame が最低 30s+RTT に 1 回は届く。
|
|
28
|
+
// これを超えて沈黙した接続は「readyState=OPEN だが TCP は死んでいる」zombie の
|
|
29
|
+
// 可能性が高い (send は kernel buffer に書けて成功扱いになるため throw しない)。
|
|
30
|
+
// heartbeat 2.5 周期 = 75s を超えたら強制再接続する。browser 側 CockpitTerminal の
|
|
31
|
+
// LIVENESS_TIMEOUT_MS (PR#2342) と同じパターンの agent 版。
|
|
32
|
+
const LIVENESS_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 2.5
|
|
33
|
+
// heartbeat tick 間隔の実測ジャンプ検知 (Mac スリープ復帰対策)。スリープ中は
|
|
34
|
+
// プロセスごと止まるため setInterval の実発火間隔が大きく開く。復帰直後の TCP は
|
|
35
|
+
// ほぼ確実に死んでいる (half-open) のに send は成功扱いになるので、ジャンプを
|
|
36
|
+
// 検知したら鮮度判定を待たず即座に再接続する。2 周期 (60s) 超のギャップで発火。
|
|
37
|
+
const TICK_JUMP_MS = HEARTBEAT_INTERVAL_MS * 2
|
|
38
|
+
|
|
26
39
|
// 接続が安定したとみなすまでの猶予。open 直後に即 backoff をリセットすると、
|
|
27
40
|
// open→即 close のフラッピング時に毎回 backoff が最小 (1s) へ戻り、1〜2 秒間隔の
|
|
28
41
|
// 再接続ストロボに陥る (CF/backend 側の瞬断時に hub-agent↔backend WS で観測)。
|
|
@@ -85,6 +98,11 @@ export class WsClient extends EventEmitter {
|
|
|
85
98
|
// 接続が STABLE_CONNECTION_MS 維持できたら backoff/5xx フラグをリセットする
|
|
86
99
|
// タイマー。close (_clearStableReset) でキャンセルされる。
|
|
87
100
|
this.stableResetTimer = null
|
|
101
|
+
// zombie WS 検知: 最後に inbound frame (heartbeat ack / agent.relay 等の何か) を
|
|
102
|
+
// 受信した時刻。健全なら heartbeat ack で 30s ごとに必ず更新される。
|
|
103
|
+
this.lastRecvAt = 0
|
|
104
|
+
// heartbeat tick の直前実発火時刻。スリープ復帰のジャンプ検知に使う。
|
|
105
|
+
this.lastTickAt = 0
|
|
88
106
|
}
|
|
89
107
|
|
|
90
108
|
/** WSS 接続を開始する。`stop()` まで自動で reconnect 続行。 */
|
|
@@ -102,6 +120,9 @@ export class WsClient extends EventEmitter {
|
|
|
102
120
|
ws.on("open", () => this._onOpen())
|
|
103
121
|
|
|
104
122
|
ws.on("message", (data) => {
|
|
123
|
+
// 何かを受信した = TCP は生きている。zombie 検知の鮮度を更新する
|
|
124
|
+
// (parse 失敗でも「届いた」事実は生存の証拠なので先に記録する)。
|
|
125
|
+
this.lastRecvAt = Date.now()
|
|
105
126
|
let msg
|
|
106
127
|
try {
|
|
107
128
|
msg = JSON.parse(data.toString("utf-8"))
|
|
@@ -151,6 +172,10 @@ export class WsClient extends EventEmitter {
|
|
|
151
172
|
// ストロボ対策)。すぐ切れた接続では backoff がリセットされず指数バックオフが
|
|
152
173
|
// 効き続けるため、1〜2 秒間隔の再接続ループに陥らない。
|
|
153
174
|
this._armStableReset()
|
|
175
|
+
// open 自体が生存の証拠。直前接続の古い lastRecvAt で開幕直後に zombie 誤検知
|
|
176
|
+
// しないようリセットする。
|
|
177
|
+
this.lastRecvAt = Date.now()
|
|
178
|
+
this.lastTickAt = Date.now()
|
|
154
179
|
this.logger?.info("ws open")
|
|
155
180
|
// 切断中に積んだ pty.data を flush。hello より先に送ると backend で stream
|
|
156
181
|
// 未登録のため unknown_stream error になる可能性があるが、hello の応答待ち
|
|
@@ -284,7 +309,35 @@ export class WsClient extends EventEmitter {
|
|
|
284
309
|
|
|
285
310
|
_startHeartbeat() {
|
|
286
311
|
this._stopHeartbeat()
|
|
312
|
+
this.lastTickAt = Date.now()
|
|
287
313
|
this.heartbeatTimer = setInterval(() => {
|
|
314
|
+
const now = Date.now()
|
|
315
|
+
// スリープ復帰検知: スリープ中はプロセスごと止まり、setInterval の実発火間隔が
|
|
316
|
+
// 大きく開く。復帰直後の TCP は half-open でほぼ確実に死んでいるのに send は
|
|
317
|
+
// 成功扱いになるため、鮮度判定 (受信側) を待たず即座に再接続へ倒す。
|
|
318
|
+
const tickGap = this.lastTickAt > 0 ? now - this.lastTickAt : 0
|
|
319
|
+
this.lastTickAt = now
|
|
320
|
+
if (tickGap > TICK_JUMP_MS) {
|
|
321
|
+
this.logger?.warn(
|
|
322
|
+
{ tickGapMs: tickGap },
|
|
323
|
+
"heartbeat tick jump detected (likely sleep wake), forcing reconnect",
|
|
324
|
+
)
|
|
325
|
+
this._forceReconnect()
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
// zombie WS 検知 (inbound 鮮度): backend は heartbeat のたびに ack を返すため、
|
|
329
|
+
// 健全なら lastRecvAt は 30s+RTT 以内に必ず更新される。LIVENESS_TIMEOUT_MS を
|
|
330
|
+
// 超えて何も届いていない接続は half-open とみなし強制再接続する。send の
|
|
331
|
+
// throw 判定 (下) では half-open を検知できない (kernel buffer に書けて
|
|
332
|
+
// 成功扱いになる) ことへの対策。browser 側 PR#2342 と同じパターン。
|
|
333
|
+
if (this.lastRecvAt > 0 && now - this.lastRecvAt > LIVENESS_TIMEOUT_MS) {
|
|
334
|
+
this.logger?.warn(
|
|
335
|
+
{ sinceLastRecvMs: now - this.lastRecvAt },
|
|
336
|
+
"ws liveness timeout (zombie suspected), forcing reconnect",
|
|
337
|
+
)
|
|
338
|
+
this._forceReconnect()
|
|
339
|
+
return
|
|
340
|
+
}
|
|
288
341
|
// heartbeat 都度 provider 経由で bundle version を最新化する。
|
|
289
342
|
// fs.watch を取り逃した変更 (atomic rename / 別マウント越し等) への保険。
|
|
290
343
|
this._refreshBundleVersion()
|