@mzzsfy/dsh-bug-perf 0.0.0-stage → 0.1.0

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/src/client.js ADDED
@@ -0,0 +1,833 @@
1
+ // dsh-bug-perf 浏览器半区:宿主缺陷的运行期兜底。
2
+ // 每个补丁独立安装、独立开关、独立失败:任何一项结构不匹配只记录原因,不影响页面与其它补丁。
3
+ window.__ModuleLoader__.load({
4
+ id: '@mzzsfy/dsh-bug-perf',
5
+ factory(require) {
6
+ const GATEWAY_CLIENT_ID = '@deepseek-ai/dsh-api-gateway/client'
7
+ const CONFIG_PATH = '/api/dsh-bug-perf/config'
8
+ const CONFIG_TIMEOUT_MS = 2000
9
+ const LOG_PREFIX = '[dsh-bug-perf]'
10
+
11
+ // 与 src/config.mjs 同源;客户端不能 import 共享模块,内联副本由测试对表
12
+ const DEFAULT_CONFIG = {
13
+ restartCoalesce: { enabled: true, windowMs: 100 },
14
+ terminalWatchdog: { enabled: true, checkMs: 2000, stallMs: 8000, cooldownMs: 60000 },
15
+ socketKeepAlive: { enabled: true, intervalMs: 1500 },
16
+ stuckSession: { enabled: true, checkMs: 2000, stallMs: 8000, reconnect: true, maxReconnectAttempts: 5, reconnectIntervalMs: 15000 },
17
+ jobStreamLimit: { enabled: true, base: 32, max: 48 },
18
+ }
19
+ const LIMITS = {
20
+ restartCoalesce: { windowMs: { min: 0, max: 2000 } },
21
+ terminalWatchdog: {
22
+ checkMs: { min: 250, max: 60000 },
23
+ stallMs: { min: 1000, max: 600000 },
24
+ cooldownMs: { min: 1000, max: 3600000 },
25
+ },
26
+ socketKeepAlive: { intervalMs: { min: 500, max: 60000 } },
27
+ stuckSession: {
28
+ checkMs: { min: 250, max: 60000 },
29
+ stallMs: { min: 1000, max: 600000 },
30
+ maxReconnectAttempts: { min: 1, max: 20 },
31
+ reconnectIntervalMs: { min: 500, max: 600000 },
32
+ },
33
+ jobStreamLimit: { base: { min: 4, max: 256 }, max: { min: 4, max: 512 } },
34
+ }
35
+
36
+ const isSection = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
37
+ const sectionOf = (value) => (isSection(value) ? value : {})
38
+ const booleanOr = (value, fallback) => (typeof value === 'boolean' ? value : fallback)
39
+ const numberWithin = (value, bounds, fallback) => {
40
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
41
+ if (value < bounds.min) return fallback
42
+ return value > bounds.max ? bounds.max : value
43
+ }
44
+ const normalizeConfig = (raw) => {
45
+ const input = sectionOf(raw)
46
+ const restart = sectionOf(input.restartCoalesce)
47
+ const watchdog = sectionOf(input.terminalWatchdog)
48
+ const keepAlive = sectionOf(input.socketKeepAlive)
49
+ const stuck = sectionOf(input.stuckSession)
50
+ const jobLimit = sectionOf(input.jobStreamLimit)
51
+ return {
52
+ restartCoalesce: {
53
+ enabled: booleanOr(restart.enabled, DEFAULT_CONFIG.restartCoalesce.enabled),
54
+ windowMs: numberWithin(restart.windowMs, LIMITS.restartCoalesce.windowMs, DEFAULT_CONFIG.restartCoalesce.windowMs),
55
+ },
56
+ terminalWatchdog: {
57
+ enabled: booleanOr(watchdog.enabled, DEFAULT_CONFIG.terminalWatchdog.enabled),
58
+ checkMs: numberWithin(watchdog.checkMs, LIMITS.terminalWatchdog.checkMs, DEFAULT_CONFIG.terminalWatchdog.checkMs),
59
+ stallMs: numberWithin(watchdog.stallMs, LIMITS.terminalWatchdog.stallMs, DEFAULT_CONFIG.terminalWatchdog.stallMs),
60
+ cooldownMs: numberWithin(watchdog.cooldownMs, LIMITS.terminalWatchdog.cooldownMs, DEFAULT_CONFIG.terminalWatchdog.cooldownMs),
61
+ },
62
+ socketKeepAlive: {
63
+ enabled: booleanOr(keepAlive.enabled, DEFAULT_CONFIG.socketKeepAlive.enabled),
64
+ intervalMs: numberWithin(keepAlive.intervalMs, LIMITS.socketKeepAlive.intervalMs, DEFAULT_CONFIG.socketKeepAlive.intervalMs),
65
+ },
66
+ jobStreamLimit: {
67
+ enabled: booleanOr(jobLimit.enabled, DEFAULT_CONFIG.jobStreamLimit.enabled),
68
+ base: Math.min(
69
+ numberWithin(jobLimit.base, LIMITS.jobStreamLimit.base, DEFAULT_CONFIG.jobStreamLimit.base),
70
+ numberWithin(jobLimit.max, LIMITS.jobStreamLimit.max, DEFAULT_CONFIG.jobStreamLimit.max),
71
+ ),
72
+ max: numberWithin(jobLimit.max, LIMITS.jobStreamLimit.max, DEFAULT_CONFIG.jobStreamLimit.max),
73
+ },
74
+ stuckSession: {
75
+ enabled: booleanOr(stuck.enabled, DEFAULT_CONFIG.stuckSession.enabled),
76
+ checkMs: numberWithin(stuck.checkMs, LIMITS.stuckSession.checkMs, DEFAULT_CONFIG.stuckSession.checkMs),
77
+ stallMs: numberWithin(stuck.stallMs, LIMITS.stuckSession.stallMs, DEFAULT_CONFIG.stuckSession.stallMs),
78
+ reconnect: booleanOr(stuck.reconnect, DEFAULT_CONFIG.stuckSession.reconnect),
79
+ maxReconnectAttempts: numberWithin(stuck.maxReconnectAttempts, LIMITS.stuckSession.maxReconnectAttempts, DEFAULT_CONFIG.stuckSession.maxReconnectAttempts),
80
+ reconnectIntervalMs: numberWithin(stuck.reconnectIntervalMs, LIMITS.stuckSession.reconnectIntervalMs, DEFAULT_CONFIG.stuckSession.reconnectIntervalMs),
81
+ },
82
+ }
83
+ }
84
+
85
+ const timers = {
86
+ now: () => performance.now(),
87
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
88
+ clearTimeout: (id) => clearTimeout(id),
89
+ setInterval: (fn, ms) => setInterval(fn, ms),
90
+ clearInterval: (id) => clearInterval(id),
91
+ }
92
+
93
+ const RESTART_MARKER = Symbol.for('@mzzsfy/dsh-bug-perf/restart-coalesce')
94
+ const WATCHDOG_MARKER = Symbol.for('@mzzsfy/dsh-bug-perf/terminal-watchdog')
95
+
96
+ // 同一 Remote stream 在窗口内的重复 restart 合并为一次尾随执行:
97
+ // restart 无参数, 最新一次即可代表期间的构建请求。
98
+ const installRestartCoalesce = ({ RemoteStream, windowMs, timers: clock }) => {
99
+ const proto = RemoteStream?.prototype
100
+ if (proto === undefined || typeof proto.restart !== 'function') {
101
+ return { installed: false, reason: 'RemoteStream.restart missing' }
102
+ }
103
+ if (proto[RESTART_MARKER] === true) return { installed: false, reason: 'already installed' }
104
+ proto[RESTART_MARKER] = true
105
+ const original = proto.restart
106
+ const states = new WeakMap()
107
+ proto.restart = function coalescedRestart() {
108
+ const now = clock.now()
109
+ let state = states.get(this)
110
+ if (state === undefined) {
111
+ state = { last: -Infinity, pending: undefined }
112
+ states.set(this, state)
113
+ }
114
+ if (now - state.last >= windowMs) {
115
+ if (state.pending !== undefined) {
116
+ clock.clearTimeout(state.pending)
117
+ state.pending = undefined
118
+ }
119
+ state.last = now
120
+ return original.call(this)
121
+ }
122
+ if (state.pending === undefined) {
123
+ state.pending = clock.setTimeout(() => {
124
+ state.pending = undefined
125
+ state.last = clock.now()
126
+ try {
127
+ original.call(this)
128
+ } catch {
129
+ // 实例已释放:尾随调用没有可重建的 generation
130
+ }
131
+ }, windowMs - (now - state.last))
132
+ }
133
+ }
134
+ return { installed: true }
135
+ }
136
+
137
+ const installRestartCoalesceFromRequire = ({ requireImpl, windowMs, timers: clock }) => {
138
+ try {
139
+ const gateway = requireImpl(GATEWAY_CLIENT_ID)
140
+ return installRestartCoalesce({ RemoteStream: gateway?.RemoteStream, windowMs, timers: clock })
141
+ } catch (error) {
142
+ return { installed: false, reason: `gateway client unavailable: ${error instanceof Error ? error.message : String(error)}` }
143
+ }
144
+ }
145
+
146
+ // 终端视图健康判定:纯函数,便于单测与现场排障。
147
+ // armed 表示该视图在本端曾持有写权;只读旁观视图不自动抢控制权。
148
+ const watchdogTick = ({ view, state, options, now, visible }) => {
149
+ const snapshot = view.state.getSnapshot()
150
+ if (!visible) {
151
+ state.changedAt = now
152
+ return 'hidden'
153
+ }
154
+ if (view.revision !== state.revision) {
155
+ state.revision = view.revision
156
+ state.changedAt = now
157
+ if (snapshot.writable === true) state.armed = true
158
+ return 'progress'
159
+ }
160
+ if (snapshot.writable === true) {
161
+ state.armed = true
162
+ state.changedAt = now
163
+ return 'progress'
164
+ }
165
+ if (!state.armed) {
166
+ state.changedAt = now
167
+ return 'idle'
168
+ }
169
+ if (snapshot.phase !== 'connected') {
170
+ state.changedAt = now
171
+ return 'not-connected'
172
+ }
173
+ if (view.pendingRender !== undefined) {
174
+ state.changedAt = now
175
+ return 'pending-render'
176
+ }
177
+ if (view.stream === undefined) {
178
+ state.changedAt = now
179
+ return 'no-stream'
180
+ }
181
+ if (now - state.changedAt < options.stallMs) return 'idle'
182
+ if (now - state.healedAt < options.cooldownMs) return 'cooldown'
183
+ state.healedAt = now
184
+ state.armed = false
185
+ return 'heal'
186
+ }
187
+
188
+ const installTerminalWatchdog = ({ holder, options, timers: clock, isVisible }) => {
189
+ if (holder === undefined || typeof holder.view !== 'function') {
190
+ return { installed: false, reason: 'webTerminals.view missing' }
191
+ }
192
+ if (holder[WATCHDOG_MARKER] === true) return { installed: false, reason: 'already installed' }
193
+ holder[WATCHDOG_MARKER] = true
194
+ const monitors = new WeakMap()
195
+ const monitor = (view) => {
196
+ if (view === undefined || monitors.has(view)) return
197
+ const state = { revision: view.revision, changedAt: clock.now(), armed: false, healedAt: -Infinity }
198
+ monitors.set(view, state)
199
+ const interval = clock.setInterval(() => {
200
+ try {
201
+ const decision = watchdogTick({ view, state, options, now: clock.now(), visible: isVisible() })
202
+ if (decision === 'heal') {
203
+ console.warn(`${LOG_PREFIX} terminal stalled, reconnecting`, { sessionId: view.sessionId, id: view.id })
204
+ view.connect()
205
+ }
206
+ } catch (error) {
207
+ console.warn(`${LOG_PREFIX} watchdog tick failed:`, error instanceof Error ? error.message : String(error))
208
+ }
209
+ }, options.checkMs)
210
+ view.lifetime?.signal?.addEventListener?.('abort', () => clock.clearInterval(interval), { once: true })
211
+ }
212
+ try {
213
+ for (const views of holder.views?.values?.() ?? []) for (const view of views.values()) monitor(view)
214
+ } catch {
215
+ // 视图表结构随宿主版本变化:已有视图漏检,新建视图仍由包装覆盖
216
+ }
217
+ const original = holder.view.bind(holder)
218
+ holder.view = (...args) => {
219
+ const view = original(...args)
220
+ monitor(view)
221
+ return view
222
+ }
223
+ return { installed: true }
224
+ }
225
+
226
+ const PLUGIN_ID = '@mzzsfy/dsh-bug-perf'
227
+ const REMOTE_MUX_PATH = '/api/remote.mux'
228
+ const KEEPALIVE_MARKER = Symbol.for('@mzzsfy/dsh-bug-perf/socket-keepalive')
229
+ const STUCK_MARKER = Symbol.for('@mzzsfy/dsh-bug-perf/stuck-session')
230
+ const KEEPALIVE_STREAM_PREFIX = 'dsh-bug-perf-keepalive'
231
+ const FRAME_TYPE_PREFIX = '{"type":"'
232
+ const STREAM_ID_KEY = '"streamId":"'
233
+ const ENDPOINT_KEY = '"endpoint":"'
234
+ const DIAG_SAMPLE_MS = 5000
235
+ const DIAG_MAX_SAMPLES = 720
236
+ // 事件循环探测: 延迟超过该值视为主线程被占满
237
+ const DIAG_BLOCK_MS = 1000
238
+ const DIAG_PROBE_MS = 2000
239
+ const DIAG_REPORT_PATH = '/api/dsh-bug-perf/diag'
240
+ // 每 N 次采样(约一分钟)把摘要落到宿主临时文件, 便于事后定位长时间运行后的退化
241
+ const DIAG_REPORT_EVERY = 12
242
+ // 定时器检查节拍: 实际发送间隔由可变的 intervalMs 决定
243
+ const KEEPALIVE_TICK_MS = 500
244
+ // 只有协议级关闭码才可能是宿主拒绝保活帧; 链路断开与主动关闭不计入
245
+ const PROTOCOL_CLOSE_CODES = Object.freeze([1002, 1003, 1007, 1008, 1009, 1011])
246
+ const BREAKER_STRIKES = 2
247
+ // 连接健康存活这么多倍间隔后清零累计, 避免历史事件把补丁永久禁用
248
+ const BREAKER_RECOVER_INTERVALS = 10
249
+ const STUCK_NOTICE_ID = 'dsh-bug-perf-stuck'
250
+ const STUCK_HINT_TEXTS = ['载入历史…', 'Loading history…']
251
+ const STUCK_MESSAGE_SELECTOR = '[data-chat-flow-key]'
252
+ const STUCK_CSS = [
253
+ '.bp-stuck{--bp-bg:var(--dsw-alias-bg-layer-1);--bp-border:var(--dsw-alias-border-l3);--bp-text:var(--dsw-alias-label-primary);--bp-accent:var(--dsw-alias-brand-primary);--bp-hover:var(--dsw-alias-interactive-bg-hover);position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:2147483000;display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:8px;background:var(--bp-bg);border:1px solid var(--bp-border);color:var(--bp-text);font-size:13px;line-height:20px}',
254
+ '.bp-stuck__action{padding:2px 10px;border-radius:6px;border:1px solid var(--bp-border);background:transparent;color:var(--bp-accent);font-size:13px;line-height:20px;cursor:pointer}',
255
+ '.bp-stuck__action:hover{background:var(--bp-hover)}',
256
+ ].join('')
257
+
258
+ // 只读诊断: 记录帧计数、消息速率、活跃流与页面规模, 用于定位“越用越卡”。
259
+ // 不解析完整帧、不改动任何流, 采样保留最近 DIAG_MAX_SAMPLES 个点。
260
+ const createDiag = (clock) => ({
261
+ startedAt: clock.now(),
262
+ sockets: [],
263
+ samples: [],
264
+ longTasks: [],
265
+ errors: [],
266
+ totals: { opened: 0, closed: 0, messages: 0, sent: 0 },
267
+ })
268
+
269
+ const activeStreams = (sockets) => {
270
+ let total = 0
271
+ for (const socket of sockets) {
272
+ for (const count of Object.values(socket.streams)) total += count
273
+ }
274
+ return total
275
+ }
276
+
277
+ const reportDiag = (win, diag) => {
278
+ const fetchImpl = win.fetch
279
+ if (typeof fetchImpl !== 'function') return
280
+ const payload = {
281
+ at: Date.now(),
282
+ path: win.location?.pathname ?? '',
283
+ totals: diag.totals,
284
+ last: diag.samples.slice(-6),
285
+ blockedMs: diag.blockedMs,
286
+ longTasks: diag.longTasks.slice(-8),
287
+ errors: diag.errors.slice(-4),
288
+ }
289
+ try {
290
+ void fetchImpl(`${DIAG_REPORT_PATH}?d=${encodeURIComponent(JSON.stringify(payload))}`, { cache: 'no-store', keepalive: true }).catch(() => { })
291
+ } catch {
292
+ // 上报失败不影响页面
293
+ }
294
+ }
295
+
296
+ const startDiagSampling = ({ win, clock, diag, doc }) => {
297
+ const heapq = () => {
298
+ const memory = win.performance?.memory
299
+ return memory === undefined ? undefined : Math.round(memory.usedJSHeapSize / 1048576)
300
+ }
301
+ const sample = () => {
302
+ const live = diag.sockets.filter((socket) => !socket.closed)
303
+ diag.samples.push({
304
+ t: Math.round(clock.now()),
305
+ heapMB: heapq(),
306
+ nodes: doc?.querySelectorAll?.('*').length,
307
+ sockets: diag.sockets.length,
308
+ liveSockets: live.length,
309
+ activeStreams: activeStreams(live),
310
+ messages: diag.totals.messages,
311
+ sent: diag.totals.sent,
312
+ })
313
+ if (diag.samples.length > DIAG_MAX_SAMPLES) diag.samples.shift()
314
+ diag.count = (diag.count ?? 0) + 1
315
+ if (diag.count % DIAG_REPORT_EVERY === 0) reportDiag(win, diag)
316
+ }
317
+ sample()
318
+ const timer = clock.setInterval(sample, DIAG_SAMPLE_MS)
319
+ const schedule = win.setTimeout
320
+ if (typeof schedule === 'function') {
321
+ clock.setInterval(() => {
322
+ const started = clock.now()
323
+ schedule(() => {
324
+ const delay = clock.now() - started
325
+ if (delay < DIAG_BLOCK_MS) return
326
+ diag.blockedAt = clock.now()
327
+ diag.blockedMs = Math.round(delay)
328
+ }, 0)
329
+ }, DIAG_PROBE_MS)
330
+ }
331
+ const observer = win.PerformanceObserver === undefined ? undefined : new win.PerformanceObserver((list) => {
332
+ for (const entry of list.getEntries()) {
333
+ diag.longTasks.push({ t: Math.round(clock.now()), ms: Math.round(entry.duration) })
334
+ if (diag.longTasks.length > DIAG_MAX_SAMPLES) diag.longTasks.shift()
335
+ }
336
+ })
337
+ try { observer?.observe({ entryTypes: ['longtask'] }) } catch { }
338
+ win.addEventListener?.('error', (event) => {
339
+ diag.errors.push(String(event?.message ?? '').slice(0, 200))
340
+ if (diag.errors.length > DIAG_MAX_SAMPLES) diag.errors.shift()
341
+ })
342
+ return { timer, observer }
343
+ }
344
+
345
+ // 宿主心跳为固定短周期且丢两次即 terminate, 经反代时 pong 常被链路吞掉。
346
+ // 客户端在会话流连接上按间隔发一条未知 streamId 的 item 帧: 宿主收到任意帧都会刷新存活标记,
347
+ // 未知 streamId 的 item 在宿主侧被忽略, 不改动任何流的状态。
348
+ // 只在连接空闲(客户端自己没有上行帧)时补帧; 覆盖范围仅限安装之后新建的连接。
349
+ const installSocketKeepAlive = ({ win, options, clock, onDisabled, diag }) => {
350
+ const Native = win?.WebSocket
351
+ if (typeof Native !== 'function') return { installed: false, reason: 'WebSocket unavailable' }
352
+ if (options?.enabled === false) return { installed: false, reason: 'disabled' }
353
+ if (Native[KEEPALIVE_MARKER] !== undefined) return { installed: false, reason: 'already installed' }
354
+ const state = { intervalMs: options.intervalMs, timers: new Set(), strikes: 0, disabled: false, disabledReason: undefined }
355
+ let Wrapped
356
+ const disable = (reason) => {
357
+ if (state.disabled) return
358
+ state.disabled = true
359
+ state.disabledReason = reason
360
+ for (const id of state.timers) clock.clearInterval(id)
361
+ state.timers.clear()
362
+ if (win.WebSocket === Wrapped) win.WebSocket = Native
363
+ if (Native[KEEPALIVE_MARKER] === Wrapped) delete Native[KEEPALIVE_MARKER]
364
+ console.warn(`${LOG_PREFIX} socket keepalive disabled: ${reason}`)
365
+ onDisabled?.(reason)
366
+ }
367
+ const nextStreamId = () => `${KEEPALIVE_STREAM_PREFIX}-${clock.now()}-${Math.random().toString(36).slice(2)}`
368
+ // 只读帧统计: 用字符串扫描代替整帧解析, 用于观察活跃流与帧速率, 不改动任何流
369
+ const readFrame = (raw) => {
370
+ if (typeof raw !== 'string' || !raw.startsWith(FRAME_TYPE_PREFIX)) return undefined
371
+ const typeEnd = raw.indexOf('"', FRAME_TYPE_PREFIX.length)
372
+ if (typeEnd < 0) return undefined
373
+ const type = raw.slice(FRAME_TYPE_PREFIX.length, typeEnd)
374
+ const idAt = raw.indexOf(STREAM_ID_KEY)
375
+ const streamId = idAt < 0 ? '' : raw.slice(idAt + STREAM_ID_KEY.length, raw.indexOf('"', idAt + STREAM_ID_KEY.length))
376
+ const endpointAt = raw.indexOf(ENDPOINT_KEY)
377
+ const endpoint = endpointAt < 0 ? '' : raw.slice(endpointAt + ENDPOINT_KEY.length, raw.indexOf('"', endpointAt + ENDPOINT_KEY.length))
378
+ return { type, streamId, endpoint }
379
+ }
380
+ const trackFrame = (socket, raw) => {
381
+ const frame = readFrame(raw)
382
+ if (frame === undefined) return
383
+ if (frame.type === 'open') {
384
+ const endpoint = frame.endpoint === '' ? 'unknown' : frame.endpoint
385
+ socket.byStream.set(frame.streamId, endpoint)
386
+ socket.streams[endpoint] = (socket.streams[endpoint] ?? 0) + 1
387
+ return
388
+ }
389
+ if (frame.type !== 'end' && frame.type !== 'cancel') return
390
+ const endpoint = socket.byStream.get(frame.streamId)
391
+ if (endpoint === undefined) return
392
+ socket.byStream.delete(frame.streamId)
393
+ socket.streams[endpoint] = (socket.streams[endpoint] ?? 0) - 1
394
+ }
395
+ Wrapped = class KeepAliveSocket extends Native {
396
+ constructor(...args) {
397
+ super(...args)
398
+ if (state.disabled) return
399
+ let path
400
+ try { path = new URL(String(args[0]), win.location?.href ?? 'https://localhost/').pathname } catch { return }
401
+ if (path !== REMOTE_MUX_PATH) return
402
+ const openedAt = clock.now()
403
+ const site = { lastUpstreamAt: openedAt, lastKeepAliveAt: openedAt, keepAliveSent: false }
404
+ const socketStats = diag === undefined ? undefined : { streams: {}, byStream: new Map(), openedAt: clock.now(), closed: false }
405
+ if (socketStats !== undefined) {
406
+ diag.sockets.push(socketStats)
407
+ diag.totals.opened += 1
408
+ }
409
+ this.addEventListener('message', (event) => {
410
+ if (socketStats === undefined) return
411
+ trackFrame(socketStats, event?.data)
412
+ diag.totals.messages += 1
413
+ })
414
+ this.addEventListener('close', () => {
415
+ if (socketStats === undefined || socketStats.closed) return
416
+ socketStats.closed = true
417
+ diag.totals.closed += 1
418
+ })
419
+ const rawSend = this.send.bind(this)
420
+ this.send = (data) => {
421
+ site.lastUpstreamAt = clock.now()
422
+ if (socketStats !== undefined) {
423
+ trackFrame(socketStats, data)
424
+ diag.totals.sent += 1
425
+ }
426
+ return rawSend(data)
427
+ }
428
+ const timer = clock.setInterval(() => {
429
+ if (state.disabled || this.readyState !== Native.OPEN) return
430
+ const now = clock.now()
431
+ if (state.strikes > 0 && now - openedAt >= state.intervalMs * BREAKER_RECOVER_INTERVALS) state.strikes = 0
432
+ if (now - Math.max(site.lastUpstreamAt, site.lastKeepAliveAt) < state.intervalMs) return
433
+ try {
434
+ rawSend(JSON.stringify({ type: 'item', streamId: nextStreamId(), value: 0 }))
435
+ site.lastKeepAliveAt = now
436
+ site.keepAliveSent = true
437
+ } catch {
438
+ // 已进入关闭流程: 交给 close 事件清理
439
+ }
440
+ }, KEEPALIVE_TICK_MS)
441
+ state.timers.add(timer)
442
+ this.addEventListener('close', (event) => {
443
+ clock.clearInterval(timer)
444
+ state.timers.delete(timer)
445
+ if (state.disabled || !site.keepAliveSent || !PROTOCOL_CLOSE_CODES.includes(event?.code)) return
446
+ state.strikes += 1
447
+ if (state.strikes >= BREAKER_STRIKES) disable('protocol closed the connection after a keepalive frame')
448
+ })
449
+ }
450
+ }
451
+ Native[KEEPALIVE_MARKER] = Wrapped
452
+ win.WebSocket = Wrapped
453
+ return {
454
+ installed: true,
455
+ state,
456
+ setIntervalMs: (ms) => { state.intervalMs = ms },
457
+ uninstall: () => disable('disabled'),
458
+ }
459
+ }
460
+
461
+ // 与宿主自己的列表项口径一致: 排除分组行、过程行、空行与隐藏子树
462
+ const isMeaningfulRow = (node) => {
463
+ if (node.hasAttribute?.('data-chat-group-key')) return false
464
+ if (node.getAttribute?.('data-chat-flow-kind') === 'turn-process') return false
465
+ const hiddenAncestor = node.closest?.('[hidden]')
466
+ if (hiddenAncestor !== null && hiddenAncestor !== undefined) return false
467
+ if ((node.childElementCount ?? 0) === 0 && (node.textContent ?? '').trim() === '') return false
468
+ return true
469
+ }
470
+
471
+ // 加载态判定只认会话流容器里没有任何有效消息行、且直接子节点文本与加载提示完全一致的节点;
472
+ // 对话正文或历史里出现同样文字不会命中, 终端等其它区域的存在也不影响判断。
473
+ const sessionStalled = (doc) => {
474
+ const flow = doc.querySelector?.('[data-chat-flow]')
475
+ if (flow === null || flow === undefined) return false
476
+ const rows = flow.querySelectorAll?.(STUCK_MESSAGE_SELECTOR) ?? []
477
+ for (const row of rows) {
478
+ if (isMeaningfulRow(row)) return false
479
+ }
480
+ for (const child of flow.children ?? []) {
481
+ const text = typeof child.textContent === 'string' ? child.textContent.trim() : ''
482
+ if (STUCK_HINT_TEXTS.includes(text)) return true
483
+ }
484
+ return false
485
+ }
486
+
487
+ const ensureStuckStyle = (doc) => {
488
+ const selector = `style[data-plugin="${PLUGIN_ID}"]`
489
+ const existing = doc.querySelector?.(selector)
490
+ if (existing !== null && existing !== undefined) return
491
+ const style = doc.createElement('style')
492
+ style.setAttribute('data-plugin', PLUGIN_ID)
493
+ style.textContent = STUCK_CSS
494
+ doc.head.appendChild(style)
495
+ }
496
+
497
+ // 会话停滞时优先让宿主自己重建连接: 官方 connection 服务的 reconnect() 会中止当前
498
+ // generation 并立即重试, 页面随之恢复, 不打断会话与未发送的草稿。
499
+ // 连续多次重建仍无效才显示提示, 刷新入口只是最后手段。
500
+ const installStuckSession = ({ win, options, clock, connection, diag }) => {
501
+ const doc = win?.document
502
+ if (doc === undefined || typeof doc.createElement !== 'function') return { installed: false, reason: 'document unavailable' }
503
+ if (options?.enabled === false) return { installed: false, reason: 'disabled' }
504
+ // 样式随每次安装重注: HMR 重建会先删掉本插件的 style
505
+ ensureStuckStyle(doc)
506
+ if (win[STUCK_MARKER] === true) return { installed: false, reason: 'already installed' }
507
+ const state = { since: undefined, notice: undefined, attempts: 0, lastAttemptAt: undefined }
508
+ const reload = () => { try { win.location?.reload?.() } catch { } }
509
+ const showNotice = () => {
510
+ if (state.notice !== undefined || doc.getElementById(STUCK_NOTICE_ID) !== null) return
511
+ const notice = doc.createElement('div')
512
+ notice.setAttribute('id', STUCK_NOTICE_ID)
513
+ notice.setAttribute('data-plugin', PLUGIN_ID)
514
+ notice.setAttribute('role', 'status')
515
+ notice.className = 'bp-stuck'
516
+ const text = doc.createElement('span')
517
+ text.textContent = '连接重建未成功,可手动刷新页面'
518
+ const action = doc.createElement('button')
519
+ action.setAttribute('type', 'button')
520
+ action.className = 'bp-stuck__action'
521
+ action.textContent = '刷新页面'
522
+ action.addEventListener('click', reload)
523
+ notice.appendChild(text)
524
+ notice.appendChild(action)
525
+ doc.body.appendChild(notice)
526
+ state.notice = notice
527
+ }
528
+ const hideNotice = () => {
529
+ if (state.notice === undefined) return
530
+ state.notice.remove()
531
+ state.notice = undefined
532
+ }
533
+ const revive = () => {
534
+ if (options.reconnect !== true || connection === undefined) return false
535
+ if (state.attempts >= options.maxReconnectAttempts) return false
536
+ const now = clock.now()
537
+ if (state.lastAttemptAt !== undefined && now - state.lastAttemptAt < options.reconnectIntervalMs) return true
538
+ state.attempts += 1
539
+ state.lastAttemptAt = now
540
+ try {
541
+ connection.reconnect?.()
542
+ } catch {
543
+ return false
544
+ }
545
+ console.info(`${LOG_PREFIX} session stalled, requesting reconnect #${state.attempts}`)
546
+ return true
547
+ }
548
+ const blocked = () => diag !== undefined && diag.blockedAt !== undefined && clock.now() - diag.blockedAt < options.stallMs * 2
549
+ const tick = () => {
550
+ const now = clock.now()
551
+ if (!sessionStalled(doc) && !blocked()) {
552
+ if (state.attempts > 0) console.info(`${LOG_PREFIX} session recovered after ${state.attempts} reconnect attempt(s)`)
553
+ state.since = undefined
554
+ state.attempts = 0
555
+ state.lastAttemptAt = undefined
556
+ hideNotice()
557
+ return
558
+ }
559
+ if (state.since === undefined) state.since = now
560
+ if (now - state.since < options.stallMs) return
561
+ if (revive()) return
562
+ showNotice()
563
+ }
564
+ const timer = clock.setInterval(tick, options.checkMs)
565
+ win[STUCK_MARKER] = true
566
+ return { installed: true, state, timer }
567
+ }
568
+
569
+ const loadConfig = async () => {
570
+ try {
571
+ const response = await fetch(CONFIG_PATH, {
572
+ signal: AbortSignal.timeout(CONFIG_TIMEOUT_MS),
573
+ headers: { accept: 'application/json' },
574
+ })
575
+ if (!response.ok) return undefined
576
+ const body = await response.json()
577
+ return body?.config
578
+ } catch {
579
+ // 旧宿主没有该路由:退回内联默认值,补丁照常按默认开关安装
580
+ return undefined
581
+ }
582
+ }
583
+
584
+ const installTerminalWatchdogWhenReady = (ctx, options, report) => {
585
+ const start = (holder) => {
586
+ try {
587
+ report.features.terminalWatchdog = installTerminalWatchdog({
588
+ holder,
589
+ options,
590
+ timers,
591
+ isVisible: () => document.visibilityState !== 'hidden',
592
+ })
593
+ } catch (error) {
594
+ report.features.terminalWatchdog = { installed: false, reason: error instanceof Error ? error.message : String(error) }
595
+ }
596
+ }
597
+ if (typeof ctx.inject === 'function') {
598
+ report.features.terminalWatchdog = { installed: false, reason: 'waiting for webTerminals' }
599
+ ctx.inject(['webTerminals'], (sub) => {
600
+ start(sub.webTerminals)
601
+ console.info(`${LOG_PREFIX} terminal watchdog installed`, JSON.stringify(report.features.terminalWatchdog))
602
+ })
603
+ return
604
+ }
605
+ const holder = ctx.get?.('webTerminals')
606
+ if (holder === undefined) {
607
+ report.features.terminalWatchdog = { installed: false, reason: 'webTerminals unavailable' }
608
+ return
609
+ }
610
+ start(holder)
611
+ }
612
+
613
+
614
+ // 每个会话行都会订阅一条常驻 job/list 流, 会话越多流越多(实测 361 条),
615
+ // 每次 job 变化宿主逐条推全量帧, 主线程随之出现数百毫秒长任务。
616
+ // 默认窗口只给主会话前 base 条; 点击过的会话额外放行(子 agent 会话只有点击才建);
617
+ // 总数到 max 后按加入顺序淘汰最老的一条, 被淘汰或未放行的行只是不显示任务状态。
618
+ const JOB_LIMIT_MARKER = Symbol.for('@mzzsfy/dsh-bug-perf/job-stream-limit')
619
+ const SESSION_ROW_PREFIX = 'session:'
620
+ const installJobStreamLimit = ({ jobs, options, sessionsOf, doc }) => {
621
+ if (jobs === undefined || typeof jobs.watchRows !== 'function') return { installed: false, reason: 'jobs unavailable' }
622
+ if (jobs[JOB_LIMIT_MARKER] !== undefined) return { installed: false, reason: 'already installed' }
623
+ const original = jobs.watchRows.bind(jobs)
624
+ const entries = new Map()
625
+ const order = []
626
+ const state = { main: 0, subagent: 0, clicked: 0, rejected: 0, rejectedSubagent: 0, evicted: 0, summaries: 0, subagentSessions: 0 }
627
+ // sessions 服务可能晚于 jobs 就绪, 每次判定都重新取; 快照未变时复用解析结果
628
+ const cache = { snapshot: undefined, byId: undefined }
629
+ const readIndex = () => {
630
+ const sessions = typeof sessionsOf === 'function' ? sessionsOf() : sessionsOf
631
+ const snapshot = sessions?.list?.getSnapshot?.()
632
+ if (snapshot === undefined) return undefined
633
+ if (cache.snapshot === snapshot) return cache.byId
634
+ const byId = snapshot.byId === undefined ? undefined : new Map(Object.entries(snapshot.byId))
635
+ cache.snapshot = snapshot
636
+ cache.byId = byId
637
+ if (byId !== undefined) {
638
+ state.summaries = byId.size
639
+ state.subagentSessions = [...byId.values()].filter((item) => item?.origin === 'subagent' || item?.parentSessionId !== undefined).length
640
+ }
641
+ return byId
642
+ }
643
+ const isSubagent = (sessionId) => {
644
+ const summary = readIndex()?.get(sessionId)
645
+ return summary?.origin === 'subagent' || summary?.parentSessionId !== undefined
646
+ }
647
+ const forget = (key) => {
648
+ const entry = entries.get(key)
649
+ if (entry === undefined) return
650
+ entries.delete(key)
651
+ if (entry.subagent) state.subagent -= 1
652
+ else state.main -= 1
653
+ const index = order.indexOf(key)
654
+ if (index >= 0) order.splice(index, 1)
655
+ }
656
+ const evictOldest = () => {
657
+ while (order.length > 0) {
658
+ const key = order[0]
659
+ const entry = entries.get(key)
660
+ if (entry === undefined) { order.shift(); continue }
661
+ forget(key)
662
+ entry.release()
663
+ state.evicted += 1
664
+ return true
665
+ }
666
+ return false
667
+ }
668
+ const add = (key, clicked) => {
669
+ if (entries.size >= options.max && !evictOldest()) return undefined
670
+ const subagent = isSubagent(key)
671
+ const entry = { release: original(key), subagent, clicked, refs: 1 }
672
+ entries.set(key, entry)
673
+ order.push(key)
674
+ if (subagent) state.subagent += 1
675
+ else state.main += 1
676
+ return entry
677
+ }
678
+ const releaser = (key, entry) => {
679
+ let released = false
680
+ return () => {
681
+ if (released) return
682
+ released = true
683
+ entry.refs -= 1
684
+ if (entry.refs > 0) return
685
+ if (entries.get(key) !== entry) return
686
+ forget(key)
687
+ entry.release()
688
+ }
689
+ }
690
+ jobs.watchRows = (sessionId) => {
691
+ const key = String(sessionId)
692
+ const existing = entries.get(key)
693
+ if (existing !== undefined) {
694
+ existing.refs += 1
695
+ return releaser(key, existing)
696
+ }
697
+ const subagent = isSubagent(key)
698
+ // 子 agent 会话不占默认窗口, 只由点击放行
699
+ if (subagent) {
700
+ state.rejected += 1
701
+ state.rejectedSubagent += 1
702
+ return () => { }
703
+ }
704
+ if (state.main >= options.base) {
705
+ state.rejected += 1
706
+ return () => { }
707
+ }
708
+ const entry = add(key, false)
709
+ return entry === undefined ? (() => { }) : releaser(key, entry)
710
+ }
711
+ const promote = (sessionId) => {
712
+ const key = String(sessionId)
713
+ const existing = entries.get(key)
714
+ if (existing !== undefined) { existing.clicked = true; return }
715
+ const entry = add(key, true)
716
+ if (entry === undefined) return
717
+ state.clicked += 1
718
+ }
719
+ const onDocumentClick = (event) => {
720
+ const row = event?.target?.closest?.('[data-row-key]')
721
+ const rowKey = row?.getAttribute?.('data-row-key')
722
+ if (typeof rowKey !== 'string' || !rowKey.startsWith(SESSION_ROW_PREFIX)) return
723
+ promote(rowKey.slice(SESSION_ROW_PREFIX.length))
724
+ }
725
+ doc?.addEventListener?.('click', onDocumentClick, true)
726
+ const dispose = () => {
727
+ doc?.removeEventListener?.('click', onDocumentClick, true)
728
+ for (const [key, entry] of [...entries]) {
729
+ forget(key)
730
+ entry.release()
731
+ }
732
+ }
733
+ Object.defineProperty(jobs, JOB_LIMIT_MARKER, { value: { state, dispose }, configurable: true })
734
+ return { installed: true, state, dispose }
735
+ }
736
+
737
+ const summarize = (result) => (result.installed === true ? { installed: true } : { installed: false, reason: result.reason })
738
+
739
+ // 安装失败只进入报告, 不影响页面与其它补丁
740
+ const safely = (install) => {
741
+ try {
742
+ return install()
743
+ } catch (error) {
744
+ return { installed: false, reason: error instanceof Error ? error.message : String(error) }
745
+ }
746
+ }
747
+
748
+ const apply = (ctx) => {
749
+ const report = { config: undefined, features: {} }
750
+ window.__dshBugPerf = report
751
+ const diag = createDiag(timers)
752
+ report.diag = diag
753
+ startDiagSampling({ win: window, clock: timers, diag, doc: window.document })
754
+ // 会话流连接在页面启动早期建立, 保活先按默认值同步安装, 配置到达后再校正间隔
755
+ const keepAlive = safely(() => installSocketKeepAlive({
756
+ win: window,
757
+ options: DEFAULT_CONFIG.socketKeepAlive,
758
+ clock: timers,
759
+ diag,
760
+ onDisabled: (reason) => { report.features.socketKeepAlive = { installed: false, reason } },
761
+ }))
762
+ report.features.socketKeepAlive = summarize(keepAlive)
763
+ void (async () => {
764
+ const config = normalizeConfig(await loadConfig())
765
+ report.config = config
766
+ if (!config.socketKeepAlive.enabled) {
767
+ keepAlive.uninstall?.()
768
+ report.features.socketKeepAlive = { installed: false, reason: 'disabled' }
769
+ } else if (keepAlive.installed === true) {
770
+ keepAlive.setIntervalMs?.(config.socketKeepAlive.intervalMs)
771
+ }
772
+ report.features.restartCoalesce = config.restartCoalesce.enabled
773
+ ? installRestartCoalesceFromRequire({ requireImpl: require, windowMs: config.restartCoalesce.windowMs, timers })
774
+ : { installed: false, reason: 'disabled' }
775
+ if (config.terminalWatchdog.enabled) {
776
+ installTerminalWatchdogWhenReady(ctx, config.terminalWatchdog, report)
777
+ } else {
778
+ report.features.terminalWatchdog = { installed: false, reason: 'disabled' }
779
+ }
780
+ const stuckOptions = config.stuckSession
781
+ const startStuckSession = (connection) => {
782
+ report.actions = { reconnect: () => { try { connection?.reconnect?.() } catch { } } }
783
+ report.features.stuckSession = stuckOptions.enabled
784
+ ? summarize(safely(() => installStuckSession({ win: window, options: stuckOptions, clock: timers, connection, diag })))
785
+ : { installed: false, reason: 'disabled' }
786
+ }
787
+ const startJobStreamLimit = (jobs) => {
788
+ report.features.jobStreamLimit = config.jobStreamLimit.enabled
789
+ ? safely(() => {
790
+ const result = installJobStreamLimit({ jobs, options: config.jobStreamLimit, sessionsOf: () => ctx.get?.('sessions'), doc: window.document })
791
+ return result.installed === true ? { installed: true, state: result.state } : result
792
+ })
793
+ : { installed: false, reason: 'disabled' }
794
+ }
795
+ const readyJobs = ctx.get?.('jobs')
796
+ if (readyJobs !== undefined || ctx.inject === undefined) {
797
+ startJobStreamLimit(readyJobs)
798
+ } else {
799
+ ctx.inject(['jobs'], (jobs) => { startJobStreamLimit(jobs) })
800
+ }
801
+ const readyConnection = ctx.get?.('connection')
802
+ if (readyConnection !== undefined || ctx.inject === undefined) {
803
+ startStuckSession(readyConnection)
804
+ } else {
805
+ // connection 由核心插件提供; 等它就绪后再装, 避免错过主动重连入口
806
+ ctx.inject(['connection'], (connection) => { startStuckSession(connection) })
807
+ }
808
+ console.info(`${LOG_PREFIX} applied`, JSON.stringify(report.features))
809
+ })().catch((error) => {
810
+ report.features.applyError = error instanceof Error ? error.message : String(error)
811
+ console.warn(`${LOG_PREFIX} apply failed:`, report.features.applyError)
812
+ })
813
+ }
814
+
815
+ return {
816
+ inject: [],
817
+ apply,
818
+ __test: {
819
+ DEFAULT_CONFIG,
820
+ LIMITS,
821
+ normalizeConfig,
822
+ installRestartCoalesce,
823
+ installRestartCoalesceFromRequire,
824
+ watchdogTick,
825
+ installTerminalWatchdog,
826
+ installSocketKeepAlive,
827
+ installJobStreamLimit,
828
+ installStuckSession,
829
+ sessionStalled,
830
+ },
831
+ }
832
+ },
833
+ })