@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 +1 -1
- package/src/webrtc/webrtc-peer.js +83 -1
package/package.json
CHANGED
|
@@ -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
|
}
|