@coclaw/openclaw-coclaw 0.13.2 → 0.14.1
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/index.js +100 -0
- package/package.json +2 -2
- package/src/agent-abort.js +35 -0
- package/src/platform-info.js +60 -0
- package/src/realtime-bridge.js +24 -2
- package/src/topic-manager/title-gen.js +2 -1
- package/src/webrtc/dc-chunking.js +28 -10
- package/src/webrtc/rpc-send-queue.js +182 -0
- package/src/webrtc/webrtc-peer.js +70 -25
package/index.js
CHANGED
|
@@ -13,10 +13,78 @@ import { generateTitle } from './src/topic-manager/title-gen.js';
|
|
|
13
13
|
import { AutoUpgradeScheduler } from './src/auto-upgrade/updater.js';
|
|
14
14
|
import { getPackageInfo } from './src/auto-upgrade/updater-check.js';
|
|
15
15
|
import { createFileHandler } from './src/file-manager/handler.js';
|
|
16
|
+
import { abortAgentRun } from './src/agent-abort.js';
|
|
17
|
+
import { remoteLog } from './src/remote-log.js';
|
|
16
18
|
|
|
17
19
|
import { getPluginVersion, __resetPluginVersion } from './src/plugin-version.js';
|
|
18
20
|
export { getPluginVersion, __resetPluginVersion };
|
|
19
21
|
|
|
22
|
+
// 侧门注册表观测:patch OpenClaw embeddedRunState.activeRuns 的 set/delete,
|
|
23
|
+
// 用于跟踪 sessionId 何时注册/注销(agent 取消流程实际读取的就是这张表)。
|
|
24
|
+
// OpenClaw 侧门形状变化时(缺失 / 抛异常),通过 remoteLog 上报为升级契约变更的早期信号。
|
|
25
|
+
const PATCH_LABELS = [
|
|
26
|
+
['embedded.activeRuns', () => globalThis[Symbol.for('openclaw.embeddedRunState')]?.activeRuns],
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function installAbortRegistryDiag(logger) {
|
|
30
|
+
const installed = [];
|
|
31
|
+
const missing = [];
|
|
32
|
+
try {
|
|
33
|
+
for (const [label, resolve] of PATCH_LABELS) {
|
|
34
|
+
if (patchMapLogging(resolve(), label, logger)) installed.push(label);
|
|
35
|
+
else missing.push(label);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
logger?.warn?.(`[coclaw.diag] installAbortRegistryDiag failed: ${String(err?.message ?? err)}`);
|
|
40
|
+
remoteLog(`abort.patch-failed reason=${String(err?.message ?? err)}`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
remoteLog(`abort.patch installed=${installed.join(',') || 'none'} missing=${missing.join(',') || 'none'}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function patchMapLogging(map, label, logger) {
|
|
47
|
+
if (!map || typeof map.set !== 'function' || typeof map.delete !== 'function') return false;
|
|
48
|
+
if (map.__coclawDiagPatched) return true;
|
|
49
|
+
// 先打 idempotent 标记:若 map 是 frozen/sealed/Proxy 致 defineProperty 抛,
|
|
50
|
+
// 立即返回 false 让上层归入 missing;不留下半装好的 wrapper 状态
|
|
51
|
+
try {
|
|
52
|
+
Object.defineProperty(map, '__coclawDiagPatched', { value: true, enumerable: false });
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
logger?.warn?.(`[coclaw.diag] cannot mark ${label} patched: ${String(err?.message ?? err)}`);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const origSet = map.set.bind(map);
|
|
59
|
+
const origDel = map.delete.bind(map);
|
|
60
|
+
// log 行包 try/catch 兜底:上游若把 Map 换成有 throwing getter(如 Proxy)的对象,
|
|
61
|
+
// 不能让本插件的诊断 log 把 OpenClaw 内部 set/delete 流程带崩
|
|
62
|
+
const safeLog = (msg) => {
|
|
63
|
+
try { logger?.info?.(msg); } catch { /* swallow — diag log 不得影响主流程 */ }
|
|
64
|
+
};
|
|
65
|
+
const safeSize = () => {
|
|
66
|
+
try { return map.size; } catch { return '?'; }
|
|
67
|
+
};
|
|
68
|
+
map.set = (key, value) => {
|
|
69
|
+
const res = origSet(key, value);
|
|
70
|
+
safeLog(`[coclaw.diag] ${label}.set key=${stringifyKey(key)} size=${safeSize()}`);
|
|
71
|
+
return res;
|
|
72
|
+
};
|
|
73
|
+
map.delete = (key) => {
|
|
74
|
+
let had;
|
|
75
|
+
try { had = map.has(key); } catch { had = '?'; }
|
|
76
|
+
const res = origDel(key);
|
|
77
|
+
safeLog(`[coclaw.diag] ${label}.delete key=${stringifyKey(key)} had=${had} size=${safeSize()}`);
|
|
78
|
+
return res;
|
|
79
|
+
};
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function stringifyKey(k) {
|
|
84
|
+
if (typeof k === 'string') return k;
|
|
85
|
+
try { return JSON.stringify(k); } catch { return String(k); }
|
|
86
|
+
}
|
|
87
|
+
|
|
20
88
|
/* c8 ignore start */
|
|
21
89
|
function parseCommandArgs(args) {
|
|
22
90
|
const tokens = (args ?? '').split(/\s+/).filter(Boolean);
|
|
@@ -64,6 +132,7 @@ const plugin = {
|
|
|
64
132
|
register(api) {
|
|
65
133
|
setRuntime(api.runtime);
|
|
66
134
|
const logger = api?.logger ?? console;
|
|
135
|
+
installAbortRegistryDiag(logger);
|
|
67
136
|
const manager = createSessionManager({ logger });
|
|
68
137
|
const topicManager = new TopicManager({ logger });
|
|
69
138
|
const chatHistoryManager = new ChatHistoryManager({ logger });
|
|
@@ -457,6 +526,37 @@ const plugin = {
|
|
|
457
526
|
}
|
|
458
527
|
});
|
|
459
528
|
|
|
529
|
+
// 取消正在执行的 embedded agent run(通过 OpenClaw 全局 symbol 侧门)
|
|
530
|
+
// 侧门不存在 / sessionId 未注册 / handle.abort 抛异常时返回 { ok:false, reason } —— UI 静默降级
|
|
531
|
+
// UI 可能在 OpenClaw 注册 sessionId 前点 STOP(注册空窗期),此时返回 not-found;UI 会按 500ms 间隔重试。
|
|
532
|
+
api.registerGatewayMethod('coclaw.agent.abort', ({ params, respond }) => {
|
|
533
|
+
try {
|
|
534
|
+
const sessionId = params?.sessionId;
|
|
535
|
+
if (typeof sessionId !== 'string' || !sessionId) {
|
|
536
|
+
logger.warn?.(`[coclaw.agent.abort] invalid sessionId: ${JSON.stringify(sessionId)}`);
|
|
537
|
+
respondInvalid(respond, 'sessionId is required');
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const result = abortAgentRun(sessionId);
|
|
541
|
+
// not-found 是 UI 重试期常态(注册空窗),不打日志避免噪音;其余分支保留 info
|
|
542
|
+
if (result.reason !== 'not-found') {
|
|
543
|
+
logger.info?.(`[coclaw.agent.abort] result sessionId=${sessionId} ok=${result.ok}${result.reason ? ` reason=${result.reason}` : ''}${result.error ? ` error=${result.error}` : ''}`);
|
|
544
|
+
}
|
|
545
|
+
if (result.ok) {
|
|
546
|
+
remoteLog(`abort.success sid=${sessionId}`);
|
|
547
|
+
}
|
|
548
|
+
else if (result.reason === 'not-supported') {
|
|
549
|
+
// 侧门缺失或 handle shape 变化:OpenClaw 升级契约变更的早期信号
|
|
550
|
+
remoteLog(`abort.not-supported sid=${sessionId}`);
|
|
551
|
+
}
|
|
552
|
+
respond(true, result);
|
|
553
|
+
}
|
|
554
|
+
catch (err) {
|
|
555
|
+
logger.error?.(`[coclaw.agent.abort] handler threw: ${String(err?.message ?? err)}`);
|
|
556
|
+
respondError(respond, err);
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
460
560
|
api.registerGatewayMethod('coclaw.upgradeHealth', async ({ respond }) => {
|
|
461
561
|
try {
|
|
462
562
|
const { version } = await getPackageInfo();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coclaw/openclaw-coclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "OpenClaw CoClaw channel plugin for remote chat",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"node-datachannel": "0.32.2",
|
|
63
|
-
"@coclaw/pion-node": "^0.1.
|
|
63
|
+
"@coclaw/pion-node": "^0.1.2",
|
|
64
64
|
"werift": "^0.19.0",
|
|
65
65
|
"ws": "^8.19.0"
|
|
66
66
|
},
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-abort:封装 OpenClaw embedded agent run 的侧门取消入口
|
|
3
|
+
*
|
|
4
|
+
* OpenClaw 自 v2026.3.12 起通过全局 symbol 注册表暴露 activeRuns 映射,
|
|
5
|
+
* 允许外部根据 sessionId 调 handle.abort() 真正终止正在执行的 agent run
|
|
6
|
+
*(LLM + 工具 + compaction 均受影响)。
|
|
7
|
+
*
|
|
8
|
+
* 本模块是 CoClaw 插件访问该侧门的唯一入口,未来上游提供正式 API 时集中替换。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const EMBEDDED_RUN_STATE_KEY = Symbol.for('openclaw.embeddedRunState');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 尝试取消 sessionId 对应的 embedded agent run
|
|
15
|
+
* @param {string} sessionId
|
|
16
|
+
* @returns {{ ok: true } | { ok: false, reason: 'not-supported' | 'not-found' | 'abort-threw', error?: string }}
|
|
17
|
+
*/
|
|
18
|
+
export function abortAgentRun(sessionId) {
|
|
19
|
+
const state = globalThis[EMBEDDED_RUN_STATE_KEY];
|
|
20
|
+
if (!state || !state.activeRuns || typeof state.activeRuns.get !== 'function') {
|
|
21
|
+
return { ok: false, reason: 'not-supported' };
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const handle = state.activeRuns.get(sessionId);
|
|
25
|
+
if (!handle) return { ok: false, reason: 'not-found' };
|
|
26
|
+
// shape 守卫:abort 字段应为函数;若不是说明 OpenClaw handle 契约变化(归入 not-supported 让 UI 提示升级)
|
|
27
|
+
if (typeof handle.abort !== 'function') return { ok: false, reason: 'not-supported' };
|
|
28
|
+
handle.abort();
|
|
29
|
+
return { ok: true };
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
// activeRuns.get() 或 handle.abort() 抛(非 Map 实现 / OpenClaw 内部错误)
|
|
33
|
+
return { ok: false, reason: 'abort-threw', error: String(err?.message ?? err) };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
|
|
4
|
+
// 模块级缓存:所有字段在进程生命周期内不变,缓存后 ws 重连补发零开销。
|
|
5
|
+
let __cachedLine = null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 以 "key=value" 形式收集运行环境信息,用于诊断平台相关二进制依赖(如 pion-ipc)问题。
|
|
9
|
+
*
|
|
10
|
+
* 尽力而为:每项独立 try/catch,单项失败不影响其它项;无法获取时该字段省略。
|
|
11
|
+
* 结果在进程生命周期内缓存,重复调用零额外开销。
|
|
12
|
+
*
|
|
13
|
+
* 字段:platform / arch / node / osrel / cpu / cores / mem
|
|
14
|
+
*
|
|
15
|
+
* @returns {string} - 形如 `platform=linux arch=x64 node=v20.11.0 osrel=6.6.87 cpu="Intel Xeon" cores=8 mem=16.0GB`
|
|
16
|
+
*/
|
|
17
|
+
export function getPlatformInfoLine() {
|
|
18
|
+
if (__cachedLine !== null) return __cachedLine;
|
|
19
|
+
const parts = [];
|
|
20
|
+
|
|
21
|
+
tryPush(parts, 'platform', () => process.platform);
|
|
22
|
+
tryPush(parts, 'arch', () => process.arch);
|
|
23
|
+
tryPush(parts, 'node', () => process.version);
|
|
24
|
+
tryPush(parts, 'osrel', () => os.release());
|
|
25
|
+
tryPush(parts, 'cpu', () => {
|
|
26
|
+
const model = os.cpus()?.[0]?.model;
|
|
27
|
+
if (!model) return undefined;
|
|
28
|
+
// 外层包双引号以保留含空格的 model;内部双引号 / C0 控制字符 / DEL 替换为空格后折叠空白
|
|
29
|
+
const cleaned = String(model).replace(/["\x00-\x1F\x7F]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
30
|
+
if (!cleaned) return undefined;
|
|
31
|
+
return `"${cleaned}"`;
|
|
32
|
+
});
|
|
33
|
+
tryPush(parts, 'cores', () => {
|
|
34
|
+
const n = os.cpus()?.length;
|
|
35
|
+
return n > 0 ? n : undefined;
|
|
36
|
+
});
|
|
37
|
+
tryPush(parts, 'mem', () => {
|
|
38
|
+
const bytes = os.totalmem();
|
|
39
|
+
if (!bytes || !Number.isFinite(bytes)) return undefined;
|
|
40
|
+
return `${(bytes / 1024 ** 3).toFixed(1)}GB`;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
__cachedLine = parts.join(' ');
|
|
44
|
+
return __cachedLine;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 测试用:清缓存以便覆盖不同 monkey-patch 场景 */
|
|
48
|
+
export function __resetPlatformInfoCache() {
|
|
49
|
+
__cachedLine = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tryPush(parts, key, resolver) {
|
|
53
|
+
try {
|
|
54
|
+
const value = resolver();
|
|
55
|
+
if (value === undefined || value === null || value === '') return;
|
|
56
|
+
parts.push(`${key}=${value}`);
|
|
57
|
+
} catch {
|
|
58
|
+
// 单项失败静默跳过,不影响其它字段
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/realtime-bridge.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { getRuntime } from './runtime.js';
|
|
15
15
|
import { setSender as setRemoteLogSender, remoteLog } from './remote-log.js';
|
|
16
16
|
import { getPluginVersion } from './plugin-version.js';
|
|
17
|
+
import { getPlatformInfoLine } from './platform-info.js';
|
|
17
18
|
|
|
18
19
|
const DEFAULT_GATEWAY_WS_URL = `ws://127.0.0.1:${process.env.OPENCLAW_GATEWAY_PORT || '18789'}`;
|
|
19
20
|
const RECONNECT_MS = 10_000;
|
|
@@ -811,9 +812,14 @@ export class RealtimeBridge {
|
|
|
811
812
|
this.__clearConnectTimer();
|
|
812
813
|
this.logger.info?.(`[coclaw] realtime bridge connected: ${maskedTarget}`);
|
|
813
814
|
remoteLog('ws.connected peer=server');
|
|
815
|
+
// 顺序很重要:先注入 sender 再 remoteLog 环境信息——这样环境信息能随当前 sock
|
|
816
|
+
// 立即 flush;同时 sender 内部仅做 sock.send(不回调 remoteLog),无循环依赖。
|
|
814
817
|
setRemoteLogSender((msg) => {
|
|
815
818
|
if (sock.readyState === 1) sock.send(JSON.stringify(msg));
|
|
816
819
|
});
|
|
820
|
+
// ws 重连后补发环境信息:server 重启重连后能立即看到当前 claw 的运行环境与 webrtc 选型。
|
|
821
|
+
// __buildEnvLine 内部所有读取均为缓存值,无 native syscall。
|
|
822
|
+
remoteLog(this.__buildEnvLine());
|
|
817
823
|
this.__startServerHeartbeat(sock);
|
|
818
824
|
this.__ensureGatewayConnection();
|
|
819
825
|
});
|
|
@@ -967,12 +973,28 @@ export class RealtimeBridge {
|
|
|
967
973
|
this.__ndcPreloadResult = preloadResult;
|
|
968
974
|
this.__ndcCleanup = preloadResult.cleanup;
|
|
969
975
|
const implLabel = preloadResult.impl === 'ndc' ? 'node-datachannel(ndc)' : preloadResult.impl;
|
|
976
|
+
this.__implLabel = implLabel; // 缓存供 ws.open 时发送
|
|
977
|
+
// 启动信息只本地 log;远程发送统一由 ws.open 触发,避免重复
|
|
970
978
|
this.logger.info?.(`[coclaw] WebRTC impl: ${implLabel}`);
|
|
971
|
-
|
|
972
|
-
remoteLog(
|
|
979
|
+
this.logger.info?.(`[coclaw] ${this.__buildEnvLine()}`);
|
|
980
|
+
remoteLog('bridge.started');
|
|
973
981
|
await this.__connectIfNeeded();
|
|
974
982
|
}
|
|
975
983
|
|
|
984
|
+
/**
|
|
985
|
+
* 组装一条覆盖最基础环境信息的 log 行:
|
|
986
|
+
* coclaw.env impl=<...> plugin=<ver> openclaw=<ver> platform=<...> ... mem=<...>
|
|
987
|
+
*
|
|
988
|
+
* 字段值均为已缓存的轻量同步读取,无 native syscall;不调用 remoteLog,无循环依赖。
|
|
989
|
+
*/
|
|
990
|
+
__buildEnvLine() {
|
|
991
|
+
const rt = getRuntime();
|
|
992
|
+
const openclawVer = (rt?.version && rt.version !== 'unknown') ? rt.version : 'unknown';
|
|
993
|
+
const impl = this.__implLabel ?? 'pending';
|
|
994
|
+
const plugin = this.__pluginVersion ?? 'unknown';
|
|
995
|
+
return `coclaw.env impl=${impl} plugin=${plugin} openclaw=${openclawVer} ${getPlatformInfoLine()}`;
|
|
996
|
+
}
|
|
997
|
+
|
|
976
998
|
async refresh() {
|
|
977
999
|
await this.stop();
|
|
978
1000
|
await this.start({
|
|
@@ -114,7 +114,8 @@ export async function generateTitle({ topicId, topicManager, agentRpc, logger })
|
|
|
114
114
|
message: '请为这段对话生成标题',
|
|
115
115
|
idempotencyKey: randomUUID(),
|
|
116
116
|
}, {
|
|
117
|
-
|
|
117
|
+
// 题目生成需等待 LLM 完整响应;60s 在复杂对话/慢模型下易超时
|
|
118
|
+
timeoutMs: 300_000,
|
|
118
119
|
acceptTimeoutMs: 10_000,
|
|
119
120
|
});
|
|
120
121
|
|
|
@@ -22,20 +22,15 @@ const encoder = new TextEncoder();
|
|
|
22
22
|
const decoder = new TextDecoder();
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
* 小于 maxMessageSize 直接发 string;否则切成 binary chunk 逐个发送
|
|
27
|
-
* @param {object} dc - DataChannel(werift 或浏览器)
|
|
25
|
+
* 按需分片:小于等于 maxMessageSize 返回 null(调用方直发 string),否则返回 chunk 数组
|
|
28
26
|
* @param {string} jsonStr - 已序列化的 JSON 字符串
|
|
29
27
|
* @param {number} maxMessageSize - 对端声明的 maxMessageSize
|
|
30
28
|
* @param {() => number} getNextMsgId - 获取下一个 msgId
|
|
29
|
+
* @returns {Buffer[]|null} null 表示不需要分片;否则为 chunk Buffer 数组
|
|
31
30
|
*/
|
|
32
|
-
export function
|
|
31
|
+
export function buildChunks(jsonStr, maxMessageSize, getNextMsgId) {
|
|
33
32
|
const fullBytes = encoder.encode(jsonStr);
|
|
34
|
-
|
|
35
|
-
if (fullBytes.byteLength <= maxMessageSize) {
|
|
36
|
-
dc.send(jsonStr);
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
33
|
+
if (fullBytes.byteLength <= maxMessageSize) return null;
|
|
39
34
|
|
|
40
35
|
const chunkPayloadSize = maxMessageSize - HEADER_SIZE;
|
|
41
36
|
if (chunkPayloadSize <= 0) {
|
|
@@ -44,7 +39,7 @@ export function chunkAndSend(dc, jsonStr, maxMessageSize, getNextMsgId, logger)
|
|
|
44
39
|
|
|
45
40
|
const msgId = getNextMsgId();
|
|
46
41
|
const totalChunks = Math.ceil(fullBytes.byteLength / chunkPayloadSize);
|
|
47
|
-
|
|
42
|
+
const chunks = new Array(totalChunks);
|
|
48
43
|
|
|
49
44
|
for (let i = 0; i < totalChunks; i++) {
|
|
50
45
|
const start = i * chunkPayloadSize;
|
|
@@ -55,7 +50,30 @@ export function chunkAndSend(dc, jsonStr, maxMessageSize, getNextMsgId, logger)
|
|
|
55
50
|
chunk[0] = flag;
|
|
56
51
|
chunk.writeUInt32BE(msgId, 1);
|
|
57
52
|
chunk.set(fullBytes.subarray(start, end), HEADER_SIZE);
|
|
53
|
+
chunks[i] = chunk;
|
|
54
|
+
}
|
|
55
|
+
return chunks;
|
|
56
|
+
}
|
|
58
57
|
|
|
58
|
+
/**
|
|
59
|
+
* 按需分片并发送消息(薄包装:buildChunks + dc.send)
|
|
60
|
+
* 注意:无应用层流控;生产路径请使用 RpcSendQueue
|
|
61
|
+
* @param {object} dc - DataChannel
|
|
62
|
+
* @param {string} jsonStr - 已序列化的 JSON 字符串
|
|
63
|
+
* @param {number} maxMessageSize - 对端声明的 maxMessageSize
|
|
64
|
+
* @param {() => number} getNextMsgId - 获取下一个 msgId
|
|
65
|
+
* @param {object} [logger] - 可选 logger
|
|
66
|
+
*/
|
|
67
|
+
export function chunkAndSend(dc, jsonStr, maxMessageSize, getNextMsgId, logger) {
|
|
68
|
+
const chunks = buildChunks(jsonStr, maxMessageSize, getNextMsgId);
|
|
69
|
+
if (!chunks) {
|
|
70
|
+
dc.send(jsonStr);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const msgId = chunks[0].readUInt32BE(1);
|
|
74
|
+
const totalBytes = chunks.reduce((n, c) => n + (c.length - HEADER_SIZE), 0);
|
|
75
|
+
logger?.info?.(`[dc-chunking] chunking msgId=${msgId}: ${totalBytes} bytes → ${chunks.length} chunks (maxMsgSize=${maxMessageSize})`);
|
|
76
|
+
for (const chunk of chunks) {
|
|
59
77
|
dc.send(chunk);
|
|
60
78
|
}
|
|
61
79
|
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rpc DataChannel 发送流控队列
|
|
3
|
+
*
|
|
4
|
+
* 针对 plugin 侧 rpc DC 的应用层流控:与 UI 侧 `webrtc-connection.js` 语义对齐,
|
|
5
|
+
* 但因插件运行在 gateway 进程内,必须对队列大小设硬/软上限,避免 OOM。
|
|
6
|
+
*
|
|
7
|
+
* 使用方式:每条 rpc DC 一个实例,绑定到 session.rpcSendQueue。
|
|
8
|
+
* - send(jsonStr):同步入口,fire-and-forget;返回 accepted/dropped
|
|
9
|
+
* - onBufferedAmountLow():由 DC `bufferedamountlow` 事件转调,触发 drain
|
|
10
|
+
* - close():DC 关闭时调用,清空队列并汇总 drop 统计
|
|
11
|
+
*
|
|
12
|
+
* 不做:Promise 送达保证;单条消息硬上限内的背压;自动重试。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { buildChunks } from './dc-chunking.js';
|
|
16
|
+
import { remoteLog } from '../remote-log.js';
|
|
17
|
+
|
|
18
|
+
/** 高水位:`dc.bufferedAmount >= HIGH` 时暂停 fast-path / drain */
|
|
19
|
+
export const DC_HIGH_WATER_MARK = 1024 * 1024; // 1 MB
|
|
20
|
+
/** 低水位:设置 `dc.bufferedAmountLowThreshold`,触发 `bufferedamountlow` 事件 */
|
|
21
|
+
export const DC_LOW_WATER_MARK = 256 * 1024; // 256 KB
|
|
22
|
+
/** 应用层队列软上限:`queueBytes >= MAX_QUEUE_BYTES` 时新消息被 drop */
|
|
23
|
+
export const MAX_QUEUE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
24
|
+
/** 单条消息硬上限(对齐 dc-chunking.js MAX_REASSEMBLY_BYTES,接收端重组不了也无意义) */
|
|
25
|
+
export const MAX_SINGLE_MSG_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
26
|
+
|
|
27
|
+
export class RpcSendQueue {
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} opts
|
|
30
|
+
* @param {object} opts.dc - DataChannel 实例(需支持 send / bufferedAmount / readyState)
|
|
31
|
+
* @param {number} opts.maxMessageSize - 对端 SDP 声明的 a=max-message-size
|
|
32
|
+
* @param {() => number} opts.getNextMsgId - 分片 msgId 生成器
|
|
33
|
+
* @param {object} [opts.logger] - pino 风格 logger
|
|
34
|
+
* @param {string} [opts.tag] - 诊断 tag(通常是 connId)
|
|
35
|
+
*/
|
|
36
|
+
constructor({ dc, maxMessageSize, getNextMsgId, logger, tag }) {
|
|
37
|
+
if (!dc) throw new Error('RpcSendQueue: dc is required');
|
|
38
|
+
this.dc = dc;
|
|
39
|
+
this.maxMessageSize = maxMessageSize;
|
|
40
|
+
this.getNextMsgId = getNextMsgId;
|
|
41
|
+
this.logger = logger ?? console;
|
|
42
|
+
this.tag = tag ?? '';
|
|
43
|
+
|
|
44
|
+
/** @type {Buffer[]} chunks 或 Buffer 化的 string 消息 */
|
|
45
|
+
this.queue = [];
|
|
46
|
+
this.queueBytes = 0;
|
|
47
|
+
this.closed = false;
|
|
48
|
+
|
|
49
|
+
// drop 统计(累计到 close 时汇总)
|
|
50
|
+
this.droppedCount = 0;
|
|
51
|
+
this.droppedBytes = 0;
|
|
52
|
+
// 队列"满"状态:仅 queue-full drop 触发 true;drain 下降到 < MAX 翻回 false
|
|
53
|
+
// single-msg-oversize drop 不影响此状态(它是应用 bug 性质,不代表队列压力)
|
|
54
|
+
this.queueOverflowActive = false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 同步发送一条 JSON 字符串。
|
|
59
|
+
* @param {string} jsonStr
|
|
60
|
+
* @returns {boolean} true=accepted(至少已入队或已直发),false=dropped
|
|
61
|
+
*/
|
|
62
|
+
send(jsonStr) {
|
|
63
|
+
if (this.closed || this.dc.readyState !== 'open') return false;
|
|
64
|
+
|
|
65
|
+
const chunks = buildChunks(jsonStr, this.maxMessageSize, this.getNextMsgId);
|
|
66
|
+
const totalBytes = chunks
|
|
67
|
+
? chunks.reduce((n, c) => n + c.length, 0)
|
|
68
|
+
: Buffer.byteLength(jsonStr, 'utf8');
|
|
69
|
+
|
|
70
|
+
// 硬上限:单条超限
|
|
71
|
+
if (totalBytes > MAX_SINGLE_MSG_BYTES) {
|
|
72
|
+
this.droppedCount += 1;
|
|
73
|
+
this.droppedBytes += totalBytes;
|
|
74
|
+
this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] drop reason=single-msg-oversize size=${totalBytes} cap=${MAX_SINGLE_MSG_BYTES}`);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 软上限:队列已积压到 MAX(允许之前单条溢出,但新消息从此开始拒绝)
|
|
79
|
+
if (this.queueBytes >= MAX_QUEUE_BYTES) {
|
|
80
|
+
this.droppedCount += 1;
|
|
81
|
+
this.droppedBytes += totalBytes;
|
|
82
|
+
this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] drop reason=queue-full size=${totalBytes} queueBytes=${this.queueBytes}`);
|
|
83
|
+
if (!this.queueOverflowActive) {
|
|
84
|
+
this.queueOverflowActive = true;
|
|
85
|
+
remoteLog(`rpc-queue.overflow-start${this.__tagSuffix()} queueBytes=${this.queueBytes}`);
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 不分片:单条 string 或 Buffer 直接处理
|
|
91
|
+
if (!chunks) {
|
|
92
|
+
if (this.queue.length === 0
|
|
93
|
+
&& this.dc.readyState === 'open'
|
|
94
|
+
&& this.dc.bufferedAmount < DC_HIGH_WATER_MARK) {
|
|
95
|
+
try {
|
|
96
|
+
this.dc.send(jsonStr);
|
|
97
|
+
return true;
|
|
98
|
+
} catch (err) {
|
|
99
|
+
this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] fast-path send failed: ${err?.message}`);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const buf = Buffer.from(jsonStr, 'utf8');
|
|
104
|
+
this.queue.push(buf);
|
|
105
|
+
this.queueBytes += buf.length;
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 分片:fast-path 尝试同步直发尽可能多的 chunk
|
|
110
|
+
// 循环条件与 __drain 一致:DC 仍 open 且 bufferedAmount 未顶到 HIGH
|
|
111
|
+
let i = 0;
|
|
112
|
+
if (this.queue.length === 0) {
|
|
113
|
+
while (i < chunks.length
|
|
114
|
+
&& this.dc.readyState === 'open'
|
|
115
|
+
&& this.dc.bufferedAmount < DC_HIGH_WATER_MARK) {
|
|
116
|
+
try {
|
|
117
|
+
this.dc.send(chunks[i]);
|
|
118
|
+
i += 1;
|
|
119
|
+
} catch (err) {
|
|
120
|
+
this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] fast-path send failed at chunk ${i}/${chunks.length}: ${err?.message}`);
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// 剩余 chunk 原子性入队(保证同一消息分片连续,不被其他消息插入)
|
|
126
|
+
for (; i < chunks.length; i += 1) {
|
|
127
|
+
this.queue.push(chunks[i]);
|
|
128
|
+
this.queueBytes += chunks[i].length;
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 由外部 `dc.onbufferedamountlow` 事件触发 */
|
|
134
|
+
onBufferedAmountLow() {
|
|
135
|
+
this.__drain();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 关闭队列:清空待发送 chunks,汇总并 remoteLog drop 统计。幂等。
|
|
140
|
+
*/
|
|
141
|
+
close() {
|
|
142
|
+
if (this.closed) return;
|
|
143
|
+
this.closed = true;
|
|
144
|
+
const residual = this.queue.length;
|
|
145
|
+
const residualBytes = this.queueBytes;
|
|
146
|
+
this.queue.length = 0;
|
|
147
|
+
this.queueBytes = 0;
|
|
148
|
+
this.queueOverflowActive = false;
|
|
149
|
+
if (this.droppedCount > 0 || residual > 0) {
|
|
150
|
+
remoteLog(`rpc-queue.close${this.__tagSuffix()} dropped=${this.droppedCount} droppedBytes=${this.droppedBytes} residualChunks=${residual} residualBytes=${residualBytes}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @private 排队持续发送直至 HIGH 水位或队列空 */
|
|
155
|
+
__drain() {
|
|
156
|
+
if (this.closed) return;
|
|
157
|
+
const dc = this.dc;
|
|
158
|
+
while (this.queue.length > 0
|
|
159
|
+
&& dc.readyState === 'open'
|
|
160
|
+
&& dc.bufferedAmount < DC_HIGH_WATER_MARK) {
|
|
161
|
+
const chunk = this.queue[0];
|
|
162
|
+
try {
|
|
163
|
+
dc.send(chunk);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] drain send failed: ${err?.message}`);
|
|
166
|
+
return; // 保留队列,等 onclose 统一清理
|
|
167
|
+
}
|
|
168
|
+
this.queue.shift();
|
|
169
|
+
this.queueBytes -= chunk.length;
|
|
170
|
+
// 满 → 未满 状态转换
|
|
171
|
+
if (this.queueOverflowActive && this.queueBytes < MAX_QUEUE_BYTES) {
|
|
172
|
+
this.queueOverflowActive = false;
|
|
173
|
+
remoteLog(`rpc-queue.overflow-end${this.__tagSuffix()} dropped=${this.droppedCount} droppedBytes=${this.droppedBytes}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** @private */
|
|
179
|
+
__tagSuffix() {
|
|
180
|
+
return this.tag ? ` ${this.tag}` : '';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createReassembler } from './dc-chunking.js';
|
|
2
|
+
import { RpcSendQueue, DC_LOW_WATER_MARK } from './rpc-send-queue.js';
|
|
2
3
|
import { remoteLog } from '../remote-log.js';
|
|
3
4
|
|
|
4
5
|
// 单个 session 内 file DC 历史快照的容量上限(满后按 FIFO 淘汰最老条目)。
|
|
@@ -40,7 +41,7 @@ export class WebRtcPeer {
|
|
|
40
41
|
this.__PeerConnection = PeerConnection;
|
|
41
42
|
this.__impl = impl ?? null;
|
|
42
43
|
this.__rtcTag = impl ? `[coclaw/rtc:${impl}]` : '[coclaw/rtc]';
|
|
43
|
-
/** @type {Map<string, { pc: object, rpcChannel: object|null, remoteMaxMessageSize: number, nextMsgId: number }>} */
|
|
44
|
+
/** @type {Map<string, { pc: object, rpcChannel: object|null, rpcSendQueue: RpcSendQueue|null, fileChannels: Set, remoteMaxMessageSize: number, nextMsgId: number }>} */
|
|
44
45
|
this.__sessions = new Map();
|
|
45
46
|
}
|
|
46
47
|
|
|
@@ -69,6 +70,13 @@ export class WebRtcPeer {
|
|
|
69
70
|
session.__failedTimer = null;
|
|
70
71
|
}
|
|
71
72
|
this.__sessions.delete(connId);
|
|
73
|
+
// 显式关闭 rpc 发送队列:dc.onclose 路径中 `sessions.get(connId)` 已返回 undefined 而短路,
|
|
74
|
+
// 此处不主动 close 会丢失 drop 汇总 remoteLog 诊断
|
|
75
|
+
if (session.rpcSendQueue) {
|
|
76
|
+
session.rpcSendQueue.close();
|
|
77
|
+
session.rpcSendQueue = null;
|
|
78
|
+
session.rpcChannel = null;
|
|
79
|
+
}
|
|
72
80
|
// 先 detach 事件,防止 pc.close() 异步触发 onconnectionstatechange 删除新 session
|
|
73
81
|
session.pc.onconnectionstatechange = null;
|
|
74
82
|
session.pc.onicecandidate = null;
|
|
@@ -86,15 +94,16 @@ export class WebRtcPeer {
|
|
|
86
94
|
await Promise.all(closing);
|
|
87
95
|
}
|
|
88
96
|
|
|
89
|
-
/** 向所有已打开的 rpcChannel
|
|
97
|
+
/** 向所有已打开的 rpcChannel 广播(大消息自动分片,经由 RpcSendQueue 流控) */
|
|
90
98
|
broadcast(payload) {
|
|
91
99
|
const jsonStr = JSON.stringify(payload);
|
|
92
100
|
for (const [connId, session] of this.__sessions) {
|
|
93
|
-
const
|
|
94
|
-
if (
|
|
101
|
+
const q = session.rpcSendQueue;
|
|
102
|
+
if (q && session.rpcChannel?.readyState === 'open') {
|
|
95
103
|
try {
|
|
96
|
-
|
|
104
|
+
q.send(jsonStr);
|
|
97
105
|
} catch (err) {
|
|
106
|
+
// buildChunks 抛(maxMessageSize 配置错)等罕见情况
|
|
98
107
|
this.__logDebug(`[${connId}] broadcast send failed: ${err.message}`);
|
|
99
108
|
}
|
|
100
109
|
}
|
|
@@ -129,6 +138,13 @@ export class WebRtcPeer {
|
|
|
129
138
|
this.logger.info?.(`${this.__rtcTag} ICE restart offer from ${connId}, renegotiating`);
|
|
130
139
|
try {
|
|
131
140
|
await existing.pc.setRemoteDescription({ type: 'offer', sdp: msg.payload.sdp });
|
|
141
|
+
// 重协商 SDP 可能变更 a=max-message-size,同步刷新 queue 分片阈值;
|
|
142
|
+
// queue 中已入队的 chunks 按旧值分片保留,新消息用新值
|
|
143
|
+
const newMMS = this.__resolveMaxMessageSize(existing.pc, msg.payload.sdp);
|
|
144
|
+
if (newMMS !== existing.remoteMaxMessageSize) {
|
|
145
|
+
existing.remoteMaxMessageSize = newMMS;
|
|
146
|
+
if (existing.rpcSendQueue) existing.rpcSendQueue.maxMessageSize = newMMS;
|
|
147
|
+
}
|
|
132
148
|
const answer = await existing.pc.createAnswer();
|
|
133
149
|
await existing.pc.setLocalDescription(answer);
|
|
134
150
|
this.__onSend({
|
|
@@ -200,15 +216,9 @@ export class WebRtcPeer {
|
|
|
200
216
|
|
|
201
217
|
const pc = new this.__PeerConnection({ iceServers });
|
|
202
218
|
|
|
203
|
-
|
|
204
|
-
// 远端:从 offer SDP 的 a=max-message-size 解析(缺失则 RFC 8841 默认 65536)
|
|
205
|
-
// 本地:pc.maxMessageSize(pion 为 65536,ndc/werift 无此属性则不限制)
|
|
206
|
-
const mmsMatch = msg.payload.sdp?.match(/a=max-message-size:(\d+)/);
|
|
207
|
-
const remoteMMS = mmsMatch ? parseInt(mmsMatch[1], 10) : 65536;
|
|
208
|
-
const localMMS = pc.maxMessageSize ?? remoteMMS;
|
|
209
|
-
const remoteMaxMessageSize = Math.min(remoteMMS, localMMS);
|
|
219
|
+
const remoteMaxMessageSize = this.__resolveMaxMessageSize(pc, msg.payload.sdp);
|
|
210
220
|
|
|
211
|
-
const session = { pc, rpcChannel: null, fileChannels: new Set(), remoteMaxMessageSize, nextMsgId: 1 };
|
|
221
|
+
const session = { pc, rpcChannel: null, rpcSendQueue: null, fileChannels: new Set(), remoteMaxMessageSize, nextMsgId: 1 };
|
|
212
222
|
this.__sessions.set(connId, session);
|
|
213
223
|
|
|
214
224
|
// ICE candidate → 发给 UI,并统计各类型 candidate 数量
|
|
@@ -363,9 +373,29 @@ export class WebRtcPeer {
|
|
|
363
373
|
}
|
|
364
374
|
|
|
365
375
|
__setupDataChannel(connId, dc) {
|
|
376
|
+
// rpc DC 发送流控:每条 rpc DC 绑定一个 RpcSendQueue,广播与 files RPC 响应均经此出口
|
|
377
|
+
const session = this.__sessions.get(connId);
|
|
378
|
+
if (session && dc.label === 'rpc') {
|
|
379
|
+
if ('bufferedAmountLowThreshold' in dc) {
|
|
380
|
+
dc.bufferedAmountLowThreshold = DC_LOW_WATER_MARK;
|
|
381
|
+
}
|
|
382
|
+
session.rpcSendQueue = new RpcSendQueue({
|
|
383
|
+
dc,
|
|
384
|
+
maxMessageSize: session.remoteMaxMessageSize,
|
|
385
|
+
getNextMsgId: () => session.nextMsgId++,
|
|
386
|
+
logger: this.logger,
|
|
387
|
+
tag: `conn=${connId}`,
|
|
388
|
+
});
|
|
389
|
+
dc.onbufferedamountlow = () => {
|
|
390
|
+
session.rpcSendQueue?.onBufferedAmountLow();
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
366
394
|
const reassembler = createReassembler((jsonStr) => {
|
|
367
395
|
const payload = JSON.parse(jsonStr);
|
|
368
396
|
// DC 探测:立即回复,不走 gateway
|
|
397
|
+
// 故意绕过 RpcSendQueue:probe-ack 仅用于测量传输层(SCTP/DTLS)健康,
|
|
398
|
+
// 走 queue 会把应用层积压压力错误地映射到"DC 不通"上。
|
|
369
399
|
if (payload.type === 'probe') {
|
|
370
400
|
try { dc.send(JSON.stringify({ type: 'probe-ack' })); }
|
|
371
401
|
catch { /* DC 已关闭,忽略 */ }
|
|
@@ -374,15 +404,10 @@ export class WebRtcPeer {
|
|
|
374
404
|
if (payload.type === 'req') {
|
|
375
405
|
// coclaw.files.* 方法本地处理,不转发 gateway
|
|
376
406
|
if (payload.method?.startsWith('coclaw.files.') && this.__onFileRpc) {
|
|
377
|
-
const
|
|
407
|
+
const sess = this.__sessions.get(connId);
|
|
378
408
|
const sendFn = (response) => {
|
|
379
409
|
try {
|
|
380
|
-
|
|
381
|
-
dc, JSON.stringify(response),
|
|
382
|
-
session?.remoteMaxMessageSize ?? 65536,
|
|
383
|
-
() => session.nextMsgId++,
|
|
384
|
-
this.logger,
|
|
385
|
-
);
|
|
410
|
+
sess?.rpcSendQueue?.send(JSON.stringify(response));
|
|
386
411
|
} catch (err) {
|
|
387
412
|
this.__logDebug(`[${connId}] sendFn failed: ${err.message}`);
|
|
388
413
|
}
|
|
@@ -404,8 +429,12 @@ export class WebRtcPeer {
|
|
|
404
429
|
this.__remoteLog(`dc.closed conn=${connId} label=${dc.label}`);
|
|
405
430
|
this.logger.info?.(`${this.__rtcTag} [${connId}] DataChannel "${dc.label}" closed`);
|
|
406
431
|
reassembler.reset();
|
|
407
|
-
const
|
|
408
|
-
if (
|
|
432
|
+
const sess = this.__sessions.get(connId);
|
|
433
|
+
if (sess && dc.label === 'rpc') {
|
|
434
|
+
sess.rpcSendQueue?.close();
|
|
435
|
+
sess.rpcSendQueue = null;
|
|
436
|
+
sess.rpcChannel = null;
|
|
437
|
+
}
|
|
409
438
|
};
|
|
410
439
|
dc.onerror = (err) => {
|
|
411
440
|
this.__remoteLog(`dc.error conn=${connId} label=${dc.label}`);
|
|
@@ -431,8 +460,24 @@ export class WebRtcPeer {
|
|
|
431
460
|
? 'none'
|
|
432
461
|
/* c8 ignore next -- ?? fallback for missing readyState */
|
|
433
462
|
: [...session.fileChannels].map((dc) => `${dc.label}=${dc.readyState ?? '?'}`).join(',');
|
|
434
|
-
|
|
435
|
-
|
|
463
|
+
const q = session.rpcSendQueue;
|
|
464
|
+
const queueInfo = q
|
|
465
|
+
? `queueLen=${q.queue.length} queueBytes=${q.queueBytes} dropped=${q.droppedCount}`
|
|
466
|
+
: 'queue=none';
|
|
467
|
+
this.__remoteLog(`rtc.dump conn=${connId} state=${state} sessions=${this.__sessions.size} rpc=${rpcState} ${queueInfo} fileCount=${session.fileChannels.size} files=[${fileSummary}]`);
|
|
468
|
+
this.logger.info?.(`${this.__rtcTag} [${connId}] dump state=${state} rpc=${rpcState} ${queueInfo} fileCount=${session.fileChannels.size} files=${fileSummary}`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* 分片阈值 = min(远端能接收, 本地能发送)
|
|
473
|
+
* 远端:从 SDP 的 a=max-message-size 解析(缺失则 RFC 8841 默认 65536)
|
|
474
|
+
* 本地:pc.maxMessageSize(pion 为 65536,ndc/werift 无此属性则不限制)
|
|
475
|
+
*/
|
|
476
|
+
__resolveMaxMessageSize(pc, sdp) {
|
|
477
|
+
const mmsMatch = sdp?.match(/a=max-message-size:(\d+)/);
|
|
478
|
+
const remoteMMS = mmsMatch ? parseInt(mmsMatch[1], 10) : 65536;
|
|
479
|
+
const localMMS = pc.maxMessageSize ?? remoteMMS;
|
|
480
|
+
return Math.min(remoteMMS, localMMS);
|
|
436
481
|
}
|
|
437
482
|
|
|
438
483
|
__logNominatedPair(connId, pair) {
|