@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 +58 -3
- package/package.json +4 -2
- package/src/auto-upgrade/updater-check.js +3 -1
- package/src/auto-upgrade/updater-spawn.js +2 -0
- package/src/auto-upgrade/updater.js +3 -0
- package/src/auto-upgrade/worker-verify.js +4 -1
- package/src/auto-upgrade/worker.js +8 -3
- package/src/chat-history-manager/manager.js +1 -0
- package/src/cli-registrar.js +3 -1
- package/src/common/gateway-notify.js +1 -0
- package/src/device-identity.js +2 -0
- package/src/file-manager/handler.js +109 -14
- package/src/plugin-version.js +1 -0
- package/src/realtime-bridge.js +67 -45
- package/src/session-manager/manager.js +1 -0
- package/src/settings.js +1 -0
- package/src/topic-manager/manager.js +1 -0
- package/src/topic-manager/title-gen.js +2 -0
- package/src/webrtc/dc-chunking.js +1 -1
- package/src/webrtc/ndc-preloader.js +2 -0
- package/src/webrtc/pion-preloader.js +83 -0
- package/src/webrtc/webrtc-peer.js +133 -39
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
|
-
|
|
6
|
-
|
|
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.
|
|
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
|
-
"
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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]: [...] }
|
package/src/cli-registrar.js
CHANGED
|
@@ -54,6 +54,7 @@ async function callWithRetry(method, deps, rpcOpts) {
|
|
|
54
54
|
let result = await callRpc();
|
|
55
55
|
|
|
56
56
|
if (isGatewayUnavailable(result)) {
|
|
57
|
+
/* c8 ignore next -- ?? fallback:测试始终注入 deps.restartGateway */
|
|
57
58
|
const restartFn = deps.restartGateway ?? restartGatewayProcess;
|
|
58
59
|
try {
|
|
59
60
|
await restartFn(deps.spawn);
|
|
@@ -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) => {
|
package/src/device-identity.js
CHANGED
|
@@ -108,6 +108,7 @@ export function loadOrCreateDeviceIdentity(filePath) {
|
|
|
108
108
|
if (derivedId && derivedId !== parsed.deviceId) {
|
|
109
109
|
const updated = { ...parsed, deviceId: derivedId };
|
|
110
110
|
fs.writeFileSync(fp, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
|
|
111
|
+
/* c8 ignore next -- best-effort chmod */
|
|
111
112
|
try { fs.chmodSync(fp, 0o600); } catch { /* best-effort */ }
|
|
112
113
|
return { deviceId: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
|
|
113
114
|
}
|
|
@@ -131,6 +132,7 @@ export function loadOrCreateDeviceIdentity(filePath) {
|
|
|
131
132
|
createdAtMs: Date.now(),
|
|
132
133
|
};
|
|
133
134
|
fs.writeFileSync(fp, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
|
|
135
|
+
/* c8 ignore next -- best-effort chmod */
|
|
134
136
|
try { fs.chmodSync(fp, 0o600); } catch { /* best-effort */ }
|
|
135
137
|
return identity;
|
|
136
138
|
}
|
|
@@ -11,7 +11,7 @@ const HIGH_WATER_MARK = 262_144; // 256KB
|
|
|
11
11
|
const LOW_WATER_MARK = 65_536; // 64KB
|
|
12
12
|
const MAX_UPLOAD_SIZE = 1_073_741_824; // 1GB
|
|
13
13
|
const FILE_DC_TIMEOUT_MS = 30_000; // DC 打开后 30s 内需收到请求
|
|
14
|
-
const TMP_CLEANUP_DELAY_MS =
|
|
14
|
+
const TMP_CLEANUP_DELAY_MS = 2_000; // 启动后 2s 延迟清理
|
|
15
15
|
const TMP_FILE_PATTERN = /\.tmp\.[0-9a-f-]{36}$/;
|
|
16
16
|
|
|
17
17
|
// --- 路径安全校验 ---
|
|
@@ -70,6 +70,7 @@ export async function validatePath(workspaceDir, userPath, deps = {}) {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
// 仅允许普通文件和目录
|
|
73
|
+
/* c8 ignore next 4 -- 特殊文件类型(socket/FIFO/device)在测试环境无法可靠构造 */
|
|
73
74
|
if (!stat.isFile() && !stat.isDirectory() && !stat.isSymbolicLink()) {
|
|
74
75
|
const err = new Error(`Special file type denied: ${userPath}`);
|
|
75
76
|
err.code = 'PATH_DENIED';
|
|
@@ -329,6 +330,13 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
329
330
|
}, FILE_DC_TIMEOUT_MS);
|
|
330
331
|
requestTimer.unref?.();
|
|
331
332
|
|
|
333
|
+
// 早期 error 上报:保护 GET/PUT/POST 接管前的窗口期
|
|
334
|
+
// 内部 handler 接管后会用更具上下文的 onerror 替换此处
|
|
335
|
+
dc.onerror = (err) => {
|
|
336
|
+
/* c8 ignore next -- ?? fallback for missing label/err.message */
|
|
337
|
+
remoteLog(`file.dc.error conn=${connId} label=${dc.label ?? 'unknown'} stage=pre-request err=${err?.message ?? err}`);
|
|
338
|
+
};
|
|
339
|
+
|
|
332
340
|
let requestReceived = false;
|
|
333
341
|
|
|
334
342
|
dc.onmessage = (event) => {
|
|
@@ -348,9 +356,13 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
348
356
|
return;
|
|
349
357
|
}
|
|
350
358
|
|
|
359
|
+
// 本地 logger.info:让 gateway 本地 log 直接看到 file 操作的开始
|
|
360
|
+
// (远端诊断走 remoteLog,但本地能看到对排查 WSL2 假活/重连场景至关重要)
|
|
361
|
+
log.info?.(`[coclaw/file] [${connId}] ${req.method} label=${dc.label ?? '?'} path=${req.path ?? '?'}${req.size != null ? ` size=${req.size}` : ''}`);
|
|
362
|
+
|
|
351
363
|
if (req.method === 'GET') {
|
|
352
364
|
/* c8 ignore next 3 -- handleGet 内部已完整处理异常,此 catch 纯防御 */
|
|
353
|
-
handleGet(dc, req).catch((err) => {
|
|
365
|
+
handleGet(dc, req, connId).catch((err) => {
|
|
354
366
|
log.warn?.(`[coclaw/file] GET error: ${err.message}`);
|
|
355
367
|
});
|
|
356
368
|
} else if (req.method === 'PUT') {
|
|
@@ -369,7 +381,12 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
369
381
|
};
|
|
370
382
|
}
|
|
371
383
|
|
|
372
|
-
async function handleGet(dc, req) {
|
|
384
|
+
async function handleGet(dc, req, connId) {
|
|
385
|
+
/* c8 ignore next -- ?./?? fallback for non-file: label */
|
|
386
|
+
const transferId = dc.label?.split(':')?.[1] ?? randomUUID();
|
|
387
|
+
const logTag = connId ? `conn=${connId} ` : '';
|
|
388
|
+
const startTime = Date.now();
|
|
389
|
+
|
|
373
390
|
let workspaceDir, resolved;
|
|
374
391
|
try {
|
|
375
392
|
const agentId = req.agentId?.trim?.() || 'main'; /* c8 ignore next -- ?./?? fallback */
|
|
@@ -410,18 +427,50 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
410
427
|
} catch {
|
|
411
428
|
return; // DC 已关闭
|
|
412
429
|
}
|
|
430
|
+
remoteLog(`file.dl.start ${logTag}id=${transferId} size=${stat.size}`);
|
|
431
|
+
/* c8 ignore next -- 空文件分支:进度日志条件下永远不触发,无需 25% 阈值 */
|
|
432
|
+
let nextLogAt = stat.size > 0 ? Math.floor(stat.size * 0.25) : Infinity;
|
|
433
|
+
let logStep = 1;
|
|
413
434
|
|
|
414
435
|
// 流式发送文件内容
|
|
415
436
|
const stream = _createReadStream(resolved, { highWaterMark: CHUNK_SIZE });
|
|
416
437
|
let sentBytes = 0;
|
|
417
438
|
let dcClosed = false;
|
|
418
439
|
|
|
419
|
-
|
|
440
|
+
// flow control 状态
|
|
441
|
+
let bufferedAmountLowCount = 0;
|
|
442
|
+
let pauseCount = 0;
|
|
443
|
+
let resumeCount = 0;
|
|
444
|
+
let pausedNow = false;
|
|
445
|
+
|
|
446
|
+
dc.onclose = () => {
|
|
447
|
+
dcClosed = true;
|
|
448
|
+
stream.destroy();
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
// pion 异步 send 错误经此回调上报;ndc 同步抛错由 stream.on('data') 的 try/catch 接住
|
|
452
|
+
dc.onerror = (err) => {
|
|
453
|
+
if (dcClosed) return;
|
|
454
|
+
dcClosed = true;
|
|
455
|
+
stream.destroy();
|
|
456
|
+
const elapsed = Date.now() - startTime;
|
|
457
|
+
/* c8 ignore next -- ?? fallback for non-Error throw */
|
|
458
|
+
const errMsg = err?.message ?? String(err);
|
|
459
|
+
remoteLog(`file.dl.fail ${logTag}id=${transferId} reason=dc-error err=${errMsg} sent=${sentBytes}/${stat.size} elapsed=${elapsed}ms`);
|
|
460
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] dl.fail id=${transferId} reason=dc-error sent=${sentBytes}/${stat.size} err=${errMsg}`);
|
|
461
|
+
};
|
|
420
462
|
|
|
421
463
|
if (dc.bufferedAmountLowThreshold !== undefined) {
|
|
422
464
|
dc.bufferedAmountLowThreshold = LOW_WATER_MARK;
|
|
423
465
|
}
|
|
424
|
-
dc.onbufferedamountlow = () =>
|
|
466
|
+
dc.onbufferedamountlow = () => {
|
|
467
|
+
bufferedAmountLowCount++;
|
|
468
|
+
if (pausedNow) {
|
|
469
|
+
resumeCount++;
|
|
470
|
+
pausedNow = false;
|
|
471
|
+
stream.resume();
|
|
472
|
+
}
|
|
473
|
+
};
|
|
425
474
|
|
|
426
475
|
await new Promise((resolve, reject) => {
|
|
427
476
|
stream.on('data', (chunk) => {
|
|
@@ -430,8 +479,17 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
430
479
|
dc.send(chunk);
|
|
431
480
|
sentBytes += chunk.length;
|
|
432
481
|
if (dc.bufferedAmount > HIGH_WATER_MARK) {
|
|
482
|
+
pauseCount++;
|
|
483
|
+
pausedNow = true;
|
|
433
484
|
stream.pause();
|
|
434
485
|
}
|
|
486
|
+
// 进度日志(25% / 50% / 75%)
|
|
487
|
+
if (sentBytes >= nextLogAt && logStep <= 3) {
|
|
488
|
+
remoteLog(`file.dl.progress ${logTag}id=${transferId} ${logStep * 25}% sent=${sentBytes}/${stat.size}`);
|
|
489
|
+
logStep++;
|
|
490
|
+
/* c8 ignore next -- 空文件分支:进入此循环时 stat.size 必然 > 0 */
|
|
491
|
+
nextLogAt = stat.size > 0 ? Math.floor(stat.size * logStep * 0.25) : Infinity;
|
|
492
|
+
}
|
|
435
493
|
/* c8 ignore start -- dc.send 抛异常属罕见竞态 */
|
|
436
494
|
} catch {
|
|
437
495
|
dcClosed = true;
|
|
@@ -439,18 +497,27 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
439
497
|
}
|
|
440
498
|
/* c8 ignore stop */
|
|
441
499
|
});
|
|
442
|
-
stream.on('end', () => {
|
|
500
|
+
stream.on('end', async () => {
|
|
443
501
|
if (dcClosed) { resolve(); return; }
|
|
444
502
|
try {
|
|
445
503
|
dc.send(JSON.stringify({ ok: true, bytes: sentBytes }));
|
|
446
|
-
|
|
504
|
+
// 必须 await close():pion-node 等价 W3C graceful close,
|
|
505
|
+
// 否则在不支持 graceful 的实现上最后一条 ok JSON 会被丢弃
|
|
506
|
+
await dc.close();
|
|
447
507
|
} catch { /* ignore */ }
|
|
508
|
+
const elapsed = Date.now() - startTime;
|
|
509
|
+
// 完成时也 dump 一次最终统计,便于事后审计 backpressure 行为
|
|
510
|
+
remoteLog(`file.dl.ok ${logTag}id=${transferId} bytes=${sentBytes} elapsed=${elapsed}ms balCount=${bufferedAmountLowCount} pauseCount=${pauseCount} resumeCount=${resumeCount}`);
|
|
511
|
+
log.info?.(`[coclaw/file] [${connId ?? '?'}] dl.ok id=${transferId} bytes=${sentBytes} elapsed=${elapsed}ms balCount=${bufferedAmountLowCount} pauseCount=${pauseCount}`);
|
|
448
512
|
resolve();
|
|
449
513
|
});
|
|
450
514
|
stream.on('error', (err) => {
|
|
451
515
|
if (!dcClosed) {
|
|
452
516
|
sendError(dc, 'READ_FAILED', err.message);
|
|
453
517
|
}
|
|
518
|
+
const elapsed = Date.now() - startTime;
|
|
519
|
+
remoteLog(`file.dl.fail ${logTag}id=${transferId} reason=read-error err=${err.message} sent=${sentBytes}/${stat.size} elapsed=${elapsed}ms`);
|
|
520
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] dl.fail id=${transferId} reason=read-error sent=${sentBytes}/${stat.size} err=${err.message}`);
|
|
454
521
|
reject(err);
|
|
455
522
|
});
|
|
456
523
|
}).catch((err) => {
|
|
@@ -630,6 +697,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
630
697
|
if (dcClosed) {
|
|
631
698
|
safeUnlink(tmpPath);
|
|
632
699
|
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-closed-before-flush received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
700
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=dc-closed-before-flush received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
633
701
|
return;
|
|
634
702
|
}
|
|
635
703
|
const valid = receivedBytes === declaredSize;
|
|
@@ -639,9 +707,10 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
639
707
|
/* c8 ignore next */
|
|
640
708
|
} catch { /* ignore */ }
|
|
641
709
|
safeUnlink(tmpPath);
|
|
642
|
-
|
|
643
|
-
try { dc.close(); } catch { /* ignore */ }
|
|
710
|
+
// graceful close:必须 await,否则 send 入队的 error JSON 会被 close 丢弃
|
|
711
|
+
try { await dc.close(); } catch { /* ignore */ }
|
|
644
712
|
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=size-mismatch expected=${declaredSize} got=${receivedBytes} elapsed=${elapsed}ms`);
|
|
713
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=size-mismatch expected=${declaredSize} got=${receivedBytes} elapsed=${elapsed}ms`);
|
|
645
714
|
return;
|
|
646
715
|
}
|
|
647
716
|
// 先 rename,再发成功响应(避免 rename 失败时 UI 误认为成功)
|
|
@@ -654,9 +723,9 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
654
723
|
dc.send(JSON.stringify({ ok: false, error: { code: 'WRITE_FAILED', message: `rename failed: ${renameErr.message}` } }));
|
|
655
724
|
} catch { /* ignore */ }
|
|
656
725
|
safeUnlink(tmpPath);
|
|
657
|
-
/*
|
|
658
|
-
|
|
659
|
-
|
|
726
|
+
try { await dc.close(); } catch { /* ignore */ }
|
|
727
|
+
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=rename-failed received=${receivedBytes} elapsed=${elapsed}ms`);
|
|
728
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=rename-failed received=${receivedBytes} elapsed=${elapsed}ms`);
|
|
660
729
|
return;
|
|
661
730
|
}
|
|
662
731
|
const result = { ok: true, bytes: receivedBytes };
|
|
@@ -665,9 +734,10 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
665
734
|
dc.send(JSON.stringify(result));
|
|
666
735
|
/* c8 ignore next */
|
|
667
736
|
} catch { /* ignore */ }
|
|
668
|
-
|
|
669
|
-
try { dc.close(); } catch { /* ignore */ }
|
|
737
|
+
// graceful close:上传成功路径同样必须 await,否则 result JSON 会丢
|
|
738
|
+
try { await dc.close(); } catch { /* ignore */ }
|
|
670
739
|
remoteLog(`file.up.ok ${logTag}id=${transferId} bytes=${receivedBytes} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
740
|
+
log.info?.(`[coclaw/file] [${connId ?? '?'}] up.ok id=${transferId} bytes=${receivedBytes} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
671
741
|
});
|
|
672
742
|
}
|
|
673
743
|
|
|
@@ -701,6 +771,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
701
771
|
} catch { /* ignore */ }
|
|
702
772
|
try { dc.close(); } catch { /* ignore */ }
|
|
703
773
|
remoteLog(`file.up.reject ${logTag}id=${transferId} reason=size-exceeded received=${receivedBytes}`);
|
|
774
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.reject id=${transferId} reason=size-exceeded received=${receivedBytes}`);
|
|
704
775
|
return;
|
|
705
776
|
}
|
|
706
777
|
|
|
@@ -711,6 +782,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
711
782
|
if (receivedBytes >= nextLogAt && logStep <= 3) {
|
|
712
783
|
remoteLog(`file.up.progress ${logTag}id=${transferId} ${logStep * 25}% received=${receivedBytes}/${declaredSize} bp=${wsBackpressureCount}`);
|
|
713
784
|
logStep++;
|
|
785
|
+
/* c8 ignore next -- declaredSize=0 的上传不会达到进度日志阈值 */
|
|
714
786
|
nextLogAt = declaredSize > 0 ? Math.floor(declaredSize * logStep * 0.25) : Infinity;
|
|
715
787
|
}
|
|
716
788
|
}
|
|
@@ -728,11 +800,31 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
728
800
|
safeUnlink(tmpPath);
|
|
729
801
|
const elapsed = Date.now() - startTime;
|
|
730
802
|
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
803
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
731
804
|
}
|
|
732
805
|
};
|
|
733
806
|
|
|
807
|
+
// pion 异步 send 错误经此回调上报;触发已有清理路径
|
|
808
|
+
dc.onerror = (err) => {
|
|
809
|
+
if (dcClosed || wsError) return;
|
|
810
|
+
wsError = true;
|
|
811
|
+
draining = false;
|
|
812
|
+
pendingQueue.length = 0;
|
|
813
|
+
ws.destroy();
|
|
814
|
+
safeUnlink(tmpPath);
|
|
815
|
+
const elapsed = Date.now() - startTime;
|
|
816
|
+
/* c8 ignore next -- ?? fallback for non-Error throw */
|
|
817
|
+
const errMsg = err?.message ?? String(err);
|
|
818
|
+
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-error err=${errMsg} received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
819
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
820
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=dc-error received=${receivedBytes}/${declaredSize} err=${errMsg}`);
|
|
821
|
+
};
|
|
822
|
+
|
|
734
823
|
// WriteStream 错误处理
|
|
735
824
|
ws.on('error', (err) => {
|
|
825
|
+
// 幂等:dc.onerror 路径会先 ws.destroy(),destroy 可能再触发一次 'error',
|
|
826
|
+
// 已设 wsError 后直接返回,避免产生第二条 fail 日志
|
|
827
|
+
if (wsError) return;
|
|
736
828
|
wsError = true;
|
|
737
829
|
draining = false;
|
|
738
830
|
pendingQueue.length = 0;
|
|
@@ -744,6 +836,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
744
836
|
safeUnlink(tmpPath);
|
|
745
837
|
const elapsed = Date.now() - startTime;
|
|
746
838
|
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=write-error err=${err.code || err.message} received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms`);
|
|
839
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=write-error received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms err=${err.code || err.message}`);
|
|
747
840
|
});
|
|
748
841
|
}
|
|
749
842
|
|
|
@@ -799,9 +892,11 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
799
892
|
// --- 工具函数 ---
|
|
800
893
|
|
|
801
894
|
function sendError(dc, code, message) {
|
|
895
|
+
/* c8 ignore next 2 -- DC 可能已关闭,catch 纯防御 */
|
|
802
896
|
try {
|
|
803
897
|
dc.send(JSON.stringify({ ok: false, error: { code, message } }));
|
|
804
898
|
} catch { /* DC 可能已关闭 */ }
|
|
899
|
+
/* c8 ignore next */
|
|
805
900
|
try { dc.close(); } catch { /* ignore */ }
|
|
806
901
|
}
|
|
807
902
|
|
package/src/plugin-version.js
CHANGED
|
@@ -8,6 +8,7 @@ export async function getPluginVersion() {
|
|
|
8
8
|
try {
|
|
9
9
|
const pkgPath = nodePath.resolve(import.meta.dirname, '..', 'package.json');
|
|
10
10
|
const raw = await fs.readFile(pkgPath, 'utf8');
|
|
11
|
+
/* c8 ignore next -- ?? fallback */
|
|
11
12
|
__pluginVersion = JSON.parse(raw).version ?? 'unknown';
|
|
12
13
|
} catch {
|
|
13
14
|
return 'unknown';
|
package/src/realtime-bridge.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
|
|
934
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
982
|
-
// cleanup()
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 { /*
|
|
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
|
}
|
package/src/settings.js
CHANGED
|
@@ -29,6 +29,7 @@ export class TopicManager {
|
|
|
29
29
|
* @param {Function} [opts.copyFile] - 测试注入
|
|
30
30
|
*/
|
|
31
31
|
constructor(opts = {}) {
|
|
32
|
+
/* c8 ignore next 6 -- ?? fallback:测试始终注入 */
|
|
32
33
|
this.__rootDir = opts.rootDir ?? nodePath.join(os.homedir(), '.openclaw', 'agents');
|
|
33
34
|
this.__logger = opts.logger ?? console;
|
|
34
35
|
this.__readFile = opts.readFile ?? fs.readFile;
|
|
@@ -136,10 +136,12 @@ export async function generateTitle({ topicId, topicManager, agentRpc, logger })
|
|
|
136
136
|
await topicManager.updateTitle({ topicId, title });
|
|
137
137
|
return { title };
|
|
138
138
|
} catch (err) {
|
|
139
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
139
140
|
log.warn?.(`[coclaw] generateTitle failed for topic ${topicId}: ${String(err?.message ?? err)}`);
|
|
140
141
|
throw err;
|
|
141
142
|
} finally {
|
|
142
143
|
// 清理临时文件
|
|
144
|
+
/* c8 ignore next -- .catch() 防御 */
|
|
143
145
|
await topicManager.cleanupTempFile(tempPath).catch(() => {});
|
|
144
146
|
}
|
|
145
147
|
}
|
|
@@ -82,7 +82,7 @@ export function createReassembler(onComplete, opts = {}) {
|
|
|
82
82
|
// binary = 分片 chunk
|
|
83
83
|
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
84
84
|
if (buf.length < HEADER_SIZE) {
|
|
85
|
-
logger?.warn?.('[dc-chunking] chunk too short, discarding');
|
|
85
|
+
logger?.warn?.('[dc-chunking] chunk too short, discarding'); /* c8 ignore -- ?./?. fallback */
|
|
86
86
|
return;
|
|
87
87
|
}
|
|
88
88
|
|
|
@@ -73,6 +73,7 @@ function wrapNdcCredentials(NativeRTC) {
|
|
|
73
73
|
config = {
|
|
74
74
|
...config,
|
|
75
75
|
iceServers: config.iceServers.map(s => {
|
|
76
|
+
/* c8 ignore next -- TURN 无凭据时的短路,集成环境下不经过此路径 */
|
|
76
77
|
if (!s.username && !s.credential) return s;
|
|
77
78
|
return {
|
|
78
79
|
...s,
|
|
@@ -185,6 +186,7 @@ export async function preloadNdc(deps = {}) {
|
|
|
185
186
|
// 用于捕获 ICE/DTLS/SCTP 层断连原因。
|
|
186
187
|
// initLogger 是进程全局单例,调用一次即可(cleanup 不会被调用,logger 全程有效)。
|
|
187
188
|
// callback 通过 TSFN 投递到 JS 主线程,Warning 级别正常运行时零输出。
|
|
189
|
+
/* c8 ignore next -- ??/?. fallback */
|
|
188
190
|
const initLogger = ndc.initLogger ?? ndc.default?.initLogger;
|
|
189
191
|
if (typeof initLogger === 'function') {
|
|
190
192
|
try {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { remoteLog as defaultRemoteLog } from '../remote-log.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_START_TIMEOUT_MS = 10_000;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 预加载 Pion WebRTC 实现:启动 pion-ipc Go 进程,返回绑定了 ipc 的 PeerConnection。
|
|
7
|
+
*
|
|
8
|
+
* **此函数永不 throw**——所有异常内部捕获,通过 remoteLog 报告。
|
|
9
|
+
* 失败时返回 null(调用方降级到 ndc/werift)。
|
|
10
|
+
*
|
|
11
|
+
* binary 解析由 @coclaw/pion-node 内部处理(env → npm 平台包 → PATH)。
|
|
12
|
+
*
|
|
13
|
+
* @param {object} [deps] - 可注入依赖(测试用)
|
|
14
|
+
* @param {Function} [deps.dynamicImport] - (specifier) => import(specifier)
|
|
15
|
+
* @param {Function} [deps.remoteLog] - (text) => void
|
|
16
|
+
* @param {number} [deps.startTimeout] - 启动超时(ms),默认 10s
|
|
17
|
+
* @returns {Promise<{ PeerConnection: Function, cleanup: Function, impl: string, ipc: object }|null>}
|
|
18
|
+
*/
|
|
19
|
+
export async function preloadPion(deps = {}) {
|
|
20
|
+
const log = deps.remoteLog ?? defaultRemoteLog;
|
|
21
|
+
const dynamicImport = deps.dynamicImport ?? ((spec) => import(spec));
|
|
22
|
+
const startTimeout = deps.startTimeout ?? DEFAULT_START_TIMEOUT_MS;
|
|
23
|
+
|
|
24
|
+
log('pion.preload');
|
|
25
|
+
|
|
26
|
+
let ipc = null;
|
|
27
|
+
try {
|
|
28
|
+
// 加载 pion-node SDK
|
|
29
|
+
let PionIpc, RTCPeerConnection;
|
|
30
|
+
try {
|
|
31
|
+
const mod = await dynamicImport('@coclaw/pion-node');
|
|
32
|
+
PionIpc = mod.PionIpc;
|
|
33
|
+
RTCPeerConnection = mod.RTCPeerConnection;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
log(`pion.skip reason=import-failed error=${err.message}`);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (typeof PionIpc !== 'function' || typeof RTCPeerConnection !== 'function') {
|
|
40
|
+
log('pion.skip reason=invalid-exports');
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 启动 IPC 进程(内部会 ping 验证就绪,binary 由 pion-node 自动解析)
|
|
45
|
+
ipc = new PionIpc({
|
|
46
|
+
logger: (msg) => log(`pion.ipc ${msg}`),
|
|
47
|
+
timeout: startTimeout,
|
|
48
|
+
autoRestart: true,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await ipc.start();
|
|
53
|
+
} catch (err) {
|
|
54
|
+
log(`pion.skip reason=start-failed error=${err.message}`);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 创建绑定了 ipc 的 PeerConnection 子类
|
|
59
|
+
class BoundPeerConnection extends RTCPeerConnection {
|
|
60
|
+
constructor(config = {}) {
|
|
61
|
+
super({ ...config, _ipc: ipc });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const cleanup = async () => {
|
|
66
|
+
try {
|
|
67
|
+
await ipc.stop();
|
|
68
|
+
} catch {
|
|
69
|
+
// 静默忽略,stop 失败不影响后续
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
log('pion.loaded');
|
|
74
|
+
return { PeerConnection: BoundPeerConnection, cleanup, impl: 'pion', ipc };
|
|
75
|
+
} catch (err) {
|
|
76
|
+
// ipc 已启动但后续步骤意外失败 → 关闭 Go 进程,防止泄漏
|
|
77
|
+
if (ipc) {
|
|
78
|
+
ipc.stop().catch(() => {});
|
|
79
|
+
}
|
|
80
|
+
log(`pion.skip reason=unexpected error=${err.message}`);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { chunkAndSend, createReassembler } from './dc-chunking.js';
|
|
2
2
|
import { remoteLog } from '../remote-log.js';
|
|
3
3
|
|
|
4
|
+
// 单个 session 内 file DC 历史快照的容量上限(满后按 FIFO 淘汰最老条目)。
|
|
5
|
+
// 用于诊断 dump:过大会撑爆 remoteLog 单帧,20 足以覆盖典型多文件传输会话。
|
|
6
|
+
const FILE_CHANNEL_HISTORY_LIMIT = 20;
|
|
7
|
+
|
|
4
8
|
/**
|
|
5
9
|
* 管理多个 WebRTC PeerConnection(以 connId 为粒度)。
|
|
6
10
|
* Plugin 作为被叫方:收到 UI 的 offer → 回复 answer。
|
|
@@ -14,8 +18,9 @@ export class WebRtcPeer {
|
|
|
14
18
|
* @param {function} [opts.onFileChannel] - file:<transferId> DataChannel 的回调 (dc, connId) => void
|
|
15
19
|
* @param {object} [opts.logger] - pino 风格 logger
|
|
16
20
|
* @param {function} opts.PeerConnection - RTCPeerConnection 构造函数(由 ndc-preloader 提供)
|
|
21
|
+
* @param {string} [opts.impl] - WebRTC 实现标识(pion / ndc / werift)
|
|
17
22
|
*/
|
|
18
|
-
constructor({ onSend, onRequest, onFileRpc, onFileChannel, logger, PeerConnection }) {
|
|
23
|
+
constructor({ onSend, onRequest, onFileRpc, onFileChannel, logger, PeerConnection, impl }) {
|
|
19
24
|
if (!PeerConnection) {
|
|
20
25
|
throw new Error('PeerConnection constructor is required');
|
|
21
26
|
}
|
|
@@ -25,6 +30,8 @@ export class WebRtcPeer {
|
|
|
25
30
|
this.__onFileChannel = onFileChannel;
|
|
26
31
|
this.logger = logger ?? console;
|
|
27
32
|
this.__PeerConnection = PeerConnection;
|
|
33
|
+
this.__impl = impl ?? null;
|
|
34
|
+
this.__rtcTag = impl ? `[coclaw/rtc:${impl}]` : '[coclaw/rtc]';
|
|
28
35
|
/** @type {Map<string, { pc: object, rpcChannel: object|null, remoteMaxMessageSize: number, nextMsgId: number }>} */
|
|
29
36
|
this.__sessions = new Map();
|
|
30
37
|
}
|
|
@@ -52,9 +59,12 @@ export class WebRtcPeer {
|
|
|
52
59
|
// 先 detach 事件,防止 pc.close() 异步触发 onconnectionstatechange 删除新 session
|
|
53
60
|
session.pc.onconnectionstatechange = null;
|
|
54
61
|
session.pc.onicecandidate = null;
|
|
62
|
+
if ('onselectedcandidatepairchange' in session.pc) {
|
|
63
|
+
session.pc.onselectedcandidatepairchange = null;
|
|
64
|
+
}
|
|
55
65
|
await session.pc.close();
|
|
56
|
-
|
|
57
|
-
this.logger.info?.(
|
|
66
|
+
this.__remoteLog(`rtc.closed conn=${connId}`);
|
|
67
|
+
this.logger.info?.(`${this.__rtcTag} [${connId}] closed`);
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
/** 关闭所有 PeerConnection */
|
|
@@ -86,8 +96,8 @@ export class WebRtcPeer {
|
|
|
86
96
|
if (isIceRestart) {
|
|
87
97
|
const existing = this.__sessions.get(connId);
|
|
88
98
|
if (existing) {
|
|
89
|
-
|
|
90
|
-
this.logger.info?.(
|
|
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?.(
|
|
110
|
+
this.logger.info?.(`${this.__rtcTag} ICE restart answer sent to ${connId}`);
|
|
101
111
|
return;
|
|
102
112
|
} catch (err) {
|
|
103
|
-
// ICE restart 协商失败 →
|
|
104
|
-
|
|
105
|
-
this.logger.warn?.(
|
|
106
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
113
|
-
this.logger.info?.(
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
this.logger.info?.(
|
|
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
|
-
|
|
186
|
-
this.logger.info?.(
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
if (
|
|
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
|
-
|
|
199
|
-
this.logger.info?.(
|
|
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
|
-
|
|
220
|
-
this.logger.info?.(
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
279
|
-
this.logger.info?.(
|
|
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
|
-
|
|
283
|
-
this.logger.info?.(
|
|
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
|
-
|
|
358
|
+
this.__remoteLog(`dc.error conn=${connId} label=${dc.label}`);
|
|
290
359
|
/* c8 ignore next -- ?./?? fallback */
|
|
291
|
-
this.logger.warn?.(
|
|
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?.(
|
|
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(
|
|
398
|
+
this.logger.debug(`${this.__rtcTag} ${message}`);
|
|
305
399
|
}
|
|
306
400
|
}
|
|
307
401
|
}
|