@mzzsfy/dsh-bug-perf 0.0.0-stage → 0.2.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,1158 @@
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
+ const PKG_ID = '@mzzsfy/dsh-bug-perf'
11
+ const NAMESPACE = 'bug-perf'
12
+ const DIAG_SAMPLE_MS = 5000
13
+ const DIAG_MAX_SAMPLES = 720
14
+ const DIAG_DEFAULT_REPORT_EVERY = 12
15
+
16
+ // 设置卡片依赖 React;缺失时只跳过 UI,不影响其余补丁
17
+ let React
18
+ try {
19
+ React = require('react')
20
+ } catch {
21
+ React = undefined
22
+ }
23
+
24
+ // 与 src/config.mjs 同源;客户端不能 import 共享模块,内联副本由测试对表
25
+ const DEFAULT_CONFIG = {
26
+ restartCoalesce: { enabled: true, windowMs: 100 },
27
+ terminalWatchdog: { enabled: true, checkMs: 2000, stallMs: 8000, cooldownMs: 60000 },
28
+ socketKeepAlive: { enabled: true, intervalMs: 1500 },
29
+ stuckSession: { enabled: true, checkMs: 2000, stallMs: 8000, reconnect: true, maxReconnectAttempts: 5, reconnectIntervalMs: 15000 },
30
+ jobStreamLimit: { enabled: true, base: 32, max: 48 },
31
+ listSlim: { enabled: true },
32
+ diag: { enabled: false, reportEvery: DIAG_DEFAULT_REPORT_EVERY },
33
+ }
34
+ const LIMITS = {
35
+ restartCoalesce: { windowMs: { min: 0, max: 2000 } },
36
+ terminalWatchdog: {
37
+ checkMs: { min: 250, max: 60000 },
38
+ stallMs: { min: 1000, max: 600000 },
39
+ cooldownMs: { min: 1000, max: 3600000 },
40
+ },
41
+ socketKeepAlive: { intervalMs: { min: 500, max: 60000 } },
42
+ stuckSession: {
43
+ checkMs: { min: 250, max: 60000 },
44
+ stallMs: { min: 1000, max: 600000 },
45
+ maxReconnectAttempts: { min: 1, max: 20 },
46
+ reconnectIntervalMs: { min: 500, max: 600000 },
47
+ },
48
+ jobStreamLimit: { base: { min: 4, max: 256 }, max: { min: 4, max: 512 } },
49
+ diag: { reportEvery: { min: 1, max: DIAG_MAX_SAMPLES } },
50
+ }
51
+
52
+ const isSection = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
53
+ const sectionOf = (value) => (isSection(value) ? value : {})
54
+ const booleanOr = (value, fallback) => (typeof value === 'boolean' ? value : fallback)
55
+ const numberWithin = (value, bounds, fallback) => {
56
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
57
+ if (value < bounds.min) return fallback
58
+ return value > bounds.max ? bounds.max : value
59
+ }
60
+ const numberClamped = (value, bounds, fallback) => {
61
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
62
+ return Math.min(bounds.max, Math.max(bounds.min, value))
63
+ }
64
+ const normalizeConfig = (raw) => {
65
+ const input = sectionOf(raw)
66
+ const restart = sectionOf(input.restartCoalesce)
67
+ const watchdog = sectionOf(input.terminalWatchdog)
68
+ const keepAlive = sectionOf(input.socketKeepAlive)
69
+ const stuck = sectionOf(input.stuckSession)
70
+ const jobLimit = sectionOf(input.jobStreamLimit)
71
+ const listSlim = sectionOf(input.listSlim)
72
+ const diag = sectionOf(input.diag)
73
+ return {
74
+ restartCoalesce: {
75
+ enabled: booleanOr(restart.enabled, DEFAULT_CONFIG.restartCoalesce.enabled),
76
+ windowMs: numberWithin(restart.windowMs, LIMITS.restartCoalesce.windowMs, DEFAULT_CONFIG.restartCoalesce.windowMs),
77
+ },
78
+ terminalWatchdog: {
79
+ enabled: booleanOr(watchdog.enabled, DEFAULT_CONFIG.terminalWatchdog.enabled),
80
+ checkMs: numberWithin(watchdog.checkMs, LIMITS.terminalWatchdog.checkMs, DEFAULT_CONFIG.terminalWatchdog.checkMs),
81
+ stallMs: numberWithin(watchdog.stallMs, LIMITS.terminalWatchdog.stallMs, DEFAULT_CONFIG.terminalWatchdog.stallMs),
82
+ cooldownMs: numberWithin(watchdog.cooldownMs, LIMITS.terminalWatchdog.cooldownMs, DEFAULT_CONFIG.terminalWatchdog.cooldownMs),
83
+ },
84
+ socketKeepAlive: {
85
+ enabled: booleanOr(keepAlive.enabled, DEFAULT_CONFIG.socketKeepAlive.enabled),
86
+ intervalMs: numberWithin(keepAlive.intervalMs, LIMITS.socketKeepAlive.intervalMs, DEFAULT_CONFIG.socketKeepAlive.intervalMs),
87
+ },
88
+ jobStreamLimit: {
89
+ enabled: booleanOr(jobLimit.enabled, DEFAULT_CONFIG.jobStreamLimit.enabled),
90
+ base: Math.min(
91
+ numberWithin(jobLimit.base, LIMITS.jobStreamLimit.base, DEFAULT_CONFIG.jobStreamLimit.base),
92
+ numberWithin(jobLimit.max, LIMITS.jobStreamLimit.max, DEFAULT_CONFIG.jobStreamLimit.max),
93
+ ),
94
+ max: numberWithin(jobLimit.max, LIMITS.jobStreamLimit.max, DEFAULT_CONFIG.jobStreamLimit.max),
95
+ },
96
+ stuckSession: {
97
+ enabled: booleanOr(stuck.enabled, DEFAULT_CONFIG.stuckSession.enabled),
98
+ checkMs: numberWithin(stuck.checkMs, LIMITS.stuckSession.checkMs, DEFAULT_CONFIG.stuckSession.checkMs),
99
+ stallMs: numberWithin(stuck.stallMs, LIMITS.stuckSession.stallMs, DEFAULT_CONFIG.stuckSession.stallMs),
100
+ reconnect: booleanOr(stuck.reconnect, DEFAULT_CONFIG.stuckSession.reconnect),
101
+ maxReconnectAttempts: numberWithin(stuck.maxReconnectAttempts, LIMITS.stuckSession.maxReconnectAttempts, DEFAULT_CONFIG.stuckSession.maxReconnectAttempts),
102
+ reconnectIntervalMs: numberWithin(stuck.reconnectIntervalMs, LIMITS.stuckSession.reconnectIntervalMs, DEFAULT_CONFIG.stuckSession.reconnectIntervalMs),
103
+ },
104
+ listSlim: {
105
+ enabled: booleanOr(listSlim.enabled, DEFAULT_CONFIG.listSlim.enabled),
106
+ },
107
+ diag: {
108
+ enabled: booleanOr(diag.enabled, DEFAULT_CONFIG.diag.enabled),
109
+ reportEvery: numberClamped(diag.reportEvery, LIMITS.diag.reportEvery, DEFAULT_CONFIG.diag.reportEvery),
110
+ },
111
+ }
112
+ }
113
+
114
+ const timers = {
115
+ now: () => performance.now(),
116
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
117
+ clearTimeout: (id) => clearTimeout(id),
118
+ setInterval: (fn, ms) => setInterval(fn, ms),
119
+ clearInterval: (id) => clearInterval(id),
120
+ }
121
+
122
+ const RESTART_MARKER = Symbol.for('@mzzsfy/dsh-bug-perf/restart-coalesce')
123
+ const WATCHDOG_MARKER = Symbol.for('@mzzsfy/dsh-bug-perf/terminal-watchdog')
124
+
125
+ // 同一 Remote stream 在窗口内的重复 restart 合并为一次尾随执行:
126
+ // restart 无参数, 最新一次即可代表期间的构建请求。
127
+ const installRestartCoalesce = ({ RemoteStream, windowMs, timers: clock }) => {
128
+ const proto = RemoteStream?.prototype
129
+ if (proto === undefined || typeof proto.restart !== 'function') {
130
+ return { installed: false, reason: 'RemoteStream.restart missing' }
131
+ }
132
+ if (proto[RESTART_MARKER] === true) return { installed: false, reason: 'already installed' }
133
+ proto[RESTART_MARKER] = true
134
+ const original = proto.restart
135
+ const states = new WeakMap()
136
+ proto.restart = function coalescedRestart() {
137
+ const now = clock.now()
138
+ let state = states.get(this)
139
+ if (state === undefined) {
140
+ state = { last: -Infinity, pending: undefined }
141
+ states.set(this, state)
142
+ }
143
+ if (now - state.last >= windowMs) {
144
+ if (state.pending !== undefined) {
145
+ clock.clearTimeout(state.pending)
146
+ state.pending = undefined
147
+ }
148
+ state.last = now
149
+ return original.call(this)
150
+ }
151
+ if (state.pending === undefined) {
152
+ state.pending = clock.setTimeout(() => {
153
+ state.pending = undefined
154
+ state.last = clock.now()
155
+ try {
156
+ original.call(this)
157
+ } catch {
158
+ // 实例已释放:尾随调用没有可重建的 generation
159
+ }
160
+ }, windowMs - (now - state.last))
161
+ }
162
+ }
163
+ return { installed: true }
164
+ }
165
+
166
+ const installRestartCoalesceFromRequire = ({ requireImpl, windowMs, timers: clock }) => {
167
+ try {
168
+ const gateway = requireImpl(GATEWAY_CLIENT_ID)
169
+ return installRestartCoalesce({ RemoteStream: gateway?.RemoteStream, windowMs, timers: clock })
170
+ } catch (error) {
171
+ return { installed: false, reason: `gateway client unavailable: ${error instanceof Error ? error.message : String(error)}` }
172
+ }
173
+ }
174
+
175
+ // 终端视图健康判定:纯函数,便于单测与现场排障。
176
+ // armed 表示该视图在本端曾持有写权;只读旁观视图不自动抢控制权。
177
+ const watchdogTick = ({ view, state, options, now, visible }) => {
178
+ const snapshot = view.state.getSnapshot()
179
+ if (!visible) {
180
+ state.changedAt = now
181
+ return 'hidden'
182
+ }
183
+ if (view.revision !== state.revision) {
184
+ state.revision = view.revision
185
+ state.changedAt = now
186
+ if (snapshot.writable === true) state.armed = true
187
+ return 'progress'
188
+ }
189
+ if (snapshot.writable === true) {
190
+ state.armed = true
191
+ state.changedAt = now
192
+ return 'progress'
193
+ }
194
+ if (!state.armed) {
195
+ state.changedAt = now
196
+ return 'idle'
197
+ }
198
+ if (snapshot.phase !== 'connected') {
199
+ state.changedAt = now
200
+ return 'not-connected'
201
+ }
202
+ if (view.pendingRender !== undefined) {
203
+ state.changedAt = now
204
+ return 'pending-render'
205
+ }
206
+ if (view.stream === undefined) {
207
+ state.changedAt = now
208
+ return 'no-stream'
209
+ }
210
+ if (now - state.changedAt < options.stallMs) return 'idle'
211
+ if (now - state.healedAt < options.cooldownMs) return 'cooldown'
212
+ state.healedAt = now
213
+ state.armed = false
214
+ return 'heal'
215
+ }
216
+
217
+ const installTerminalWatchdog = ({ holder, options, timers: clock, isVisible }) => {
218
+ if (holder === undefined || typeof holder.view !== 'function') {
219
+ return { installed: false, reason: 'webTerminals.view missing' }
220
+ }
221
+ if (holder[WATCHDOG_MARKER] === true) return { installed: false, reason: 'already installed' }
222
+ holder[WATCHDOG_MARKER] = true
223
+ const monitors = new WeakMap()
224
+ const monitor = (view) => {
225
+ if (view === undefined || monitors.has(view)) return
226
+ const state = { revision: view.revision, changedAt: clock.now(), armed: false, healedAt: -Infinity }
227
+ monitors.set(view, state)
228
+ const interval = clock.setInterval(() => {
229
+ try {
230
+ const decision = watchdogTick({ view, state, options, now: clock.now(), visible: isVisible() })
231
+ if (decision === 'heal') {
232
+ console.warn(`${LOG_PREFIX} terminal stalled, reconnecting`, { sessionId: view.sessionId, id: view.id })
233
+ view.connect()
234
+ }
235
+ } catch (error) {
236
+ console.warn(`${LOG_PREFIX} watchdog tick failed:`, error instanceof Error ? error.message : String(error))
237
+ }
238
+ }, options.checkMs)
239
+ view.lifetime?.signal?.addEventListener?.('abort', () => clock.clearInterval(interval), { once: true })
240
+ }
241
+ try {
242
+ for (const views of holder.views?.values?.() ?? []) for (const view of views.values()) monitor(view)
243
+ } catch {
244
+ // 视图表结构随宿主版本变化:已有视图漏检,新建视图仍由包装覆盖
245
+ }
246
+ const original = holder.view.bind(holder)
247
+ holder.view = (...args) => {
248
+ const view = original(...args)
249
+ monitor(view)
250
+ return view
251
+ }
252
+ return { installed: true }
253
+ }
254
+
255
+ const PLUGIN_ID = '@mzzsfy/dsh-bug-perf'
256
+ const REMOTE_MUX_PATH = '/api/remote.mux'
257
+ const KEEPALIVE_MARKER = Symbol.for('@mzzsfy/dsh-bug-perf/socket-keepalive')
258
+ const STUCK_MARKER = Symbol.for('@mzzsfy/dsh-bug-perf/stuck-session')
259
+ const KEEPALIVE_STREAM_PREFIX = 'dsh-bug-perf-keepalive'
260
+ const FRAME_TYPE_PREFIX = '{"type":"'
261
+ const STREAM_ID_KEY = '"streamId":"'
262
+ const ENDPOINT_KEY = '"endpoint":"'
263
+ // 事件循环探测: 延迟超过该值视为主线程被占满
264
+ const DIAG_BLOCK_MS = 1000
265
+ const DIAG_PROBE_MS = 2000
266
+ const DIAG_REPORT_PATH = '/api/dsh-bug-perf/diag'
267
+ // 定时器检查节拍: 实际发送间隔由可变的 intervalMs 决定
268
+ const KEEPALIVE_TICK_MS = 500
269
+ // 只有协议级关闭码才可能是宿主拒绝保活帧; 链路断开与主动关闭不计入
270
+ const PROTOCOL_CLOSE_CODES = Object.freeze([1002, 1003, 1007, 1008, 1009, 1011])
271
+ const BREAKER_STRIKES = 2
272
+ // 连接健康存活这么多倍间隔后清零累计, 避免历史事件把补丁永久禁用
273
+ const BREAKER_RECOVER_INTERVALS = 10
274
+ const STUCK_NOTICE_ID = 'dsh-bug-perf-stuck'
275
+ const STUCK_HINT_TEXTS = ['载入历史…', 'Loading history…']
276
+ const STUCK_MESSAGE_SELECTOR = '[data-chat-flow-key]'
277
+ const STUCK_CSS = [
278
+ '.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}',
279
+ '.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}',
280
+ '.bp-stuck__action:hover{background:var(--bp-hover)}',
281
+ ].join('')
282
+
283
+ // 只读诊断: 记录帧计数、消息速率、活跃流与页面规模, 用于定位“越用越卡”。
284
+ // 不解析完整帧、不改动任何流, 采样保留最近 DIAG_MAX_SAMPLES 个点。
285
+ const createDiag = (clock) => ({
286
+ startedAt: clock.now(),
287
+ enabled: false,
288
+ sockets: [],
289
+ samples: [],
290
+ longTasks: [],
291
+ errors: [],
292
+ totals: { opened: 0, closed: 0, messages: 0, sent: 0 },
293
+ })
294
+
295
+ const activeStreams = (sockets) => {
296
+ let total = 0
297
+ for (const socket of sockets) {
298
+ for (const count of Object.values(socket.streams)) total += count
299
+ }
300
+ return total
301
+ }
302
+
303
+ const reportDiag = (win, diag) => {
304
+ const fetchImpl = win.fetch
305
+ if (typeof fetchImpl !== 'function') return
306
+ const payload = {
307
+ at: Date.now(),
308
+ path: win.location?.pathname ?? '',
309
+ totals: diag.totals,
310
+ last: diag.samples.slice(-6),
311
+ blockedMs: diag.blockedMs ?? 0,
312
+ longTasks: diag.longTasks.slice(-8),
313
+ errors: diag.errors.slice(-4),
314
+ }
315
+ try {
316
+ void fetchImpl(`${DIAG_REPORT_PATH}?d=${encodeURIComponent(JSON.stringify(payload))}`, { cache: 'no-store', keepalive: true }).catch(() => { })
317
+ } catch {
318
+ // 上报失败不影响页面
319
+ }
320
+ }
321
+
322
+ const startDiagSampling = ({ win, clock, diag, doc }) => {
323
+ const heapq = () => {
324
+ const memory = win.performance?.memory
325
+ return memory === undefined ? undefined : Math.round(memory.usedJSHeapSize / 1048576)
326
+ }
327
+ const sample = () => {
328
+ const live = diag.sockets.filter((socket) => !socket.closed)
329
+ diag.samples.push({
330
+ t: Math.round(clock.now()),
331
+ heapMB: heapq(),
332
+ nodes: doc?.querySelectorAll?.('*').length,
333
+ sockets: diag.sockets.length,
334
+ liveSockets: live.length,
335
+ activeStreams: activeStreams(live),
336
+ messages: diag.totals.messages,
337
+ sent: diag.totals.sent,
338
+ })
339
+ if (diag.samples.length > DIAG_MAX_SAMPLES) diag.samples.shift()
340
+ }
341
+ sample()
342
+ const timer = clock.setInterval(sample, DIAG_SAMPLE_MS)
343
+ const schedule = win.setTimeout
344
+ if (typeof schedule === 'function') {
345
+ clock.setInterval(() => {
346
+ const started = clock.now()
347
+ schedule(() => {
348
+ const delay = clock.now() - started
349
+ if (delay < DIAG_BLOCK_MS) return
350
+ diag.blockedAt = clock.now()
351
+ diag.blockedMs = Math.round(delay)
352
+ }, 0)
353
+ }, DIAG_PROBE_MS)
354
+ }
355
+ const observer = win.PerformanceObserver === undefined ? undefined : new win.PerformanceObserver((list) => {
356
+ for (const entry of list.getEntries()) {
357
+ diag.longTasks.push({ t: Math.round(clock.now()), ms: Math.round(entry.duration) })
358
+ if (diag.longTasks.length > DIAG_MAX_SAMPLES) diag.longTasks.shift()
359
+ }
360
+ })
361
+ try { observer?.observe({ entryTypes: ['longtask'] }) } catch { }
362
+ win.addEventListener?.('error', (event) => {
363
+ diag.errors.push(String(event?.message ?? '').slice(0, 200))
364
+ if (diag.errors.length > DIAG_MAX_SAMPLES) diag.errors.shift()
365
+ })
366
+ return { timer, observer }
367
+ }
368
+
369
+ // 采样与上报分离:关闭上报只停请求,内存采样继续供停滞判定使用
370
+ const startDiagReporting = ({ win, clock, diag, options }) => {
371
+ if (options?.enabled !== true) {
372
+ diag.enabled = false
373
+ return { enabled: false, reason: 'disabled' }
374
+ }
375
+ const everyMs = options.reportEvery * DIAG_SAMPLE_MS
376
+ const timer = clock.setInterval(() => reportDiag(win, diag), everyMs)
377
+ diag.enabled = true
378
+ return { enabled: true, timer }
379
+ }
380
+
381
+ // 宿主心跳为固定短周期且丢两次即 terminate, 经反代时 pong 常被链路吞掉。
382
+ // 客户端在会话流连接上按间隔发一条未知 streamId 的 item 帧: 宿主收到任意帧都会刷新存活标记,
383
+ // 未知 streamId 的 item 在宿主侧被忽略, 不改动任何流的状态。
384
+ // 只在连接空闲(客户端自己没有上行帧)时补帧; 覆盖范围仅限安装之后新建的连接。
385
+ const installSocketKeepAlive = ({ win, options, clock, onDisabled, diag }) => {
386
+ const Native = win?.WebSocket
387
+ if (typeof Native !== 'function') return { installed: false, reason: 'WebSocket unavailable' }
388
+ if (options?.enabled === false) return { installed: false, reason: 'disabled' }
389
+ if (Native[KEEPALIVE_MARKER] !== undefined) return { installed: false, reason: 'already installed' }
390
+ const state = { intervalMs: options.intervalMs, timers: new Set(), strikes: 0, disabled: false, disabledReason: undefined }
391
+ let Wrapped
392
+ const disable = (reason) => {
393
+ if (state.disabled) return
394
+ state.disabled = true
395
+ state.disabledReason = reason
396
+ for (const id of state.timers) clock.clearInterval(id)
397
+ state.timers.clear()
398
+ if (win.WebSocket === Wrapped) win.WebSocket = Native
399
+ if (Native[KEEPALIVE_MARKER] === Wrapped) delete Native[KEEPALIVE_MARKER]
400
+ console.warn(`${LOG_PREFIX} socket keepalive disabled: ${reason}`)
401
+ onDisabled?.(reason)
402
+ }
403
+ const nextStreamId = () => `${KEEPALIVE_STREAM_PREFIX}-${clock.now()}-${Math.random().toString(36).slice(2)}`
404
+ // 只读帧统计: 用字符串扫描代替整帧解析, 用于观察活跃流与帧速率, 不改动任何流
405
+ const readFrame = (raw) => {
406
+ if (typeof raw !== 'string' || !raw.startsWith(FRAME_TYPE_PREFIX)) return undefined
407
+ const typeEnd = raw.indexOf('"', FRAME_TYPE_PREFIX.length)
408
+ if (typeEnd < 0) return undefined
409
+ const type = raw.slice(FRAME_TYPE_PREFIX.length, typeEnd)
410
+ const idAt = raw.indexOf(STREAM_ID_KEY)
411
+ const streamId = idAt < 0 ? '' : raw.slice(idAt + STREAM_ID_KEY.length, raw.indexOf('"', idAt + STREAM_ID_KEY.length))
412
+ const endpointAt = raw.indexOf(ENDPOINT_KEY)
413
+ const endpoint = endpointAt < 0 ? '' : raw.slice(endpointAt + ENDPOINT_KEY.length, raw.indexOf('"', endpointAt + ENDPOINT_KEY.length))
414
+ return { type, streamId, endpoint }
415
+ }
416
+ const trackFrame = (socket, raw) => {
417
+ const frame = readFrame(raw)
418
+ if (frame === undefined) return
419
+ if (frame.type === 'open') {
420
+ const endpoint = frame.endpoint === '' ? 'unknown' : frame.endpoint
421
+ socket.byStream.set(frame.streamId, endpoint)
422
+ socket.streams[endpoint] = (socket.streams[endpoint] ?? 0) + 1
423
+ return
424
+ }
425
+ if (frame.type !== 'end' && frame.type !== 'cancel') return
426
+ const endpoint = socket.byStream.get(frame.streamId)
427
+ if (endpoint === undefined) return
428
+ socket.byStream.delete(frame.streamId)
429
+ socket.streams[endpoint] = (socket.streams[endpoint] ?? 0) - 1
430
+ }
431
+ Wrapped = class KeepAliveSocket extends Native {
432
+ constructor(...args) {
433
+ super(...args)
434
+ if (state.disabled) return
435
+ let path
436
+ try { path = new URL(String(args[0]), win.location?.href ?? 'https://localhost/').pathname } catch { return }
437
+ if (path !== REMOTE_MUX_PATH) return
438
+ const openedAt = clock.now()
439
+ const site = { lastUpstreamAt: openedAt, lastKeepAliveAt: openedAt, keepAliveSent: false }
440
+ const socketStats = diag === undefined ? undefined : { streams: {}, byStream: new Map(), openedAt: clock.now(), closed: false }
441
+ if (socketStats !== undefined) {
442
+ diag.sockets.push(socketStats)
443
+ diag.totals.opened += 1
444
+ }
445
+ this.addEventListener('message', (event) => {
446
+ if (socketStats === undefined) return
447
+ trackFrame(socketStats, event?.data)
448
+ diag.totals.messages += 1
449
+ })
450
+ this.addEventListener('close', () => {
451
+ if (socketStats === undefined || socketStats.closed) return
452
+ socketStats.closed = true
453
+ diag.totals.closed += 1
454
+ })
455
+ const rawSend = this.send.bind(this)
456
+ this.send = (data) => {
457
+ site.lastUpstreamAt = clock.now()
458
+ if (socketStats !== undefined) {
459
+ trackFrame(socketStats, data)
460
+ diag.totals.sent += 1
461
+ }
462
+ return rawSend(data)
463
+ }
464
+ const timer = clock.setInterval(() => {
465
+ if (state.disabled || this.readyState !== Native.OPEN) return
466
+ const now = clock.now()
467
+ if (state.strikes > 0 && now - openedAt >= state.intervalMs * BREAKER_RECOVER_INTERVALS) state.strikes = 0
468
+ if (now - Math.max(site.lastUpstreamAt, site.lastKeepAliveAt) < state.intervalMs) return
469
+ try {
470
+ rawSend(JSON.stringify({ type: 'item', streamId: nextStreamId(), value: 0 }))
471
+ site.lastKeepAliveAt = now
472
+ site.keepAliveSent = true
473
+ } catch {
474
+ // 已进入关闭流程: 交给 close 事件清理
475
+ }
476
+ }, KEEPALIVE_TICK_MS)
477
+ state.timers.add(timer)
478
+ this.addEventListener('close', (event) => {
479
+ clock.clearInterval(timer)
480
+ state.timers.delete(timer)
481
+ if (state.disabled || !site.keepAliveSent || !PROTOCOL_CLOSE_CODES.includes(event?.code)) return
482
+ state.strikes += 1
483
+ if (state.strikes >= BREAKER_STRIKES) disable('protocol closed the connection after a keepalive frame')
484
+ })
485
+ }
486
+ }
487
+ Native[KEEPALIVE_MARKER] = Wrapped
488
+ win.WebSocket = Wrapped
489
+ return {
490
+ installed: true,
491
+ state,
492
+ setIntervalMs: (ms) => { state.intervalMs = ms },
493
+ uninstall: () => disable('disabled'),
494
+ }
495
+ }
496
+
497
+ // 与宿主自己的列表项口径一致: 排除分组行、过程行、空行与隐藏子树
498
+ const isMeaningfulRow = (node) => {
499
+ if (node.hasAttribute?.('data-chat-group-key')) return false
500
+ if (node.getAttribute?.('data-chat-flow-kind') === 'turn-process') return false
501
+ const hiddenAncestor = node.closest?.('[hidden]')
502
+ if (hiddenAncestor !== null && hiddenAncestor !== undefined) return false
503
+ if ((node.childElementCount ?? 0) === 0 && (node.textContent ?? '').trim() === '') return false
504
+ return true
505
+ }
506
+
507
+ // 加载态判定只认会话流容器里没有任何有效消息行、且直接子节点文本与加载提示完全一致的节点;
508
+ // 对话正文或历史里出现同样文字不会命中, 终端等其它区域的存在也不影响判断。
509
+ const sessionStalled = (doc) => {
510
+ const flow = doc.querySelector?.('[data-chat-flow]')
511
+ if (flow === null || flow === undefined) return false
512
+ const rows = flow.querySelectorAll?.(STUCK_MESSAGE_SELECTOR) ?? []
513
+ for (const row of rows) {
514
+ if (isMeaningfulRow(row)) return false
515
+ }
516
+ for (const child of flow.children ?? []) {
517
+ const text = typeof child.textContent === 'string' ? child.textContent.trim() : ''
518
+ if (STUCK_HINT_TEXTS.includes(text)) return true
519
+ }
520
+ return false
521
+ }
522
+
523
+ const ensureStuckStyle = (doc) => {
524
+ const selector = `style[data-plugin="${PLUGIN_ID}"]`
525
+ const existing = doc.querySelector?.(selector)
526
+ if (existing !== null && existing !== undefined) return
527
+ const style = doc.createElement('style')
528
+ style.setAttribute('data-plugin', PLUGIN_ID)
529
+ style.textContent = STUCK_CSS
530
+ doc.head.appendChild(style)
531
+ }
532
+
533
+ // 会话停滞时优先让宿主自己重建连接: 官方 connection 服务的 reconnect() 会中止当前
534
+ // generation 并立即重试, 页面随之恢复, 不打断会话与未发送的草稿。
535
+ // 连续多次重建仍无效才显示提示, 刷新入口只是最后手段。
536
+ const installStuckSession = ({ win, options, clock, connection, diag }) => {
537
+ const doc = win?.document
538
+ if (doc === undefined || typeof doc.createElement !== 'function') return { installed: false, reason: 'document unavailable' }
539
+ if (options?.enabled === false) return { installed: false, reason: 'disabled' }
540
+ // 样式随每次安装重注: HMR 重建会先删掉本插件的 style
541
+ ensureStuckStyle(doc)
542
+ if (win[STUCK_MARKER] === true) return { installed: false, reason: 'already installed' }
543
+ const state = { since: undefined, notice: undefined, attempts: 0, lastAttemptAt: undefined }
544
+ const reload = () => { try { win.location?.reload?.() } catch { } }
545
+ const showNotice = () => {
546
+ if (state.notice !== undefined || doc.getElementById(STUCK_NOTICE_ID) !== null) return
547
+ const notice = doc.createElement('div')
548
+ notice.setAttribute('id', STUCK_NOTICE_ID)
549
+ notice.setAttribute('data-plugin', PLUGIN_ID)
550
+ notice.setAttribute('role', 'status')
551
+ notice.className = 'bp-stuck'
552
+ const text = doc.createElement('span')
553
+ text.textContent = '连接重建未成功,可手动刷新页面'
554
+ const action = doc.createElement('button')
555
+ action.setAttribute('type', 'button')
556
+ action.className = 'bp-stuck__action'
557
+ action.textContent = '刷新页面'
558
+ action.addEventListener('click', reload)
559
+ notice.appendChild(text)
560
+ notice.appendChild(action)
561
+ doc.body.appendChild(notice)
562
+ state.notice = notice
563
+ }
564
+ const hideNotice = () => {
565
+ if (state.notice === undefined) return
566
+ state.notice.remove()
567
+ state.notice = undefined
568
+ }
569
+ const revive = () => {
570
+ if (options.reconnect !== true || connection === undefined) return false
571
+ if (state.attempts >= options.maxReconnectAttempts) return false
572
+ const now = clock.now()
573
+ if (state.lastAttemptAt !== undefined && now - state.lastAttemptAt < options.reconnectIntervalMs) return true
574
+ state.attempts += 1
575
+ state.lastAttemptAt = now
576
+ try {
577
+ connection.reconnect?.()
578
+ } catch {
579
+ return false
580
+ }
581
+ console.info(`${LOG_PREFIX} session stalled, requesting reconnect #${state.attempts}`)
582
+ return true
583
+ }
584
+ const blocked = () => diag !== undefined && diag.blockedAt !== undefined && clock.now() - diag.blockedAt < options.stallMs * 2
585
+ const tick = () => {
586
+ const now = clock.now()
587
+ if (!sessionStalled(doc) && !blocked()) {
588
+ if (state.attempts > 0) console.info(`${LOG_PREFIX} session recovered after ${state.attempts} reconnect attempt(s)`)
589
+ state.since = undefined
590
+ state.attempts = 0
591
+ state.lastAttemptAt = undefined
592
+ hideNotice()
593
+ return
594
+ }
595
+ if (state.since === undefined) state.since = now
596
+ if (now - state.since < options.stallMs) return
597
+ if (revive()) return
598
+ showNotice()
599
+ }
600
+ const timer = clock.setInterval(tick, options.checkMs)
601
+ win[STUCK_MARKER] = true
602
+ return { installed: true, state, timer }
603
+ }
604
+
605
+ const loadConfig = async () => {
606
+ try {
607
+ const response = await fetch(CONFIG_PATH, {
608
+ signal: AbortSignal.timeout(CONFIG_TIMEOUT_MS),
609
+ headers: { accept: 'application/json' },
610
+ })
611
+ if (!response.ok) return undefined
612
+ const body = await response.json()
613
+ return body?.config
614
+ } catch {
615
+ // 旧宿主没有该路由:退回内联默认值,补丁照常按默认开关安装
616
+ return undefined
617
+ }
618
+ }
619
+
620
+ const installTerminalWatchdogWhenReady = (ctx, options, report) => {
621
+ const start = (holder) => {
622
+ try {
623
+ report.features.terminalWatchdog = installTerminalWatchdog({
624
+ holder,
625
+ options,
626
+ timers,
627
+ isVisible: () => document.visibilityState !== 'hidden',
628
+ })
629
+ } catch (error) {
630
+ report.features.terminalWatchdog = { installed: false, reason: error instanceof Error ? error.message : String(error) }
631
+ }
632
+ }
633
+ if (typeof ctx.inject === 'function') {
634
+ report.features.terminalWatchdog = { installed: false, reason: 'waiting for webTerminals' }
635
+ ctx.inject(['webTerminals'], (sub) => {
636
+ start(sub.webTerminals)
637
+ console.info(`${LOG_PREFIX} terminal watchdog installed`, JSON.stringify(report.features.terminalWatchdog))
638
+ })
639
+ return
640
+ }
641
+ const holder = ctx.get?.('webTerminals')
642
+ if (holder === undefined) {
643
+ report.features.terminalWatchdog = { installed: false, reason: 'webTerminals unavailable' }
644
+ return
645
+ }
646
+ start(holder)
647
+ }
648
+
649
+
650
+ // 每个会话行都会订阅一条常驻 job/list 流, 会话越多流越多(实测 361 条),
651
+ // 每次 job 变化宿主逐条推全量帧, 主线程随之出现数百毫秒长任务。
652
+ // 默认窗口只给主会话前 base 条; 点击过的会话额外放行(子 agent 会话只有点击才建);
653
+ // 总数到 max 后按加入顺序淘汰最老的一条, 被淘汰或未放行的行只是不显示任务状态。
654
+ const JOB_LIMIT_MARKER = Symbol.for('@mzzsfy/dsh-bug-perf/job-stream-limit')
655
+ const SESSION_ROW_PREFIX = 'session:'
656
+ const installJobStreamLimit = ({ jobs, options, sessionsOf, doc }) => {
657
+ if (jobs === undefined || typeof jobs.watchRows !== 'function') return { installed: false, reason: 'jobs unavailable' }
658
+ if (jobs[JOB_LIMIT_MARKER] !== undefined) return { installed: false, reason: 'already installed' }
659
+ const original = jobs.watchRows.bind(jobs)
660
+ const entries = new Map()
661
+ const order = []
662
+ const state = { main: 0, subagent: 0, clicked: 0, rejected: 0, rejectedSubagent: 0, evicted: 0, summaries: 0, subagentSessions: 0 }
663
+ // sessions 服务可能晚于 jobs 就绪, 每次判定都重新取; 快照未变时复用解析结果
664
+ const cache = { snapshot: undefined, byId: undefined }
665
+ const readIndex = () => {
666
+ const sessions = typeof sessionsOf === 'function' ? sessionsOf() : sessionsOf
667
+ const snapshot = sessions?.list?.getSnapshot?.()
668
+ if (snapshot === undefined) return undefined
669
+ if (cache.snapshot === snapshot) return cache.byId
670
+ const byId = snapshot.byId === undefined ? undefined : new Map(Object.entries(snapshot.byId))
671
+ cache.snapshot = snapshot
672
+ cache.byId = byId
673
+ if (byId !== undefined) {
674
+ state.summaries = byId.size
675
+ state.subagentSessions = [...byId.values()].filter((item) => item?.origin === 'subagent' || item?.parentSessionId !== undefined).length
676
+ }
677
+ return byId
678
+ }
679
+ const isSubagent = (sessionId) => {
680
+ const summary = readIndex()?.get(sessionId)
681
+ return summary?.origin === 'subagent' || summary?.parentSessionId !== undefined
682
+ }
683
+ const forget = (key) => {
684
+ const entry = entries.get(key)
685
+ if (entry === undefined) return
686
+ entries.delete(key)
687
+ if (entry.subagent) state.subagent -= 1
688
+ else state.main -= 1
689
+ const index = order.indexOf(key)
690
+ if (index >= 0) order.splice(index, 1)
691
+ }
692
+ const evictOldest = () => {
693
+ while (order.length > 0) {
694
+ const key = order[0]
695
+ const entry = entries.get(key)
696
+ if (entry === undefined) { order.shift(); continue }
697
+ forget(key)
698
+ entry.release()
699
+ state.evicted += 1
700
+ return true
701
+ }
702
+ return false
703
+ }
704
+ const add = (key, clicked) => {
705
+ if (entries.size >= options.max && !evictOldest()) return undefined
706
+ const subagent = isSubagent(key)
707
+ const entry = { release: original(key), subagent, clicked, refs: 1 }
708
+ entries.set(key, entry)
709
+ order.push(key)
710
+ if (subagent) state.subagent += 1
711
+ else state.main += 1
712
+ return entry
713
+ }
714
+ const releaser = (key, entry) => {
715
+ let released = false
716
+ return () => {
717
+ if (released) return
718
+ released = true
719
+ entry.refs -= 1
720
+ if (entry.refs > 0) return
721
+ if (entries.get(key) !== entry) return
722
+ forget(key)
723
+ entry.release()
724
+ }
725
+ }
726
+ jobs.watchRows = (sessionId) => {
727
+ const key = String(sessionId)
728
+ const existing = entries.get(key)
729
+ if (existing !== undefined) {
730
+ existing.refs += 1
731
+ return releaser(key, existing)
732
+ }
733
+ const subagent = isSubagent(key)
734
+ // 子 agent 会话不占默认窗口, 只由点击放行
735
+ if (subagent) {
736
+ state.rejected += 1
737
+ state.rejectedSubagent += 1
738
+ return () => { }
739
+ }
740
+ if (state.main >= options.base) {
741
+ state.rejected += 1
742
+ return () => { }
743
+ }
744
+ const entry = add(key, false)
745
+ return entry === undefined ? (() => { }) : releaser(key, entry)
746
+ }
747
+ const promote = (sessionId) => {
748
+ const key = String(sessionId)
749
+ const existing = entries.get(key)
750
+ if (existing !== undefined) { existing.clicked = true; return }
751
+ const entry = add(key, true)
752
+ if (entry === undefined) return
753
+ state.clicked += 1
754
+ }
755
+ const onDocumentClick = (event) => {
756
+ const row = event?.target?.closest?.('[data-row-key]')
757
+ const rowKey = row?.getAttribute?.('data-row-key')
758
+ if (typeof rowKey !== 'string' || !rowKey.startsWith(SESSION_ROW_PREFIX)) return
759
+ promote(rowKey.slice(SESSION_ROW_PREFIX.length))
760
+ }
761
+ doc?.addEventListener?.('click', onDocumentClick, true)
762
+ const dispose = () => {
763
+ doc?.removeEventListener?.('click', onDocumentClick, true)
764
+ for (const [key, entry] of [...entries]) {
765
+ forget(key)
766
+ entry.release()
767
+ }
768
+ }
769
+ Object.defineProperty(jobs, JOB_LIMIT_MARKER, { value: { state, dispose }, configurable: true })
770
+ return { installed: true, state, dispose }
771
+ }
772
+
773
+ const summarize = (result) => (result.installed === true ? { installed: true } : { installed: false, reason: result.reason })
774
+
775
+ // 安装失败只进入报告, 不影响页面与其它补丁
776
+ const safely = (install) => {
777
+ try {
778
+ return install()
779
+ } catch (error) {
780
+ return { installed: false, reason: error instanceof Error ? error.message : String(error) }
781
+ }
782
+ }
783
+
784
+ // 设置卡片:注册进官方「插件」设置页与插件管理页配置槽,不占独立导航项
785
+ const UI_SECTIONS = [
786
+ {
787
+ key: 'restartCoalesce',
788
+ title: '会话流重建合并',
789
+ description: '同一会话流在窗口内的重复 restart 合并为一次尾随执行,降低列表重建风暴。',
790
+ fields: [{ name: 'windowMs', label: '合并窗口', unit: '毫秒' }],
791
+ },
792
+ {
793
+ key: 'terminalWatchdog',
794
+ title: '终端输出看门狗',
795
+ description: '终端视图停滞且仍持有 attach 流时自动重连,避免输出永久卡住。',
796
+ fields: [
797
+ { name: 'checkMs', label: '检查周期', unit: '毫秒' },
798
+ { name: 'stallMs', label: '停滞判定', unit: '毫秒' },
799
+ { name: 'cooldownMs', label: '自愈冷却', unit: '毫秒' },
800
+ ],
801
+ },
802
+ {
803
+ key: 'socketKeepAlive',
804
+ title: '会话流保活',
805
+ description: '空闲时向会话流连接发送无害帧,避免短心跳把公网连接误判为失联。',
806
+ fields: [{ name: 'intervalMs', label: '保活间隔', unit: '毫秒' }],
807
+ },
808
+ {
809
+ key: 'stuckSession',
810
+ title: '会话加载停滞兜底',
811
+ description: '加载卡住时主动请求宿主重建连接,多次无效才给出提示。',
812
+ fields: [
813
+ { name: 'checkMs', label: '检查周期', unit: '毫秒' },
814
+ { name: 'stallMs', label: '停滞判定', unit: '毫秒' },
815
+ { name: 'maxReconnectAttempts', label: '最大重连次数', unit: '次' },
816
+ { name: 'reconnectIntervalMs', label: '重连间隔', unit: '毫秒' },
817
+ ],
818
+ toggles: [{ name: 'reconnect', label: '停滞时主动重建连接' }],
819
+ },
820
+ {
821
+ key: 'jobStreamLimit',
822
+ title: '任务状态流上限',
823
+ description: '每个会话行常驻一条 job/list 流,会话多时按默认窗口限制,点击会话可越过窗口。',
824
+ fields: [
825
+ { name: 'base', label: '默认窗口', unit: '条' },
826
+ { name: 'max', label: '硬上限', unit: '条' },
827
+ ],
828
+ },
829
+ {
830
+ key: 'listSlim',
831
+ title: '会话列表瘦身',
832
+ description: '按白名单裁剪 session/list 投影并缓存;响应形状不符时透传,新增列表字段需同步白名单。',
833
+ fields: [],
834
+ },
835
+ {
836
+ key: 'diag',
837
+ title: '性能采样上报',
838
+ description: '内存采样始终运行;开启后按采样次数把摘要写入宿主临时文件,默认不产生请求与落盘。',
839
+ fields: [{ name: 'reportEvery', label: '上报间隔', unit: '次采样' }],
840
+ },
841
+ ]
842
+
843
+ const UI_CSS = [
844
+ '.bp-card { --bp-accent:var(--dsw-alias-brand-primary); --bp-accent-deep:var(--dsw-alias-button-primary-hover);',
845
+ ' --bp-bg:var(--dsw-alias-bg-layer-1); --bp-bg-sub:var(--dsw-alias-bg-layer-2);',
846
+ ' --bp-border:var(--dsw-alias-border-l2); --bp-border-strong:var(--dsw-alias-border-l3);',
847
+ ' --bp-text:var(--dsw-alias-label-primary); --bp-text-sub:var(--dsw-alias-label-secondary);',
848
+ ' --bp-text-dim:var(--dsw-alias-label-tertiary); --bp-inverted:var(--dsw-alias-label-primary-inverted);',
849
+ ' --bp-danger:var(--dsw-alias-state-error-primary); --bp-radius-sm:6px; --bp-radius-md:8px;',
850
+ ' border:1px solid var(--bp-border); background:var(--bp-bg); border-radius:var(--bp-radius-md);',
851
+ ' overflow:hidden; color:var(--bp-text); font-size:12px; line-height:1.5; }',
852
+ '.bp-card__header { display:flex; align-items:center; justify-content:space-between; gap:12px; width:100%;',
853
+ ' padding:16px; border:0; background:transparent; text-align:left; cursor:pointer; color:inherit; }',
854
+ '.bp-card__header:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:-2px; }',
855
+ '.bp-card__head-text { display:flex; flex-direction:column; gap:4px; min-width:0; }',
856
+ '.bp-card__name { font-size:14px; font-weight:600; line-height:1.3; }',
857
+ '.bp-card__description { font-size:12px; color:var(--bp-text-sub); }',
858
+ '.bp-card__chevron { flex:none; color:var(--bp-text-dim); transition:transform .15s; }',
859
+ '.bp-card--open .bp-card__chevron { transform:rotate(180deg); }',
860
+ '.bp-card__body { padding:0 16px 16px; border-top:1px solid var(--bp-border); }',
861
+ '.bp-hint { margin:12px 0 0; color:var(--bp-text-sub); }',
862
+ '.bp-error { margin:8px 0 0; color:var(--bp-text); border-left:2px solid var(--bp-danger); padding-left:8px; }',
863
+ '.bp-section { padding:12px 0; border-top:1px solid var(--bp-border); }',
864
+ '.bp-section:first-of-type { border-top:0; }',
865
+ '.bp-section__head { display:flex; align-items:center; justify-content:space-between; gap:12px; }',
866
+ '.bp-section__title { font-size:14px; font-weight:600; line-height:1.3; }',
867
+ '.bp-section__description { margin:4px 0 8px; color:var(--bp-text-sub); }',
868
+ '.bp-switch { display:inline-flex; align-items:center; gap:8px; min-height:28px; cursor:pointer; flex:none; user-select:none; }',
869
+ '.bp-switch input[type="checkbox"] { position:absolute; width:0; height:0; opacity:0; }',
870
+ '.bp-switch__track { position:relative; flex-shrink:0; width:34px; height:19px; border-radius:999px; box-sizing:border-box;',
871
+ ' background:var(--bp-bg-sub); border:1px solid var(--bp-border); transition:background .15s,border-color .15s; }',
872
+ '.bp-switch__thumb { position:absolute; top:50%; left:2px; width:13px; height:13px; border-radius:50%;',
873
+ ' background:var(--bp-text-dim); transform:translateY(-50%); transition:left .15s,background .15s; }',
874
+ '.bp-switch:hover .bp-switch__track { border-color:var(--bp-accent); }',
875
+ '.bp-switch input[type="checkbox"]:checked + .bp-switch__track { background:var(--bp-accent); border-color:var(--bp-accent); }',
876
+ '.bp-switch input[type="checkbox"]:checked + .bp-switch__track .bp-switch__thumb { left:17px; background:var(--bp-bg); }',
877
+ '.bp-switch input[type="checkbox"]:focus-visible + .bp-switch__track { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:2px; }',
878
+ '.bp-switch:has(input[type="checkbox"]:disabled) { opacity:.5; cursor:not-allowed; }',
879
+ '.bp-fields { display:flex; flex-wrap:wrap; gap:8px 12px; margin-top:8px; }',
880
+ '.bp-field { display:inline-flex; align-items:center; gap:8px; color:var(--bp-text-sub); }',
881
+ '.bp-field input[type="number"] { width:88px; padding:4px 8px; border:1px solid var(--bp-border-strong); border-radius:var(--bp-radius-sm);',
882
+ ' background:var(--bp-bg); color:var(--bp-text); font:inherit; }',
883
+ '.bp-field input[type="number"]:focus { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:0; }',
884
+ '.bp-footer { display:flex; align-items:center; gap:12px; padding-top:12px; border-top:1px solid var(--bp-border); }',
885
+ '.bp-button { min-height:28px; padding:4px 12px; border:1px solid var(--bp-border-strong); border-radius:var(--bp-radius-sm);',
886
+ ' background:transparent; color:var(--bp-text); cursor:pointer; font:inherit; }',
887
+ '.bp-button:hover { background:var(--dsw-alias-interactive-bg-hover); }',
888
+ '.bp-button--primary { border-color:var(--bp-accent); background:var(--bp-accent); color:var(--bp-inverted); }',
889
+ '.bp-button--primary:hover { border-color:var(--bp-accent-deep); background:var(--bp-accent-deep); }',
890
+ '.bp-button:disabled { opacity:.5; cursor:not-allowed; }',
891
+ '@media (prefers-reduced-motion: reduce) { .bp-card__chevron, .bp-switch__track, .bp-switch__thumb { transition:none; } }',
892
+ ].join('\n')
893
+
894
+ const h = React === undefined ? undefined : React.createElement
895
+
896
+ const Switch = ({ checked, disabled, label, onChange }) => h('label', { className: 'bp-switch' },
897
+ h('input', {
898
+ type: 'checkbox',
899
+ checked,
900
+ disabled,
901
+ onChange: (event) => onChange(event.target.checked),
902
+ }),
903
+ h('span', { className: 'bp-switch__track' }, h('span', { className: 'bp-switch__thumb' })),
904
+ h('span', { className: 'bp-switch__label' }, label),
905
+ )
906
+
907
+ function SettingsPanel() {
908
+ const [open, setOpen] = React.useState(false)
909
+ const [config, setConfig] = React.useState(null)
910
+ const [dirty, setDirty] = React.useState(false)
911
+ const [saving, setSaving] = React.useState(false)
912
+ const [error, setError] = React.useState('')
913
+ const [notice, setNotice] = React.useState('')
914
+ React.useEffect(() => {
915
+ let alive = true
916
+ loadConfig()
917
+ .then((raw) => { if (alive) setConfig(normalizeConfig(raw ?? window.__dshBugPerf?.config)) })
918
+ .catch(() => { if (alive) setConfig(normalizeConfig(DEFAULT_CONFIG)) })
919
+ return () => { alive = false }
920
+ }, [])
921
+ const updateSection = (key, patch) => {
922
+ setConfig({ ...config, [key]: { ...config[key], ...patch } })
923
+ setDirty(true)
924
+ setError('')
925
+ setNotice('')
926
+ }
927
+ const save = async () => {
928
+ setSaving(true)
929
+ setError('')
930
+ try {
931
+ const response = await fetch(CONFIG_PATH, {
932
+ method: 'POST',
933
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
934
+ body: JSON.stringify({ config }),
935
+ })
936
+ const body = await response.json().catch(() => undefined)
937
+ if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`)
938
+ setConfig(normalizeConfig(body?.config ?? config))
939
+ setDirty(false)
940
+ setNotice('已保存,刷新页面生效')
941
+ } catch (failure) {
942
+ setError(`保存失败:${failure instanceof Error ? failure.message : String(failure)}`)
943
+ } finally {
944
+ setSaving(false)
945
+ }
946
+ }
947
+ return h('div', { className: 'bp-card' + (open ? ' bp-card--open' : '') },
948
+ h('button', {
949
+ type: 'button',
950
+ className: 'bp-card__header',
951
+ 'aria-expanded': open,
952
+ onClick: () => setOpen(!open),
953
+ },
954
+ h('span', { className: 'bp-card__head-text' },
955
+ h('span', { className: 'bp-card__name' }, '缺陷兜底与性能补丁'),
956
+ h('span', { className: 'bp-card__description' }, '会话流、终端与列表的运行时补丁;每个功能独立开关'),
957
+ ),
958
+ h('span', { className: 'bp-card__chevron', 'aria-hidden': 'true' },
959
+ h('svg', { width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none' },
960
+ h('path', {
961
+ d: 'M3.5 5.25L7 8.75L10.5 5.25',
962
+ stroke: 'currentColor',
963
+ 'stroke-width': '1.2',
964
+ 'stroke-linecap': 'round',
965
+ 'stroke-linejoin': 'round',
966
+ }),
967
+ ),
968
+ ),
969
+ ),
970
+ open && h('div', { className: 'bp-card__body' },
971
+ h('p', { className: 'bp-hint' }, '修改后点保存;客户端补丁刷新页面生效,列表瘦身在下次列表请求生效。'),
972
+ error !== '' && h('p', { className: 'bp-error' }, error),
973
+ config === null && h('p', { className: 'bp-hint' }, '加载配置…'),
974
+ config !== null && UI_SECTIONS.map((section) => h('section', { key: section.key, className: 'bp-section' },
975
+ h('div', { className: 'bp-section__head' },
976
+ h('span', { className: 'bp-section__title' }, section.title),
977
+ h(Switch, {
978
+ checked: config[section.key].enabled,
979
+ disabled: saving,
980
+ label: '启用',
981
+ onChange: (enabled) => updateSection(section.key, { enabled }),
982
+ }),
983
+ ),
984
+ h('p', { className: 'bp-section__description' }, section.description),
985
+ section.fields.map((field) => {
986
+ const bounds = LIMITS[section.key]?.[field.name]
987
+ return h('label', { key: field.name, className: 'bp-field' },
988
+ h('span', {}, field.label),
989
+ h('input', {
990
+ type: 'number',
991
+ min: bounds?.min,
992
+ max: bounds?.max,
993
+ step: 1,
994
+ value: config[section.key][field.name],
995
+ disabled: saving,
996
+ onChange: (event) => {
997
+ const value = Number(event.target.value)
998
+ if (Number.isFinite(value)) updateSection(section.key, { [field.name]: value })
999
+ },
1000
+ }),
1001
+ h('span', {}, field.unit),
1002
+ )
1003
+ }),
1004
+ (section.toggles ?? []).map((toggle) => h('div', { key: toggle.name, className: 'bp-fields' },
1005
+ h(Switch, {
1006
+ checked: config[section.key][toggle.name],
1007
+ disabled: saving,
1008
+ label: toggle.label,
1009
+ onChange: (value) => updateSection(section.key, { [toggle.name]: value }),
1010
+ }),
1011
+ )),
1012
+ )),
1013
+ config !== null && h('div', { className: 'bp-footer' },
1014
+ h('button', {
1015
+ type: 'button',
1016
+ className: 'bp-button bp-button--primary',
1017
+ disabled: saving || !dirty,
1018
+ onClick: save,
1019
+ }, saving ? '保存中…' : '保存'),
1020
+ dirty && h('span', { className: 'bp-hint' }, '有未保存的修改'),
1021
+ !dirty && notice !== '' && h('span', { className: 'bp-hint' }, notice),
1022
+ ),
1023
+ ),
1024
+ )
1025
+ }
1026
+
1027
+ const registerSettingsUi = (ctx) => {
1028
+ if (h === undefined || typeof ctx.inject !== 'function') return
1029
+ ctx.inject(['slots'], (scope) => {
1030
+ const slots = scope.slots
1031
+ if (slots === undefined) return
1032
+ try {
1033
+ scope.effect(() => {
1034
+ const style = document.createElement('style')
1035
+ style.setAttribute('data-plugin', PKG_ID)
1036
+ style.textContent = UI_CSS
1037
+ document.head.appendChild(style)
1038
+ return () => style.remove()
1039
+ })
1040
+ } catch (error) {
1041
+ console.warn(`${LOG_PREFIX} styles unavailable:`, error instanceof Error ? error.message : String(error))
1042
+ }
1043
+ const register = (name, options) => {
1044
+ try {
1045
+ slots.inject(name, () => {
1046
+ try {
1047
+ slots.register(options, () => h(SettingsPanel))
1048
+ } catch (error) {
1049
+ console.warn(`${LOG_PREFIX} settings slot ${name} register failed:`, error instanceof Error ? error.message : String(error))
1050
+ }
1051
+ })
1052
+ } catch (error) {
1053
+ console.warn(`${LOG_PREFIX} settings slot ${name} unavailable:`, error instanceof Error ? error.message : String(error))
1054
+ }
1055
+ }
1056
+ register('settings.plugin.item', { name: 'settings.plugin.item', id: NAMESPACE, key: NAMESPACE })
1057
+ register('plugins.bundle.config', { name: 'plugins.bundle.config', key: PKG_ID })
1058
+ })
1059
+ }
1060
+
1061
+ const apply = (ctx) => {
1062
+ registerSettingsUi(ctx)
1063
+ const report = { config: undefined, features: {} }
1064
+ window.__dshBugPerf = report
1065
+ const diag = createDiag(timers)
1066
+ report.diag = diag
1067
+ startDiagSampling({ win: window, clock: timers, diag, doc: window.document })
1068
+ // 会话流连接在页面启动早期建立, 保活先按默认值同步安装, 配置到达后再校正间隔
1069
+ const keepAlive = safely(() => installSocketKeepAlive({
1070
+ win: window,
1071
+ options: DEFAULT_CONFIG.socketKeepAlive,
1072
+ clock: timers,
1073
+ diag,
1074
+ onDisabled: (reason) => { report.features.socketKeepAlive = { installed: false, reason } },
1075
+ }))
1076
+ report.features.socketKeepAlive = summarize(keepAlive)
1077
+ void (async () => {
1078
+ const config = normalizeConfig(await loadConfig())
1079
+ report.config = config
1080
+ const reporting = startDiagReporting({ win: window, clock: timers, diag, options: config.diag })
1081
+ report.features.diag = reporting.enabled ? { installed: true } : { installed: false, reason: 'disabled' }
1082
+ if (!config.socketKeepAlive.enabled) {
1083
+ keepAlive.uninstall?.()
1084
+ report.features.socketKeepAlive = { installed: false, reason: 'disabled' }
1085
+ } else if (keepAlive.installed === true) {
1086
+ keepAlive.setIntervalMs?.(config.socketKeepAlive.intervalMs)
1087
+ }
1088
+ report.features.restartCoalesce = config.restartCoalesce.enabled
1089
+ ? installRestartCoalesceFromRequire({ requireImpl: require, windowMs: config.restartCoalesce.windowMs, timers })
1090
+ : { installed: false, reason: 'disabled' }
1091
+ if (config.terminalWatchdog.enabled) {
1092
+ installTerminalWatchdogWhenReady(ctx, config.terminalWatchdog, report)
1093
+ } else {
1094
+ report.features.terminalWatchdog = { installed: false, reason: 'disabled' }
1095
+ }
1096
+ const stuckOptions = config.stuckSession
1097
+ const startStuckSession = (connection) => {
1098
+ report.actions = { reconnect: () => { try { connection?.reconnect?.() } catch { } } }
1099
+ report.features.stuckSession = stuckOptions.enabled
1100
+ ? summarize(safely(() => installStuckSession({ win: window, options: stuckOptions, clock: timers, connection, diag })))
1101
+ : { installed: false, reason: 'disabled' }
1102
+ }
1103
+ const startJobStreamLimit = (jobs) => {
1104
+ report.features.jobStreamLimit = config.jobStreamLimit.enabled
1105
+ ? safely(() => {
1106
+ const result = installJobStreamLimit({ jobs, options: config.jobStreamLimit, sessionsOf: () => ctx.get?.('sessions'), doc: window.document })
1107
+ return result.installed === true ? { installed: true, state: result.state } : result
1108
+ })
1109
+ : { installed: false, reason: 'disabled' }
1110
+ }
1111
+ const readyJobs = ctx.get?.('jobs')
1112
+ if (readyJobs !== undefined || ctx.inject === undefined) {
1113
+ startJobStreamLimit(readyJobs)
1114
+ } else {
1115
+ ctx.inject(['jobs'], (jobs) => { startJobStreamLimit(jobs) })
1116
+ }
1117
+ const readyConnection = ctx.get?.('connection')
1118
+ if (readyConnection !== undefined || ctx.inject === undefined) {
1119
+ startStuckSession(readyConnection)
1120
+ } else {
1121
+ // connection 由核心插件提供; 等它就绪后再装, 避免错过主动重连入口
1122
+ ctx.inject(['connection'], (connection) => { startStuckSession(connection) })
1123
+ }
1124
+ console.info(`${LOG_PREFIX} applied`, JSON.stringify(report.features))
1125
+ })().catch((error) => {
1126
+ report.features.applyError = error instanceof Error ? error.message : String(error)
1127
+ console.warn(`${LOG_PREFIX} apply failed:`, report.features.applyError)
1128
+ })
1129
+ }
1130
+
1131
+ return {
1132
+ inject: [],
1133
+ apply,
1134
+ __test: {
1135
+ DEFAULT_CONFIG,
1136
+ LIMITS,
1137
+ DIAG_SAMPLE_MS,
1138
+ DIAG_MAX_SAMPLES,
1139
+ DIAG_PROBE_MS,
1140
+ DIAG_BLOCK_MS,
1141
+ normalizeConfig,
1142
+ registerSettingsUi,
1143
+ UI_SECTIONS,
1144
+ createDiag,
1145
+ startDiagSampling,
1146
+ startDiagReporting,
1147
+ installRestartCoalesce,
1148
+ installRestartCoalesceFromRequire,
1149
+ watchdogTick,
1150
+ installTerminalWatchdog,
1151
+ installSocketKeepAlive,
1152
+ installJobStreamLimit,
1153
+ installStuckSession,
1154
+ sessionStalled,
1155
+ },
1156
+ }
1157
+ },
1158
+ })