@coclaw/openclaw-coclaw 0.20.0 → 0.20.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/auto-upgrade/updater.js +82 -20
- package/src/auto-upgrade/worker-verify.js +12 -5
- package/src/claw-config.js +27 -0
- package/src/realtime-bridge.js +14 -6
- package/src/utils/file-backed-queue.js +68 -16
- package/src/webrtc/webrtc-peer.js +66 -15
package/package.json
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
+
import nodeFs from 'node:fs';
|
|
2
3
|
import nodePath from 'node:path';
|
|
3
4
|
|
|
4
5
|
import { checkForUpdate } from './updater-check.js';
|
|
5
6
|
import { spawnUpgradeWorker } from './updater-spawn.js';
|
|
6
7
|
import { readState, resolveStateDir, writeState } from './state.js';
|
|
7
|
-
import {
|
|
8
|
+
import { getClawConfig } from '../claw-config.js';
|
|
8
9
|
import { remoteLog } from '../remote-log.js';
|
|
9
10
|
import { atomicWriteFile } from '../utils/atomic-write.js';
|
|
10
11
|
|
|
12
|
+
// OpenClaw ≥ 2026.4.25 起把插件安装记录从 openclaw.json 的 plugins.installs
|
|
13
|
+
// 迁移到独立账本文件,并在 loadConfig() 返回前剥掉 plugins.installs。
|
|
14
|
+
const INSTALLS_LEDGER_RELATIVE_PATH = nodePath.join('plugins', 'installs.json');
|
|
15
|
+
|
|
11
16
|
// 首次检查延迟较长:失败时由 worker 触发 gateway restart,scheduler 重启后会重新计时;
|
|
12
17
|
// 60 分钟基线(实际随机 60-120 分钟)能把"失败→重启→再次检查"的循环周期拉长,
|
|
13
18
|
// 避免连续升级失败时 gateway 在短时间内反复被打扰。
|
|
@@ -109,6 +114,80 @@ export async function writeUpgradeLock(pid) {
|
|
|
109
114
|
);
|
|
110
115
|
}
|
|
111
116
|
|
|
117
|
+
/**
|
|
118
|
+
* 读取本插件的安装记录(兼容新旧 OpenClaw 契约)
|
|
119
|
+
*
|
|
120
|
+
* - 新版(OpenClaw ≥ 2026.4.25):账本文件 `<state-dir>/plugins/installs.json`
|
|
121
|
+
* 下的 `installRecords[pluginId]` 是来源真相;`loadConfig()` 返回的对象里
|
|
122
|
+
* `plugins.installs` 已被剥离。
|
|
123
|
+
* - 旧版(OpenClaw ≤ 2026.4.24):账本文件不存在,
|
|
124
|
+
* `loadConfig().plugins.installs[pluginId]` 是来源真相。
|
|
125
|
+
*
|
|
126
|
+
* 兼容策略:先尝试账本文件;ENOENT(文件不存在)→ 回落到旧字段;
|
|
127
|
+
* 其它失败(权限/JSON 损坏/缺记录)→ 视为账本不可用,按"无来源信息"处理,不回落。
|
|
128
|
+
* 这两条互斥(新 gateway 必有账本、旧 gateway 必无)能让两个分支天然分流。
|
|
129
|
+
*
|
|
130
|
+
* 失败路径会通过 `remoteLog` 外推诊断信号(`upgrade.state-dir-failed` /
|
|
131
|
+
* `upgrade.ledger-read-failed` / `upgrade.ledger-parse-failed`),避免运维只
|
|
132
|
+
* 看到 start() 那条 "Skipping: not an npm-installed plugin" 时误判方向。
|
|
133
|
+
*
|
|
134
|
+
* @param {string} pluginId
|
|
135
|
+
* @returns {object|null}
|
|
136
|
+
*/
|
|
137
|
+
function loadInstallRecord(pluginId) {
|
|
138
|
+
let ledgerPath;
|
|
139
|
+
try {
|
|
140
|
+
ledgerPath = nodePath.join(resolveStateDir(), INSTALLS_LEDGER_RELATIVE_PATH);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
// 极少触发:host runtime 的 state resolver 自身异常
|
|
144
|
+
/* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
|
|
145
|
+
remoteLog(`upgrade.state-dir-failed msg=${err?.message ?? String(err)}`);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
let raw;
|
|
149
|
+
try {
|
|
150
|
+
raw = nodeFs.readFileSync(ledgerPath, 'utf8');
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
if (err?.code === 'ENOENT') {
|
|
154
|
+
return loadInstallRecordFromLegacyConfig(pluginId);
|
|
155
|
+
}
|
|
156
|
+
// 账本应该可读但读不到(权限/EISDIR/IO 错误):不回落到旧字段,避免误判老路径
|
|
157
|
+
// 静默返回 null 会让 start() 打 "Skipping: not an npm-installed plugin",对运维毫无指向;
|
|
158
|
+
// 把诊断信号外推到 server,便于定位
|
|
159
|
+
/* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
|
|
160
|
+
remoteLog(`upgrade.ledger-read-failed code=${err?.code ?? 'unknown'} msg=${err?.message ?? String(err)}`);
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
let parsed;
|
|
164
|
+
try {
|
|
165
|
+
parsed = JSON.parse(raw);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
// 账本损坏:同样不回落,并外推诊断信号
|
|
169
|
+
/* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
|
|
170
|
+
remoteLog(`upgrade.ledger-parse-failed msg=${err?.message ?? String(err)}`);
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
return parsed?.installRecords?.[pluginId] ?? null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 旧版 OpenClaw(≤ 2026.4.24)账本路径:openclaw.json 的 plugins.installs。
|
|
178
|
+
* @param {string} pluginId
|
|
179
|
+
* @returns {object|null}
|
|
180
|
+
*/
|
|
181
|
+
function loadInstallRecordFromLegacyConfig(pluginId) {
|
|
182
|
+
try {
|
|
183
|
+
const config = getClawConfig();
|
|
184
|
+
return config?.plugins?.installs?.[pluginId] ?? null;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
112
191
|
/**
|
|
113
192
|
* 判断是否应跳过自动升级
|
|
114
193
|
*
|
|
@@ -122,16 +201,7 @@ export async function writeUpgradeLock(pid) {
|
|
|
122
201
|
* @returns {boolean} true 表示应跳过自动升级
|
|
123
202
|
*/
|
|
124
203
|
export function shouldSkipAutoUpgrade(pluginId) {
|
|
125
|
-
|
|
126
|
-
if (!rt?.config?.loadConfig) return true;
|
|
127
|
-
try {
|
|
128
|
-
const config = rt.config.loadConfig();
|
|
129
|
-
const installInfo = config?.plugins?.installs?.[pluginId];
|
|
130
|
-
return installInfo?.source !== 'npm';
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
return true;
|
|
134
|
-
}
|
|
204
|
+
return loadInstallRecord(pluginId)?.source !== 'npm';
|
|
135
205
|
}
|
|
136
206
|
|
|
137
207
|
/**
|
|
@@ -140,15 +210,7 @@ export function shouldSkipAutoUpgrade(pluginId) {
|
|
|
140
210
|
* @returns {string|null}
|
|
141
211
|
*/
|
|
142
212
|
export function getPluginInstallPath(pluginId) {
|
|
143
|
-
|
|
144
|
-
if (!rt?.config?.loadConfig) return null;
|
|
145
|
-
try {
|
|
146
|
-
const config = rt.config.loadConfig();
|
|
147
|
-
return config?.plugins?.installs?.[pluginId]?.installPath ?? null;
|
|
148
|
-
}
|
|
149
|
-
catch {
|
|
150
|
-
return null;
|
|
151
|
-
}
|
|
213
|
+
return loadInstallRecord(pluginId)?.installPath ?? null;
|
|
152
214
|
}
|
|
153
215
|
|
|
154
216
|
/**
|
|
@@ -43,6 +43,13 @@ const HEALTH_POLL_INTERVAL_MS = 3_000;
|
|
|
43
43
|
// 及插件 bootstrap,合计 30~60s 常见;5 分钟给足余量
|
|
44
44
|
const HEALTH_TOTAL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
45
45
|
|
|
46
|
+
// 单调时钟(毫秒,整数)。轮询超时只关心"流逝",必须避开 Date.now() 的墙钟
|
|
47
|
+
// 跳变(NTP 同步、host suspend/resume、WSL2 vmtime sync)—— 这些跳变会让
|
|
48
|
+
// loop 误以为已经超时而提前退出
|
|
49
|
+
function monoNowMs() {
|
|
50
|
+
return Number(process.hrtime.bigint() / 1_000_000n);
|
|
51
|
+
}
|
|
52
|
+
|
|
46
53
|
/**
|
|
47
54
|
* 执行命令并返回 stdout;错误对象附带 stderr 以便诊断
|
|
48
55
|
* @param {string} cmd
|
|
@@ -149,12 +156,12 @@ export async function pollUpgradeHealth(toVersion, opts) {
|
|
|
149
156
|
const totalTimeout = opts?.totalTimeoutMs ?? HEALTH_TOTAL_TIMEOUT_MS;
|
|
150
157
|
/* c8 ignore next -- ?? fallback */
|
|
151
158
|
const pollInterval = opts?.pollIntervalMs ?? HEALTH_POLL_INTERVAL_MS;
|
|
152
|
-
const start =
|
|
159
|
+
const start = monoNowMs();
|
|
153
160
|
let attempts = 0;
|
|
154
161
|
let lastReason = '';
|
|
155
162
|
let lastVersion = '';
|
|
156
163
|
|
|
157
|
-
while (
|
|
164
|
+
while (monoNowMs() - start < totalTimeout) {
|
|
158
165
|
attempts += 1;
|
|
159
166
|
const result = await callUpgradeHealthOnce(opts);
|
|
160
167
|
if (result.ok) {
|
|
@@ -164,7 +171,7 @@ export async function pollUpgradeHealth(toVersion, opts) {
|
|
|
164
171
|
ok: true,
|
|
165
172
|
version: result.version,
|
|
166
173
|
attempts,
|
|
167
|
-
elapsedMs:
|
|
174
|
+
elapsedMs: monoNowMs() - start,
|
|
168
175
|
};
|
|
169
176
|
}
|
|
170
177
|
lastVersion = result.version;
|
|
@@ -174,14 +181,14 @@ export async function pollUpgradeHealth(toVersion, opts) {
|
|
|
174
181
|
lastReason = result.reason;
|
|
175
182
|
}
|
|
176
183
|
// 剩余时间不足以再等一个 interval 就直接退出,避免最后一次毫无意义的 sleep
|
|
177
|
-
if (
|
|
184
|
+
if (monoNowMs() - start + pollInterval >= totalTimeout) break;
|
|
178
185
|
await sleep(pollInterval);
|
|
179
186
|
}
|
|
180
187
|
|
|
181
188
|
return {
|
|
182
189
|
ok: false,
|
|
183
190
|
attempts,
|
|
184
|
-
elapsedMs:
|
|
191
|
+
elapsedMs: monoNowMs() - start,
|
|
185
192
|
lastReason,
|
|
186
193
|
lastVersion,
|
|
187
194
|
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claw-config.js — OpenClaw runtime config 的统一访问入口
|
|
3
|
+
*
|
|
4
|
+
* 设计原则:
|
|
5
|
+
* - 业务代码只调 getClawConfig(),不直接摸 rt.config,新旧 API 切换全在此处兜底
|
|
6
|
+
* - OpenClaw v2026.4.27+ 新 API `config.current()`;老 API `config.loadConfig()` 仍可用但触发 deprecation 警告
|
|
7
|
+
* - 两个 API 内部都返回同一个 getRuntimeConfig() 快照,字段语义一致
|
|
8
|
+
* - 异常不在此处吞,让调用方按需处理(取 token 与读账本兜底策略不同)
|
|
9
|
+
*
|
|
10
|
+
* 拆分触发:本文件超约 200 行,或某一类 host 适配独立成块且 ≥ 100 行时再拆出去;
|
|
11
|
+
* 否则 path 之外的 host 适配优先往本文件加(必要时改名 claw-host.js)
|
|
12
|
+
*/
|
|
13
|
+
import { getRuntime } from './runtime.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 读取当前 OpenClaw 运行时配置快照
|
|
17
|
+
*
|
|
18
|
+
* 优先 `config.current()`(v2026.4.27+),缺失时回落到 `config.loadConfig()`。
|
|
19
|
+
*
|
|
20
|
+
* @returns {object|null} runtime 未注入或缺 config 访问 API 时返回 null
|
|
21
|
+
*/
|
|
22
|
+
export function getClawConfig() {
|
|
23
|
+
const rt = getRuntime();
|
|
24
|
+
const reader = rt?.config?.current ?? rt?.config?.loadConfig;
|
|
25
|
+
if (!reader) return null;
|
|
26
|
+
return reader();
|
|
27
|
+
}
|
package/src/realtime-bridge.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import nodePath from 'node:path';
|
|
2
2
|
import { WebSocket as WsWebSocket } from 'ws';
|
|
3
3
|
|
|
4
|
+
import { getClawConfig } from './claw-config.js';
|
|
4
5
|
import { pluginDir } from './claw-paths.js';
|
|
5
6
|
import { clearConfig, getBindingsPath, readConfig } from './config.js';
|
|
6
7
|
import { cleanupResiduals as defaultCleanupResiduals, measureDiskCap as defaultMeasureDiskCap } from './rpc-queue-startup.js';
|
|
@@ -95,11 +96,7 @@ export function defaultResolveGatewayAuthToken() {
|
|
|
95
96
|
return envToken;
|
|
96
97
|
}
|
|
97
98
|
try {
|
|
98
|
-
const
|
|
99
|
-
if (!rt?.config?.loadConfig) {
|
|
100
|
-
return '';
|
|
101
|
-
}
|
|
102
|
-
const cfg = rt.config.loadConfig();
|
|
99
|
+
const cfg = getClawConfig();
|
|
103
100
|
const token = cfg?.gateway?.auth?.token;
|
|
104
101
|
return typeof token === 'string' && token.trim() ? token.trim() : '';
|
|
105
102
|
}
|
|
@@ -183,8 +180,11 @@ export class RealtimeBridge {
|
|
|
183
180
|
// runId → connId 路由表:用于 event:agent 帧按发起方单播。
|
|
184
181
|
// 实例延迟到 start() 真 logger 到位时再 new;stop() destroy 后置 null。
|
|
185
182
|
this.__runEventRoutes = null;
|
|
186
|
-
// rpc DC 文件回退队列的磁盘容量(B-stage1 plan-2 探测,
|
|
183
|
+
// rpc DC 文件回退队列的磁盘容量(B-stage1 plan-2 探测,B9b 在装配 FBQ 时取)
|
|
187
184
|
this.__diskCap = null;
|
|
185
|
+
// rpc DC 文件回退队列根目录(B-stage1 plan-2 已 cleanupResiduals 过;B9b 装配 FBQ 时取)。
|
|
186
|
+
// prep 失败时 null → webrtc-peer 装配点自动降级到 MemoryQueue
|
|
187
|
+
this.__queueDir = null;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
__resolveWebSocket() {
|
|
@@ -329,6 +329,10 @@ export class RealtimeBridge {
|
|
|
329
329
|
PeerConnection,
|
|
330
330
|
impl: this.__ndcPreloadResult?.impl,
|
|
331
331
|
logger: this.logger,
|
|
332
|
+
// B9b:webrtc-peer 装配 FBQ 时取 disk 容量 + 队列根目录;
|
|
333
|
+
// __queueDir 为 null 时(plan-2 prep 失败)自动降级到 MemoryQueue
|
|
334
|
+
getDiskCap: () => this.__diskCap,
|
|
335
|
+
queueDir: this.__queueDir,
|
|
332
336
|
});
|
|
333
337
|
}
|
|
334
338
|
/* c8 ignore stop */
|
|
@@ -1395,11 +1399,15 @@ export class RealtimeBridge {
|
|
|
1395
1399
|
const queueDir = nodePath.join(pluginDir(), 'rpc-queues');
|
|
1396
1400
|
await this.__cleanupRpcQueueResiduals(queueDir, { logger: this.logger });
|
|
1397
1401
|
this.__diskCap = await this.__measureRpcQueueDiskCap(queueDir, { logger: this.logger });
|
|
1402
|
+
// 只有 cleanupResiduals 成功 + measureDiskCap 完成后才暴露 queueDir 给 webrtc 装配;
|
|
1403
|
+
// 任一抛错都让 __queueDir 留 null,下游自动降级到 MemoryQueue
|
|
1404
|
+
this.__queueDir = queueDir;
|
|
1398
1405
|
}
|
|
1399
1406
|
catch (err) {
|
|
1400
1407
|
/* c8 ignore next -- ?./?? fallback:err 总是 Error,logger.warn 总存在 */
|
|
1401
1408
|
this.logger.warn?.(`[coclaw] rpc-queues startup prep failed (skipped): ${err?.message ?? err}`);
|
|
1402
1409
|
this.__diskCap = null;
|
|
1410
|
+
this.__queueDir = null;
|
|
1403
1411
|
}
|
|
1404
1412
|
// race 守卫:cleanup/measure 期间若 stop() 已执行,不应再启动 native WebRTC 进程。
|
|
1405
1413
|
// preload 后还有一道 started 检查兜底(含 pion cleanup),这里先挡住一次无意义的 preload。
|
|
@@ -35,8 +35,10 @@ class FileBackedQueue {
|
|
|
35
35
|
* @param {string} opts.id - 队列标识,字符集受限,防路径穿越
|
|
36
36
|
* @param {number} [opts.memBudget=8MB] - 内存持有字节数上限
|
|
37
37
|
* @param {number} [opts.diskCap=1GB] - 磁盘+内存总字节数硬上限(含 `\n`)
|
|
38
|
-
* @param {
|
|
38
|
+
* @param {number} [opts.maxMessageBytes=Infinity] - 单条硬上限;超过即 drop(bypass 也不豁免)
|
|
39
|
+
* @param {(reason: string, size: number, err?: Error) => void} [opts.onDrop] - 拒入队时的回调;'fs-error' 传底层 err,其它 reason 第三参为 undefined
|
|
39
40
|
* @param {{ warn?: Function, info?: Function, error?: Function }} [opts.logger=console]
|
|
41
|
+
* @param {(jsonStr: string) => boolean} [opts.bypassAdmission] - 白名单谓词,命中则容量层 admission 豁免(与 MemoryQueue 同义)
|
|
40
42
|
*/
|
|
41
43
|
constructor(opts) {
|
|
42
44
|
const {
|
|
@@ -44,8 +46,10 @@ class FileBackedQueue {
|
|
|
44
46
|
id,
|
|
45
47
|
memBudget = DEFAULT_MEM_BUDGET,
|
|
46
48
|
diskCap = DEFAULT_DISK_CAP,
|
|
49
|
+
maxMessageBytes = Infinity,
|
|
47
50
|
onDrop,
|
|
48
51
|
logger = console,
|
|
52
|
+
bypassAdmission,
|
|
49
53
|
} = opts ?? {};
|
|
50
54
|
|
|
51
55
|
if (!dir || typeof dir !== 'string') throw new TypeError('dir is required');
|
|
@@ -61,13 +65,19 @@ class FileBackedQueue {
|
|
|
61
65
|
if (!Number.isFinite(diskCap) || diskCap <= 0) {
|
|
62
66
|
throw new TypeError('diskCap must be a finite positive number');
|
|
63
67
|
}
|
|
68
|
+
if (maxMessageBytes !== Infinity && (!Number.isFinite(maxMessageBytes) || maxMessageBytes <= 0)) {
|
|
69
|
+
throw new TypeError('maxMessageBytes must be Infinity or a finite positive number');
|
|
70
|
+
}
|
|
64
71
|
|
|
65
72
|
this.dir = dir;
|
|
66
73
|
this.id = id;
|
|
67
74
|
this.memBudget = memBudget;
|
|
68
75
|
this.diskCap = diskCap;
|
|
76
|
+
this.maxMessageBytes = maxMessageBytes;
|
|
69
77
|
this.onDrop = onDrop;
|
|
70
78
|
this.logger = logger;
|
|
79
|
+
// 非函数(含 undefined / null / 字符串等)一律收编为 null,保持向后兼容
|
|
80
|
+
this.bypassAdmission = typeof bypassAdmission === 'function' ? bypassAdmission : null;
|
|
71
81
|
|
|
72
82
|
this.filePath = nodePath.join(dir, `${id}.jsonl`);
|
|
73
83
|
|
|
@@ -83,6 +93,9 @@ class FileBackedQueue {
|
|
|
83
93
|
this.fsBroken = false; // 粘性:一旦 FS 出错,不再尝试 reopen
|
|
84
94
|
this.writeStream = null;
|
|
85
95
|
this.writeErr = null;
|
|
96
|
+
// 粘性最新 fs 错误:__handleFsError 在 mutex 内缓存;后续走 fsBroken 短路的 enqueue 通过
|
|
97
|
+
// __dispatchDrop 第三参把 err 透传给 onDrop,让 monitor / 运维拿到具体 errno
|
|
98
|
+
this.lastFsErr = null;
|
|
86
99
|
this.waiters = [];
|
|
87
100
|
this.mutex = createMutex();
|
|
88
101
|
}
|
|
@@ -130,10 +143,20 @@ class FileBackedQueue {
|
|
|
130
143
|
|
|
131
144
|
const size = Buffer.byteLength(jsonStr, 'utf8');
|
|
132
145
|
|
|
146
|
+
// per-message 硬上限:bypass 也不豁免(红线 3:bypass 仅豁免容量层 admission)。
|
|
147
|
+
// 与 sender 端 MAX_SINGLE_MSG_BYTES 检查对齐——避免大帧先入队再被 sender 拒,
|
|
148
|
+
// 导致 backlog 异常膨胀且 oversize 不进 monitor 账(loud-on-loss)。
|
|
149
|
+
if (size > this.maxMessageBytes) {
|
|
150
|
+
this.__dispatchDrop('oversize', size);
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
133
154
|
// admission:按物理占用(mem + 已写文件总字节,含 \n)判定,保证 diskCap 是真正的硬上限。
|
|
134
155
|
// 用 writtenBytes(不减 readOffset)的含义:文件前缀已读但未被 __dropFile 回收前仍算占用。
|
|
135
156
|
// 代价:持续背压下消费者还没追到写端时新消息可能被 drop,直到完全 drain 触发 __dropFile 重置。
|
|
136
|
-
|
|
157
|
+
// bypassAdmission 命中时容量层豁免(与 MemoryQueue 一致):白名单消息可越过 diskCap 入队,
|
|
158
|
+
// 实际占用可能短暂超 diskCap——这是红线 3 的明确预期。物理 IO 失败仍会按 fs-error drop。
|
|
159
|
+
if (this.memBytes + this.writtenBytes + size + 1 > this.diskCap && !this.__isBypass(jsonStr)) {
|
|
137
160
|
this.__dispatchDrop('disk-cap', size);
|
|
138
161
|
return false;
|
|
139
162
|
}
|
|
@@ -150,9 +173,10 @@ class FileBackedQueue {
|
|
|
150
173
|
}
|
|
151
174
|
}
|
|
152
175
|
|
|
153
|
-
// 溢出路径:FS 已破直接 drop,不再尝试 reopen
|
|
176
|
+
// 溢出路径:FS 已破直接 drop,不再尝试 reopen。lastFsErr 由 __handleFsError 已粘性置好,
|
|
177
|
+
// 把它透传给 onDrop 第三参——红线 2 "丢失/延迟必须 loud 可观测" 要求把 errno 抬出去
|
|
154
178
|
if (this.fsBroken) {
|
|
155
|
-
this.__dispatchDrop('fs-error', size);
|
|
179
|
+
this.__dispatchDrop('fs-error', size, this.lastFsErr);
|
|
156
180
|
return false;
|
|
157
181
|
}
|
|
158
182
|
|
|
@@ -160,7 +184,7 @@ class FileBackedQueue {
|
|
|
160
184
|
await this.__openWriteStream();
|
|
161
185
|
if (this.writeErr) {
|
|
162
186
|
const err = this.writeErr;
|
|
163
|
-
this.__dispatchDrop('fs-error', size);
|
|
187
|
+
this.__dispatchDrop('fs-error', size, err);
|
|
164
188
|
// 前置 mkdir/rm 失败也进入粘性降级:与 stream 'error' 路径语义一致,
|
|
165
189
|
// 避免后续每次 overflow 都重试同一个持续性 FS 故障。
|
|
166
190
|
await this.__handleFsError(err);
|
|
@@ -176,7 +200,7 @@ class FileBackedQueue {
|
|
|
176
200
|
return true;
|
|
177
201
|
} catch (err) {
|
|
178
202
|
this.logger?.warn?.('fbq.enqueue fs-error', err);
|
|
179
|
-
this.__dispatchDrop('fs-error', size);
|
|
203
|
+
this.__dispatchDrop('fs-error', size, err);
|
|
180
204
|
// 直接在当前锁内触发粘性降级:真实 Node stream 下 cb err 通常也会 emit 'error'
|
|
181
205
|
// (监听器会另外排一次 handleFsError,但 fsBroken 已置 → no-op);测试里的 monkey-patch
|
|
182
206
|
// 只触发 cb、不发 'error',这里主动降级保证行为一致。
|
|
@@ -223,17 +247,33 @@ class FileBackedQueue {
|
|
|
223
247
|
this.spilled = false;
|
|
224
248
|
this.fsBroken = false;
|
|
225
249
|
this.writeErr = null;
|
|
250
|
+
this.lastFsErr = null;
|
|
226
251
|
});
|
|
227
252
|
}
|
|
228
253
|
|
|
229
254
|
/**
|
|
230
255
|
* 停写、关 FD、删文件、结束所有迭代器。幂等。
|
|
256
|
+
*
|
|
257
|
+
* @param {(residual: { memCount: number, memBytes: number, diskBytes: number, writtenBytes: number, spilled: boolean, fsBroken: boolean }) => void} [onBeforeClear]
|
|
258
|
+
* 可选回调(**必须为同步函数**):在 mutex 内、清空字段 / 关流 / 删文件**之前**触发,
|
|
259
|
+
* 参数是销毁时刻的 6 字段残留快照。存在意义:mutex 保证 in-flight enqueue 已落地;调用方
|
|
260
|
+
* sync 调 `queue.stats()` 看不到 in-flight 入队的消息,改用此回调可拿原子准确的残留快照。
|
|
261
|
+
* 回调同步抛错被 swallow,不影响 destroy 完成。
|
|
262
|
+
* **注意**:返回 Promise 的异步回调其 rejection 不会被捕获——仅设计为 sync 钩子(与 MemoryQueue 一致)。
|
|
231
263
|
*/
|
|
232
|
-
async destroy() {
|
|
264
|
+
async destroy(onBeforeClear) {
|
|
233
265
|
return await this.mutex.withLock(async () => {
|
|
234
266
|
if (this.destroyed) return;
|
|
235
267
|
this.destroyed = true;
|
|
236
268
|
|
|
269
|
+
// 在 mutex 内、清空 / 关流 / 删文件之前快照 6 字段残留:mutex 保证看到所有已入队的消息(含 in-flight);
|
|
270
|
+
// 异步 IO 清理(__closeWriteStream / fs.rm)会改 spilled/writtenBytes,所以快照必须先抓
|
|
271
|
+
if (typeof onBeforeClear === 'function') {
|
|
272
|
+
const residual = this.stats();
|
|
273
|
+
try { onBeforeClear(residual); }
|
|
274
|
+
catch { /* 回调自身抛是调用方的 bug;不能传染给 destroy 契约(与 MemoryQueue silent gotcha 镜像)*/ }
|
|
275
|
+
}
|
|
276
|
+
|
|
237
277
|
// 唤醒所有等待者,让它们在下一轮循环中看到 destroyed 并返回 done
|
|
238
278
|
const toWake = this.waiters.splice(0);
|
|
239
279
|
for (const w of toWake) w.resolve();
|
|
@@ -252,6 +292,7 @@ class FileBackedQueue {
|
|
|
252
292
|
this.writtenBytes = 0;
|
|
253
293
|
this.readOffset = 0;
|
|
254
294
|
this.spilled = false;
|
|
295
|
+
this.lastFsErr = null;
|
|
255
296
|
});
|
|
256
297
|
}
|
|
257
298
|
|
|
@@ -307,14 +348,24 @@ class FileBackedQueue {
|
|
|
307
348
|
for (const w of toWake) w.resolve();
|
|
308
349
|
}
|
|
309
350
|
|
|
310
|
-
__dispatchDrop(reason, size) {
|
|
351
|
+
__dispatchDrop(reason, size, err) {
|
|
311
352
|
try {
|
|
312
|
-
this.onDrop?.(reason, size);
|
|
313
|
-
} catch (
|
|
353
|
+
this.onDrop?.(reason, size, err);
|
|
354
|
+
} catch (cbErr) {
|
|
314
355
|
/* c8 ignore next 2 -- onDrop throwing is caller's bug */
|
|
315
|
-
this.logger?.warn?.('fbq.onDrop threw',
|
|
356
|
+
this.logger?.warn?.('fbq.onDrop threw', cbErr);
|
|
357
|
+
}
|
|
358
|
+
this.logger?.warn?.('fbq.drop', { reason, size, err: err?.message });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
__isBypass(jsonStr) {
|
|
362
|
+
if (!this.bypassAdmission) return false;
|
|
363
|
+
try {
|
|
364
|
+
return Boolean(this.bypassAdmission(jsonStr));
|
|
365
|
+
} catch {
|
|
366
|
+
// 谓词自身抛 → 视为非白名单(最安全的回退;保守 drop 而非误入队)
|
|
367
|
+
return false;
|
|
316
368
|
}
|
|
317
|
-
this.logger?.warn?.('fbq.drop', { reason, size });
|
|
318
369
|
}
|
|
319
370
|
|
|
320
371
|
async __openWriteStream() {
|
|
@@ -373,16 +424,17 @@ class FileBackedQueue {
|
|
|
373
424
|
});
|
|
374
425
|
}
|
|
375
426
|
|
|
376
|
-
// mutex 内调用:FS
|
|
377
|
-
async __handleFsError(
|
|
427
|
+
// mutex 内调用:FS 错误粘性降级。err 缓存到 lastFsErr 供后续 fsBroken 短路 enqueue 透传给 onDrop
|
|
428
|
+
async __handleFsError(err) {
|
|
378
429
|
if (this.destroyed || this.fsBroken) return;
|
|
379
430
|
this.fsBroken = true;
|
|
431
|
+
this.lastFsErr = err;
|
|
380
432
|
await this.__closeWriteStream();
|
|
381
433
|
try {
|
|
382
434
|
await fs.rm(this.filePath, { force: true });
|
|
383
|
-
} catch (
|
|
435
|
+
} catch (rmErr) {
|
|
384
436
|
/* c8 ignore next 2 -- rm with force rarely fails */
|
|
385
|
-
this.logger?.warn?.('fbq.handleFsError rm error',
|
|
437
|
+
this.logger?.warn?.('fbq.handleFsError rm error', rmErr);
|
|
386
438
|
}
|
|
387
439
|
this.spilled = false;
|
|
388
440
|
this.writtenBytes = 0;
|
|
@@ -1,10 +1,24 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
1
3
|
import { createReassembler } from './dc-chunking.js';
|
|
2
4
|
import { MemoryQueue } from '../utils/memory-queue.js';
|
|
5
|
+
import { FileBackedQueue } from '../utils/file-backed-queue.js';
|
|
3
6
|
import { RpcDcSender, DC_LOW_WATER_MARK, MAX_SINGLE_MSG_BYTES } from './rpc-dc-sender.js';
|
|
4
7
|
import { createRpcDropMonitor } from './rpc-drop-monitor.js';
|
|
5
8
|
import { isAgentRunResponse } from './agent-run-response.js';
|
|
6
9
|
import { remoteLog } from '../remote-log.js';
|
|
7
10
|
|
|
11
|
+
// rpc DC 发送队列实现选择(B-stage2 B9b)。
|
|
12
|
+
// - 'mem':MemoryQueue(当前生产默认)—— 不碰 fs,溢出即 drop;FBQ 未充分本地验证前先用此模式发布
|
|
13
|
+
// - 'fbq':FileBackedQueue —— 长时间后台 / ICE 恢复等慢消化场景溢出到磁盘
|
|
14
|
+
// 当 'fbq' 但 queueDir 不可用(bridge 启动期 plan-2 prep 失败)时自动降级到 'mem',避免阻塞 webrtc 装配。
|
|
15
|
+
// 单点常量;构造时可通过 `rpcQueueImpl` opt 覆盖(测试用)。生产侧改回 'fbq' 只需翻这一行。
|
|
16
|
+
const RPC_QUEUE_IMPL = 'mem';
|
|
17
|
+
|
|
18
|
+
// FBQ 装配兜底:bridge 启动期 measureDiskCap 失败 → __diskCap=null → 这里兜底 1GB
|
|
19
|
+
const ONE_GB = 1024 * 1024 * 1024;
|
|
20
|
+
const RPC_QUEUE_MEM_BUDGET = 10 * 1024 * 1024;
|
|
21
|
+
|
|
8
22
|
// 单个 session 内 file DC 历史快照的容量上限(满后按 FIFO 淘汰最老条目)。
|
|
9
23
|
// 用于诊断 dump:过大会撑爆 remoteLog 单帧,20 足以覆盖典型多文件传输会话。
|
|
10
24
|
const FILE_CHANNEL_HISTORY_LIMIT = 20;
|
|
@@ -31,8 +45,13 @@ export class WebRtcPeer {
|
|
|
31
45
|
* @param {object} [opts.logger] - pino 风格 logger
|
|
32
46
|
* @param {function} opts.PeerConnection - RTCPeerConnection 构造函数(由 ndc-preloader 提供)
|
|
33
47
|
* @param {string} [opts.impl] - WebRTC 实现标识(pion / ndc / werift)
|
|
48
|
+
* @param {() => (number|null)} [opts.getDiskCap] - 启动期测得的 diskCap 字节数;prep 失败返 null。
|
|
49
|
+
* FBQ 装配时取(兜底 1GB);MemoryQueue 装配不消费。
|
|
50
|
+
* @param {string} [opts.queueDir] - rpc DC 队列文件目录(FBQ 模式所需);空 / 非字符串时降级到 MemoryQueue
|
|
51
|
+
* @param {'fbq'|'mem'} [opts.rpcQueueImpl] - rpc 队列实现选择,默认取模块级 RPC_QUEUE_IMPL;
|
|
52
|
+
* 测试用——生产路径走默认即可
|
|
34
53
|
*/
|
|
35
|
-
constructor({ onSend, onRequest, onFileRpc, onFileChannel, logger, PeerConnection, impl }) {
|
|
54
|
+
constructor({ onSend, onRequest, onFileRpc, onFileChannel, logger, PeerConnection, impl, getDiskCap, queueDir, rpcQueueImpl }) {
|
|
36
55
|
if (!PeerConnection) {
|
|
37
56
|
throw new Error('PeerConnection constructor is required');
|
|
38
57
|
}
|
|
@@ -43,6 +62,12 @@ export class WebRtcPeer {
|
|
|
43
62
|
this.logger = logger ?? console;
|
|
44
63
|
this.__PeerConnection = PeerConnection;
|
|
45
64
|
this.__impl = impl ?? null;
|
|
65
|
+
// 非函数(含 undefined / null / 字符串等)一律收编为 null,调用时再做 null 兜底
|
|
66
|
+
this.__getDiskCap = typeof getDiskCap === 'function' ? getDiskCap : null;
|
|
67
|
+
// FBQ 文件根目录;非字符串 / 空字符串 → null → 装配时自动降级到 MemoryQueue
|
|
68
|
+
this.__queueDir = typeof queueDir === 'string' && queueDir.length > 0 ? queueDir : null;
|
|
69
|
+
// 队列实现选择:测试可显式覆盖;非 'fbq'/'mem' 一律收编为模块默认,避免误用
|
|
70
|
+
this.__rpcQueueImpl = (rpcQueueImpl === 'fbq' || rpcQueueImpl === 'mem') ? rpcQueueImpl : RPC_QUEUE_IMPL;
|
|
46
71
|
this.__rtcTag = impl ? `[coclaw/rtc:${impl}]` : '[coclaw/rtc]';
|
|
47
72
|
/** @type {Map<string, { pc: object, rpcChannel: object|null, rpcQueue: MemoryQueue|null, rpcDcSender: RpcDcSender|null, rpcConsumeLoop: Promise<void>|null, rpcDropMonitor: object|null, fileChannels: Set, remoteMaxMessageSize: number, nextMsgId: number }>} */
|
|
48
73
|
this.__sessions = new Map();
|
|
@@ -551,8 +576,8 @@ export class WebRtcPeer {
|
|
|
551
576
|
}
|
|
552
577
|
|
|
553
578
|
async __setupDataChannel(connId, dc) {
|
|
554
|
-
// rpc DC 发送流控:MemoryQueue
|
|
555
|
-
// (边沿日志 / 累计 / 汇总)+ RpcDcSender(分片 + 背压),通过消费循环串起来。
|
|
579
|
+
// rpc DC 发送流控:Queue(FileBackedQueue 默认 / MemoryQueue 降级;admission + bypass 白名单)
|
|
580
|
+
// + rpc-drop-monitor(边沿日志 / 累计 / 汇总)+ RpcDcSender(分片 + 背压),通过消费循环串起来。
|
|
556
581
|
// 广播 / sendTo / files sendFn 都向 queue.enqueue,sender 从 queue 拉。
|
|
557
582
|
const session = this.__sessions.get(connId);
|
|
558
583
|
|
|
@@ -673,20 +698,40 @@ export class WebRtcPeer {
|
|
|
673
698
|
if (session.rpcConsumeLoop) await session.rpcConsumeLoop.catch(() => { /* c8 ignore next -- 极冷防御 */ });
|
|
674
699
|
session.rpcDropMonitor = null;
|
|
675
700
|
}
|
|
676
|
-
// 创建 monitor。必须在 new
|
|
701
|
+
// 创建 monitor。必须在 new Queue 之前——Queue 的 onDrop 接 monitor.onDrop。
|
|
677
702
|
// monitor 是局部变量,stale 路径下函数返回后自然 GC,不挂 session 字段(无 drop 可汇总)。
|
|
678
703
|
const monitor = createRpcDropMonitor({ connId, logger: this.logger });
|
|
679
|
-
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
704
|
+
|
|
705
|
+
// queue 实例选择(B-stage2 B9b):默认取模块级 RPC_QUEUE_IMPL(当前 'mem');
|
|
706
|
+
// 'fbq' 模式下若 queueDir 不可用则降级到 mem,避免阻塞装配。
|
|
707
|
+
// 同 connId race 隔离(决策 4):FBQ id 加唯一后缀 ${connId}-${ts}-${nonce},
|
|
708
|
+
// 让新旧实例文件名物理不同,destroy/init 期间互不踩踏。MemoryQueue 不碰 fs,无此需求。
|
|
709
|
+
// connId 字符集(PRE-EXISTING 契约):上游 server 分配 connId 形如 `c_<digits>`;
|
|
710
|
+
// FBQ / MemoryQueue 共用 `^[A-Za-z0-9._-]+$` 校验。若 server 将来引入特殊字符,
|
|
711
|
+
// queue 构造会抛 TypeError,由 __setupDataChannel 的 .catch 兜底 warn——非 B9b 引入。
|
|
712
|
+
const useFbq = this.__rpcQueueImpl === 'fbq' && !!this.__queueDir;
|
|
713
|
+
const fbqFallback = !useFbq && this.__rpcQueueImpl === 'fbq';
|
|
714
|
+
const queue = useFbq
|
|
715
|
+
? new FileBackedQueue({
|
|
716
|
+
id: `${connId}-${Date.now()}-${randomUUID().slice(0, 8)}`,
|
|
717
|
+
dir: this.__queueDir,
|
|
718
|
+
memBudget: RPC_QUEUE_MEM_BUDGET,
|
|
719
|
+
diskCap: this.__getDiskCap?.() ?? ONE_GB,
|
|
720
|
+
maxMessageBytes: MAX_SINGLE_MSG_BYTES,
|
|
721
|
+
bypassAdmission: isAgentRunResponse,
|
|
722
|
+
onDrop: monitor.onDrop,
|
|
723
|
+
logger: this.logger,
|
|
724
|
+
})
|
|
725
|
+
: new MemoryQueue({
|
|
726
|
+
id: connId,
|
|
727
|
+
maxMessageBytes: MAX_SINGLE_MSG_BYTES,
|
|
728
|
+
bypassAdmission: isAgentRunResponse,
|
|
729
|
+
onDrop: monitor.onDrop,
|
|
730
|
+
logger: this.logger,
|
|
731
|
+
tag: `conn=${connId}`,
|
|
732
|
+
});
|
|
733
|
+
// FBQ.init 承担 fs 残留清理(含同 connId 唯一后缀文件,不会撞旧实例);MemoryQueue.init 是 no-op。
|
|
734
|
+
// await 期间可能发生 closeByConnId / 同 connId 二次 ondatachannel,因此后面必须身份重核才能赋字段。
|
|
690
735
|
await queue.init();
|
|
691
736
|
// 身份重核:init 期间 session 可能被 closeByConnId 从 Map 删除,或被同 connId 二次
|
|
692
737
|
// ondatachannel 把 rpcChannel 替换成新 dc。任一不再成立都视为 stale,destroy queue 后
|
|
@@ -696,6 +741,12 @@ export class WebRtcPeer {
|
|
|
696
741
|
await queue.destroy();
|
|
697
742
|
return;
|
|
698
743
|
}
|
|
744
|
+
// 装配成功后日志:让运维侧看到该 session 实际跑哪种 queue(特别是 fbq 降级到 mem 的场景)。
|
|
745
|
+
// 放在 stale 守卫之后——只对真正生效的 session 打 log,避免 stale 装配虚报一次。
|
|
746
|
+
// 单 session 只打一次,频率与连接频率挂钩——符合 remoteLog 红线(不高频)。
|
|
747
|
+
const queueImpl = useFbq ? 'fbq' : 'mem';
|
|
748
|
+
this.logger.info?.(`${this.__rtcTag} [${connId}] rpc queue impl=${queueImpl}${fbqFallback ? ' (fallback: queueDir unavailable)' : ''}`);
|
|
749
|
+
this.__remoteLog(`rtc.queue-impl conn=${connId} impl=${queueImpl}${fbqFallback ? ' fallback=queue-dir-null' : ''}`);
|
|
699
750
|
if ('bufferedAmountLowThreshold' in dc) {
|
|
700
751
|
dc.bufferedAmountLowThreshold = DC_LOW_WATER_MARK;
|
|
701
752
|
}
|