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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.mjs ADDED
@@ -0,0 +1,79 @@
1
+ // 宿主与客户端共享的配置事实源(客户端不能 import,内联副本由测试对表)
2
+ export const DEFAULT_CONFIG = Object.freeze({
3
+ restartCoalesce: Object.freeze({ enabled: true, windowMs: 100 }),
4
+ terminalWatchdog: Object.freeze({ enabled: true, checkMs: 2000, stallMs: 8000, cooldownMs: 60000 }),
5
+ socketKeepAlive: Object.freeze({ enabled: true, intervalMs: 1500 }),
6
+ stuckSession: Object.freeze({ enabled: true, checkMs: 2000, stallMs: 8000, reconnect: true, maxReconnectAttempts: 5, reconnectIntervalMs: 15000 }),
7
+ jobStreamLimit: Object.freeze({ enabled: true, base: 32, max: 48 }),
8
+ })
9
+
10
+ // 阈值边界:低于下限视为非法配置回退默认,高于上限收敛(防止错误配置放大补丁行为)
11
+ export const LIMITS = Object.freeze({
12
+ restartCoalesce: Object.freeze({ windowMs: Object.freeze({ min: 0, max: 2000 }) }),
13
+ terminalWatchdog: Object.freeze({
14
+ checkMs: Object.freeze({ min: 250, max: 60000 }),
15
+ stallMs: Object.freeze({ min: 1000, max: 600000 }),
16
+ cooldownMs: Object.freeze({ min: 1000, max: 3600000 }),
17
+ }),
18
+ socketKeepAlive: Object.freeze({ intervalMs: Object.freeze({ min: 500, max: 60000 }) }),
19
+ stuckSession: Object.freeze({
20
+ checkMs: Object.freeze({ min: 250, max: 60000 }),
21
+ stallMs: Object.freeze({ min: 1000, max: 600000 }),
22
+ maxReconnectAttempts: Object.freeze({ min: 1, max: 20 }),
23
+ reconnectIntervalMs: Object.freeze({ min: 500, max: 600000 }),
24
+ }),
25
+ jobStreamLimit: Object.freeze({ base: Object.freeze({ min: 4, max: 256 }), max: Object.freeze({ min: 4, max: 512 }) }),
26
+ })
27
+
28
+ const isSection = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
29
+ const sectionOf = (value) => (isSection(value) ? value : {})
30
+
31
+ const booleanOr = (value, fallback) => (typeof value === 'boolean' ? value : fallback)
32
+
33
+ // 非有限数或低于下限 = 非法, 回退默认;超过上限 = 收敛
34
+ const numberWithin = (value, bounds, fallback) => {
35
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
36
+ if (value < bounds.min) return fallback
37
+ return value > bounds.max ? bounds.max : value
38
+ }
39
+
40
+ export function normalizeConfig(raw) {
41
+ const input = sectionOf(raw)
42
+ const restart = sectionOf(input.restartCoalesce)
43
+ const watchdog = sectionOf(input.terminalWatchdog)
44
+ const keepAlive = sectionOf(input.socketKeepAlive)
45
+ const stuck = sectionOf(input.stuckSession)
46
+ const jobLimit = sectionOf(input.jobStreamLimit)
47
+ return {
48
+ restartCoalesce: {
49
+ enabled: booleanOr(restart.enabled, DEFAULT_CONFIG.restartCoalesce.enabled),
50
+ windowMs: numberWithin(restart.windowMs, LIMITS.restartCoalesce.windowMs, DEFAULT_CONFIG.restartCoalesce.windowMs),
51
+ },
52
+ terminalWatchdog: {
53
+ enabled: booleanOr(watchdog.enabled, DEFAULT_CONFIG.terminalWatchdog.enabled),
54
+ checkMs: numberWithin(watchdog.checkMs, LIMITS.terminalWatchdog.checkMs, DEFAULT_CONFIG.terminalWatchdog.checkMs),
55
+ stallMs: numberWithin(watchdog.stallMs, LIMITS.terminalWatchdog.stallMs, DEFAULT_CONFIG.terminalWatchdog.stallMs),
56
+ cooldownMs: numberWithin(watchdog.cooldownMs, LIMITS.terminalWatchdog.cooldownMs, DEFAULT_CONFIG.terminalWatchdog.cooldownMs),
57
+ },
58
+ socketKeepAlive: {
59
+ enabled: booleanOr(keepAlive.enabled, DEFAULT_CONFIG.socketKeepAlive.enabled),
60
+ intervalMs: numberWithin(keepAlive.intervalMs, LIMITS.socketKeepAlive.intervalMs, DEFAULT_CONFIG.socketKeepAlive.intervalMs),
61
+ },
62
+ stuckSession: {
63
+ enabled: booleanOr(stuck.enabled, DEFAULT_CONFIG.stuckSession.enabled),
64
+ checkMs: numberWithin(stuck.checkMs, LIMITS.stuckSession.checkMs, DEFAULT_CONFIG.stuckSession.checkMs),
65
+ stallMs: numberWithin(stuck.stallMs, LIMITS.stuckSession.stallMs, DEFAULT_CONFIG.stuckSession.stallMs),
66
+ reconnect: booleanOr(stuck.reconnect, DEFAULT_CONFIG.stuckSession.reconnect),
67
+ maxReconnectAttempts: numberWithin(stuck.maxReconnectAttempts, LIMITS.stuckSession.maxReconnectAttempts, DEFAULT_CONFIG.stuckSession.maxReconnectAttempts),
68
+ reconnectIntervalMs: numberWithin(stuck.reconnectIntervalMs, LIMITS.stuckSession.reconnectIntervalMs, DEFAULT_CONFIG.stuckSession.reconnectIntervalMs),
69
+ },
70
+ jobStreamLimit: {
71
+ enabled: booleanOr(jobLimit.enabled, DEFAULT_CONFIG.jobStreamLimit.enabled),
72
+ base: Math.min(
73
+ numberWithin(jobLimit.base, LIMITS.jobStreamLimit.base, DEFAULT_CONFIG.jobStreamLimit.base),
74
+ numberWithin(jobLimit.max, LIMITS.jobStreamLimit.max, DEFAULT_CONFIG.jobStreamLimit.max),
75
+ ),
76
+ max: numberWithin(jobLimit.max, LIMITS.jobStreamLimit.max, DEFAULT_CONFIG.jobStreamLimit.max),
77
+ },
78
+ }
79
+ }
package/src/index.js ADDED
@@ -0,0 +1,126 @@
1
+ // dsh-bug-perf Host 半区:把行配置归一化后经只读路由交给浏览器半区,
2
+ // 并接收浏览器定期上报的性能采样(只落本地临时文件,用于定位“越用越卡”)。
3
+ // 补丁本身全部在客户端执行;这里不做认证,缺 webServer 或路由注册失败都不影响宿主。
4
+ import { appendFileSync, existsSync, renameSync, statSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import z from '@deepseek-ai/schemastery'
8
+ import { DEFAULT_CONFIG, normalizeConfig } from './config.mjs'
9
+
10
+ export const name = 'dsh-bug-perf'
11
+ // web profile 必有 webServer; 声明依赖让 apply 在服务就绪后执行,缺服务宿主即不激活本行
12
+ export const inject = ['webServer']
13
+
14
+ const CONFIG_PATH = '/api/dsh-bug-perf/config'
15
+ const DIAG_PATH = '/api/dsh-bug-perf/diag'
16
+ const DIAG_FILE = join(tmpdir(), 'dsh-bug-perf-diag.jsonl')
17
+ const DIAG_MAX_BYTES = 2 * 1024 * 1024
18
+
19
+ const SETTINGS_SCHEMA = z.object({
20
+ restartCoalesce: z.object({
21
+ enabled: z.boolean().default(DEFAULT_CONFIG.restartCoalesce.enabled).description('合并 session/follow 重建:同一 Remote stream 在窗口内的多次重启只保留一次尾随执行'),
22
+ windowMs: z.number().default(DEFAULT_CONFIG.restartCoalesce.windowMs).description('合并窗口(毫秒);0 表示关闭合并,每次重启都立即执行'),
23
+ }).description('会话流重建合并'),
24
+ terminalWatchdog: z.object({
25
+ enabled: z.boolean().default(DEFAULT_CONFIG.terminalWatchdog.enabled).description('终端输出看门狗:视图停滞且仍持有 attach 流时自动重连'),
26
+ checkMs: z.number().default(DEFAULT_CONFIG.terminalWatchdog.checkMs).description('检查周期(毫秒)'),
27
+ stallMs: z.number().default(DEFAULT_CONFIG.terminalWatchdog.stallMs).description('判定停滞的输出静默时长(毫秒)'),
28
+ cooldownMs: z.number().default(DEFAULT_CONFIG.terminalWatchdog.cooldownMs).description('单个视图两次自愈的最小间隔(毫秒)'),
29
+ }).description('终端输出看门狗'),
30
+ socketKeepAlive: z.object({
31
+ enabled: z.boolean().default(DEFAULT_CONFIG.socketKeepAlive.enabled).description('会话流保活:空闲时向会话流连接发送无害帧,避免宿主短心跳把公网连接误判为失联'),
32
+ intervalMs: z.number().default(DEFAULT_CONFIG.socketKeepAlive.intervalMs).description('保活帧间隔(毫秒)'),
33
+ }).description('会话流保活'),
34
+ stuckSession: z.object({
35
+ enabled: z.boolean().default(DEFAULT_CONFIG.stuckSession.enabled).description('会话停滞自愈:加载卡住时主动请求宿主重建连接,多次无效才给出提示'),
36
+ checkMs: z.number().default(DEFAULT_CONFIG.stuckSession.checkMs).description('检查周期(毫秒)'),
37
+ stallMs: z.number().default(DEFAULT_CONFIG.stuckSession.stallMs).description('判定加载停滞的持续时长(毫秒)'),
38
+ reconnect: z.boolean().default(DEFAULT_CONFIG.stuckSession.reconnect).description('停滞时调用宿主 connection.reconnect() 主动重建连接'),
39
+ maxReconnectAttempts: z.number().default(DEFAULT_CONFIG.stuckSession.maxReconnectAttempts).description('连续重建连接的最大次数,用尽后才显示提示'),
40
+ reconnectIntervalMs: z.number().default(DEFAULT_CONFIG.stuckSession.reconnectIntervalMs).description('两次重建连接之间的最小间隔(毫秒)'),
41
+ }).description('会话加载停滞兜底'),
42
+ jobStreamLimit: z.object({
43
+ enabled: z.boolean().default(DEFAULT_CONFIG.jobStreamLimit.enabled).description('任务状态流上限:每个会话行各订阅一条常驻 job/list 流,会话多时会把主线程压满,这里按默认窗口加点击放行'),
44
+ base: z.number().default(DEFAULT_CONFIG.jobStreamLimit.base).description('默认窗口:只给主会话放行的条数,子 agent 会话不占这个窗口'),
45
+ max: z.number().default(DEFAULT_CONFIG.jobStreamLimit.max).description('硬上限;点击某个会话可越过默认窗口加入,总数到上限后淘汰最先加入的一条'),
46
+ }).description('任务状态流上限'),
47
+ }).description('宿主缺陷兜底与性能补丁,每个功能独立开关')
48
+
49
+ // 旧宿主 schemastery 无 volatile 方法,特性检测原样返回
50
+ export const Config = typeof SETTINGS_SCHEMA.volatile === 'function' ? SETTINGS_SCHEMA.volatile() : SETTINGS_SCHEMA
51
+
52
+ // 0.1.7 宿主以整节 volatile ref 传入,旧宿主为普通对象
53
+ const unwrapVolatile = (value) => (typeof value?.get === 'function' ? value.get() : value)
54
+
55
+ const sendJson = (res, status, body) => {
56
+ res.writeHead(status, {
57
+ 'content-type': 'application/json; charset=utf-8',
58
+ 'cache-control': 'no-store',
59
+ })
60
+ res.end(JSON.stringify(body))
61
+ }
62
+
63
+ // 采样文件只保留最近一轮, 超过上限后轮转一次, 避免无限增长
64
+ const recordDiag = (payload) => {
65
+ try {
66
+ if (existsSync(DIAG_FILE) && statSync(DIAG_FILE).size > DIAG_MAX_BYTES) renameSync(DIAG_FILE, `${DIAG_FILE}.1`)
67
+ appendFileSync(DIAG_FILE, `${JSON.stringify(payload)}\n`, 'utf8')
68
+ } catch {
69
+ // 磁盘不可写时丢弃采样, 不影响页面与宿主
70
+ }
71
+ }
72
+
73
+ const diagHandler = (req, res) => {
74
+ let value
75
+ try {
76
+ value = new URL(req.url ?? '/', 'http://localhost').searchParams.get('d')
77
+ } catch {
78
+ sendJson(res, 400, { error: 'bad-request' })
79
+ return
80
+ }
81
+ if (value === null) {
82
+ sendJson(res, 400, { error: 'missing-payload' })
83
+ return
84
+ }
85
+ let payload
86
+ try {
87
+ payload = JSON.parse(value)
88
+ } catch {
89
+ sendJson(res, 400, { error: 'bad-payload' })
90
+ return
91
+ }
92
+ recordDiag(payload)
93
+ sendJson(res, 200, { ok: true })
94
+ }
95
+
96
+ export function apply(ctx, config) {
97
+ const server = ctx.get('webServer')
98
+ if (server === undefined || typeof server.register !== 'function') return
99
+ const routes = [
100
+ {
101
+ kind: 'exact',
102
+ 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
+ },
110
+ },
111
+ {
112
+ kind: 'exact',
113
+ path: DIAG_PATH,
114
+ handler: diagHandler,
115
+ },
116
+ ]
117
+ try {
118
+ ctx.effect(() => {
119
+ const disposers = routes.map((route) => server.register(route))
120
+ return () => { for (const dispose of disposers) if (typeof dispose === 'function') dispose() }
121
+ }, 'bug-perf routes')
122
+ } catch (error) {
123
+ // 路由表冻结/换代竞态:补丁配置退回客户端默认值,不影响宿主
124
+ console.warn('[dsh-bug-perf] routes unavailable:', error instanceof Error ? error.message : String(error))
125
+ }
126
+ }
@@ -0,0 +1,220 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync } from 'node:fs'
4
+
5
+ const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8')
6
+
7
+ function loadPlugin() {
8
+ let registered
9
+ const window = { __ModuleLoader__: { load: (entry) => { registered = entry } } }
10
+ new Function('window', source)(window)
11
+ return registered.factory(() => { throw new Error('no modules') })
12
+ }
13
+
14
+ function fakeJobs() {
15
+ const watched = []
16
+ const released = []
17
+ return {
18
+ watched,
19
+ released,
20
+ watchRows(sessionId) {
21
+ watched.push(sessionId)
22
+ return () => released.push(sessionId)
23
+ },
24
+ }
25
+ }
26
+
27
+ function fakeDoc() {
28
+ const listeners = new Map()
29
+ return {
30
+ addEventListener(type, listener) { listeners.set(type, listener) },
31
+ removeEventListener(type) { listeners.delete(type) },
32
+ clickOn(sessionId) {
33
+ const target = { closest: (selector) => (selector === '[data-row-key]' ? { getAttribute: () => `session:${sessionId}` } : null) }
34
+ listeners.get('click')?.({ target })
35
+ },
36
+ clickOnBlank() {
37
+ listeners.get('click')?.({ target: { closest: () => null } })
38
+ },
39
+ }
40
+ }
41
+
42
+ const fakeSessions = (summaries) => {
43
+ const snapshot = { byId: Object.fromEntries(summaries.map((item) => [item.sessionId, item])) }
44
+ return { list: { getSnapshot: () => snapshot } }
45
+ }
46
+
47
+ // s1..s9 是主会话, c1..c3 是 s1 的子 agent 会话
48
+ const SESSIONS = fakeSessions([
49
+ { sessionId: 's1' },
50
+ { sessionId: 'c1', origin: 'subagent', parentSessionId: 's1' },
51
+ { sessionId: 'c2', origin: 'subagent', parentSessionId: 's1' },
52
+ { sessionId: 'c3', origin: 'subagent', parentSessionId: 's1' },
53
+ { sessionId: 's2' },
54
+ { sessionId: 's3' },
55
+ { sessionId: 's4' },
56
+ { sessionId: 's5' },
57
+ { sessionId: 's6' },
58
+ ])
59
+
60
+ const install = ({ jobs = fakeJobs(), sessions = SESSIONS, doc = fakeDoc(), options = {} } = {}) => {
61
+ const plugin = loadPlugin()
62
+ const result = plugin.__test.installJobStreamLimit({
63
+ jobs,
64
+ sessionsOf: () => sessions,
65
+ doc,
66
+ options: { enabled: true, base: 3, max: 5, ...options },
67
+ })
68
+ return { plugin, jobs, doc, result }
69
+ }
70
+
71
+ // Given 主会话数量未到默认窗口, When 列表逐行订阅, Then 都建立流
72
+ test('任务流_主会话_窗口内放行', () => {
73
+ const { jobs, result } = install()
74
+ for (const id of ['s1', 's2', 's3']) jobs.watchRows(id)
75
+ assert.deepEqual(jobs.watched, ['s1', 's2', 's3'])
76
+ assert.equal(result.state.main, 3)
77
+ assert.equal(result.state.rejected, 0)
78
+ })
79
+
80
+ // Given 默认窗口已满, When 第 4 个主会话订阅, Then 拒绝且不建流
81
+ test('任务流_主会话_超出窗口拒绝', () => {
82
+ const { jobs, result } = install()
83
+ for (const id of ['s1', 's2', 's3', 's4']) jobs.watchRows(id)
84
+ assert.deepEqual(jobs.watched, ['s1', 's2', 's3'])
85
+ assert.equal(result.state.rejected, 1)
86
+ })
87
+
88
+ // Given 默认窗口还有空位, When 子 agent 会话自动订阅, Then 不占用窗口
89
+ test('任务流_子会话_不占窗口', () => {
90
+ const { jobs, result } = install()
91
+ jobs.watchRows('c1')
92
+ jobs.watchRows('s1')
93
+ jobs.watchRows('s2')
94
+ jobs.watchRows('s3')
95
+ assert.deepEqual(jobs.watched, ['s1', 's2', 's3'])
96
+ assert.equal(result.state.subagent, 0)
97
+ assert.equal(result.state.rejected, 1)
98
+ })
99
+
100
+ // Given 点击子 agent 会话, When 窗口已满, Then 仍然为它建立流
101
+ test('任务流_点击子会话_放行', () => {
102
+ const { jobs, doc, result } = install()
103
+ for (const id of ['s1', 's2', 's3']) jobs.watchRows(id)
104
+ doc.clickOn('c1')
105
+ assert.ok(jobs.watched.includes('c1'))
106
+ assert.equal(result.state.subagent, 1)
107
+ assert.equal(result.state.clicked, 1)
108
+ })
109
+
110
+ // Given 点击的主会话不在窗口内, When 点击, Then 同样放行
111
+ test('任务流_点击主会话_放行', () => {
112
+ const { jobs, doc } = install()
113
+ for (const id of ['s1', 's2', 's3']) jobs.watchRows(id)
114
+ doc.clickOn('s6')
115
+ assert.ok(jobs.watched.includes('s6'))
116
+ })
117
+
118
+ // Given 总数已到上限, When 再点击一个会话, Then 淘汰最老的一条, 总数不变
119
+ test('任务流_达到上限_淘汰最老', () => {
120
+ const { jobs, doc, result } = install()
121
+ for (const id of ['s1', 's2', 's3']) jobs.watchRows(id)
122
+ doc.clickOn('c1')
123
+ doc.clickOn('c2')
124
+ assert.equal(result.state.main + result.state.subagent, 5)
125
+ const watchedBefore = jobs.watched.length
126
+ doc.clickOn('c3')
127
+ assert.equal(jobs.watched.length, watchedBefore + 1)
128
+ assert.deepEqual(jobs.released, ['s1'])
129
+ assert.equal(result.state.evicted, 1)
130
+ assert.equal(result.state.main + result.state.subagent, 5)
131
+ })
132
+
133
+ // Given 点击的行已经在窗口内, When 点击, Then 不重复建流, 只标记
134
+ test('任务流_点击已在窗口_不重复建流', () => {
135
+ const { jobs, doc, result } = install()
136
+ jobs.watchRows('s1')
137
+ const before = jobs.watched.length
138
+ doc.clickOn('s1')
139
+ assert.equal(jobs.watched.length, before)
140
+ assert.equal(result.state.clicked, 0)
141
+ })
142
+
143
+ // Given 点击了非会话行, When 触发点击, Then 忽略
144
+ test('任务流_点击非会话行_忽略', () => {
145
+ const { jobs, doc } = install()
146
+ doc.clickOnBlank()
147
+ assert.deepEqual(jobs.watched, [])
148
+ })
149
+
150
+ // Given 行组件卸载, When 调用释放, Then 额度回收, 新的主会话可以进入窗口
151
+ test('任务流_释放后_额度回收', () => {
152
+ const { jobs, result } = install()
153
+ const release = jobs.watchRows('s1')
154
+ jobs.watchRows('s2')
155
+ jobs.watchRows('s3')
156
+ jobs.watchRows('s4')
157
+ assert.equal(result.state.rejected, 1)
158
+ release()
159
+ jobs.watchRows('s4')
160
+ assert.deepEqual(jobs.watched, ['s1', 's2', 's3', 's4'])
161
+ assert.equal(result.state.main, 3)
162
+ })
163
+
164
+ // Given 同一行重复释放, When 调用两次, Then 只扣一次
165
+ test('任务流_重复释放_只扣一次', () => {
166
+ const { jobs, result } = install()
167
+ const release = jobs.watchRows('s1')
168
+ release()
169
+ release()
170
+ assert.equal(result.state.main, 0)
171
+ assert.deepEqual(jobs.released, ['s1'])
172
+ })
173
+
174
+ // Given 包装已安装, When 再次安装, Then 幂等
175
+ test('任务流_重复安装_幂等', () => {
176
+ const { jobs, plugin } = install()
177
+ const second = plugin.__test.installJobStreamLimit({ jobs, sessionsOf: () => SESSIONS, doc: fakeDoc(), options: { enabled: true, base: 3, max: 5 } })
178
+ assert.equal(second.installed, false)
179
+ assert.equal(second.reason, 'already installed')
180
+ })
181
+
182
+ // Given 宿主没有 jobs 服务, When 安装, Then 跳过并记录原因
183
+ test('任务流_服务缺失_跳过', () => {
184
+ const plugin = loadPlugin()
185
+ const result = plugin.__test.installJobStreamLimit({ jobs: undefined, sessionsOf: () => SESSIONS, doc: fakeDoc(), options: { enabled: true, base: 3, max: 5 } })
186
+ assert.equal(result.installed, false)
187
+ assert.equal(result.reason, 'jobs unavailable')
188
+ })
189
+
190
+ // Given 会话摘要尚未就绪, When 订阅, Then 按主会话处理, 不误判为子会话
191
+ test('任务流_摘要缺失_按主会话处理', () => {
192
+ const { jobs, result } = install({ sessions: null })
193
+ jobs.watchRows('s1')
194
+ jobs.watchRows('s2')
195
+ jobs.watchRows('c1')
196
+ jobs.watchRows('s4')
197
+ assert.deepEqual(jobs.watched, ['s1', 's2', 'c1'])
198
+ assert.equal(result.state.rejected, 1)
199
+ })
200
+
201
+ // Given 子会话已被点击放行, When 该会话从摘要里变为普通会话, Then 不重复建流
202
+ test('任务流_重复订阅同一会话_只建一次', () => {
203
+ const { jobs, doc } = install()
204
+ doc.clickOn('c1')
205
+ jobs.watchRows('c1')
206
+ jobs.watchRows('c1')
207
+ assert.deepEqual(jobs.watched, ['c1'])
208
+ })
209
+
210
+ // Given 补丁卸载, When 调用 dispose, Then 所有流被释放且点击监听移除
211
+ test('任务流_卸载_释放全部流', () => {
212
+ const { jobs, doc, result } = install()
213
+ jobs.watchRows('s1')
214
+ jobs.watchRows('s2')
215
+ doc.clickOn('c1')
216
+ result.dispose()
217
+ assert.deepEqual(jobs.released.sort(), ['c1', 's1', 's2'])
218
+ doc.clickOn('s5')
219
+ assert.equal(jobs.watched.length, 3)
220
+ })
@@ -0,0 +1,227 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync } from 'node:fs'
4
+
5
+ const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8')
6
+
7
+ function loadPlugin() {
8
+ let registered
9
+ const window = { __ModuleLoader__: { load: (entry) => { registered = entry } } }
10
+ new Function('window', source)(window)
11
+ return registered.factory(() => { throw new Error('no modules') })
12
+ }
13
+
14
+ function fakeClock() {
15
+ let now = 0
16
+ let nextId = 0
17
+ const intervals = new Map()
18
+ const fireDue = () => {
19
+ for (const [id, timer] of [...intervals]) {
20
+ if (timer.at <= now) {
21
+ timer.at = now + timer.every
22
+ timer.fn()
23
+ }
24
+ }
25
+ }
26
+ return {
27
+ now: () => now,
28
+ setInterval: (fn, ms) => { const id = ++nextId; intervals.set(id, { fn, every: ms, at: now + ms }); return id },
29
+ clearInterval: (id) => { intervals.delete(id) },
30
+ advance(ms) {
31
+ const target = now + ms
32
+ while (true) {
33
+ const due = [...intervals.values()].map((timer) => timer.at).filter((at) => at <= target)
34
+ if (due.length === 0) break
35
+ now = Math.min(...due)
36
+ fireDue()
37
+ }
38
+ now = target
39
+ },
40
+ intervalCount: () => intervals.size,
41
+ }
42
+ }
43
+
44
+ function fakeWebSocketClass() {
45
+ return class FakeSocket {
46
+ static CONNECTING = 0
47
+ static OPEN = 1
48
+ static CLOSING = 2
49
+ static CLOSED = 3
50
+ constructor(url) {
51
+ this.url = String(url)
52
+ this.readyState = 1
53
+ this.sent = []
54
+ this.listeners = new Map()
55
+ }
56
+ send(data) { this.sent.push(String(data)) }
57
+ addEventListener(type, fn) {
58
+ const list = this.listeners.get(type) ?? []
59
+ list.push(fn)
60
+ this.listeners.set(type, list)
61
+ }
62
+ removeEventListener(type, fn) {
63
+ const list = this.listeners.get(type) ?? []
64
+ this.listeners.set(type, list.filter((item) => item !== fn))
65
+ }
66
+ dispatch(type, event) {
67
+ for (const fn of this.listeners.get(type) ?? []) fn(event)
68
+ }
69
+ }
70
+ }
71
+
72
+ function install({ intervalMs = 1500, enabled = true } = {}) {
73
+ const plugin = loadPlugin()
74
+ const FakeSocket = fakeWebSocketClass()
75
+ const win = { WebSocket: FakeSocket, location: { href: 'https://dsh.example/' } }
76
+ const clock = fakeClock()
77
+ const disabled = []
78
+ const result = plugin.__test.installSocketKeepAlive({
79
+ win,
80
+ options: { enabled, intervalMs },
81
+ clock,
82
+ onDisabled: (reason) => disabled.push(reason),
83
+ })
84
+ return { plugin, win, clock, result, Native: FakeSocket, disabled }
85
+ }
86
+
87
+ const keepaliveFrames = (socket) => socket.sent.filter((raw) => raw.includes('dsh-bug-perf-keepalive'))
88
+
89
+ // Given 页面创建 /api/remote.mux 连接, When 空闲到达保活间隔, Then 发出一条合法 item 帧
90
+ test('保活_空闲_发出合法item帧', () => {
91
+ const { win, clock } = install()
92
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
93
+ assert.equal(socket.sent.length, 0)
94
+ clock.advance(1500)
95
+ const frames = keepaliveFrames(socket)
96
+ assert.equal(frames.length, 1)
97
+ const frame = JSON.parse(frames[0])
98
+ assert.equal(frame.type, 'item')
99
+ assert.equal(typeof frame.streamId, 'string')
100
+ assert.ok(frame.streamId.length > 0)
101
+ })
102
+
103
+ // Given 保活已生效, When 继续空闲多个间隔, Then 每个间隔各一条,持续保活
104
+ test('保活_持续_按间隔重复发送', () => {
105
+ const { win, clock } = install()
106
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
107
+ clock.advance(1500)
108
+ clock.advance(1500)
109
+ clock.advance(1500)
110
+ const frames = keepaliveFrames(socket)
111
+ assert.equal(frames.length, 3)
112
+ assert.equal(new Set(frames.map((raw) => JSON.parse(raw).streamId)).size, 3)
113
+ })
114
+
115
+ // Given 连接上客户端自己刚发过上行帧, When 尚未到保活间隔, Then 不补保活帧
116
+ test('保活_有上行活动_不补帧', () => {
117
+ const { win, clock } = install()
118
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
119
+ socket.send('{"type":"open"}')
120
+ clock.advance(1000)
121
+ assert.equal(keepaliveFrames(socket).length, 0)
122
+ clock.advance(1000)
123
+ assert.equal(keepaliveFrames(socket).length, 1)
124
+ })
125
+
126
+ // Given 配置改了保活间隔, When 连接已存在, Then 既有连接按新间隔发送,不需要重建
127
+ test('保活_间隔重配_既有连接生效', () => {
128
+ const { win, clock, result } = install()
129
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
130
+ result.setIntervalMs(4000)
131
+ clock.advance(2500)
132
+ assert.equal(keepaliveFrames(socket).length, 0)
133
+ clock.advance(2000)
134
+ assert.equal(keepaliveFrames(socket).length, 1)
135
+ })
136
+
137
+ // Given 连接不在 OPEN 态, When 到达检查点, Then 不发送
138
+ test('保活_非OPEN_不发送', () => {
139
+ const { win, clock } = install()
140
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
141
+ socket.readyState = 3
142
+ clock.advance(1500)
143
+ assert.equal(socket.sent.length, 0)
144
+ })
145
+
146
+ // Given 非 remote.mux 连接, When 空闲到达间隔, Then 不发送(只补会话流)
147
+ test('保活_其他连接_不发送', () => {
148
+ const { win, clock } = install()
149
+ const socket = new win.WebSocket('wss://dsh.example/api/bus/ws')
150
+ clock.advance(1500)
151
+ assert.equal(socket.sent.length, 0)
152
+ })
153
+
154
+ // Given 开关关闭, When 创建连接, Then 全局 WebSocket 不被包装且无任何帧
155
+ test('保活_关闭_不包装', () => {
156
+ const { win, clock, Native } = install({ enabled: false })
157
+ assert.equal(win.WebSocket, Native)
158
+ clock.advance(5000)
159
+ })
160
+
161
+ // Given 连接关闭, When 连接已断, Then 保活定时器随连接清理,不再发送
162
+ test('保活_连接关闭_清理定时器', () => {
163
+ const { win, clock, result } = install()
164
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
165
+ socket.dispatch('close', { code: 1000 })
166
+ clock.advance(5000)
167
+ assert.equal(socket.sent.length, 0)
168
+ assert.equal(result.state.timers.size, 0)
169
+ })
170
+
171
+ // Given 普通断线与主动关闭, When 连续发生多次, Then 不计入熔断,补丁继续工作
172
+ test('保活_普通断线_不熔断', () => {
173
+ const { win, clock, Native, disabled } = install()
174
+ for (const code of [1000, 1001, 1006, 1006]) {
175
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
176
+ clock.advance(1500)
177
+ socket.dispatch('close', { code })
178
+ }
179
+ assert.equal(win.WebSocket === Native, false)
180
+ assert.deepEqual(disabled, [])
181
+ })
182
+
183
+ // Given 宿主以协议错误码关闭连接, When 连续命中熔断阈值, Then 补丁自行禁用并恢复原始构造器
184
+ test('保活_协议关闭_熔断', () => {
185
+ const { win, clock, Native, disabled } = install()
186
+ for (let round = 0; round < 2; round++) {
187
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
188
+ clock.advance(1500)
189
+ assert.equal(keepaliveFrames(socket).length, 1)
190
+ socket.dispatch('close', { code: 1008 })
191
+ }
192
+ assert.equal(win.WebSocket, Native)
193
+ assert.equal(disabled.length, 1)
194
+ clock.advance(9000)
195
+ })
196
+
197
+ // Given 连接从未发过保活帧, When 以协议错误码关闭, Then 不计入熔断(与应用自身协议错误区分)
198
+ test('保活_未发保活帧_协议关闭不计数', () => {
199
+ const { win, clock, Native } = install()
200
+ for (let round = 0; round < 3; round++) {
201
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
202
+ socket.dispatch('close', { code: 1008 })
203
+ }
204
+ assert.equal(win.WebSocket === Native, false)
205
+ clock.advance(5000)
206
+ })
207
+
208
+ // Given 连接健康存活超过恢复窗口, When 之后才发生协议关闭, Then 历史累计已清零
209
+ test('保活_健康存活_清零累计', () => {
210
+ const { win, clock, Native } = install()
211
+ const first = new win.WebSocket('wss://dsh.example/api/remote.mux')
212
+ clock.advance(1500)
213
+ first.dispatch('close', { code: 1008 })
214
+ const second = new win.WebSocket('wss://dsh.example/api/remote.mux')
215
+ clock.advance(20000)
216
+ second.dispatch('close', { code: 1008 })
217
+ assert.equal(win.WebSocket === Native, false)
218
+ })
219
+
220
+ // Given 已被包装的连接, When 查看全局构造器, Then 仍是 WebSocket 语义且实例可判定
221
+ test('保活_包装后_保持实例语义', () => {
222
+ const { win, Native } = install()
223
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
224
+ assert.ok(socket instanceof Native)
225
+ assert.ok(socket instanceof win.WebSocket)
226
+ assert.equal(win.WebSocket.OPEN, 1)
227
+ })