/** * 有界容器 —— 给插件里那些「只增不减」的映射表兜底。 * * # 为什么需要它 * * 桥是**常驻进程**(pi 的守护进程能跑几十天,opencode/DSH 的插件跟着平台一起活)。 * 里面每一张 `Map`/`Set` 都在回答「这条会话/这封邮件我处理过吗」,键来自外部 * 事件流 —— 会话数与邮件数随时间单调增长,键却没有出口。 * * 单条成本很小(uuid 键 + 短字符串值,几十到几百字节),所以它不是几小时内撑爆 * 内存的那种故障。实际形态是:跑够久之后进程里躺着几十万个再也不会被查到的条目, * 且 **GC 回收不了**(还被强引用着)。这类问题不会在开发和测试里出现, * 只在生产上跑了几周后表现为「重启一下就好了」。 * * # 淘汰策略:丢最久没被访问的 * * JS 的 `Map`/`Set` 保证插入顺序,所以「删掉再插入」等价于「移到队尾」。 * 读也算访问(`get`/`has` 会刷新顺序),于是长期活跃的会话不会因为条目老被丢掉 —— * 被淘汰的总是「很久没人问过」的那些。 * * # 上限分表定义,因为丢一条的后果差别很大 * * - `deliveredMails` 丢一条 → 那封邮件**理论上**可能被重复投递。但它防的两种 * 重复(心跳与 SSE 建连之间的窗口、SSE 断线重放)都发生在秒到分钟级, * 几千封之前的 mail_id 不可能再来 —— 淘汰是安全的。 * * - 会话级映射丢一条 → 那条会话下次来信时被当成新会话,平台侧上下文断掉。 * 这是**真的行为退化**,所以上限给得大得多,并且优先靠 `session_archived` * 主动清理,让上限只当兜底。 * * # 不要用它装「还在等结果的东西」 * * 待决权限询问(opencode 的 `pendingPermissions`、DSH 的 `pendingApprovals`) * 里存的是 `resolve` 回调。静默淘汰一条会让对应的 `await` **永远不返回** —— * 平台侧那次工具调用就挂死了。那些表有确定的清理路径(决策到达 / 超时 / 拆插件 * 时 fail closed),不该套上界。上界只适合「记录已经发生过的事实」的表。 * * 三平台共用,必须逐字节相同(deploy/check-shared-libs.sh 校验)。 */ /** * 已投递邮件 id 的记忆上限。 * * 2000 覆盖的是去重真正需要的时间窗:SSE 重放最多回放服务端环形缓冲的 500 条 * 事件,一次补拉最多 5 封。留 2000 是三个数量级的余量,内存代价约 200KB。 */ export const MAX_TRACKED_MAILS = 2000; /** * 会话级映射的条目上限。 * * 淘汰一条会让那条会话失去平台侧上下文,所以这个数字要远大于「同时在推进的 * 任务数」。500 条 × 每条几百字节 ≈ 150KB —— 便宜到没有理由抠。 * * 真正的清理来自 `session_archived`:会话归档后它的映射再无用处,那是确定性 * 时机;上限兜的是「一直不归档」。 */ export const MAX_TRACKED_SESSIONS = 500; function normalizeLimit(limit) { const n = Number(limit); // 上限必须是正整数:0 会让每次 set 之后立刻把自己淘汰掉(表恒空,去重全部 // 失效且不报错),NaN 会让 while 条件恒假(退化成无界)。两种都是静默的 // 错误行为,不如当场拒绝。 if (!Number.isFinite(n) || n < 1) { throw new RangeError(`有界容器的上限必须是 >= 1 的整数,收到 ${limit}`); } return Math.floor(n); } /** * 有界 Map,超过上限时丢弃最久未访问的条目。 * * 只实现桥里真正用到的那几个方法 —— 不做成 Map 的完整替身,那样会掩盖 * 「这张表是有界的」这个必须被看见的事实。 */ export class BoundedMap { /** @param {number} limit 条目上限 */ constructor(limit) { this.limit = normalizeLimit(limit); /** @type {Map} */ this.map = new Map(); /** 累计淘汰条数,观测用(日志里能看出上限是否设得太小)。 */ this.evicted = 0; } get size() { return this.map.size; } has(key) { return this.map.has(key); } /** * 取值并把该键移到队尾。 * * 读也算访问:一条会话只要还在收信就会被反复 get,不刷新的话它会因为 * 「插入得早」被淘汰 —— 那恰好淘汰了最该留的那些。 */ get(key) { if (!this.map.has(key)) return undefined; const value = this.map.get(key); this.map.delete(key); this.map.set(key, value); return value; } /** 取值但**不**刷新顺序。给「只是想看一眼」的场合。 */ peek(key) { return this.map.get(key); } set(key, value) { // 已存在时先删:Map 的 set 不改变已有键的位置,不删就刷不了活跃度。 if (this.map.has(key)) this.map.delete(key); this.map.set(key, value); while (this.map.size > this.limit) { const oldest = this.map.keys().next().value; this.map.delete(oldest); this.evicted++; } return this; } delete(key) { return this.map.delete(key); } clear() { this.map.clear(); } keys() { return this.map.keys(); } values() { return this.map.values(); } entries() { return this.map.entries(); } [Symbol.iterator]() { return this.map[Symbol.iterator](); } } /** * 有界 Set,超过上限时丢弃最久未访问的成员。 * * `has` 也刷新顺序:与 `BoundedMap.get` 同理。对 `deliveredMails` 这意味着 * 「刚被去重挡下的那封」会留得更久,正合语义。 */ export class BoundedSet { /** @param {number} limit 成员上限 */ constructor(limit) { this.limit = normalizeLimit(limit); /** @type {Set} */ this.set = new Set(); this.evicted = 0; } get size() { return this.set.size; } has(value) { if (!this.set.has(value)) return false; this.set.delete(value); this.set.add(value); return true; } /** 判断存在但**不**刷新顺序。 */ peek(value) { return this.set.has(value); } add(value) { if (this.set.has(value)) this.set.delete(value); this.set.add(value); while (this.set.size > this.limit) { const oldest = this.set.values().next().value; this.set.delete(oldest); this.evicted++; } return this; } delete(value) { return this.set.delete(value); } clear() { this.set.clear(); } values() { return this.set.values(); } [Symbol.iterator]() { return this.set[Symbol.iterator](); } }