@coclaw/openclaw-coclaw 0.18.0 → 0.19.2

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.
@@ -1,5 +1,7 @@
1
1
  import { createReassembler } from './dc-chunking.js';
2
- import { RpcSendQueue, DC_LOW_WATER_MARK } from './rpc-send-queue.js';
2
+ import { MemoryQueue } from '../utils/memory-queue.js';
3
+ import { RpcDcSender, DC_LOW_WATER_MARK, MAX_SINGLE_MSG_BYTES } from './rpc-dc-sender.js';
4
+ import { isAgentRunResponse } from './agent-run-response.js';
3
5
  import { remoteLog } from '../remote-log.js';
4
6
 
5
7
  // 单个 session 内 file DC 历史快照的容量上限(满后按 FIFO 淘汰最老条目)。
@@ -41,7 +43,7 @@ export class WebRtcPeer {
41
43
  this.__PeerConnection = PeerConnection;
42
44
  this.__impl = impl ?? null;
43
45
  this.__rtcTag = impl ? `[coclaw/rtc:${impl}]` : '[coclaw/rtc]';
44
- /** @type {Map<string, { pc: object, rpcChannel: object|null, rpcSendQueue: RpcSendQueue|null, fileChannels: Set, remoteMaxMessageSize: number, nextMsgId: number }>} */
46
+ /** @type {Map<string, { pc: object, rpcChannel: object|null, rpcQueue: MemoryQueue|null, rpcDcSender: RpcDcSender|null, rpcConsumeLoop: Promise<void>|null, fileChannels: Set, remoteMaxMessageSize: number, nextMsgId: number }>} */
45
47
  this.__sessions = new Map();
46
48
  }
47
49
 
@@ -64,6 +66,23 @@ export class WebRtcPeer {
64
66
  async closeByConnId(connId) {
65
67
  const session = this.__sessions.get(connId);
66
68
  if (!session) return;
69
+ // 先 detach 所有 PC 事件,再做后续 await 链。两个目的:
70
+ // 1. 防止 pc.close() 异步触发 onconnectionstatechange 误删 connId 复用后的新 session
71
+ // 2. RPC 清理含 await(queue.destroy + consumeLoop),期间若旧 PC 还有滞后回调
72
+ // (某些 WebRTC 实现 close 后仍投递事件),可能通过 Map.get(connId) 拿到新 session 误操作
73
+ // 特别是 ondatachannel:晚到的 channel 会被 __setupDataChannel 装到新 session
74
+ session.pc.onconnectionstatechange = null;
75
+ session.pc.onicecandidate = null;
76
+ session.pc.ondatachannel = null;
77
+ if ('onselectedcandidatepairchange' in session.pc) {
78
+ session.pc.onselectedcandidatepairchange = null;
79
+ }
80
+ if ('oniceconnectionstatechange' in session.pc) {
81
+ session.pc.oniceconnectionstatechange = null;
82
+ }
83
+ if ('onicegatheringstatechange' in session.pc) {
84
+ session.pc.onicegatheringstatechange = null;
85
+ }
67
86
  // 清理 failed TTL 定时器
68
87
  if (session.__failedTimer) {
69
88
  clearTimeout(session.__failedTimer);
@@ -81,22 +100,17 @@ export class WebRtcPeer {
81
100
  session.__pluginProbeInFlight = null;
82
101
  }
83
102
  this.__sessions.delete(connId);
84
- // 显式关闭 rpc 发送队列:dc.onclose 路径中 `sessions.get(connId)` 已返回 undefined 而短路,
85
- // 此处不主动 close 会丢失 drop 汇总 remoteLog 诊断
86
- if (session.rpcSendQueue) {
87
- session.rpcSendQueue.close();
88
- session.rpcSendQueue = null;
103
+ // 显式关闭 rpc 链路:dc.onclose 路径中 `sessions.get(connId)` 已返回 undefined 而短路,
104
+ // 此处不主动 close 会丢失 drop 汇总 remoteLog 诊断 + consumeLoop 泄漏
105
+ if (session.rpcDcSender || session.rpcQueue) {
106
+ session.rpcDcSender?.close();
107
+ await session.rpcQueue?.destroy();
108
+ if (session.rpcConsumeLoop) await session.rpcConsumeLoop.catch(() => {});
109
+ session.rpcDcSender = null;
110
+ session.rpcQueue = null;
111
+ session.rpcConsumeLoop = null;
89
112
  session.rpcChannel = null;
90
113
  }
91
- // 先 detach 事件,防止 pc.close() 异步触发 onconnectionstatechange 删除新 session
92
- session.pc.onconnectionstatechange = null;
93
- session.pc.onicecandidate = null;
94
- if ('onselectedcandidatepairchange' in session.pc) {
95
- session.pc.onselectedcandidatepairchange = null;
96
- }
97
- if ('oniceconnectionstatechange' in session.pc) {
98
- session.pc.oniceconnectionstatechange = null;
99
- }
100
114
  await session.pc.close();
101
115
  this.__remoteLog(`rtc.closed conn=${connId}`);
102
116
  this.logger.info?.(`${this.__rtcTag} [${connId}] closed`);
@@ -108,7 +122,7 @@ export class WebRtcPeer {
108
122
  await Promise.all(closing);
109
123
  }
110
124
 
111
- /** 向所有已打开的 rpcChannel 广播(大消息自动分片,经由 RpcSendQueue 流控) */
125
+ /** 向所有已打开的 rpcChannel 广播(大消息自动分片,经由 MemoryQueue + RpcDcSender 流控) */
112
126
  broadcast(payload) {
113
127
  let jsonStr;
114
128
  try {
@@ -120,9 +134,14 @@ export class WebRtcPeer {
120
134
  }
121
135
  if (typeof jsonStr !== 'string') return; // payload 是 undefined/symbol 时 stringify 返回 undefined
122
136
  for (const session of this.__sessions.values()) {
123
- const q = session.rpcSendQueue;
137
+ const q = session.rpcQueue;
124
138
  if (q && session.rpcChannel?.readyState === 'open') {
125
- q.send(jsonStr);
139
+ // fire-and-forget:admission 决策返回 boolean Promise,broadcast 不关心结果;
140
+ // 内部异常(mutex 等极冷路径)catch 防 unhandled rejection
141
+ q.enqueue(jsonStr).catch((err) => {
142
+ /* c8 ignore next -- enqueue 仅在 mutex 异常等极冷路径 reject;类型校验已由调用方完成 */
143
+ this.__logDebug(`broadcast enqueue error: ${err?.message}`);
144
+ });
126
145
  }
127
146
  }
128
147
  }
@@ -130,14 +149,18 @@ export class WebRtcPeer {
130
149
  /**
131
150
  * 向指定 connId 的 rpc DC 单播一个 JSON 帧(不走 server 中转)。
132
151
  * 若 session/DC 未就绪或被发送队列拒收(队列满等)返回 false,由调用方决定是否重试。
152
+ *
153
+ * 阶段 1 改造:enqueue 是 async(mutex 内 admission 决策),sendTo 也变 async;调用方
154
+ * 已在 async 上下文(realtime-bridge.js 的 ws.message handler),增加 `await` 即可。
155
+ *
133
156
  * @param {string} connId
134
157
  * @param {object} payload - 完整的 JSON 帧(通常是 { type: 'event', event, payload })
135
- * @returns {boolean} true=已入队发送;false=session 不存在 / DC 未 open / payload 不可序列化 / 发送队列拒收
158
+ * @returns {Promise<boolean>} true=已入队发送;false=session 不存在 / DC 未 open / payload 不可序列化 / 发送队列拒收
136
159
  */
137
- sendTo(connId, payload) {
160
+ async sendTo(connId, payload) {
138
161
  const session = this.__sessions.get(connId);
139
162
  if (!session) return false;
140
- const q = session.rpcSendQueue;
163
+ const q = session.rpcQueue;
141
164
  if (!q || session.rpcChannel?.readyState !== 'open') return false;
142
165
  let jsonStr;
143
166
  try {
@@ -147,7 +170,13 @@ export class WebRtcPeer {
147
170
  return false;
148
171
  }
149
172
  if (typeof jsonStr !== 'string') return false;
150
- return q.send(jsonStr);
173
+ try {
174
+ return await q.enqueue(jsonStr);
175
+ } catch (err) {
176
+ /* c8 ignore next 3 -- enqueue 仅在 mutex 异常等极冷路径 reject;与 broadcast/file sendFn 对称 */
177
+ this.__logDebug(`[${connId}] sendTo enqueue error: ${err?.message}`);
178
+ return false;
179
+ }
151
180
  }
152
181
 
153
182
  async __handleOffer(msg) {
@@ -180,12 +209,13 @@ export class WebRtcPeer {
180
209
  this.logger.info?.(`${this.__rtcTag} ICE restart offer from ${connId}, renegotiating`);
181
210
  try {
182
211
  await existing.pc.setRemoteDescription({ type: 'offer', sdp: msg.payload.sdp });
183
- // 重协商 SDP 可能变更 a=max-message-size,同步刷新 queue 分片阈值;
184
- // queue 中已入队的 chunks 按旧值分片保留,新消息用新值
212
+ // 重协商 SDP 可能变更 a=max-message-size,同步刷新 sender 分片阈值;
213
+ // queue 存的是完整字符串(buildChunks sender.send 内同步完成),
214
+ // 已开始分片的当前消息用旧 size,下一条消息用新 size
185
215
  const newMMS = this.__resolveMaxMessageSize(existing.pc, msg.payload.sdp);
186
216
  if (newMMS !== existing.remoteMaxMessageSize) {
187
217
  existing.remoteMaxMessageSize = newMMS;
188
- if (existing.rpcSendQueue) existing.rpcSendQueue.maxMessageSize = newMMS;
218
+ if (existing.rpcDcSender) existing.rpcDcSender.maxMessageSize = newMMS;
189
219
  }
190
220
  const answer = await existing.pc.createAnswer();
191
221
  await existing.pc.setLocalDescription(answer);
@@ -242,13 +272,20 @@ export class WebRtcPeer {
242
272
  const iceServers = [];
243
273
  if (msg.turnCreds) {
244
274
  const { urls, username, credential } = msg.turnCreds;
245
- for (const url of urls) {
246
- const server = { urls: url };
247
- if (url.startsWith('turn:') || url.startsWith('turns:')) {
248
- server.username = username;
249
- server.credential = credential;
275
+ // 防御:urls 必须是 string 数组;非数组(含 undefined / 单 string)跳过,
276
+ // 避免 for-of undefined 抛错或单 string 被字符级迭代成无效 iceServers
277
+ if (Array.isArray(urls)) {
278
+ for (const url of urls) {
279
+ if (typeof url !== 'string') continue;
280
+ const server = { urls: url };
281
+ if (url.startsWith('turn:') || url.startsWith('turns:')) {
282
+ server.username = username;
283
+ server.credential = credential;
284
+ }
285
+ iceServers.push(server);
250
286
  }
251
- iceServers.push(server);
287
+ } else {
288
+ this.logger.warn?.(`${this.__rtcTag} ignored malformed turnCreds.urls (expected string[], got ${typeof urls})`);
252
289
  }
253
290
  }
254
291
 
@@ -268,7 +305,7 @@ export class WebRtcPeer {
268
305
 
269
306
  const remoteMaxMessageSize = this.__resolveMaxMessageSize(pc, msg.payload.sdp);
270
307
 
271
- const session = { pc, rpcChannel: null, rpcSendQueue: null, fileChannels: new Set(), remoteMaxMessageSize, nextMsgId: 1 };
308
+ const session = { pc, rpcChannel: null, rpcQueue: null, rpcDcSender: null, rpcConsumeLoop: null, fileChannels: new Set(), remoteMaxMessageSize, nextMsgId: 1 };
272
309
  this.__sessions.set(connId, session);
273
310
 
274
311
  // ICE candidate → 发给 UI,并统计各类型 candidate 数量
@@ -287,6 +324,11 @@ export class WebRtcPeer {
287
324
  hostAddrs.length = 0;
288
325
  };
289
326
  pc.onicecandidate = ({ candidate }) => {
327
+ // pc identity guard:旧 PC 在 closeByConnId 之后微任务里仍可能投递 onicecandidate
328
+ // (属性置 null 不阻止已 dispatch 的回调);此时 connId 可能已被新 session 复用,
329
+ // 旧 candidate 不应转发给 UI,否则 UI 会把旧 PC 的 candidate 加到新 PC 上
330
+ const cur = this.__sessions.get(connId);
331
+ if (!cur || cur.pc !== pc) return;
290
332
  if (!candidate) {
291
333
  // 浏览器路径:gathering 完成通过 null candidate 通知
292
334
  flushGatherDiag();
@@ -319,6 +361,9 @@ export class WebRtcPeer {
319
361
  // gathering→ 重置 flag 支持 ICE restart;complete→ flush 汇总
320
362
  if ('onicegatheringstatechange' in pc) {
321
363
  pc.onicegatheringstatechange = () => {
364
+ // pc identity guard:与其他 handler 对称,旧 PC 微任务迟到不污染新 session 的 gather diag
365
+ const cur = this.__sessions.get(connId);
366
+ if (!cur || cur.pc !== pc) return;
322
367
  const state = pc.iceGatheringState;
323
368
  if (state === 'gathering') {
324
369
  gatheringEmitted = false;
@@ -414,23 +459,37 @@ export class WebRtcPeer {
414
459
  // pion: 选中的 candidate pair 通过独立事件上报
415
460
  if ('onselectedcandidatepairchange' in pc) {
416
461
  pc.onselectedcandidatepairchange = () => {
462
+ // pc identity guard:旧 PC 的迟到回调会用旧 pc.selectedCandidatePair 数据
463
+ // + 闭包 connId 拼出"旧 pair + 新 connId"的混合日志,并往 UI 转发过时 transport
464
+ const cur = this.__sessions.get(connId);
465
+ if (!cur || cur.pc !== pc) return;
417
466
  const pair = pc.selectedCandidatePair;
418
467
  if (pair) {
419
468
  this.__logNominatedPair(connId, pair);
420
469
  }
421
470
  // ICE restart 或初次选中都会触发;让出一次 CPU 后再单播 transport 信息。
422
471
  // 签名去重保证 pair 不变时不会重复发送。
423
- queueMicrotask(() => this.__sendPeerTransport(connId));
472
+ queueMicrotask(() => { this.__sendPeerTransport(connId).catch(() => {}); });
424
473
  };
425
474
  }
426
475
 
427
476
  // 监听 UI 创建的 DataChannel
428
477
  pc.ondatachannel = ({ channel }) => {
478
+ // pc identity guard:旧 PC 在 closeByConnId 之后微任务里仍可能投递 ondatachannel
479
+ // (属性置 null 不阻止已 dispatch 的回调);此时 connId 可能已被新 session 复用,
480
+ // 旧 channel 必须被忽略,否则会进 __setupDataChannel 把旧 dc 装到新 session
481
+ const cur = this.__sessions.get(connId);
482
+ if (!cur || cur.pc !== pc) return;
429
483
  this.__remoteLog(`dc.received conn=${connId} label=${channel.label}`);
430
484
  this.logger.info?.(`${this.__rtcTag} [${connId}] DataChannel "${channel.label}" received`);
431
485
  if (channel.label === 'rpc') {
486
+ // rpcChannel 在 sync 路径赋值,作为 setup 内身份重核的依据;setup 本身改 async
487
+ // 后变 fire-and-forget(WebRTC 实现的 ondatachannel 是 sync 回调,不能 await)
432
488
  session.rpcChannel = channel;
433
- this.__setupDataChannel(connId, channel);
489
+ this.__setupDataChannel(connId, channel).catch((err) => {
490
+ /* c8 ignore next 2 -- setup 内部已经 try/catch 所有 await;此处仅防御性兜底 */
491
+ this.logger.warn?.(`${this.__rtcTag} [${connId}] __setupDataChannel error: ${err?.message}`);
492
+ });
434
493
  } else if (channel.label.startsWith('file:')) {
435
494
  // 跟踪 file DC 用于诊断 dump:保留全量历史以便排查"传输到一半挂掉"场景,
436
495
  // 但用 FIFO 上限避免长会话内无界增长
@@ -486,29 +545,27 @@ export class WebRtcPeer {
486
545
  }
487
546
  }
488
547
 
489
- __setupDataChannel(connId, dc) {
490
- // rpc DC 发送流控:每条 rpc DC 绑定一个 RpcSendQueue,广播与 files RPC 响应均经此出口
548
+ async __setupDataChannel(connId, dc) {
549
+ // rpc DC 发送流控:MemoryQueue(admission + 边沿日志)+ RpcDcSender(分片 + 背压)
550
+ // 通过消费循环串起来。广播 / sendTo / files sendFn 都向 queue.enqueue,sender 从 queue 拉
491
551
  const session = this.__sessions.get(connId);
492
- if (session && dc.label === 'rpc') {
493
- if ('bufferedAmountLowThreshold' in dc) {
494
- dc.bufferedAmountLowThreshold = DC_LOW_WATER_MARK;
495
- }
496
- session.rpcSendQueue = new RpcSendQueue({
497
- dc,
498
- maxMessageSize: session.remoteMaxMessageSize,
499
- getNextMsgId: () => session.nextMsgId++,
500
- logger: this.logger,
501
- tag: `conn=${connId}`,
502
- });
503
- dc.onbufferedamountlow = () => {
504
- session.rpcSendQueue?.onBufferedAmountLow();
505
- };
506
- }
507
552
 
553
+ // === 同步部分(首个 await 前):wire dc handlers ===
554
+ // reassembler / dc.onopen / dc.onclose / dc.onerror / dc.onmessage 必须在 async fn 的
555
+ // 同步部分 wire 完成;ondatachannel 是 WebRTC 实现的 sync 回调,调方调用后立即可能 dispatch
556
+ // 消息,handler 不能等 init 完才挂。stale dc 上的事件由 handler 内身份守卫吸收。
508
557
  const reassembler = createReassembler((jsonStr) => {
509
558
  const payload = JSON.parse(jsonStr);
559
+ // identity guard:与 dc.onclose 的 identity guard 对称。
560
+ // DC 重建后旧 dc 的 message event 仍可能在 microtask 队列里派发;若不核身份,旧请求会
561
+ // 注入 __onRequest 或 enqueue 到新 rpcQueue。session 已删除时(rpcChannel===null 或
562
+ // sess undefined)也按 stale 处理,避免向已清空的 session 注入消息。
563
+ const sess = this.__sessions.get(connId);
564
+ if (!sess || sess.rpcChannel !== dc) {
565
+ return;
566
+ }
510
567
  // DC 探测:立即回复,不走 gateway
511
- // 故意绕过 RpcSendQueue:probe-ack 仅用于测量传输层(SCTP/DTLS)健康,
568
+ // 故意绕过 MemoryQueue + RpcDcSender:probe-ack 仅用于测量传输层(SCTP/DTLS)健康,
512
569
  // 走 queue 会把应用层积压压力错误地映射到"DC 不通"上。
513
570
  if (payload.type === 'probe') {
514
571
  try { dc.send(JSON.stringify({ type: 'probe-ack' })); }
@@ -523,7 +580,6 @@ export class WebRtcPeer {
523
580
  if (payload.type === 'req') {
524
581
  // coclaw.files.* 方法本地处理,不转发 gateway
525
582
  if (payload.method?.startsWith('coclaw.files.') && this.__onFileRpc) {
526
- const sess = this.__sessions.get(connId);
527
583
  const sendFn = (response) => {
528
584
  let jsonStr;
529
585
  try {
@@ -533,7 +589,12 @@ export class WebRtcPeer {
533
589
  return;
534
590
  }
535
591
  if (typeof jsonStr !== 'string') return;
536
- sess?.rpcSendQueue?.send(jsonStr);
592
+ // fire-and-forget:files sendFn 历史是 sync void 接口,保留语义;
593
+ // admission 决策内部消化,失败由 overflow 状态机/close 汇总统一上报
594
+ sess?.rpcQueue?.enqueue(jsonStr).catch((err) => {
595
+ /* c8 ignore next -- enqueue 仅在 mutex 异常等极冷路径 reject */
596
+ this.__logDebug(`[${connId}] file sendFn enqueue error: ${err?.message}`);
597
+ });
537
598
  };
538
599
  this.__onFileRpc(payload, sendFn, connId);
539
600
  } else {
@@ -551,7 +612,7 @@ export class WebRtcPeer {
551
612
  // queueMicrotask 让出一次 CPU:确保 pion 侧 selectedCandidatePair setter 已 assign,
552
613
  // 同时避免在 onopen 同步栈里触发可能的重入。
553
614
  if (dc.label === 'rpc') {
554
- queueMicrotask(() => this.__sendPeerTransport(connId));
615
+ queueMicrotask(() => { this.__sendPeerTransport(connId).catch(() => {}); });
555
616
  }
556
617
  };
557
618
  dc.onclose = () => {
@@ -559,9 +620,17 @@ export class WebRtcPeer {
559
620
  this.logger.info?.(`${this.__rtcTag} [${connId}] DataChannel "${dc.label}" closed`);
560
621
  reassembler.reset();
561
622
  const sess = this.__sessions.get(connId);
562
- if (sess && dc.label === 'rpc') {
563
- sess.rpcSendQueue?.close();
564
- sess.rpcSendQueue = null;
623
+ // identity guard:仅当 sess.rpcChannel 仍是这个 dc 时才清三件套。
624
+ // 同 session 重建(UI 重建 rpc DC)后,旧 dc 的 onclose 可能滞后到达,此时
625
+ // session 已挂上新三件套;若不核身份,旧 dc 的 onclose 会瞬间杀死新链路
626
+ if (sess && dc.label === 'rpc' && sess.rpcChannel === dc) {
627
+ // dc.onclose 是 sync 回调,不能 await consumeLoop。仅触发 close + destroy;
628
+ // consumeLoop 通过 sender.close → SENDER_CLOSED → break + queue.destroy → done 自然退出
629
+ sess.rpcDcSender?.close();
630
+ sess.rpcQueue?.destroy().catch(() => {});
631
+ sess.rpcDcSender = null;
632
+ sess.rpcQueue = null;
633
+ sess.rpcConsumeLoop = null;
565
634
  sess.rpcChannel = null;
566
635
  }
567
636
  };
@@ -577,6 +646,90 @@ export class WebRtcPeer {
577
646
  this.logger.warn?.(`${this.__rtcTag} [${connId}] DC message error: ${err.message}`);
578
647
  }
579
648
  };
649
+
650
+ if (!session || dc.label !== 'rpc') return;
651
+
652
+ // === 异步部分:rpc 三件套(队列 / 发送器 / 消费循环)+ 身份守卫 ===
653
+ // 罕见:session 已有旧三件套(UI 重建 rpc DC 等)。先 await close + destroy 旧实例
654
+ // 再造新实例,避免新旧 queue/sender 在同一 session 上并存。await 的目的是让旧实例
655
+ // 完整收尾(FBQ 阶段对应底层 fs 残留清理),MemoryQueue 阶段 destroy 是 sync 完成。
656
+ if (session.rpcDcSender || session.rpcQueue) {
657
+ session.rpcDcSender?.close();
658
+ if (session.rpcQueue) await session.rpcQueue.destroy();
659
+ if (session.rpcConsumeLoop) await session.rpcConsumeLoop.catch(() => { /* c8 ignore next -- 极冷防御 */ });
660
+ }
661
+ // 构造 queue 并 await init。MemoryQueue.init 是 no-op;保留 await 占位,FBQ 阶段
662
+ // init 承担 fs 残留清理。await 期间可能发生 closeByConnId / 同 connId 二次 ondatachannel,
663
+ // 因此后面必须身份重核才能赋 session 字段。
664
+ const queue = new MemoryQueue({
665
+ id: connId,
666
+ maxMessageBytes: MAX_SINGLE_MSG_BYTES,
667
+ bypassAdmission: isAgentRunResponse,
668
+ logger: this.logger,
669
+ tag: `conn=${connId}`,
670
+ });
671
+ await queue.init();
672
+ // 身份重核:init 期间 session 可能被 closeByConnId 从 Map 删除,或被同 connId 二次
673
+ // ondatachannel 把 rpcChannel 替换成新 dc。任一不再成立都视为 stale,destroy queue 后
674
+ // 直接退出,绝不污染 session 三件套字段。
675
+ const sessAfter = this.__sessions.get(connId);
676
+ if (sessAfter !== session || session.rpcChannel !== dc) {
677
+ await queue.destroy();
678
+ return;
679
+ }
680
+ if ('bufferedAmountLowThreshold' in dc) {
681
+ dc.bufferedAmountLowThreshold = DC_LOW_WATER_MARK;
682
+ }
683
+ const sender = new RpcDcSender({
684
+ dc,
685
+ maxMessageSize: session.remoteMaxMessageSize,
686
+ getNextMsgId: () => session.nextMsgId++,
687
+ logger: this.logger,
688
+ tag: `conn=${connId}`,
689
+ });
690
+ session.rpcQueue = queue;
691
+ session.rpcDcSender = sender;
692
+ // 闭包捕获本次 sender 局部引用,而不是读 session.rpcDcSender 字段。
693
+ // 同 session rebuild 后字段会指向新 sender,旧 dc 的 BAL 滞后事件若读字段
694
+ // 会错唤醒新 sender;捕获局部引用后旧 dc 触发 BAL 调的是已 close 的旧 sender,
695
+ // onBufferedAmountLow 在 splice 空 waiter 数组上无副作用
696
+ dc.onbufferedamountlow = () => {
697
+ sender.onBufferedAmountLow();
698
+ };
699
+ // 起消费循环:从 queue 拉一条 → await sender.send()。sender close 时循环 break。
700
+ // finally 兜底关闭:覆盖 dc.send 中途抛错 / 异常退出场景——dc.onclose 不一定会及时
701
+ // 触发清理(如 readyState 短暂 open 但 send 失败),主动 close+destroy 避免 queue/sender
702
+ // 残留导致后续 broadcast 入队后无人消费。两个 close/destroy 都幂等。
703
+ session.rpcConsumeLoop = (async () => {
704
+ try {
705
+ for await (const str of queue) {
706
+ try {
707
+ await sender.send(str);
708
+ } catch (err) {
709
+ if (err.code === 'SENDER_CLOSED') break;
710
+ // safe-wrap:logger.warn 自身抛不能让消费循环挂掉
711
+ try { this.logger.warn?.(`${this.__rtcTag} [${connId}] rpc-dc.send-failed code=${err.code} size=${str.length}`); }
712
+ /* c8 ignore next -- logger 自身抛是极冷防御路径 */
713
+ catch { /* swallow */ }
714
+ }
715
+ }
716
+ } finally {
717
+ sender.close();
718
+ await queue.destroy().catch(() => {});
719
+ // 防御性清字段:若 loop 因 sender 内部错误自行退出(dc.onclose / closeByConnId
720
+ // 都不是触发方),三字段会暂留非 null 直到 dc.onclose 最终到达。期间 producer
721
+ // 看到非 null 会以为通道活着——虽然 enqueue 安全返 false,但减少误导窗口。
722
+ // 身份比对避免误清"dc.onclose 已先清掉、并被新一轮 setup 装入新实例"的字段。
723
+ if (session.rpcQueue === queue) {
724
+ session.rpcQueue = null;
725
+ session.rpcDcSender = null;
726
+ session.rpcConsumeLoop = null;
727
+ }
728
+ }
729
+ })();
730
+ // unhandled rejection 防御:循环 promise 自身极少抛(仅 iterator 实现 bug),但
731
+ // 一旦逃逸为 unhandled rejection 会让 plugin/gateway 进程退出
732
+ session.rpcConsumeLoop.catch(() => {});
580
733
  }
581
734
 
582
735
  /**
@@ -586,9 +739,15 @@ export class WebRtcPeer {
586
739
  __dumpSessionState(connId, session, state) {
587
740
  const rpcState = session.rpcChannel?.readyState ?? 'none';
588
741
  const fileSummary = this.__summarizeFileChannels(session.fileChannels);
589
- const q = session.rpcSendQueue;
742
+ const q = session.rpcQueue;
590
743
  const queueInfo = q
591
- ? `queueLen=${q.queue.length} queueBytes=${q.queueBytes} dropped=${q.droppedCount}`
744
+ ? (() => {
745
+ const s = q.stats();
746
+ // memCount 沿用历史 token 名 queueLen(不改名);其余 5 个 stats 字段保持
747
+ // 与 stats() 内部同名输出。Phase A 阶段 4 个磁盘字段恒为 0/false,是给
748
+ // Phase B 切 FBQ 留形状的占位。
749
+ return `queueLen=${s.memCount} queueBytes=${s.memBytes} diskBytes=${s.diskBytes} writtenBytes=${s.writtenBytes} spilled=${s.spilled} fsBroken=${s.fsBroken} dropped=${s.droppedCount} droppedBytes=${s.droppedBytes}`;
750
+ })()
592
751
  : 'queue=none';
593
752
  this.__remoteLog(`rtc.dump conn=${connId} state=${state} sessions=${this.__sessions.size} rpc=${rpcState} ${queueInfo} fileCount=${session.fileChannels.size} files=[${fileSummary}]`);
594
753
  this.logger.info?.(`${this.__rtcTag} [${connId}] dump state=${state} rpc=${rpcState} ${queueInfo} fileCount=${session.fileChannels.size} files=${fileSummary}`);
@@ -668,7 +827,7 @@ export class WebRtcPeer {
668
827
  *
669
828
  * @param {string} connId
670
829
  */
671
- __sendPeerTransport(connId) {
830
+ async __sendPeerTransport(connId) {
672
831
  const session = this.__sessions.get(connId);
673
832
  if (!session) return;
674
833
  const local = session.pc?.selectedCandidatePair?.local;
@@ -683,13 +842,14 @@ export class WebRtcPeer {
683
842
  const sig = `${payload.candidateType}|${payload.protocol}|${payload.relayProtocol ?? ''}`;
684
843
  if (session.__lastPeerTransportSig === sig) return;
685
844
  session.__lastPeerTransportSig = sig;
686
- const ok = this.sendTo(connId, {
845
+ // sendTo 阶段 1 改 async:await admission 决策结果
846
+ const ok = await this.sendTo(connId, {
687
847
  type: 'event',
688
848
  event: 'coclaw.rtc.peerTransport',
689
849
  payload,
690
850
  });
691
851
  if (!ok) {
692
- // DC 尚未 open,回滚签名以便 dc.onopen 再次触发时重发
852
+ // DC 尚未 open / 队列拒收:回滚签名以便 dc.onopen 再次触发时重发
693
853
  session.__lastPeerTransportSig = null;
694
854
  return;
695
855
  }
@@ -699,7 +859,7 @@ export class WebRtcPeer {
699
859
  /**
700
860
  * 主动探针:在 rpc DC 上发一个 plugin-probe,期待 UI 回 plugin-probe-ack。
701
861
  * 用于区分"pion 报告 connected 但 UI 其实没收到数据"与"UI 真的收到了但没记录事件"。
702
- * 绕过 RpcSendQueue(与 probe-ack 对称),仅测量传输层,不受应用层积压影响。
862
+ * 绕过 MemoryQueue + RpcDcSender(与 probe-ack 对称),仅测量传输层,不受应用层积压影响。
703
863
  * 同一 session 同时只保留一条 in-flight 探针;超时仅打日志,不影响业务恢复。
704
864
  */
705
865
  __sendPluginProbe(connId) {