@coclaw/openclaw-coclaw 0.21.3 → 0.21.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.21.3",
3
+ "version": "0.21.4",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat. Run `openclaw coclaw enroll` after install to connect to CoClaw.",
@@ -7,6 +7,7 @@ import { RpcDcSender, DC_LOW_WATER_MARK, MAX_SINGLE_MSG_BYTES } from './rpc-dc-s
7
7
  import { createRpcDropMonitor } from './rpc-drop-monitor.js';
8
8
  import { isAgentRunResponse } from './agent-run-response.js';
9
9
  import { remoteLog } from '../remote-log.js';
10
+ import { createMutex } from '../utils/mutex.js';
10
11
 
11
12
  // rpc DC 发送队列实现选择(B-stage2 B9b)。
12
13
  // - 'fbq':FileBackedQueue(当前生产默认)—— 长时间后台 / ICE 恢复等慢消化场景溢出到磁盘
@@ -71,6 +72,11 @@ export class WebRtcPeer {
71
72
  this.__rtcTag = impl ? `[coclaw/rtc:${impl}]` : '[coclaw/rtc]';
72
73
  /** @type {Map<string, { pc: object, rpcChannel: object|null, rpcQueue: MemoryQueue|null, rpcDcSender: RpcDcSender|null, rpcConsumeLoop: Promise<void>|null, rpcDropMonitor: object|null, fileChannels: Set, remoteMaxMessageSize: number, nextMsgId: number }>} */
73
74
  this.__sessions = new Map();
75
+ // per-connId offer mutex:串行化同 connId 的 __handleOffer 调用,防止两条 ICE restart
76
+ // offer 几乎同时到达时三连 await 中间被并发 offer 重入触发 pion 状态机
77
+ // InvalidModificationError。lazy 创建,closeByConnId 末尾 delete 释放。
78
+ /** @type {Map<string, ReturnType<typeof createMutex>>} */
79
+ this.__offerMutexes = new Map();
74
80
  }
75
81
 
76
82
  /** 处理来自 Server 转发的信令消息 */
@@ -142,6 +148,15 @@ export class WebRtcPeer {
142
148
  session.rpcChannel = null;
143
149
  }
144
150
  await session.pc.close();
151
+ // 清理 per-connId offer mutex 键:mutex 生命期与 session 生命期一一对应,
152
+ // session 销毁时 mutex 同步销毁(详见 __handleOffer 注释)。
153
+ // 注意:__handleOffer 内部 mutex 引用是闭包已在 mu.withLock 内拿到;
154
+ // 此处 delete 只影响 Map 键,不影响排队中的 fn 执行;后续同 connId 新 offer
155
+ // 走 lazy create 路径建新 mutex(旧 mutex 队列若仍有 fn 也能跑完)。
156
+ // 已知边界:当 closeByConnId 是从 __handleOfferLocked 内部触发时(restart-failed catch
157
+ // 或首次 offer 替换旧 session),fn 仍持有 mutex;此期间新 offer 进来会创建 M2
158
+ // 与 M1 并发——split race 在 TODO.md 追踪,不在本修法范围闭合。
159
+ this.__offerMutexes.delete(connId);
145
160
  this.__remoteLog(`rtc.closed conn=${connId}`);
146
161
  this.logger.info?.(`${this.__rtcTag} [${connId}] closed`);
147
162
  }
@@ -212,6 +227,46 @@ export class WebRtcPeer {
212
227
  async __handleOffer(msg) {
213
228
  const connId = msg.fromConnId;
214
229
  const isIceRestart = !!msg.payload?.iceRestart;
230
+
231
+ // no-session ICE restart 不需要串行化:直接 reject,不进 mutex。
232
+ // 设计意图:mutex 生命期严格对齐 session(仅在有 session 时存在),
233
+ // 短命路径不为它建/拆 mutex。同时该路径本身就是"无 session 立即 reject"的语义,
234
+ // 没有任何可序列化的状态。
235
+ if (isIceRestart && !this.__sessions.has(connId)) {
236
+ const credRemain = this.__credRemainSec(msg.turnCreds);
237
+ const credRemainStr = credRemain ?? 'none';
238
+ this.__remoteLog(`rtc.ice-restart-no-session conn=${connId} credRemain=${credRemainStr}`);
239
+ this.logger.warn?.(`${this.__rtcTag} ICE restart from ${connId} but no session, rejecting`);
240
+ this.__onSend({
241
+ type: 'rtc:restart-rejected',
242
+ toConnId: connId,
243
+ payload: { reason: 'no_session' },
244
+ });
245
+ return;
246
+ }
247
+
248
+ // per-connId mutex 串行化:同 connId 几乎同时到达多条 offer(典型场景:UI 多入口
249
+ // nudgeRestart 在数百毫秒内连发两条 ICE restart)时,把整段三连 await + sendSignaling
250
+ // 串成 N 轮,最后一条胜出(plugin PC 凭据 = 最后一条 offer 凭据 = UI 当前凭据)。
251
+ // 不串行化时三连 await 中间被并发 offer 重入会触发 pion InvalidModificationError。
252
+ // 包**整个** __handleOfferLocked:既覆盖 ICE restart 路径(复用现有 PC)也覆盖首次
253
+ // offer 路径(创建新 PC + 触发 closeByConnId 清场),后者若不在锁内会与紧随的 ICE
254
+ // restart 踩。
255
+ // mutex 生命期 = session 生命期:与 session 一一成对管理。删除点在两处(且仅两处):
256
+ // 1) closeByConnId(覆盖所有正常 close 路径:rtc:closed / state-change / TTL / closeAll)
257
+ // 2) __handleOfferLocked 首次 offer 的 catch(catch 手工删 session 时同步删 mutex)
258
+ // 任何新增 session 删除点必须配套删除 mutex 键,否则会泄漏。
259
+ let mu = this.__offerMutexes.get(connId);
260
+ if (!mu) {
261
+ mu = createMutex();
262
+ this.__offerMutexes.set(connId, mu);
263
+ }
264
+ return mu.withLock(() => this.__handleOfferLocked(msg));
265
+ }
266
+
267
+ async __handleOfferLocked(msg) {
268
+ const connId = msg.fromConnId;
269
+ const isIceRestart = !!msg.payload?.iceRestart;
215
270
  const credRemain = this.__credRemainSec(msg.turnCreds);
216
271
  const credRemainStr = credRemain ?? 'none';
217
272
 
@@ -239,6 +294,14 @@ export class WebRtcPeer {
239
294
  this.logger.info?.(`${this.__rtcTag} ICE restart offer from ${connId}, renegotiating`);
240
295
  try {
241
296
  await existing.pc.setRemoteDescription({ type: 'offer', sdp: msg.payload.sdp });
297
+ // 身份重核:close-during-lock 路径(rtc:closed / connectionState=closed|failed-TTL /
298
+ // closeAll)不走 offer mutex,可能在三连 await 中间删掉 session。命中即丢弃后续动作,
299
+ // 不发 stale rtc:answer。logger.info 仅本地诊断,不上 remoteLog(closeByConnId 触发方
300
+ // 自带 rtc.closed / rtc.state 等远程日志,server 端能从那侧还原"PC 哪一刻死了")。
301
+ if (this.__sessions.get(connId) !== existing) {
302
+ this.logger.info?.(`${this.__rtcTag} [${connId}] ICE restart aborted: session changed after setRemoteDescription`);
303
+ return;
304
+ }
242
305
  // 重协商 SDP 可能变更 a=max-message-size,同步刷新 sender 分片阈值;
243
306
  // queue 存的是完整字符串(buildChunks 在 sender.send 内同步完成),
244
307
  // 已开始分片的当前消息用旧 size,下一条消息用新 size
@@ -248,7 +311,15 @@ export class WebRtcPeer {
248
311
  if (existing.rpcDcSender) existing.rpcDcSender.maxMessageSize = newMMS;
249
312
  }
250
313
  const answer = await existing.pc.createAnswer();
314
+ if (this.__sessions.get(connId) !== existing) {
315
+ this.logger.info?.(`${this.__rtcTag} [${connId}] ICE restart aborted: session changed after createAnswer`);
316
+ return;
317
+ }
251
318
  await existing.pc.setLocalDescription(answer);
319
+ if (this.__sessions.get(connId) !== existing) {
320
+ this.logger.info?.(`${this.__rtcTag} [${connId}] ICE restart aborted: session changed after setLocalDescription`);
321
+ return;
322
+ }
252
323
  this.__onSend({
253
324
  type: 'rtc:answer',
254
325
  toConnId: connId,
@@ -258,6 +329,13 @@ export class WebRtcPeer {
258
329
  this.logger.info?.(`${this.__rtcTag} ICE restart answer sent to ${connId}`);
259
330
  return;
260
331
  } catch (err) {
332
+ // 身份重核:session 已被中途关掉时 catch 收到的 err 是"PC 已 close"残响,
333
+ // 不应再发 stale restart-rejected(用户会看到误报失败),也不重复调 closeByConnId
334
+ // (session 已删,再调是 no-op 但徒增 rtc.closed 日志噪声)。
335
+ if (this.__sessions.get(connId) !== existing) {
336
+ this.logger.info?.(`${this.__rtcTag} [${connId}] ICE restart error after session change (suppressed): ${err?.message}`);
337
+ return;
338
+ }
261
339
  // ICE restart 协商失败 → reject,不 fall through
262
340
  this.__remoteLog(`rtc.ice-restart-failed conn=${connId} credRemain=${credRemainStr}`);
263
341
  this.logger.warn?.(`${this.__rtcTag} ICE restart failed for ${connId}: ${err?.message}`);
@@ -546,7 +624,7 @@ export class WebRtcPeer {
546
624
  this.__remoteLog(`rtc.answer conn=${connId}`);
547
625
  this.logger.info?.(`${this.__rtcTag} answer sent to ${connId}`);
548
626
  } catch (err) {
549
- // SDP 协商失败 → 清理已入 Map 的 session,避免泄漏
627
+ // SDP 协商失败 → 清理已入 Map 的 session + mutex,避免泄漏
550
628
  const cur = this.__sessions.get(connId);
551
629
  if (cur && cur.pc === pc) {
552
630
  if (cur.__failedTimer) {
@@ -555,6 +633,10 @@ export class WebRtcPeer {
555
633
  }
556
634
  this.__sessions.delete(connId);
557
635
  }
636
+ // 同步清 mutex 键,保持 mutex 与 session 生命期一致。本 fn 即将 throw 退出,
637
+ // fn 仍持有的 mutex 实例引用会自然 GC;删 map 键防后续 offer 误用残留键
638
+ // (split race 边界仍在 TODO 中追踪,不在此修法范围)。
639
+ this.__offerMutexes.delete(connId);
558
640
  await pc.close().catch(() => {});
559
641
  throw err;
560
642
  }