@mzzsfy/dsh-bug-perf 0.1.0 → 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 +176 -21
- package/cordis.patch.yml +2 -2
- package/package.json +2 -2
- package/src/client.js +332 -7
- package/src/config.mjs +22 -1
- package/src/index.js +123 -9
- package/src/list-slim.mjs +207 -0
- package/test/client-diag.test.mjs +165 -0
- package/test/client-restart.test.mjs +7 -0
- package/test/client-settings.test.mjs +76 -0
- package/test/config.test.mjs +29 -2
- package/test/host.test.mjs +203 -4
- 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 +92 -31
package/src/client.js
CHANGED
|
@@ -7,6 +7,19 @@ window.__ModuleLoader__.load({
|
|
|
7
7
|
const CONFIG_PATH = '/api/dsh-bug-perf/config'
|
|
8
8
|
const CONFIG_TIMEOUT_MS = 2000
|
|
9
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
|
+
}
|
|
10
23
|
|
|
11
24
|
// 与 src/config.mjs 同源;客户端不能 import 共享模块,内联副本由测试对表
|
|
12
25
|
const DEFAULT_CONFIG = {
|
|
@@ -15,6 +28,8 @@ window.__ModuleLoader__.load({
|
|
|
15
28
|
socketKeepAlive: { enabled: true, intervalMs: 1500 },
|
|
16
29
|
stuckSession: { enabled: true, checkMs: 2000, stallMs: 8000, reconnect: true, maxReconnectAttempts: 5, reconnectIntervalMs: 15000 },
|
|
17
30
|
jobStreamLimit: { enabled: true, base: 32, max: 48 },
|
|
31
|
+
listSlim: { enabled: true },
|
|
32
|
+
diag: { enabled: false, reportEvery: DIAG_DEFAULT_REPORT_EVERY },
|
|
18
33
|
}
|
|
19
34
|
const LIMITS = {
|
|
20
35
|
restartCoalesce: { windowMs: { min: 0, max: 2000 } },
|
|
@@ -31,6 +46,7 @@ window.__ModuleLoader__.load({
|
|
|
31
46
|
reconnectIntervalMs: { min: 500, max: 600000 },
|
|
32
47
|
},
|
|
33
48
|
jobStreamLimit: { base: { min: 4, max: 256 }, max: { min: 4, max: 512 } },
|
|
49
|
+
diag: { reportEvery: { min: 1, max: DIAG_MAX_SAMPLES } },
|
|
34
50
|
}
|
|
35
51
|
|
|
36
52
|
const isSection = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
@@ -41,6 +57,10 @@ window.__ModuleLoader__.load({
|
|
|
41
57
|
if (value < bounds.min) return fallback
|
|
42
58
|
return value > bounds.max ? bounds.max : value
|
|
43
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
|
+
}
|
|
44
64
|
const normalizeConfig = (raw) => {
|
|
45
65
|
const input = sectionOf(raw)
|
|
46
66
|
const restart = sectionOf(input.restartCoalesce)
|
|
@@ -48,6 +68,8 @@ window.__ModuleLoader__.load({
|
|
|
48
68
|
const keepAlive = sectionOf(input.socketKeepAlive)
|
|
49
69
|
const stuck = sectionOf(input.stuckSession)
|
|
50
70
|
const jobLimit = sectionOf(input.jobStreamLimit)
|
|
71
|
+
const listSlim = sectionOf(input.listSlim)
|
|
72
|
+
const diag = sectionOf(input.diag)
|
|
51
73
|
return {
|
|
52
74
|
restartCoalesce: {
|
|
53
75
|
enabled: booleanOr(restart.enabled, DEFAULT_CONFIG.restartCoalesce.enabled),
|
|
@@ -79,6 +101,13 @@ window.__ModuleLoader__.load({
|
|
|
79
101
|
maxReconnectAttempts: numberWithin(stuck.maxReconnectAttempts, LIMITS.stuckSession.maxReconnectAttempts, DEFAULT_CONFIG.stuckSession.maxReconnectAttempts),
|
|
80
102
|
reconnectIntervalMs: numberWithin(stuck.reconnectIntervalMs, LIMITS.stuckSession.reconnectIntervalMs, DEFAULT_CONFIG.stuckSession.reconnectIntervalMs),
|
|
81
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
|
+
},
|
|
82
111
|
}
|
|
83
112
|
}
|
|
84
113
|
|
|
@@ -231,14 +260,10 @@ window.__ModuleLoader__.load({
|
|
|
231
260
|
const FRAME_TYPE_PREFIX = '{"type":"'
|
|
232
261
|
const STREAM_ID_KEY = '"streamId":"'
|
|
233
262
|
const ENDPOINT_KEY = '"endpoint":"'
|
|
234
|
-
const DIAG_SAMPLE_MS = 5000
|
|
235
|
-
const DIAG_MAX_SAMPLES = 720
|
|
236
263
|
// 事件循环探测: 延迟超过该值视为主线程被占满
|
|
237
264
|
const DIAG_BLOCK_MS = 1000
|
|
238
265
|
const DIAG_PROBE_MS = 2000
|
|
239
266
|
const DIAG_REPORT_PATH = '/api/dsh-bug-perf/diag'
|
|
240
|
-
// 每 N 次采样(约一分钟)把摘要落到宿主临时文件, 便于事后定位长时间运行后的退化
|
|
241
|
-
const DIAG_REPORT_EVERY = 12
|
|
242
267
|
// 定时器检查节拍: 实际发送间隔由可变的 intervalMs 决定
|
|
243
268
|
const KEEPALIVE_TICK_MS = 500
|
|
244
269
|
// 只有协议级关闭码才可能是宿主拒绝保活帧; 链路断开与主动关闭不计入
|
|
@@ -259,6 +284,7 @@ window.__ModuleLoader__.load({
|
|
|
259
284
|
// 不解析完整帧、不改动任何流, 采样保留最近 DIAG_MAX_SAMPLES 个点。
|
|
260
285
|
const createDiag = (clock) => ({
|
|
261
286
|
startedAt: clock.now(),
|
|
287
|
+
enabled: false,
|
|
262
288
|
sockets: [],
|
|
263
289
|
samples: [],
|
|
264
290
|
longTasks: [],
|
|
@@ -282,7 +308,7 @@ window.__ModuleLoader__.load({
|
|
|
282
308
|
path: win.location?.pathname ?? '',
|
|
283
309
|
totals: diag.totals,
|
|
284
310
|
last: diag.samples.slice(-6),
|
|
285
|
-
blockedMs: diag.blockedMs,
|
|
311
|
+
blockedMs: diag.blockedMs ?? 0,
|
|
286
312
|
longTasks: diag.longTasks.slice(-8),
|
|
287
313
|
errors: diag.errors.slice(-4),
|
|
288
314
|
}
|
|
@@ -311,8 +337,6 @@ window.__ModuleLoader__.load({
|
|
|
311
337
|
sent: diag.totals.sent,
|
|
312
338
|
})
|
|
313
339
|
if (diag.samples.length > DIAG_MAX_SAMPLES) diag.samples.shift()
|
|
314
|
-
diag.count = (diag.count ?? 0) + 1
|
|
315
|
-
if (diag.count % DIAG_REPORT_EVERY === 0) reportDiag(win, diag)
|
|
316
340
|
}
|
|
317
341
|
sample()
|
|
318
342
|
const timer = clock.setInterval(sample, DIAG_SAMPLE_MS)
|
|
@@ -342,6 +366,18 @@ window.__ModuleLoader__.load({
|
|
|
342
366
|
return { timer, observer }
|
|
343
367
|
}
|
|
344
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
|
+
|
|
345
381
|
// 宿主心跳为固定短周期且丢两次即 terminate, 经反代时 pong 常被链路吞掉。
|
|
346
382
|
// 客户端在会话流连接上按间隔发一条未知 streamId 的 item 帧: 宿主收到任意帧都会刷新存活标记,
|
|
347
383
|
// 未知 streamId 的 item 在宿主侧被忽略, 不改动任何流的状态。
|
|
@@ -745,7 +781,285 @@ window.__ModuleLoader__.load({
|
|
|
745
781
|
}
|
|
746
782
|
}
|
|
747
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
|
+
|
|
748
1061
|
const apply = (ctx) => {
|
|
1062
|
+
registerSettingsUi(ctx)
|
|
749
1063
|
const report = { config: undefined, features: {} }
|
|
750
1064
|
window.__dshBugPerf = report
|
|
751
1065
|
const diag = createDiag(timers)
|
|
@@ -763,6 +1077,8 @@ window.__ModuleLoader__.load({
|
|
|
763
1077
|
void (async () => {
|
|
764
1078
|
const config = normalizeConfig(await loadConfig())
|
|
765
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' }
|
|
766
1082
|
if (!config.socketKeepAlive.enabled) {
|
|
767
1083
|
keepAlive.uninstall?.()
|
|
768
1084
|
report.features.socketKeepAlive = { installed: false, reason: 'disabled' }
|
|
@@ -818,7 +1134,16 @@ window.__ModuleLoader__.load({
|
|
|
818
1134
|
__test: {
|
|
819
1135
|
DEFAULT_CONFIG,
|
|
820
1136
|
LIMITS,
|
|
1137
|
+
DIAG_SAMPLE_MS,
|
|
1138
|
+
DIAG_MAX_SAMPLES,
|
|
1139
|
+
DIAG_PROBE_MS,
|
|
1140
|
+
DIAG_BLOCK_MS,
|
|
821
1141
|
normalizeConfig,
|
|
1142
|
+
registerSettingsUi,
|
|
1143
|
+
UI_SECTIONS,
|
|
1144
|
+
createDiag,
|
|
1145
|
+
startDiagSampling,
|
|
1146
|
+
startDiagReporting,
|
|
822
1147
|
installRestartCoalesce,
|
|
823
1148
|
installRestartCoalesceFromRequire,
|
|
824
1149
|
watchdogTick,
|
package/src/config.mjs
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
// 宿主与客户端共享的配置事实源(客户端不能 import,内联副本由测试对表)
|
|
2
|
+
export const DIAG_MAX_SAMPLES = 720
|
|
3
|
+
const DIAG_DEFAULT_REPORT_EVERY = 12
|
|
4
|
+
|
|
2
5
|
export const DEFAULT_CONFIG = Object.freeze({
|
|
3
6
|
restartCoalesce: Object.freeze({ enabled: true, windowMs: 100 }),
|
|
4
7
|
terminalWatchdog: Object.freeze({ enabled: true, checkMs: 2000, stallMs: 8000, cooldownMs: 60000 }),
|
|
5
8
|
socketKeepAlive: Object.freeze({ enabled: true, intervalMs: 1500 }),
|
|
6
9
|
stuckSession: Object.freeze({ enabled: true, checkMs: 2000, stallMs: 8000, reconnect: true, maxReconnectAttempts: 5, reconnectIntervalMs: 15000 }),
|
|
7
10
|
jobStreamLimit: Object.freeze({ enabled: true, base: 32, max: 48 }),
|
|
11
|
+
listSlim: Object.freeze({ enabled: true }),
|
|
12
|
+
diag: Object.freeze({ enabled: false, reportEvery: DIAG_DEFAULT_REPORT_EVERY }),
|
|
8
13
|
})
|
|
9
14
|
|
|
10
|
-
//
|
|
15
|
+
// 阈值边界:非有限数或低于下限回退默认,高于上限收敛;diag.reportEvery 两侧都收敛
|
|
11
16
|
export const LIMITS = Object.freeze({
|
|
12
17
|
restartCoalesce: Object.freeze({ windowMs: Object.freeze({ min: 0, max: 2000 }) }),
|
|
13
18
|
terminalWatchdog: Object.freeze({
|
|
@@ -23,6 +28,7 @@ export const LIMITS = Object.freeze({
|
|
|
23
28
|
reconnectIntervalMs: Object.freeze({ min: 500, max: 600000 }),
|
|
24
29
|
}),
|
|
25
30
|
jobStreamLimit: Object.freeze({ base: Object.freeze({ min: 4, max: 256 }), max: Object.freeze({ min: 4, max: 512 }) }),
|
|
31
|
+
diag: Object.freeze({ reportEvery: Object.freeze({ min: 1, max: DIAG_MAX_SAMPLES }) }),
|
|
26
32
|
})
|
|
27
33
|
|
|
28
34
|
const isSection = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
@@ -37,6 +43,12 @@ const numberWithin = (value, bounds, fallback) => {
|
|
|
37
43
|
return value > bounds.max ? bounds.max : value
|
|
38
44
|
}
|
|
39
45
|
|
|
46
|
+
// 非有限数回退默认;有限数两侧都收敛到边界
|
|
47
|
+
const numberClamped = (value, bounds, fallback) => {
|
|
48
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
|
|
49
|
+
return Math.min(bounds.max, Math.max(bounds.min, value))
|
|
50
|
+
}
|
|
51
|
+
|
|
40
52
|
export function normalizeConfig(raw) {
|
|
41
53
|
const input = sectionOf(raw)
|
|
42
54
|
const restart = sectionOf(input.restartCoalesce)
|
|
@@ -44,6 +56,8 @@ export function normalizeConfig(raw) {
|
|
|
44
56
|
const keepAlive = sectionOf(input.socketKeepAlive)
|
|
45
57
|
const stuck = sectionOf(input.stuckSession)
|
|
46
58
|
const jobLimit = sectionOf(input.jobStreamLimit)
|
|
59
|
+
const listSlim = sectionOf(input.listSlim)
|
|
60
|
+
const diag = sectionOf(input.diag)
|
|
47
61
|
return {
|
|
48
62
|
restartCoalesce: {
|
|
49
63
|
enabled: booleanOr(restart.enabled, DEFAULT_CONFIG.restartCoalesce.enabled),
|
|
@@ -75,5 +89,12 @@ export function normalizeConfig(raw) {
|
|
|
75
89
|
),
|
|
76
90
|
max: numberWithin(jobLimit.max, LIMITS.jobStreamLimit.max, DEFAULT_CONFIG.jobStreamLimit.max),
|
|
77
91
|
},
|
|
92
|
+
listSlim: {
|
|
93
|
+
enabled: booleanOr(listSlim.enabled, DEFAULT_CONFIG.listSlim.enabled),
|
|
94
|
+
},
|
|
95
|
+
diag: {
|
|
96
|
+
enabled: booleanOr(diag.enabled, DEFAULT_CONFIG.diag.enabled),
|
|
97
|
+
reportEvery: numberClamped(diag.reportEvery, LIMITS.diag.reportEvery, DEFAULT_CONFIG.diag.reportEvery),
|
|
98
|
+
},
|
|
78
99
|
}
|
|
79
100
|
}
|
package/src/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
// dsh-bug-perf Host
|
|
1
|
+
// dsh-bug-perf Host 半区:把行配置归一化后经配置路由交给浏览器半区,接收设置页保存,
|
|
2
2
|
// 并接收浏览器定期上报的性能采样(只落本地临时文件,用于定位“越用越卡”)。
|
|
3
|
-
//
|
|
3
|
+
// 会话列表瘦身在宿主执行(包装 sessionController.list),其余补丁在客户端执行;
|
|
4
|
+
// 这里不做认证,缺 webServer 或路由注册失败都不影响宿主。
|
|
4
5
|
import { appendFileSync, existsSync, renameSync, statSync } from 'node:fs'
|
|
5
6
|
import { tmpdir } from 'node:os'
|
|
6
7
|
import { join } from 'node:path'
|
|
7
8
|
import z from '@deepseek-ai/schemastery'
|
|
8
9
|
import { DEFAULT_CONFIG, normalizeConfig } from './config.mjs'
|
|
10
|
+
import { installListSlim } from './list-slim.mjs'
|
|
9
11
|
|
|
10
12
|
export const name = 'dsh-bug-perf'
|
|
11
13
|
// web profile 必有 webServer; 声明依赖让 apply 在服务就绪后执行,缺服务宿主即不激活本行
|
|
@@ -13,6 +15,8 @@ export const inject = ['webServer']
|
|
|
13
15
|
|
|
14
16
|
const CONFIG_PATH = '/api/dsh-bug-perf/config'
|
|
15
17
|
const DIAG_PATH = '/api/dsh-bug-perf/diag'
|
|
18
|
+
const NAMESPACE = 'bug-perf'
|
|
19
|
+
const BODY_MAX_BYTES = 64 * 1024
|
|
16
20
|
const DIAG_FILE = join(tmpdir(), 'dsh-bug-perf-diag.jsonl')
|
|
17
21
|
const DIAG_MAX_BYTES = 2 * 1024 * 1024
|
|
18
22
|
|
|
@@ -44,6 +48,13 @@ const SETTINGS_SCHEMA = z.object({
|
|
|
44
48
|
base: z.number().default(DEFAULT_CONFIG.jobStreamLimit.base).description('默认窗口:只给主会话放行的条数,子 agent 会话不占这个窗口'),
|
|
45
49
|
max: z.number().default(DEFAULT_CONFIG.jobStreamLimit.max).description('硬上限;点击某个会话可越过默认窗口加入,总数到上限后淘汰最先加入的一条'),
|
|
46
50
|
}).description('任务状态流上限'),
|
|
51
|
+
listSlim: z.object({
|
|
52
|
+
enabled: z.boolean().default(DEFAULT_CONFIG.listSlim.enabled).description('会话列表瘦身:按白名单裁剪 session/list 的投影并缓存,降低列表响应体积与重建耗时;响应形状不符时透传,新增列表字段需同步白名单'),
|
|
53
|
+
}).description('会话列表瘦身'),
|
|
54
|
+
diag: z.object({
|
|
55
|
+
enabled: z.boolean().default(DEFAULT_CONFIG.diag.enabled).description('性能采样上报:开启后按采样次数把页面性能摘要写入宿主临时文件,供事后排查;关闭时内存采样仍运行'),
|
|
56
|
+
reportEvery: z.number().default(DEFAULT_CONFIG.diag.reportEvery).description('每多少次采样上报一次;采样周期固定,修改后刷新页面生效'),
|
|
57
|
+
}).description('性能采样上报'),
|
|
47
58
|
}).description('宿主缺陷兜底与性能补丁,每个功能独立开关')
|
|
48
59
|
|
|
49
60
|
// 旧宿主 schemastery 无 volatile 方法,特性检测原样返回
|
|
@@ -60,6 +71,90 @@ const sendJson = (res, status, body) => {
|
|
|
60
71
|
res.end(JSON.stringify(body))
|
|
61
72
|
}
|
|
62
73
|
|
|
74
|
+
// 设置页保存:legacy 命名空间以 register 在场为准(0.1.7 的 update 名义存在但按命名空间找条目必败);
|
|
75
|
+
// 0.1.7+ 走 configEditor 落 profile 条目,与命名空间解耦
|
|
76
|
+
const persistConfig = async (ctx, next) => {
|
|
77
|
+
const settings = ctx.get('settings')
|
|
78
|
+
if (settings !== undefined && typeof settings.register === 'function' && typeof settings.update === 'function') {
|
|
79
|
+
await settings.update(NAMESPACE, next)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
const editor = ctx.get('configEditor')
|
|
83
|
+
const entry = ctx.fiber?.entry
|
|
84
|
+
if (editor !== undefined && typeof editor.edit === 'function' && entry !== undefined && entry !== null) {
|
|
85
|
+
await editor.edit(entry, (current) => ({ ...(current ?? {}), ...next }))
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
throw new Error('宿主配置服务不可用')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 读回优先级:旧宿主 settings 命名空间已写入的节;否则用行配置(live ref 解箱)
|
|
92
|
+
const readConfig = (ctx, config) => {
|
|
93
|
+
const settings = ctx.get('settings')
|
|
94
|
+
const stored = settings !== undefined && typeof settings.get === 'function' ? settings.get(NAMESPACE) : undefined
|
|
95
|
+
const known = stored !== null && typeof stored === 'object'
|
|
96
|
+
&& ['restartCoalesce', 'terminalWatchdog', 'socketKeepAlive', 'stuckSession', 'jobStreamLimit', 'listSlim', 'diag']
|
|
97
|
+
.some((key) => stored[key] !== null && typeof stored[key] === 'object')
|
|
98
|
+
return normalizeConfig(known ? stored : unwrapVolatile(config))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const readBody = (req) => new Promise((resolve, reject) => {
|
|
102
|
+
let size = 0
|
|
103
|
+
const chunks = []
|
|
104
|
+
req.on('data', (chunk) => {
|
|
105
|
+
size += chunk.length
|
|
106
|
+
if (size > BODY_MAX_BYTES) {
|
|
107
|
+
reject(new Error('body too large'))
|
|
108
|
+
req.destroy()
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
chunks.push(chunk)
|
|
112
|
+
})
|
|
113
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
114
|
+
req.on('error', reject)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const configHandler = async (ctx, config, req, res) => {
|
|
118
|
+
if (req.method !== 'POST') {
|
|
119
|
+
if (req.method !== 'GET') {
|
|
120
|
+
sendJson(res, 405, { error: 'method-not-allowed' })
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
sendJson(res, 200, { config: readConfig(ctx, config) })
|
|
125
|
+
} catch {
|
|
126
|
+
sendJson(res, 500, { error: 'config-unavailable' })
|
|
127
|
+
}
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
let body
|
|
131
|
+
try {
|
|
132
|
+
body = JSON.parse(await readBody(req))
|
|
133
|
+
} catch {
|
|
134
|
+
sendJson(res, 400, { error: 'bad-payload' })
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
const patch = body?.config
|
|
138
|
+
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
139
|
+
sendJson(res, 400, { error: 'bad-config' })
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
let next
|
|
143
|
+
try {
|
|
144
|
+
next = normalizeConfig({ ...readConfig(ctx, config), ...patch })
|
|
145
|
+
} catch {
|
|
146
|
+
sendJson(res, 500, { error: 'config-unavailable' })
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
await persistConfig(ctx, next)
|
|
151
|
+
} catch {
|
|
152
|
+
sendJson(res, 503, { error: 'config-unavailable' })
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
sendJson(res, 200, { config: next })
|
|
156
|
+
}
|
|
157
|
+
|
|
63
158
|
// 采样文件只保留最近一轮, 超过上限后轮转一次, 避免无限增长
|
|
64
159
|
const recordDiag = (payload) => {
|
|
65
160
|
try {
|
|
@@ -94,19 +189,38 @@ const diagHandler = (req, res) => {
|
|
|
94
189
|
}
|
|
95
190
|
|
|
96
191
|
export function apply(ctx, config) {
|
|
192
|
+
// 列表瘦身:不依赖 webServer/路由,开关从行配置实时读取;装载失败只关闭该能力
|
|
193
|
+
installListSlim(ctx, { isEnabled: () => normalizeConfig(unwrapVolatile(config)).listSlim.enabled })
|
|
97
194
|
const server = ctx.get('webServer')
|
|
98
195
|
if (server === undefined || typeof server.register !== 'function') return
|
|
196
|
+
|
|
197
|
+
// 设置页持久化双形:legacy settings 命名空间 / 0.1.7 行配置编辑器
|
|
198
|
+
const installSettings = (scope) => {
|
|
199
|
+
const settings = scope.settings ?? scope.get?.('settings')
|
|
200
|
+
if (settings === undefined) return
|
|
201
|
+
if (typeof settings.register === 'function') {
|
|
202
|
+
try {
|
|
203
|
+
settings.register(NAMESPACE, SETTINGS_SCHEMA, { base: unwrapVolatile(config) })
|
|
204
|
+
} catch (error) {
|
|
205
|
+
console.warn('[dsh-bug-perf] settings unavailable:', error instanceof Error ? error.message : String(error))
|
|
206
|
+
}
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
if (typeof settings.configure === 'function') {
|
|
210
|
+
try {
|
|
211
|
+
scope.effect(() => settings.configure({ auto: false }, ctx.fiber))
|
|
212
|
+
} catch (error) {
|
|
213
|
+
console.warn('[dsh-bug-perf] settings configure failed:', error instanceof Error ? error.message : String(error))
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (typeof ctx.inject === 'function') ctx.inject(['settings'], installSettings)
|
|
218
|
+
|
|
99
219
|
const routes = [
|
|
100
220
|
{
|
|
101
221
|
kind: 'exact',
|
|
102
222
|
path: CONFIG_PATH,
|
|
103
|
-
handler: (
|
|
104
|
-
try {
|
|
105
|
-
sendJson(res, 200, { config: normalizeConfig(unwrapVolatile(config)) })
|
|
106
|
-
} catch {
|
|
107
|
-
sendJson(res, 500, { error: 'config-unavailable' })
|
|
108
|
-
}
|
|
109
|
-
},
|
|
223
|
+
handler: (req, res) => configHandler(ctx, config, req, res),
|
|
110
224
|
},
|
|
111
225
|
{
|
|
112
226
|
kind: 'exact',
|