@coclaw/openclaw-coclaw 0.12.3 → 0.13.1
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 -1
- package/src/auto-upgrade/updater-check.js +2 -0
- package/src/auto-upgrade/updater-spawn.js +1 -0
- package/src/auto-upgrade/updater.js +3 -0
- package/src/auto-upgrade/worker-verify.js +3 -0
- package/src/auto-upgrade/worker.js +5 -0
- package/src/chat-history-manager/manager.js +1 -0
- package/src/cli-registrar.js +2 -0
- package/src/common/gateway-notify.js +1 -0
- package/src/device-identity.js +2 -0
- package/src/file-manager/handler.js +109 -14
- package/src/plugin-version.js +1 -0
- package/src/realtime-bridge.js +55 -23
- package/src/session-manager/manager.js +1 -0
- package/src/settings.js +1 -0
- package/src/topic-manager/manager.js +1 -0
- package/src/topic-manager/title-gen.js +2 -0
- package/src/webrtc/dc-chunking.js +1 -1
- package/src/webrtc/ndc-preloader.js +2 -0
- package/src/webrtc/pion-preloader.js +83 -0
- package/src/webrtc/webrtc-peer.js +138 -37
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coclaw/openclaw-coclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "OpenClaw CoClaw channel plugin for remote chat",
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"node-datachannel": "0.32.2",
|
|
63
|
+
"@coclaw/pion-node": "^0.1.1",
|
|
63
64
|
"werift": "^0.19.0",
|
|
64
65
|
"ws": "^8.19.0"
|
|
65
66
|
},
|
|
@@ -33,6 +33,7 @@ export async function getPackageInfo(pluginDir) {
|
|
|
33
33
|
* @returns {Promise<string>}
|
|
34
34
|
*/
|
|
35
35
|
export async function getLatestVersion(pkgName, opts) {
|
|
36
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
36
37
|
const doExecFile = opts?.execFileFn ?? nodeExecFile;
|
|
37
38
|
return new Promise((resolve, reject) => {
|
|
38
39
|
doExecFile('npm', ['view', pkgName, 'version'], {
|
|
@@ -65,6 +66,7 @@ export function isNewerVersion(a, b) {
|
|
|
65
66
|
const pa = parse(a);
|
|
66
67
|
const pb = parse(b);
|
|
67
68
|
for (let i = 0; i < 3; i++) {
|
|
69
|
+
/* c8 ignore next 2 -- ?? fallback:正常 semver 不会有缺失段 */
|
|
68
70
|
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
|
|
69
71
|
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
|
|
70
72
|
}
|
|
@@ -31,6 +31,7 @@ export function getWorkerPath() {
|
|
|
31
31
|
* @returns {{ child: object }}
|
|
32
32
|
*/
|
|
33
33
|
export function spawnUpgradeWorker({ pluginDir, fromVersion, toVersion, pluginId, pkgName, opts, logger }) {
|
|
34
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
34
35
|
const doSpawn = opts?.spawnFn ?? nodeSpawn;
|
|
35
36
|
const workerPath = getWorkerPath();
|
|
36
37
|
|
|
@@ -166,6 +166,7 @@ export class AutoUpgradeScheduler {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
// 默认 5~10 分钟随机延迟,避免多实例同时发起检查
|
|
169
|
+
/* c8 ignore next 2 -- ?? fallback:测试始终注入 initialDelayMs */
|
|
169
170
|
const initialDelay = this.__opts.initialDelayMs
|
|
170
171
|
?? (INITIAL_DELAY_MS + Math.floor(Math.random() * INITIAL_DELAY_MS));
|
|
171
172
|
this.__logger.info?.(`[auto-upgrade] Scheduler started. First check in ${Math.round(initialDelay / 1000)}s`);
|
|
@@ -173,6 +174,7 @@ export class AutoUpgradeScheduler {
|
|
|
173
174
|
this.__initialTimer = setTimeout(() => {
|
|
174
175
|
this.__initialTimer = null;
|
|
175
176
|
this.__check().catch(() => {});
|
|
177
|
+
/* c8 ignore next -- ?? fallback */
|
|
176
178
|
const interval = this.__opts.checkIntervalMs ?? CHECK_INTERVAL_MS;
|
|
177
179
|
this.__intervalTimer = setInterval(() => this.__check().catch(() => {}), interval);
|
|
178
180
|
}, initialDelay);
|
|
@@ -274,6 +276,7 @@ export class AutoUpgradeScheduler {
|
|
|
274
276
|
});
|
|
275
277
|
|
|
276
278
|
// 记录 worker PID,下次 check 时据此判断 worker 是否仍在运行
|
|
279
|
+
/* c8 ignore next -- ?? fallback */
|
|
277
280
|
const writeLock = this.__opts.writeUpgradeLockFn ?? writeUpgradeLock;
|
|
278
281
|
await writeLock(child.pid);
|
|
279
282
|
}
|
|
@@ -24,6 +24,7 @@ const CMD_TIMEOUT_MS = 30_000;
|
|
|
24
24
|
* @returns {Promise<string>}
|
|
25
25
|
*/
|
|
26
26
|
function runCmd(cmd, args, opts) {
|
|
27
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
27
28
|
const doExecFile = opts?.execFileFn ?? nodeExecFile;
|
|
28
29
|
return new Promise((resolve, reject) => {
|
|
29
30
|
doExecFile(cmd, args, { timeout: CMD_TIMEOUT_MS, shell: process.platform === 'win32' }, (err, stdout) => {
|
|
@@ -50,6 +51,7 @@ export async function waitForGateway(opts) {
|
|
|
50
51
|
// restart 命令失败不阻断流程,仍尝试等待
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/* c8 ignore next 2 -- ?./?? fallback */
|
|
53
55
|
const timeout = opts?.timeoutMs ?? GATEWAY_READY_TIMEOUT_MS;
|
|
54
56
|
const interval = opts?.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
55
57
|
const start = Date.now();
|
|
@@ -124,6 +126,7 @@ export async function verifyUpgrade(pluginId, opts) {
|
|
|
124
126
|
return { ok: true, version };
|
|
125
127
|
}
|
|
126
128
|
catch (err) {
|
|
129
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
127
130
|
return { ok: false, error: String(err?.message ?? err) };
|
|
128
131
|
}
|
|
129
132
|
}
|
|
@@ -30,6 +30,7 @@ const SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.-]+)?$/;
|
|
|
30
30
|
// openclaw plugins update 内部实现为 staged backup-and-replace,
|
|
31
31
|
// 仅支持 source === "npm" 的安装(updater 已做前置过滤)
|
|
32
32
|
function runPluginUpdate(pluginId, opts) {
|
|
33
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
33
34
|
const doExecFile = opts?.execFileFn ?? nodeExecFile;
|
|
34
35
|
return new Promise((resolve, reject) => {
|
|
35
36
|
doExecFile('openclaw', ['plugins', 'update', pluginId], {
|
|
@@ -61,6 +62,7 @@ async function fallbackInstallOldVersion(pkgName, version, pluginId, opts) {
|
|
|
61
62
|
if (!SEMVER_RE.test(version)) {
|
|
62
63
|
throw new Error(`invalid version format: ${version}`);
|
|
63
64
|
}
|
|
65
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
64
66
|
const doExecFile = opts?.execFileFn ?? nodeExecFile;
|
|
65
67
|
const run = (args, timeout = 120_000) => new Promise((resolve, reject) => {
|
|
66
68
|
doExecFile('openclaw', args, { timeout, shell: process.platform === 'win32' }, (err) => {
|
|
@@ -193,11 +195,14 @@ async function handleRollback({ pluginDir, fromVersion, toVersion, pluginId, pkg
|
|
|
193
195
|
// update 命令失败可能是瞬态故障(网络、磁盘等),不应永久跳过该版本
|
|
194
196
|
if (skipVersion) {
|
|
195
197
|
try { await addSkippedVersion(toVersion); }
|
|
198
|
+
/* c8 ignore next -- 状态写入 catch:测试中 stub 不会失败 */
|
|
196
199
|
catch (e) { log(`[upgrade-worker] Failed to record skipped version (non-fatal): ${e.message}`); }
|
|
197
200
|
}
|
|
198
201
|
try { await updateLastUpgrade({ from: fromVersion, to: toVersion, result: 'rollback' }); }
|
|
202
|
+
/* c8 ignore next -- 状态写入 catch */
|
|
199
203
|
catch (e) { log(`[upgrade-worker] Failed to update lastUpgrade (non-fatal): ${e.message}`); }
|
|
200
204
|
try { await appendLog({ from: fromVersion, to: toVersion, result: 'rollback', error }); }
|
|
205
|
+
/* c8 ignore next -- 状态写入 catch */
|
|
201
206
|
catch (e) { log(`[upgrade-worker] Failed to append log (non-fatal): ${e.message}`); }
|
|
202
207
|
if (skipVersion) {
|
|
203
208
|
log(`[upgrade-worker] Rollback complete. Version ${toVersion} added to skipped list`);
|
|
@@ -36,6 +36,7 @@ export class ChatHistoryManager {
|
|
|
36
36
|
constructor(opts = {}) {
|
|
37
37
|
this.__rootDir = opts.rootDir ?? nodePath.join(os.homedir(), '.openclaw', 'agents');
|
|
38
38
|
this.__logger = opts.logger ?? console;
|
|
39
|
+
/* c8 ignore next 2 -- ?? fallback:测试始终注入 */
|
|
39
40
|
this.__readFile = opts.readFile ?? fs.readFile;
|
|
40
41
|
this.__writeJsonFile = opts.writeJsonFile ?? atomicWriteJsonFile;
|
|
41
42
|
// 内存缓存:agentId -> { version, [sessionKey]: [...] }
|
package/src/cli-registrar.js
CHANGED
|
@@ -54,6 +54,7 @@ async function callWithRetry(method, deps, rpcOpts) {
|
|
|
54
54
|
let result = await callRpc();
|
|
55
55
|
|
|
56
56
|
if (isGatewayUnavailable(result)) {
|
|
57
|
+
/* c8 ignore next -- ?? fallback:测试始终注入 deps.restartGateway */
|
|
57
58
|
const restartFn = deps.restartGateway ?? restartGatewayProcess;
|
|
58
59
|
try {
|
|
59
60
|
await restartFn(deps.spawn);
|
|
@@ -144,6 +145,7 @@ export function registerCoclawCli({ program, logger: _logger }, deps = {}) {
|
|
|
144
145
|
console.log(claimCodeCreated({
|
|
145
146
|
code: data.code,
|
|
146
147
|
appUrl: data.appUrl,
|
|
148
|
+
/* c8 ignore next -- ?? fallback */
|
|
147
149
|
expiresMinutes: data.expiresMinutes ?? 30,
|
|
148
150
|
}));
|
|
149
151
|
} else {
|
|
@@ -49,6 +49,7 @@ export function escapeJsonForCmd(json) {
|
|
|
49
49
|
* @returns {Promise<{ ok: boolean, status?: string, error?: string }>}
|
|
50
50
|
*/
|
|
51
51
|
export function callGatewayMethod(method, spawnFn, opts) {
|
|
52
|
+
/* c8 ignore next -- ?? fallback */
|
|
52
53
|
const doSpawn = spawnFn ?? nodeSpawn;
|
|
53
54
|
|
|
54
55
|
return new Promise((resolve) => {
|
package/src/device-identity.js
CHANGED
|
@@ -108,6 +108,7 @@ export function loadOrCreateDeviceIdentity(filePath) {
|
|
|
108
108
|
if (derivedId && derivedId !== parsed.deviceId) {
|
|
109
109
|
const updated = { ...parsed, deviceId: derivedId };
|
|
110
110
|
fs.writeFileSync(fp, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
|
|
111
|
+
/* c8 ignore next -- best-effort chmod */
|
|
111
112
|
try { fs.chmodSync(fp, 0o600); } catch { /* best-effort */ }
|
|
112
113
|
return { deviceId: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
|
|
113
114
|
}
|
|
@@ -131,6 +132,7 @@ export function loadOrCreateDeviceIdentity(filePath) {
|
|
|
131
132
|
createdAtMs: Date.now(),
|
|
132
133
|
};
|
|
133
134
|
fs.writeFileSync(fp, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
|
|
135
|
+
/* c8 ignore next -- best-effort chmod */
|
|
134
136
|
try { fs.chmodSync(fp, 0o600); } catch { /* best-effort */ }
|
|
135
137
|
return identity;
|
|
136
138
|
}
|
|
@@ -11,7 +11,7 @@ const HIGH_WATER_MARK = 262_144; // 256KB
|
|
|
11
11
|
const LOW_WATER_MARK = 65_536; // 64KB
|
|
12
12
|
const MAX_UPLOAD_SIZE = 1_073_741_824; // 1GB
|
|
13
13
|
const FILE_DC_TIMEOUT_MS = 30_000; // DC 打开后 30s 内需收到请求
|
|
14
|
-
const TMP_CLEANUP_DELAY_MS =
|
|
14
|
+
const TMP_CLEANUP_DELAY_MS = 2_000; // 启动后 2s 延迟清理
|
|
15
15
|
const TMP_FILE_PATTERN = /\.tmp\.[0-9a-f-]{36}$/;
|
|
16
16
|
|
|
17
17
|
// --- 路径安全校验 ---
|
|
@@ -70,6 +70,7 @@ export async function validatePath(workspaceDir, userPath, deps = {}) {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
// 仅允许普通文件和目录
|
|
73
|
+
/* c8 ignore next 4 -- 特殊文件类型(socket/FIFO/device)在测试环境无法可靠构造 */
|
|
73
74
|
if (!stat.isFile() && !stat.isDirectory() && !stat.isSymbolicLink()) {
|
|
74
75
|
const err = new Error(`Special file type denied: ${userPath}`);
|
|
75
76
|
err.code = 'PATH_DENIED';
|
|
@@ -329,6 +330,13 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
329
330
|
}, FILE_DC_TIMEOUT_MS);
|
|
330
331
|
requestTimer.unref?.();
|
|
331
332
|
|
|
333
|
+
// 早期 error 上报:保护 GET/PUT/POST 接管前的窗口期
|
|
334
|
+
// 内部 handler 接管后会用更具上下文的 onerror 替换此处
|
|
335
|
+
dc.onerror = (err) => {
|
|
336
|
+
/* c8 ignore next -- ?? fallback for missing label/err.message */
|
|
337
|
+
remoteLog(`file.dc.error conn=${connId} label=${dc.label ?? 'unknown'} stage=pre-request err=${err?.message ?? err}`);
|
|
338
|
+
};
|
|
339
|
+
|
|
332
340
|
let requestReceived = false;
|
|
333
341
|
|
|
334
342
|
dc.onmessage = (event) => {
|
|
@@ -348,9 +356,13 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
348
356
|
return;
|
|
349
357
|
}
|
|
350
358
|
|
|
359
|
+
// 本地 logger.info:让 gateway 本地 log 直接看到 file 操作的开始
|
|
360
|
+
// (远端诊断走 remoteLog,但本地能看到对排查 WSL2 假活/重连场景至关重要)
|
|
361
|
+
log.info?.(`[coclaw/file] [${connId}] ${req.method} label=${dc.label ?? '?'} path=${req.path ?? '?'}${req.size != null ? ` size=${req.size}` : ''}`);
|
|
362
|
+
|
|
351
363
|
if (req.method === 'GET') {
|
|
352
364
|
/* c8 ignore next 3 -- handleGet 内部已完整处理异常,此 catch 纯防御 */
|
|
353
|
-
handleGet(dc, req).catch((err) => {
|
|
365
|
+
handleGet(dc, req, connId).catch((err) => {
|
|
354
366
|
log.warn?.(`[coclaw/file] GET error: ${err.message}`);
|
|
355
367
|
});
|
|
356
368
|
} else if (req.method === 'PUT') {
|
|
@@ -369,7 +381,12 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
369
381
|
};
|
|
370
382
|
}
|
|
371
383
|
|
|
372
|
-
async function handleGet(dc, req) {
|
|
384
|
+
async function handleGet(dc, req, connId) {
|
|
385
|
+
/* c8 ignore next -- ?./?? fallback for non-file: label */
|
|
386
|
+
const transferId = dc.label?.split(':')?.[1] ?? randomUUID();
|
|
387
|
+
const logTag = connId ? `conn=${connId} ` : '';
|
|
388
|
+
const startTime = Date.now();
|
|
389
|
+
|
|
373
390
|
let workspaceDir, resolved;
|
|
374
391
|
try {
|
|
375
392
|
const agentId = req.agentId?.trim?.() || 'main'; /* c8 ignore next -- ?./?? fallback */
|
|
@@ -410,18 +427,50 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
410
427
|
} catch {
|
|
411
428
|
return; // DC 已关闭
|
|
412
429
|
}
|
|
430
|
+
remoteLog(`file.dl.start ${logTag}id=${transferId} size=${stat.size}`);
|
|
431
|
+
/* c8 ignore next -- 空文件分支:进度日志条件下永远不触发,无需 25% 阈值 */
|
|
432
|
+
let nextLogAt = stat.size > 0 ? Math.floor(stat.size * 0.25) : Infinity;
|
|
433
|
+
let logStep = 1;
|
|
413
434
|
|
|
414
435
|
// 流式发送文件内容
|
|
415
436
|
const stream = _createReadStream(resolved, { highWaterMark: CHUNK_SIZE });
|
|
416
437
|
let sentBytes = 0;
|
|
417
438
|
let dcClosed = false;
|
|
418
439
|
|
|
419
|
-
|
|
440
|
+
// flow control 状态
|
|
441
|
+
let bufferedAmountLowCount = 0;
|
|
442
|
+
let pauseCount = 0;
|
|
443
|
+
let resumeCount = 0;
|
|
444
|
+
let pausedNow = false;
|
|
445
|
+
|
|
446
|
+
dc.onclose = () => {
|
|
447
|
+
dcClosed = true;
|
|
448
|
+
stream.destroy();
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
// pion 异步 send 错误经此回调上报;ndc 同步抛错由 stream.on('data') 的 try/catch 接住
|
|
452
|
+
dc.onerror = (err) => {
|
|
453
|
+
if (dcClosed) return;
|
|
454
|
+
dcClosed = true;
|
|
455
|
+
stream.destroy();
|
|
456
|
+
const elapsed = Date.now() - startTime;
|
|
457
|
+
/* c8 ignore next -- ?? fallback for non-Error throw */
|
|
458
|
+
const errMsg = err?.message ?? String(err);
|
|
459
|
+
remoteLog(`file.dl.fail ${logTag}id=${transferId} reason=dc-error err=${errMsg} sent=${sentBytes}/${stat.size} elapsed=${elapsed}ms`);
|
|
460
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] dl.fail id=${transferId} reason=dc-error sent=${sentBytes}/${stat.size} err=${errMsg}`);
|
|
461
|
+
};
|
|
420
462
|
|
|
421
463
|
if (dc.bufferedAmountLowThreshold !== undefined) {
|
|
422
464
|
dc.bufferedAmountLowThreshold = LOW_WATER_MARK;
|
|
423
465
|
}
|
|
424
|
-
dc.onbufferedamountlow = () =>
|
|
466
|
+
dc.onbufferedamountlow = () => {
|
|
467
|
+
bufferedAmountLowCount++;
|
|
468
|
+
if (pausedNow) {
|
|
469
|
+
resumeCount++;
|
|
470
|
+
pausedNow = false;
|
|
471
|
+
stream.resume();
|
|
472
|
+
}
|
|
473
|
+
};
|
|
425
474
|
|
|
426
475
|
await new Promise((resolve, reject) => {
|
|
427
476
|
stream.on('data', (chunk) => {
|
|
@@ -430,8 +479,17 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
430
479
|
dc.send(chunk);
|
|
431
480
|
sentBytes += chunk.length;
|
|
432
481
|
if (dc.bufferedAmount > HIGH_WATER_MARK) {
|
|
482
|
+
pauseCount++;
|
|
483
|
+
pausedNow = true;
|
|
433
484
|
stream.pause();
|
|
434
485
|
}
|
|
486
|
+
// 进度日志(25% / 50% / 75%)
|
|
487
|
+
if (sentBytes >= nextLogAt && logStep <= 3) {
|
|
488
|
+
remoteLog(`file.dl.progress ${logTag}id=${transferId} ${logStep * 25}% sent=${sentBytes}/${stat.size}`);
|
|
489
|
+
logStep++;
|
|
490
|
+
/* c8 ignore next -- 空文件分支:进入此循环时 stat.size 必然 > 0 */
|
|
491
|
+
nextLogAt = stat.size > 0 ? Math.floor(stat.size * logStep * 0.25) : Infinity;
|
|
492
|
+
}
|
|
435
493
|
/* c8 ignore start -- dc.send 抛异常属罕见竞态 */
|
|
436
494
|
} catch {
|
|
437
495
|
dcClosed = true;
|
|
@@ -439,18 +497,27 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
439
497
|
}
|
|
440
498
|
/* c8 ignore stop */
|
|
441
499
|
});
|
|
442
|
-
stream.on('end', () => {
|
|
500
|
+
stream.on('end', async () => {
|
|
443
501
|
if (dcClosed) { resolve(); return; }
|
|
444
502
|
try {
|
|
445
503
|
dc.send(JSON.stringify({ ok: true, bytes: sentBytes }));
|
|
446
|
-
|
|
504
|
+
// 必须 await close():pion-node 等价 W3C graceful close,
|
|
505
|
+
// 否则在不支持 graceful 的实现上最后一条 ok JSON 会被丢弃
|
|
506
|
+
await dc.close();
|
|
447
507
|
} catch { /* ignore */ }
|
|
508
|
+
const elapsed = Date.now() - startTime;
|
|
509
|
+
// 完成时也 dump 一次最终统计,便于事后审计 backpressure 行为
|
|
510
|
+
remoteLog(`file.dl.ok ${logTag}id=${transferId} bytes=${sentBytes} elapsed=${elapsed}ms balCount=${bufferedAmountLowCount} pauseCount=${pauseCount} resumeCount=${resumeCount}`);
|
|
511
|
+
log.info?.(`[coclaw/file] [${connId ?? '?'}] dl.ok id=${transferId} bytes=${sentBytes} elapsed=${elapsed}ms balCount=${bufferedAmountLowCount} pauseCount=${pauseCount}`);
|
|
448
512
|
resolve();
|
|
449
513
|
});
|
|
450
514
|
stream.on('error', (err) => {
|
|
451
515
|
if (!dcClosed) {
|
|
452
516
|
sendError(dc, 'READ_FAILED', err.message);
|
|
453
517
|
}
|
|
518
|
+
const elapsed = Date.now() - startTime;
|
|
519
|
+
remoteLog(`file.dl.fail ${logTag}id=${transferId} reason=read-error err=${err.message} sent=${sentBytes}/${stat.size} elapsed=${elapsed}ms`);
|
|
520
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] dl.fail id=${transferId} reason=read-error sent=${sentBytes}/${stat.size} err=${err.message}`);
|
|
454
521
|
reject(err);
|
|
455
522
|
});
|
|
456
523
|
}).catch((err) => {
|
|
@@ -630,6 +697,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
630
697
|
if (dcClosed) {
|
|
631
698
|
safeUnlink(tmpPath);
|
|
632
699
|
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-closed-before-flush received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
700
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=dc-closed-before-flush received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
633
701
|
return;
|
|
634
702
|
}
|
|
635
703
|
const valid = receivedBytes === declaredSize;
|
|
@@ -639,9 +707,10 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
639
707
|
/* c8 ignore next */
|
|
640
708
|
} catch { /* ignore */ }
|
|
641
709
|
safeUnlink(tmpPath);
|
|
642
|
-
|
|
643
|
-
try { dc.close(); } catch { /* ignore */ }
|
|
710
|
+
// graceful close:必须 await,否则 send 入队的 error JSON 会被 close 丢弃
|
|
711
|
+
try { await dc.close(); } catch { /* ignore */ }
|
|
644
712
|
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=size-mismatch expected=${declaredSize} got=${receivedBytes} elapsed=${elapsed}ms`);
|
|
713
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=size-mismatch expected=${declaredSize} got=${receivedBytes} elapsed=${elapsed}ms`);
|
|
645
714
|
return;
|
|
646
715
|
}
|
|
647
716
|
// 先 rename,再发成功响应(避免 rename 失败时 UI 误认为成功)
|
|
@@ -654,9 +723,9 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
654
723
|
dc.send(JSON.stringify({ ok: false, error: { code: 'WRITE_FAILED', message: `rename failed: ${renameErr.message}` } }));
|
|
655
724
|
} catch { /* ignore */ }
|
|
656
725
|
safeUnlink(tmpPath);
|
|
657
|
-
/*
|
|
658
|
-
|
|
659
|
-
|
|
726
|
+
try { await dc.close(); } catch { /* ignore */ }
|
|
727
|
+
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=rename-failed received=${receivedBytes} elapsed=${elapsed}ms`);
|
|
728
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=rename-failed received=${receivedBytes} elapsed=${elapsed}ms`);
|
|
660
729
|
return;
|
|
661
730
|
}
|
|
662
731
|
const result = { ok: true, bytes: receivedBytes };
|
|
@@ -665,9 +734,10 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
665
734
|
dc.send(JSON.stringify(result));
|
|
666
735
|
/* c8 ignore next */
|
|
667
736
|
} catch { /* ignore */ }
|
|
668
|
-
|
|
669
|
-
try { dc.close(); } catch { /* ignore */ }
|
|
737
|
+
// graceful close:上传成功路径同样必须 await,否则 result JSON 会丢
|
|
738
|
+
try { await dc.close(); } catch { /* ignore */ }
|
|
670
739
|
remoteLog(`file.up.ok ${logTag}id=${transferId} bytes=${receivedBytes} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
740
|
+
log.info?.(`[coclaw/file] [${connId ?? '?'}] up.ok id=${transferId} bytes=${receivedBytes} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
671
741
|
});
|
|
672
742
|
}
|
|
673
743
|
|
|
@@ -701,6 +771,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
701
771
|
} catch { /* ignore */ }
|
|
702
772
|
try { dc.close(); } catch { /* ignore */ }
|
|
703
773
|
remoteLog(`file.up.reject ${logTag}id=${transferId} reason=size-exceeded received=${receivedBytes}`);
|
|
774
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.reject id=${transferId} reason=size-exceeded received=${receivedBytes}`);
|
|
704
775
|
return;
|
|
705
776
|
}
|
|
706
777
|
|
|
@@ -711,6 +782,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
711
782
|
if (receivedBytes >= nextLogAt && logStep <= 3) {
|
|
712
783
|
remoteLog(`file.up.progress ${logTag}id=${transferId} ${logStep * 25}% received=${receivedBytes}/${declaredSize} bp=${wsBackpressureCount}`);
|
|
713
784
|
logStep++;
|
|
785
|
+
/* c8 ignore next -- declaredSize=0 的上传不会达到进度日志阈值 */
|
|
714
786
|
nextLogAt = declaredSize > 0 ? Math.floor(declaredSize * logStep * 0.25) : Infinity;
|
|
715
787
|
}
|
|
716
788
|
}
|
|
@@ -728,11 +800,31 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
728
800
|
safeUnlink(tmpPath);
|
|
729
801
|
const elapsed = Date.now() - startTime;
|
|
730
802
|
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
803
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
731
804
|
}
|
|
732
805
|
};
|
|
733
806
|
|
|
807
|
+
// pion 异步 send 错误经此回调上报;触发已有清理路径
|
|
808
|
+
dc.onerror = (err) => {
|
|
809
|
+
if (dcClosed || wsError) return;
|
|
810
|
+
wsError = true;
|
|
811
|
+
draining = false;
|
|
812
|
+
pendingQueue.length = 0;
|
|
813
|
+
ws.destroy();
|
|
814
|
+
safeUnlink(tmpPath);
|
|
815
|
+
const elapsed = Date.now() - startTime;
|
|
816
|
+
/* c8 ignore next -- ?? fallback for non-Error throw */
|
|
817
|
+
const errMsg = err?.message ?? String(err);
|
|
818
|
+
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-error err=${errMsg} received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
819
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
820
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=dc-error received=${receivedBytes}/${declaredSize} err=${errMsg}`);
|
|
821
|
+
};
|
|
822
|
+
|
|
734
823
|
// WriteStream 错误处理
|
|
735
824
|
ws.on('error', (err) => {
|
|
825
|
+
// 幂等:dc.onerror 路径会先 ws.destroy(),destroy 可能再触发一次 'error',
|
|
826
|
+
// 已设 wsError 后直接返回,避免产生第二条 fail 日志
|
|
827
|
+
if (wsError) return;
|
|
736
828
|
wsError = true;
|
|
737
829
|
draining = false;
|
|
738
830
|
pendingQueue.length = 0;
|
|
@@ -744,6 +836,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
744
836
|
safeUnlink(tmpPath);
|
|
745
837
|
const elapsed = Date.now() - startTime;
|
|
746
838
|
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=write-error err=${err.code || err.message} received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms`);
|
|
839
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=write-error received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms err=${err.code || err.message}`);
|
|
747
840
|
});
|
|
748
841
|
}
|
|
749
842
|
|
|
@@ -799,9 +892,11 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
799
892
|
// --- 工具函数 ---
|
|
800
893
|
|
|
801
894
|
function sendError(dc, code, message) {
|
|
895
|
+
/* c8 ignore next 2 -- DC 可能已关闭,catch 纯防御 */
|
|
802
896
|
try {
|
|
803
897
|
dc.send(JSON.stringify({ ok: false, error: { code, message } }));
|
|
804
898
|
} catch { /* DC 可能已关闭 */ }
|
|
899
|
+
/* c8 ignore next */
|
|
805
900
|
try { dc.close(); } catch { /* ignore */ }
|
|
806
901
|
}
|
|
807
902
|
|
package/src/plugin-version.js
CHANGED
|
@@ -8,6 +8,7 @@ export async function getPluginVersion() {
|
|
|
8
8
|
try {
|
|
9
9
|
const pkgPath = nodePath.resolve(import.meta.dirname, '..', 'package.json');
|
|
10
10
|
const raw = await fs.readFile(pkgPath, 'utf8');
|
|
11
|
+
/* c8 ignore next -- ?? fallback */
|
|
11
12
|
__pluginVersion = JSON.parse(raw).version ?? 'unknown';
|
|
12
13
|
} catch {
|
|
13
14
|
return 'unknown';
|
package/src/realtime-bridge.js
CHANGED
|
@@ -85,6 +85,7 @@ export class RealtimeBridge {
|
|
|
85
85
|
this.__getBindingsPath = deps.getBindingsPath ?? getBindingsPath;
|
|
86
86
|
this.__resolveGatewayAuthToken = deps.resolveGatewayAuthToken ?? defaultResolveGatewayAuthToken;
|
|
87
87
|
this.__loadDeviceIdentity = deps.loadDeviceIdentity ?? loadOrCreateDeviceIdentity;
|
|
88
|
+
this.__preloadPion = deps.preloadPion ?? null;
|
|
88
89
|
this.__preloadNdc = deps.preloadNdc ?? null;
|
|
89
90
|
this.__WebSocket = deps.WebSocket; // undefined=使用 ws 包, null=禁用(测试用), 其他=自定义实现
|
|
90
91
|
this.__gatewayReadyTimeoutMs = deps.gatewayReadyTimeoutMs ?? 1500;
|
|
@@ -238,6 +239,7 @@ export class RealtimeBridge {
|
|
|
238
239
|
this.__fileHandler.handleFileChannel(dc, connId);
|
|
239
240
|
},
|
|
240
241
|
PeerConnection,
|
|
242
|
+
impl: this.__ndcPreloadResult?.impl,
|
|
241
243
|
logger: this.logger,
|
|
242
244
|
});
|
|
243
245
|
}
|
|
@@ -902,6 +904,7 @@ export class RealtimeBridge {
|
|
|
902
904
|
this.__clearConnectTimer();
|
|
903
905
|
setRemoteLogSender(null);
|
|
904
906
|
remoteLog(`ws.error peer=server msg=${String(err?.message ?? err)}`);
|
|
907
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
905
908
|
this.logger.warn?.(`[coclaw] realtime bridge error, will retry in ${RECONNECT_MS}ms: ${String(err?.message ?? err)}`);
|
|
906
909
|
this.serverWs = null;
|
|
907
910
|
this.__closeGatewayWs();
|
|
@@ -912,27 +915,53 @@ export class RealtimeBridge {
|
|
|
912
915
|
});
|
|
913
916
|
}
|
|
914
917
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
918
|
+
/* c8 ignore start -- WebRTC preload 涉及 native/Go 进程,集成测试覆盖 */
|
|
919
|
+
async __preloadWebrtc() {
|
|
920
|
+
// 版本预热并行启动
|
|
921
|
+
const versionPromise = getPluginVersion()
|
|
922
|
+
.then((v) => { this.__pluginVersion = v; })
|
|
923
|
+
.catch(() => { this.__pluginVersion = 'unknown'; });
|
|
924
|
+
|
|
925
|
+
// 1. 尝试 pion(最高优先级)
|
|
926
|
+
const preloadPionFn = this.__preloadPion
|
|
927
|
+
?? (await import('./webrtc/pion-preloader.js')).preloadPion;
|
|
928
|
+
const pionResult = await preloadPionFn().catch((err) => {
|
|
929
|
+
this.logger.warn?.(`[coclaw] pion preload unexpected failure: ${err?.message}`);
|
|
930
|
+
return null;
|
|
931
|
+
});
|
|
932
|
+
if (pionResult?.PeerConnection) {
|
|
933
|
+
await versionPromise;
|
|
934
|
+
return pionResult;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// 2. 回退到 ndc/werift
|
|
938
|
+
const preloadNdcFn = this.__preloadNdc
|
|
921
939
|
?? (await import('./webrtc/ndc-preloader.js')).preloadNdc;
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
preloadFn().catch((err) => {
|
|
925
|
-
// preloadNdc 设计上永不 throw,此 catch 为纯防御性兜底
|
|
940
|
+
const [ndcResult] = await Promise.all([
|
|
941
|
+
preloadNdcFn().catch((err) => {
|
|
926
942
|
this.logger.warn?.(`[coclaw] ndc preload unexpected failure: ${err?.message}`);
|
|
927
943
|
return { PeerConnection: null, cleanup: null, impl: 'none' };
|
|
928
944
|
}),
|
|
929
|
-
|
|
930
|
-
.then((v) => { this.__pluginVersion = v; })
|
|
931
|
-
.catch(() => { this.__pluginVersion = 'unknown'; }),
|
|
945
|
+
versionPromise,
|
|
932
946
|
]);
|
|
947
|
+
return ndcResult;
|
|
948
|
+
}
|
|
949
|
+
/* c8 ignore stop */
|
|
950
|
+
|
|
951
|
+
async start({ logger, pluginConfig } = {}) {
|
|
952
|
+
/* c8 ignore next 2 -- ?? fallback:测试始终注入 logger/pluginConfig */
|
|
953
|
+
this.logger = logger ?? console;
|
|
954
|
+
this.pluginConfig = pluginConfig ?? {};
|
|
955
|
+
this.started = true;
|
|
956
|
+
// 先完成 WebRTC 实现加载,再建立连接,避免 UI 发来 offer 时 RTC 包未就绪
|
|
957
|
+
// 优先级:pion → ndc → werift → none
|
|
958
|
+
const preloadResult = await this.__preloadWebrtc();
|
|
933
959
|
// 竞态保护:若 preload 期间 stop() 已执行,不再赋值,直接返回。
|
|
934
|
-
// 不调 cleanup()——与 stop() 策略一致,native threads 保持活跃供后续复用。
|
|
935
960
|
if (!this.started) {
|
|
961
|
+
// pion 进程需要关闭
|
|
962
|
+
if (preloadResult.impl === 'pion' && preloadResult.cleanup) {
|
|
963
|
+
preloadResult.cleanup().catch(() => {});
|
|
964
|
+
}
|
|
936
965
|
return;
|
|
937
966
|
}
|
|
938
967
|
this.__ndcPreloadResult = preloadResult;
|
|
@@ -967,12 +996,12 @@ export class RealtimeBridge {
|
|
|
967
996
|
this.webrtcPeer = null;
|
|
968
997
|
this.__webrtcPeerReady = null;
|
|
969
998
|
}
|
|
970
|
-
//
|
|
971
|
-
// cleanup()
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
999
|
+
// pion: 关闭 Go 进程(异步,快速)
|
|
1000
|
+
// ndc: 不调用 cleanup()——同步 join native threads 耗时 10s+,进程退出时 OS 回收
|
|
1001
|
+
const impl = this.__ndcPreloadResult?.impl;
|
|
1002
|
+
if (impl === 'pion' && this.__ndcCleanup) {
|
|
1003
|
+
await this.__ndcCleanup().catch(() => {});
|
|
1004
|
+
}
|
|
976
1005
|
this.__ndcCleanup = null;
|
|
977
1006
|
this.__ndcPreloadResult = null;
|
|
978
1007
|
if (this.__fileHandler) {
|
|
@@ -1033,11 +1062,14 @@ export async function stopRealtimeBridge({ forceCleanup = false } = {}) {
|
|
|
1033
1062
|
if (!singleton) {
|
|
1034
1063
|
return;
|
|
1035
1064
|
}
|
|
1036
|
-
|
|
1065
|
+
// pion 的 cleanup 已在 stop() 内处理(fast async),此处 forceCleanup 仅用于 ndc
|
|
1066
|
+
const impl = singleton.__ndcPreloadResult?.impl;
|
|
1067
|
+
const cleanupFn = (forceCleanup && impl !== 'pion') ? singleton.__ndcCleanup : null;
|
|
1037
1068
|
await singleton.stop();
|
|
1038
1069
|
singleton = null; // 置 null 后须通过 restartRealtimeBridge 重建
|
|
1070
|
+
/* c8 ignore next 3 -- forceCleanup 仅 ndc 测试清理 TSFN,pion binary 存在时走 pion 路径不触发 */
|
|
1039
1071
|
if (typeof cleanupFn === 'function') {
|
|
1040
|
-
try { cleanupFn(); } catch { /*
|
|
1072
|
+
try { cleanupFn(); } catch { /* cleanup 失败不影响 stop 结果 */ }
|
|
1041
1073
|
}
|
|
1042
1074
|
}
|
|
1043
1075
|
|
|
@@ -1076,5 +1108,5 @@ export function broadcastPluginEvent(event, payload) {
|
|
|
1076
1108
|
if (!singleton) return;
|
|
1077
1109
|
const frame = { type: 'event', event, payload };
|
|
1078
1110
|
singleton.__forwardToServer(frame);
|
|
1079
|
-
singleton.webrtcPeer?.broadcast(frame);
|
|
1111
|
+
singleton.webrtcPeer?.broadcast(frame); /* c8 ignore -- ?. fallback */
|
|
1080
1112
|
}
|
package/src/settings.js
CHANGED
|
@@ -29,6 +29,7 @@ export class TopicManager {
|
|
|
29
29
|
* @param {Function} [opts.copyFile] - 测试注入
|
|
30
30
|
*/
|
|
31
31
|
constructor(opts = {}) {
|
|
32
|
+
/* c8 ignore next 6 -- ?? fallback:测试始终注入 */
|
|
32
33
|
this.__rootDir = opts.rootDir ?? nodePath.join(os.homedir(), '.openclaw', 'agents');
|
|
33
34
|
this.__logger = opts.logger ?? console;
|
|
34
35
|
this.__readFile = opts.readFile ?? fs.readFile;
|
|
@@ -136,10 +136,12 @@ export async function generateTitle({ topicId, topicManager, agentRpc, logger })
|
|
|
136
136
|
await topicManager.updateTitle({ topicId, title });
|
|
137
137
|
return { title };
|
|
138
138
|
} catch (err) {
|
|
139
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
139
140
|
log.warn?.(`[coclaw] generateTitle failed for topic ${topicId}: ${String(err?.message ?? err)}`);
|
|
140
141
|
throw err;
|
|
141
142
|
} finally {
|
|
142
143
|
// 清理临时文件
|
|
144
|
+
/* c8 ignore next -- .catch() 防御 */
|
|
143
145
|
await topicManager.cleanupTempFile(tempPath).catch(() => {});
|
|
144
146
|
}
|
|
145
147
|
}
|
|
@@ -82,7 +82,7 @@ export function createReassembler(onComplete, opts = {}) {
|
|
|
82
82
|
// binary = 分片 chunk
|
|
83
83
|
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
84
84
|
if (buf.length < HEADER_SIZE) {
|
|
85
|
-
logger?.warn?.('[dc-chunking] chunk too short, discarding');
|
|
85
|
+
logger?.warn?.('[dc-chunking] chunk too short, discarding'); /* c8 ignore -- ?./?. fallback */
|
|
86
86
|
return;
|
|
87
87
|
}
|
|
88
88
|
|
|
@@ -73,6 +73,7 @@ function wrapNdcCredentials(NativeRTC) {
|
|
|
73
73
|
config = {
|
|
74
74
|
...config,
|
|
75
75
|
iceServers: config.iceServers.map(s => {
|
|
76
|
+
/* c8 ignore next -- TURN 无凭据时的短路,集成环境下不经过此路径 */
|
|
76
77
|
if (!s.username && !s.credential) return s;
|
|
77
78
|
return {
|
|
78
79
|
...s,
|
|
@@ -185,6 +186,7 @@ export async function preloadNdc(deps = {}) {
|
|
|
185
186
|
// 用于捕获 ICE/DTLS/SCTP 层断连原因。
|
|
186
187
|
// initLogger 是进程全局单例,调用一次即可(cleanup 不会被调用,logger 全程有效)。
|
|
187
188
|
// callback 通过 TSFN 投递到 JS 主线程,Warning 级别正常运行时零输出。
|
|
189
|
+
/* c8 ignore next -- ??/?. fallback */
|
|
188
190
|
const initLogger = ndc.initLogger ?? ndc.default?.initLogger;
|
|
189
191
|
if (typeof initLogger === 'function') {
|
|
190
192
|
try {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { remoteLog as defaultRemoteLog } from '../remote-log.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_START_TIMEOUT_MS = 10_000;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 预加载 Pion WebRTC 实现:启动 pion-ipc Go 进程,返回绑定了 ipc 的 PeerConnection。
|
|
7
|
+
*
|
|
8
|
+
* **此函数永不 throw**——所有异常内部捕获,通过 remoteLog 报告。
|
|
9
|
+
* 失败时返回 null(调用方降级到 ndc/werift)。
|
|
10
|
+
*
|
|
11
|
+
* binary 解析由 @coclaw/pion-node 内部处理(env → npm 平台包 → PATH)。
|
|
12
|
+
*
|
|
13
|
+
* @param {object} [deps] - 可注入依赖(测试用)
|
|
14
|
+
* @param {Function} [deps.dynamicImport] - (specifier) => import(specifier)
|
|
15
|
+
* @param {Function} [deps.remoteLog] - (text) => void
|
|
16
|
+
* @param {number} [deps.startTimeout] - 启动超时(ms),默认 10s
|
|
17
|
+
* @returns {Promise<{ PeerConnection: Function, cleanup: Function, impl: string, ipc: object }|null>}
|
|
18
|
+
*/
|
|
19
|
+
export async function preloadPion(deps = {}) {
|
|
20
|
+
const log = deps.remoteLog ?? defaultRemoteLog;
|
|
21
|
+
const dynamicImport = deps.dynamicImport ?? ((spec) => import(spec));
|
|
22
|
+
const startTimeout = deps.startTimeout ?? DEFAULT_START_TIMEOUT_MS;
|
|
23
|
+
|
|
24
|
+
log('pion.preload');
|
|
25
|
+
|
|
26
|
+
let ipc = null;
|
|
27
|
+
try {
|
|
28
|
+
// 加载 pion-node SDK
|
|
29
|
+
let PionIpc, RTCPeerConnection;
|
|
30
|
+
try {
|
|
31
|
+
const mod = await dynamicImport('@coclaw/pion-node');
|
|
32
|
+
PionIpc = mod.PionIpc;
|
|
33
|
+
RTCPeerConnection = mod.RTCPeerConnection;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
log(`pion.skip reason=import-failed error=${err.message}`);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (typeof PionIpc !== 'function' || typeof RTCPeerConnection !== 'function') {
|
|
40
|
+
log('pion.skip reason=invalid-exports');
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 启动 IPC 进程(内部会 ping 验证就绪,binary 由 pion-node 自动解析)
|
|
45
|
+
ipc = new PionIpc({
|
|
46
|
+
logger: (msg) => log(`pion.ipc ${msg}`),
|
|
47
|
+
timeout: startTimeout,
|
|
48
|
+
autoRestart: true,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await ipc.start();
|
|
53
|
+
} catch (err) {
|
|
54
|
+
log(`pion.skip reason=start-failed error=${err.message}`);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 创建绑定了 ipc 的 PeerConnection 子类
|
|
59
|
+
class BoundPeerConnection extends RTCPeerConnection {
|
|
60
|
+
constructor(config = {}) {
|
|
61
|
+
super({ ...config, _ipc: ipc });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const cleanup = async () => {
|
|
66
|
+
try {
|
|
67
|
+
await ipc.stop();
|
|
68
|
+
} catch {
|
|
69
|
+
// 静默忽略,stop 失败不影响后续
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
log('pion.loaded');
|
|
74
|
+
return { PeerConnection: BoundPeerConnection, cleanup, impl: 'pion', ipc };
|
|
75
|
+
} catch (err) {
|
|
76
|
+
// ipc 已启动但后续步骤意外失败 → 关闭 Go 进程,防止泄漏
|
|
77
|
+
if (ipc) {
|
|
78
|
+
ipc.stop().catch(() => {});
|
|
79
|
+
}
|
|
80
|
+
log(`pion.skip reason=unexpected error=${err.message}`);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { chunkAndSend, createReassembler } from './dc-chunking.js';
|
|
2
2
|
import { remoteLog } from '../remote-log.js';
|
|
3
3
|
|
|
4
|
+
// 单个 session 内 file DC 历史快照的容量上限(满后按 FIFO 淘汰最老条目)。
|
|
5
|
+
// 用于诊断 dump:过大会撑爆 remoteLog 单帧,20 足以覆盖典型多文件传输会话。
|
|
6
|
+
const FILE_CHANNEL_HISTORY_LIMIT = 20;
|
|
7
|
+
|
|
4
8
|
/**
|
|
5
9
|
* 管理多个 WebRTC PeerConnection(以 connId 为粒度)。
|
|
6
10
|
* Plugin 作为被叫方:收到 UI 的 offer → 回复 answer。
|
|
@@ -14,8 +18,9 @@ export class WebRtcPeer {
|
|
|
14
18
|
* @param {function} [opts.onFileChannel] - file:<transferId> DataChannel 的回调 (dc, connId) => void
|
|
15
19
|
* @param {object} [opts.logger] - pino 风格 logger
|
|
16
20
|
* @param {function} opts.PeerConnection - RTCPeerConnection 构造函数(由 ndc-preloader 提供)
|
|
21
|
+
* @param {string} [opts.impl] - WebRTC 实现标识(pion / ndc / werift)
|
|
17
22
|
*/
|
|
18
|
-
constructor({ onSend, onRequest, onFileRpc, onFileChannel, logger, PeerConnection }) {
|
|
23
|
+
constructor({ onSend, onRequest, onFileRpc, onFileChannel, logger, PeerConnection, impl }) {
|
|
19
24
|
if (!PeerConnection) {
|
|
20
25
|
throw new Error('PeerConnection constructor is required');
|
|
21
26
|
}
|
|
@@ -25,6 +30,8 @@ export class WebRtcPeer {
|
|
|
25
30
|
this.__onFileChannel = onFileChannel;
|
|
26
31
|
this.logger = logger ?? console;
|
|
27
32
|
this.__PeerConnection = PeerConnection;
|
|
33
|
+
this.__impl = impl ?? null;
|
|
34
|
+
this.__rtcTag = impl ? `[coclaw/rtc:${impl}]` : '[coclaw/rtc]';
|
|
28
35
|
/** @type {Map<string, { pc: object, rpcChannel: object|null, remoteMaxMessageSize: number, nextMsgId: number }>} */
|
|
29
36
|
this.__sessions = new Map();
|
|
30
37
|
}
|
|
@@ -52,9 +59,12 @@ export class WebRtcPeer {
|
|
|
52
59
|
// 先 detach 事件,防止 pc.close() 异步触发 onconnectionstatechange 删除新 session
|
|
53
60
|
session.pc.onconnectionstatechange = null;
|
|
54
61
|
session.pc.onicecandidate = null;
|
|
62
|
+
if ('onselectedcandidatepairchange' in session.pc) {
|
|
63
|
+
session.pc.onselectedcandidatepairchange = null;
|
|
64
|
+
}
|
|
55
65
|
await session.pc.close();
|
|
56
|
-
|
|
57
|
-
this.logger.info?.(
|
|
66
|
+
this.__remoteLog(`rtc.closed conn=${connId}`);
|
|
67
|
+
this.logger.info?.(`${this.__rtcTag} [${connId}] closed`);
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
/** 关闭所有 PeerConnection */
|
|
@@ -86,8 +96,19 @@ export class WebRtcPeer {
|
|
|
86
96
|
if (isIceRestart) {
|
|
87
97
|
const existing = this.__sessions.get(connId);
|
|
88
98
|
if (existing) {
|
|
89
|
-
|
|
90
|
-
this.
|
|
99
|
+
// 仅已验证支持 ICE restart 的 impl 放行,其余立即 reject 让 UI 走 rebuild
|
|
100
|
+
if (this.__impl !== 'pion') {
|
|
101
|
+
this.__remoteLog(`rtc.ice-restart-unsupported conn=${connId} impl=${this.__impl}`);
|
|
102
|
+
this.logger.info?.(`${this.__rtcTag} ICE restart rejected: impl=${this.__impl} not verified`);
|
|
103
|
+
this.__onSend({
|
|
104
|
+
type: 'rtc:restart-rejected',
|
|
105
|
+
toConnId: connId,
|
|
106
|
+
payload: { reason: 'impl_unsupported' },
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
this.__remoteLog(`rtc.ice-restart conn=${connId}`);
|
|
111
|
+
this.logger.info?.(`${this.__rtcTag} ICE restart offer from ${connId}, renegotiating`);
|
|
91
112
|
try {
|
|
92
113
|
await existing.pc.setRemoteDescription({ type: 'offer', sdp: msg.payload.sdp });
|
|
93
114
|
const answer = await existing.pc.createAnswer();
|
|
@@ -97,20 +118,37 @@ export class WebRtcPeer {
|
|
|
97
118
|
toConnId: connId,
|
|
98
119
|
payload: { sdp: answer.sdp },
|
|
99
120
|
});
|
|
100
|
-
this.logger.info?.(
|
|
121
|
+
this.logger.info?.(`${this.__rtcTag} ICE restart answer sent to ${connId}`);
|
|
101
122
|
return;
|
|
102
123
|
} catch (err) {
|
|
103
|
-
// ICE restart 协商失败 →
|
|
104
|
-
|
|
105
|
-
this.logger.warn?.(
|
|
106
|
-
|
|
124
|
+
// ICE restart 协商失败 → reject,不 fall through
|
|
125
|
+
this.__remoteLog(`rtc.ice-restart-failed conn=${connId}`);
|
|
126
|
+
this.logger.warn?.(`${this.__rtcTag} ICE restart failed for ${connId}: ${err?.message}`);
|
|
127
|
+
this.__onSend({
|
|
128
|
+
type: 'rtc:restart-rejected',
|
|
129
|
+
toConnId: connId,
|
|
130
|
+
payload: { reason: 'restart_failed' },
|
|
131
|
+
});
|
|
132
|
+
await this.closeByConnId(connId).catch((closeErr) => {
|
|
133
|
+
/* c8 ignore next -- closeByConnId 内部已 try/catch,此路径极难触发 */
|
|
134
|
+
this.logger.warn?.(`${this.__rtcTag} closeByConnId failed after restart rejection for ${connId}: ${closeErr?.message}`);
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
107
137
|
}
|
|
108
138
|
}
|
|
109
|
-
//
|
|
139
|
+
// 无 session → reject(plugin 可能已重启)
|
|
140
|
+
this.__remoteLog(`rtc.ice-restart-no-session conn=${connId}`);
|
|
141
|
+
this.logger.warn?.(`${this.__rtcTag} ICE restart from ${connId} but no session, rejecting`);
|
|
142
|
+
this.__onSend({
|
|
143
|
+
type: 'rtc:restart-rejected',
|
|
144
|
+
toConnId: connId,
|
|
145
|
+
payload: { reason: 'no_session' },
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
110
148
|
}
|
|
111
149
|
|
|
112
|
-
|
|
113
|
-
this.logger.info?.(
|
|
150
|
+
this.__remoteLog(`rtc.offer conn=${connId}`);
|
|
151
|
+
this.logger.info?.(`${this.__rtcTag} offer received from ${connId}, creating answer`);
|
|
114
152
|
|
|
115
153
|
// 同一 connId 重复 offer → 先关闭旧连接
|
|
116
154
|
if (this.__sessions.has(connId)) {
|
|
@@ -135,15 +173,19 @@ export class WebRtcPeer {
|
|
|
135
173
|
// 记录 ICE 服务器配置(脱敏,不含 credential)
|
|
136
174
|
const stunUrl = iceServers.find((s) => s.urls?.startsWith('stun:'))?.urls ?? 'none';
|
|
137
175
|
const turnUrl = iceServers.find((s) => s.urls?.startsWith('turn:'))?.urls ?? 'none';
|
|
138
|
-
|
|
176
|
+
this.__remoteLog(`rtc.ice-config conn=${connId} stun=${stunUrl} turn=${turnUrl}`);
|
|
139
177
|
|
|
140
178
|
const pc = new this.__PeerConnection({ iceServers });
|
|
141
179
|
|
|
142
|
-
//
|
|
180
|
+
// 分片阈值 = min(远端能接收, 本地能发送)
|
|
181
|
+
// 远端:从 offer SDP 的 a=max-message-size 解析(缺失则 RFC 8841 默认 65536)
|
|
182
|
+
// 本地:pc.maxMessageSize(pion 为 65536,ndc/werift 无此属性则不限制)
|
|
143
183
|
const mmsMatch = msg.payload.sdp?.match(/a=max-message-size:(\d+)/);
|
|
144
|
-
const
|
|
184
|
+
const remoteMMS = mmsMatch ? parseInt(mmsMatch[1], 10) : 65536;
|
|
185
|
+
const localMMS = pc.maxMessageSize ?? remoteMMS;
|
|
186
|
+
const remoteMaxMessageSize = Math.min(remoteMMS, localMMS);
|
|
145
187
|
|
|
146
|
-
const session = { pc, rpcChannel: null, remoteMaxMessageSize, nextMsgId: 1 };
|
|
188
|
+
const session = { pc, rpcChannel: null, fileChannels: new Set(), remoteMaxMessageSize, nextMsgId: 1 };
|
|
147
189
|
this.__sessions.set(connId, session);
|
|
148
190
|
|
|
149
191
|
// ICE candidate → 发给 UI,并统计各类型 candidate 数量
|
|
@@ -151,7 +193,7 @@ export class WebRtcPeer {
|
|
|
151
193
|
pc.onicecandidate = ({ candidate }) => {
|
|
152
194
|
if (!candidate) {
|
|
153
195
|
// gathering 完成,输出汇总
|
|
154
|
-
|
|
196
|
+
this.__remoteLog(`rtc.ice-gathered conn=${connId} host=${candidateCounts.host} srflx=${candidateCounts.srflx} relay=${candidateCounts.relay}`);
|
|
155
197
|
return;
|
|
156
198
|
}
|
|
157
199
|
// 从 candidate 字符串中提取类型(typ host / typ srflx / typ relay)
|
|
@@ -173,34 +215,68 @@ export class WebRtcPeer {
|
|
|
173
215
|
// 连接状态变更(校验 pc 归属,防止旧 PC 异步回调删除新 session)
|
|
174
216
|
pc.onconnectionstatechange = () => {
|
|
175
217
|
const state = pc.connectionState;
|
|
176
|
-
|
|
177
|
-
this.logger.info?.(
|
|
218
|
+
this.__remoteLog(`rtc.state conn=${connId} ${state}`);
|
|
219
|
+
this.logger.info?.(`${this.__rtcTag} [${connId}] connectionState: ${state}`);
|
|
220
|
+
|
|
221
|
+
// 校验 pc 归属:旧 PC 的异步回调可能在新 session 已建立后触发
|
|
222
|
+
const cur = this.__sessions.get(connId);
|
|
223
|
+
if (!cur || cur.pc !== pc) return;
|
|
224
|
+
|
|
178
225
|
if (state === 'connected') {
|
|
226
|
+
// 重置 dump 去重水位(disconnected → connected → disconnected 仍能再 dump)
|
|
227
|
+
cur.__lastDumpState = null;
|
|
228
|
+
// werift: iceTransports[0].connection.nominated
|
|
179
229
|
const nominated = pc.iceTransports?.[0]?.connection?.nominated;
|
|
180
230
|
if (nominated) {
|
|
181
231
|
const localC = nominated.localCandidate;
|
|
182
232
|
const remoteC = nominated.remoteCandidate;
|
|
183
233
|
const localInfo = `${localC?.type ?? '?'} ${localC?.host ?? '?'}:${localC?.port ?? '?'}`;
|
|
184
234
|
const remoteInfo = `${remoteC?.type ?? '?'} ${remoteC?.host ?? '?'}:${remoteC?.port ?? '?'}`;
|
|
185
|
-
|
|
186
|
-
this.logger.info?.(
|
|
235
|
+
this.__remoteLog(`rtc.ice-nominated conn=${connId} local=${localInfo} remote=${remoteInfo}`);
|
|
236
|
+
this.logger.info?.(`${this.__rtcTag} [${connId}] ICE nominated: local=${localInfo} remote=${remoteInfo}`);
|
|
237
|
+
}
|
|
238
|
+
// pion: pair 通过独立的 selectedcandidatepairchange 事件上报
|
|
239
|
+
} else if (state === 'disconnected' || state === 'failed' || state === 'closed') {
|
|
240
|
+
// 诊断 dump:失败/断连/关闭时输出当前 PC 上 DC 状态,定位"PC 假活/DC 死"现象
|
|
241
|
+
// - closed 多由本地主动关闭触发,dump 收敛诊断噪声但仍清理 session
|
|
242
|
+
// - disconnected 可能反复触发,去重避免噪声
|
|
243
|
+
if (state !== 'closed' && cur.__lastDumpState !== state) {
|
|
244
|
+
cur.__lastDumpState = state;
|
|
245
|
+
this.__dumpSessionState(connId, cur, state);
|
|
187
246
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (
|
|
247
|
+
// 仅 closed 删除 session;failed 保留以支持 ICE restart 恢复
|
|
248
|
+
// (如 app 后台冻结 → pion ICE failed → 前台恢复后 restart)
|
|
249
|
+
if (state === 'closed') {
|
|
191
250
|
this.__sessions.delete(connId);
|
|
192
251
|
}
|
|
193
252
|
}
|
|
194
253
|
};
|
|
195
254
|
|
|
255
|
+
// pion: 选中的 candidate pair 通过独立事件上报
|
|
256
|
+
if ('onselectedcandidatepairchange' in pc) {
|
|
257
|
+
pc.onselectedcandidatepairchange = () => {
|
|
258
|
+
const pair = pc.selectedCandidatePair;
|
|
259
|
+
if (pair) {
|
|
260
|
+
this.__logNominatedPair(connId, pair);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
196
265
|
// 监听 UI 创建的 DataChannel
|
|
197
266
|
pc.ondatachannel = ({ channel }) => {
|
|
198
|
-
|
|
199
|
-
this.logger.info?.(
|
|
267
|
+
this.__remoteLog(`dc.received conn=${connId} label=${channel.label}`);
|
|
268
|
+
this.logger.info?.(`${this.__rtcTag} [${connId}] DataChannel "${channel.label}" received`);
|
|
200
269
|
if (channel.label === 'rpc') {
|
|
201
270
|
session.rpcChannel = channel;
|
|
202
271
|
this.__setupDataChannel(connId, channel);
|
|
203
272
|
} else if (channel.label.startsWith('file:')) {
|
|
273
|
+
// 跟踪 file DC 用于诊断 dump:保留全量历史以便排查"传输到一半挂掉"场景,
|
|
274
|
+
// 但用 FIFO 上限避免长会话内无界增长
|
|
275
|
+
if (session.fileChannels.size >= FILE_CHANNEL_HISTORY_LIMIT) {
|
|
276
|
+
const oldest = session.fileChannels.values().next().value;
|
|
277
|
+
session.fileChannels.delete(oldest);
|
|
278
|
+
}
|
|
279
|
+
session.fileChannels.add(channel);
|
|
204
280
|
this.__onFileChannel?.(channel, connId);
|
|
205
281
|
}
|
|
206
282
|
};
|
|
@@ -216,8 +292,8 @@ export class WebRtcPeer {
|
|
|
216
292
|
toConnId: connId,
|
|
217
293
|
payload: { sdp: answer.sdp },
|
|
218
294
|
});
|
|
219
|
-
|
|
220
|
-
this.logger.info?.(
|
|
295
|
+
this.__remoteLog(`rtc.answer conn=${connId}`);
|
|
296
|
+
this.logger.info?.(`${this.__rtcTag} answer sent to ${connId}`);
|
|
221
297
|
} catch (err) {
|
|
222
298
|
// SDP 协商失败 → 清理已入 Map 的 session,避免泄漏
|
|
223
299
|
const cur = this.__sessions.get(connId);
|
|
@@ -279,33 +355,58 @@ export class WebRtcPeer {
|
|
|
279
355
|
}, { logger: this.logger });
|
|
280
356
|
|
|
281
357
|
dc.onopen = () => {
|
|
282
|
-
|
|
283
|
-
this.logger.info?.(
|
|
358
|
+
this.__remoteLog(`dc.open conn=${connId} label=${dc.label}`);
|
|
359
|
+
this.logger.info?.(`${this.__rtcTag} [${connId}] DataChannel "${dc.label}" opened`);
|
|
284
360
|
};
|
|
285
361
|
dc.onclose = () => {
|
|
286
|
-
|
|
287
|
-
this.logger.info?.(
|
|
362
|
+
this.__remoteLog(`dc.closed conn=${connId} label=${dc.label}`);
|
|
363
|
+
this.logger.info?.(`${this.__rtcTag} [${connId}] DataChannel "${dc.label}" closed`);
|
|
288
364
|
reassembler.reset();
|
|
289
365
|
const session = this.__sessions.get(connId);
|
|
290
366
|
if (session && dc.label === 'rpc') session.rpcChannel = null;
|
|
291
367
|
};
|
|
292
368
|
dc.onerror = (err) => {
|
|
293
|
-
|
|
369
|
+
this.__remoteLog(`dc.error conn=${connId} label=${dc.label}`);
|
|
294
370
|
/* c8 ignore next -- ?./?? fallback */
|
|
295
|
-
this.logger.warn?.(
|
|
371
|
+
this.logger.warn?.(`${this.__rtcTag} [${connId}] DataChannel "${dc.label}" error: ${String(err?.message ?? err)}`);
|
|
296
372
|
};
|
|
297
373
|
dc.onmessage = (event) => {
|
|
298
374
|
try {
|
|
299
375
|
reassembler.feed(event.data);
|
|
300
376
|
} catch (err) {
|
|
301
|
-
this.logger.warn?.(
|
|
377
|
+
this.logger.warn?.(`${this.__rtcTag} [${connId}] DC message error: ${err.message}`);
|
|
302
378
|
}
|
|
303
379
|
};
|
|
304
380
|
}
|
|
305
381
|
|
|
382
|
+
/**
|
|
383
|
+
* 失败/断连时输出 session 诊断快照:rpc/file DC readyState、session 总数。
|
|
384
|
+
* 用于定位"PC 假活但 DC 已死"或"PC 已断但 DC 仍在传"的异常现象。
|
|
385
|
+
*/
|
|
386
|
+
__dumpSessionState(connId, session, state) {
|
|
387
|
+
const rpcState = session.rpcChannel?.readyState ?? 'none';
|
|
388
|
+
const fileSummary = session.fileChannels.size === 0
|
|
389
|
+
? 'none'
|
|
390
|
+
/* c8 ignore next -- ?? fallback for missing readyState */
|
|
391
|
+
: [...session.fileChannels].map((dc) => `${dc.label}=${dc.readyState ?? '?'}`).join(',');
|
|
392
|
+
this.__remoteLog(`rtc.dump conn=${connId} state=${state} sessions=${this.__sessions.size} rpc=${rpcState} fileCount=${session.fileChannels.size} files=[${fileSummary}]`);
|
|
393
|
+
this.logger.info?.(`${this.__rtcTag} [${connId}] dump state=${state} rpc=${rpcState} fileCount=${session.fileChannels.size} files=${fileSummary}`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
__logNominatedPair(connId, pair) {
|
|
397
|
+
const localInfo = `${pair.local?.type ?? '?'} ${pair.local?.address ?? pair.local?.host ?? '?'}:${pair.local?.port ?? '?'}`;
|
|
398
|
+
const remoteInfo = `${pair.remote?.type ?? '?'} ${pair.remote?.address ?? pair.remote?.host ?? '?'}:${pair.remote?.port ?? '?'}`;
|
|
399
|
+
this.__remoteLog(`rtc.ice-nominated conn=${connId} local=${localInfo} remote=${remoteInfo}`);
|
|
400
|
+
this.logger.info?.(`${this.__rtcTag} [${connId}] ICE nominated: local=${localInfo} remote=${remoteInfo}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
__remoteLog(msg) {
|
|
404
|
+
remoteLog(this.__impl ? `${msg} rtc=${this.__impl}` : msg);
|
|
405
|
+
}
|
|
406
|
+
|
|
306
407
|
__logDebug(message) {
|
|
307
408
|
if (typeof this.logger?.debug === 'function') {
|
|
308
|
-
this.logger.debug(
|
|
409
|
+
this.logger.debug(`${this.__rtcTag} ${message}`);
|
|
309
410
|
}
|
|
310
411
|
}
|
|
311
412
|
}
|