@coclaw/openclaw-coclaw 0.17.1 → 0.17.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/package.json +2 -2
- package/src/utils/file-backed-queue.js +375 -0
- package/src/webrtc/webrtc-peer.js +53 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coclaw/openclaw-coclaw",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "OpenClaw CoClaw channel plugin for remote chat",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"release:versions": "npm view @coclaw/openclaw-coclaw versions --json --registry=https://registry.npmjs.org/ && npm view @coclaw/openclaw-coclaw versions --json"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@coclaw/pion-node": "^0.
|
|
61
|
+
"@coclaw/pion-node": "^0.3.0",
|
|
62
62
|
"werift": "^0.19.0",
|
|
63
63
|
"ws": "^8.19.0"
|
|
64
64
|
},
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件回退队列:内存优先,超过预算后追加写入 JSONL 文件。
|
|
3
|
+
* 业务无关纯工具:存储任意字符串(调用方需保证不含裸 `\n`,否则行分隔语义被破坏)。
|
|
4
|
+
*
|
|
5
|
+
* 行为约定详见 docs/rpc-dc-file-queue.md。
|
|
6
|
+
* - FIFO、单一生产者/消费者;多消费者时每条只交付给其中一个。
|
|
7
|
+
* - 构造时清理目录残留(不跨生命周期复用)。
|
|
8
|
+
* - 消费侧:`for await (const item of queue) { ... }`;`destroy()` 让迭代结束。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import { createReadStream, createWriteStream, rmSync } from 'node:fs';
|
|
13
|
+
import nodePath from 'node:path';
|
|
14
|
+
import readline from 'node:readline';
|
|
15
|
+
|
|
16
|
+
import { createMutex } from './mutex.js';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_MEM_BUDGET = 8 * 1024 * 1024;
|
|
19
|
+
const DEFAULT_DISK_CAP = 1024 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
class FileBackedQueue {
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} opts
|
|
24
|
+
* @param {string} opts.dir - 队列文件根目录
|
|
25
|
+
* @param {string} opts.id - 队列标识(用于子目录命名)
|
|
26
|
+
* @param {number} [opts.memBudget=8MB] - 内存持有字节数上限
|
|
27
|
+
* @param {number} [opts.diskCap=1GB] - 磁盘+内存总字节数硬上限
|
|
28
|
+
* @param {(reason: string, size: number) => void} [opts.onDrop] - 拒入队时的回调
|
|
29
|
+
* @param {{ warn?: Function, info?: Function, error?: Function }} [opts.logger=console]
|
|
30
|
+
*/
|
|
31
|
+
constructor(opts) {
|
|
32
|
+
const {
|
|
33
|
+
dir,
|
|
34
|
+
id,
|
|
35
|
+
memBudget = DEFAULT_MEM_BUDGET,
|
|
36
|
+
diskCap = DEFAULT_DISK_CAP,
|
|
37
|
+
onDrop,
|
|
38
|
+
logger = console,
|
|
39
|
+
} = opts ?? {};
|
|
40
|
+
|
|
41
|
+
if (!dir || typeof dir !== 'string') throw new TypeError('dir is required');
|
|
42
|
+
if (!id || typeof id !== 'string') throw new TypeError('id is required');
|
|
43
|
+
|
|
44
|
+
this.dir = dir;
|
|
45
|
+
this.id = id;
|
|
46
|
+
this.memBudget = memBudget;
|
|
47
|
+
this.diskCap = diskCap;
|
|
48
|
+
this.onDrop = onDrop;
|
|
49
|
+
this.logger = logger;
|
|
50
|
+
|
|
51
|
+
this.subdir = nodePath.join(dir, id);
|
|
52
|
+
this.filePath = nodePath.join(this.subdir, 'queue.jsonl');
|
|
53
|
+
|
|
54
|
+
this.memQueue = [];
|
|
55
|
+
this.memBytes = 0;
|
|
56
|
+
this.diskBytes = 0; // 磁盘上未消费的 payload 字节(不含分隔 \n)
|
|
57
|
+
this.writtenBytes = 0; // 已写入文件的累计字节(含 \n)
|
|
58
|
+
this.readOffset = 0; // 下次 refill 的起始偏移
|
|
59
|
+
this.spilled = false;
|
|
60
|
+
this.destroyed = false;
|
|
61
|
+
this.writeStream = null;
|
|
62
|
+
this.writeErr = null;
|
|
63
|
+
this.waiters = [];
|
|
64
|
+
this.mutex = createMutex();
|
|
65
|
+
|
|
66
|
+
// 防御性清理:不跨生命周期复用旧数据
|
|
67
|
+
try {
|
|
68
|
+
rmSync(this.subdir, { recursive: true, force: true });
|
|
69
|
+
} catch (err) {
|
|
70
|
+
/* c8 ignore next 2 -- rmSync with force rarely fails on posix */
|
|
71
|
+
this.logger?.warn?.('fbq.construct cleanup error', err);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 入队一条字符串。
|
|
77
|
+
* @param {string} jsonStr
|
|
78
|
+
* @returns {Promise<boolean>} accepted(true)/ dropped(false)
|
|
79
|
+
*/
|
|
80
|
+
async enqueue(jsonStr) {
|
|
81
|
+
return await this.mutex.withLock(async () => {
|
|
82
|
+
if (this.destroyed) return false;
|
|
83
|
+
if (typeof jsonStr !== 'string') throw new TypeError('jsonStr must be a string');
|
|
84
|
+
|
|
85
|
+
const size = Buffer.byteLength(jsonStr, 'utf8');
|
|
86
|
+
|
|
87
|
+
if (this.memBytes + this.diskBytes + size > this.diskCap) {
|
|
88
|
+
this.__dispatchDrop('disk-cap', size);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 内存路径:未溢出且加上新条目仍在预算内
|
|
93
|
+
if (!this.spilled && this.memBytes + size <= this.memBudget) {
|
|
94
|
+
this.memQueue.push(jsonStr);
|
|
95
|
+
this.memBytes += size;
|
|
96
|
+
this.__wakeOne();
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 溢出路径:lazy 打开写流
|
|
101
|
+
if (!this.spilled) {
|
|
102
|
+
await this.__openWriteStream();
|
|
103
|
+
if (this.writeErr) {
|
|
104
|
+
this.__dispatchDrop('fs-error', size);
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
this.spilled = true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
await this.__writeLine(jsonStr + '\n');
|
|
112
|
+
this.diskBytes += size;
|
|
113
|
+
this.writtenBytes += size + 1;
|
|
114
|
+
this.__wakeOne();
|
|
115
|
+
return true;
|
|
116
|
+
} catch (err) {
|
|
117
|
+
this.logger?.warn?.('fbq.enqueue fs-error', err);
|
|
118
|
+
this.__dispatchDrop('fs-error', size);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @returns {{ memCount: number, memBytes: number, diskBytes: number, spilled: boolean }}
|
|
126
|
+
*/
|
|
127
|
+
stats() {
|
|
128
|
+
return {
|
|
129
|
+
memCount: this.memQueue.length,
|
|
130
|
+
memBytes: this.memBytes,
|
|
131
|
+
diskBytes: this.diskBytes,
|
|
132
|
+
spilled: this.spilled,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 清空数据但保留实例可用。
|
|
138
|
+
*/
|
|
139
|
+
async clear() {
|
|
140
|
+
return await this.mutex.withLock(async () => {
|
|
141
|
+
if (this.destroyed) return;
|
|
142
|
+
await this.__closeWriteStream();
|
|
143
|
+
try {
|
|
144
|
+
await fs.rm(this.filePath, { force: true });
|
|
145
|
+
} catch (err) {
|
|
146
|
+
/* c8 ignore next 2 -- rm with force rarely fails */
|
|
147
|
+
this.logger?.warn?.('fbq.clear rm error', err);
|
|
148
|
+
}
|
|
149
|
+
this.memQueue = [];
|
|
150
|
+
this.memBytes = 0;
|
|
151
|
+
this.diskBytes = 0;
|
|
152
|
+
this.writtenBytes = 0;
|
|
153
|
+
this.readOffset = 0;
|
|
154
|
+
this.spilled = false;
|
|
155
|
+
this.writeErr = null;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 停写、关 FD、删目录、结束所有迭代器。幂等。
|
|
161
|
+
*/
|
|
162
|
+
async destroy() {
|
|
163
|
+
return await this.mutex.withLock(async () => {
|
|
164
|
+
if (this.destroyed) return;
|
|
165
|
+
this.destroyed = true;
|
|
166
|
+
|
|
167
|
+
// 唤醒所有等待者,让它们在下一轮循环中看到 destroyed 并返回 done
|
|
168
|
+
const toWake = this.waiters.splice(0);
|
|
169
|
+
for (const w of toWake) w.resolve();
|
|
170
|
+
|
|
171
|
+
await this.__closeWriteStream();
|
|
172
|
+
try {
|
|
173
|
+
await fs.rm(this.subdir, { recursive: true, force: true });
|
|
174
|
+
} catch (err) {
|
|
175
|
+
/* c8 ignore next 2 -- rm with force rarely fails */
|
|
176
|
+
this.logger?.warn?.('fbq.destroy rm error', err);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
this.memQueue = [];
|
|
180
|
+
this.memBytes = 0;
|
|
181
|
+
this.diskBytes = 0;
|
|
182
|
+
this.writtenBytes = 0;
|
|
183
|
+
this.readOffset = 0;
|
|
184
|
+
this.spilled = false;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
[Symbol.asyncIterator]() {
|
|
189
|
+
const self = this;
|
|
190
|
+
return {
|
|
191
|
+
next() { return self.__nextIter(); },
|
|
192
|
+
return() { return Promise.resolve({ done: true, value: undefined }); },
|
|
193
|
+
[Symbol.asyncIterator]() { return this; },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async __nextIter() {
|
|
198
|
+
while (true) {
|
|
199
|
+
let waitPromise = null;
|
|
200
|
+
const result = await this.mutex.withLock(async () => {
|
|
201
|
+
if (this.memQueue.length === 0 && this.spilled && !this.destroyed) {
|
|
202
|
+
await this.__refillImpl();
|
|
203
|
+
}
|
|
204
|
+
if (this.memQueue.length > 0) {
|
|
205
|
+
const item = this.memQueue.shift();
|
|
206
|
+
this.memBytes -= Buffer.byteLength(item, 'utf8');
|
|
207
|
+
return { value: item, done: false };
|
|
208
|
+
}
|
|
209
|
+
if (this.destroyed) return { done: true, value: undefined };
|
|
210
|
+
waitPromise = new Promise((resolve, reject) => {
|
|
211
|
+
this.waiters.push({ resolve, reject });
|
|
212
|
+
});
|
|
213
|
+
return null;
|
|
214
|
+
});
|
|
215
|
+
if (result !== null) return result;
|
|
216
|
+
await waitPromise;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
__wakeOne() {
|
|
221
|
+
if (this.waiters.length > 0) {
|
|
222
|
+
const w = this.waiters.shift();
|
|
223
|
+
w.resolve();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
__dispatchDrop(reason, size) {
|
|
228
|
+
try {
|
|
229
|
+
this.onDrop?.(reason, size);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
/* c8 ignore next 2 -- onDrop throwing is caller's bug */
|
|
232
|
+
this.logger?.warn?.('fbq.onDrop threw', err);
|
|
233
|
+
}
|
|
234
|
+
this.logger?.warn?.('fbq.drop', { reason, size });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async __openWriteStream() {
|
|
238
|
+
this.writeErr = null;
|
|
239
|
+
try {
|
|
240
|
+
await fs.mkdir(this.subdir, { recursive: true });
|
|
241
|
+
} catch (err) {
|
|
242
|
+
this.writeErr = err;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
this.writeStream = createWriteStream(this.filePath, { flags: 'a' });
|
|
246
|
+
this.writeStream.on('error', (err) => {
|
|
247
|
+
this.writeErr = err;
|
|
248
|
+
this.logger?.warn?.('fbq.writeStream error', err);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async __writeLine(str) {
|
|
253
|
+
if (this.writeErr) throw this.writeErr;
|
|
254
|
+
return await new Promise((resolve, reject) => {
|
|
255
|
+
this.writeStream.write(str, (err) => {
|
|
256
|
+
if (err) reject(err);
|
|
257
|
+
else resolve();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async __closeWriteStream() {
|
|
263
|
+
if (!this.writeStream) return;
|
|
264
|
+
const stream = this.writeStream;
|
|
265
|
+
this.writeStream = null;
|
|
266
|
+
if (stream.destroyed || stream.writableEnded) return;
|
|
267
|
+
// 使用事件而非 end(cb):errored 流上 end 的回调可能永不触发 → 死锁风险。
|
|
268
|
+
// 'close' 在正常结束后触发;'error' 在异常流上作为兜底。Promise 幂等。
|
|
269
|
+
await new Promise((resolve) => {
|
|
270
|
+
stream.once('close', resolve);
|
|
271
|
+
stream.once('error', resolve);
|
|
272
|
+
try {
|
|
273
|
+
stream.end();
|
|
274
|
+
/* c8 ignore next 3 -- stream.end 同步抛极少见 */
|
|
275
|
+
} catch {
|
|
276
|
+
resolve();
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 调用方必须已持有 mutex,且已确认 !destroyed
|
|
282
|
+
async __refillImpl() {
|
|
283
|
+
if (!this.spilled) return;
|
|
284
|
+
|
|
285
|
+
let actualEnd;
|
|
286
|
+
try {
|
|
287
|
+
const st = await fs.stat(this.filePath);
|
|
288
|
+
actualEnd = st.size;
|
|
289
|
+
} catch (err) {
|
|
290
|
+
/* c8 ignore next 3 -- stat 在正常持有期间不会失败 */
|
|
291
|
+
this.logger?.warn?.('fbq.refill stat error', err);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (this.readOffset >= actualEnd) {
|
|
296
|
+
await this.__dropFile();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const newLines = [];
|
|
301
|
+
let cumBytes = 0; // 文件字节:payload + \n
|
|
302
|
+
let cumPayload = 0; // 仅 payload
|
|
303
|
+
let stoppedAtEof = true;
|
|
304
|
+
|
|
305
|
+
const stream = createReadStream(this.filePath, {
|
|
306
|
+
start: this.readOffset,
|
|
307
|
+
end: actualEnd - 1,
|
|
308
|
+
});
|
|
309
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
for await (const line of rl) {
|
|
313
|
+
const sz = Buffer.byteLength(line, 'utf8');
|
|
314
|
+
if (newLines.length > 0 && this.memBytes + cumPayload + sz > this.memBudget) {
|
|
315
|
+
stoppedAtEof = false;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
newLines.push(line);
|
|
319
|
+
cumBytes += sz + 1;
|
|
320
|
+
cumPayload += sz;
|
|
321
|
+
}
|
|
322
|
+
} catch (err) {
|
|
323
|
+
/* c8 ignore next 4 -- read 错误罕见,保守退出 */
|
|
324
|
+
this.logger?.warn?.('fbq.refill read error', err);
|
|
325
|
+
rl.close();
|
|
326
|
+
stream.destroy();
|
|
327
|
+
return;
|
|
328
|
+
} finally {
|
|
329
|
+
rl.close();
|
|
330
|
+
stream.destroy();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const availableBytes = actualEnd - this.readOffset;
|
|
334
|
+
|
|
335
|
+
if (stoppedAtEof && cumBytes > availableBytes) {
|
|
336
|
+
// 最后一行未终止(尾部 \n 缺失):视为半截,丢弃
|
|
337
|
+
const partial = newLines.pop();
|
|
338
|
+
cumPayload -= Buffer.byteLength(partial, 'utf8');
|
|
339
|
+
this.logger?.warn?.('fbq.refill partial tail discarded', {
|
|
340
|
+
size: Buffer.byteLength(partial, 'utf8'),
|
|
341
|
+
});
|
|
342
|
+
// 将 readOffset 推到 writtenBytes,彻底丢弃尾部残片
|
|
343
|
+
this.readOffset = this.writtenBytes;
|
|
344
|
+
} else {
|
|
345
|
+
this.readOffset += cumBytes;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
for (const line of newLines) {
|
|
349
|
+
this.memQueue.push(line);
|
|
350
|
+
this.memBytes += Buffer.byteLength(line, 'utf8');
|
|
351
|
+
}
|
|
352
|
+
this.diskBytes -= cumPayload;
|
|
353
|
+
|
|
354
|
+
if (this.readOffset >= this.writtenBytes) {
|
|
355
|
+
await this.__dropFile();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async __dropFile() {
|
|
360
|
+
await this.__closeWriteStream();
|
|
361
|
+
try {
|
|
362
|
+
await fs.rm(this.filePath, { force: true });
|
|
363
|
+
} catch (err) {
|
|
364
|
+
/* c8 ignore next 2 -- rm with force rarely fails */
|
|
365
|
+
this.logger?.warn?.('fbq.dropFile error', err);
|
|
366
|
+
}
|
|
367
|
+
this.spilled = false;
|
|
368
|
+
this.writtenBytes = 0;
|
|
369
|
+
this.readOffset = 0;
|
|
370
|
+
this.diskBytes = 0;
|
|
371
|
+
this.writeErr = null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export { FileBackedQueue };
|
|
@@ -252,7 +252,14 @@ export class WebRtcPeer {
|
|
|
252
252
|
const turnUrl = iceServers.find((s) => s.urls?.startsWith('turn:'))?.urls ?? 'none';
|
|
253
253
|
this.__remoteLog(`rtc.ice-config conn=${connId} stun=${stunUrl} turn=${turnUrl}`);
|
|
254
254
|
|
|
255
|
-
|
|
255
|
+
// settings 仅对 pion 生效:werift 路径不吃 settings 字段(大概率静默忽略,
|
|
256
|
+
// 但按 __impl 分层更干净)。只收紧 pion 的 SCTP RTO 退避上限到 10s,
|
|
257
|
+
// 让 APK 后台唤醒后的深度退避窗口能落在 UI 的 15s 超时内。
|
|
258
|
+
const pcConfig = { iceServers };
|
|
259
|
+
if (this.__impl === 'pion') {
|
|
260
|
+
pcConfig.settings = { sctpRtoMax: 10000 };
|
|
261
|
+
}
|
|
262
|
+
const pc = new this.__PeerConnection(pcConfig);
|
|
256
263
|
|
|
257
264
|
const remoteMaxMessageSize = this.__resolveMaxMessageSize(pc, msg.payload.sdp);
|
|
258
265
|
|
|
@@ -569,16 +576,57 @@ export class WebRtcPeer {
|
|
|
569
576
|
*/
|
|
570
577
|
__dumpSessionState(connId, session, state) {
|
|
571
578
|
const rpcState = session.rpcChannel?.readyState ?? 'none';
|
|
572
|
-
const fileSummary = session.fileChannels
|
|
573
|
-
? 'none'
|
|
574
|
-
/* c8 ignore next -- ?? fallback for missing readyState */
|
|
575
|
-
: [...session.fileChannels].map((dc) => `${dc.label}=${dc.readyState ?? '?'}`).join(',');
|
|
579
|
+
const fileSummary = this.__summarizeFileChannels(session.fileChannels);
|
|
576
580
|
const q = session.rpcSendQueue;
|
|
577
581
|
const queueInfo = q
|
|
578
582
|
? `queueLen=${q.queue.length} queueBytes=${q.queueBytes} dropped=${q.droppedCount}`
|
|
579
583
|
: 'queue=none';
|
|
580
584
|
this.__remoteLog(`rtc.dump conn=${connId} state=${state} sessions=${this.__sessions.size} rpc=${rpcState} ${queueInfo} fileCount=${session.fileChannels.size} files=[${fileSummary}]`);
|
|
581
585
|
this.logger.info?.(`${this.__rtcTag} [${connId}] dump state=${state} rpc=${rpcState} ${queueInfo} fileCount=${session.fileChannels.size} files=${fileSummary}`);
|
|
586
|
+
// 仅 pion 路径追加 SCTP 采样:cwnd 是否塌回 1×MTU + bytesSent 增量是否 ~0
|
|
587
|
+
// 是判定"是否陷入深度 RTO 退避"的关键。fire-and-forget + 内部 try/catch
|
|
588
|
+
// 双保险,不阻塞 dump 主流程;rtc.sctp 独立一行避免污染既有 rtc.dump 格式。
|
|
589
|
+
if (this.__impl === 'pion' && typeof session.pc.getSctpStats === 'function') {
|
|
590
|
+
this.__dumpSctpStats(connId, session, state).catch(() => {});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* 按 readyState 聚合 file DC。closed 态只给计数,非 closed 态附带 label —
|
|
596
|
+
* 长会话内已关闭的 DC 会累积到 FIFO 上限,全量拼 label 会让 dump 膨胀,
|
|
597
|
+
* 而断连时真正有诊断价值的是"还没关干净"的 DC。
|
|
598
|
+
*/
|
|
599
|
+
__summarizeFileChannels(fileChannels) {
|
|
600
|
+
if (fileChannels.size === 0) return 'none';
|
|
601
|
+
const byState = new Map();
|
|
602
|
+
for (const dc of fileChannels) {
|
|
603
|
+
/* c8 ignore next -- ?? fallback for missing readyState */
|
|
604
|
+
const st = dc.readyState ?? '?';
|
|
605
|
+
if (!byState.has(st)) byState.set(st, []);
|
|
606
|
+
byState.get(st).push(dc.label);
|
|
607
|
+
}
|
|
608
|
+
const parts = [];
|
|
609
|
+
for (const [st, labels] of byState) {
|
|
610
|
+
if (st === 'closed') parts.push(`closed:${labels.length}`);
|
|
611
|
+
else parts.push(`${st}:${labels.length}(${labels.join(',')})`);
|
|
612
|
+
}
|
|
613
|
+
return parts.join(' ');
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async __dumpSctpStats(connId, session, state) {
|
|
617
|
+
try {
|
|
618
|
+
const stats = await session.pc.getSctpStats();
|
|
619
|
+
if (!stats) {
|
|
620
|
+
this.__remoteLog(`rtc.sctp conn=${connId} state=${state} sctp=none`);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
this.__remoteLog(
|
|
624
|
+
`rtc.sctp conn=${connId} state=${state} cwnd=${stats.congestionWindow} srtt=${Math.round(stats.srttMs)}ms sent=${stats.bytesSent} recv=${stats.bytesReceived} mtu=${stats.mtu}`,
|
|
625
|
+
);
|
|
626
|
+
} catch (err) {
|
|
627
|
+
this.__remoteLog(`rtc.sctp conn=${connId} state=${state} error=${err.message}`);
|
|
628
|
+
this.logger.warn?.(`${this.__rtcTag} [${connId}] getSctpStats error: ${err.message}`);
|
|
629
|
+
}
|
|
582
630
|
}
|
|
583
631
|
|
|
584
632
|
/**
|