@coclaw/openclaw-coclaw 0.12.3 → 0.13.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.12.3",
3
+ "version": "0.13.0",
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]: [...] }
@@ -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) => {
@@ -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 = 60_000; // 启动后 60s 延迟清理
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
- dc.onclose = () => { dcClosed = true; stream.destroy(); };
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 = () => stream.resume();
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
- dc.close();
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
- /* c8 ignore next */
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
- /* c8 ignore next */
658
- try { dc.close(); } catch { /* ignore */ }
659
- remoteLog(`file.up.fail ${logTag}id=${transferId} reason=rename-failed elapsed=${elapsed}ms`);
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
- /* c8 ignore next */
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
 
@@ -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';
@@ -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
- async start({ logger, pluginConfig } = {}) {
916
- this.logger = logger ?? console;
917
- this.pluginConfig = pluginConfig ?? {};
918
- this.started = true;
919
- // 先完成 WebRTC 实现加载,再建立连接,避免 UI 发来 offer 时 RTC 包未就绪
920
- const preloadFn = this.__preloadNdc
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
- // 版本预热与 preload 并行,供 gateway connect 请求同步使用
923
- const [preloadResult] = await Promise.all([
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
- getPluginVersion()
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
- // 不在 stop() 中调用 ndc.cleanup():
971
- // cleanup() 是同步 native 调用,需 join native threads,耗时 10s+,
972
- // 会阻塞事件循环导致 RPC handler 超时。
973
- // gateway 是长驻进程,native threads 保持活跃即可;
974
- // 下次 start() 重新 import(ESM 缓存命中)可直接复用。
975
- // 进程退出时 OS 会回收所有资源。
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
- const cleanupFn = forceCleanup ? singleton.__ndcCleanup : null;
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 { /* c8 ignore next -- cleanup 失败不影响 stop 结果 */ }
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
  }
@@ -242,6 +242,7 @@ export function createSessionManager(options = {}) {
242
242
  indexed: true,
243
243
  archiveType: 'live',
244
244
  fileName: null,
245
+ /* c8 ignore next -- ?? fallback */
245
246
  updatedAt: entry.updatedAt ?? 0,
246
247
  size: 0,
247
248
  });
package/src/settings.js CHANGED
@@ -75,5 +75,6 @@ export async function writeName(name) {
75
75
  */
76
76
  export function getHostName() {
77
77
  const raw = os.hostname().trim();
78
+ /* c8 ignore next -- || fallback:hostname 恰好等于 ".local" 的情况极罕见 */
78
79
  return raw.replace(/\.local$/i, '') || 'openclaw';
79
80
  }
@@ -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
- remoteLog(`rtc.closed conn=${connId}`);
57
- this.logger.info?.(`[coclaw/rtc] [${connId}] closed`);
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,8 @@ export class WebRtcPeer {
86
96
  if (isIceRestart) {
87
97
  const existing = this.__sessions.get(connId);
88
98
  if (existing) {
89
- remoteLog(`rtc.ice-restart conn=${connId}`);
90
- this.logger.info?.(`[coclaw/rtc] ICE restart offer from ${connId}, renegotiating`);
99
+ this.__remoteLog(`rtc.ice-restart conn=${connId}`);
100
+ this.logger.info?.(`${this.__rtcTag} ICE restart offer from ${connId}, renegotiating`);
91
101
  try {
92
102
  await existing.pc.setRemoteDescription({ type: 'offer', sdp: msg.payload.sdp });
93
103
  const answer = await existing.pc.createAnswer();
@@ -97,20 +107,37 @@ export class WebRtcPeer {
97
107
  toConnId: connId,
98
108
  payload: { sdp: answer.sdp },
99
109
  });
100
- this.logger.info?.(`[coclaw/rtc] ICE restart answer sent to ${connId}`);
110
+ this.logger.info?.(`${this.__rtcTag} ICE restart answer sent to ${connId}`);
101
111
  return;
102
112
  } catch (err) {
103
- // ICE restart 协商失败 → 回退到 full rebuild
104
- remoteLog(`rtc.ice-restart-failed conn=${connId}`);
105
- this.logger.warn?.(`[coclaw/rtc] ICE restart failed for ${connId}, falling back to rebuild: ${err?.message}`);
106
- await this.closeByConnId(connId);
113
+ // ICE restart 协商失败 → reject,不 fall through
114
+ this.__remoteLog(`rtc.ice-restart-failed conn=${connId}`);
115
+ this.logger.warn?.(`${this.__rtcTag} ICE restart failed for ${connId}: ${err?.message}`);
116
+ this.__onSend({
117
+ type: 'rtc:restart-rejected',
118
+ toConnId: connId,
119
+ payload: { reason: 'restart_failed' },
120
+ });
121
+ await this.closeByConnId(connId).catch((closeErr) => {
122
+ /* c8 ignore next -- closeByConnId 内部已 try/catch,此路径极难触发 */
123
+ this.logger.warn?.(`${this.__rtcTag} closeByConnId failed after restart rejection for ${connId}: ${closeErr?.message}`);
124
+ });
125
+ return;
107
126
  }
108
127
  }
109
- // 无现有 session 或 ICE restart 失败 full rebuild 继续
128
+ // session → reject(plugin 可能已重启)
129
+ this.__remoteLog(`rtc.ice-restart-no-session conn=${connId}`);
130
+ this.logger.warn?.(`${this.__rtcTag} ICE restart from ${connId} but no session, rejecting`);
131
+ this.__onSend({
132
+ type: 'rtc:restart-rejected',
133
+ toConnId: connId,
134
+ payload: { reason: 'no_session' },
135
+ });
136
+ return;
110
137
  }
111
138
 
112
- remoteLog(`rtc.offer conn=${connId}`);
113
- this.logger.info?.(`[coclaw/rtc] offer received from ${connId}, creating answer`);
139
+ this.__remoteLog(`rtc.offer conn=${connId}`);
140
+ this.logger.info?.(`${this.__rtcTag} offer received from ${connId}, creating answer`);
114
141
 
115
142
  // 同一 connId 重复 offer → 先关闭旧连接
116
143
  if (this.__sessions.has(connId)) {
@@ -135,15 +162,19 @@ export class WebRtcPeer {
135
162
  // 记录 ICE 服务器配置(脱敏,不含 credential)
136
163
  const stunUrl = iceServers.find((s) => s.urls?.startsWith('stun:'))?.urls ?? 'none';
137
164
  const turnUrl = iceServers.find((s) => s.urls?.startsWith('turn:'))?.urls ?? 'none';
138
- remoteLog(`rtc.ice-config conn=${connId} stun=${stunUrl} turn=${turnUrl}`);
165
+ this.__remoteLog(`rtc.ice-config conn=${connId} stun=${stunUrl} turn=${turnUrl}`);
139
166
 
140
167
  const pc = new this.__PeerConnection({ iceServers });
141
168
 
142
- // SDP 解析对端 maxMessageSize(用于分片决策)
169
+ // 分片阈值 = min(远端能接收, 本地能发送)
170
+ // 远端:从 offer SDP 的 a=max-message-size 解析(缺失则 RFC 8841 默认 65536)
171
+ // 本地:pc.maxMessageSize(pion 为 65536,ndc/werift 无此属性则不限制)
143
172
  const mmsMatch = msg.payload.sdp?.match(/a=max-message-size:(\d+)/);
144
- const remoteMaxMessageSize = mmsMatch ? parseInt(mmsMatch[1], 10) : 65536;
173
+ const remoteMMS = mmsMatch ? parseInt(mmsMatch[1], 10) : 65536;
174
+ const localMMS = pc.maxMessageSize ?? remoteMMS;
175
+ const remoteMaxMessageSize = Math.min(remoteMMS, localMMS);
145
176
 
146
- const session = { pc, rpcChannel: null, remoteMaxMessageSize, nextMsgId: 1 };
177
+ const session = { pc, rpcChannel: null, fileChannels: new Set(), remoteMaxMessageSize, nextMsgId: 1 };
147
178
  this.__sessions.set(connId, session);
148
179
 
149
180
  // ICE candidate → 发给 UI,并统计各类型 candidate 数量
@@ -151,7 +182,7 @@ export class WebRtcPeer {
151
182
  pc.onicecandidate = ({ candidate }) => {
152
183
  if (!candidate) {
153
184
  // gathering 完成,输出汇总
154
- remoteLog(`rtc.ice-gathered conn=${connId} host=${candidateCounts.host} srflx=${candidateCounts.srflx} relay=${candidateCounts.relay}`);
185
+ this.__remoteLog(`rtc.ice-gathered conn=${connId} host=${candidateCounts.host} srflx=${candidateCounts.srflx} relay=${candidateCounts.relay}`);
155
186
  return;
156
187
  }
157
188
  // 从 candidate 字符串中提取类型(typ host / typ srflx / typ relay)
@@ -173,34 +204,68 @@ export class WebRtcPeer {
173
204
  // 连接状态变更(校验 pc 归属,防止旧 PC 异步回调删除新 session)
174
205
  pc.onconnectionstatechange = () => {
175
206
  const state = pc.connectionState;
176
- remoteLog(`rtc.state conn=${connId} ${state}`);
177
- this.logger.info?.(`[coclaw/rtc] [${connId}] connectionState: ${state}`);
207
+ this.__remoteLog(`rtc.state conn=${connId} ${state}`);
208
+ this.logger.info?.(`${this.__rtcTag} [${connId}] connectionState: ${state}`);
209
+
210
+ // 校验 pc 归属:旧 PC 的异步回调可能在新 session 已建立后触发
211
+ const cur = this.__sessions.get(connId);
212
+ if (!cur || cur.pc !== pc) return;
213
+
178
214
  if (state === 'connected') {
215
+ // 重置 dump 去重水位(disconnected → connected → disconnected 仍能再 dump)
216
+ cur.__lastDumpState = null;
217
+ // werift: iceTransports[0].connection.nominated
179
218
  const nominated = pc.iceTransports?.[0]?.connection?.nominated;
180
219
  if (nominated) {
181
220
  const localC = nominated.localCandidate;
182
221
  const remoteC = nominated.remoteCandidate;
183
222
  const localInfo = `${localC?.type ?? '?'} ${localC?.host ?? '?'}:${localC?.port ?? '?'}`;
184
223
  const remoteInfo = `${remoteC?.type ?? '?'} ${remoteC?.host ?? '?'}:${remoteC?.port ?? '?'}`;
185
- remoteLog(`rtc.ice-nominated conn=${connId} local=${localInfo} remote=${remoteInfo}`);
186
- this.logger.info?.(`[coclaw/rtc] [${connId}] ICE nominated: local=${localInfo} remote=${remoteInfo}`);
224
+ this.__remoteLog(`rtc.ice-nominated conn=${connId} local=${localInfo} remote=${remoteInfo}`);
225
+ this.logger.info?.(`${this.__rtcTag} [${connId}] ICE nominated: local=${localInfo} remote=${remoteInfo}`);
187
226
  }
188
- } else if (state === 'failed' || state === 'closed') {
189
- const cur = this.__sessions.get(connId);
190
- if (cur && cur.pc === pc) {
227
+ // pion: pair 通过独立的 selectedcandidatepairchange 事件上报
228
+ } else if (state === 'disconnected' || state === 'failed' || state === 'closed') {
229
+ // 诊断 dump:失败/断连/关闭时输出当前 PC DC 状态,定位"PC 假活/DC 死"现象
230
+ // - closed 多由本地主动关闭触发,dump 收敛诊断噪声但仍清理 session
231
+ // - disconnected 可能反复触发,去重避免噪声
232
+ if (state !== 'closed' && cur.__lastDumpState !== state) {
233
+ cur.__lastDumpState = state;
234
+ this.__dumpSessionState(connId, cur, state);
235
+ }
236
+ // 仅 closed 删除 session;failed 保留以支持 ICE restart 恢复
237
+ // (如 app 后台冻结 → pion ICE failed → 前台恢复后 restart)
238
+ if (state === 'closed') {
191
239
  this.__sessions.delete(connId);
192
240
  }
193
241
  }
194
242
  };
195
243
 
244
+ // pion: 选中的 candidate pair 通过独立事件上报
245
+ if ('onselectedcandidatepairchange' in pc) {
246
+ pc.onselectedcandidatepairchange = () => {
247
+ const pair = pc.selectedCandidatePair;
248
+ if (pair) {
249
+ this.__logNominatedPair(connId, pair);
250
+ }
251
+ };
252
+ }
253
+
196
254
  // 监听 UI 创建的 DataChannel
197
255
  pc.ondatachannel = ({ channel }) => {
198
- remoteLog(`dc.received conn=${connId} label=${channel.label}`);
199
- this.logger.info?.(`[coclaw/rtc] [${connId}] DataChannel "${channel.label}" received`);
256
+ this.__remoteLog(`dc.received conn=${connId} label=${channel.label}`);
257
+ this.logger.info?.(`${this.__rtcTag} [${connId}] DataChannel "${channel.label}" received`);
200
258
  if (channel.label === 'rpc') {
201
259
  session.rpcChannel = channel;
202
260
  this.__setupDataChannel(connId, channel);
203
261
  } else if (channel.label.startsWith('file:')) {
262
+ // 跟踪 file DC 用于诊断 dump:保留全量历史以便排查"传输到一半挂掉"场景,
263
+ // 但用 FIFO 上限避免长会话内无界增长
264
+ if (session.fileChannels.size >= FILE_CHANNEL_HISTORY_LIMIT) {
265
+ const oldest = session.fileChannels.values().next().value;
266
+ session.fileChannels.delete(oldest);
267
+ }
268
+ session.fileChannels.add(channel);
204
269
  this.__onFileChannel?.(channel, connId);
205
270
  }
206
271
  };
@@ -216,8 +281,8 @@ export class WebRtcPeer {
216
281
  toConnId: connId,
217
282
  payload: { sdp: answer.sdp },
218
283
  });
219
- remoteLog(`rtc.answer conn=${connId}`);
220
- this.logger.info?.(`[coclaw/rtc] answer sent to ${connId}`);
284
+ this.__remoteLog(`rtc.answer conn=${connId}`);
285
+ this.logger.info?.(`${this.__rtcTag} answer sent to ${connId}`);
221
286
  } catch (err) {
222
287
  // SDP 协商失败 → 清理已入 Map 的 session,避免泄漏
223
288
  const cur = this.__sessions.get(connId);
@@ -279,33 +344,58 @@ export class WebRtcPeer {
279
344
  }, { logger: this.logger });
280
345
 
281
346
  dc.onopen = () => {
282
- remoteLog(`dc.open conn=${connId} label=${dc.label}`);
283
- this.logger.info?.(`[coclaw/rtc] [${connId}] DataChannel "${dc.label}" opened`);
347
+ this.__remoteLog(`dc.open conn=${connId} label=${dc.label}`);
348
+ this.logger.info?.(`${this.__rtcTag} [${connId}] DataChannel "${dc.label}" opened`);
284
349
  };
285
350
  dc.onclose = () => {
286
- remoteLog(`dc.closed conn=${connId} label=${dc.label}`);
287
- this.logger.info?.(`[coclaw/rtc] [${connId}] DataChannel "${dc.label}" closed`);
351
+ this.__remoteLog(`dc.closed conn=${connId} label=${dc.label}`);
352
+ this.logger.info?.(`${this.__rtcTag} [${connId}] DataChannel "${dc.label}" closed`);
288
353
  reassembler.reset();
289
354
  const session = this.__sessions.get(connId);
290
355
  if (session && dc.label === 'rpc') session.rpcChannel = null;
291
356
  };
292
357
  dc.onerror = (err) => {
293
- remoteLog(`dc.error conn=${connId} label=${dc.label}`);
358
+ this.__remoteLog(`dc.error conn=${connId} label=${dc.label}`);
294
359
  /* c8 ignore next -- ?./?? fallback */
295
- this.logger.warn?.(`[coclaw/rtc] [${connId}] DataChannel "${dc.label}" error: ${String(err?.message ?? err)}`);
360
+ this.logger.warn?.(`${this.__rtcTag} [${connId}] DataChannel "${dc.label}" error: ${String(err?.message ?? err)}`);
296
361
  };
297
362
  dc.onmessage = (event) => {
298
363
  try {
299
364
  reassembler.feed(event.data);
300
365
  } catch (err) {
301
- this.logger.warn?.(`[coclaw/rtc] [${connId}] DC message error: ${err.message}`);
366
+ this.logger.warn?.(`${this.__rtcTag} [${connId}] DC message error: ${err.message}`);
302
367
  }
303
368
  };
304
369
  }
305
370
 
371
+ /**
372
+ * 失败/断连时输出 session 诊断快照:rpc/file DC readyState、session 总数。
373
+ * 用于定位"PC 假活但 DC 已死"或"PC 已断但 DC 仍在传"的异常现象。
374
+ */
375
+ __dumpSessionState(connId, session, state) {
376
+ const rpcState = session.rpcChannel?.readyState ?? 'none';
377
+ const fileSummary = session.fileChannels.size === 0
378
+ ? 'none'
379
+ /* c8 ignore next -- ?? fallback for missing readyState */
380
+ : [...session.fileChannels].map((dc) => `${dc.label}=${dc.readyState ?? '?'}`).join(',');
381
+ this.__remoteLog(`rtc.dump conn=${connId} state=${state} sessions=${this.__sessions.size} rpc=${rpcState} fileCount=${session.fileChannels.size} files=[${fileSummary}]`);
382
+ this.logger.info?.(`${this.__rtcTag} [${connId}] dump state=${state} rpc=${rpcState} fileCount=${session.fileChannels.size} files=${fileSummary}`);
383
+ }
384
+
385
+ __logNominatedPair(connId, pair) {
386
+ const localInfo = `${pair.local?.type ?? '?'} ${pair.local?.address ?? pair.local?.host ?? '?'}:${pair.local?.port ?? '?'}`;
387
+ const remoteInfo = `${pair.remote?.type ?? '?'} ${pair.remote?.address ?? pair.remote?.host ?? '?'}:${pair.remote?.port ?? '?'}`;
388
+ this.__remoteLog(`rtc.ice-nominated conn=${connId} local=${localInfo} remote=${remoteInfo}`);
389
+ this.logger.info?.(`${this.__rtcTag} [${connId}] ICE nominated: local=${localInfo} remote=${remoteInfo}`);
390
+ }
391
+
392
+ __remoteLog(msg) {
393
+ remoteLog(this.__impl ? `${msg} rtc=${this.__impl}` : msg);
394
+ }
395
+
306
396
  __logDebug(message) {
307
397
  if (typeof this.logger?.debug === 'function') {
308
- this.logger.debug(`[coclaw/rtc] ${message}`);
398
+ this.logger.debug(`${this.__rtcTag} ${message}`);
309
399
  }
310
400
  }
311
401
  }