@coclaw/openclaw-coclaw 0.19.2 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * rpc-queues/ 启动期预热(B-stage1 plan-2)。
3
+ *
4
+ * 提供两个 async 函数,均**永不抛**——bridge.start 不能被启动期 fs 操作阻断:
5
+ *
6
+ * - `cleanupResiduals(dir, opts)`:mkdir { recursive: true } → readdir →
7
+ * 按 `*.jsonl` 白名单逐个 unlink。任何子步失败均 warn 并跳过。
8
+ * 白名单确保不会误删邻近文件(dump 设计:保留账本类小文件的扩展位)。
9
+ *
10
+ * - `measureDiskCap(dir, opts)`:fs.statfs → 公式
11
+ * `min(1GB, max(64MB, floor(free × 0.5)))`;statfs 抛错或缺失(Node <18.15)
12
+ * 走 catch 路径回退固定 1GB。返回值由 bridge 暂存到 `__diskCap`,
13
+ * B-stage2 切 FBQ 时再消费(路径 TBD)。
14
+ *
15
+ * `fsOps` 注入仅供测试覆盖错误分支;生产路径默认 `fs.promises`,调用方不传。
16
+ *
17
+ * 行为契约(红线):
18
+ * - 不抛错,只 warn
19
+ * - 不递归删——白名单仅 `*.jsonl`
20
+ * - statfs 失败/缺失统一回退 1GB
21
+ */
22
+
23
+ import fs from 'node:fs/promises';
24
+ import nodePath from 'node:path';
25
+
26
+ export const ONE_GB = 1024 * 1024 * 1024;
27
+ export const SIXTY_FOUR_MB = 64 * 1024 * 1024;
28
+
29
+ /**
30
+ * @param {string} dir - 队列目录绝对路径
31
+ * @param {object} [opts]
32
+ * @param {object} [opts.logger] - pino 风格 logger(warn? / info? / error?)
33
+ * @param {object} [opts.fsOps] - fs.promises 兼容子集(mkdir/readdir/unlink),仅供测试
34
+ */
35
+ export async function cleanupResiduals(dir, { logger, fsOps = fs } = {}) {
36
+ try {
37
+ await fsOps.mkdir(dir, { recursive: true });
38
+ }
39
+ catch (err) {
40
+ /* c8 ignore next -- ?./?? fallback:err 总是 Error,.message 总存在 */
41
+ logger?.warn?.(`[coclaw] rpc-queues cleanup mkdir failed: ${err?.message ?? err}`);
42
+ return;
43
+ }
44
+
45
+ let names;
46
+ try {
47
+ names = await fsOps.readdir(dir);
48
+ }
49
+ catch (err) {
50
+ /* c8 ignore next -- ?./?? fallback:err 总是 Error,.message 总存在 */
51
+ logger?.warn?.(`[coclaw] rpc-queues cleanup readdir failed: ${err?.message ?? err}`);
52
+ return;
53
+ }
54
+
55
+ for (const name of names) {
56
+ // readdir 默认返回 string[],但若调用方注入 Buffer/Dirent 风格 mock,name.endsWith
57
+ // 会抛出冲过"模块永不抛"红线。生产路径不会触发——纯防御。
58
+ if (typeof name !== 'string') {
59
+ logger?.warn?.(`[coclaw] rpc-queues unexpected non-string entry: ${typeof name}`);
60
+ continue;
61
+ }
62
+ if (!name.endsWith('.jsonl')) continue;
63
+ // nodePath.join 与 unlink 共享同一 try/catch:dir 若误传非 string(生产路径不会,
64
+ // 但 typeof readdir 防御已挡 name 那一头),nodePath.join(dir, name) 会抛 TypeError,
65
+ // 必须落在同一个 catch 里兜住才不破"模块永不抛"红线。
66
+ try {
67
+ const p = nodePath.join(dir, name);
68
+ await fsOps.unlink(p);
69
+ }
70
+ catch (err) {
71
+ /* c8 ignore next -- ?./?? fallback:err 总是 Error,.message 总存在 */
72
+ logger?.warn?.(`[coclaw] rpc-queues unlink failed file=${name} err=${err?.message ?? err}`);
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * @param {string} dir - 队列目录绝对路径(statfs 自动定位所在文件系统)
79
+ * @param {object} [opts]
80
+ * @param {object} [opts.logger]
81
+ * @param {object} [opts.fsOps] - fs.promises 兼容子集(statfs),仅供测试
82
+ * @returns {Promise<number>} diskCap 字节数;statfs 失败回退 1GB
83
+ */
84
+ export async function measureDiskCap(dir, { logger, fsOps = fs } = {}) {
85
+ try {
86
+ const st = await fsOps.statfs(dir);
87
+ const free = Number(st.bavail) * Number(st.bsize);
88
+ // 真实生产环境(容器、网络挂载、ENOSYS 走 catch 之外的怪环境)下 statfs 偶有
89
+ // 返回非 number / NaN / 负数字段的情况;Number(NaN/undefined) 乘任何东西都是 NaN,
90
+ // floor(NaN * 0.5) = NaN,max/min 链路也会冒泡 NaN——不防御会让 __diskCap 为 NaN。
91
+ if (!Number.isFinite(free) || free < 0) {
92
+ /* c8 ignore next -- ?./?? fallback */
93
+ logger?.warn?.(`[coclaw] rpc-queues statfs failed (non-finite, fallback 1GB): bavail=${st?.bavail} bsize=${st?.bsize}`);
94
+ return ONE_GB;
95
+ }
96
+ return Math.min(ONE_GB, Math.max(SIXTY_FOUR_MB, Math.floor(free * 0.5)));
97
+ }
98
+ catch (err) {
99
+ /* c8 ignore next -- ?./?? fallback:err 总是 Error 或 TypeError,.message 总存在 */
100
+ logger?.warn?.(`[coclaw] rpc-queues statfs failed (fallback 1GB): ${err?.message ?? err}`);
101
+ return ONE_GB;
102
+ }
103
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * runId → connId 路由表(已集成于 realtime-bridge)
3
+ *
4
+ * 用途:把 OpenClaw gateway 推来的 `event:agent` 帧按 runId 单播给真正发起这个 run 的 DC,
5
+ * 避免多 PC 场景下"广播给所有连过来的 rpc DC"导致死 PC 也收到的问题。
6
+ *
7
+ * 设计要点:
8
+ * - 构造函数纯组装,无副作用;起 timer 走 init(),停 timer 走 destroy()
9
+ * - destroy 后 add / remove / lookup / clear / init 全是 no-op
10
+ * - add 写入策略:runId 不在表写入;同 reqId 刷新 expireAt;不同 reqId 跳过覆盖(首发优先)
11
+ * - remove 删除策略:runId 在表 + entry.reqId === 入参 reqId 才删(防跨 RPC 巧合 runId 误删)
12
+ * - lookup hot path:不顺手清过期,TTL 由 scan timer 负责
13
+ * - scan 整段 try/catch 兜底,logger 抛错也不能击穿 gateway 进程
14
+ * - timer 必须 unref()——避免 hold 进程退出
15
+ *
16
+ * 与 reqId 路由表(realtime-bridge.js __dcPendingRequests)保持行为对齐:
17
+ * 对 PC 关闭不做联动清理,TTL 兜底;网关 WS 断开走 clear()。
18
+ */
19
+
20
+ /** 路由条目最大存活时间(24h),与 reqId 表对齐 */
21
+ export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
22
+ /** 整表周期扫描间隔(1h),与 reqId 表对齐 */
23
+ export const DEFAULT_SCAN_MS = 60 * 60 * 1000;
24
+
25
+ export class RunEventRoutes {
26
+ /**
27
+ * @param {object} [opts]
28
+ * @param {{ info?: Function, warn?: Function, error?: Function, debug?: Function }} [opts.logger=console]
29
+ * @param {number} [opts.ttlMs=DEFAULT_TTL_MS]
30
+ * @param {number} [opts.scanMs=DEFAULT_SCAN_MS]
31
+ */
32
+ constructor({ logger, ttlMs, scanMs } = {}) {
33
+ this.logger = logger ?? console;
34
+ this.ttlMs = ttlMs ?? DEFAULT_TTL_MS;
35
+ this.scanMs = scanMs ?? DEFAULT_SCAN_MS;
36
+ /** @type {Map<string, { connId: string, reqId: string, expireAt: number }>} */
37
+ this.__entries = new Map();
38
+ this.__scanTimer = null;
39
+ this.__destroyed = false;
40
+ }
41
+
42
+ /** 起周期扫描 timer。重入安全:已 init / 已 destroy 均 no-op */
43
+ init() {
44
+ if (this.__destroyed) return;
45
+ if (this.__scanTimer) return;
46
+ this.__scanTimer = setInterval(() => this.__scanExpired(), this.scanMs);
47
+ this.__scanTimer.unref?.();
48
+ }
49
+
50
+ /**
51
+ * 添加路由条目。任一参数 falsy 静默返回(防御性)。
52
+ * @param {string} runId
53
+ * @param {string} connId
54
+ * @param {string} reqId
55
+ */
56
+ add(runId, connId, reqId) {
57
+ if (this.__destroyed) return;
58
+ if (!runId || !connId || !reqId) return;
59
+ const existing = this.__entries.get(runId);
60
+ const expireAt = Date.now() + this.ttlMs;
61
+ if (existing && existing.reqId !== reqId) {
62
+ // 已被首发占用,跳过覆盖(防 attach 抢路由)。debug 日志便于观察。
63
+ // logger.debug 自身抛错也不能让 add 失败(与 __scanExpired 内的 try/catch 风格一致)
64
+ try { this.logger.debug?.(`[run-event-routes] add skipped: runId already routed by reqId=${existing.reqId} new reqId=${reqId}`); }
65
+ catch { /* logger 自身坏了不能让 add 抛 */ }
66
+ return;
67
+ }
68
+ // 同 reqId 重发:仅刷新 expireAt,connId 锁死在首发值(首发优先的彻底化)
69
+ if (existing) {
70
+ existing.expireAt = expireAt;
71
+ return;
72
+ }
73
+ this.__entries.set(runId, { connId, reqId, expireAt });
74
+ }
75
+
76
+ /**
77
+ * 移除路由条目。runId 在表且 entry.reqId === 入参 reqId 才删。
78
+ * @param {string} runId
79
+ * @param {string} reqId
80
+ */
81
+ remove(runId, reqId) {
82
+ if (this.__destroyed) return;
83
+ if (!runId || !reqId) return;
84
+ const entry = this.__entries.get(runId);
85
+ if (!entry) return;
86
+ if (entry.reqId !== reqId) return;
87
+ this.__entries.delete(runId);
88
+ }
89
+
90
+ /**
91
+ * 查路由 → connId 或 undefined。不顺手清过期。
92
+ * @param {string} runId
93
+ * @returns {string | undefined}
94
+ */
95
+ lookup(runId) {
96
+ if (this.__destroyed) return undefined;
97
+ if (!runId) return undefined;
98
+ const entry = this.__entries.get(runId);
99
+ return entry?.connId;
100
+ }
101
+
102
+ /** 整表清空。不动 timer(语义=网关 WS 断开后清表,保留 scan 给后续 init 用)*/
103
+ clear() {
104
+ if (this.__destroyed) return;
105
+ this.__entries.clear();
106
+ }
107
+
108
+ /** 停 timer + clear + 标 destroyed。幂等。*/
109
+ destroy() {
110
+ if (this.__destroyed) return;
111
+ this.__destroyed = true;
112
+ if (this.__scanTimer) {
113
+ clearInterval(this.__scanTimer);
114
+ this.__scanTimer = null;
115
+ }
116
+ this.__entries.clear();
117
+ }
118
+
119
+ /** 内部扫描过期条目;try/catch 兜底,避免 timer 回调异常击穿 gateway 进程 */
120
+ __scanExpired() {
121
+ try {
122
+ const now = Date.now();
123
+ let cleaned = 0;
124
+ for (const [runId, entry] of this.__entries) {
125
+ if (entry.expireAt <= now) {
126
+ this.__entries.delete(runId);
127
+ cleaned += 1;
128
+ }
129
+ }
130
+ if (cleaned > 0) {
131
+ this.logger.warn?.(`[run-event-routes] expired entries cleaned: count=${cleaned}`);
132
+ }
133
+ }
134
+ catch {
135
+ // 扫描器自身异常静默吞掉,避免拖垮 gateway(如 logger.warn 抛错)
136
+ }
137
+ }
138
+ }
@@ -1,7 +1,8 @@
1
1
  import fs from 'node:fs';
2
- import os from 'node:os';
3
2
  import nodePath from 'node:path';
4
3
 
4
+ import { agentSessionsDir, sessionStorePath, sessionTranscriptPath } from '../claw-paths.js';
5
+
5
6
  const DERIVED_TITLE_MAX_LEN = 60;
6
7
 
7
8
  // OC 注入的 inbound metadata 头部(Conversation info / Sender / Thread starter 等)
@@ -172,18 +173,22 @@ function deriveTitle(filePath, logger) {
172
173
  }
173
174
 
174
175
  export function createSessionManager(options = {}) {
175
- const rootDir = options.rootDir ?? nodePath.join(os.homedir(), '.openclaw', 'agents');
176
176
  /* c8 ignore next */
177
177
  const logger = options.logger ?? console;
178
+ const resolveSessionsDir = options.resolveSessionsDir ?? agentSessionsDir;
179
+ const resolveStorePath = options.resolveStorePath ?? sessionStorePath;
180
+ const resolveTranscriptPath = options.resolveTranscriptPath ?? sessionTranscriptPath;
178
181
 
179
182
  function sessionsDir(agentId = 'main') {
180
183
  /* c8 ignore next */
181
184
  const aid = typeof agentId === 'string' && agentId.trim() ? agentId.trim() : 'main';
182
- return nodePath.join(rootDir, aid, 'sessions');
185
+ return resolveSessionsDir(aid);
183
186
  }
184
187
 
185
188
  function readIndex(agentId = 'main') {
186
- const file = nodePath.join(sessionsDir(agentId), 'sessions.json');
189
+ /* c8 ignore next */
190
+ const aid = typeof agentId === 'string' && agentId.trim() ? agentId.trim() : 'main';
191
+ const file = resolveStorePath(aid);
187
192
  const data = readJsonSafe(file, {});
188
193
  /* c8 ignore next */
189
194
  if (!data || typeof data !== 'object') return {};
@@ -279,7 +284,7 @@ export function createSessionManager(options = {}) {
279
284
  const dir = sessionsDir(agentId);
280
285
  // live 文件优先:同一 sessionId 可能同时存在 live 和 reset 文件
281
286
  // (OpenClaw reset 后复用 sessionId),live 代表当前活跃 transcript
282
- const livePath = nodePath.join(dir, `${sessionId}.jsonl`);
287
+ const livePath = resolveTranscriptPath(sessionId, agentId);
283
288
  if (fs.existsSync(livePath)) {
284
289
  return livePath;
285
290
  }
package/src/settings.js CHANGED
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import os from 'node:os';
3
3
  import nodePath from 'node:path';
4
4
 
5
- import { resolveStateDir, CHANNEL_ID } from './config.js';
5
+ import { pluginDir } from './claw-paths.js';
6
6
  import { atomicWriteJsonFile } from './utils/atomic-write.js';
7
7
  import { createMutex } from './utils/mutex.js';
8
8
 
@@ -12,7 +12,7 @@ export const MAX_NAME_LENGTH = 63;
12
12
  const settingsMutex = createMutex();
13
13
 
14
14
  function getSettingsPath() {
15
- return nodePath.join(resolveStateDir(), CHANNEL_ID, SETTINGS_FILENAME);
15
+ return nodePath.join(pluginDir(), SETTINGS_FILENAME);
16
16
  }
17
17
 
18
18
  async function readJsonSafe(filePath) {
@@ -1,8 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
- import os from 'node:os';
3
2
  import nodePath from 'node:path';
4
3
  import { randomUUID } from 'node:crypto';
5
4
 
5
+ import { agentSessionsDir } from '../claw-paths.js';
6
6
  import { atomicWriteJsonFile } from '../utils/atomic-write.js';
7
7
  import { createMutex } from '../utils/mutex.js';
8
8
 
@@ -21,16 +21,15 @@ function emptyStore() {
21
21
  export class TopicManager {
22
22
  /**
23
23
  * @param {object} [opts]
24
- * @param {string} [opts.rootDir] - agents 根目录,默认 ~/.openclaw/agents
25
24
  * @param {object} [opts.logger]
25
+ * @param {Function} [opts.resolveSessionsDir] - 测试注入:自定义 sessions 目录解析
26
26
  * @param {Function} [opts.readFile] - 测试注入
27
27
  * @param {Function} [opts.writeJsonFile] - 测试注入
28
28
  * @param {Function} [opts.unlinkFile] - 测试注入
29
29
  * @param {Function} [opts.copyFile] - 测试注入
30
30
  */
31
31
  constructor(opts = {}) {
32
- /* c8 ignore next 6 -- ?? fallback:测试始终注入 */
33
- this.__rootDir = opts.rootDir ?? nodePath.join(os.homedir(), '.openclaw', 'agents');
32
+ this.__resolveSessionsDir = opts.resolveSessionsDir ?? agentSessionsDir;
34
33
  this.__logger = opts.logger ?? console;
35
34
  this.__readFile = opts.readFile ?? fs.readFile;
36
35
  this.__writeJsonFile = opts.writeJsonFile ?? atomicWriteJsonFile;
@@ -45,7 +44,7 @@ export class TopicManager {
45
44
  }
46
45
 
47
46
  __sessionsDir(agentId) {
48
- return nodePath.join(this.__rootDir, agentId, 'sessions');
47
+ return this.__resolveSessionsDir(agentId);
49
48
  }
50
49
 
51
50
  __topicsFilePath(agentId) {
@@ -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 {(reason: string, size: number) => void} [opts.onDrop] - 拒入队时的回调
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
- if (this.memBytes + this.writtenBytes + size + 1 > this.diskCap) {
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 (err) {
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', err);
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(_err) {
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 (err) {
435
+ } catch (rmErr) {
384
436
  /* c8 ignore next 2 -- rm with force rarely fails */
385
- this.logger?.warn?.('fbq.handleFsError rm error', err);
437
+ this.logger?.warn?.('fbq.handleFsError rm error', rmErr);
386
438
  }
387
439
  this.spilled = false;
388
440
  this.writtenBytes = 0;