@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 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.13.2",
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.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
+ }
@@ -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
- remoteLog(`bridge.webrtc-impl impl=${implLabel}`);
972
- remoteLog(`bridge.started version=${this.__pluginVersion}`);
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
- timeoutMs: 60_000,
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 chunkAndSend(dc, jsonStr, maxMessageSize, getNextMsgId, logger) {
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
- logger?.info?.(`[dc-chunking] chunking msgId=${msgId}: ${fullBytes.byteLength} bytes → ${totalChunks} chunks (maxMsgSize=${maxMessageSize})`);
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 { chunkAndSend, createReassembler } from './dc-chunking.js';
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 dc = session.rpcChannel;
94
- if (dc?.readyState === 'open') {
101
+ const q = session.rpcSendQueue;
102
+ if (q && session.rpcChannel?.readyState === 'open') {
95
103
  try {
96
- chunkAndSend(dc, jsonStr, session.remoteMaxMessageSize, () => session.nextMsgId++, this.logger);
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
- // 分片阈值 = min(远端能接收, 本地能发送)
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 session = this.__sessions.get(connId);
407
+ const sess = this.__sessions.get(connId);
378
408
  const sendFn = (response) => {
379
409
  try {
380
- chunkAndSend(
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 session = this.__sessions.get(connId);
408
- if (session && dc.label === 'rpc') session.rpcChannel = null;
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
- this.__remoteLog(`rtc.dump conn=${connId} state=${state} sessions=${this.__sessions.size} rpc=${rpcState} fileCount=${session.fileChannels.size} files=[${fileSummary}]`);
435
- this.logger.info?.(`${this.__rtcTag} [${connId}] dump state=${state} rpc=${rpcState} fileCount=${session.fileChannels.size} files=${fileSummary}`);
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) {