dsh-mobile-plugin 0.1.84 → 0.1.86
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/mobile/events.js +49 -5
- package/lib/mobile/history-derive.js +21 -3
- package/lib/mobile/local-images.js +87 -4
- package/lib/mobile/plugins.js +22 -28
- package/lib/mobile/references.js +17 -30
- package/lib/mobile/session-send.js +28 -38
- package/lib/mobile/stream.js +108 -8
- package/lib/session-cleanup.cjs +13 -3
- package/package.json +1 -1
package/lib/mobile/events.js
CHANGED
|
@@ -24,6 +24,16 @@ const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal
|
|
|
24
24
|
|
|
25
25
|
const SSE_RETRY_MS = 3000;
|
|
26
26
|
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;
|
|
27
37
|
/** 心跳帧间隔。App 用它判定「这条流真的还活着」,必须与 app 侧的 SIGNAL_HEARTBEAT_MS 一致。 */
|
|
28
38
|
const HEARTBEAT_MS = 15_000;
|
|
29
39
|
|
|
@@ -56,12 +66,40 @@ function stableEventId(payload, frame) {
|
|
|
56
66
|
return frame && typeof frame.rpcId === 'string' && frame.rpcId ? frame.rpcId : undefined;
|
|
57
67
|
}
|
|
58
68
|
|
|
69
|
+
/**
|
|
70
|
+
* 这条连接上真正「还没发出去」的字节数。
|
|
71
|
+
*
|
|
72
|
+
* 只看 `res.writableLength` 是错的:它只算响应对象自己那一层,真正的积压在底下的套接字
|
|
73
|
+
* 写队列里(堆快照里那些 `{chunk, encoding, callback}`)。2026-10-18 复测证实:只看
|
|
74
|
+
* 前者时闸门从不触发,内存照涨。所以顺着 socket 一起看,取较大值。
|
|
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` 写进去了但底下已经写不动。
|
|
86
|
+
*
|
|
87
|
+
* 设备级流的帧全是「有东西变了」的信号,本来就幂等:丢掉一帧的代价是那一次刷新要等
|
|
88
|
+
* 下一次变更或 60s 兜底轮询;而堆在写缓冲里的代价是宿主 OOM(2026-10-17 现场:466MB
|
|
89
|
+
* 的 SSE 帧挂在 socket 写队列上,宿主撞 4GB 上限被杀)。所以软水位一律 shed。
|
|
90
|
+
*/
|
|
59
91
|
function writeSse(res, payload, frame) {
|
|
92
|
+
const buffered = bufferedBytesOf(res);
|
|
93
|
+
if (buffered > SSE_CLOSE_BUFFERED_BYTES) return { ok: false };
|
|
94
|
+
if (buffered > SSE_SHED_BUFFERED_BYTES) return { ok: true, shed: true };
|
|
60
95
|
const id = stableEventId(payload, frame);
|
|
61
96
|
const fields = [`retry: ${SSE_RETRY_MS}`];
|
|
62
97
|
if (id) fields.push(`id: ${id}`);
|
|
63
98
|
fields.push(`data: ${JSON.stringify(payload)}`, '', '');
|
|
64
|
-
try {
|
|
99
|
+
try {
|
|
100
|
+
const accepted = res.write(`${fields.join('\n')}\n`);
|
|
101
|
+
return { ok: true, needDrain: accepted === false };
|
|
102
|
+
} catch { return { ok: false }; }
|
|
65
103
|
}
|
|
66
104
|
|
|
67
105
|
async function* mergeHostAndMux(ctx, signal) {
|
|
@@ -153,13 +191,17 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
153
191
|
/** 这条连接上真正写出去的帧数,只用于收尾日志(诊断「连着却一帧不发」)。 */
|
|
154
192
|
let framesSent = 0;
|
|
155
193
|
const writeFrame = (devicePayload) => {
|
|
156
|
-
const
|
|
194
|
+
const result = writeSse(res, {
|
|
157
195
|
type: 'device/event',
|
|
158
196
|
version: 1,
|
|
159
197
|
payload: devicePayload,
|
|
160
198
|
}, null);
|
|
161
|
-
if (
|
|
162
|
-
return
|
|
199
|
+
if (!result.ok) return false;
|
|
200
|
+
if (result.shed) return true;
|
|
201
|
+
framesSent += 1;
|
|
202
|
+
// 所有调用点都是同步回调(信号总线、心跳),没法在这里 await;写不动就如实回报,
|
|
203
|
+
// 由调用方 abort —— 这条流本来就是「断了重连、靠补发收敛」的语义。
|
|
204
|
+
return !result.needDrain;
|
|
163
205
|
};
|
|
164
206
|
/**
|
|
165
207
|
* 自产的「清单变了」帧:镜像推送调度器每 500ms 比一次清单内容哈希,变了就在这里推给手机。
|
|
@@ -267,7 +309,9 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
267
309
|
payload,
|
|
268
310
|
};
|
|
269
311
|
// Keep the existing device/event JSON envelope unchanged; SSE metadata is additive.
|
|
270
|
-
|
|
312
|
+
const sent = writeSse(res, envelope, frame);
|
|
313
|
+
if (sent.shed) continue;
|
|
314
|
+
if (!sent.ok || sent.needDrain) { clientGone = true; break; }
|
|
271
315
|
framesSent += 1;
|
|
272
316
|
}
|
|
273
317
|
if (!clientGone) loopEnded = true;
|
|
@@ -79,6 +79,26 @@ function normalizeResultDiffs(meta) {
|
|
|
79
79
|
return out.length > 0 ? out : undefined;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* tool/result 的可见内容:两种形状都认。
|
|
84
|
+
*
|
|
85
|
+
* - 当前形状:`message.content` 直接就是内容块数组,成败在 `message.isError`;
|
|
86
|
+
* - 旧形状:`message.content[0]` 是 `{ isError, content: [内容块] }` 信封。
|
|
87
|
+
*
|
|
88
|
+
* 曾经只认旧形状,于是真实结果里的图片(`{type:'image', attachment}`)被当成信封剥掉、
|
|
89
|
+
* 整张图丢失,结果级 isError 也读不到(失败的工具显示成成功)。
|
|
90
|
+
*/
|
|
91
|
+
function toolResultParts(msg) {
|
|
92
|
+
const direct = Array.isArray(msg.content) ? msg.content : [];
|
|
93
|
+
const envelope = direct.length === 1 && direct[0] && typeof direct[0] === 'object'
|
|
94
|
+
&& typeof direct[0].type !== 'string' ? direct[0] : null;
|
|
95
|
+
if (!envelope) return { parts: direct, isError: msg.isError === true };
|
|
96
|
+
return {
|
|
97
|
+
parts: Array.isArray(envelope.content) ? envelope.content : [],
|
|
98
|
+
isError: envelope.isError === true || msg.isError === true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
82
102
|
/** 预扫描 tool/result 事件,构建 callId → {isError, code} 映射(对接桌面 toolRowModel 状态派生)。 */
|
|
83
103
|
function buildToolResultMap(events) {
|
|
84
104
|
const map = new Map();
|
|
@@ -88,10 +108,8 @@ function buildToolResultMap(events) {
|
|
|
88
108
|
const msg = e.data.message;
|
|
89
109
|
const src = msg.source;
|
|
90
110
|
if (!src || typeof src.callId !== 'string') continue;
|
|
91
|
-
const
|
|
92
|
-
const isError = content && content.isError === true;
|
|
111
|
+
const { parts, isError } = toolResultParts(msg);
|
|
93
112
|
const code = e.data.error && typeof e.data.error.code === 'string' ? e.data.error.code : undefined;
|
|
94
|
-
const parts = content && Array.isArray(content.content) ? content.content : content ? [content] : [];
|
|
95
113
|
const output = parts.map((part) => {
|
|
96
114
|
if (part && typeof part.text === 'string') return part.text;
|
|
97
115
|
if (part && typeof part === 'object' && part.type === 'image') return '';
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* - 三级体积预算(单图 / 单段文本 / 单次响应,见下方常量):超预算的图保持字面 markdown,
|
|
19
19
|
* 不截断、不缩水成半张图。
|
|
20
20
|
* - 读不到、不是普通文件、读取失败:原样保留 —— 一条图片取不到不能让整段历史或整帧 SSE 挂掉。
|
|
21
|
+
* - 围栏代码块与行内代码里的图片语法一律不碰:那是**写法示例**,不是要画的图;换掉会把
|
|
22
|
+
* 三行代码变成几十万字符的 base64,App 的代码块会铺成很长很长的一块。
|
|
21
23
|
*/
|
|
22
24
|
|
|
23
25
|
const fs = require('node:fs/promises');
|
|
@@ -126,17 +128,97 @@ async function dataUriFor(filePath) {
|
|
|
126
128
|
return uri;
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Markdown 里「必须保持字面」的区间:围栏代码块与行内代码。
|
|
133
|
+
*
|
|
134
|
+
* 代码块里的 `` 是**写法示例**而不是要画的图。旧实现整段正则替换,
|
|
135
|
+
* 会把三行代码换成几十万字符的 base64——App 把这段按代码文本铺开,就成了很长很长
|
|
136
|
+
* 的一块(真机现象:图片语法那块刷出几十屏,要划很久)。围栏判定与 App 的
|
|
137
|
+
* `utils/markdown/parser.ts` 同口径(两边都 trim 后看 3 个以上 ` / ~)。
|
|
138
|
+
*/
|
|
139
|
+
function literalRanges(text) {
|
|
140
|
+
const ranges = [];
|
|
141
|
+
const lines = text.split('\n');
|
|
142
|
+
const lineStarts = [];
|
|
143
|
+
let offset = 0;
|
|
144
|
+
for (const line of lines) {
|
|
145
|
+
lineStarts.push(offset);
|
|
146
|
+
offset += line.length + 1;
|
|
147
|
+
}
|
|
148
|
+
let fence = null;
|
|
149
|
+
let fenceStart = -1;
|
|
150
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
151
|
+
const line = lines[index];
|
|
152
|
+
if (fence) {
|
|
153
|
+
const close = /^(`{3,}|~{3,})[ \t]*$/.exec(line.trim());
|
|
154
|
+
if (close && close[1][0] === fence.char && close[1].length >= fence.length) {
|
|
155
|
+
ranges.push([fenceStart, lineStarts[index] + line.length]);
|
|
156
|
+
fence = null;
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const open = /^(`{3,}|~{3,})/.exec(line.trim());
|
|
161
|
+
if (!open) continue;
|
|
162
|
+
fence = { char: open[1][0], length: open[1].length };
|
|
163
|
+
fenceStart = lineStarts[index];
|
|
164
|
+
}
|
|
165
|
+
// 没闭合的围栏按 App 的解析口径一直吃到文末。
|
|
166
|
+
if (fence) ranges.push([fenceStart, text.length]);
|
|
167
|
+
|
|
168
|
+
// 行内代码:同长度的反引号成对(`code`)。围栏内部的反引号已在上面标过。
|
|
169
|
+
let cursor = 0;
|
|
170
|
+
while (cursor < text.length) {
|
|
171
|
+
if (text[cursor] !== '`' || inRanges(cursor, ranges)) {
|
|
172
|
+
cursor += 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
let ticks = 1;
|
|
176
|
+
while (text[cursor + ticks] === '`') ticks += 1;
|
|
177
|
+
let scan = cursor + ticks;
|
|
178
|
+
let closed = -1;
|
|
179
|
+
while (scan < text.length) {
|
|
180
|
+
if (text[scan] !== '`' || inRanges(scan, ranges)) {
|
|
181
|
+
scan += 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
let run = 1;
|
|
185
|
+
while (text[scan + run] === '`') run += 1;
|
|
186
|
+
if (run === ticks) {
|
|
187
|
+
closed = scan + run;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
scan += run;
|
|
191
|
+
}
|
|
192
|
+
if (closed < 0) {
|
|
193
|
+
cursor += ticks;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
ranges.push([cursor, closed]);
|
|
197
|
+
cursor = closed;
|
|
198
|
+
}
|
|
199
|
+
return ranges;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function inRanges(index, ranges) {
|
|
203
|
+
for (const [start, end] of ranges) {
|
|
204
|
+
if (index >= start && index < end) return true;
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
129
209
|
/**
|
|
130
210
|
* 把一段文本里的本机图片语法换成 data URI。返回原字符串本身(`===`)表示一个字没动,
|
|
131
211
|
* 调用方据此免掉不必要的对象拷贝。
|
|
132
212
|
*/
|
|
133
213
|
async function inlineTextImages(text, budget) {
|
|
134
214
|
if (typeof text !== 'string' || text.indexOf('![') < 0) return text;
|
|
135
|
-
const
|
|
215
|
+
const ranges = literalRanges(text);
|
|
216
|
+
const matches = [...text.matchAll(IMAGE_RE)].filter((m) => !inRanges(m.index, ranges));
|
|
136
217
|
if (!matches.length) return text;
|
|
137
218
|
const pool = budget || createTextImageBudget();
|
|
138
219
|
let textBudget = MAX_TEXT_CHARS;
|
|
139
|
-
let out =
|
|
220
|
+
let out = '';
|
|
221
|
+
let cursor = 0;
|
|
140
222
|
for (const m of matches) {
|
|
141
223
|
if (textBudget <= 0 || pool.remaining <= 0) break;
|
|
142
224
|
const filePath = localImagePathOf(m[2]);
|
|
@@ -145,9 +227,10 @@ async function inlineTextImages(text, budget) {
|
|
|
145
227
|
if (!uri || uri.length > textBudget || uri.length > pool.remaining) continue;
|
|
146
228
|
textBudget -= uri.length;
|
|
147
229
|
pool.remaining -= uri.length;
|
|
148
|
-
out
|
|
230
|
+
out += text.slice(cursor, m.index) + `![${m[1]}](${uri})`;
|
|
231
|
+
cursor = m.index + m[0].length;
|
|
149
232
|
}
|
|
150
|
-
return
|
|
233
|
+
return cursor === 0 ? text : out + text.slice(cursor);
|
|
151
234
|
}
|
|
152
235
|
|
|
153
236
|
module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf };
|
package/lib/mobile/plugins.js
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
*
|
|
11
11
|
* 关键:这个宿主服务 **不在 apiProxy 上** —— 它是 Typert Remote 服务
|
|
12
12
|
* (dsh-host-plugin-inventory 的 pluginInventory)。手机侧本来就能到它:
|
|
13
|
-
* lib/mobile/sessions.js
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* lib/mobile/sessions.js 早已派发 commands.execute,lib/mobile/references.js 通了
|
|
14
|
+
* fileReferences / sessionReferenceResolver,这里照搬同一条路 —— 都经
|
|
15
|
+
* host-bridge/transport.js 的 `invoke`,由它统一补 Peer 身份与网关错误收敛。
|
|
16
16
|
*
|
|
17
17
|
* 接口(只转发宿主的部署快照,不做任何加工):
|
|
18
18
|
* - GET /api/mobile/v1/plugins → pluginInventory.list
|
|
@@ -32,17 +32,13 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
const { sendJson } = require('../shared/http-util');
|
|
35
|
+
const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/transport.js');
|
|
35
36
|
|
|
36
37
|
const PLUGIN_ID_MAX_CHARS = 200; // entryId 长度上限
|
|
37
38
|
const PLUGIN_NAME_MAX_CHARS = 300; // moduleName 长度上限
|
|
38
39
|
const PLUGIN_MAX_ITEMS = 500; // 手机侧兜底上限(真实宿主是几十条)
|
|
39
40
|
const PLUGIN_PHASES = new Set(['failed', 'pending', 'active', 'loading', 'unloading']);
|
|
40
41
|
|
|
41
|
-
/** 宿主网关;宿主没组合 dsh-api-gateway 时返回 null,调用方回 503。 */
|
|
42
|
-
function gatewayOf(ctx) {
|
|
43
|
-
return (ctx.get && ctx.get('typertGateway')) || null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
42
|
/** 网关缺失/命名空间缺失对客户端是同一件事:宿主这个能力没装。 */
|
|
47
43
|
const UNAVAILABLE = Object.freeze({
|
|
48
44
|
code: 'plugin-inventory-unavailable',
|
|
@@ -57,35 +53,33 @@ function errorText(error) {
|
|
|
57
53
|
}
|
|
58
54
|
|
|
59
55
|
/**
|
|
60
|
-
*
|
|
56
|
+
* 经网关取一次插件清单快照。
|
|
57
|
+
*
|
|
58
|
+
* 走 host-bridge/transport.js 的 `invoke`:Peer 身份、`gateway/` 前缀的缺失类错误
|
|
59
|
+
* 都在那里统一处理,这里只做本域的错误码翻译。**不能直接 `gateway.invoke`** ——
|
|
60
|
+
* 0.2.1 起网关要求每个请求自带 Peer,绕过去就是对 undefined 读 `.signal`,
|
|
61
|
+
* 手机端看到的是「插件清单加载失败」。
|
|
61
62
|
*
|
|
62
63
|
* @returns {Promise<{value?: unknown, error?: {code: string, message: string}}>}
|
|
63
64
|
*/
|
|
64
65
|
async function callInventory(ctx) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
} catch (error) {
|
|
72
|
-
// 0.2.0 的网关错误码带 `gateway/` 前缀(dsh-api-gateway/src/types.ts:117-136);
|
|
73
|
-
// 0.1.x 时期是不带前缀的。两种都认,否则「宿主没装这个服务」的降级分支形同虚设。
|
|
74
|
-
const raw = error && typeof error.code === 'string' ? error.code.replace(/^gateway\//, '') : ''
|
|
75
|
-
const code = raw === 'invocation-unavailable' || raw === 'service-unavailable' || raw === 'method-unavailable'
|
|
76
|
-
? UNAVAILABLE.code
|
|
77
|
-
: 'plugin-inventory-error';
|
|
78
|
-
return { error: { code, message: errorText(error) } };
|
|
66
|
+
// 没有声明参数 → args 必须是空对象;这个方法也没声明 cancellation,不能多塞 signal。
|
|
67
|
+
const result = await invoke(ctx, 'pluginInventory', 'list', {});
|
|
68
|
+
if (result.error) {
|
|
69
|
+
// 宿主的服务/方法缺失由传输层收敛成同一个码:对客户端就是「这个能力没装」。
|
|
70
|
+
if (result.error.code === GATEWAY_UNAVAILABLE.code) return { error: { ...UNAVAILABLE } };
|
|
71
|
+
return { error: { code: 'plugin-inventory-error', message: errorText(result.error) } };
|
|
79
72
|
}
|
|
73
|
+
const value = result.value;
|
|
80
74
|
// decode 本体就是 { entries: […] };另外两种形状只是容错,防宿主以后改成联合。
|
|
81
|
-
if (
|
|
82
|
-
if (
|
|
83
|
-
if (
|
|
84
|
-
const raw =
|
|
75
|
+
if (value && typeof value === 'object') {
|
|
76
|
+
if (value.ok === true) return { value: value.value };
|
|
77
|
+
if (value.ok === false) {
|
|
78
|
+
const raw = value.error && typeof value.error === 'object' ? value.error : {};
|
|
85
79
|
const code = typeof raw.code === 'string' && raw.code ? raw.code : 'plugin-inventory-error';
|
|
86
80
|
return { error: { code, message: errorText(raw) } };
|
|
87
81
|
}
|
|
88
|
-
return { value
|
|
82
|
+
return { value };
|
|
89
83
|
}
|
|
90
84
|
return { error: { code: 'plugin-inventory-error', message: '宿主插件清单返回了意外的结果' } };
|
|
91
85
|
}
|
package/lib/mobile/references.js
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* 关键:这两个宿主服务 **不在 apiProxy 上** —— 它们是 Typert Remote 服务
|
|
12
12
|
* (dsh-file-reference 的 fileReferences、dsh-session-reference 的
|
|
13
13
|
* sessionReferenceResolver)。手机侧本来就能到它们:dsh-mobile-plugin 与宿主同进程,
|
|
14
|
-
* lib/mobile/sessions.js
|
|
15
|
-
*
|
|
14
|
+
* lib/mobile/sessions.js 已经派发过 commands.execute,lib/mobile/plugins.js 也走同一条路
|
|
15
|
+
* 读 pluginInventory —— 都经 host-bridge/transport.js 的 `invoke`,这里照搬。
|
|
16
16
|
*
|
|
17
17
|
* 两条接口(都只是转发宿主的发现域,不做任何排序或截断之外的加工):
|
|
18
18
|
* - GET /api/mobile/v1/file-references?sessionId=…&query=… → fileReferences.list
|
|
@@ -29,17 +29,13 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
const { sendJson } = require('../shared/http-util');
|
|
32
|
+
const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/transport.js');
|
|
32
33
|
|
|
33
34
|
const REFERENCE_ID_MAX_CHARS = 200; // sessionId 长度上限
|
|
34
35
|
const REFERENCE_QUERY_MAX_CHARS = 200; // 查询串长度上限(宿主 schema 只要求 string)
|
|
35
36
|
const REFERENCE_MAX_ITEMS = 50; // 与宿主 session-reference 的 candidateLimit 默认值一致
|
|
36
37
|
const REFERENCE_KINDS = new Set(['file', 'directory']);
|
|
37
38
|
|
|
38
|
-
/** 宿主网关;宿主没组合 dsh-api-gateway 时返回 null,调用方回 503。 */
|
|
39
|
-
function gatewayOf(ctx) {
|
|
40
|
-
return (ctx.get && ctx.get('typertGateway')) || null;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
39
|
/** 网关缺失/命名空间缺失对客户端是同一件事:宿主这个能力没装。 */
|
|
44
40
|
const UNAVAILABLE = Object.freeze({
|
|
45
41
|
code: 'reference-unavailable',
|
|
@@ -54,9 +50,13 @@ function errorText(error) {
|
|
|
54
50
|
}
|
|
55
51
|
|
|
56
52
|
/**
|
|
57
|
-
*
|
|
53
|
+
* 经网关调一次引用发现。
|
|
54
|
+
*
|
|
55
|
+
* 走 host-bridge/transport.js 的 `invoke`:Peer 身份与「服务/方法不存在」的收敛都在
|
|
56
|
+
* 那里,本域只做自己的 503/400 翻译。**不要自己直连网关** —— 0.2.1 起
|
|
57
|
+
* 要求每个请求自带 Peer,绕过去就是对 undefined 读 `.signal`,手机端 @ 补全整块失效。
|
|
58
58
|
*
|
|
59
|
-
*
|
|
59
|
+
* 注意返回值形状:网关回的是 **decode 后的业务结果本体**
|
|
60
60
|
* (dsh-api-gateway/lib/index.js:112 `return decode(descriptor.result, result, …)`),
|
|
61
61
|
* 那层 `{ok:true,value}` 外壳是浏览器传输(connection.rpc.intercept('/api'))加的,
|
|
62
62
|
* invoke 不负责包。所以形状取决于服务声明的 result schema:
|
|
@@ -67,28 +67,15 @@ function errorText(error) {
|
|
|
67
67
|
* @returns {Promise<{value?: unknown, error?: {code: string, message: string}}>}
|
|
68
68
|
*/
|
|
69
69
|
async function callReference(ctx, namespace, method, agentId, query) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
result = await gateway.invoke({
|
|
78
|
-
namespace,
|
|
79
|
-
method,
|
|
80
|
-
args: { agentId, query },
|
|
81
|
-
signal: new AbortController().signal,
|
|
82
|
-
});
|
|
83
|
-
} catch (error) {
|
|
84
|
-
// 0.2.0 的网关错误码带 `gateway/` 前缀(dsh-api-gateway/src/types.ts:117-136);
|
|
85
|
-
// 0.1.x 时期是不带前缀的。两种都认,否则「宿主没装这个服务」的降级分支形同虚设。
|
|
86
|
-
const raw = error && typeof error.code === 'string' ? error.code.replace(/^gateway\//, '') : ''
|
|
87
|
-
const code = raw === 'invocation-unavailable' || raw === 'service-unavailable' || raw === 'method-unavailable'
|
|
88
|
-
? UNAVAILABLE.code
|
|
89
|
-
: 'reference-error';
|
|
90
|
-
return { error: { code, message: errorText(error) } };
|
|
70
|
+
// 两个方法的声明参数都是 agent(wire=agentId) + query(wire=query),args 必须精确匹配;
|
|
71
|
+
// 末位 signal 是可选的取消边界(gateway 会补 NEVER_ABORTED_SIGNAL),这里给真的,
|
|
72
|
+
// 客户端断开时能让宿主那次发现提前收工。
|
|
73
|
+
const called = await invoke(ctx, namespace, method, { agentId, query }, new AbortController().signal);
|
|
74
|
+
if (called.error) {
|
|
75
|
+
if (called.error.code === GATEWAY_UNAVAILABLE.code) return { error: { ...UNAVAILABLE } };
|
|
76
|
+
return { error: { code: 'reference-error', message: errorText(called.error) } };
|
|
91
77
|
}
|
|
78
|
+
const result = called.value;
|
|
92
79
|
// 正常形状:裸数组(宿主声明的 result 就是数组)
|
|
93
80
|
if (Array.isArray(result)) return { value: result };
|
|
94
81
|
if (result && typeof result === 'object') {
|
|
@@ -21,6 +21,7 @@ const {
|
|
|
21
21
|
} = require('./session-cache.js');
|
|
22
22
|
const { promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive.js');
|
|
23
23
|
const { withIdempotency, requestIdOf } = require('./idempotency.js');
|
|
24
|
+
const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/transport.js');
|
|
24
25
|
const readReceipt = require('../read-receipt.cjs');
|
|
25
26
|
|
|
26
27
|
const SEND_MAX_BODY = 8 * 1024 * 1024; // /send 请求体上限(图片 base64)
|
|
@@ -34,7 +35,7 @@ function singleTextOf(content) {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
|
-
*
|
|
38
|
+
* 经网关执行 slash 命令(commands.execute——桌面 Web 端同一条派发路径:
|
|
38
39
|
* 命令注册表命中才执行,绝不经 session.prompt 发给模型;agent 由 agentId 经同一
|
|
39
40
|
* agent 查找器解析,与 session.prompt 的 turnAgentFor 同源,支持冷会话自动 resume)。
|
|
40
41
|
*
|
|
@@ -51,18 +52,13 @@ function singleTextOf(content) {
|
|
|
51
52
|
* | {error} 查找或派发失败 | {missing} 网关服务未组合(调用方回退普通消息)
|
|
52
53
|
*/
|
|
53
54
|
async function executeCommand(ctx, sessionId, line) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
method: 'execute',
|
|
60
|
-
args: { agentId: sessionId, line, submittedAttachments: [] },
|
|
61
|
-
});
|
|
62
|
-
return { execution };
|
|
63
|
-
} catch (error) {
|
|
64
|
-
return { error };
|
|
55
|
+
// 走 host-bridge/transport.js 的 invoke:Peer 身份与「服务/方法不存在」的收敛都在
|
|
56
|
+
// 那里。这里只把「网关没组合」翻成 {missing},让调用方回退普通消息。
|
|
57
|
+
const called = await invoke(ctx, 'commands', 'execute', { agentId: sessionId, line, submittedAttachments: [] });
|
|
58
|
+
if (called.error) {
|
|
59
|
+
return called.error.code === GATEWAY_UNAVAILABLE.code ? { missing: true } : { error: called.error };
|
|
65
60
|
}
|
|
61
|
+
return { execution: called.value };
|
|
66
62
|
}
|
|
67
63
|
|
|
68
64
|
/**
|
|
@@ -172,36 +168,30 @@ async function handleSend(ctx, req, res) {
|
|
|
172
168
|
}
|
|
173
169
|
|
|
174
170
|
/** GET /api/mobile/v1/commands?sessionId=…(token):会话有效命令目录(与桌面 Web 端命令面板
|
|
175
|
-
* 同源——
|
|
171
|
+
* 同源——commands.list,即服务端命令注册表对该 agent 生效的完整清单)。
|
|
176
172
|
* 响应:{ ok, items:[{name, description, input?:{hint}}] };网关未组合或失败时降级为空清单,
|
|
177
|
-
* 客户端菜单据此只展示真实可执行的命令(不再硬编码)。
|
|
173
|
+
* 客户端菜单据此只展示真实可执行的命令(不再硬编码)。
|
|
174
|
+
*
|
|
175
|
+
* 失败一律降级成空清单是**刻意设计**:输入区不能因为目录拉不到就报错。代价是这个端点
|
|
176
|
+
* 出问题时手机上看不出任何提示,「+」菜单静默只剩媒体与文件两行 —— 2026-10 宿主升到
|
|
177
|
+
* 0.2.1 要求每个请求自带 Peer,这条路径没跟上,用户看到的就是「目标、计划、指令全没了」。 */
|
|
178
178
|
async function handleCommands(ctx, req, res) {
|
|
179
179
|
const sessionId = new URL(req.url, 'http://x').searchParams.get('sessionId');
|
|
180
180
|
if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
...(d.input && typeof d.input === 'object' && typeof d.input.hint === 'string' && d.input.hint
|
|
196
|
-
? { input: { hint: d.input.hint } }
|
|
197
|
-
: {}),
|
|
198
|
-
}))
|
|
199
|
-
: [];
|
|
200
|
-
sendJson(res, 200, { ok: true, items });
|
|
201
|
-
} catch (error) {
|
|
202
|
-
// 目录拉取失败不阻塞输入区:降级为空清单(等价桌面网关缺失)。
|
|
203
|
-
sendJson(res, 200, { ok: true, items: [] });
|
|
204
|
-
}
|
|
181
|
+
const called = await invoke(ctx, 'commands', 'list', { agentId: sessionId });
|
|
182
|
+
const descriptors = called.error ? null : called.value;
|
|
183
|
+
const items = Array.isArray(descriptors)
|
|
184
|
+
? descriptors
|
|
185
|
+
.filter((d) => d && typeof d.name === 'string' && d.name && typeof d.description === 'string')
|
|
186
|
+
.map((d) => ({
|
|
187
|
+
name: d.name,
|
|
188
|
+
description: d.description,
|
|
189
|
+
...(d.input && typeof d.input === 'object' && typeof d.input.hint === 'string' && d.input.hint
|
|
190
|
+
? { input: { hint: d.input.hint } }
|
|
191
|
+
: {}),
|
|
192
|
+
}))
|
|
193
|
+
: [];
|
|
194
|
+
sendJson(res, 200, { ok: true, items });
|
|
205
195
|
}
|
|
206
196
|
|
|
207
197
|
/** POST /api/mobile/v1/session/read(token){sessionId}:清除完成提醒。 */
|
package/lib/mobile/stream.js
CHANGED
|
@@ -30,6 +30,25 @@ const SSE_RETRY_MS = 3000;
|
|
|
30
30
|
// internal progress threshold. Pad each SSE record so onprogress exposes it
|
|
31
31
|
// promptly; the padding is an SSE comment and is ignored by clients.
|
|
32
32
|
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;
|
|
33
52
|
const REPLAY_WINDOW = 3000; // 断线补偿回放的最大事件数
|
|
34
53
|
|
|
35
54
|
/**
|
|
@@ -74,13 +93,72 @@ function stableEventId(payload, frame) {
|
|
|
74
93
|
return frame && typeof frame.rpcId === 'string' && frame.rpcId ? frame.rpcId : undefined;
|
|
75
94
|
}
|
|
76
95
|
|
|
96
|
+
/**
|
|
97
|
+
* 这条连接上真正「还没发出去」的字节数。
|
|
98
|
+
*
|
|
99
|
+
* `res.writableLength` 只算响应对象自己那一层缓冲,真正堆积的地方在它底下的套接字
|
|
100
|
+
* 写队列(堆快照里那些 `{chunk, encoding, callback}` 就是它)。第一版只看
|
|
101
|
+
* `res.writableLength`,它恒为 0,两道闸一次都没触发 —— 2026-10-18 复测:修完内存
|
|
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 记录。
|
|
137
|
+
*
|
|
138
|
+
* @returns {{ok: boolean, shed?: boolean, needDrain?: boolean}}
|
|
139
|
+
* `ok:false` → 这条流该收尾(客户端走了,或积压超过硬水位);
|
|
140
|
+
* `shed:true` → 这条**增量**帧被丢掉、连接还活着(超过软水位且没有 seq);
|
|
141
|
+
* `needDrain:true` → 写成功了,但底下已经写不动,调用方要先 await 一次 drain 再继续。
|
|
142
|
+
*
|
|
143
|
+
* 丢增量只丢没有 seq 的帧:有 seq 的帧是客户端水位的一部分,丢了不回放就在手机上
|
|
144
|
+
* 留一个永久的洞,那比断流更糟。
|
|
145
|
+
*/
|
|
77
146
|
function writeSse(res, payload, frame) {
|
|
147
|
+
const buffered = bufferedBytesOf(res);
|
|
148
|
+
if (buffered > SSE_CLOSE_BUFFERED_BYTES) return { ok: false };
|
|
78
149
|
const id = stableEventId(payload, frame);
|
|
150
|
+
const hasSeq = Number.isFinite(payload && payload.seq);
|
|
151
|
+
if (buffered > SSE_SHED_BUFFERED_BYTES && !hasSeq) return { ok: true, shed: true };
|
|
79
152
|
const fields = [`retry: ${SSE_RETRY_MS}`];
|
|
80
153
|
if (id) fields.push(`id: ${id}`);
|
|
81
154
|
fields.push(`data: ${JSON.stringify(payload)}`);
|
|
82
155
|
fields.push(`: ${' '.repeat(SSE_FLUSH_PADDING_BYTES)}`, '', '');
|
|
83
|
-
try {
|
|
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 }; }
|
|
84
162
|
}
|
|
85
163
|
|
|
86
164
|
/**
|
|
@@ -163,7 +241,7 @@ async function handleStream(ctx, req, res) {
|
|
|
163
241
|
if (replay.gap && !ac.signal.aborted) {
|
|
164
242
|
const value = { type: 'session/resync-required', sessionId, resumeSeq, oldestSeq: replay.oldestSeq };
|
|
165
243
|
// 旧实现在这里直接 `return`:既不清 keepAlive 也不 end,漏出一条永不收尾的连接。
|
|
166
|
-
if (!writeSse(res, value, { payload: value })) clientGone = true;
|
|
244
|
+
if (!writeSse(res, value, { payload: value }).ok) clientGone = true;
|
|
167
245
|
}
|
|
168
246
|
}
|
|
169
247
|
|
|
@@ -172,6 +250,19 @@ async function handleStream(ctx, req, res) {
|
|
|
172
250
|
// 这一条会话的 follow + 投影 + 任务三条流(见 mobile/host-bridge.js 的 muxFrames)。
|
|
173
251
|
// 不带就是设备级视角,只会给工作区基线与全局投影 —— 这里反正只要单会话。
|
|
174
252
|
const frames = ctx.apiProxy.events.mux({ rpcId: 'mobile-stream', payload: { sessionId } }, ac.signal);
|
|
253
|
+
// 写一帧并把三种结果统一收口:丢增量、需要等 drain、该收尾。
|
|
254
|
+
// 「需要等 drain」这一步是这次 OOM 的正解 —— 写不动就停手,等底层消化完再继续,
|
|
255
|
+
// 而不是把帧继续往内存里塞。
|
|
256
|
+
const push = async (payload, frameArg) => {
|
|
257
|
+
const sent = writeSse(res, payload, frameArg);
|
|
258
|
+
if (sent.shed) return 'shed';
|
|
259
|
+
if (!sent.ok) return 'closed';
|
|
260
|
+
if (sent.needDrain) {
|
|
261
|
+
const drained = await waitForDrain(res, ac.signal);
|
|
262
|
+
if (!drained) { logStream(sessionId, 'drain timeout', null); return 'closed'; }
|
|
263
|
+
}
|
|
264
|
+
return 'sent';
|
|
265
|
+
};
|
|
175
266
|
for await (const frame of frames) {
|
|
176
267
|
const p = frame.payload;
|
|
177
268
|
if (!p || p.sessionId !== sessionId) continue;
|
|
@@ -181,25 +272,31 @@ async function handleStream(ctx, req, res) {
|
|
|
181
272
|
// 回放已覆盖该 seq(回放与实时交界处)→ 跳过,避免重复交付。
|
|
182
273
|
if (seq !== null && seq <= lastDeliveredSeq) continue;
|
|
183
274
|
const event = await resolveEventAttachments(ctx, sessionId, p.event);
|
|
184
|
-
|
|
275
|
+
// 被 shed 的只可能是没有 seq 的增量(见 writeSse 的软水位闸);它不推进水位,
|
|
276
|
+
// 同一轮收尾的 assistant/message 会把完整正文补上。
|
|
277
|
+
const result = await push(event, { ...frame, payload: { ...p, event } });
|
|
278
|
+
if (result === 'shed') continue;
|
|
279
|
+
if (result === 'closed') { clientGone = true; break; }
|
|
185
280
|
if (seq !== null) lastDeliveredSeq = seq;
|
|
186
281
|
} else if (p.type === 'session/queue' || p.type === 'session/projection') {
|
|
187
282
|
// 队列/投影整帧转发;不假定存在可重放的宿主 API。
|
|
188
|
-
|
|
283
|
+
const result = await push(p, frame);
|
|
284
|
+
if (result === 'shed') continue;
|
|
285
|
+
if (result === 'closed') { clientGone = true; break; }
|
|
189
286
|
} else if (p.type === 'session/jobs') {
|
|
190
287
|
// 后台任务整帧(桌面端会话头部任务面板的数据源)。宿主只在任务有变化时发,
|
|
191
288
|
// 且**空数组也要转发** —— 那是「任务都结束了」的唯一信号,丢掉它就永远清不掉。
|
|
192
289
|
const value = { type: 'session/jobs', sessionId, jobs: normalizeJobs(p.jobs) };
|
|
193
|
-
if (
|
|
290
|
+
if (await push(value, frame) === 'closed') { clientGone = true; break; }
|
|
194
291
|
} else if (p.type === 'question/requested') {
|
|
195
292
|
// 问题是可回答的 mux server-request,而非 session/event;保留 rpcId 供回答关联。
|
|
196
293
|
const value = { ...p, rpcId: frame.rpcId };
|
|
197
|
-
if (
|
|
294
|
+
if (await push(value, { ...frame, payload: value }) === 'closed') { clientGone = true; break; }
|
|
198
295
|
} else if (p.type === 'question/resolved' || p.type === 'approval/requested' || p.type === 'approval/asked' || p.type === 'approval/decided' || p.type === 'approval/resolved') {
|
|
199
296
|
// Approval frames are server requests/resolutions, not session/event frames;
|
|
200
297
|
// forward the rpcId and approval identity so the mobile client can answer safely.
|
|
201
298
|
const value = ANSWERABLE_APPROVAL_TYPES.has(p.type) && frame.rpcId ? { ...p, rpcId: frame.rpcId } : p;
|
|
202
|
-
if (
|
|
299
|
+
if (await push(value, { ...frame, payload: value }) === 'closed') { clientGone = true; break; }
|
|
203
300
|
}
|
|
204
301
|
}
|
|
205
302
|
if (!clientGone) loopEnded = true;
|
|
@@ -255,7 +352,10 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered) {
|
|
|
255
352
|
// Keep replay data identical to live SSE data: the client reducer consumes
|
|
256
353
|
// the event's own type (turn/start, assistant/message, etc.).
|
|
257
354
|
const payload = { type: 'session/event', sessionId, event };
|
|
258
|
-
|
|
355
|
+
const sent = writeSse(res, event, { payload });
|
|
356
|
+
if (!sent.ok) break;
|
|
357
|
+
// 回放是「一次性灌几千条」,最容易把写队列撑爆:写不动就在这里等,别一口气塞完。
|
|
358
|
+
if (sent.needDrain && !(await waitForDrain(res, signal))) break;
|
|
259
359
|
if (Number.isFinite(event.seq)) onDelivered(event.seq);
|
|
260
360
|
}
|
|
261
361
|
return { gap, oldestSeq };
|
package/lib/session-cleanup.cjs
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* (pruneOrphanedArchivedSessionsOnce)。理由写在那个函数上:桌面端的会话列表是
|
|
17
17
|
* 连上宿主时拉的一份快照 + 之后的事件增量,进程里擦集合等于把已经删掉、却还留在
|
|
18
18
|
* 快照里的行放回正常列表。
|
|
19
|
+
* - **Windows 上目录删不掉 ≠ 会话没删掉**:宿主对它打开过的会话一直握着 `session.lock`
|
|
20
|
+
* 的写句柄,日志 unlink 成功后那个名字要等句柄关闭才消失(delete-pending),rmdir
|
|
21
|
+
* 于是报 ENOTEMPTY。判据改成「目录里还有没有规范名日志」:没有就是删成了。
|
|
19
22
|
*/
|
|
20
23
|
'use strict'
|
|
21
24
|
|
|
@@ -90,6 +93,9 @@ async function readHeader(file) {
|
|
|
90
93
|
} finally {
|
|
91
94
|
stream.destroy()
|
|
92
95
|
source.destroy()
|
|
96
|
+
// Windows 上句柄没真的关掉之前,这个目录项删不掉(delete-pending → rmdir 报 ENOTEMPTY)。
|
|
97
|
+
// 删会话目录就紧跟在读 header 之后,所以先等 close 再往下走。
|
|
98
|
+
if (!source.closed) await new Promise(resolve => source.once('close', resolve))
|
|
93
99
|
}
|
|
94
100
|
return undefined
|
|
95
101
|
}
|
|
@@ -116,12 +122,16 @@ async function deleteSessionData(sessionIds) {
|
|
|
116
122
|
for (const file of sessionFiles(root)) {
|
|
117
123
|
const header = await readHeader(file)
|
|
118
124
|
if (!header || !ids.has(header.id)) continue
|
|
125
|
+
const directory = path.dirname(file)
|
|
119
126
|
try {
|
|
120
|
-
fs.rmSync(
|
|
121
|
-
removed.push(header.id)
|
|
127
|
+
fs.rmSync(directory, { recursive: true, force: true })
|
|
122
128
|
} catch (error) {
|
|
123
|
-
|
|
129
|
+
// Windows:宿主握着 session.lock,日志 unlink 成功了但名字还在(delete-pending),
|
|
130
|
+
// rmdir 报 ENOTEMPTY。日志都没了,这条会话对宿主就等于已经删掉,按删成记账、继续清
|
|
131
|
+
// 下一条;日志还在才算真失败(别把没删掉的说成删掉了)。
|
|
132
|
+
if (sessionFiles(directory).length > 0) throw new Error(`删除会话 ${header.id} 失败:${error.message}`)
|
|
124
133
|
}
|
|
134
|
+
removed.push(header.id)
|
|
125
135
|
}
|
|
126
136
|
const cacheRoot = path.join(dshHome(), 'storages', 'session_projcache', 'sessions')
|
|
127
137
|
for (const id of ids) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.86",
|
|
4
4
|
"description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|