@coclaw/openclaw-coclaw 0.12.2 → 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/README.md CHANGED
@@ -1,10 +1,17 @@
1
1
  # @coclaw/openclaw-coclaw
2
2
 
3
- CoClaw 的 OpenClaw 插件(npm: `@coclaw/openclaw-coclaw`,plugin id: `openclaw-coclaw`),包含:
3
+ CoClaw 的 OpenClaw 插件(npm: `@coclaw/openclaw-coclaw`,plugin id: `openclaw-coclaw`),运行在 OpenClaw gateway 进程中,是 CoClaw 与 OpenClaw 之间的核心连接层。
4
4
 
5
- - **transport bridge** — CoClaw server 与 OpenClaw gateway 之间的实时消息桥接
6
- - **session-manager** — 会话列表/读取能力(`nativeui.sessions.listAll` / `nativeui.sessions.get`)
5
+ 主要模块:
6
+
7
+ - **realtime bridge** — CoClaw server 与 OpenClaw gateway 之间的 WebSocket 实时消息桥接,支持 RPC 转发和事件广播
8
+ - **WebRTC peer** — 与 CoClaw UI 建立 WebRTC DataChannel 直连,提供 RPC 和文件传输两类通道
9
+ - **session manager** — 会话列表/读取能力(`nativeui.sessions.listAll` / `nativeui.sessions.get`)
10
+ - **chat history manager** — 跟踪和管理 chat reset 产生的孤儿 session(`coclaw.chatHistory.list`)
11
+ - **topic manager** — 独立话题的创建、列表、标题生成与删除(`coclaw.topics.*`)
12
+ - **file manager** — 工作区文件管理,支持通过 WebRTC DataChannel 流式传输和 gateway RPC 回退(`coclaw.files.*`)
7
13
  - **auto-upgrade** — 从 npm 安装的插件自动检查并升级到最新版本
14
+ - **device identity** — Ed25519 密钥对管理,用于 gateway WebSocket 连接的设备认证
8
15
 
9
16
  ## 安装与模式切换
10
17
 
@@ -60,6 +67,41 @@ WAIT=1 pnpm run release:check -- 0.1.7 # 轮询直到版本生效
60
67
  pnpm run release:versions # 显示所有已发布版本
61
68
  ```
62
69
 
70
+ ## Gateway RPC 方法
71
+
72
+ 插件注册的所有 gateway method(通过 `openclaw gateway call <method>` 或 WebRTC DC 调用):
73
+
74
+ | 方法 | 说明 |
75
+ |------|------|
76
+ | `coclaw.bind` | 绑定 Claw 到 CoClaw server |
77
+ | `coclaw.unbind` | 解绑并停止 bridge |
78
+ | `coclaw.enroll` | 生成认领码,等待用户完成绑定 |
79
+ | `coclaw.info` / `coclaw.info.get` | 获取插件版本、claw 版本、capabilities、名称、主机名 |
80
+ | `coclaw.info.patch` | 修改 claw 显示名称,广播 `coclaw.info.updated` 事件 |
81
+ | `coclaw.topics.create` | 创建话题 |
82
+ | `coclaw.topics.list` | 列出指定 agent 的话题 |
83
+ | `coclaw.topics.get` | 获取单个话题 |
84
+ | `coclaw.topics.getHistory` | 获取话题对话记录 |
85
+ | `coclaw.topics.update` | 更新话题标题 |
86
+ | `coclaw.topics.generateTitle` | 通过 agent RPC 自动生成话题标题 |
87
+ | `coclaw.topics.delete` | 删除话题及其 `.jsonl` 文件 |
88
+ | `coclaw.chatHistory.list` | 列出 chat 的历史(孤儿)session |
89
+ | `coclaw.sessions.getById` | 按 sessionId 获取消息记录 |
90
+ | `coclaw.upgradeHealth` | 返回当前插件版本(升级健康检查) |
91
+ | `coclaw.files.list` | 列出工作区文件(RPC 回退) |
92
+ | `coclaw.files.delete` | 删除工作区文件/目录 |
93
+ | `coclaw.files.mkdir` | 创建工作区目录 |
94
+ | `coclaw.files.create` | 创建空文件 |
95
+ | `nativeui.sessions.listAll` | 列出所有 session(分页 + 标题推导) |
96
+ | `nativeui.sessions.get` | 获取 session 原始 JSONL 行(分页) |
97
+
98
+ ## Gateway Services
99
+
100
+ | Service ID | 说明 |
101
+ |------------|------|
102
+ | `coclaw-realtime-bridge` | CoClaw server WebSocket 桥接 + WebRTC peer 管理 |
103
+ | `coclaw-auto-upgrade` | npm 安装模式下的自动升级调度器 |
104
+
63
105
  ## 绑定 / 解绑
64
106
 
65
107
  绑定码从 CoClaw Web 端生成,有效期有限。
@@ -183,6 +225,19 @@ openclaw logs --limit 300 --plain | rg -n "gateway connect failed|protocol misma
183
225
  openclaw logs --limit 300 --plain | rg -n "ui->server req|bot->server res|bot->server event|gateway req" -i
184
226
  ```
185
227
 
228
+ ## 设置
229
+
230
+ 插件设置存储在 `~/.openclaw/coclaw/settings.json`,独立于绑定信息,解绑后重新绑定不会丢失。
231
+
232
+ 当前支持的设置项:
233
+ - `name` — Claw 显示名称(可选,最长 63 字符),通过 `coclaw.info.patch` RPC 修改
234
+
235
+ `settings.js` 是读写设置的唯一入口。
236
+
237
+ ## 设备身份
238
+
239
+ 插件在首次运行时自动生成 Ed25519 密钥对,存储在 `~/.openclaw/coclaw/device-identity.json`(mode 0o600)。`deviceId` 为公钥的 SHA-256 摘要,用于 gateway WebSocket 连接的设备认证(v3 auth payload)。
240
+
186
241
  ## 测试门禁
187
242
 
188
243
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.12.2",
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,7 +60,9 @@
60
60
  },
61
61
  "dependencies": {
62
62
  "node-datachannel": "0.32.2",
63
- "werift": "^0.19.0"
63
+ "@coclaw/pion-node": "^0.1.1",
64
+ "werift": "^0.19.0",
65
+ "ws": "^8.19.0"
64
66
  },
65
67
  "devDependencies": {
66
68
  "c8": "^10.1.3",
@@ -33,11 +33,12 @@ 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'], {
39
40
  timeout: 30_000,
40
- shell: true,
41
+ shell: process.platform === 'win32',
41
42
  }, (err, stdout) => {
42
43
  if (err) {
43
44
  reject(new Error(`npm view failed: ${err.message}`));
@@ -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
 
@@ -52,6 +53,7 @@ export function spawnUpgradeWorker({ pluginDir, fromVersion, toVersion, pluginId
52
53
  detached: true,
53
54
  stdio: 'ignore',
54
55
  env,
56
+ windowsHide: true,
55
57
  });
56
58
 
57
59
  // spawn 失败时 Node.js 会异步 emit 'error';若无监听器则变为未捕获异常导致 gateway 崩溃
@@ -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,9 +24,10 @@ 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
- doExecFile(cmd, args, { timeout: CMD_TIMEOUT_MS, shell: true }, (err, stdout) => {
30
+ doExecFile(cmd, args, { timeout: CMD_TIMEOUT_MS, shell: process.platform === 'win32' }, (err, stdout) => {
30
31
  if (err) reject(err);
31
32
  else resolve(String(stdout).trim());
32
33
  });
@@ -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
  }
@@ -9,7 +9,7 @@
9
9
  * 注意:
10
10
  * - 本模块作为独立 node 进程运行,与 gateway 进程隔离
11
11
  * - state dir 通过 OPENCLAW_STATE_DIR 环境变量由 spawner 传入
12
- * - shell: true Windows 兼容性所需(openclaw 全局安装生成 .cmd 包装器)
12
+ * - shell 仅在 Windows 启用(openclaw 全局安装生成 .cmd 包装器,需 shell 解析)
13
13
  */
14
14
 
15
15
  import { execFile as nodeExecFile } from 'node:child_process';
@@ -30,11 +30,12 @@ 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], {
36
37
  timeout: 120_000,
37
- shell: true,
38
+ shell: process.platform === 'win32',
38
39
  }, (err) => {
39
40
  if (err) reject(new Error(`plugins update failed: ${err.message}`));
40
41
  else resolve();
@@ -61,9 +62,10 @@ 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
- doExecFile('openclaw', args, { timeout, shell: true }, (err) => {
68
+ doExecFile('openclaw', args, { timeout, shell: process.platform === 'win32' }, (err) => {
67
69
  if (err) reject(err);
68
70
  else resolve();
69
71
  });
@@ -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);
@@ -87,7 +88,7 @@ function handleRpcError(result, fallbackMsg) {
87
88
  * @param {object} ctx.logger - 日志实例
88
89
  * @param {object} [deps] - 可注入依赖(测试用)
89
90
  */
90
- export function registerCoclawCli({ program, logger }, deps = {}) {
91
+ export function registerCoclawCli({ program, logger: _logger }, deps = {}) {
91
92
  const coclaw = program
92
93
  .command('coclaw')
93
94
  .description('CoClaw bind/unbind commands');
@@ -144,6 +145,7 @@ export function registerCoclawCli({ program, 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';
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import nodePath from 'node:path';
4
+ import { WebSocket as WsWebSocket } from 'ws';
4
5
 
5
6
  import { clearConfig, getBindingsPath, readConfig } from './config.js';
6
7
  import { getHostName, readSettings } from './settings.js';
@@ -84,8 +85,9 @@ export class RealtimeBridge {
84
85
  this.__getBindingsPath = deps.getBindingsPath ?? getBindingsPath;
85
86
  this.__resolveGatewayAuthToken = deps.resolveGatewayAuthToken ?? defaultResolveGatewayAuthToken;
86
87
  this.__loadDeviceIdentity = deps.loadDeviceIdentity ?? loadOrCreateDeviceIdentity;
88
+ this.__preloadPion = deps.preloadPion ?? null;
87
89
  this.__preloadNdc = deps.preloadNdc ?? null;
88
- this.__WebSocket = deps.WebSocket ?? null;
90
+ this.__WebSocket = deps.WebSocket; // undefined=使用 ws 包, null=禁用(测试用), 其他=自定义实现
89
91
  this.__gatewayReadyTimeoutMs = deps.gatewayReadyTimeoutMs ?? 1500;
90
92
 
91
93
  this.serverWs = null;
@@ -112,7 +114,7 @@ export class RealtimeBridge {
112
114
  }
113
115
 
114
116
  __resolveWebSocket() {
115
- return this.__WebSocket ?? globalThis.WebSocket;
117
+ return this.__WebSocket === undefined ? WsWebSocket : this.__WebSocket;
116
118
  }
117
119
 
118
120
  __logDebug(message) {
@@ -226,7 +228,8 @@ export class RealtimeBridge {
226
228
  this.webrtcPeer = new WebRtcPeer({
227
229
  onSend: (msg) => this.__forwardToServer(msg),
228
230
  onRequest: (dcPayload) => {
229
- void this.__handleGatewayRequestFromServer(dcPayload);
231
+ this.__handleGatewayRequestFromDc(dcPayload)
232
+ .catch((err) => this.logger.warn?.(`[coclaw] dc request handler error: ${err?.message}`));
230
233
  },
231
234
  onFileRpc: (payload, sendFn) => {
232
235
  this.__fileHandler.handleRpcRequest(payload, sendFn)
@@ -236,6 +239,7 @@ export class RealtimeBridge {
236
239
  this.__fileHandler.handleFileChannel(dc, connId);
237
240
  },
238
241
  PeerConnection,
242
+ impl: this.__ndcPreloadResult?.impl,
239
243
  logger: this.logger,
240
244
  });
241
245
  }
@@ -625,8 +629,6 @@ export class RealtimeBridge {
625
629
  return;
626
630
  }
627
631
  if (payload.type === 'res' || payload.type === 'event') {
628
- // TODO: UI 已通过 DataChannel 接收业务消息,待旧版 UI 全部更新后移除此转发
629
- this.__forwardToServer(payload);
630
632
  this.webrtcPeer?.broadcast(payload);
631
633
  }
632
634
  });
@@ -698,11 +700,11 @@ export class RealtimeBridge {
698
700
  });
699
701
  }
700
702
 
701
- async __handleGatewayRequestFromServer(payload) {
703
+ async __handleGatewayRequestFromDc(payload) {
702
704
  const ready = await this.__waitGatewayReady();
703
705
  if (!ready || !this.gatewayWs || this.gatewayWs.readyState !== 1) {
704
706
  this.__logDebug(`gateway req drop (offline): id=${payload.id} method=${payload.method}`);
705
- const errorRes = {
707
+ this.webrtcPeer?.broadcast({
706
708
  type: 'res',
707
709
  id: payload.id,
708
710
  ok: false,
@@ -710,9 +712,7 @@ export class RealtimeBridge {
710
712
  code: 'GATEWAY_OFFLINE',
711
713
  message: 'Gateway is offline',
712
714
  },
713
- };
714
- this.__forwardToServer(errorRes);
715
- this.webrtcPeer?.broadcast(errorRes);
715
+ });
716
716
  return;
717
717
  }
718
718
  try {
@@ -725,7 +725,7 @@ export class RealtimeBridge {
725
725
  }));
726
726
  }
727
727
  catch {
728
- const errorRes = {
728
+ this.webrtcPeer?.broadcast({
729
729
  type: 'res',
730
730
  id: payload.id,
731
731
  ok: false,
@@ -733,9 +733,7 @@ export class RealtimeBridge {
733
733
  code: 'GATEWAY_SEND_FAILED',
734
734
  message: 'Failed to send request to gateway',
735
735
  },
736
- };
737
- this.__forwardToServer(errorRes);
738
- this.webrtcPeer?.broadcast(errorRes);
736
+ });
739
737
  }
740
738
  }
741
739
 
@@ -848,13 +846,6 @@ export class RealtimeBridge {
848
846
  }
849
847
  return;
850
848
  }
851
- if (payload?.type === 'req' || payload?.type === 'rpc.req') {
852
- void this.__handleGatewayRequestFromServer({
853
- id: payload.id,
854
- method: payload.method,
855
- params: payload.params ?? {},
856
- });
857
- }
858
849
  }
859
850
  catch (err) {
860
851
  this.logger.warn?.(`[coclaw] realtime message parse failed: ${String(err?.message ?? err)}`);
@@ -913,6 +904,7 @@ export class RealtimeBridge {
913
904
  this.__clearConnectTimer();
914
905
  setRemoteLogSender(null);
915
906
  remoteLog(`ws.error peer=server msg=${String(err?.message ?? err)}`);
907
+ /* c8 ignore next -- ?./?? fallback */
916
908
  this.logger.warn?.(`[coclaw] realtime bridge error, will retry in ${RECONNECT_MS}ms: ${String(err?.message ?? err)}`);
917
909
  this.serverWs = null;
918
910
  this.__closeGatewayWs();
@@ -923,27 +915,53 @@ export class RealtimeBridge {
923
915
  });
924
916
  }
925
917
 
926
- async start({ logger, pluginConfig } = {}) {
927
- this.logger = logger ?? console;
928
- this.pluginConfig = pluginConfig ?? {};
929
- this.started = true;
930
- // 先完成 WebRTC 实现加载,再建立连接,避免 UI 发来 offer 时 RTC 包未就绪
931
- 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
932
939
  ?? (await import('./webrtc/ndc-preloader.js')).preloadNdc;
933
- // 版本预热与 preload 并行,供 gateway connect 请求同步使用
934
- const [preloadResult] = await Promise.all([
935
- preloadFn().catch((err) => {
936
- // preloadNdc 设计上永不 throw,此 catch 为纯防御性兜底
940
+ const [ndcResult] = await Promise.all([
941
+ preloadNdcFn().catch((err) => {
937
942
  this.logger.warn?.(`[coclaw] ndc preload unexpected failure: ${err?.message}`);
938
943
  return { PeerConnection: null, cleanup: null, impl: 'none' };
939
944
  }),
940
- getPluginVersion()
941
- .then((v) => { this.__pluginVersion = v; })
942
- .catch(() => { this.__pluginVersion = 'unknown'; }),
945
+ versionPromise,
943
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();
944
959
  // 竞态保护:若 preload 期间 stop() 已执行,不再赋值,直接返回。
945
- // 不调 cleanup()——与 stop() 策略一致,native threads 保持活跃供后续复用。
946
960
  if (!this.started) {
961
+ // pion 进程需要关闭
962
+ if (preloadResult.impl === 'pion' && preloadResult.cleanup) {
963
+ preloadResult.cleanup().catch(() => {});
964
+ }
947
965
  return;
948
966
  }
949
967
  this.__ndcPreloadResult = preloadResult;
@@ -978,12 +996,12 @@ export class RealtimeBridge {
978
996
  this.webrtcPeer = null;
979
997
  this.__webrtcPeerReady = null;
980
998
  }
981
- // 不在 stop() 中调用 ndc.cleanup():
982
- // cleanup() 是同步 native 调用,需 join native threads,耗时 10s+,
983
- // 会阻塞事件循环导致 RPC handler 超时。
984
- // gateway 是长驻进程,native threads 保持活跃即可;
985
- // 下次 start() 重新 import(ESM 缓存命中)可直接复用。
986
- // 进程退出时 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
+ }
987
1005
  this.__ndcCleanup = null;
988
1006
  this.__ndcPreloadResult = null;
989
1007
  if (this.__fileHandler) {
@@ -1029,7 +1047,8 @@ export async function restartRealtimeBridge(opts) {
1029
1047
  await singleton.stop();
1030
1048
  singleton = null;
1031
1049
  }
1032
- singleton = new RealtimeBridge();
1050
+ const deps = opts?.__deps; // 仅测试用
1051
+ singleton = new RealtimeBridge(deps);
1033
1052
  await singleton.start(opts);
1034
1053
  }
1035
1054
 
@@ -1043,11 +1062,14 @@ export async function stopRealtimeBridge({ forceCleanup = false } = {}) {
1043
1062
  if (!singleton) {
1044
1063
  return;
1045
1064
  }
1046
- 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;
1047
1068
  await singleton.stop();
1048
1069
  singleton = null; // 置 null 后须通过 restartRealtimeBridge 重建
1070
+ /* c8 ignore next 3 -- forceCleanup 仅 ndc 测试清理 TSFN,pion binary 存在时走 pion 路径不触发 */
1049
1071
  if (typeof cleanupFn === 'function') {
1050
- try { cleanupFn(); } catch { /* c8 ignore next -- cleanup 失败不影响 stop 结果 */ }
1072
+ try { cleanupFn(); } catch { /* cleanup 失败不影响 stop 结果 */ }
1051
1073
  }
1052
1074
  }
1053
1075
 
@@ -1086,5 +1108,5 @@ export function broadcastPluginEvent(event, payload) {
1086
1108
  if (!singleton) return;
1087
1109
  const frame = { type: 'event', event, payload };
1088
1110
  singleton.__forwardToServer(frame);
1089
- singleton.webrtcPeer?.broadcast(frame);
1111
+ singleton.webrtcPeer?.broadcast(frame); /* c8 ignore -- ?. fallback */
1090
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}`);
226
+ }
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);
187
235
  }
188
- } else if (state === 'failed' || state === 'closed') {
189
- const cur = this.__sessions.get(connId);
190
- if (cur && cur.pc === pc) {
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);
@@ -236,8 +301,12 @@ export class WebRtcPeer {
236
301
  this.__logDebug(`ICE candidate from ${connId} but no session`);
237
302
  return;
238
303
  }
239
- await session.pc.addIceCandidate(msg.payload);
240
- this.__logDebug(`[${connId}] ICE candidate added`);
304
+ try {
305
+ await session.pc.addIceCandidate(msg.payload);
306
+ this.__logDebug(`[${connId}] ICE candidate added`);
307
+ } catch (err) {
308
+ this.__logDebug(`[${connId}] addIceCandidate failed: ${err?.message}`);
309
+ }
241
310
  }
242
311
 
243
312
  __setupDataChannel(connId, dc) {
@@ -275,33 +344,58 @@ export class WebRtcPeer {
275
344
  }, { logger: this.logger });
276
345
 
277
346
  dc.onopen = () => {
278
- remoteLog(`dc.open conn=${connId} label=${dc.label}`);
279
- 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`);
280
349
  };
281
350
  dc.onclose = () => {
282
- remoteLog(`dc.closed conn=${connId} label=${dc.label}`);
283
- 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`);
284
353
  reassembler.reset();
285
354
  const session = this.__sessions.get(connId);
286
355
  if (session && dc.label === 'rpc') session.rpcChannel = null;
287
356
  };
288
357
  dc.onerror = (err) => {
289
- remoteLog(`dc.error conn=${connId} label=${dc.label}`);
358
+ this.__remoteLog(`dc.error conn=${connId} label=${dc.label}`);
290
359
  /* c8 ignore next -- ?./?? fallback */
291
- 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)}`);
292
361
  };
293
362
  dc.onmessage = (event) => {
294
363
  try {
295
364
  reassembler.feed(event.data);
296
365
  } catch (err) {
297
- this.logger.warn?.(`[coclaw/rtc] [${connId}] DC message error: ${err.message}`);
366
+ this.logger.warn?.(`${this.__rtcTag} [${connId}] DC message error: ${err.message}`);
298
367
  }
299
368
  };
300
369
  }
301
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
+
302
396
  __logDebug(message) {
303
397
  if (typeof this.logger?.debug === 'function') {
304
- this.logger.debug(`[coclaw/rtc] ${message}`);
398
+ this.logger.debug(`${this.__rtcTag} ${message}`);
305
399
  }
306
400
  }
307
401
  }