@coclaw/openclaw-coclaw 0.21.3 → 0.21.5

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/README.md CHANGED
@@ -92,7 +92,7 @@ pnpm run release:versions # 显示所有已发布版本
92
92
  | `coclaw.files.delete` | 删除工作区文件/目录 |
93
93
  | `coclaw.files.mkdir` | 创建工作区目录 |
94
94
  | `coclaw.files.create` | 创建空文件 |
95
- | `nativeui.sessions.listAll` | 列出所有 session(分页 + 标题推导) |
95
+ | `nativeui.sessions.listAll` | 列出所有 session(分页) |
96
96
  | `nativeui.sessions.get` | 获取 session 原始 JSONL 行(分页) |
97
97
 
98
98
  ## Gateway Services
package/index.js CHANGED
@@ -363,21 +363,21 @@ const plugin = {
363
363
  // best-effort ensure:失败不阻断 listAll
364
364
  try { await ensureAgentSession(agentId); }
365
365
  catch {}
366
- respond(true, manager.listAll(params ?? {}));
366
+ respond(true, await manager.listAll(params ?? {}));
367
367
  }
368
368
  catch (err) {
369
369
  respondError(respond, err);
370
370
  }
371
371
  });
372
372
 
373
- api.registerGatewayMethod('nativeui.sessions.get', ({ params, respond }) => {
373
+ api.registerGatewayMethod('nativeui.sessions.get', async ({ params, respond }) => {
374
374
  try {
375
375
  const sessionId = params?.sessionId;
376
376
  if (typeof sessionId !== 'string' || sessionId.trim().length === 0) {
377
377
  respondInvalid(respond, 'sessionId required');
378
378
  return;
379
379
  }
380
- respond(true, manager.get(params ?? {}));
380
+ respond(true, await manager.get(params ?? {}));
381
381
  }
382
382
  catch (err) {
383
383
  respondError(respond, err);
@@ -474,7 +474,7 @@ const plugin = {
474
474
  }
475
475
  });
476
476
 
477
- api.registerGatewayMethod('coclaw.topics.getHistory', ({ params, respond }) => {
477
+ api.registerGatewayMethod('coclaw.topics.getHistory', async ({ params, respond }) => {
478
478
  try {
479
479
  const topicId = params?.topicId?.trim?.();
480
480
  if (!topicId) {
@@ -483,7 +483,7 @@ const plugin = {
483
483
  }
484
484
  const agentId = params?.agentId?.trim?.() || 'main';
485
485
  // 直接复用 session-manager 的 get(),topicId 即 sessionId
486
- respond(true, manager.get({ agentId, sessionId: topicId }));
486
+ respond(true, await manager.get({ agentId, sessionId: topicId }));
487
487
  }
488
488
  catch (err) {
489
489
  respondError(respond, err);
@@ -577,7 +577,7 @@ const plugin = {
577
577
  });
578
578
 
579
579
  // TODO: coclaw.topics.getHistory 未来可废弃,UI 改用 coclaw.sessions.getById
580
- api.registerGatewayMethod('coclaw.sessions.getById', ({ params, respond }) => {
580
+ api.registerGatewayMethod('coclaw.sessions.getById', async ({ params, respond }) => {
581
581
  try {
582
582
  const sessionId = params?.sessionId?.trim?.();
583
583
  if (!sessionId) {
@@ -586,7 +586,7 @@ const plugin = {
586
586
  }
587
587
  const agentId = params?.agentId?.trim?.() || 'main';
588
588
  const limit = params?.limit;
589
- respond(true, manager.getById({ agentId, sessionId, limit }));
589
+ respond(true, await manager.getById({ agentId, sessionId, limit }));
590
590
  }
591
591
  catch (err) {
592
592
  respondError(respond, err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.21.3",
3
+ "version": "0.21.5",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat. Run `openclaw coclaw enroll` after install to connect to CoClaw.",
@@ -131,6 +131,14 @@ export async function writeUpgradeLock(pid) {
131
131
  * `upgrade.ledger-read-failed` / `upgrade.ledger-parse-failed`),避免运维只
132
132
  * 看到 start() 那条 "Skipping: not an npm-installed plugin" 时误判方向。
133
133
  *
134
+ * 注:内部 `readFileSync` 为同步 IO,**有意保留**——只在升级周期决策时读一次
135
+ * 账本(整个进程生命周期通常一锤子)。改 async 必须沿 `shouldSkipAutoUpgrade`
136
+ * 等调用链向上传播,收益不抵成本。
137
+ *
138
+ * 另:OpenClaw plugin SDK 当前未暴露查询 installRecords 的 API,只能直接读
139
+ * `<state-dir>/plugins/installs.json`(与上游 `manifest-metadata-scan` 等
140
+ * 内部模块同源做法)。如果上游后续开放官方接口,可切换并删除直读分支。
141
+ *
134
142
  * @param {string} pluginId
135
143
  * @returns {object|null}
136
144
  */
@@ -77,6 +77,13 @@ function generateIdentity() {
77
77
  * 加载或创建设备身份(Ed25519 密钥对)
78
78
  *
79
79
  * 存储格式与 OpenClaw device-identity.ts 保持一致。
80
+ *
81
+ * 注:本函数及内部 `fs.existsSync` / `readFileSync` / `generateKeyPairSync` /
82
+ * `atomicWriteFileSync` 均为同步 IO,**有意保留**——本路径只在 plugin↔本机
83
+ * gateway 首次握手时命中一次(后续走 realtime-bridge 的内存缓存),属于"启动期
84
+ * 一锤子"。改 async 必须把握手链路也 async 化,复杂度全长在握手那条线上,收益
85
+ * 不抵成本。重新评估前请先确认调用频率确实发生了变化。
86
+ *
80
87
  * @param {string} [filePath] - 自定义路径,默认 &lt;state-dir&gt;/coclaw/device-identity.json
81
88
  * @returns {{ deviceId: string, publicKeyPem: string, privateKeyPem: string }}
82
89
  */
@@ -768,9 +768,13 @@ export class RealtimeBridge {
768
768
  if (this.gatewayWs !== ws) {
769
769
  return;
770
770
  }
771
+ // rawData 保留供转发链路(res 单播 / agent event 单播 / 兜底广播)透传:
772
+ // 跳过 webrtcPeer.broadcast/sendTo 内部的重新 stringify,省掉对大 payload
773
+ // (如 agent.run.event 含大 tool_result)的重复序列化主线程阻塞。
774
+ const rawData = String(event.data ?? '{}');
771
775
  let payload = null;
772
776
  try {
773
- payload = JSON.parse(String(event.data ?? '{}'));
777
+ payload = JSON.parse(rawData);
774
778
  }
775
779
  catch {
776
780
  return;
@@ -892,7 +896,9 @@ export class RealtimeBridge {
892
896
  /* c8 ignore next -- TODO: 2026-05-20 后删除 */
893
897
  this.logger.debug?.(`[coclaw/rpc-res-route] hit, reqId=${payload.id} → connId=${info.connId}`);
894
898
  // sendTo 阶段 1 改为 async(admission 决策 await);外层 listener 已是 async
895
- const delivered = await this.webrtcPeer?.sendTo(info.connId, payload);
899
+ // 透传 rawData 跳过重新 stringify:gateway → plugin → DC 转发链路上的大 payload
900
+ // (如 agent.run.event 含大 tool_result)省掉一次主线程序列化阻塞。
901
+ const delivered = await this.webrtcPeer?.sendTo(info.connId, payload, rawData);
896
902
  if (!delivered) {
897
903
  // PC 已断 / DC 未 open / 队列拒收:本地 log 丢弃,不退回广播
898
904
  this.__logDebug(
@@ -913,7 +919,8 @@ export class RealtimeBridge {
913
919
  /* c8 ignore next -- TODO: 2026-05-20 后删除 */
914
920
  this.logger.debug?.(`[coclaw/run-event-route] hit, runId=${runId} → connId=${connId}`);
915
921
  // sendTo 失败不打 log(PC 状态翻转日志已足够,drop 是正确语义)
916
- await this.webrtcPeer?.sendTo(connId, payload);
922
+ // 透传 rawData 跳过重新 stringify:agent event payload 可能很大(如 tool_result)
923
+ await this.webrtcPeer?.sendTo(connId, payload, rawData);
917
924
  return;
918
925
  }
919
926
  }
@@ -921,7 +928,8 @@ export class RealtimeBridge {
921
928
  this.logger.debug?.(`[coclaw/run-event-route] miss, broadcast, runId=${runId ?? '<missing>'}`);
922
929
  }
923
930
  // (d) 兜底广播:覆盖 event 类型 / 映射未命中场景
924
- this.webrtcPeer?.broadcast(payload);
931
+ // 透传 rawData 跳过重新 stringify:agent event 兜底广播同样可能很大
932
+ this.webrtcPeer?.broadcast(payload, rawData);
925
933
  }
926
934
  })().catch((err) => {
927
935
  this.logger.warn?.(`[coclaw] gateway ws message handler error: ${err?.message ?? err}`);
@@ -1310,8 +1318,14 @@ export class RealtimeBridge {
1310
1318
  await this.__webrtcPeerReady;
1311
1319
  await this.webrtcPeer.handleSignaling(payload);
1312
1320
  } catch (err) {
1313
- this.logger.warn?.(`[coclaw/rtc] signaling error (or werift not found): ${err?.message}`);
1314
- remoteLog(`rtc.signaling-error msg=${err?.message}`);
1321
+ // 现在 handleSignaling drain 自己 catch + 转 rtc.signaling-error remoteLog 不
1322
+ // 抛出,这层 catch 只会兜到 __initWebrtcPeer() 失败(peer 未就绪)。type/conn
1323
+ // 字段保留是为了 server 端日志区分发生位置——init 路径下 type/conn 仍来自当前
1324
+ // 信令消息,与 drain 内的同名日志做区分主要看 msg 文本。
1325
+ const sigType = payload?.type ?? 'unknown';
1326
+ const sigConn = payload?.fromConnId ?? payload?.toConnId ?? 'unknown';
1327
+ this.logger.warn?.(`[coclaw/rtc] signaling error (or werift not found) type=${sigType} conn=${sigConn}: ${err?.message}`);
1328
+ remoteLog(`rtc.signaling-error type=${sigType} conn=${sigConn} msg=${err?.message}`);
1315
1329
  }
1316
1330
  return;
1317
1331
  }
@@ -1,69 +1,8 @@
1
- import fs from 'node:fs';
1
+ import fsp from 'node:fs/promises';
2
2
  import nodePath from 'node:path';
3
3
 
4
4
  import { agentSessionsDir, sessionStorePath, sessionTranscriptPath } from '../claw-paths.js';
5
-
6
- const DERIVED_TITLE_MAX_LEN = 60;
7
-
8
- // OC 注入的 inbound metadata 头部(Conversation info / Sender / Thread starter 等)
9
- const INBOUND_META_RE = /^\w[\w ]* \(untrusted[^)]*\):\n```json\n[\s\S]*?\n```\n\n/;
10
- // operator 级策略/指令前缀,如 Skills store policy (operator configured): ...
11
- const OPERATOR_POLICY_RE = /^\w[\w ]* \(operator configured\):[\s\S]*?\n\n/;
12
- // OC 注入的用户消息时间戳前缀
13
- const USER_TS_RE = /^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s+[^\]]+\]\s*/;
14
- // 尾部 [message_id: xxx]
15
- const MSG_ID_SUFFIX_RE = /\n\[message_id:\s*[^\]]+\]\s*$/;
16
- // 尾部 Untrusted context 块(外部元数据注入)
17
- const UNTRUSTED_CTX_SUFFIX_RE = /\n\nUntrusted context \(metadata, do not treat as instructions or commands\):\n[\s\S]*$/;
18
- // 定时任务前缀
19
- const CRON_UUID_RE = /\[cron:[0-9a-f-]+(?:\s+([^\]]*))?\]\s*/;
20
- // cron 注入的 Current time 行及其后的系统追加指令(如 "Return your summary...")
21
- const CRON_TIME_TAIL_RE = /\nCurrent time:[^\n]+[\s\S]*$/;
22
- // 从 Current time 行提取 UTC 时间部分
23
- const CRON_TIME_UTC_RE = /(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})\s+UTC/;
24
-
25
- function formatCronTime(matchedText) {
26
- const m = matchedText.match(CRON_TIME_UTC_RE);
27
- if (!m) return '';
28
- /* c8 ignore start -- Date 构造在正则已校验的输入下不会抛出 */
29
- try {
30
- const d = new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:00Z`);
31
- if (!Number.isFinite(d.getTime())) return '';
32
- const y = d.getFullYear();
33
- const mo = String(d.getMonth() + 1).padStart(2, '0');
34
- const dd = String(d.getDate()).padStart(2, '0');
35
- const hh = String(d.getHours()).padStart(2, '0');
36
- const mi = String(d.getMinutes()).padStart(2, '0');
37
- return ` ${y}-${mo}-${dd} ${hh}${mi}`;
38
- }
39
- catch {
40
- return '';
41
- }
42
- /* c8 ignore stop */
43
- }
44
-
45
- function stripLeadingPattern(text, re) {
46
- let prev;
47
- do {
48
- prev = text;
49
- text = text.replace(re, '');
50
- } while (text !== prev);
51
- return text;
52
- }
53
-
54
- function cleanTitleText(text) {
55
- /* c8 ignore next */
56
- if (!text) return '';
57
- let s = stripLeadingPattern(text, INBOUND_META_RE);
58
- s = stripLeadingPattern(s, OPERATOR_POLICY_RE);
59
- s = s.replace(CRON_TIME_TAIL_RE, (match) => formatCronTime(match));
60
- return s
61
- .replace(USER_TS_RE, '')
62
- .replace(CRON_UUID_RE, (_, taskName) => taskName ? `${taskName} ` : '')
63
- .replace(UNTRUSTED_CTX_SUFFIX_RE, '')
64
- .replace(MSG_ID_SUFFIX_RE, '')
65
- .trim();
66
- }
5
+ import { iterTextLines } from '../utils/text-line-stream.js';
67
6
 
68
7
  function toNum(value, fallback) {
69
8
  const n = Number(value);
@@ -77,15 +16,36 @@ function clamp(value, min, max, fallback) {
77
16
  return n;
78
17
  }
79
18
 
80
- function readJsonSafe(filePath, fallback) {
19
+ async function readJsonSafe(filePath, fallback) {
81
20
  try {
82
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
21
+ const text = await fsp.readFile(filePath, 'utf8');
22
+ return JSON.parse(text);
83
23
  }
84
24
  catch {
85
25
  return fallback;
86
26
  }
87
27
  }
88
28
 
29
+ // readdir 与后续 stat 之间存在天然 race window(文件被并发删除/reset 归档)。
30
+ // 统一把 ENOENT/ENOTDIR 视为"目录消失即空目录",其它错误(如 EACCES)按原样上抛。
31
+ async function safeReaddir(dir) {
32
+ try { return await fsp.readdir(dir); }
33
+ catch (err) {
34
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return [];
35
+ /* c8 ignore next 2 -- 非 ENOENT/ENOTDIR 的 fs 错误按原样上抛 */
36
+ throw err;
37
+ }
38
+ }
39
+
40
+ async function safeAccess(filePath) {
41
+ try { await fsp.access(filePath); return true; }
42
+ catch (err) {
43
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return false;
44
+ /* c8 ignore next 2 -- 非 ENOENT/ENOTDIR 的 fs 错误按原样上抛 */
45
+ throw err;
46
+ }
47
+ }
48
+
89
49
  function parseSessionFileName(fileName) {
90
50
  if (typeof fileName !== 'string' || !fileName.includes('.jsonl')) return null;
91
51
  if (fileName.includes('.jsonl.delete.') || fileName.includes('.jsonl.deleted.')) return null;
@@ -118,60 +78,6 @@ function shouldReplaceByPriority(current, next) {
118
78
  return next.updatedAt > current.updatedAt;
119
79
  }
120
80
 
121
- function truncateTitle(text, maxLen = DERIVED_TITLE_MAX_LEN) {
122
- if (text.length <= maxLen) return text;
123
- const cut = text.slice(0, maxLen - 1);
124
- const lastSpace = cut.lastIndexOf(' ');
125
- if (lastSpace > maxLen * 0.6) {
126
- return `${cut.slice(0, lastSpace)}…`;
127
- }
128
- return `${cut}…`;
129
- }
130
-
131
- function extractRawTextFromContent(content) {
132
- if (typeof content === 'string') return content;
133
- /* c8 ignore next */
134
- if (!Array.isArray(content)) return undefined;
135
- for (const part of content) {
136
- if (!part || typeof part !== 'object') continue;
137
- if (part.type !== 'text') continue;
138
- /* c8 ignore next */
139
- if (typeof part.text !== 'string') continue;
140
- if (part.text.trim()) return part.text;
141
- }
142
- return undefined;
143
- }
144
-
145
- function findFirstUserRawText(filePath, logger) {
146
- const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
147
- for (const line of lines) {
148
- if (!line) continue;
149
- try {
150
- const row = JSON.parse(line);
151
- if (row?.type !== 'message') continue;
152
- if (row?.message?.role !== 'user') continue;
153
- const raw = extractRawTextFromContent(row?.message?.content);
154
- if (raw && raw.trim()) return raw;
155
- }
156
- catch (err) {
157
- /* c8 ignore next -- ?./?? fallback */
158
- logger.warn?.(`[session-manager] bad json line skipped when deriving title: ${String(err?.message ?? err)}`);
159
- }
160
- }
161
- return undefined;
162
- }
163
-
164
- function deriveTitle(filePath, logger) {
165
- const rawText = findFirstUserRawText(filePath, logger);
166
- if (!rawText) return undefined;
167
- const cleaned = cleanTitleText(rawText);
168
- if (!cleaned) return undefined;
169
- const normalized = cleaned.replace(/\s+/g, ' ').trim();
170
- /* c8 ignore next */
171
- if (!normalized) return undefined;
172
- return truncateTitle(normalized, DERIVED_TITLE_MAX_LEN);
173
- }
174
-
175
81
  export function createSessionManager(options = {}) {
176
82
  /* c8 ignore next */
177
83
  const logger = options.logger ?? console;
@@ -185,22 +91,22 @@ export function createSessionManager(options = {}) {
185
91
  return resolveSessionsDir(aid);
186
92
  }
187
93
 
188
- function readIndex(agentId = 'main') {
94
+ async function readIndex(agentId = 'main') {
189
95
  /* c8 ignore next */
190
96
  const aid = typeof agentId === 'string' && agentId.trim() ? agentId.trim() : 'main';
191
97
  const file = resolveStorePath(aid);
192
- const data = readJsonSafe(file, {});
98
+ const data = await readJsonSafe(file, {});
193
99
  /* c8 ignore next */
194
100
  if (!data || typeof data !== 'object') return {};
195
101
  return data;
196
102
  }
197
103
 
198
- function listAll(params = {}) {
104
+ async function listAll(params = {}) {
199
105
  const agentId = typeof params.agentId === 'string' && params.agentId.trim() ? params.agentId.trim() : 'main';
200
106
  const limit = clamp(params.limit, 1, 200, 50);
201
107
  const cursor = clamp(params.cursor, 0, Number.MAX_SAFE_INTEGER, 0);
202
108
  const dir = sessionsDir(agentId);
203
- const index = readIndex(agentId);
109
+ const index = await readIndex(agentId);
204
110
  const indexed = new Set(
205
111
  Object.values(index)
206
112
  .map((item) => item?.sessionId)
@@ -214,13 +120,20 @@ export function createSessionManager(options = {}) {
214
120
  }
215
121
  }
216
122
 
217
- const files = fs.existsSync(dir) ? fs.readdirSync(dir) : [];
123
+ const files = await safeReaddir(dir);
218
124
  const grouped = new Map();
219
125
  for (const file of files) {
220
126
  const parsed = parseSessionFileName(file);
221
127
  if (!parsed?.sessionId) continue;
222
128
  const full = nodePath.join(dir, file);
223
- const stat = fs.statSync(full);
129
+ let stat;
130
+ try { stat = await fsp.stat(full); }
131
+ /* c8 ignore start -- readdir→stat race window:文件被并发删除时跳过 */
132
+ catch (err) {
133
+ if (err.code === 'ENOENT') continue;
134
+ throw err;
135
+ }
136
+ /* c8 ignore stop */
224
137
  const row = {
225
138
  sessionId: parsed.sessionId,
226
139
  sessionKey: sessionKeyById.get(parsed.sessionId) ?? null,
@@ -256,20 +169,7 @@ export function createSessionManager(options = {}) {
256
169
  const rows = Array.from(grouped.values());
257
170
  rows.sort((a, b) => b.updatedAt - a.updatedAt);
258
171
 
259
- const items = rows.slice(cursor, cursor + limit).map((row) => {
260
- if (!row.fileName) {
261
- return { ...row };
262
- }
263
- const transcriptPath = nodePath.join(dir, row.fileName);
264
- const derivedTitle = deriveTitle(transcriptPath, logger);
265
- if (!derivedTitle) {
266
- return { ...row };
267
- }
268
- return {
269
- ...row,
270
- derivedTitle,
271
- };
272
- });
172
+ const items = rows.slice(cursor, cursor + limit).map((row) => ({ ...row }));
273
173
  const nextCursor = cursor + limit < rows.length ? String(cursor + limit) : null;
274
174
  return {
275
175
  agentId,
@@ -280,53 +180,74 @@ export function createSessionManager(options = {}) {
280
180
  };
281
181
  }
282
182
 
283
- function resolveTranscriptFile(agentId, sessionId) {
183
+ async function resolveTranscriptFile(agentId, sessionId) {
284
184
  const dir = sessionsDir(agentId);
285
185
  // live 文件优先:同一 sessionId 可能同时存在 live 和 reset 文件
286
186
  // (OpenClaw reset 后复用 sessionId),live 代表当前活跃 transcript
287
187
  const livePath = resolveTranscriptPath(sessionId, agentId);
288
- if (fs.existsSync(livePath)) {
289
- return livePath;
290
- }
291
- const files = fs.existsSync(dir) ? fs.readdirSync(dir) : [];
188
+ if (await safeAccess(livePath)) return livePath;
189
+
190
+ const files = await safeReaddir(dir);
292
191
  const resetPrefix = `${sessionId}.jsonl.reset.`;
293
- const resetCandidates = files
294
- .filter((name) => name.startsWith(resetPrefix))
295
- .map((name) => {
296
- const full = nodePath.join(dir, name);
297
- const stat = fs.statSync(full);
298
- return {
299
- path: full,
300
- archiveStamp: name.slice(resetPrefix.length),
301
- updatedAt: stat.mtimeMs,
302
- };
303
- })
304
- .sort((a, b) => {
305
- if (a.archiveStamp !== b.archiveStamp) {
306
- return b.archiveStamp.localeCompare(a.archiveStamp);
307
- }
308
- /* c8 ignore next -- 同一 sessionId 的 reset 文件不会有相同 archiveStamp */
309
- return b.updatedAt - a.updatedAt;
192
+ const resetCandidates = [];
193
+ for (const name of files) {
194
+ if (!name.startsWith(resetPrefix)) continue;
195
+ const full = nodePath.join(dir, name);
196
+ let stat;
197
+ try { stat = await fsp.stat(full); }
198
+ /* c8 ignore start -- readdir→stat race window */
199
+ catch (err) {
200
+ if (err.code === 'ENOENT') continue;
201
+ throw err;
202
+ }
203
+ /* c8 ignore stop */
204
+ resetCandidates.push({
205
+ path: full,
206
+ archiveStamp: name.slice(resetPrefix.length),
207
+ updatedAt: stat.mtimeMs,
310
208
  });
209
+ }
210
+ resetCandidates.sort((a, b) => {
211
+ if (a.archiveStamp !== b.archiveStamp) {
212
+ return b.archiveStamp.localeCompare(a.archiveStamp);
213
+ }
214
+ /* c8 ignore next -- 同一 sessionId 的 reset 文件不会有相同 archiveStamp */
215
+ return b.updatedAt - a.updatedAt;
216
+ });
311
217
  if (resetCandidates.length > 0) {
312
218
  return resetCandidates[0].path;
313
219
  }
314
220
  return null;
315
221
  }
316
222
 
317
- function get(params = {}) {
223
+ // transcript 全文;不存在视为空字符串。返回原始文本,由调用方走 iterTextLines
224
+ // 流式扫描 + 解析,避免大文件 split 一次性卡 event loop。
225
+ async function readTranscriptText(file) {
226
+ try {
227
+ return await fsp.readFile(file, 'utf8');
228
+ }
229
+ /* c8 ignore start -- resolveTranscriptFile→readFile race window */
230
+ catch (err) {
231
+ if (err.code === 'ENOENT') return '';
232
+ throw err;
233
+ }
234
+ /* c8 ignore stop */
235
+ }
236
+
237
+ async function get(params = {}) {
318
238
  const agentId = typeof params.agentId === 'string' && params.agentId.trim() ? params.agentId.trim() : 'main';
319
239
  const sessionId = typeof params.sessionId === 'string' ? params.sessionId.trim() : '';
320
240
  if (!sessionId) throw new Error('sessionId required');
321
241
  const limit = clamp(params.limit, 1, 500, 100);
322
242
  const cursor = clamp(params.cursor, 0, Number.MAX_SAFE_INTEGER, 0);
323
- const file = resolveTranscriptFile(agentId, sessionId);
243
+ const file = await resolveTranscriptFile(agentId, sessionId);
324
244
  if (!file) {
325
245
  return { agentId, sessionId, total: 0, cursor: String(cursor), nextCursor: null, messages: [] };
326
246
  }
327
247
 
248
+ const text = await readTranscriptText(file);
328
249
  const all = [];
329
- for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/).filter(Boolean)) {
250
+ for await (const line of iterTextLines(text)) {
330
251
  try {
331
252
  all.push(JSON.parse(line));
332
253
  }
@@ -352,20 +273,21 @@ export function createSessionManager(options = {}) {
352
273
  * 按 sessionId 获取消息,返回完整 JSONL 行级结构。
353
274
  * 只返回 type==="message" 且有合法 message.role 的行。
354
275
  * @param {{ sessionId: string, agentId?: string, limit?: number }} params
355
- * @returns {{ messages: object[] }}
276
+ * @returns {Promise<{ messages: object[] }>}
356
277
  */
357
- function getById(params = {}) {
278
+ async function getById(params = {}) {
358
279
  const agentId = typeof params.agentId === 'string' && params.agentId.trim() ? params.agentId.trim() : 'main';
359
280
  const sessionId = typeof params.sessionId === 'string' ? params.sessionId.trim() : '';
360
281
  if (!sessionId) throw new Error('sessionId required');
361
282
  const limit = clamp(params.limit, 1, 500, 500);
362
- const file = resolveTranscriptFile(agentId, sessionId);
283
+ const file = await resolveTranscriptFile(agentId, sessionId);
363
284
  if (!file) {
364
285
  return { messages: [] };
365
286
  }
366
287
 
288
+ const text = await readTranscriptText(file);
367
289
  const messages = [];
368
- for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/).filter(Boolean)) {
290
+ for await (const line of iterTextLines(text)) {
369
291
  try {
370
292
  const row = JSON.parse(line);
371
293
  if (row?.type !== 'message') continue;
@@ -0,0 +1,60 @@
1
+ export const DEFAULT_YIELD_EVERY = 100;
2
+
3
+ /**
4
+ * 流式扫描大字符串的行,分批让出 event loop。
5
+ *
6
+ * 不预先 split——按 \n 游标推进,避免大字符串一次性 split 卡主线程。
7
+ *
8
+ * 行终止与 split(/\r?\n/) 的语义差异:
9
+ * - 把 \n 视为"行终止符"而非"分隔符"。末尾 \n 之后**不产生**额外空段,即
10
+ * `'foo\n'` 仅产 `['foo']`(split 会得 `['foo','']`)。`skipEmpty: false`
11
+ * 下亦如此——空段是"两个分隔符之间没字符",末尾 \n 之后没有内容也没有下
12
+ * 一个分隔符,所以本来就不应该产空段。
13
+ * - 仅剥紧贴 \n 之前的 \r(CRLF 折行)。**孤立的 \r**(行内或末尾段无 \n)
14
+ * 按字面保留,与 split(/\r?\n/) 一致。
15
+ *
16
+ * 空行(连续 \n\n 之间的空段)按 skipEmpty 选项过滤,默认跳过。
17
+ *
18
+ * 让出策略:每处理 yieldEvery 行 await 一次 setImmediate,让 I/O 回调(如 RTC
19
+ * 数据通道帧、其它 RPC handler)有机会插入。Node 中首选 setImmediate 而非
20
+ * setTimeout(0):后者最小延迟被钳制到 1ms,让出净开销显著更大。
21
+ *
22
+ * @param {string} text - 待扫描文本
23
+ * @param {{ yieldEvery?: number, skipEmpty?: boolean }} [opts]
24
+ * @returns {AsyncGenerator<string>}
25
+ */
26
+ export async function* iterTextLines(text, opts = {}) {
27
+ if (typeof text !== 'string' || text.length === 0) return;
28
+ const yieldEvery = Number.isFinite(opts.yieldEvery) && opts.yieldEvery > 0
29
+ ? Math.trunc(opts.yieldEvery)
30
+ : DEFAULT_YIELD_EVERY;
31
+ const skipEmpty = opts.skipEmpty !== false;
32
+
33
+ const len = text.length;
34
+ let start = 0;
35
+ let count = 0;
36
+
37
+ while (start < len) {
38
+ const lfIdx = text.indexOf('\n', start);
39
+ const terminatedByLf = lfIdx !== -1;
40
+ const end = terminatedByLf ? lfIdx : len;
41
+
42
+ // 仅当本段确实由 LF 终止时才剥末尾 \r(CRLF)。
43
+ // 末尾段(无 LF)保留原文,与 split(/\r?\n/) 行为一致——
44
+ // 否则 'a\r' 会被剥成 'a' 静默丢失,违反"行为等价"约定。
45
+ let lineEnd = end;
46
+ if (terminatedByLf && lineEnd > start && text.charCodeAt(lineEnd - 1) === 13) {
47
+ lineEnd--;
48
+ }
49
+
50
+ if (!skipEmpty || lineEnd > start) {
51
+ yield text.slice(start, lineEnd);
52
+ }
53
+
54
+ start = end + 1;
55
+
56
+ if (++count % yieldEvery === 0) {
57
+ await new Promise((resolve) => setImmediate(resolve));
58
+ }
59
+ }
60
+ }
@@ -69,12 +69,121 @@ export class WebRtcPeer {
69
69
  // 队列实现选择:测试可显式覆盖;非 'fbq'/'mem' 一律收编为模块默认,避免误用
70
70
  this.__rpcQueueImpl = (rpcQueueImpl === 'fbq' || rpcQueueImpl === 'mem') ? rpcQueueImpl : RPC_QUEUE_IMPL;
71
71
  this.__rtcTag = impl ? `[coclaw/rtc:${impl}]` : '[coclaw/rtc]';
72
- /** @type {Map<string, { pc: object, rpcChannel: object|null, rpcQueue: MemoryQueue|null, rpcDcSender: RpcDcSender|null, rpcConsumeLoop: Promise<void>|null, rpcDropMonitor: object|null, fileChannels: Set, remoteMaxMessageSize: number, nextMsgId: number }>} */
72
+ /** @type {Map<string, { pc: object, connId: string, rpcChannel: object|null, rpcQueue: MemoryQueue|null, rpcDcSender: RpcDcSender|null, rpcConsumeLoop: Promise<void>|null, rpcDropMonitor: object|null, fileChannels: Set, remoteMaxMessageSize: number, nextMsgId: number }>} */
73
73
  this.__sessions = new Map();
74
+ // per-connId 信令串行化队列:把外线 ws 信令处理改成"按 connId 维护 FIFO 队列",
75
+ // 消息按到达顺序串行处理。替换原 offerMutex 方案——后者的 await prev 微任务跳板
76
+ // 会让 setRemoteDescription 的 IPC 字节序被推到紧随而至的 ICE 候选之后,导致冷
77
+ // 启动时部分 ICE 候选撞"remote description is not set"被 pion 丢弃。
78
+ //
79
+ // 队列与 sessions 生命周期完全解耦:closeByConnId / closeAll / failed-TTL 等 session
80
+ // 清理路径不动 __signalingQueues;唯一删除路径是 drain 跑空后自删 entry,下条同
81
+ // connId 消息进来再按需重建。
82
+ /** @type {Map<string, { queue: Array<{ msg: object, doneCb: () => void }>, running: boolean }>} */
83
+ this.__signalingQueues = new Map();
84
+ // 关停门禁:closeAll 入口立即置 true,后续 drain / __handleOffer 在 await 边界看到
85
+ // __stopping 即放弃后续动作,避免在 plugin 关停期间继续新建/响应 PC。
86
+ this.__stopping = false;
74
87
  }
75
88
 
76
- /** 处理来自 Server 转发的信令消息 */
89
+ /**
90
+ * 处理来自 Server 转发的信令消息。
91
+ *
92
+ * 入队即调度,每条消息附带 doneCb;caller `await handleSignaling(msg)` 会等本条 msg
93
+ * 真正 drain 完毕(含错误被 drain 内 catch 转 remoteLog 的情形)才回。错误不再冒到
94
+ * 调用方——`rtc.signaling-error` remoteLog 是单一来源,由 drain 的 per-item catch 输出。
95
+ *
96
+ * 同 connId 的多条消息严格 FIFO;不同 connId 互不阻塞(每个 connId 自己一条 drain
97
+ * 微任务链)。冷启动 1 offer + 5 ICE 并发投递时,drain 保证 SRD 完整跑完后再发 ICE
98
+ * 的 addIceCandidate,避免 pion 端"remote description is not set"丢候选。
99
+ */
77
100
  async handleSignaling(msg) {
101
+ const connId = msg?.fromConnId ?? msg?.toConnId;
102
+ if (!connId) return;
103
+ return new Promise((resolve) => {
104
+ this.__enqueueSignaling(connId, msg, resolve);
105
+ });
106
+ }
107
+
108
+ __enqueueSignaling(connId, msg, doneCb) {
109
+ let state = this.__signalingQueues.get(connId);
110
+ if (!state) {
111
+ state = { queue: [], running: false };
112
+ this.__signalingQueues.set(connId, state);
113
+ }
114
+ state.queue.push({ msg, doneCb });
115
+ // fire-and-forget 触发 drain;running flag 保证只有第一条入队的 caller 启动真实 drain,
116
+ // 后续入队的 caller 走 push + 由当前 drain 顺序消化。drain 自身用 try/finally 兜底,
117
+ // per-item 诊断调用也都包了 try/catch;这里再加一层 .catch() 兜 unhandled rejection
118
+ // 的极冷防御——CoClaw plugin 强约束要求"不许带垮 gateway"。
119
+ this.__drainSignaling(connId, state).catch((err) => {
120
+ /* c8 ignore start -- drain 内部 try/finally + per-item try/catch 已覆盖;此 catch 仅极冷防御 */
121
+ try {
122
+ this.logger.error?.(`${this.__rtcTag} __drainSignaling unhandled error conn=${connId}: ${err?.message ?? err}`);
123
+ } catch {
124
+ /* swallow */
125
+ }
126
+ /* c8 ignore stop */
127
+ });
128
+ }
129
+
130
+ async __drainSignaling(connId, state) {
131
+ if (state.running) return;
132
+ // 同步置位:入口到首次 await 之间不能让步,否则同 tick 内多次 enqueue 会双开 drain。
133
+ state.running = true;
134
+ try {
135
+ while (state.queue.length > 0) {
136
+ // 关停门禁:closeAll 已置位时,剩余信令全部丢弃但仍 fire 每条 doneCb,
137
+ // 防止 caller `await handleSignaling` 悬挂(队列在 finally 内删除 entry)。
138
+ if (this.__stopping) {
139
+ for (const item of state.queue) {
140
+ /* c8 ignore next 2 -- doneCb 是构造时塞入的 Promise resolver,理论上不抛;try 仅极冷防御 */
141
+ try { item.doneCb?.(); } catch { /* swallow */ }
142
+ }
143
+ state.queue.length = 0;
144
+ break;
145
+ }
146
+ const item = state.queue.shift();
147
+ try {
148
+ await this.__handleSignalingMsg(item.msg);
149
+ } catch (err) {
150
+ // 单条信令出错不打断 drain;与 realtime-bridge 原 outer catch 的诊断格式对齐。
151
+ // 诊断调用各自 try/catch:若 logger / remoteLog 自身抛错(极冷),不能让异常
152
+ // 冒出 per-item catch 阻断后续 doneCb fire(CoClaw plugin "不许带垮 gateway"
153
+ // 强约束 + 防止 caller `await handleSignaling` 永挂)。
154
+ const t = item.msg?.type ?? 'unknown';
155
+ /* c8 ignore next 2 -- 诊断 wrap 是极冷防御,注入的 logger 不抛 */
156
+ try { this.logger.warn?.(`${this.__rtcTag} signaling error type=${t} conn=${connId}: ${err?.message}`); } catch { /* swallow */ }
157
+ /* c8 ignore next 2 -- 同上 */
158
+ try { this.__remoteLog(`rtc.signaling-error type=${t} conn=${connId} msg=${err?.message}`); } catch { /* swallow */ }
159
+ } finally {
160
+ // 始终 fire doneCb(即便 await 抛错或 logger 自身抛错),caller 不悬挂。
161
+ /* c8 ignore next 2 -- 同上,doneCb 是 Promise resolver;try 仅极冷防御 */
162
+ try { item.doneCb?.(); } catch { /* swallow */ }
163
+ }
164
+ }
165
+ } finally {
166
+ state.running = false;
167
+ if (state.queue.length === 0) {
168
+ // 同 connId 旧 entry 可能在 drain 期间被替换;仅当 map 仍指向本 state 时才删,
169
+ // 避免误删新 entry。
170
+ if (this.__signalingQueues.get(connId) === state) {
171
+ this.__signalingQueues.delete(connId);
172
+ }
173
+ /* c8 ignore start -- 防御性兜底;正常控制流 while 退出时 queue.length 必为 0 */
174
+ } else {
175
+ // 契约违反:while 条件按 length>0 进,__stopping 分支 break 前会 length=0,
176
+ // 正常退出意味着 queue 已空——理论不可达。不主动重启 drain(running 已落 false,
177
+ // 下条 enqueue 自然 kick off 消化残留),保留 entry 以便后续 drain 继续处理。
178
+ this.logger.error?.(`${this.__rtcTag} __drainSignaling exited with non-empty queue conn=${connId} len=${state.queue.length}`);
179
+ this.__remoteLog(`drain.contract-violation conn=${connId} len=${state.queue.length}`);
180
+ }
181
+ /* c8 ignore stop */
182
+ }
183
+ }
184
+
185
+ /** 单条信令的 type→handler 分发(drain 内部调用,外部测试可直接复用 handleSignaling) */
186
+ async __handleSignalingMsg(msg) {
78
187
  const connId = msg.fromConnId ?? msg.toConnId;
79
188
  if (msg.type === 'rtc:offer') {
80
189
  await this.__handleOffer(msg);
@@ -83,20 +192,60 @@ export class WebRtcPeer {
83
192
  } else if (msg.type === 'rtc:ready' || msg.type === 'rtc:closed') {
84
193
  this.__logDebug(`${msg.type} from ${connId}`);
85
194
  if (msg.type === 'rtc:closed') {
86
- await this.closeByConnId(connId);
195
+ const session = this.__sessions.get(connId);
196
+ if (session) await this.closeByConnId(connId, session);
87
197
  }
88
198
  }
89
199
  }
90
200
 
91
- /** 关闭指定 connId 的 PeerConnection */
92
- async closeByConnId(connId) {
93
- const session = this.__sessions.get(connId);
94
- if (!session) return;
95
- // detach 所有 PC 事件,再做后续 await 链。两个目的:
96
- // 1. 防止 pc.close() 异步触发 onconnectionstatechange 误删 connId 复用后的新 session
97
- // 2. RPC 清理含 await(queue.destroy + consumeLoop),期间若旧 PC 还有滞后回调
98
- // (某些 WebRTC 实现 close 后仍投递事件),可能通过 Map.get(connId) 拿到新 session 误操作
99
- // 特别是 ondatachannel:晚到的 channel 会被 __setupDataChannel 装到新 session
201
+ /**
202
+ * 关闭指定 session:身份守卫确保 connId 仍指向 expectedSession 时才动手,
203
+ * 否则 no-op(session 已被同步段五件事原子替换 / 旧 close 已完成 / 等)。
204
+ * @param {string} connId
205
+ * @param {object} expectedSession - 调用方持有的 session 引用;nullish 视为 no-op。
206
+ */
207
+ async closeByConnId(connId, expectedSession) {
208
+ if (!expectedSession) return;
209
+ if (this.__sessions.get(connId) !== expectedSession) return;
210
+ const session = expectedSession;
211
+ // 同步先 detach handler + clear timer,再 delete map,最后才进入 fire-and-forget 兼容的
212
+ // 异步收尾(sender/queue/pc.close)。这个顺序保证:(1) pc.close 期间的滞后回调不会
213
+ // 误改新 session;(2) sessions.get(connId) 在删表后立即返回 undefined,并发 offer 走
214
+ // 同步段五件事路径而非误以为"close 还在跑"。
215
+ this.__detachPcHandlers(session);
216
+ this.__clearSessionSyncState(session);
217
+ this.__sessions.delete(connId);
218
+ await this.__finalizeSessionAsync(session);
219
+ this.__remoteLog(`rtc.closed conn=${connId}`);
220
+ this.logger.info?.(`${this.__rtcTag} [${connId}] closed`);
221
+ }
222
+
223
+ /**
224
+ * 关闭所有 PeerConnection。每轮快照表内当前所有 session 并发关闭,
225
+ * 表非空就继续 drain——若 closeAll await Promise.all 期间另一条 message handler
226
+ * 已过 sock guard 并落地新 session(auth-close / stop 窄缝),下一轮把它收掉,
227
+ * 结构性消除"快照漏掉新 session"的 race,不必依赖 12h failed-TTL 兜底。
228
+ *
229
+ * 终止条件:调用方(realtime-bridge auth-close / stop)已先关 server WS,
230
+ * 不会再有新 rtc:offer 涌入,drain 至多一两轮就达表空。
231
+ */
232
+ async closeAll() {
233
+ // 关停门禁:drain 与 __handleOffer 看到 __stopping 后立刻放弃后续动作,
234
+ // 配合 while-drain 兜底"snapshot 漏掉新 session"竞态。__stopping 一旦置 true
235
+ // 不再重置——本插件 closeAll 调用方(auth-close / 析构)均紧跟 webrtcPeer = null,
236
+ // 下次复用会建新实例从 false 起。
237
+ this.__stopping = true;
238
+ while (this.__sessions.size > 0) {
239
+ const closing = [...this.__sessions.values()].map((s) => this.closeByConnId(s.connId, s));
240
+ await Promise.all(closing);
241
+ }
242
+ }
243
+
244
+ /**
245
+ * 同步 detach PC 上 6 个 handler。防止 pc.close 异步触发的滞后回调
246
+ * 通过 sessions.get(connId) 误访问已被替换/正在替换的新 session。
247
+ */
248
+ __detachPcHandlers(session) {
100
249
  session.pc.onconnectionstatechange = null;
101
250
  session.pc.onicecandidate = null;
102
251
  session.pc.ondatachannel = null;
@@ -109,13 +258,14 @@ export class WebRtcPeer {
109
258
  if ('onicegatheringstatechange' in session.pc) {
110
259
  session.pc.onicegatheringstatechange = null;
111
260
  }
112
- // 清理 failed TTL 定时器
261
+ }
262
+
263
+ /** 同步清 session 上的 timer 与 in-flight 标志,避免 close 后还在 fire 副作用。 */
264
+ __clearSessionSyncState(session) {
113
265
  if (session.__failedTimer) {
114
266
  clearTimeout(session.__failedTimer);
115
267
  session.__failedTimer = null;
116
268
  }
117
- // 清理 plugin-probe 定时器(避免 session 已关闭仍触发 timeout 日志,
118
- // 或 500ms 调度窗口内 session 被替换时对着新 session 误发探针)
119
269
  if (session.__pluginProbeSchedTimer) {
120
270
  clearTimeout(session.__pluginProbeSchedTimer);
121
271
  session.__pluginProbeSchedTimer = null;
@@ -125,11 +275,15 @@ export class WebRtcPeer {
125
275
  session.__pluginProbeTimer = null;
126
276
  session.__pluginProbeInFlight = null;
127
277
  }
128
- this.__sessions.delete(connId);
129
- // 显式关闭 rpc 链路:dc.onclose 路径中 `sessions.get(connId)` 已返回 undefined 而短路,
130
- // 此处不主动 close 会丢失 drop 汇总 remoteLog 诊断 + consumeLoop 泄漏。
131
- // summarize destroy onBeforeClear 钩子在 mutex 内拿原子快照——同步读 stats 看不到
132
- // 还在 mutex 队列里的 in-flight enqueue(broadcast fire-and-forget,会与 close 并发)。
278
+ }
279
+
280
+ /**
281
+ * 异步收尾:rpc 链路 destroy + monitor summarize + pc.close。可作为 fire-and-forget
282
+ * (同步段非 ICE 重发替换旧 session 时),也作为 closeByConnId await 末段。
283
+ * 调用前必须已完成 __detachPcHandlers + __clearSessionSyncState + sessions.delete,否则
284
+ * 滞后回调可能踩新 session。
285
+ */
286
+ async __finalizeSessionAsync(session) {
133
287
  if (session.rpcDcSender || session.rpcQueue) {
134
288
  session.rpcDcSender?.close();
135
289
  const monRef = session.rpcDropMonitor;
@@ -142,27 +296,19 @@ export class WebRtcPeer {
142
296
  session.rpcChannel = null;
143
297
  }
144
298
  await session.pc.close();
145
- this.__remoteLog(`rtc.closed conn=${connId}`);
146
- this.logger.info?.(`${this.__rtcTag} [${connId}] closed`);
147
- }
148
-
149
- /** 关闭所有 PeerConnection */
150
- async closeAll() {
151
- const closing = [...this.__sessions.keys()].map((id) => this.closeByConnId(id));
152
- await Promise.all(closing);
153
299
  }
154
300
 
155
- /** 向所有已打开的 rpcChannel 广播(大消息自动分片,经由 FBQ/MemoryQueue + RpcDcSender 流控) */
156
- broadcast(payload) {
157
- let jsonStr;
158
- try {
159
- jsonStr = JSON.stringify(payload);
160
- } catch (err) {
161
- // 循环引用 / BigInt 等导致 stringify 抛——记日志后整条丢弃,不冒到 gateway
162
- this.__logDebug(`broadcast stringify failed: ${err?.message}`);
163
- return;
164
- }
165
- if (typeof jsonStr !== 'string') return; // payload 是 undefined/symbol 时 stringify 返回 undefined
301
+ /**
302
+ * 向所有已打开的 rpcChannel 广播(大消息自动分片,经由 FBQ/MemoryQueue + RpcDcSender 流控)。
303
+ * @param {object} payload - 完整的 JSON 帧
304
+ * @param {string} [rawStr] - 已序列化字符串旁路:非空字符串且不含字面 \n/\r 时直接透传,
305
+ * 跳过 stringify;含换行或类型不符时回退到 JSON.stringify(payload)
306
+ * 用于 gateway WS → DC 转发链路,省掉对大 payload(如 agent.run.event
307
+ * 含大 tool_result / compaction summary)的主线程重复序列化阻塞。
308
+ */
309
+ broadcast(payload, rawStr) {
310
+ const jsonStr = this.__resolveJsonStr(payload, rawStr, 'broadcast');
311
+ if (jsonStr === null) return;
166
312
  for (const session of this.__sessions.values()) {
167
313
  const q = session.rpcQueue;
168
314
  if (q && session.rpcChannel?.readyState === 'open') {
@@ -185,21 +331,16 @@ export class WebRtcPeer {
185
331
  *
186
332
  * @param {string} connId
187
333
  * @param {object} payload - 完整的 JSON 帧(通常是 { type: 'event', event, payload })
334
+ * @param {string} [rawStr] - 已序列化字符串旁路:语义同 broadcast 的 rawStr 参数
188
335
  * @returns {Promise<boolean>} true=已入队发送;false=session 不存在 / DC 未 open / payload 不可序列化 / 发送队列拒收
189
336
  */
190
- async sendTo(connId, payload) {
337
+ async sendTo(connId, payload, rawStr) {
191
338
  const session = this.__sessions.get(connId);
192
339
  if (!session) return false;
193
340
  const q = session.rpcQueue;
194
341
  if (!q || session.rpcChannel?.readyState !== 'open') return false;
195
- let jsonStr;
196
- try {
197
- jsonStr = JSON.stringify(payload);
198
- } catch (err) {
199
- this.__logDebug(`[${connId}] sendTo stringify failed: ${err?.message}`);
200
- return false;
201
- }
202
- if (typeof jsonStr !== 'string') return false;
342
+ const jsonStr = this.__resolveJsonStr(payload, rawStr, `[${connId}] sendTo`);
343
+ if (jsonStr === null) return false;
203
344
  try {
204
345
  return await q.enqueue(jsonStr);
205
346
  } catch (err) {
@@ -209,71 +350,53 @@ export class WebRtcPeer {
209
350
  }
210
351
  }
211
352
 
353
+ /**
354
+ * 把 (payload, rawStr) 归一为可入队的 JSON 字符串。
355
+ * 直通条件:rawStr 为非空字符串且不含字面 \n/\r。
356
+ * 回退路径:含换行或类型不符时走 JSON.stringify(payload);并对 stringify 抛 / 返非字符串做防御。
357
+ *
358
+ * 为何排除字面换行:rpcQueue 的 FBQ 实现在溢出到磁盘时按 JSONL 格式(rec + '\n')追加,
359
+ * 用 readline 回填——若入队字符串内含字面 \n/\r 会切坏 spill 文件。旧 stringify 路径输出
360
+ * 紧凑无换行天然满足;新 raw 直通必须保持同等强度的入队不变量。
361
+ *
362
+ * @param {object} payload
363
+ * @param {string} [rawStr]
364
+ * @param {string} tag - 日志前缀(broadcast / [connId] sendTo)
365
+ * @returns {string | null} 可入队的 JSON 字符串;null 表示丢弃(stringify 抛 / 返非字符串)
366
+ */
367
+ __resolveJsonStr(payload, rawStr, tag) {
368
+ if (typeof rawStr === 'string' && rawStr.length > 0) {
369
+ if (!rawStr.includes('\n') && !rawStr.includes('\r')) {
370
+ return rawStr;
371
+ }
372
+ // 含换行 → 走 stringify 重做归一化(保 FBQ JSONL 行约束)
373
+ this.__logDebug(`${tag} rawStr fallback: contains newline`);
374
+ }
375
+ try {
376
+ const jsonStr = JSON.stringify(payload);
377
+ // payload 是 undefined/symbol 时 stringify 返回 undefined
378
+ if (typeof jsonStr !== 'string') return null;
379
+ return jsonStr;
380
+ } catch (err) {
381
+ // 循环引用 / BigInt 等导致 stringify 抛——记日志后整条丢弃,不冒到 gateway
382
+ this.__logDebug(`${tag} stringify failed: ${err?.message}`);
383
+ return null;
384
+ }
385
+ }
386
+
212
387
  async __handleOffer(msg) {
388
+ // 关停门禁:closeAll 已置 __stopping 后任何 offer 都不再创建 session 或回 answer,
389
+ // 与 drain 顶端的 __stopping 检查互为冗余(drain 抢先一步,但 __handleOffer 入口
390
+ // 这条是"今天每条无害不代表明天加副作用还无害"的稳健补丁)。
391
+ if (this.__stopping) return;
392
+
213
393
  const connId = msg.fromConnId;
214
394
  const isIceRestart = !!msg.payload?.iceRestart;
215
- const credRemain = this.__credRemainSec(msg.turnCreds);
216
- const credRemainStr = credRemain ?? 'none';
217
395
 
218
- // ICE restart:在现有 PC 上重新协商,保持 DTLS session
219
- if (isIceRestart) {
220
- const existing = this.__sessions.get(connId);
221
- if (existing) {
222
- // 仅已验证支持 ICE restart 的 impl 放行,其余立即 reject 让 UI 走 rebuild
223
- if (this.__impl !== 'pion') {
224
- this.__remoteLog(`rtc.ice-restart-unsupported conn=${connId} impl=${this.__impl} credRemain=${credRemainStr}`);
225
- this.logger.info?.(`${this.__rtcTag} ICE restart rejected: impl=${this.__impl} not verified`);
226
- this.__onSend({
227
- type: 'rtc:restart-rejected',
228
- toConnId: connId,
229
- payload: { reason: 'impl_unsupported' },
230
- });
231
- return; // TTL timer 保持不变(reject 是同步的,不影响 timer 正常工作)
232
- }
233
- // 暂停 failed TTL timer:pion restart 涉及异步协商,期间不应被回收
234
- if (existing.__failedTimer) {
235
- clearTimeout(existing.__failedTimer);
236
- existing.__failedTimer = null;
237
- }
238
- this.__remoteLog(`rtc.ice-restart conn=${connId} credRemain=${credRemainStr}`);
239
- this.logger.info?.(`${this.__rtcTag} ICE restart offer from ${connId}, renegotiating`);
240
- try {
241
- await existing.pc.setRemoteDescription({ type: 'offer', sdp: msg.payload.sdp });
242
- // 重协商 SDP 可能变更 a=max-message-size,同步刷新 sender 分片阈值;
243
- // queue 存的是完整字符串(buildChunks 在 sender.send 内同步完成),
244
- // 已开始分片的当前消息用旧 size,下一条消息用新 size
245
- const newMMS = this.__resolveMaxMessageSize(existing.pc, msg.payload.sdp);
246
- if (newMMS !== existing.remoteMaxMessageSize) {
247
- existing.remoteMaxMessageSize = newMMS;
248
- if (existing.rpcDcSender) existing.rpcDcSender.maxMessageSize = newMMS;
249
- }
250
- const answer = await existing.pc.createAnswer();
251
- await existing.pc.setLocalDescription(answer);
252
- this.__onSend({
253
- type: 'rtc:answer',
254
- toConnId: connId,
255
- payload: { sdp: answer.sdp },
256
- });
257
- this.__remoteLog(`rtc.restart-answer-sent conn=${connId}`);
258
- this.logger.info?.(`${this.__rtcTag} ICE restart answer sent to ${connId}`);
259
- return;
260
- } catch (err) {
261
- // ICE restart 协商失败 → reject,不 fall through
262
- this.__remoteLog(`rtc.ice-restart-failed conn=${connId} credRemain=${credRemainStr}`);
263
- this.logger.warn?.(`${this.__rtcTag} ICE restart failed for ${connId}: ${err?.message}`);
264
- this.__onSend({
265
- type: 'rtc:restart-rejected',
266
- toConnId: connId,
267
- payload: { reason: 'restart_failed' },
268
- });
269
- await this.closeByConnId(connId).catch((closeErr) => {
270
- /* c8 ignore next -- closeByConnId 内部已 try/catch,此路径极难触发 */
271
- this.logger.warn?.(`${this.__rtcTag} closeByConnId failed after restart rejection for ${connId}: ${closeErr?.message}`);
272
- });
273
- return;
274
- }
275
- }
276
- // 无 session → reject(plugin 可能已重启)
396
+ // no-session ICE restart:立即 reject,不建 session
397
+ if (isIceRestart && !this.__sessions.has(connId)) {
398
+ const credRemain = this.__credRemainSec(msg.turnCreds);
399
+ const credRemainStr = credRemain ?? 'none';
277
400
  this.__remoteLog(`rtc.ice-restart-no-session conn=${connId} credRemain=${credRemainStr}`);
278
401
  this.logger.warn?.(`${this.__rtcTag} ICE restart from ${connId} but no session, rejecting`);
279
402
  this.__onSend({
@@ -284,19 +407,175 @@ export class WebRtcPeer {
284
407
  return;
285
408
  }
286
409
 
287
- this.__remoteLog(`rtc.offer conn=${connId}`);
288
- this.logger.info?.(`${this.__rtcTag} offer received from ${connId}, creating answer`);
410
+ let session;
411
+ if (isIceRestart) {
412
+ // ICE restart:复用现有 session
413
+ session = this.__sessions.get(connId);
414
+ } else {
415
+ // 非 ICE:必要时同步替换旧 session。五件事原子(JS 单线程保证不与并发 offer 交错):
416
+ // (a) detach 旧 PC handler (b) clear 旧 session 同步状态 (c) sessions.delete
417
+ // (d) 触发 fire-and-forget 异步收尾(闭包持旧 session 引用,不按 connId 查表)
418
+ // (e) 建新 session + wire handler + sessions.set —— 由 __createSession 完成
419
+ const oldSession = this.__sessions.get(connId);
420
+ if (oldSession) {
421
+ this.__detachPcHandlers(oldSession);
422
+ this.__clearSessionSyncState(oldSession);
423
+ this.__sessions.delete(connId);
424
+ this.__finalizeSessionAsync(oldSession).catch((err) => {
425
+ /* c8 ignore next 2 -- finalize 内 pc.close 在生产路径稳定;防 unhandled rejection */
426
+ this.logger.warn?.(`${this.__rtcTag} [${connId}] background finalize failed: ${err?.message ?? err}`);
427
+ });
428
+ this.__remoteLog(`rtc.closed conn=${connId}`);
429
+ this.logger.info?.(`${this.__rtcTag} [${connId}] closed`);
430
+ }
431
+ if (this.__sessions.size >= MAX_SESSIONS) {
432
+ this.__evictOldestFailed();
433
+ }
434
+ session = this.__createSession(msg, connId);
435
+ }
436
+
437
+ // SDP 协商:原 __handleOfferLocked 主体直接嵌入,删除 offerMutex.withLock 包裹。
438
+ // per-connId FIFO drain 已保证同 connId 信令串行;mutex.withLock 的 await prev
439
+ // microtask 让步即是冷启动 ICE 候选撞 SRD 未设的根因,移除后顺序恢复:"offer→ICE→ICE..."
440
+ // 的 ws 到达顺序在 pion-ipc 端被原样保留。
441
+ //
442
+ // 三段 await 间的身份重核仍保留:可在 await 中途删 session 的路径只剩"不走信令
443
+ // 队列"那几条——connectionState=closed/failed-TTL 同步触发 closeByConnId、
444
+ // closeAll 外线调用、闭包别处直接调 closeByConnId。rtc:closed 现在走 FIFO drain,
445
+ // 不会在同 connId 三段 await 间插入;最多排在 offer 后面、offer 完成发出 answer
446
+ // 后才被处理(UI 端 setRemoteDescription 已 try/catch,stale answer 无回归)。
447
+ // 命中身份重核即丢弃后续动作,不发 stale rtc:answer。
448
+ const credRemain = this.__credRemainSec(msg.turnCreds);
449
+ const credRemainStr = credRemain ?? 'none';
289
450
 
290
- // 同一 connId 重复 offer → 先关闭旧连接
291
- if (this.__sessions.has(connId)) {
292
- await this.closeByConnId(connId);
451
+ if (isIceRestart) {
452
+ // 仅已验证支持 ICE restart 的 impl 放行,其余立即 reject 让 UI 走 rebuild
453
+ if (this.__impl !== 'pion') {
454
+ this.__remoteLog(`rtc.ice-restart-unsupported conn=${connId} impl=${this.__impl} credRemain=${credRemainStr}`);
455
+ this.logger.info?.(`${this.__rtcTag} ICE restart rejected: impl=${this.__impl} not verified`);
456
+ this.__onSend({
457
+ type: 'rtc:restart-rejected',
458
+ toConnId: connId,
459
+ payload: { reason: 'impl_unsupported' },
460
+ });
461
+ return; // TTL timer 保持不变(reject 是同步的,不影响 timer 正常工作)
462
+ }
463
+ // 暂停 failed TTL timer:pion restart 涉及异步协商,期间不应被回收
464
+ if (session.__failedTimer) {
465
+ clearTimeout(session.__failedTimer);
466
+ session.__failedTimer = null;
467
+ }
468
+ this.__remoteLog(`rtc.ice-restart conn=${connId} credRemain=${credRemainStr}`);
469
+ this.logger.info?.(`${this.__rtcTag} ICE restart offer from ${connId}, renegotiating`);
470
+ try {
471
+ await session.pc.setRemoteDescription({ type: 'offer', sdp: msg.payload.sdp });
472
+ // 身份重核:能在三连 await 中间删 session 的路径都不走信令队列——
473
+ // connectionState=closed/failed-TTL 同步触发 closeByConnId、closeAll 外线调用、
474
+ // 闭包别处直调 closeByConnId。命中即丢弃后续动作,不发 stale rtc:answer。
475
+ // logger.info 仅本地诊断,不上 remoteLog(closeByConnId 触发方自带 rtc.closed /
476
+ // rtc.state 等远程日志,server 端能从那侧还原"PC 哪一刻死了")。
477
+ if (this.__sessions.get(connId) !== session) {
478
+ this.logger.info?.(`${this.__rtcTag} [${connId}] ICE restart aborted: session changed after setRemoteDescription`);
479
+ return;
480
+ }
481
+ // 重协商 SDP 可能变更 a=max-message-size,同步刷新 sender 分片阈值;
482
+ // queue 存的是完整字符串(buildChunks 在 sender.send 内同步完成),
483
+ // 已开始分片的当前消息用旧 size,下一条消息用新 size
484
+ const newMMS = this.__resolveMaxMessageSize(session.pc, msg.payload.sdp);
485
+ if (newMMS !== session.remoteMaxMessageSize) {
486
+ session.remoteMaxMessageSize = newMMS;
487
+ if (session.rpcDcSender) session.rpcDcSender.maxMessageSize = newMMS;
488
+ }
489
+ const answer = await session.pc.createAnswer();
490
+ if (this.__sessions.get(connId) !== session) {
491
+ this.logger.info?.(`${this.__rtcTag} [${connId}] ICE restart aborted: session changed after createAnswer`);
492
+ return;
493
+ }
494
+ await session.pc.setLocalDescription(answer);
495
+ if (this.__sessions.get(connId) !== session) {
496
+ this.logger.info?.(`${this.__rtcTag} [${connId}] ICE restart aborted: session changed after setLocalDescription`);
497
+ return;
498
+ }
499
+ this.__onSend({
500
+ type: 'rtc:answer',
501
+ toConnId: connId,
502
+ payload: { sdp: answer.sdp },
503
+ });
504
+ this.__remoteLog(`rtc.restart-answer-sent conn=${connId}`);
505
+ this.logger.info?.(`${this.__rtcTag} ICE restart answer sent to ${connId}`);
506
+ return;
507
+ } catch (err) {
508
+ // 身份重核:session 已被中途关掉时 catch 收到的 err 是"PC 已 close"残响,
509
+ // 不应再发 stale restart-rejected(用户会看到误报失败),也不重复调 closeByConnId
510
+ // (session 已删,再调是 no-op 但徒增 rtc.closed 日志噪声)。
511
+ if (this.__sessions.get(connId) !== session) {
512
+ this.logger.info?.(`${this.__rtcTag} [${connId}] ICE restart error after session change (suppressed): ${err?.message}`);
513
+ return;
514
+ }
515
+ // ICE restart 协商失败 → reject,不 fall through
516
+ this.__remoteLog(`rtc.ice-restart-failed conn=${connId} credRemain=${credRemainStr}`);
517
+ this.logger.warn?.(`${this.__rtcTag} ICE restart failed for ${connId}: ${err?.message}`);
518
+ this.__onSend({
519
+ type: 'rtc:restart-rejected',
520
+ toConnId: connId,
521
+ payload: { reason: 'restart_failed' },
522
+ });
523
+ await this.closeByConnId(connId, session).catch((closeErr) => {
524
+ /* c8 ignore next -- closeByConnId 内部已 try/catch,此路径极难触发 */
525
+ this.logger.warn?.(`${this.__rtcTag} closeByConnId failed after restart rejection for ${connId}: ${closeErr?.message}`);
526
+ });
527
+ return;
528
+ }
293
529
  }
294
530
 
295
- // session 总数限制:溢出时淘汰最旧的 failed session
296
- if (this.__sessions.size >= MAX_SESSIONS) {
297
- this.__evictOldestFailed();
531
+ // 首次 offer:session 已由 sync gate 建好(含 wire 全部 PC handler 与 sessions.set),
532
+ // 锁内只跑 SDP 协商三段 await。每段后做身份重核:pion-ipc 不在 pc.close reject
533
+ // in-flight 请求,过期 setRemoteDescription / createAnswer / setLocalDescription 可能
534
+ // 正常 resolve,身份重核拦截"已被替换的 session 发出 stale answer"。
535
+ this.__remoteLog(`rtc.offer conn=${connId}`);
536
+ this.logger.info?.(`${this.__rtcTag} offer received from ${connId}, creating answer`);
537
+ try {
538
+ await session.pc.setRemoteDescription({ type: 'offer', sdp: msg.payload.sdp });
539
+ if (this.__sessions.get(connId) !== session) {
540
+ this.logger.info?.(`${this.__rtcTag} [${connId}] first offer aborted: session changed after setRemoteDescription`);
541
+ return;
542
+ }
543
+ const answer = await session.pc.createAnswer();
544
+ if (this.__sessions.get(connId) !== session) {
545
+ this.logger.info?.(`${this.__rtcTag} [${connId}] first offer aborted: session changed after createAnswer`);
546
+ return;
547
+ }
548
+ await session.pc.setLocalDescription(answer);
549
+ if (this.__sessions.get(connId) !== session) {
550
+ this.logger.info?.(`${this.__rtcTag} [${connId}] first offer aborted: session changed after setLocalDescription`);
551
+ return;
552
+ }
553
+ this.__onSend({
554
+ type: 'rtc:answer',
555
+ toConnId: connId,
556
+ payload: { sdp: answer.sdp },
557
+ });
558
+ this.__remoteLog(`rtc.answer conn=${connId}`);
559
+ this.logger.info?.(`${this.__rtcTag} answer sent to ${connId}`);
560
+ } catch (err) {
561
+ // 身份重核:session 已被替换/外部 close 时 err 是 PC 残响,suppress;
562
+ // 由替换方 / close 方负责清理,不重复抛错到上游(避免误报"首次连接失败")。
563
+ if (this.__sessions.get(connId) !== session) {
564
+ this.logger.info?.(`${this.__rtcTag} [${connId}] first offer error after session change (suppressed): ${err?.message}`);
565
+ return;
566
+ }
567
+ // 自家 session 仍在表里:走 closeByConnId 集中清理(detach + timer 清理 + pc.close + 日志)
568
+ await this.closeByConnId(connId, session).catch(() => { /* c8 ignore next -- 极冷防御 */ });
569
+ throw err;
298
570
  }
571
+ }
299
572
 
573
+ /**
574
+ * 同步建新 session:构造 PC、wire 全部 PC handler、sessions.set。
575
+ * 在 sync gate 内调用,调用前应已完成对旧 session 的同步替换(如有)。
576
+ * 异常会传播到 __handleOffer,由 handleSignaling 调用方处理。
577
+ */
578
+ __createSession(msg, connId) {
300
579
  // 从 Server 注入的 turnCreds 构建 iceServers
301
580
  // werift 的 urls 必须是单个 string,每个 URL 独立一个对象
302
581
  const iceServers = [];
@@ -335,7 +614,18 @@ export class WebRtcPeer {
335
614
 
336
615
  const remoteMaxMessageSize = this.__resolveMaxMessageSize(pc, msg.payload.sdp);
337
616
 
338
- const session = { pc, rpcChannel: null, rpcQueue: null, rpcDcSender: null, rpcConsumeLoop: null, rpcDropMonitor: null, fileChannels: new Set(), remoteMaxMessageSize, nextMsgId: 1 };
617
+ const session = {
618
+ pc,
619
+ connId,
620
+ rpcChannel: null,
621
+ rpcQueue: null,
622
+ rpcDcSender: null,
623
+ rpcConsumeLoop: null,
624
+ rpcDropMonitor: null,
625
+ fileChannels: new Set(),
626
+ remoteMaxMessageSize,
627
+ nextMsgId: 1,
628
+ };
339
629
  this.__sessions.set(connId, session);
340
630
 
341
631
  // ICE candidate → 发给 UI,并统计各类型 candidate 数量
@@ -476,12 +766,12 @@ export class WebRtcPeer {
476
766
  cur.__failedTimer = setTimeout(() => {
477
767
  this.__remoteLog(`rtc.session-expired conn=${connId} ttl=${FAILED_SESSION_TTL_MS / 1000}s`);
478
768
  this.logger.info?.(`${this.__rtcTag} [${connId}] session TTL expired, closing`);
479
- this.closeByConnId(connId).catch(() => {});
769
+ this.closeByConnId(connId, cur).catch(() => {});
480
770
  }, FAILED_SESSION_TTL_MS);
481
771
  cur.__failedTimer.unref?.();
482
772
  } else if (state === 'closed') {
483
773
  // 自然进入 closed 时也需通过 closeByConnId 释放 IPC listeners 和 Go 资源
484
- this.closeByConnId(connId).catch(() => {});
774
+ this.closeByConnId(connId, cur).catch(() => {});
485
775
  }
486
776
  }
487
777
  };
@@ -532,32 +822,7 @@ export class WebRtcPeer {
532
822
  }
533
823
  };
534
824
 
535
- // offer → answer
536
- try {
537
- await pc.setRemoteDescription({ type: 'offer', sdp: msg.payload.sdp });
538
- const answer = await pc.createAnswer();
539
- await pc.setLocalDescription(answer);
540
-
541
- this.__onSend({
542
- type: 'rtc:answer',
543
- toConnId: connId,
544
- payload: { sdp: answer.sdp },
545
- });
546
- this.__remoteLog(`rtc.answer conn=${connId}`);
547
- this.logger.info?.(`${this.__rtcTag} answer sent to ${connId}`);
548
- } catch (err) {
549
- // SDP 协商失败 → 清理已入 Map 的 session,避免泄漏
550
- const cur = this.__sessions.get(connId);
551
- if (cur && cur.pc === pc) {
552
- if (cur.__failedTimer) {
553
- clearTimeout(cur.__failedTimer);
554
- cur.__failedTimer = null;
555
- }
556
- this.__sessions.delete(connId);
557
- }
558
- await pc.close().catch(() => {});
559
- throw err;
560
- }
825
+ return session;
561
826
  }
562
827
 
563
828
  async __handleIce(msg) {
@@ -1030,7 +1295,7 @@ export class WebRtcPeer {
1030
1295
  if (session.pc.connectionState === 'failed') {
1031
1296
  this.__remoteLog(`rtc.session-evicted conn=${connId} sessions=${this.__sessions.size}`);
1032
1297
  this.logger.info?.(`${this.__rtcTag} [${connId}] session evicted (limit ${MAX_SESSIONS}), closing`);
1033
- this.closeByConnId(connId).catch(() => {});
1298
+ this.closeByConnId(connId, session).catch(() => {});
1034
1299
  return true;
1035
1300
  }
1036
1301
  }