dsh-mobile-plugin 0.1.87 → 0.1.89
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/lib/impl.js +7 -0
- package/lib/mobile/events.js +14 -58
- package/lib/mobile/history-derive.js +32 -4
- package/lib/mobile/host-bridge/endpoints.js +3 -2
- package/lib/mobile/host-bridge/shims.js +31 -5
- package/lib/mobile/self-check.cjs +254 -0
- package/lib/mobile/stream.js +14 -98
- package/lib/panel/index.html +9 -9
- package/lib/relay/lan-proxy.js +66 -77
- package/lib/relay/tunnel.js +8 -0
- package/lib/routes/control-plane.js +21 -1
- package/lib/shared/sse-write.js +168 -0
- package/lib/sidecar-facade-worker.cjs +8 -1
- package/package.json +1 -1
package/lib/impl.js
CHANGED
|
@@ -353,6 +353,13 @@ function apply(ctx) {
|
|
|
353
353
|
// 桌面端「我读过了」那条路由(/api/relay/desktop-read)要清掉宿主侧的完成提醒,
|
|
354
354
|
// 需要插件 ctx 才能拿到按设备隔离的状态表。
|
|
355
355
|
ctx,
|
|
356
|
+
// 内存自检台账(`/api/relay/status` 的 `selfCheck`)的四个取值器:台账本体在
|
|
357
|
+
// `lib/mobile/self-check.cjs`。侧车内存与两条路的流数都只有 worker 的 `/status`
|
|
358
|
+
// 快照这一条既有通道(局域网那份由 lan-proxy 的 stats() 经快照带出来)。
|
|
359
|
+
getSidecarSnapshot: sidecarLifecycle.getSnapshot,
|
|
360
|
+
getMirrorPush: mirror.getMirrorPush,
|
|
361
|
+
getMirrorPushFailures: mirror.getPushFailures,
|
|
362
|
+
getLanProxyStats: () => sidecarLifecycle.getSnapshot()?.streams?.lan ?? null,
|
|
356
363
|
})
|
|
357
364
|
|
|
358
365
|
|
package/lib/mobile/events.js
CHANGED
|
@@ -17,23 +17,11 @@
|
|
|
17
17
|
*/
|
|
18
18
|
'use strict';
|
|
19
19
|
|
|
20
|
-
const crypto = require('crypto');
|
|
21
|
-
|
|
22
20
|
const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
|
|
23
21
|
const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal.js');
|
|
22
|
+
const { writeSse: sharedWriteSse, stableEventId: sharedEventId } = require('../shared/sse-write.js');
|
|
24
23
|
|
|
25
|
-
const SSE_RETRY_MS = 3000;
|
|
26
24
|
const DEDUP_WINDOW = 2048;
|
|
27
|
-
/**
|
|
28
|
-
* 写缓冲的两道水位(字节),与 `mobile/stream.js` 同一口径。
|
|
29
|
-
*
|
|
30
|
-
* `res.write()` 在客户端读得慢时返回 false,字节留在 Node 的写队列里。设备级流以前
|
|
31
|
-
* 完全不看这个信号,于是「手机挂着一条流、网又慢」时帧无限堆积 —— 2026-10-17 真机
|
|
32
|
-
* 现场:堆快照里 17.6 万条 SSE 帧(466MB)挂在 `WriteWrap._chunks` 上,宿主 70 分钟
|
|
33
|
-
* 涨到 3.9G 被 OOM 杀掉。软水位只丢信号帧(幂等、下一条变更会再补),硬水位才收尾。
|
|
34
|
-
*/
|
|
35
|
-
const SSE_SHED_BUFFERED_BYTES = 4 * 1024 * 1024;
|
|
36
|
-
const SSE_CLOSE_BUFFERED_BYTES = 16 * 1024 * 1024;
|
|
37
25
|
/** 心跳帧间隔。App 用它判定「这条流真的还活着」,必须与 app 侧的 SIGNAL_HEARTBEAT_MS 一致。 */
|
|
38
26
|
const HEARTBEAT_MS = 15_000;
|
|
39
27
|
|
|
@@ -51,55 +39,23 @@ function logDeviceStream(message, error) {
|
|
|
51
39
|
} catch { /* ignore */ }
|
|
52
40
|
}
|
|
53
41
|
|
|
54
|
-
function stableEventId(payload, frame) {
|
|
55
|
-
if (payload && typeof payload === 'object') {
|
|
56
|
-
if (payload.type === 'session/event' && payload.sessionId && payload.event && Number.isFinite(payload.event.seq)) {
|
|
57
|
-
return `${payload.sessionId}:${payload.event.seq}`;
|
|
58
|
-
}
|
|
59
|
-
for (const key of ['eventId', 'rpcId', 'id', 'approvalId']) {
|
|
60
|
-
if (typeof payload[key] === 'string' && payload[key]) return payload[key];
|
|
61
|
-
}
|
|
62
|
-
const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : 'device';
|
|
63
|
-
const digest = crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 24);
|
|
64
|
-
return `${sessionId}:${payload.type || 'event'}:${digest}`;
|
|
65
|
-
}
|
|
66
|
-
return frame && typeof frame.rpcId === 'string' && frame.rpcId ? frame.rpcId : undefined;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
42
|
/**
|
|
70
|
-
*
|
|
43
|
+
* 写一条 SSE 记录(策略声明,实现见 `lib/shared/sse-write.js`)。
|
|
71
44
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
-
function bufferedBytesOf(res) {
|
|
77
|
-
const own = Number(res.writableLength) || 0;
|
|
78
|
-
const socket = res.socket || res.connection;
|
|
79
|
-
const queued = socket ? Number(socket.writableLength) || 0 : 0;
|
|
80
|
-
return Math.max(own, queued);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* 写一条 SSE 记录。返回值与 `mobile/stream.js` 的同名函数一致:`ok:false` 该收尾,
|
|
85
|
-
* `shed:true` 这条被丢掉,`needDrain:true` 写进去了但底下已经写不动。
|
|
45
|
+
* 这条流的策略:软水位**一律** shed。设备级流的帧全是「有东西变了」的信号,本来就幂等
|
|
46
|
+
* —— 丢掉一帧的代价是那一次刷新要等下一次变更或 60s 兜底轮询,而堆在写缓冲里的代价是
|
|
47
|
+
* 宿主 OOM(2026-10-17 现场:466MB 的 SSE 帧挂在 socket 写队列上,宿主撞 4GB 上限被杀)。
|
|
48
|
+
* 不补填充:这里的帧少、单帧也小,不需要撑 RN 的缓冲阈值。
|
|
86
49
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* 的 SSE 帧挂在 socket 写队列上,宿主撞 4GB 上限被杀)。所以软水位一律 shed。
|
|
50
|
+
* 返回值与会话流一致:`ok:false` 该收尾,`shed:true` 这条被丢掉,`needDrain:true`
|
|
51
|
+
* 写进去了但底下已经写不动。
|
|
90
52
|
*/
|
|
91
53
|
function writeSse(res, payload, frame) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (id) fields.push(`id: ${id}`);
|
|
98
|
-
fields.push(`data: ${JSON.stringify(payload)}`, '', '');
|
|
99
|
-
try {
|
|
100
|
-
const accepted = res.write(`${fields.join('\n')}\n`);
|
|
101
|
-
return { ok: true, needDrain: accepted === false };
|
|
102
|
-
} catch { return { ok: false }; }
|
|
54
|
+
return sharedWriteSse(res, payload, {
|
|
55
|
+
frame,
|
|
56
|
+
namespace: 'device',
|
|
57
|
+
shedWhenBuffered: () => true,
|
|
58
|
+
});
|
|
103
59
|
}
|
|
104
60
|
|
|
105
61
|
async function* mergeHostAndMux(ctx, signal) {
|
|
@@ -290,7 +246,7 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
290
246
|
const payload = listRelevantPayload(rawPayload);
|
|
291
247
|
if (!payload) continue;
|
|
292
248
|
const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : undefined;
|
|
293
|
-
const eventId =
|
|
249
|
+
const eventId = sharedEventId(rawPayload, frame, 'device');
|
|
294
250
|
if (eventId) {
|
|
295
251
|
if (seenEventIds.has(eventId)) continue;
|
|
296
252
|
seenEventIds.add(eventId);
|
|
@@ -138,6 +138,10 @@ function parseBlocks(blocks, toolResults) {
|
|
|
138
138
|
// does not expose chain-of-thought. They are known blocks, not unsupported
|
|
139
139
|
// content; silently omit empty placeholders and avoid leaking their JSON.
|
|
140
140
|
if (typeof b.text === 'string' && b.text) out.push({ type: 'reasoning', text: b.text });
|
|
141
|
+
} else if (b.type === 'image' && b.dataUnavailable === true) {
|
|
142
|
+
// The image attachment resolver marked this block as unavailable. Keep it out of
|
|
143
|
+
// visible text; pushMessage projects its retry metadata onto unavailableImages.
|
|
144
|
+
continue;
|
|
141
145
|
} else if (b.type === 'image' && typeof b.data === 'string' && typeof b.mediaType === 'string') {
|
|
142
146
|
out.push({ type: 'image', mediaType: b.mediaType, data: b.data, name: typeof b.name === 'string' ? b.name : undefined });
|
|
143
147
|
} else if (b.type === 'tool-call') {
|
|
@@ -321,12 +325,35 @@ function deriveMessages(events) {
|
|
|
321
325
|
|
|
322
326
|
// 普通消息(user/assistant)沿用原折叠逻辑。
|
|
323
327
|
const pushMessage = (msg, e) => {
|
|
324
|
-
const
|
|
328
|
+
const parsed = parseBlocks(msg.content, toolResults);
|
|
329
|
+
// 工具产出的图挂成这条消息的图片附件:App 把它画在气泡上方(缩略图 + 文件名,点开全屏),
|
|
330
|
+
// 与用户自己发的图同一种形状。工具卡里不再重复画同一张图 —— 一张图两处画会把字节也翻倍。
|
|
331
|
+
const toolImages = [];
|
|
332
|
+
const blocks = parsed.map((block) => {
|
|
333
|
+
if (block.type !== 'tool-call' || !Array.isArray(block.images) || block.images.length === 0) return block;
|
|
334
|
+
toolImages.push(...block.images);
|
|
335
|
+
const { images: _movedToAttachments, ...rest } = block;
|
|
336
|
+
return rest;
|
|
337
|
+
});
|
|
325
338
|
const text = blocksToText(blocks);
|
|
326
|
-
const images =
|
|
339
|
+
const images = [
|
|
340
|
+
...blocks.filter((b) => b.type === 'image'),
|
|
341
|
+
...toolImages,
|
|
342
|
+
].map(({ mediaType, data, name }) => ({ mediaType, data, name }));
|
|
343
|
+
const unavailableImages = (Array.isArray(msg.content) ? msg.content : [])
|
|
344
|
+
.filter((block) => block && block.type === 'image' && block.dataUnavailable === true)
|
|
345
|
+
.map((block) => {
|
|
346
|
+
const attachment = block.attachment && typeof block.attachment === 'object' ? block.attachment : {};
|
|
347
|
+
return {
|
|
348
|
+
attachmentId: typeof attachment.attachmentId === 'string' ? attachment.attachmentId : '',
|
|
349
|
+
...(typeof block.mediaType === 'string' ? { mediaType: block.mediaType } : {}),
|
|
350
|
+
...(typeof block.name === 'string' ? { name: block.name } : {}),
|
|
351
|
+
};
|
|
352
|
+
})
|
|
353
|
+
.filter((image) => image.attachmentId !== '');
|
|
327
354
|
// 只要存在可识别或保留的 block 就入列表;纯推理/工具调用也必须保留,
|
|
328
|
-
//
|
|
329
|
-
const hasContent = blocks.length > 0;
|
|
355
|
+
// 否则流式标签在重同步时会消失。
|
|
356
|
+
const hasContent = blocks.length > 0 || unavailableImages.length > 0;
|
|
330
357
|
if (!hasContent) return;
|
|
331
358
|
const clientMessageId = msg.role === 'user' ? clientMessageIdFromSource(msg.source, msg) : undefined;
|
|
332
359
|
const item = {
|
|
@@ -335,6 +362,7 @@ function deriveMessages(events) {
|
|
|
335
362
|
text,
|
|
336
363
|
blocks,
|
|
337
364
|
images: images.length > 0 ? images : undefined,
|
|
365
|
+
...(unavailableImages.length > 0 ? { unavailableImages } : {}),
|
|
338
366
|
...(clientMessageId ? { clientMessageId } : {}),
|
|
339
367
|
seq: e.seq,
|
|
340
368
|
time: e.time,
|
|
@@ -62,8 +62,9 @@ const ENDPOINTS = {
|
|
|
62
62
|
'sessions.updateQueue': inv('session', 'updateQueue'),
|
|
63
63
|
// 宿主的 fork 请求体只有 sessionId / atSeq,increaseTitle 在 0.2.0 已删除。
|
|
64
64
|
'sessions.fork': inv('session', 'fork', (p) => ({ request: pick(p, ['sessionId', 'atSeq']) })),
|
|
65
|
-
// 宿主的模型目录是全局的(不再按 session
|
|
66
|
-
|
|
65
|
+
// 宿主的模型目录是全局的(不再按 session 查),这里翻译成插件/移动端要的形状;
|
|
66
|
+
// payload 带 sessionId,供 modelCatalog 叠加会话自己的 modelSelection 投影。
|
|
67
|
+
'sessions.models': (ctx, payload, signal) => modelCatalog(ctx, payload, signal),
|
|
67
68
|
|
|
68
69
|
// agentPresets(namespace 是复数 agentPresets)
|
|
69
70
|
'agentPresets.list': inv('agentPresets', 'list', () => ({})),
|
|
@@ -47,23 +47,49 @@ async function historyViaPage(ctx, payload, signal) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* `session.models` → `session.modelCatalog`。
|
|
50
|
+
* `session.models` → `session.modelCatalog` + 会话自己的 `session/projections`。
|
|
51
51
|
*
|
|
52
52
|
* 宿主改成全局目录了(args 为空),字段也换了名:default → current、
|
|
53
53
|
* routableProviders 变成「能不能用这个 provider」。移动端要的 groups 结构两边一致,
|
|
54
54
|
* 原样带过去即可。
|
|
55
|
+
*
|
|
56
|
+
* 但 `default` 是**全局**默认,不是「这个会话实际用的模型」。会话自己的模型记在
|
|
57
|
+
* `session/projections` 的 `modelSelection` 投影里(`next` 是「下一次请求要用」,
|
|
58
|
+
* 落到 `lastUsed` 是「最近一次真正跑过的」);只有会话从没选过模型时,投影里才没有
|
|
59
|
+
* 这一项,此时才回落到全局默认。桌面端「切到某个对话显示那个对话的模型」读的正是
|
|
60
|
+
* 这份会话级投影,不是全局默认。移动端这里也要对同一口径,否则 A、B 两个会话各选
|
|
61
|
+
* 了不同模型,手机却都显示同一个「默认模型」。
|
|
62
|
+
*
|
|
63
|
+
* 读不到投影(会话不存在 / 宿主没挂投影能力)时不升级成错误:退回全局默认即可,
|
|
64
|
+
* 让模型目录照常下发,宁可少一次会话级精修也不让整个模型面板消失。
|
|
55
65
|
*/
|
|
56
|
-
async function modelCatalog(ctx, signal) {
|
|
57
|
-
const
|
|
66
|
+
async function modelCatalog(ctx, payload, signal) {
|
|
67
|
+
const sessionId = payload && typeof payload.sessionId === 'string' ? payload.sessionId : '';
|
|
68
|
+
const [result, projection] = await Promise.all([
|
|
69
|
+
invoke(ctx, 'session', 'modelCatalog', {}, signal),
|
|
70
|
+
sessionId ? invoke(ctx, 'session', 'projections', { request: { sessionId } }, signal) : Promise.resolve({ error: undefined, value: undefined }),
|
|
71
|
+
]);
|
|
58
72
|
if (result.error) return fail(result.error);
|
|
59
73
|
const catalog = result.value && typeof result.value === 'object' ? result.value : {};
|
|
60
74
|
const current = catalog.default && typeof catalog.default === 'object'
|
|
61
75
|
? pick(catalog.default, ['provider', 'model', 'reasoningEffort'])
|
|
62
76
|
: null;
|
|
77
|
+
// 会话自己选过模型时,用它覆盖全局默认;读不到或没选过才保留全局默认。
|
|
78
|
+
const sessionModel = !projection.error && projection.value && typeof projection.value === 'object'
|
|
79
|
+
? projection.value.values && typeof projection.value.values === 'object' && projection.value.values.modelSelection
|
|
80
|
+
: undefined;
|
|
81
|
+
const sessionSelection = sessionModel && typeof sessionModel === 'object'
|
|
82
|
+
? (sessionModel.next && typeof sessionModel.next === 'object'
|
|
83
|
+
? pick(sessionModel.next, ['provider', 'model', 'reasoningEffort'])
|
|
84
|
+
: sessionModel.lastUsed && typeof sessionModel.lastUsed === 'object'
|
|
85
|
+
? pick(sessionModel.lastUsed, ['provider', 'model', 'reasoningEffort'])
|
|
86
|
+
: null)
|
|
87
|
+
: null;
|
|
88
|
+
const effective = sessionSelection && sessionSelection.provider && sessionSelection.model ? sessionSelection : current;
|
|
63
89
|
const routable = Array.isArray(catalog.routableProviders) ? catalog.routableProviders : [];
|
|
64
90
|
return ok({
|
|
65
|
-
...(
|
|
66
|
-
routable: Boolean(
|
|
91
|
+
...(effective ? { current: effective } : {}),
|
|
92
|
+
routable: Boolean(effective && routable.includes(effective.provider)),
|
|
67
93
|
groups: Array.isArray(catalog.groups) ? catalog.groups : [],
|
|
68
94
|
});
|
|
69
95
|
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 内存自检台账:内存出问题之后,用来回答「当时到底堆在哪儿」。
|
|
5
|
+
*
|
|
6
|
+
* 起因:宿主进程内存涨到 3.5~5G,排查时手上只有 Activity Monitor 的一个总数,
|
|
7
|
+
* 分不出「插件推的镜像在堆」「遗弃的流订阅在堆」还是「宿主自己」。所以这里把三件事
|
|
8
|
+
* 一次性报出来:两个**进程**的内存、各路**活着的流数**、以及插件自己那几个
|
|
9
|
+
* 只增不减的容器有多大。
|
|
10
|
+
*
|
|
11
|
+
* ## 最重要的一条:口径
|
|
12
|
+
*
|
|
13
|
+
* **插件住在宿主进程里,没有独立内存可测。** `hostProcess` 报的是**整个宿主进程**
|
|
14
|
+
* (DSH host)的 `process.memoryUsage()`——插件、宿主自身、其它插件、V8 的堆外缓冲
|
|
15
|
+
* 全在这个数里。它不是「插件占了多少」。要判断「插件占多少」只能拿它和流数、镜像
|
|
16
|
+
* 缓存估算字节一起看趋势(插件侧唯一能量化的是 `mirror.cachedBytes`,那还是估算)。
|
|
17
|
+
* 字段名与 `caveat` 都按这个口径写死,别把它改名成 pluginMemory 之类。
|
|
18
|
+
*
|
|
19
|
+
* 侧车(sidecar facade worker)是**另一个进程**(lib/sidecar-facade.cjs 的 fork),
|
|
20
|
+
* 它的内存在 `sidecarProcess` 下,与宿主分列——两个数不能相加成「插件内存」。
|
|
21
|
+
*
|
|
22
|
+
* ## 数据从哪儿来(全走既有通道,没有新开一条)
|
|
23
|
+
*
|
|
24
|
+
* - 宿主进程内存:本进程 `process.memoryUsage()` / `v8.getHeapStatistics()`。
|
|
25
|
+
* - 侧车进程内存、流数:**worker 的 `/status` 快照**(`sidecarLifecycle.getSnapshot()`,
|
|
26
|
+
* 与 `tunnel`/`lan` 状态同一个来源)。worker 没上报对应字段时如实报 `null` 并记进
|
|
27
|
+
* `unavailable`——**不拿 0 顶替**:0 是「一条流都没有」,与「不知道」是两件事。
|
|
28
|
+
* - 局域网直连路的流数:由注入的 `getLanProxyStats()` 提供,约定形状
|
|
29
|
+
* `() => ({ upstreamOpen, sseOpen }) | null`(`lan-proxy.js` 的 `stats()`)。
|
|
30
|
+
* - 镜像:`mirror.getMirrorPush()` / `getPushFailures()`(不在本模块重复记账),
|
|
31
|
+
* 缓存本体走 `session-cache.js` 的 `mirrorHistoryStateOf(ctx)`。
|
|
32
|
+
* - 凭据库:`credentials.listActive().length`(凭据包只暴露「活跃」这一档)。
|
|
33
|
+
*
|
|
34
|
+
* ## 为什么是工厂而不是模块级单例
|
|
35
|
+
*
|
|
36
|
+
* `desktop-half-stats.cjs` 是单例:它只记进程级的计数。这份不一样——镜像缓存按
|
|
37
|
+
* `ctx` 隔离(WeakMap),拿不到 ctx 就读不到。所以做成 `createSelfCheck(deps)`,
|
|
38
|
+
* 由 control-plane 用注入的 ctx 建一次,而不是往模块级塞一个 ctx。
|
|
39
|
+
*
|
|
40
|
+
* ## 字节估算的口径
|
|
41
|
+
*
|
|
42
|
+
* `mirror.cachedBytes` 是**估算**,不是精确占用:字符串按 `String.length`(会话正文
|
|
43
|
+
* 以 ASCII 为主,字符数≈字节)、数字 8 字节、布尔 4、每个对象/数组 24 字节固定开销。
|
|
44
|
+
* 走查有节点预算(`MAX_WALK_NODES`),用尽时 `cachedBytesComplete` 为 false,
|
|
45
|
+
* 此时那个数是**下限**而不是总量。
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
const v8 = require('node:v8');
|
|
49
|
+
|
|
50
|
+
const { mirrorHistoryStateOf } = require('./session-cache.js');
|
|
51
|
+
|
|
52
|
+
/** 字节估算的节点预算:status 每 3s 被面板拉一次,走查必须是有界的。 */
|
|
53
|
+
const MAX_WALK_NODES = 20000;
|
|
54
|
+
/** 每个对象/数组的固定开销估计(V8 里一个空对象约这个量级)。 */
|
|
55
|
+
const OBJECT_OVERHEAD_BYTES = 24;
|
|
56
|
+
|
|
57
|
+
/** 只认有限数:NaN/Infinity/字符串一律记 null,「不知道」不能写成 0。 */
|
|
58
|
+
function finiteNumber(value) {
|
|
59
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `process.memoryUsage()` 的白名单副本 + V8 堆上限(超了这个数就是 heap OOM)。 */
|
|
63
|
+
function memoryOf(usage, heapStatistics) {
|
|
64
|
+
if (!usage || typeof usage !== 'object') return null;
|
|
65
|
+
return {
|
|
66
|
+
rss: finiteNumber(usage.rss),
|
|
67
|
+
heapTotal: finiteNumber(usage.heapTotal),
|
|
68
|
+
heapUsed: finiteNumber(usage.heapUsed),
|
|
69
|
+
external: finiteNumber(usage.external),
|
|
70
|
+
arrayBuffers: finiteNumber(usage.arrayBuffers),
|
|
71
|
+
heapSizeLimit: heapStatistics && typeof heapStatistics === 'object'
|
|
72
|
+
? finiteNumber(heapStatistics.heap_size_limit)
|
|
73
|
+
: null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 估算一棵对象树占的字节(口径见文件头)。节点预算用尽时 `complete:false`,`bytes` 是下限。
|
|
79
|
+
*/
|
|
80
|
+
function estimateBytes(root, budget = MAX_WALK_NODES) {
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
const stack = [root];
|
|
83
|
+
let bytes = 0;
|
|
84
|
+
let nodes = 0;
|
|
85
|
+
while (stack.length) {
|
|
86
|
+
if (nodes >= budget) return { bytes, complete: false };
|
|
87
|
+
const node = stack.pop();
|
|
88
|
+
nodes += 1;
|
|
89
|
+
if (typeof node === 'string') { bytes += node.length; continue; }
|
|
90
|
+
if (typeof node === 'number') { bytes += 8; continue; }
|
|
91
|
+
if (typeof node === 'boolean') { bytes += 4; continue; }
|
|
92
|
+
if (!node || typeof node !== 'object') continue;
|
|
93
|
+
if (seen.has(node)) continue;
|
|
94
|
+
seen.add(node);
|
|
95
|
+
bytes += OBJECT_OVERHEAD_BYTES;
|
|
96
|
+
if (Array.isArray(node)) {
|
|
97
|
+
for (const item of node) stack.push(item);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
for (const key of Object.keys(node)) {
|
|
101
|
+
bytes += key.length;
|
|
102
|
+
stack.push(node[key]);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { bytes, complete: true };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 镜像缓存本体:条目数 / 消息条数 / 估算字节。
|
|
110
|
+
*
|
|
111
|
+
* ⚠️ `mirrorHistoryStateOf(ctx)` 在 ctx 上还没有这张表时会**建一张空的**——这与第一次
|
|
112
|
+
* 镜像推送本来就会做的事完全一样,没有额外状态。ctx 为空(本文件被单独 require 时)
|
|
113
|
+
* 返回 null,不抛。
|
|
114
|
+
*/
|
|
115
|
+
function mirrorCacheOf(ctx) {
|
|
116
|
+
if (!ctx) return null;
|
|
117
|
+
const cache = mirrorHistoryStateOf(ctx);
|
|
118
|
+
if (!cache || typeof cache.size !== 'number') return null;
|
|
119
|
+
let messages = 0;
|
|
120
|
+
const bodies = [];
|
|
121
|
+
for (const entry of cache.values()) {
|
|
122
|
+
const items = entry && entry.body && Array.isArray(entry.body.items) ? entry.body.items : null;
|
|
123
|
+
if (items) messages += items.length;
|
|
124
|
+
bodies.push(entry ? entry.body : null);
|
|
125
|
+
}
|
|
126
|
+
const estimate = estimateBytes(bodies);
|
|
127
|
+
return { entries: cache.size, messages, bytes: estimate.bytes, complete: estimate.complete };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 建一份自检台账。所有依赖都是取值器或只读模块,本模块不持有 ctx 之外的可变状态。
|
|
132
|
+
*
|
|
133
|
+
* @param {object} [deps]
|
|
134
|
+
* @param {object} [deps.ctx] 插件 ctx(读镜像缓存需要;缺省时镜像缓存报 null)
|
|
135
|
+
* @param {() => object|null} [deps.getSidecarSnapshot] worker 的 `/status` 快照取值器
|
|
136
|
+
* @param {() => object|null} [deps.getMirrorPush] 镜像推送记账(pusher 提供)
|
|
137
|
+
* @param {() => Array} [deps.getMirrorPushFailures] 待补推会话账(pusher 提供)
|
|
138
|
+
* @param {() => ({upstreamOpen: number, sseOpen: number})|null} [deps.getLanProxyStats]
|
|
139
|
+
* 局域网直连路的活连接数(`lan-proxy.js` 的 `stats()`)
|
|
140
|
+
* @param {object|null} [deps.credentials] 本机设备凭据库
|
|
141
|
+
*/
|
|
142
|
+
function createSelfCheck(deps = {}) {
|
|
143
|
+
const {
|
|
144
|
+
ctx = null,
|
|
145
|
+
getSidecarSnapshot = () => null,
|
|
146
|
+
getMirrorPush = () => null,
|
|
147
|
+
getMirrorPushFailures = () => null,
|
|
148
|
+
getLanProxyStats = () => null,
|
|
149
|
+
credentials = null,
|
|
150
|
+
} = deps;
|
|
151
|
+
const since = Date.now();
|
|
152
|
+
|
|
153
|
+
function hostProcess() {
|
|
154
|
+
const usage = process.memoryUsage();
|
|
155
|
+
return {
|
|
156
|
+
// ⚠️ 口径:整个宿主进程,不是插件自己(见文件头)。
|
|
157
|
+
scope: 'host-process',
|
|
158
|
+
caveat: '插件住在宿主里,没有独立内存;这里是整个 DSH 宿主进程,含宿主自身与其它插件',
|
|
159
|
+
...memoryOf(usage, v8.getHeapStatistics()),
|
|
160
|
+
startedAt: since,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function sidecarProcess(snapshot) {
|
|
165
|
+
const reported = snapshot && snapshot.process && snapshot.process.memoryUsage;
|
|
166
|
+
const memory = memoryOf(reported);
|
|
167
|
+
return { memory };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function streams(snapshot) {
|
|
171
|
+
const lan = getLanProxyStats();
|
|
172
|
+
const relaySseOpen = snapshot && snapshot.streams ? finiteNumber(snapshot.streams.relaySseOpen) : null;
|
|
173
|
+
return {
|
|
174
|
+
// 中转路:worker 里 `relay/tunnel.js` 的 sseClients.size。
|
|
175
|
+
relaySseOpen,
|
|
176
|
+
// 局域网直连路:`lan-proxy.js` 的 stats()。
|
|
177
|
+
lanUpstreamOpen: lan ? finiteNumber(lan.upstreamOpen) : null,
|
|
178
|
+
lanSseOpen: lan ? finiteNumber(lan.sseOpen) : null,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function mirror() {
|
|
183
|
+
// 拿不到记账时整块报 null 并记进 unavailable:`skippedBodies` 报 0 会被读成
|
|
184
|
+
// 「一次都没跳过」,而那是「不知道」——两者在排查时结论正好相反。
|
|
185
|
+
const push = getMirrorPush();
|
|
186
|
+
const known = Boolean(push) && typeof push === 'object';
|
|
187
|
+
const failures = getMirrorPushFailures();
|
|
188
|
+
const cache = mirrorCacheOf(ctx);
|
|
189
|
+
return {
|
|
190
|
+
pushedAt: known ? (finiteNumber(push.pushedAt) === null ? 0 : finiteNumber(push.pushedAt)) : null,
|
|
191
|
+
ok: known && typeof push.ok === 'boolean' ? push.ok : null,
|
|
192
|
+
error: known && typeof push.error === 'string' ? push.error : null,
|
|
193
|
+
skippedBodies: known ? (finiteNumber(push.skippedBodies) === null ? 0 : finiteNumber(push.skippedBodies)) : null,
|
|
194
|
+
skipReason: known && typeof push.skipReason === 'string' ? push.skipReason : null,
|
|
195
|
+
// 待补推的会话数(pusher 的失败重试账长度)。
|
|
196
|
+
pushFailures: Array.isArray(failures) ? failures.length : null,
|
|
197
|
+
cachedSessions: cache ? cache.entries : null,
|
|
198
|
+
cachedMessages: cache ? cache.messages : null,
|
|
199
|
+
cachedBytes: cache ? cache.bytes : null,
|
|
200
|
+
cachedBytesComplete: cache ? cache.complete : null,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function credentialsBlock() {
|
|
205
|
+
const list = credentials && typeof credentials.listActive === 'function'
|
|
206
|
+
? credentials.listActive()
|
|
207
|
+
: null;
|
|
208
|
+
return { active: Array.isArray(list) ? list.length : null };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 当前读数副本:每次调用都是新对象,status 视图读它、改它都不影响下一份。 */
|
|
212
|
+
function snapshot() {
|
|
213
|
+
const sidecarSnapshot = getSidecarSnapshot();
|
|
214
|
+
const sidecar = sidecarProcess(sidecarSnapshot);
|
|
215
|
+
const flow = streams(sidecarSnapshot);
|
|
216
|
+
const mir = mirror();
|
|
217
|
+
const creds = credentialsBlock();
|
|
218
|
+
const unavailable = [];
|
|
219
|
+
if (!sidecar.memory) {
|
|
220
|
+
unavailable.push('sidecarProcess.memory: worker 的 /status 未上报 process.memoryUsage');
|
|
221
|
+
}
|
|
222
|
+
if (flow.relaySseOpen === null) {
|
|
223
|
+
unavailable.push('streams.relaySseOpen: worker 的 /status 未上报 streams.relaySseOpen');
|
|
224
|
+
}
|
|
225
|
+
if (flow.lanUpstreamOpen === null) {
|
|
226
|
+
unavailable.push('streams.lanUpstreamOpen: 拿不到局域网 stats()(见 self-check.cjs 文件头)');
|
|
227
|
+
}
|
|
228
|
+
if (mir.cachedSessions === null) {
|
|
229
|
+
unavailable.push('mirror.cachedSessions: 没有 ctx,读不到镜像缓存');
|
|
230
|
+
}
|
|
231
|
+
if (mir.pushedAt === null) {
|
|
232
|
+
unavailable.push('mirror.pushedAt: 拿不到镜像推送记账(getMirrorPush)');
|
|
233
|
+
}
|
|
234
|
+
if (mir.pushFailures === null) {
|
|
235
|
+
unavailable.push('mirror.pushFailures: 拿不到镜像待补推账(getMirrorPushFailures)');
|
|
236
|
+
}
|
|
237
|
+
if (creds.active === null) {
|
|
238
|
+
unavailable.push('credentials.active: 拿不到凭据库');
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
since,
|
|
242
|
+
hostProcess: hostProcess(),
|
|
243
|
+
sidecarProcess: sidecar,
|
|
244
|
+
streams: flow,
|
|
245
|
+
mirror: mir,
|
|
246
|
+
credentials: creds,
|
|
247
|
+
unavailable,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { snapshot };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
module.exports = { createSelfCheck, estimateBytes, memoryOf, MAX_WALK_NODES };
|
package/lib/mobile/stream.js
CHANGED
|
@@ -17,38 +17,21 @@
|
|
|
17
17
|
* 要 unref —— 它只是保活,不该把进程退出按在 15s 一拍上。
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
const crypto = require('crypto');
|
|
21
|
-
|
|
22
20
|
const { sendJson } = require('../shared/http-util');
|
|
21
|
+
const {
|
|
22
|
+
writeSse: sharedWriteSse,
|
|
23
|
+
waitForDrain,
|
|
24
|
+
ssePadding,
|
|
25
|
+
} = require('../shared/sse-write.js');
|
|
23
26
|
const { resolveEventAttachments } = require('./sessions.js');
|
|
24
27
|
const { callSessions } = require('./rpc.js');
|
|
25
28
|
const { unwrap } = require('./envelope.js');
|
|
26
29
|
const { normalizeJobs } = require('./jobs.js');
|
|
27
30
|
|
|
28
|
-
const SSE_RETRY_MS = 3000;
|
|
29
31
|
// Android React Native's XHR may buffer tiny chunked responses until its
|
|
30
32
|
// internal progress threshold. Pad each SSE record so onprogress exposes it
|
|
31
33
|
// promptly; the padding is an SSE comment and is ignored by clients.
|
|
32
34
|
const SSE_FLUSH_PADDING_BYTES = 2048;
|
|
33
|
-
/**
|
|
34
|
-
* 写缓冲的两道水位(字节)。`res.write()` 在客户端读得慢时返回 false,字节留在 Node
|
|
35
|
-
* 的写队列里;以前没人看这个返回值,于是「手机停在一个生成中的会话上、网络又慢」时
|
|
36
|
-
* 帧无限堆积 —— 2026-10-17 真机现场:堆快照里 17.6 万条 SSE 帧(466MB)挂在
|
|
37
|
-
* `WriteWrap._chunks` 上,宿主 70 分钟涨到 3.9G 被 OOM 杀掉。
|
|
38
|
-
*
|
|
39
|
-
* 两道闸分开,因为两种帧的「丢了能不能补」不一样:
|
|
40
|
-
* - 软水位:只丢**没有 seq 的增量帧**(`assistant/chunk`)。它本来就是可丢弃的草稿
|
|
41
|
-
* 增量,同一轮收尾的 `assistant/message` 带完整正文且有 seq,重连回放能补齐;
|
|
42
|
-
* - 硬水位:连有 seq 的帧也放不下了,就返回 false 让上层收尾。客户端带 `lastSeq`
|
|
43
|
-
* 重连后走 `replaySince` 把缺口补齐 —— 断流可恢复,堆内存不会。
|
|
44
|
-
*/
|
|
45
|
-
const SSE_SHED_BUFFERED_BYTES = 4 * 1024 * 1024;
|
|
46
|
-
const SSE_CLOSE_BUFFERED_BYTES = 16 * 1024 * 1024;
|
|
47
|
-
/**
|
|
48
|
-
* 等一次 `drain` 的上限。超过这么久还没排空,说明对面不是慢、是真的不读了(手机切后台、
|
|
49
|
-
* 中转卡住),继续等只是把这条连接挂在那里,不如收尾让客户端重连。
|
|
50
|
-
*/
|
|
51
|
-
const SSE_DRAIN_TIMEOUT_MS = 20_000;
|
|
52
35
|
const REPLAY_WINDOW = 3000; // 断线补偿回放的最大事件数
|
|
53
36
|
|
|
54
37
|
/**
|
|
@@ -75,90 +58,23 @@ function logStream(sessionId, message, error) {
|
|
|
75
58
|
} catch { /* ignore */ }
|
|
76
59
|
}
|
|
77
60
|
|
|
78
|
-
function stableEventId(payload, frame) {
|
|
79
|
-
const source = payload && payload.type === 'session/event' ? payload : frame?.payload;
|
|
80
|
-
if (source && typeof source === 'object') {
|
|
81
|
-
if (source.type === 'session/event' && source.sessionId && source.event && Number.isFinite(source.event.seq)) {
|
|
82
|
-
return `${source.sessionId}:${source.event.seq}`;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
if (payload && typeof payload === 'object') {
|
|
86
|
-
for (const key of ['eventId', 'rpcId', 'id', 'approvalId']) {
|
|
87
|
-
if (typeof payload[key] === 'string' && payload[key]) return payload[key];
|
|
88
|
-
}
|
|
89
|
-
const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : 'session';
|
|
90
|
-
const digest = crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 24);
|
|
91
|
-
return `${sessionId}:${payload.type || 'event'}:${digest}`;
|
|
92
|
-
}
|
|
93
|
-
return frame && typeof frame.rpcId === 'string' && frame.rpcId ? frame.rpcId : undefined;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
61
|
/**
|
|
97
|
-
*
|
|
62
|
+
* 写一条 SSE 记录(策略声明,实现见 `lib/shared/sse-write.js`)。
|
|
98
63
|
*
|
|
99
|
-
* `
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* 仍从 410MB 涨到 1.4GB。所以要顺着 socket 一起看,取两者较大值。
|
|
103
|
-
*/
|
|
104
|
-
function bufferedBytesOf(res) {
|
|
105
|
-
const own = Number(res.writableLength) || 0;
|
|
106
|
-
const socket = res.socket || res.connection;
|
|
107
|
-
const queued = socket ? Number(socket.writableLength) || 0 : 0;
|
|
108
|
-
return Math.max(own, queued);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* 等底层把缓冲吐出去(`drain`)。背压的正解:写不动就停手,而不是继续塞。
|
|
113
|
-
*
|
|
114
|
-
* @returns {Promise<boolean>} 真的 drain 了 → true;超时或流被中断 → false(该收尾)。
|
|
115
|
-
*/
|
|
116
|
-
function waitForDrain(res, signal, timeoutMs = SSE_DRAIN_TIMEOUT_MS) {
|
|
117
|
-
return new Promise((resolve) => {
|
|
118
|
-
if (signal && signal.aborted) return resolve(false);
|
|
119
|
-
let done = false;
|
|
120
|
-
const cleanup = () => {
|
|
121
|
-
clearTimeout(timer);
|
|
122
|
-
if (typeof res.off === 'function') res.off('drain', onDrain);
|
|
123
|
-
if (signal && typeof signal.removeEventListener === 'function') signal.removeEventListener('abort', onAbort);
|
|
124
|
-
};
|
|
125
|
-
const finish = (value) => { if (done) return; done = true; cleanup(); resolve(value); };
|
|
126
|
-
const onDrain = () => finish(true);
|
|
127
|
-
const onAbort = () => finish(false);
|
|
128
|
-
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
129
|
-
if (typeof timer.unref === 'function') timer.unref();
|
|
130
|
-
if (typeof res.once === 'function') res.once('drain', onDrain);
|
|
131
|
-
if (signal && typeof signal.addEventListener === 'function') signal.addEventListener('abort', onAbort, { once: true });
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* 写一条 SSE 记录。
|
|
64
|
+
* 这条流的策略:软水位只丢**没有 seq** 的增量(`assistant/chunk` 是可丢弃草稿,同一轮
|
|
65
|
+
* 收尾的 `assistant/message` 带完整正文且有 seq,重连回放能补齐);每条记录补 2048 字节
|
|
66
|
+
* 注释,让 RN 的 XHR 尽快吐出来。
|
|
137
67
|
*
|
|
138
68
|
* @returns {{ok: boolean, shed?: boolean, needDrain?: boolean}}
|
|
139
69
|
* `ok:false` → 这条流该收尾(客户端走了,或积压超过硬水位);
|
|
140
|
-
* `shed:true` →
|
|
70
|
+
* `shed:true` → 这条**增量**帧被丢掉、连接还活着;
|
|
141
71
|
* `needDrain:true` → 写成功了,但底下已经写不动,调用方要先 await 一次 drain 再继续。
|
|
142
|
-
*
|
|
143
|
-
* 丢增量只丢没有 seq 的帧:有 seq 的帧是客户端水位的一部分,丢了不回放就在手机上
|
|
144
|
-
* 留一个永久的洞,那比断流更糟。
|
|
145
72
|
*/
|
|
146
73
|
function writeSse(res, payload, frame) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (buffered > SSE_SHED_BUFFERED_BYTES && !hasSeq) return { ok: true, shed: true };
|
|
152
|
-
const fields = [`retry: ${SSE_RETRY_MS}`];
|
|
153
|
-
if (id) fields.push(`id: ${id}`);
|
|
154
|
-
fields.push(`data: ${JSON.stringify(payload)}`);
|
|
155
|
-
fields.push(`: ${' '.repeat(SSE_FLUSH_PADDING_BYTES)}`, '', '');
|
|
156
|
-
try {
|
|
157
|
-
// `write()` 返回 false 才是背压的正信号:数据已经堆到高水位,继续写只会把内存
|
|
158
|
-
// 堆起来。以前把它当成功吞掉,正是这次 4GB OOM 的直接原因。
|
|
159
|
-
const accepted = res.write(`${fields.join('\n')}\n`);
|
|
160
|
-
return { ok: true, needDrain: accepted === false };
|
|
161
|
-
} catch { return { ok: false }; }
|
|
74
|
+
return sharedWriteSse(res, payload, {
|
|
75
|
+
frame,
|
|
76
|
+
padding: ssePadding(SSE_FLUSH_PADDING_BYTES),
|
|
77
|
+
});
|
|
162
78
|
}
|
|
163
79
|
|
|
164
80
|
/**
|