@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 +1 -1
- package/index.js +7 -7
- package/package.json +1 -1
- package/src/auto-upgrade/updater.js +8 -0
- package/src/device-identity.js +7 -0
- package/src/realtime-bridge.js +20 -6
- package/src/session-manager/manager.js +90 -168
- package/src/utils/text-line-stream.js +60 -0
- package/src/webrtc/webrtc-peer.js +412 -147
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
|
@@ -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
|
*/
|
package/src/device-identity.js
CHANGED
|
@@ -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] - 自定义路径,默认 <state-dir>/coclaw/device-identity.json
|
|
81
88
|
* @returns {{ deviceId: string, publicKeyPem: string, privateKeyPem: string }}
|
|
82
89
|
*/
|
package/src/realtime-bridge.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1314
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
289
|
-
|
|
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 =
|
|
294
|
-
|
|
295
|
-
.
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
195
|
+
const session = this.__sessions.get(connId);
|
|
196
|
+
if (session) await this.closeByConnId(connId, session);
|
|
87
197
|
}
|
|
88
198
|
}
|
|
89
199
|
}
|
|
90
200
|
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
/**
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (
|
|
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
|
-
|
|
196
|
-
|
|
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
|
|
219
|
-
if (isIceRestart) {
|
|
220
|
-
const
|
|
221
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
296
|
-
|
|
297
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
}
|