dsh-mobile-plugin 0.1.85 → 0.1.87

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.
@@ -17,10 +17,10 @@
17
17
 
18
18
  const { sendJson, readJsonBody } = require('../shared/http-util');
19
19
  const { withIdempotency, requestIdOf } = require('./idempotency.js');
20
- const { canonicalDeviceKey, rememberDeletedSessionIds } = require('./session-cache.js');
20
+ const { canonicalDeviceKey, rememberDeletedSessionIds, deletedSessionIdsOf } = require('./session-cache.js');
21
21
  const { invalidateSessionList } = require('./sessions.js');
22
22
  const { unwrap } = require('./envelope.js');
23
- const { deleteSessionData } = require('../session-cleanup.cjs');
23
+ const { deleteSessionData, sessionIdsWithoutAnyData } = require('../session-cleanup.cjs');
24
24
 
25
25
  /**
26
26
  * POST /api/mobile/v1/workspace/clear-archived(token)
@@ -43,10 +43,15 @@ async function handleClearArchived(ctx, req, res) {
43
43
  if (selected.length === 0) return { status: 200, body: { ok: true, deleted: 0 } };
44
44
  const removed = await deleteSessionData(selected);
45
45
  // 删的是列表里可见的行:手机端正开着列表,宿主那份快照可能还带着它们,先在本进程压住。
46
- rememberDeletedSessionIds(ctx, removed);
46
+ // 盘上已经没有数据的归档 id 一并压住:上一次删到一半抛错时,已删的那批没记进删除集,
47
+ // 宿主的进程内清单却还挂着它们 —— 不补记的话再点清理只回 0,手机上永远清不掉。
48
+ const already = deletedSessionIdsOf(ctx);
49
+ const cleared = new Set(removed);
50
+ for (const id of sessionIdsWithoutAnyData(selected)) if (!already.has(id)) cleared.add(id);
51
+ rememberDeletedSessionIds(ctx, cleared);
47
52
  // 不立刻失效的话,最长 10 秒还能点到已删的行。
48
53
  invalidateSessionList(ctx);
49
- return { status: 200, body: { ok: true, deleted: removed.length } };
54
+ return { status: 200, body: { ok: true, deleted: cleared.size } };
50
55
  });
51
56
  sendJson(res, outcome.status, outcome.body);
52
57
  }
@@ -67,22 +67,39 @@ function stableEventId(payload, frame) {
67
67
  }
68
68
 
69
69
  /**
70
- * 写一条 SSE 记录。返回值与 `mobile/stream.js` 的同名函数一致:`ok:false` 表示该收尾,
71
- * `shed:true` 表示这条帧被主动丢掉(写缓冲超过软水位)。
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` 写进去了但底下已经写不动。
72
86
  *
73
87
  * 设备级流的帧全是「有东西变了」的信号,本来就幂等:丢掉一帧的代价是那一次刷新要等
74
88
  * 下一次变更或 60s 兜底轮询;而堆在写缓冲里的代价是宿主 OOM(2026-10-17 现场:466MB
75
89
  * 的 SSE 帧挂在 socket 写队列上,宿主撞 4GB 上限被杀)。所以软水位一律 shed。
76
90
  */
77
91
  function writeSse(res, payload, frame) {
78
- const buffered = Number(res.writableLength) || 0;
92
+ const buffered = bufferedBytesOf(res);
79
93
  if (buffered > SSE_CLOSE_BUFFERED_BYTES) return { ok: false };
80
94
  if (buffered > SSE_SHED_BUFFERED_BYTES) return { ok: true, shed: true };
81
95
  const id = stableEventId(payload, frame);
82
96
  const fields = [`retry: ${SSE_RETRY_MS}`];
83
97
  if (id) fields.push(`id: ${id}`);
84
98
  fields.push(`data: ${JSON.stringify(payload)}`, '', '');
85
- try { res.write(`${fields.join('\n')}\n`); return { ok: true }; } catch { return { ok: false }; }
99
+ try {
100
+ const accepted = res.write(`${fields.join('\n')}\n`);
101
+ return { ok: true, needDrain: accepted === false };
102
+ } catch { return { ok: false }; }
86
103
  }
87
104
 
88
105
  async function* mergeHostAndMux(ctx, signal) {
@@ -179,8 +196,12 @@ async function handleDeviceStream(ctx, req, res) {
179
196
  version: 1,
180
197
  payload: devicePayload,
181
198
  }, null);
182
- if (result.ok && !result.shed) framesSent += 1;
183
- return result.ok;
199
+ if (!result.ok) return false;
200
+ if (result.shed) return true;
201
+ framesSent += 1;
202
+ // 所有调用点都是同步回调(信号总线、心跳),没法在这里 await;写不动就如实回报,
203
+ // 由调用方 abort —— 这条流本来就是「断了重连、靠补发收敛」的语义。
204
+ return !result.needDrain;
184
205
  };
185
206
  /**
186
207
  * 自产的「清单变了」帧:镜像推送调度器每 500ms 比一次清单内容哈希,变了就在这里推给手机。
@@ -290,7 +311,7 @@ async function handleDeviceStream(ctx, req, res) {
290
311
  // Keep the existing device/event JSON envelope unchanged; SSE metadata is additive.
291
312
  const sent = writeSse(res, envelope, frame);
292
313
  if (sent.shed) continue;
293
- if (!sent.ok) { clientGone = true; break; }
314
+ if (!sent.ok || sent.needDrain) { clientGone = true; break; }
294
315
  framesSent += 1;
295
316
  }
296
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 content = Array.isArray(msg.content) ? msg.content[0] : null;
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
+ * 代码块里的 `![](/tmp/a.png)` 是**写法示例**而不是要画的图。旧实现整段正则替换,
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 matches = [...text.matchAll(IMAGE_RE)];
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 = text;
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 = out.replace(m[0], `![${m[1]}](${uri})`);
230
+ out += text.slice(cursor, m.index) + `![${m[1]}](${uri})`;
231
+ cursor = m.index + m[0].length;
149
232
  }
150
- return out === text ? text : out;
233
+ return cursor === 0 ? text : out + text.slice(cursor);
151
234
  }
152
235
 
153
236
  module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf };
@@ -44,6 +44,11 @@ const SSE_FLUSH_PADDING_BYTES = 2048;
44
44
  */
45
45
  const SSE_SHED_BUFFERED_BYTES = 4 * 1024 * 1024;
46
46
  const SSE_CLOSE_BUFFERED_BYTES = 16 * 1024 * 1024;
47
+ /**
48
+ * 等一次 `drain` 的上限。超过这么久还没排空,说明对面不是慢、是真的不读了(手机切后台、
49
+ * 中转卡住),继续等只是把这条连接挂在那里,不如收尾让客户端重连。
50
+ */
51
+ const SSE_DRAIN_TIMEOUT_MS = 20_000;
47
52
  const REPLAY_WINDOW = 3000; // 断线补偿回放的最大事件数
48
53
 
49
54
  /**
@@ -88,18 +93,58 @@ function stableEventId(payload, frame) {
88
93
  return frame && typeof frame.rpcId === 'string' && frame.rpcId ? frame.rpcId : undefined;
89
94
  }
90
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
+
91
135
  /**
92
136
  * 写一条 SSE 记录。
93
137
  *
94
- * @returns {{ok: boolean, shed?: boolean}} `ok:false` 表示这条流该收尾了(客户端已经
95
- * 走了,或者写缓冲超过硬水位);`shed:true` 表示这条**增量**帧被主动丢掉、连接还
96
- * 活着(见 SSE_SHED_BUFFERED_BYTES)。
138
+ * @returns {{ok: boolean, shed?: boolean, needDrain?: boolean}}
139
+ * `ok:false` → 这条流该收尾(客户端走了,或积压超过硬水位);
140
+ * `shed:true` → 这条**增量**帧被丢掉、连接还活着(超过软水位且没有 seq);
141
+ * `needDrain:true` → 写成功了,但底下已经写不动,调用方要先 await 一次 drain 再继续。
97
142
  *
98
- * 丢增量必须只丢没有 seq 的帧:有 seq 的帧是客户端水位的一部分,丢了不回放就在手机
99
- * 上留一个永久的洞,那比断流更糟。
143
+ * 丢增量只丢没有 seq 的帧:有 seq 的帧是客户端水位的一部分,丢了不回放就在手机上
144
+ * 留一个永久的洞,那比断流更糟。
100
145
  */
101
146
  function writeSse(res, payload, frame) {
102
- const buffered = Number(res.writableLength) || 0;
147
+ const buffered = bufferedBytesOf(res);
103
148
  if (buffered > SSE_CLOSE_BUFFERED_BYTES) return { ok: false };
104
149
  const id = stableEventId(payload, frame);
105
150
  const hasSeq = Number.isFinite(payload && payload.seq);
@@ -108,7 +153,12 @@ function writeSse(res, payload, frame) {
108
153
  if (id) fields.push(`id: ${id}`);
109
154
  fields.push(`data: ${JSON.stringify(payload)}`);
110
155
  fields.push(`: ${' '.repeat(SSE_FLUSH_PADDING_BYTES)}`, '', '');
111
- try { res.write(`${fields.join('\n')}\n`); return { ok: true }; } catch { return { ok: false }; }
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 }; }
112
162
  }
113
163
 
114
164
  /**
@@ -200,6 +250,19 @@ async function handleStream(ctx, req, res) {
200
250
  // 这一条会话的 follow + 投影 + 任务三条流(见 mobile/host-bridge.js 的 muxFrames)。
201
251
  // 不带就是设备级视角,只会给工作区基线与全局投影 —— 这里反正只要单会话。
202
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
+ };
203
266
  for await (const frame of frames) {
204
267
  const p = frame.payload;
205
268
  if (!p || p.sessionId !== sessionId) continue;
@@ -211,29 +274,29 @@ async function handleStream(ctx, req, res) {
211
274
  const event = await resolveEventAttachments(ctx, sessionId, p.event);
212
275
  // 被 shed 的只可能是没有 seq 的增量(见 writeSse 的软水位闸);它不推进水位,
213
276
  // 同一轮收尾的 assistant/message 会把完整正文补上。
214
- const sent = writeSse(res, event, { ...frame, payload: { ...p, event } });
215
- if (sent.shed) continue;
216
- if (!sent.ok) { clientGone = true; break; }
277
+ const result = await push(event, { ...frame, payload: { ...p, event } });
278
+ if (result === 'shed') continue;
279
+ if (result === 'closed') { clientGone = true; break; }
217
280
  if (seq !== null) lastDeliveredSeq = seq;
218
281
  } else if (p.type === 'session/queue' || p.type === 'session/projection') {
219
282
  // 队列/投影整帧转发;不假定存在可重放的宿主 API。
220
- const sent = writeSse(res, p, frame);
221
- if (sent.shed) continue;
222
- if (!sent.ok) { clientGone = true; break; }
283
+ const result = await push(p, frame);
284
+ if (result === 'shed') continue;
285
+ if (result === 'closed') { clientGone = true; break; }
223
286
  } else if (p.type === 'session/jobs') {
224
287
  // 后台任务整帧(桌面端会话头部任务面板的数据源)。宿主只在任务有变化时发,
225
288
  // 且**空数组也要转发** —— 那是「任务都结束了」的唯一信号,丢掉它就永远清不掉。
226
289
  const value = { type: 'session/jobs', sessionId, jobs: normalizeJobs(p.jobs) };
227
- if (!writeSse(res, value, frame).ok) { clientGone = true; break; }
290
+ if (await push(value, frame) === 'closed') { clientGone = true; break; }
228
291
  } else if (p.type === 'question/requested') {
229
292
  // 问题是可回答的 mux server-request,而非 session/event;保留 rpcId 供回答关联。
230
293
  const value = { ...p, rpcId: frame.rpcId };
231
- if (!writeSse(res, value, { ...frame, payload: value }).ok) { clientGone = true; break; }
294
+ if (await push(value, { ...frame, payload: value }) === 'closed') { clientGone = true; break; }
232
295
  } else if (p.type === 'question/resolved' || p.type === 'approval/requested' || p.type === 'approval/asked' || p.type === 'approval/decided' || p.type === 'approval/resolved') {
233
296
  // Approval frames are server requests/resolutions, not session/event frames;
234
297
  // forward the rpcId and approval identity so the mobile client can answer safely.
235
298
  const value = ANSWERABLE_APPROVAL_TYPES.has(p.type) && frame.rpcId ? { ...p, rpcId: frame.rpcId } : p;
236
- if (!writeSse(res, value, { ...frame, payload: value }).ok) { clientGone = true; break; }
299
+ if (await push(value, { ...frame, payload: value }) === 'closed') { clientGone = true; break; }
237
300
  }
238
301
  }
239
302
  if (!clientGone) loopEnded = true;
@@ -289,7 +352,10 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered) {
289
352
  // Keep replay data identical to live SSE data: the client reducer consumes
290
353
  // the event's own type (turn/start, assistant/message, etc.).
291
354
  const payload = { type: 'session/event', sessionId, event };
292
- if (!writeSse(res, event, { payload }).ok) break;
355
+ const sent = writeSse(res, event, { payload });
356
+ if (!sent.ok) break;
357
+ // 回放是「一次性灌几千条」,最容易把写队列撑爆:写不动就在这里等,别一口气塞完。
358
+ if (sent.needDrain && !(await waitForDrain(res, signal))) break;
293
359
  if (Number.isFinite(event.seq)) onDelivered(event.seq);
294
360
  }
295
361
  return { gap, oldestSeq };
@@ -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(path.dirname(file), { recursive: true, force: true })
121
- removed.push(header.id)
127
+ fs.rmSync(directory, { recursive: true, force: true })
122
128
  } catch (error) {
123
- throw new Error(`删除会话 ${header.id} 失败:${error.message}`)
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) {
@@ -176,6 +186,38 @@ async function archivedIdsWithoutLogs(archivedSessionIds) {
176
186
  return [...orphans]
177
187
  }
178
188
 
189
+ /**
190
+ * 盘上已经不剩任何会话数据的 id:目录没了,或者目录里只剩宿主锁着的 `session.lock`。
191
+ *
192
+ * 只给移动端的「清理所有已归档」补账用:更早一次删除如果删到一半抛错(2026-10-18 前
193
+ * Windows 的 ENOTEMPTY 就是这样),已经删掉的那批 id 没能记进本进程的删除集,而宿主的
194
+ * 进程内清单还挂着它们 —— 再点清理只会回 `deleted: 0`,手机上永远清不掉。
195
+ *
196
+ * 判据比 archivedIdsWithoutLogs 更宽(那个只认规范名日志),但**不许宽到把迁移中途的
197
+ * 临时文件当成没有数据**:目录里只要还有任何非锁文件(含子目录)就算「还在」,读不出来
198
+ * 也按「还在」算。
199
+ */
200
+ function sessionIdsWithoutAnyData(sessionIds) {
201
+ const candidates = new Set(sessionIds)
202
+ const withoutData = new Set()
203
+ if (candidates.size === 0) return []
204
+ const visit = (directory) => {
205
+ let entries
206
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }) } catch { return }
207
+ for (const entry of entries) {
208
+ if (!entry.isDirectory()) continue
209
+ const child = path.join(directory, entry.name)
210
+ if (!candidates.has(entry.name)) { visit(child); continue }
211
+ candidates.delete(entry.name)
212
+ let files
213
+ try { files = fs.readdirSync(child, { withFileTypes: true }) } catch { continue }
214
+ if (!files.some((file) => !file.name.endsWith('.lock'))) withoutData.add(entry.name)
215
+ }
216
+ }
217
+ visit(path.join(dshHome(), 'sessions'))
218
+ return [...candidates, ...withoutData]
219
+ }
220
+
179
221
  async function pruneOrphanedArchivedSessions(ctx) {
180
222
  const workspaceList = ctx && ctx.apiProxy && ctx.apiProxy.workspace ? ctx.apiProxy.workspace.list : undefined
181
223
  if (typeof workspaceList !== 'function') return 0
@@ -298,4 +340,4 @@ function installSessionCleanup(ctx) {
298
340
  registerSessionCleanupTool(ctx)
299
341
  }
300
342
 
301
- module.exports = { cleanupSessions, registerSessionCleanupTool, installSessionCleanup, deleteSessionData, forgetArchivedSessions, pruneOrphanedArchivedSessions, pruneOrphanedArchivedSessionsOnce }
343
+ module.exports = { cleanupSessions, registerSessionCleanupTool, installSessionCleanup, deleteSessionData, sessionIdsWithoutAnyData, forgetArchivedSessions, pruneOrphanedArchivedSessions, pruneOrphanedArchivedSessionsOnce }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.1.85",
3
+ "version": "0.1.87",
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",