@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/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
- // 补丁本身全部在客户端执行;这里不做认证,缺 webServer 或路由注册失败都不影响宿主。
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: (_req, res) => {
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',