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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.mjs ADDED
@@ -0,0 +1,100 @@
1
+ // 宿主与客户端共享的配置事实源(客户端不能 import,内联副本由测试对表)
2
+ export const DIAG_MAX_SAMPLES = 720
3
+ const DIAG_DEFAULT_REPORT_EVERY = 12
4
+
5
+ export const DEFAULT_CONFIG = Object.freeze({
6
+ restartCoalesce: Object.freeze({ enabled: true, windowMs: 100 }),
7
+ terminalWatchdog: Object.freeze({ enabled: true, checkMs: 2000, stallMs: 8000, cooldownMs: 60000 }),
8
+ socketKeepAlive: Object.freeze({ enabled: true, intervalMs: 1500 }),
9
+ stuckSession: Object.freeze({ enabled: true, checkMs: 2000, stallMs: 8000, reconnect: true, maxReconnectAttempts: 5, reconnectIntervalMs: 15000 }),
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 }),
13
+ })
14
+
15
+ // 阈值边界:非有限数或低于下限回退默认,高于上限收敛;diag.reportEvery 两侧都收敛
16
+ export const LIMITS = Object.freeze({
17
+ restartCoalesce: Object.freeze({ windowMs: Object.freeze({ min: 0, max: 2000 }) }),
18
+ terminalWatchdog: Object.freeze({
19
+ checkMs: Object.freeze({ min: 250, max: 60000 }),
20
+ stallMs: Object.freeze({ min: 1000, max: 600000 }),
21
+ cooldownMs: Object.freeze({ min: 1000, max: 3600000 }),
22
+ }),
23
+ socketKeepAlive: Object.freeze({ intervalMs: Object.freeze({ min: 500, max: 60000 }) }),
24
+ stuckSession: Object.freeze({
25
+ checkMs: Object.freeze({ min: 250, max: 60000 }),
26
+ stallMs: Object.freeze({ min: 1000, max: 600000 }),
27
+ maxReconnectAttempts: Object.freeze({ min: 1, max: 20 }),
28
+ reconnectIntervalMs: Object.freeze({ min: 500, max: 600000 }),
29
+ }),
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 }) }),
32
+ })
33
+
34
+ const isSection = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
35
+ const sectionOf = (value) => (isSection(value) ? value : {})
36
+
37
+ const booleanOr = (value, fallback) => (typeof value === 'boolean' ? value : fallback)
38
+
39
+ // 非有限数或低于下限 = 非法, 回退默认;超过上限 = 收敛
40
+ const numberWithin = (value, bounds, fallback) => {
41
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
42
+ if (value < bounds.min) return fallback
43
+ return value > bounds.max ? bounds.max : value
44
+ }
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
+
52
+ export function normalizeConfig(raw) {
53
+ const input = sectionOf(raw)
54
+ const restart = sectionOf(input.restartCoalesce)
55
+ const watchdog = sectionOf(input.terminalWatchdog)
56
+ const keepAlive = sectionOf(input.socketKeepAlive)
57
+ const stuck = sectionOf(input.stuckSession)
58
+ const jobLimit = sectionOf(input.jobStreamLimit)
59
+ const listSlim = sectionOf(input.listSlim)
60
+ const diag = sectionOf(input.diag)
61
+ return {
62
+ restartCoalesce: {
63
+ enabled: booleanOr(restart.enabled, DEFAULT_CONFIG.restartCoalesce.enabled),
64
+ windowMs: numberWithin(restart.windowMs, LIMITS.restartCoalesce.windowMs, DEFAULT_CONFIG.restartCoalesce.windowMs),
65
+ },
66
+ terminalWatchdog: {
67
+ enabled: booleanOr(watchdog.enabled, DEFAULT_CONFIG.terminalWatchdog.enabled),
68
+ checkMs: numberWithin(watchdog.checkMs, LIMITS.terminalWatchdog.checkMs, DEFAULT_CONFIG.terminalWatchdog.checkMs),
69
+ stallMs: numberWithin(watchdog.stallMs, LIMITS.terminalWatchdog.stallMs, DEFAULT_CONFIG.terminalWatchdog.stallMs),
70
+ cooldownMs: numberWithin(watchdog.cooldownMs, LIMITS.terminalWatchdog.cooldownMs, DEFAULT_CONFIG.terminalWatchdog.cooldownMs),
71
+ },
72
+ socketKeepAlive: {
73
+ enabled: booleanOr(keepAlive.enabled, DEFAULT_CONFIG.socketKeepAlive.enabled),
74
+ intervalMs: numberWithin(keepAlive.intervalMs, LIMITS.socketKeepAlive.intervalMs, DEFAULT_CONFIG.socketKeepAlive.intervalMs),
75
+ },
76
+ stuckSession: {
77
+ enabled: booleanOr(stuck.enabled, DEFAULT_CONFIG.stuckSession.enabled),
78
+ checkMs: numberWithin(stuck.checkMs, LIMITS.stuckSession.checkMs, DEFAULT_CONFIG.stuckSession.checkMs),
79
+ stallMs: numberWithin(stuck.stallMs, LIMITS.stuckSession.stallMs, DEFAULT_CONFIG.stuckSession.stallMs),
80
+ reconnect: booleanOr(stuck.reconnect, DEFAULT_CONFIG.stuckSession.reconnect),
81
+ maxReconnectAttempts: numberWithin(stuck.maxReconnectAttempts, LIMITS.stuckSession.maxReconnectAttempts, DEFAULT_CONFIG.stuckSession.maxReconnectAttempts),
82
+ reconnectIntervalMs: numberWithin(stuck.reconnectIntervalMs, LIMITS.stuckSession.reconnectIntervalMs, DEFAULT_CONFIG.stuckSession.reconnectIntervalMs),
83
+ },
84
+ jobStreamLimit: {
85
+ enabled: booleanOr(jobLimit.enabled, DEFAULT_CONFIG.jobStreamLimit.enabled),
86
+ base: Math.min(
87
+ numberWithin(jobLimit.base, LIMITS.jobStreamLimit.base, DEFAULT_CONFIG.jobStreamLimit.base),
88
+ numberWithin(jobLimit.max, LIMITS.jobStreamLimit.max, DEFAULT_CONFIG.jobStreamLimit.max),
89
+ ),
90
+ max: numberWithin(jobLimit.max, LIMITS.jobStreamLimit.max, DEFAULT_CONFIG.jobStreamLimit.max),
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
+ },
99
+ }
100
+ }
package/src/index.js ADDED
@@ -0,0 +1,240 @@
1
+ // dsh-bug-perf Host 半区:把行配置归一化后经配置路由交给浏览器半区,接收设置页保存,
2
+ // 并接收浏览器定期上报的性能采样(只落本地临时文件,用于定位“越用越卡”)。
3
+ // 会话列表瘦身在宿主执行(包装 sessionController.list),其余补丁在客户端执行;
4
+ // 这里不做认证,缺 webServer 或路由注册失败都不影响宿主。
5
+ import { appendFileSync, existsSync, renameSync, statSync } from 'node:fs'
6
+ import { tmpdir } from 'node:os'
7
+ import { join } from 'node:path'
8
+ import z from '@deepseek-ai/schemastery'
9
+ import { DEFAULT_CONFIG, normalizeConfig } from './config.mjs'
10
+ import { installListSlim } from './list-slim.mjs'
11
+
12
+ export const name = 'dsh-bug-perf'
13
+ // web profile 必有 webServer; 声明依赖让 apply 在服务就绪后执行,缺服务宿主即不激活本行
14
+ export const inject = ['webServer']
15
+
16
+ const CONFIG_PATH = '/api/dsh-bug-perf/config'
17
+ const DIAG_PATH = '/api/dsh-bug-perf/diag'
18
+ const NAMESPACE = 'bug-perf'
19
+ const BODY_MAX_BYTES = 64 * 1024
20
+ const DIAG_FILE = join(tmpdir(), 'dsh-bug-perf-diag.jsonl')
21
+ const DIAG_MAX_BYTES = 2 * 1024 * 1024
22
+
23
+ const SETTINGS_SCHEMA = z.object({
24
+ restartCoalesce: z.object({
25
+ enabled: z.boolean().default(DEFAULT_CONFIG.restartCoalesce.enabled).description('合并 session/follow 重建:同一 Remote stream 在窗口内的多次重启只保留一次尾随执行'),
26
+ windowMs: z.number().default(DEFAULT_CONFIG.restartCoalesce.windowMs).description('合并窗口(毫秒);0 表示关闭合并,每次重启都立即执行'),
27
+ }).description('会话流重建合并'),
28
+ terminalWatchdog: z.object({
29
+ enabled: z.boolean().default(DEFAULT_CONFIG.terminalWatchdog.enabled).description('终端输出看门狗:视图停滞且仍持有 attach 流时自动重连'),
30
+ checkMs: z.number().default(DEFAULT_CONFIG.terminalWatchdog.checkMs).description('检查周期(毫秒)'),
31
+ stallMs: z.number().default(DEFAULT_CONFIG.terminalWatchdog.stallMs).description('判定停滞的输出静默时长(毫秒)'),
32
+ cooldownMs: z.number().default(DEFAULT_CONFIG.terminalWatchdog.cooldownMs).description('单个视图两次自愈的最小间隔(毫秒)'),
33
+ }).description('终端输出看门狗'),
34
+ socketKeepAlive: z.object({
35
+ enabled: z.boolean().default(DEFAULT_CONFIG.socketKeepAlive.enabled).description('会话流保活:空闲时向会话流连接发送无害帧,避免宿主短心跳把公网连接误判为失联'),
36
+ intervalMs: z.number().default(DEFAULT_CONFIG.socketKeepAlive.intervalMs).description('保活帧间隔(毫秒)'),
37
+ }).description('会话流保活'),
38
+ stuckSession: z.object({
39
+ enabled: z.boolean().default(DEFAULT_CONFIG.stuckSession.enabled).description('会话停滞自愈:加载卡住时主动请求宿主重建连接,多次无效才给出提示'),
40
+ checkMs: z.number().default(DEFAULT_CONFIG.stuckSession.checkMs).description('检查周期(毫秒)'),
41
+ stallMs: z.number().default(DEFAULT_CONFIG.stuckSession.stallMs).description('判定加载停滞的持续时长(毫秒)'),
42
+ reconnect: z.boolean().default(DEFAULT_CONFIG.stuckSession.reconnect).description('停滞时调用宿主 connection.reconnect() 主动重建连接'),
43
+ maxReconnectAttempts: z.number().default(DEFAULT_CONFIG.stuckSession.maxReconnectAttempts).description('连续重建连接的最大次数,用尽后才显示提示'),
44
+ reconnectIntervalMs: z.number().default(DEFAULT_CONFIG.stuckSession.reconnectIntervalMs).description('两次重建连接之间的最小间隔(毫秒)'),
45
+ }).description('会话加载停滞兜底'),
46
+ jobStreamLimit: z.object({
47
+ enabled: z.boolean().default(DEFAULT_CONFIG.jobStreamLimit.enabled).description('任务状态流上限:每个会话行各订阅一条常驻 job/list 流,会话多时会把主线程压满,这里按默认窗口加点击放行'),
48
+ base: z.number().default(DEFAULT_CONFIG.jobStreamLimit.base).description('默认窗口:只给主会话放行的条数,子 agent 会话不占这个窗口'),
49
+ max: z.number().default(DEFAULT_CONFIG.jobStreamLimit.max).description('硬上限;点击某个会话可越过默认窗口加入,总数到上限后淘汰最先加入的一条'),
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('性能采样上报'),
58
+ }).description('宿主缺陷兜底与性能补丁,每个功能独立开关')
59
+
60
+ // 旧宿主 schemastery 无 volatile 方法,特性检测原样返回
61
+ export const Config = typeof SETTINGS_SCHEMA.volatile === 'function' ? SETTINGS_SCHEMA.volatile() : SETTINGS_SCHEMA
62
+
63
+ // 0.1.7 宿主以整节 volatile ref 传入,旧宿主为普通对象
64
+ const unwrapVolatile = (value) => (typeof value?.get === 'function' ? value.get() : value)
65
+
66
+ const sendJson = (res, status, body) => {
67
+ res.writeHead(status, {
68
+ 'content-type': 'application/json; charset=utf-8',
69
+ 'cache-control': 'no-store',
70
+ })
71
+ res.end(JSON.stringify(body))
72
+ }
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
+
158
+ // 采样文件只保留最近一轮, 超过上限后轮转一次, 避免无限增长
159
+ const recordDiag = (payload) => {
160
+ try {
161
+ if (existsSync(DIAG_FILE) && statSync(DIAG_FILE).size > DIAG_MAX_BYTES) renameSync(DIAG_FILE, `${DIAG_FILE}.1`)
162
+ appendFileSync(DIAG_FILE, `${JSON.stringify(payload)}\n`, 'utf8')
163
+ } catch {
164
+ // 磁盘不可写时丢弃采样, 不影响页面与宿主
165
+ }
166
+ }
167
+
168
+ const diagHandler = (req, res) => {
169
+ let value
170
+ try {
171
+ value = new URL(req.url ?? '/', 'http://localhost').searchParams.get('d')
172
+ } catch {
173
+ sendJson(res, 400, { error: 'bad-request' })
174
+ return
175
+ }
176
+ if (value === null) {
177
+ sendJson(res, 400, { error: 'missing-payload' })
178
+ return
179
+ }
180
+ let payload
181
+ try {
182
+ payload = JSON.parse(value)
183
+ } catch {
184
+ sendJson(res, 400, { error: 'bad-payload' })
185
+ return
186
+ }
187
+ recordDiag(payload)
188
+ sendJson(res, 200, { ok: true })
189
+ }
190
+
191
+ export function apply(ctx, config) {
192
+ // 列表瘦身:不依赖 webServer/路由,开关从行配置实时读取;装载失败只关闭该能力
193
+ installListSlim(ctx, { isEnabled: () => normalizeConfig(unwrapVolatile(config)).listSlim.enabled })
194
+ const server = ctx.get('webServer')
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
+
219
+ const routes = [
220
+ {
221
+ kind: 'exact',
222
+ path: CONFIG_PATH,
223
+ handler: (req, res) => configHandler(ctx, config, req, res),
224
+ },
225
+ {
226
+ kind: 'exact',
227
+ path: DIAG_PATH,
228
+ handler: diagHandler,
229
+ },
230
+ ]
231
+ try {
232
+ ctx.effect(() => {
233
+ const disposers = routes.map((route) => server.register(route))
234
+ return () => { for (const dispose of disposers) if (typeof dispose === 'function') dispose() }
235
+ }, 'bug-perf routes')
236
+ } catch (error) {
237
+ // 路由表冻结/换代竞态:补丁配置退回客户端默认值,不影响宿主
238
+ console.warn('[dsh-bug-perf] routes unavailable:', error instanceof Error ? error.message : String(error))
239
+ }
240
+ }
@@ -0,0 +1,207 @@
1
+ // 会话列表瘦身:session/list 为每个会话下发完整投影快照,列表行只消费其中少数几项。
2
+ // 本模块在 remote 方法返回前按白名单裁剪并缓存结果。任何失败路径都降级为原行为:
3
+ // 未装载、形状不符、原方法抛错一律透传,不影响本插件的其它补丁。
4
+ // sessionController 经 ctx.inject 可选依赖取得,宿主缺该服务时静默等待。
5
+ // 缓存只服务 owner/本地调用:非 owner Remote 主体直接透传原方法,避免跨主体命中共享缓存。
6
+
7
+ // 列表行消费的投影;其余键不下发。被裁掉的投影仍由会话详情接口完整返回。
8
+ export const KEEP_PROJECTIONS = ['title', 'sessionListMetadata', 'inbox', 'modelSelection', 'tokenUsage']
9
+
10
+ // 缓存有效期:会话增删立即失效,TTL 只兜底标题与运行时状态的漂移。
11
+ export const TTL_MS = 15 * 1000
12
+
13
+ // 启动后预热延迟:让宿主先完成装载,再让出一次列表重建。
14
+ export const WARMUP_DELAY_MS = 10 * 1000
15
+
16
+ // 预热等待上限:宿主列表长期不返回时不无限占用共享构建。
17
+ export const WARMUP_TIMEOUT_MS = 2 * 60 * 1000
18
+
19
+ const LOG_PREFIX = '[dsh-bug-perf] 列表瘦身'
20
+ const INSTALL_MARKER = Symbol.for('@mzzsfy/dsh-bug-perf/list-slim')
21
+ const WRAPPED_MARKER = Symbol.for('@mzzsfy/dsh-bug-perf/list-slim/wrapped')
22
+
23
+ /**
24
+ * 当前 receiver 是否来自非 owner Remote 主体。
25
+ * 与宿主 remoteAccess.allows 的判定同源:owner 或非 Remote 调用没有 principal。
26
+ * @param receiver - 列表方法调用时的 this。
27
+ * @returns 非 owner 主体返回 true。
28
+ */
29
+ function isNonOwner(receiver) {
30
+ const principal = receiver?.ctx?.invocation?.peer?.principal
31
+ return principal !== undefined && principal.kind !== 'owner'
32
+ }
33
+
34
+ /**
35
+ * 让一个调用者按自己的 signal 等待共享构建,取消只影响本次等待。
36
+ * @param promise - 共享的列表构建。
37
+ * @param signal - 调用者的取消信号。
38
+ * @returns 本调用者的等待结果。
39
+ */
40
+ function raceWithSignal(promise, signal) {
41
+ if (signal === undefined) return promise
42
+ if (signal.aborted) return Promise.reject(signal.reason ?? new Error('aborted'))
43
+ return new Promise((resolve, reject) => {
44
+ const onAbort = () => reject(signal.reason ?? new Error('aborted'))
45
+ signal.addEventListener('abort', onAbort, { once: true })
46
+ promise.then(
47
+ (value) => {
48
+ signal.removeEventListener('abort', onAbort)
49
+ resolve(value)
50
+ },
51
+ (error) => {
52
+ signal.removeEventListener('abort', onAbort)
53
+ reject(error)
54
+ },
55
+ )
56
+ })
57
+ }
58
+
59
+ /**
60
+ * 裁剪一条列表项的投影,只保留白名单里的键。
61
+ * @param item - 宿主返回的列表项。
62
+ * @param keep - 保留的投影键。
63
+ * @returns 投影已裁剪的列表项;形状不符时原样返回。
64
+ */
65
+ export function slimItem(item, keep = KEEP_PROJECTIONS) {
66
+ const values = item?.projections?.values
67
+ if (values === null || typeof values !== 'object' || Array.isArray(values)) return item
68
+ const picked = {}
69
+ for (const key of keep) if (key in values) picked[key] = values[key]
70
+ return { ...item, projections: { ...item.projections, values: picked } }
71
+ }
72
+
73
+ /**
74
+ * 给 sessionController.list 装上裁剪与缓存。
75
+ * @param ctx - 宿主上下文。
76
+ * @param options - 开关、白名单、缓存有效期与预热延迟。
77
+ * @returns 是否提交了装载回调;sessionController 缺席时回调不执行,宿主行为不变。
78
+ */
79
+ export function installListSlim(ctx, options = {}) {
80
+ const {
81
+ isEnabled,
82
+ keep = KEEP_PROJECTIONS,
83
+ ttlMs = TTL_MS,
84
+ warmupDelayMs = WARMUP_DELAY_MS,
85
+ } = options
86
+ const warn = (message) => ctx.logger?.warn?.(LOG_PREFIX + ' ' + message)
87
+ try {
88
+ ctx.inject(['sessionController'], (scope) => {
89
+ try {
90
+ const controller = scope.sessionController
91
+ const original = controller?.list
92
+ if (typeof original !== 'function') {
93
+ warn('sessionController.list 不可用,跳过')
94
+ return
95
+ }
96
+ if (controller[INSTALL_MARKER] !== undefined && controller.list?.[WRAPPED_MARKER] === true) return
97
+ let cache
98
+ let cachedAt = 0
99
+ let inflight
100
+ let generation = 0
101
+ let active = true
102
+
103
+ const fresh = () => cache !== undefined && Date.now() - cachedAt < ttlMs
104
+ const invalidate = () => {
105
+ generation += 1
106
+ cachedAt = 0
107
+ }
108
+ scope.on('api-session/added', invalidate)
109
+ scope.on('api-session/removed', invalidate)
110
+
111
+ // 每个调用者独立等待共享构建;所有等待者都取消时中止构建,避免挂起被后续调用复用
112
+ const awaitBuild = (entry, signal) => {
113
+ const waiter = {}
114
+ entry.waiters.add(waiter)
115
+ const result = raceWithSignal(entry.promise, signal)
116
+ const settle = () => {
117
+ entry.waiters.delete(waiter)
118
+ if (entry.waiters.size === 0 && inflight === entry) {
119
+ entry.controller.abort()
120
+ inflight = undefined
121
+ }
122
+ }
123
+ result.then(settle, settle)
124
+ return result
125
+ }
126
+
127
+ // 缓存跨调用共享,按 TTL 与会话增删事件失效;非 owner 不读写缓存。
128
+ // 普通函数保留调用方 receiver:宿主在 list 内按 invocation 做逐项鉴权。
129
+ const wrapped = function list(request, signal) {
130
+ const receiver = this
131
+ // 卸载、关闭、已取消或非 owner:不裁剪、不缓存,原样交给宿主
132
+ if (!active || signal?.aborted || (isEnabled !== undefined && !isEnabled()) || isNonOwner(receiver)) {
133
+ return original.call(receiver, request, signal)
134
+ }
135
+ if (fresh()) return cache
136
+ // 共享构建不绑定任何调用者的 signal;调用者取消只结束自己的等待
137
+ if (inflight !== undefined && inflight.generation === generation) return awaitBuild(inflight, signal)
138
+ const current = generation
139
+ const buildController = new AbortController()
140
+ const entry = { promise: undefined, generation: current, controller: buildController, waiters: new Set() }
141
+ const created = Promise.resolve(original.call(receiver, request, buildController.signal))
142
+ .then((value) => {
143
+ if (!Array.isArray(value?.items)) return value
144
+ const slimmed = { items: value.items.map((item) => slimItem(item, keep)) }
145
+ // 在途期间发生增删失效时只返回本次结果,不把旧快照写回缓存
146
+ if (generation === current && inflight === entry) {
147
+ cache = slimmed
148
+ cachedAt = Date.now()
149
+ }
150
+ return slimmed
151
+ })
152
+ .finally(() => {
153
+ if (inflight === entry) inflight = undefined
154
+ })
155
+ entry.promise = created
156
+ inflight = entry
157
+ return awaitBuild(entry, signal)
158
+ }
159
+ Object.defineProperty(controller, INSTALL_MARKER, { value: wrapped, configurable: true })
160
+ Object.defineProperty(wrapped, WRAPPED_MARKER, { value: true })
161
+ try {
162
+ controller.list = wrapped
163
+ } catch (error) {
164
+ if (controller[INSTALL_MARKER] === wrapped) delete controller[INSTALL_MARKER]
165
+ throw error
166
+ }
167
+ let timer
168
+ if (warmupDelayMs >= 0) {
169
+ timer = setTimeout(() => {
170
+ if (isEnabled !== undefined && !isEnabled()) return
171
+ if (fresh() || inflight !== undefined) return
172
+ const timeout = AbortSignal.timeout?.(WARMUP_TIMEOUT_MS)
173
+ void wrapped.call(controller, {}, timeout).catch(() => {})
174
+ }, warmupDelayMs)
175
+ timer.unref?.()
176
+ }
177
+
178
+ // 卸载恢复原方法:按包装标记与归属判断,不摘掉后装或其它实例的包装;
179
+ // 已被后装引用时由 active 失活,链上继续透传;在途构建一并中止
180
+ const dispose = () => {
181
+ active = false
182
+ if (timer !== undefined) clearTimeout(timer)
183
+ if (inflight !== undefined) {
184
+ inflight.controller.abort()
185
+ inflight = undefined
186
+ }
187
+ if (controller[INSTALL_MARKER] === wrapped && controller.list?.[WRAPPED_MARKER] === true) controller.list = original
188
+ if (controller[INSTALL_MARKER] === wrapped) delete controller[INSTALL_MARKER]
189
+ }
190
+ try {
191
+ scope.effect(() => dispose)
192
+ } catch (error) {
193
+ // effect 注册失败时回滚包装与标记,避免留下无法卸载的包装
194
+ dispose()
195
+ throw error
196
+ }
197
+ ctx.logger?.info?.(`${LOG_PREFIX} 已装载 (keep=${keep.join(',')} ttl=${ttlMs}ms)`)
198
+ } catch (error) {
199
+ warn('装载失败,保持原行为: ' + String(error?.message ?? error))
200
+ }
201
+ })
202
+ return true
203
+ } catch (error) {
204
+ warn('未启用: ' + String(error?.message ?? error))
205
+ return false
206
+ }
207
+ }