@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.
- package/index.js +62 -17
- package/openclaw.plugin.json +3 -0
- package/package.json +1 -1
- package/src/agent-cancel-heuristic.js +42 -0
- package/src/auto-upgrade/state.js +4 -3
- package/src/auto-upgrade/updater.js +2 -3
- package/src/common/claw-binding.js +53 -14
- package/src/device-identity.js +3 -7
- package/src/file-manager/handler.js +1 -1
- package/src/realtime-bridge.js +158 -105
- package/src/utils/atomic-write.js +37 -1
- package/src/utils/memory-queue.js +310 -0
- package/src/webrtc/agent-run-response.js +20 -0
- package/src/webrtc/dc-chunking.js +20 -1
- package/src/webrtc/rpc-dc-sender.js +178 -0
- package/src/webrtc/webrtc-peer.js +225 -65
- package/src/webrtc/rpc-send-queue.js +0 -271
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createReassembler } from './dc-chunking.js';
|
|
2
|
-
import {
|
|
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,
|
|
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
|
|
85
|
-
// 此处不主动 close 会丢失 drop 汇总 remoteLog 诊断
|
|
86
|
-
if (session.
|
|
87
|
-
session.
|
|
88
|
-
session.
|
|
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 广播(大消息自动分片,经由
|
|
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.
|
|
137
|
+
const q = session.rpcQueue;
|
|
124
138
|
if (q && session.rpcChannel?.readyState === 'open') {
|
|
125
|
-
|
|
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.
|
|
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
|
-
|
|
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,同步刷新
|
|
184
|
-
// queue
|
|
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.
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
// 故意绕过
|
|
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
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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.
|
|
742
|
+
const q = session.rpcQueue;
|
|
590
743
|
const queueInfo = q
|
|
591
|
-
?
|
|
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
|
-
|
|
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
|
|
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
|
-
* 绕过
|
|
862
|
+
* 绕过 MemoryQueue + RpcDcSender(与 probe-ack 对称),仅测量传输层,不受应用层积压影响。
|
|
703
863
|
* 同一 session 同时只保留一条 in-flight 探针;超时仅打日志,不影响业务恢复。
|
|
704
864
|
*/
|
|
705
865
|
__sendPluginProbe(connId) {
|