@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/README.md +276 -3
- package/cordis.patch.yml +9 -0
- package/package.json +52 -4
- package/src/client.js +1158 -0
- package/src/config.mjs +100 -0
- package/src/index.js +240 -0
- package/src/list-slim.mjs +207 -0
- package/test/client-diag.test.mjs +165 -0
- package/test/client-jobs.test.mjs +220 -0
- package/test/client-keepalive.test.mjs +227 -0
- package/test/client-restart.test.mjs +182 -0
- package/test/client-settings.test.mjs +76 -0
- package/test/client-stuck.test.mjs +350 -0
- package/test/client-watchdog.test.mjs +186 -0
- package/test/config.test.mjs +145 -0
- package/test/host.test.mjs +340 -0
- package/test/list-slim.test.mjs +421 -0
- package//345/205/274/345/256/271/346/200/247/346/265/213/350/257/225.md +260 -0
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
|
+
})
|