@coclaw/openclaw-coclaw 0.19.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.
@@ -2,6 +2,9 @@
2
2
  "id": "openclaw-coclaw",
3
3
  "name": "CoClaw",
4
4
  "description": "OpenClaw CoClaw channel plugin for remote chat",
5
+ "activation": {
6
+ "onStartup": true
7
+ },
5
8
  "configSchema": {
6
9
  "type": "object",
7
10
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat",
@@ -483,8 +483,13 @@ export class WebRtcPeer {
483
483
  this.__remoteLog(`dc.received conn=${connId} label=${channel.label}`);
484
484
  this.logger.info?.(`${this.__rtcTag} [${connId}] DataChannel "${channel.label}" received`);
485
485
  if (channel.label === 'rpc') {
486
+ // rpcChannel 在 sync 路径赋值,作为 setup 内身份重核的依据;setup 本身改 async
487
+ // 后变 fire-and-forget(WebRTC 实现的 ondatachannel 是 sync 回调,不能 await)
486
488
  session.rpcChannel = channel;
487
- 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
+ });
488
493
  } else if (channel.label.startsWith('file:')) {
489
494
  // 跟踪 file DC 用于诊断 dump:保留全量历史以便排查"传输到一半挂掉"场景,
490
495
  // 但用 FIFO 上限避免长会话内无界增长
@@ -540,86 +545,18 @@ export class WebRtcPeer {
540
545
  }
541
546
  }
542
547
 
543
- __setupDataChannel(connId, dc) {
548
+ async __setupDataChannel(connId, dc) {
544
549
  // rpc DC 发送流控:MemoryQueue(admission + 边沿日志)+ RpcDcSender(分片 + 背压)
545
550
  // 通过消费循环串起来。广播 / sendTo / files sendFn 都向 queue.enqueue,sender 从 queue 拉
546
551
  const session = this.__sessions.get(connId);
547
- if (session && dc.label === 'rpc') {
548
- // 防御:罕见情况下 session 已有旧三件套(UI 重建 rpc DC 等),先 close + destroy 旧实例。
549
- // 否则旧 consumeLoop 会永挂在旧 queue iterator 上不退出,孤儿 sender 持有旧 dc 引用,
550
- // 导致内存泄漏。新三件套在下面赋值后 finally 的 identity guard 会保护 OLD loop 不误清新字段。
551
- if (session.rpcDcSender || session.rpcQueue) {
552
- session.rpcDcSender?.close();
553
- // fire-and-forget:__setupDataChannel 是 sync,不能 await;sender.close 已 reject
554
- // 所有 BAL waiter,旧 loop 在 microtask 内 SENDER_CLOSED break 进入 finally
555
- session.rpcQueue?.destroy().catch(() => { /* c8 ignore next -- 极冷防御 */ });
556
- session.rpcConsumeLoop?.catch?.(() => { /* c8 ignore next -- 极冷防御 */ });
557
- }
558
- if ('bufferedAmountLowThreshold' in dc) {
559
- dc.bufferedAmountLowThreshold = DC_LOW_WATER_MARK;
560
- }
561
- const queue = new MemoryQueue({
562
- id: connId,
563
- maxMessageBytes: MAX_SINGLE_MSG_BYTES,
564
- bypassAdmission: isAgentRunResponse,
565
- logger: this.logger,
566
- tag: `conn=${connId}`,
567
- });
568
- const sender = new RpcDcSender({
569
- dc,
570
- maxMessageSize: session.remoteMaxMessageSize,
571
- getNextMsgId: () => session.nextMsgId++,
572
- logger: this.logger,
573
- tag: `conn=${connId}`,
574
- });
575
- session.rpcQueue = queue;
576
- session.rpcDcSender = sender;
577
- // 闭包捕获本次 sender 局部引用,而不是读 session.rpcDcSender 字段。
578
- // 同 session rebuild 后字段会指向新 sender,旧 dc 的 BAL 滞后事件若读字段
579
- // 会错唤醒新 sender;捕获局部引用后旧 dc 触发 BAL 调的是已 close 的旧 sender,
580
- // onBufferedAmountLow 在 splice 空 waiter 数组上无副作用
581
- dc.onbufferedamountlow = () => {
582
- sender.onBufferedAmountLow();
583
- };
584
- // 起消费循环:从 queue 拉一条 → await sender.send()。sender close 时循环 break。
585
- // finally 兜底关闭:覆盖 dc.send 中途抛错 / 异常退出场景——dc.onclose 不一定会及时
586
- // 触发清理(如 readyState 短暂 open 但 send 失败),主动 close+destroy 避免 queue/sender
587
- // 残留导致后续 broadcast 入队后无人消费。两个 close/destroy 都幂等。
588
- session.rpcConsumeLoop = (async () => {
589
- try {
590
- for await (const str of queue) {
591
- try {
592
- await sender.send(str);
593
- } catch (err) {
594
- if (err.code === 'SENDER_CLOSED') break;
595
- // safe-wrap:logger.warn 自身抛不能让消费循环挂掉
596
- try { this.logger.warn?.(`${this.__rtcTag} [${connId}] rpc-dc.send-failed code=${err.code} size=${str.length}`); }
597
- /* c8 ignore next -- logger 自身抛是极冷防御路径 */
598
- catch { /* swallow */ }
599
- }
600
- }
601
- } finally {
602
- sender.close();
603
- await queue.destroy().catch(() => {});
604
- // 防御性清字段:若 loop 因 sender 内部错误自行退出(dc.onclose / closeByConnId
605
- // 都不是触发方),三字段会暂留非 null 直到 dc.onclose 最终到达。期间 producer
606
- // 看到非 null 会以为通道活着——虽然 enqueue 安全返 false,但减少误导窗口。
607
- // 身份比对避免误清"dc.onclose 已先清掉、并被新一轮 setup 装入新实例"的字段。
608
- if (session.rpcQueue === queue) {
609
- session.rpcQueue = null;
610
- session.rpcDcSender = null;
611
- session.rpcConsumeLoop = null;
612
- }
613
- }
614
- })();
615
- // unhandled rejection 防御:循环 promise 自身极少抛(仅 iterator 实现 bug),但
616
- // 一旦逃逸为 unhandled rejection 会让 plugin/gateway 进程退出
617
- session.rpcConsumeLoop.catch(() => {});
618
- }
619
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 内身份守卫吸收。
620
557
  const reassembler = createReassembler((jsonStr) => {
621
558
  const payload = JSON.parse(jsonStr);
622
- // identity guard:与 dc.onclose 的 identity guard 对称(line ~682)。
559
+ // identity guard:与 dc.onclose 的 identity guard 对称。
623
560
  // DC 重建后旧 dc 的 message event 仍可能在 microtask 队列里派发;若不核身份,旧请求会
624
561
  // 注入 __onRequest 或 enqueue 到新 rpcQueue。session 已删除时(rpcChannel===null 或
625
562
  // sess undefined)也按 stale 处理,避免向已清空的 session 注入消息。
@@ -709,6 +646,90 @@ export class WebRtcPeer {
709
646
  this.logger.warn?.(`${this.__rtcTag} [${connId}] DC message error: ${err.message}`);
710
647
  }
711
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(() => {});
712
733
  }
713
734
 
714
735
  /**
@@ -722,7 +743,10 @@ export class WebRtcPeer {
722
743
  const queueInfo = q
723
744
  ? (() => {
724
745
  const s = q.stats();
725
- return `queueLen=${s.memCount} queueBytes=${s.memBytes} dropped=${s.droppedCount}`;
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}`;
726
750
  })()
727
751
  : 'queue=none';
728
752
  this.__remoteLog(`rtc.dump conn=${connId} state=${state} sessions=${this.__sessions.size} rpc=${rpcState} ${queueInfo} fileCount=${session.fileChannels.size} files=[${fileSummary}]`);