@coclaw/openclaw-coclaw 0.17.9 → 0.18.0
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/realtime-bridge.js +108 -9
- package/src/webrtc/pion-preloader.js +3 -2
- package/src/webrtc/rpc-send-queue.js +73 -17
- package/src/webrtc/webrtc-peer.js +27 -18
package/package.json
CHANGED
package/src/realtime-bridge.js
CHANGED
|
@@ -35,6 +35,13 @@ const LAG_PROBE_PERIOD_MS = 200;
|
|
|
35
35
|
const LAG_PROBE_THRESHOLD_MS = 100;
|
|
36
36
|
const LAG_PROBE_MAX_DURATION_MS = 60_000;
|
|
37
37
|
|
|
38
|
+
// UI 转发 RPC 路由表条目的最大存活时间(24h)。
|
|
39
|
+
// 选 24h 的理由:agent run 极端可达数小时甚至更久;正常 RPC 在终态触达前已自然清除;
|
|
40
|
+
// 24h 足够覆盖几乎所有真实场景,且条目内存压力可忽略(百量级 × 几十字节)。
|
|
41
|
+
const DC_REQ_TTL_MS = 24 * 60 * 60 * 1000;
|
|
42
|
+
// 整表周期扫描间隔(1h)。条目存留误差 0~1h,对内存压力毫无影响。
|
|
43
|
+
const DC_REQ_SCAN_MS = 60 * 60 * 1000;
|
|
44
|
+
|
|
38
45
|
/**
|
|
39
46
|
* 判断一个出方向 res payload 是否表示 agent RPC 进入 phase-2 终态。
|
|
40
47
|
* 终态 = res 帧 + status !== 'accepted'。覆盖三种情形:
|
|
@@ -52,6 +59,19 @@ export function classifyAgentLagStop(payload) {
|
|
|
52
59
|
return status ?? (payload.ok === false ? 'error' : 'ok');
|
|
53
60
|
}
|
|
54
61
|
|
|
62
|
+
/**
|
|
63
|
+
* 判断一个 res 帧是否为终态(不会再有后续同 id 帧跟随)。
|
|
64
|
+
* 与 OpenClaw 上游 gateway/client.ts 的 `expectFinal && status === "accepted"` 判据严格镜像:
|
|
65
|
+
* 仅当 payload.status==='accepted' 时为中间态,其他一切(含无 status 字段)均为终态。
|
|
66
|
+
* 见 docs/designs/dc-rpc-response-unicast.md §2.8。
|
|
67
|
+
*
|
|
68
|
+
* @param {object} frame - 待判断的 gateway res 帧
|
|
69
|
+
* @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
export function isFinalResMsg(frame) {
|
|
72
|
+
return frame?.type === 'res' && frame?.payload?.status !== 'accepted';
|
|
73
|
+
}
|
|
74
|
+
|
|
55
75
|
function toServerWsUrl(baseUrl, token) {
|
|
56
76
|
const url = new URL(baseUrl);
|
|
57
77
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
@@ -108,6 +128,8 @@ export class RealtimeBridge {
|
|
|
108
128
|
* @param {Function} [deps.resolveGatewayAuthToken] - 获取 gateway 认证 token
|
|
109
129
|
* @param {Function} [deps.loadDeviceIdentity] - 加载设备身份
|
|
110
130
|
* @param {number} [deps.gatewayReadyTimeoutMs] - __waitGatewayReady 默认超时(测试可注入短值)
|
|
131
|
+
* @param {number} [deps.dcReqTtlMs] - UI 转发 RPC 路由表条目 TTL(测试可注入短值)
|
|
132
|
+
* @param {number} [deps.dcReqScanMs] - UI 转发 RPC 路由表周期扫描间隔(测试可注入短值)
|
|
111
133
|
*/
|
|
112
134
|
constructor(deps = {}) {
|
|
113
135
|
this.__readConfig = deps.readConfig ?? readConfig;
|
|
@@ -119,6 +141,8 @@ export class RealtimeBridge {
|
|
|
119
141
|
this.__preloadNdc = deps.preloadNdc ?? null;
|
|
120
142
|
this.__WebSocket = deps.WebSocket; // undefined=使用 ws 包, null=禁用(测试用), 其他=自定义实现
|
|
121
143
|
this.__gatewayReadyTimeoutMs = deps.gatewayReadyTimeoutMs ?? 1500;
|
|
144
|
+
this.__dcReqTtlMs = deps.dcReqTtlMs ?? DC_REQ_TTL_MS;
|
|
145
|
+
this.__dcReqScanMs = deps.dcReqScanMs ?? DC_REQ_SCAN_MS;
|
|
122
146
|
|
|
123
147
|
this.serverWs = null;
|
|
124
148
|
this.gatewayWs = null;
|
|
@@ -149,6 +173,10 @@ export class RealtimeBridge {
|
|
|
149
173
|
this.__gatewayLastReason = null; // 最近一次失败原因(用于 gave-up 上报)
|
|
150
174
|
// agent RPC 进 in-flight 时建探针、phase-2 终态时移除:id -> { interval, timeout, stats }
|
|
151
175
|
this.__agentLagProbes = new Map();
|
|
176
|
+
// UI 转发 RPC 路由表:reqId -> { connId, expireAt }
|
|
177
|
+
// 用于 res 帧按发起方单播;查不到时回退广播兜底(兼容旧 UI / 撞号 / 上游新增中间态字符串等)
|
|
178
|
+
this.__dcPendingRequests = new Map();
|
|
179
|
+
this.__dcPendingScanTimer = null;
|
|
152
180
|
}
|
|
153
181
|
|
|
154
182
|
__resolveWebSocket() {
|
|
@@ -253,6 +281,8 @@ export class RealtimeBridge {
|
|
|
253
281
|
settle({ ok: false, error: 'gateway_closed' });
|
|
254
282
|
}
|
|
255
283
|
this.gatewayPendingRequests.clear();
|
|
284
|
+
// 清空 UI 转发 RPC 路由表:gateway 已断,不会再有响应回来;不主动通知 UI,由 UI 30/60s 超时兜底
|
|
285
|
+
this.__dcPendingRequests.clear();
|
|
256
286
|
}
|
|
257
287
|
|
|
258
288
|
/** 懒加载 WebRtcPeer(promise 锁防并发重复创建) */
|
|
@@ -273,8 +303,8 @@ export class RealtimeBridge {
|
|
|
273
303
|
this.__fileHandler.scheduleTmpCleanup(() => this.__listAgentWorkspaces());
|
|
274
304
|
this.webrtcPeer = new WebRtcPeer({
|
|
275
305
|
onSend: (msg) => this.__forwardToServer(msg),
|
|
276
|
-
onRequest: (dcPayload) => {
|
|
277
|
-
this.__handleGatewayRequestFromDc(dcPayload)
|
|
306
|
+
onRequest: (dcPayload, connId) => {
|
|
307
|
+
this.__handleGatewayRequestFromDc(dcPayload, connId)
|
|
278
308
|
.catch((err) => this.logger.warn?.(`[coclaw] dc request handler error: ${err?.message}`));
|
|
279
309
|
},
|
|
280
310
|
onFileRpc: (payload, sendFn) => {
|
|
@@ -778,7 +808,7 @@ export class RealtimeBridge {
|
|
|
778
808
|
return;
|
|
779
809
|
}
|
|
780
810
|
if (payload.type === 'res' || payload.type === 'event') {
|
|
781
|
-
// 过滤 gateway 的管理层广播事件,这些对 WebChat / plugin 客户端无意义:
|
|
811
|
+
// (a) 过滤 gateway 的管理层广播事件,这些对 WebChat / plugin 客户端无意义:
|
|
782
812
|
// - health: 全量状态快照(~3KB, ~60s 一次 + RPC 触发),给 Admin UI 的监控仪表盘用
|
|
783
813
|
// - tick: gateway WS 保活心跳(30s 一次),UI 隔着 DC 不需要,DC 自己有 probe 机制
|
|
784
814
|
// 不转发可避免后台时 rpc DC 队列被灌满。上游支持按需订阅前先在插件侧拦截。
|
|
@@ -786,11 +816,31 @@ export class RealtimeBridge {
|
|
|
786
816
|
&& (payload.event === 'health' || payload.event === 'tick')) {
|
|
787
817
|
return;
|
|
788
818
|
}
|
|
789
|
-
// agent RPC 进入 phase-2 终态时停 lag
|
|
819
|
+
// (b) agent RPC 进入 phase-2 终态时停 lag 探针(必须放在 (c) 单播分支之前,
|
|
820
|
+
// 避免命中后探针不停导致 60s 兜底 + 噪声日志)
|
|
790
821
|
const lagReason = classifyAgentLagStop(payload);
|
|
791
822
|
if (lagReason !== null) {
|
|
792
823
|
this.__stopLagProbe(payload.id, lagReason);
|
|
793
824
|
}
|
|
825
|
+
// (c) UI 转发 RPC 的 res 单播:按 reqId 查路由表,命中则定向 sendTo
|
|
826
|
+
if (payload.type === 'res' && typeof payload.id === 'string') {
|
|
827
|
+
const info = this.__dcPendingRequests.get(payload.id);
|
|
828
|
+
if (info) {
|
|
829
|
+
// 终态才清条目;accepted 类中间态保留等下一帧
|
|
830
|
+
if (isFinalResMsg(payload)) {
|
|
831
|
+
this.__dcPendingRequests.delete(payload.id);
|
|
832
|
+
}
|
|
833
|
+
const delivered = this.webrtcPeer?.sendTo(info.connId, payload);
|
|
834
|
+
if (!delivered) {
|
|
835
|
+
// PC 已断 / DC 未 open / 队列拒收:本地 log 丢弃,不退回广播
|
|
836
|
+
this.__logDebug(
|
|
837
|
+
`dc res undeliverable: id=${payload.id} connId=${info.connId}`
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
// (d) 兜底广播:覆盖 event 类型 / 映射未命中场景
|
|
794
844
|
this.webrtcPeer?.broadcast(payload);
|
|
795
845
|
}
|
|
796
846
|
});
|
|
@@ -818,6 +868,8 @@ export class RealtimeBridge {
|
|
|
818
868
|
settle({ ok: false, error: 'gateway_closed' });
|
|
819
869
|
}
|
|
820
870
|
this.gatewayPendingRequests.clear();
|
|
871
|
+
// 同步清空 UI 转发 RPC 路由表(同 __closeGatewayWs 语义)
|
|
872
|
+
this.__dcPendingRequests.clear();
|
|
821
873
|
// 调度下一次尝试:仅在 bridge 仍活着、未 gave-up、server WS 健康时;
|
|
822
874
|
// 其他场景(如 bridge stop、server WS 已断)由上游流程兜底,不参与 gateway 重试。
|
|
823
875
|
if (this.started && !this.__gatewayGaveUp
|
|
@@ -890,9 +942,10 @@ export class RealtimeBridge {
|
|
|
890
942
|
});
|
|
891
943
|
}
|
|
892
944
|
|
|
893
|
-
async __handleGatewayRequestFromDc(payload) {
|
|
945
|
+
async __handleGatewayRequestFromDc(payload, connId) {
|
|
894
946
|
const ready = await this.__waitGatewayReady();
|
|
895
947
|
if (!ready || !this.gatewayWs || this.gatewayWs.readyState !== 1) {
|
|
948
|
+
// OFFLINE 路径在写映射前触发,无脏映射;保留广播语义(属系统状态公告)
|
|
896
949
|
this.__logDebug(`gateway req drop (offline): id=${payload.id} method=${payload.method}`);
|
|
897
950
|
this.webrtcPeer?.broadcast({
|
|
898
951
|
type: 'res',
|
|
@@ -905,23 +958,41 @@ export class RealtimeBridge {
|
|
|
905
958
|
});
|
|
906
959
|
return;
|
|
907
960
|
}
|
|
961
|
+
// 撞号检测:UUID 全唯一时极小概率,但旧 UI 跨 tab 或 UI bug 可能触发。
|
|
962
|
+
// 删旧条目让旧响应未来走广播兜底,不主动回错给旧发起方(可能已断)
|
|
963
|
+
const id = payload.id;
|
|
964
|
+
if (typeof id === 'string' && this.__dcPendingRequests.has(id)) {
|
|
965
|
+
this.logger.warn?.(`[coclaw] duplicate dc reqId, dropping previous mapping: id=${id}`);
|
|
966
|
+
this.__dcPendingRequests.delete(id);
|
|
967
|
+
}
|
|
968
|
+
// 写映射:必须在 ready 通过后、send 之前;缺 connId 时退化为旧广播行为
|
|
969
|
+
if (typeof id === 'string' && connId) {
|
|
970
|
+
this.__dcPendingRequests.set(id, {
|
|
971
|
+
connId,
|
|
972
|
+
expireAt: Date.now() + this.__dcReqTtlMs,
|
|
973
|
+
});
|
|
974
|
+
}
|
|
908
975
|
try {
|
|
909
|
-
this.__logDebug(`gateway req -> id=${
|
|
976
|
+
this.__logDebug(`gateway req -> id=${id} method=${payload.method}`);
|
|
910
977
|
this.gatewayWs.send(JSON.stringify({
|
|
911
978
|
type: 'req',
|
|
912
|
-
id
|
|
979
|
+
id,
|
|
913
980
|
method: payload.method,
|
|
914
981
|
params: payload.params ?? {},
|
|
915
982
|
}));
|
|
916
983
|
// 仅 agent RPC 启动 lag 探针(覆盖发送 → phase-2 终态全程)。
|
|
917
984
|
if (payload.method === 'agent') {
|
|
918
|
-
this.__startLagProbe(
|
|
985
|
+
this.__startLagProbe(id);
|
|
919
986
|
}
|
|
920
987
|
}
|
|
921
988
|
catch {
|
|
989
|
+
// SEND_FAILED:撤回映射后广播错误响应
|
|
990
|
+
if (typeof id === 'string') {
|
|
991
|
+
this.__dcPendingRequests.delete(id);
|
|
992
|
+
}
|
|
922
993
|
this.webrtcPeer?.broadcast({
|
|
923
994
|
type: 'res',
|
|
924
|
-
id
|
|
995
|
+
id,
|
|
925
996
|
ok: false,
|
|
926
997
|
error: {
|
|
927
998
|
code: 'GATEWAY_SEND_FAILED',
|
|
@@ -1231,6 +1302,29 @@ export class RealtimeBridge {
|
|
|
1231
1302
|
this.logger.info?.(`[coclaw] WebRTC impl: ${implLabel}`);
|
|
1232
1303
|
this.logger.info?.(`[coclaw] ${this.__buildEnvLine()}`);
|
|
1233
1304
|
remoteLog('bridge.started');
|
|
1305
|
+
// 启动 UI 转发 RPC 路由表周期扫描:1h 间隔扫描 24h 过期条目,避免长程 RPC 残留。
|
|
1306
|
+
// try/catch 兜底:插件运行在 gateway 进程内,timer 回调任何同步抛出都会让进程崩溃
|
|
1307
|
+
// (CLAUDE.md 禁止全局异常兜底),与 __startLagProbe 的实现保持一致。
|
|
1308
|
+
this.__dcPendingScanTimer = setInterval(() => {
|
|
1309
|
+
try {
|
|
1310
|
+
const now = Date.now();
|
|
1311
|
+
let cleaned = 0;
|
|
1312
|
+
for (const [id, info] of this.__dcPendingRequests) {
|
|
1313
|
+
if (info.expireAt <= now) {
|
|
1314
|
+
this.__dcPendingRequests.delete(id);
|
|
1315
|
+
cleaned++;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
if (cleaned > 0) {
|
|
1319
|
+
this.logger.warn?.(`[coclaw] dc pending entries expired: count=${cleaned}`);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
/* c8 ignore next 3 -- 防御性兜底,正常路径下 Map ops + logger.warn 不抛 */
|
|
1323
|
+
catch {
|
|
1324
|
+
// 扫描器自身异常静默吞掉,避免拖垮 gateway。
|
|
1325
|
+
}
|
|
1326
|
+
}, this.__dcReqScanMs);
|
|
1327
|
+
this.__dcPendingScanTimer.unref?.();
|
|
1234
1328
|
await this.__connectIfNeeded();
|
|
1235
1329
|
}
|
|
1236
1330
|
|
|
@@ -1276,6 +1370,11 @@ export class RealtimeBridge {
|
|
|
1276
1370
|
this.__gatewayGaveUp = false;
|
|
1277
1371
|
this.__gatewayLegacyMode = false;
|
|
1278
1372
|
this.__gatewayLastReason = null;
|
|
1373
|
+
// 停止 UI 转发 RPC 路由表的周期扫描
|
|
1374
|
+
if (this.__dcPendingScanTimer) {
|
|
1375
|
+
clearInterval(this.__dcPendingScanTimer);
|
|
1376
|
+
this.__dcPendingScanTimer = null;
|
|
1377
|
+
}
|
|
1279
1378
|
this.__closeGatewayWs();
|
|
1280
1379
|
if (this.webrtcPeer) {
|
|
1281
1380
|
await this.webrtcPeer.closeAll().catch(() => {});
|
|
@@ -49,13 +49,14 @@ export async function preloadPion(deps = {}) {
|
|
|
49
49
|
// 启动 IPC 进程(内部会 ping 验证就绪,binary 由 pion-node 自动解析)
|
|
50
50
|
// logger 回调双打:始终走 remoteLog;同时送本地 logger,严重事件(IPC 超时、orphan 响应)
|
|
51
51
|
// 升级到 error 级别,便于本地调试时一眼可见;其他运维类消息走 info。
|
|
52
|
+
// pion-node SDK 已在 msg 中加 [pion-ipc] 前缀,此处不再重复
|
|
52
53
|
ipc = new PionIpc({
|
|
53
54
|
logger: (msg) => {
|
|
54
55
|
log(`pion.ipc ${msg}`);
|
|
55
56
|
if (SEVERE_LOG_PATTERN.test(msg)) {
|
|
56
|
-
localLogger?.error?.(
|
|
57
|
+
localLogger?.error?.(msg);
|
|
57
58
|
} else {
|
|
58
|
-
localLogger?.info?.(
|
|
59
|
+
localLogger?.info?.(msg);
|
|
59
60
|
}
|
|
60
61
|
},
|
|
61
62
|
timeout: ipcRequestTimeout,
|
|
@@ -10,6 +10,15 @@
|
|
|
10
10
|
* - close():DC 关闭时调用,清空队列并汇总 drop 统计
|
|
11
11
|
*
|
|
12
12
|
* 不做:Promise 送达保证;单条消息硬上限内的背压;自动重试。
|
|
13
|
+
*
|
|
14
|
+
* 契约(重要,修改 send/__drain/onBufferedAmountLow 时务必维持):
|
|
15
|
+
* send/__drain/onBufferedAmountLow 必须是「总函数」——任何分支都不得抛异常给调用方。
|
|
16
|
+
* 这是上游(webrtc-peer 的 broadcast/sendTo/files sendFn)能去掉 try/catch 的前提。
|
|
17
|
+
* 队列内部所有外部调用(buildChunks、dc.send、logger.*、remoteLog 等)都必须就地保护,
|
|
18
|
+
* 失败转化为 dropped 计数 / 静默吞掉,返回 false。日志输出统一走 __safeWarn / __safeInfo /
|
|
19
|
+
* __safeRemoteLog,避免外部传入的 logger 实现自身抛异常时破坏契约。
|
|
20
|
+
* 入参防御:jsonStr 必须是 string;非 string 直接 drop,避免 Buffer.byteLength 抛 TypeError。
|
|
21
|
+
* 仅构造器允许抛(参数校验,初始化期),运行期入口都不允许。
|
|
13
22
|
*/
|
|
14
23
|
|
|
15
24
|
import { buildChunks } from './dc-chunking.js';
|
|
@@ -64,20 +73,44 @@ export class RpcSendQueue {
|
|
|
64
73
|
send(jsonStr) {
|
|
65
74
|
if (this.closed || this.dc.readyState !== 'open') return false;
|
|
66
75
|
|
|
76
|
+
// 入参防御:契约要求 jsonStr 是 string;非 string 直接 drop(避免 Buffer.byteLength 抛 TypeError)
|
|
77
|
+
if (typeof jsonStr !== 'string') {
|
|
78
|
+
this.droppedCount += 1;
|
|
79
|
+
this.__safeWarn(`drop reason=non-string-input type=${typeof jsonStr}`);
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
67
83
|
// 诊断日志:打印每次入队的事件,跟踪 gateway 还会推哪些事件
|
|
68
84
|
// 需要时临时打开,平时保持注释避免日志噪音
|
|
69
|
-
// this.
|
|
85
|
+
// this.__safeInfo(`send-payload ${jsonStr}`);
|
|
86
|
+
|
|
87
|
+
// payload 字节:UTF-8 实际字节数,与对端 reassembly 上限同口径
|
|
88
|
+
const payloadBytes = Buffer.byteLength(jsonStr, 'utf8');
|
|
89
|
+
|
|
90
|
+
// 分片:异常需在 send 内吃掉,避免抛回 gateway 主循环(plugin 硬约束)
|
|
91
|
+
let chunks;
|
|
92
|
+
try {
|
|
93
|
+
chunks = buildChunks(jsonStr, this.maxMessageSize, this.getNextMsgId);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
this.droppedCount += 1;
|
|
96
|
+
this.droppedBytes += payloadBytes;
|
|
97
|
+
const errMsg = err?.message ?? String(err);
|
|
98
|
+
this.__safeWarn(`drop reason=build-chunks-failed size=${payloadBytes} maxMessageSize=${this.maxMessageSize} err=${errMsg}`);
|
|
99
|
+
this.__safeRemoteLog(`rpc-queue.build-chunks-failed${this.__tagSuffix()} size=${payloadBytes} maxMessageSize=${this.maxMessageSize} err=${errMsg}`);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
70
102
|
|
|
71
|
-
|
|
72
|
-
const
|
|
103
|
+
// 帧字节:含 5 字节 header 的实际网络字节,用于队列核算
|
|
104
|
+
const frameBytes = chunks
|
|
73
105
|
? chunks.reduce((n, c) => n + c.length, 0)
|
|
74
|
-
:
|
|
106
|
+
: payloadBytes;
|
|
75
107
|
|
|
76
|
-
//
|
|
77
|
-
|
|
108
|
+
// 硬上限:单条超限——按 payload 字节判断,对齐对端 reassembly payload 上限
|
|
109
|
+
// (帧字节因 header 累计可能在 payload 恰好不超时被误判 drop)
|
|
110
|
+
if (payloadBytes > MAX_SINGLE_MSG_BYTES) {
|
|
78
111
|
this.droppedCount += 1;
|
|
79
|
-
this.droppedBytes +=
|
|
80
|
-
this.
|
|
112
|
+
this.droppedBytes += frameBytes;
|
|
113
|
+
this.__safeWarn(`drop reason=single-msg-oversize size=${payloadBytes} cap=${MAX_SINGLE_MSG_BYTES}`);
|
|
81
114
|
return false;
|
|
82
115
|
}
|
|
83
116
|
|
|
@@ -87,13 +120,13 @@ export class RpcSendQueue {
|
|
|
87
120
|
// 仍受 50MB 单条硬上限约束(接收端重组上限,超过也无意义)。
|
|
88
121
|
if (this.queueBytes >= MAX_QUEUE_BYTES && !isAgentRunResponse(jsonStr)) {
|
|
89
122
|
this.droppedCount += 1;
|
|
90
|
-
this.droppedBytes +=
|
|
123
|
+
this.droppedBytes += frameBytes;
|
|
91
124
|
// 仅状态翻转点打 log(warn + remoteLog 各一次);overflow 持续期间所有 drop 静默累加,
|
|
92
125
|
// 避免 UI 离线 + ICE 失败导致 DC 永远不 drain 时的日志刷屏
|
|
93
126
|
if (!this.queueOverflowActive) {
|
|
94
127
|
this.queueOverflowActive = true;
|
|
95
|
-
this.
|
|
96
|
-
|
|
128
|
+
this.__safeWarn(`overflow-start queueBytes=${this.queueBytes}`);
|
|
129
|
+
this.__safeRemoteLog(`rpc-queue.overflow-start${this.__tagSuffix()} queueBytes=${this.queueBytes}`);
|
|
97
130
|
}
|
|
98
131
|
return false;
|
|
99
132
|
}
|
|
@@ -107,7 +140,7 @@ export class RpcSendQueue {
|
|
|
107
140
|
this.dc.send(jsonStr);
|
|
108
141
|
return true;
|
|
109
142
|
} catch (err) {
|
|
110
|
-
this.
|
|
143
|
+
this.__safeWarn(`fast-path send failed: ${err?.message}`);
|
|
111
144
|
return false;
|
|
112
145
|
}
|
|
113
146
|
}
|
|
@@ -128,7 +161,7 @@ export class RpcSendQueue {
|
|
|
128
161
|
this.dc.send(chunks[i]);
|
|
129
162
|
i += 1;
|
|
130
163
|
} catch (err) {
|
|
131
|
-
this.
|
|
164
|
+
this.__safeWarn(`fast-path send failed at chunk ${i}/${chunks.length}: ${err?.message}`);
|
|
132
165
|
return false;
|
|
133
166
|
}
|
|
134
167
|
}
|
|
@@ -158,7 +191,7 @@ export class RpcSendQueue {
|
|
|
158
191
|
this.queueBytes = 0;
|
|
159
192
|
this.queueOverflowActive = false;
|
|
160
193
|
if (this.droppedCount > 0 || residual > 0) {
|
|
161
|
-
|
|
194
|
+
this.__safeRemoteLog(`rpc-queue.close${this.__tagSuffix()} dropped=${this.droppedCount} droppedBytes=${this.droppedBytes} residualChunks=${residual} residualBytes=${residualBytes}`);
|
|
162
195
|
}
|
|
163
196
|
}
|
|
164
197
|
|
|
@@ -173,7 +206,7 @@ export class RpcSendQueue {
|
|
|
173
206
|
try {
|
|
174
207
|
dc.send(item.data);
|
|
175
208
|
} catch (err) {
|
|
176
|
-
this.
|
|
209
|
+
this.__safeWarn(`drain send failed: ${err?.message}`);
|
|
177
210
|
return; // 保留队列,等 onclose 统一清理
|
|
178
211
|
}
|
|
179
212
|
this.queue.shift();
|
|
@@ -181,8 +214,8 @@ export class RpcSendQueue {
|
|
|
181
214
|
// 满 → 未满 状态转换:打一条带累计数的 log,与 overflow-start 对称
|
|
182
215
|
if (this.queueOverflowActive && this.queueBytes < MAX_QUEUE_BYTES) {
|
|
183
216
|
this.queueOverflowActive = false;
|
|
184
|
-
this.
|
|
185
|
-
|
|
217
|
+
this.__safeInfo(`overflow-end dropped=${this.droppedCount} droppedBytes=${this.droppedBytes}`);
|
|
218
|
+
this.__safeRemoteLog(`rpc-queue.overflow-end${this.__tagSuffix()} dropped=${this.droppedCount} droppedBytes=${this.droppedBytes}`);
|
|
186
219
|
}
|
|
187
220
|
}
|
|
188
221
|
}
|
|
@@ -191,6 +224,29 @@ export class RpcSendQueue {
|
|
|
191
224
|
__tagSuffix() {
|
|
192
225
|
return this.tag ? ` ${this.tag}` : '';
|
|
193
226
|
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @private logger.warn 安全包装:吃掉 logger 自身抛的异常,保护 send/__drain 的 no-throw 契约
|
|
230
|
+
* @param {string} msg
|
|
231
|
+
*/
|
|
232
|
+
__safeWarn(msg) {
|
|
233
|
+
try { this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] ${msg}`); } catch { /* logger 自身坏了也不能让 send 抛 */ }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** @private 同 __safeWarn,info 级别 */
|
|
237
|
+
__safeInfo(msg) {
|
|
238
|
+
try { this.logger.info?.(`[rpc-queue${this.__tagSuffix()}] ${msg}`); } catch { /* logger 自身坏了也不能让 send 抛 */ }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* @private remoteLog 安全包装。
|
|
243
|
+
* 当前 `remoteLog` 实现同步路径仅做数组操作 + flush().catch(),不抛异常,本 catch 块覆盖率因此为 0。
|
|
244
|
+
* 保留 try/catch 是为了让"send 不抛"契约不依赖 remoteLog 模块的具体实现——如果未来 remoteLog
|
|
245
|
+
* 内部加入可能抛的同步路径(如序列化、外部 sink 注入),此 wrapper 仍能兜底。
|
|
246
|
+
*/
|
|
247
|
+
__safeRemoteLog(text) {
|
|
248
|
+
try { remoteLog(text); } catch { /* 未来防御:见上方注释 */ }
|
|
249
|
+
}
|
|
194
250
|
}
|
|
195
251
|
|
|
196
252
|
/**
|
|
@@ -110,39 +110,44 @@ export class WebRtcPeer {
|
|
|
110
110
|
|
|
111
111
|
/** 向所有已打开的 rpcChannel 广播(大消息自动分片,经由 RpcSendQueue 流控) */
|
|
112
112
|
broadcast(payload) {
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
let jsonStr;
|
|
114
|
+
try {
|
|
115
|
+
jsonStr = JSON.stringify(payload);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// 循环引用 / BigInt 等导致 stringify 抛——记日志后整条丢弃,不冒到 gateway
|
|
118
|
+
this.__logDebug(`broadcast stringify failed: ${err?.message}`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (typeof jsonStr !== 'string') return; // payload 是 undefined/symbol 时 stringify 返回 undefined
|
|
122
|
+
for (const session of this.__sessions.values()) {
|
|
115
123
|
const q = session.rpcSendQueue;
|
|
116
124
|
if (q && session.rpcChannel?.readyState === 'open') {
|
|
117
|
-
|
|
118
|
-
q.send(jsonStr);
|
|
119
|
-
} catch (err) {
|
|
120
|
-
// buildChunks 抛(maxMessageSize 配置错)等罕见情况
|
|
121
|
-
this.__logDebug(`[${connId}] broadcast send failed: ${err.message}`);
|
|
122
|
-
}
|
|
125
|
+
q.send(jsonStr);
|
|
123
126
|
}
|
|
124
127
|
}
|
|
125
128
|
}
|
|
126
129
|
|
|
127
130
|
/**
|
|
128
131
|
* 向指定 connId 的 rpc DC 单播一个 JSON 帧(不走 server 中转)。
|
|
129
|
-
* 若 session/DC
|
|
132
|
+
* 若 session/DC 未就绪或被发送队列拒收(队列满等)返回 false,由调用方决定是否重试。
|
|
130
133
|
* @param {string} connId
|
|
131
134
|
* @param {object} payload - 完整的 JSON 帧(通常是 { type: 'event', event, payload })
|
|
132
|
-
* @returns {boolean} true
|
|
135
|
+
* @returns {boolean} true=已入队发送;false=session 不存在 / DC 未 open / payload 不可序列化 / 发送队列拒收
|
|
133
136
|
*/
|
|
134
137
|
sendTo(connId, payload) {
|
|
135
138
|
const session = this.__sessions.get(connId);
|
|
136
139
|
if (!session) return false;
|
|
137
140
|
const q = session.rpcSendQueue;
|
|
138
141
|
if (!q || session.rpcChannel?.readyState !== 'open') return false;
|
|
142
|
+
let jsonStr;
|
|
139
143
|
try {
|
|
140
|
-
|
|
141
|
-
return true;
|
|
144
|
+
jsonStr = JSON.stringify(payload);
|
|
142
145
|
} catch (err) {
|
|
143
|
-
this.__logDebug(`[${connId}] sendTo failed: ${err
|
|
146
|
+
this.__logDebug(`[${connId}] sendTo stringify failed: ${err?.message}`);
|
|
144
147
|
return false;
|
|
145
148
|
}
|
|
149
|
+
if (typeof jsonStr !== 'string') return false;
|
|
150
|
+
return q.send(jsonStr);
|
|
146
151
|
}
|
|
147
152
|
|
|
148
153
|
async __handleOffer(msg) {
|
|
@@ -520,11 +525,15 @@ export class WebRtcPeer {
|
|
|
520
525
|
if (payload.method?.startsWith('coclaw.files.') && this.__onFileRpc) {
|
|
521
526
|
const sess = this.__sessions.get(connId);
|
|
522
527
|
const sendFn = (response) => {
|
|
528
|
+
let jsonStr;
|
|
523
529
|
try {
|
|
524
|
-
|
|
530
|
+
jsonStr = JSON.stringify(response);
|
|
525
531
|
} catch (err) {
|
|
526
|
-
this.__logDebug(`[${connId}] sendFn failed: ${err
|
|
532
|
+
this.__logDebug(`[${connId}] file sendFn stringify failed: ${err?.message}`);
|
|
533
|
+
return;
|
|
527
534
|
}
|
|
535
|
+
if (typeof jsonStr !== 'string') return;
|
|
536
|
+
sess?.rpcSendQueue?.send(jsonStr);
|
|
528
537
|
};
|
|
529
538
|
this.__onFileRpc(payload, sendFn, connId);
|
|
530
539
|
} else {
|
|
@@ -769,9 +778,9 @@ export class WebRtcPeer {
|
|
|
769
778
|
}
|
|
770
779
|
|
|
771
780
|
__logDebug(message) {
|
|
772
|
-
if (typeof this.logger?.debug
|
|
773
|
-
|
|
774
|
-
}
|
|
781
|
+
if (typeof this.logger?.debug !== 'function') return;
|
|
782
|
+
try { this.logger.debug(`${this.__rtcTag} ${message}`); }
|
|
783
|
+
catch { /* 上层(broadcast / sendTo / sendFn 等的 stringify catch)依赖 __logDebug 不抛 */ }
|
|
775
784
|
}
|
|
776
785
|
}
|
|
777
786
|
|