Skip to content

fix(cli): 稳定性专项——ENOBUFS、jlist 超时、恢复广播抑制、loopback 放宽、版本错配警告 - #1005

Open
ITcathyh wants to merge 7 commits into
deepcoldy:masterfrom
ITcathyh:sprint-stability
Open

fix(cli): 稳定性专项——ENOBUFS、jlist 超时、恢复广播抑制、loopback 放宽、版本错配警告#1005
ITcathyh wants to merge 7 commits into
deepcoldy:masterfrom
ITcathyh:sprint-stability

Conversation

@ITcathyh

Copy link
Copy Markdown
Contributor

PR 1: sprint-stability(稳定性修复 5 项)

改了什么

来自「Botmux 交流群」近 7 天用户反馈的稳定性专项,5 个修复:

  1. fix(cli): ps 进程表扫描增加 maxBuffer 防止 ENOBUFS

    • macOS 进程表输出超 Node 默认 1MiB 时 spawnSync('ps') 抛 ENOBUFS,导致 botmux start/stop/restart fail-closed。统一给 ps/powershell 进程表扫描加 maxBuffer: 32MiB(cli.ts、reasonix、zellij-backend、adopt-route、session-discovery、codex-rpc-engine)。
  2. fix(cli): pm2 jlist 超时改为 env 可配置并放宽钳制

    • 高负载机器 pm2 jlist 需 9-18s,写死的 10s 超时导致 restart 安全检查间歇失败。新增 BOTMUX_PM2_JLIST_TIMEOUT_MS(默认 30s,clamp 1-120s),两处 Math.min(10_000, ...) 钳制放宽到 60s。
  3. fix(worker-pool): 恢复模式下抑制启动失败卡片广播

    • daemon 重启批量恢复会话时,每个失败 worker 都往自己会话发「会话启动失败」卡片,形成广播。notifyStartupFailuresuppressRecoveryCard 期间只写 daemon 日志、不发卡片(抑制解除后新一代失败仍可通知)。
  4. fix(dashboard): loopback 判定支持自定义放行地址

    • 定制内核把 loopback peer 上报成 LAN IP,3 处 HMAC 认证点(dashboard/auth、daemon-internal-auth、workflows/v3/daemon-ipc-auth)严格 127.0.0.1/::1 判定导致 401。新建 src/utils/loopback-peers.ts,env BOTMUX_LOOPBACK_PEERS(IP/CIDR)额外放行,默认行为逐字节等价,非法项 fail-closed 忽略。
  5. feat(cli): status 检测 CLI 与 daemon 版本错配

    • 升级 npm 包后 daemon 仍跑旧代码,新功能静默失效(本周 5+ 人次反馈)。DaemonDescriptorversion 字段,botmux status 检测到错配时打印醒目警告并提示 restart。旧 daemon 无字段时跳过(向后兼容)。

影响面

  • 跨平台:ps(Linux/macOS)+ powershell(Windows)双路径;loopback 纯 bigint 计算无平台依赖
  • 跨 CLI:maxBuffer 改动只影响失败路径,成功路径输出不变;其余不触及适配器运行时
  • 跨后端:恢复广播抑制影响所有后端的恢复路径;zellij-backend 改动仅 zellij 用户
  • 跨会话类型:恢复抑制对话题/群/adopt 一视同仁,suppressRecoveryCard 解除后行为恢复
  • 公共层:loopback 3 处认证点默认零变化;descriptor 加可选字段,所有既有读者向后兼容

测试验证

  • pnpm build 通过(tsc + dashboard bundle + audit)
  • 新增 5 个测试文件(pm2-ps-enobufs / pm2-jlist / restart-shutdown-preflight / worker-startup-suppress-recovery / loopback-peers / daemon-version-mismatch),64/64 全绿
  • pnpm test 全量:18180 passed;3 个失败(bwrap MCP 沙箱 ×2、dsh 文件沙箱 ×1)已在干净基线 0517705 复现确认为本机环境预存问题

huangyuhang.edu and others added 6 commits August 25, 2026 19:11
listPm2GodDaemonPids 的 ps/powershell 扫描及各适配器(reasonix、
zellij、adopt-route、session-discovery、codex-rpc-engine)的 ps 回退
路径均未设 maxBuffer,Node 默认 1MiB,macOS 进程表超 1MiB 时抛
ENOBUFS,导致 start/stop/restart 的 God 查重 fail-closed。统一显式
传 32MiB(与 browser-restart 一致),真 ENOBUFS 仍 fail-closed 抛出。

Co-Authored-By: Claude <noreply@anthropic.com>
高负载机器 pm2 jlist 实测需 9-18s,历史写死 10s 让 restart 安全检查
间歇误判失败。新增 BOTMUX_PM2_JLIST_TIMEOUT_MS(默认 30s,clamp
1s-120s),pm2Capture、readLivePm2Projection、
readVerifiedBotmuxPm2Projection 默认值统一读它;两处 Math.min(10_000)
钳制上限放宽到 60s。preflight 复制解析逻辑而非 import cli.ts 以避免
循环依赖(cli.ts 反向 import preflight)。

Co-Authored-By: Claude <noreply@anthropic.com>
notifyStartupFailure 只检查 failureNotified 和 managedAuxUiSuppressed,
不检查 suppressRecoveryCard。daemon 重启批量恢复会话时每个失败 worker
都往自己会话发「会话启动失败」卡片,形成广播风暴。现在在
failureNotified 检查之后短路:恢复模式下只写 daemon 日志、不发卡片;
故意不置 failureNotified,待抑制解除后新一代失败仍可正常通知。

Co-Authored-By: Claude <noreply@anthropic.com>
三处内部 HMAC 端点(dashboard verifyHmac、daemon-internal-auth、
v3 daemon-ipc-auth)写死 127.0.0.1/::1/::ffff:127.0.0.1 字面量检查,
定制内核把 loopback peer 上报成 LAN IP 时内部请求全 401。抽出
utils/loopback-peers.ts:默认语义逐字节保留,BOTMUX_LOOPBACK_PEERS
(逗号分隔 IP/CIDR,IPv4/IPv6 双栈)额外放行,非法项 fail-closed
忽略并告警。

Co-Authored-By: Claude <noreply@anthropic.com>
DaemonDescriptor 增加 version 字段(daemon 启动时从 package.json
写入),botmux status 读取在线 daemon 描述符并与 CLI 版本比较,
不一致时打印醒目警告提示 restart。旧 daemon 无 version 字段自动
跳过(向后兼容),源码 checkout(0.0.0)跳过避免噪音。

Co-Authored-By: Claude <noreply@anthropic.com>
- browser-restart 的 ps 进程表扫描补 maxBuffer 32MiB(统一 ENOBUFS 清扫)
- dashboard-ipc-server /api/sessions/migrate-to-chat 迁移到 isLoopbackPeer,
  BOTMUX_LOOPBACK_PEERS 放行地址对该端点同样生效
- worker-pool suppressRecoveryCard 注释修正:owner 并非总能收到恢复 DM
  (崩溃/pm2 自动重启无 breadcrumb),失败写 daemon 日志、下一轮用户消息兜底

Co-Authored-By: Claude <noreply@anthropic.com>
@ITcathyh
ITcathyh requested a review from deepcoldy as a code owner August 25, 2026 12:21

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 182414f482

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/core/worker-pool.ts
// (rememberLastCliInput) so later failures notify normally. Do NOT set
// failureNotified here: a later generation failing after the flag clears
// must still surface.
if (ds.suppressRecoveryCard) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Suppress fork-level recovery failures as well

When restoration fails at the child-process fork boundary (for example, spawn EAGAIN before worker IPC is established), execution never reaches this guard: the separate normal and adopt worker.on('error') handlers still call sessionReply without checking suppressRecoveryCard. Under resource pressure during batch restore, this can therefore still post one startup-failure message per restored normal/adopt session—the broadcast storm this change is intended to prevent—so both fork-error paths need the same chat-notification suppression while retaining their logging and lifecycle handling.

AGENTS.md reference: AGENTS.md:L68-L68

Useful? React with 👍 / 👎.

@deepcoldy

Copy link
Copy Markdown
Owner

感谢这个稳定性专项 PR,5 个修复都是近期用户真实反馈的痛点,方向很好 👍 我们做了一轮自动评审 + 交叉复核,绝大部分逻辑扎实(默认行为字节等价、fail-closed、向后兼容都做得很到位)。有一处建议在合入前修,其余是可选 nit,具体如下。

🟠 建议合前修:Fix3 恢复抑制——queued 两条 refork 路径的 flag 永不清除

Fix3 的核心不变量(notifyStartupFailure 不设 failureNotified)是对的,注释里"下一条真实用户轮会让失败重新浮上水面"这个契约在主 refork 路径daemon.ts:20579 fork 后无条件 rememberLastCliInput 清 flag)和 live-worker 路径daemon.ts:20278)都成立。

但在两条 queued refork 路径上不成立:

  • daemon.ts:20383 queuedActivationPending 分支
  • daemon.ts:20411 retainedQueuedActivation 分支

这两条 forkWorker(...) 后直接 return,中间不调用 rememberLastCliInput(全仓唯一清 suppressRecoveryCard 的点是 session-manager.ts:1706),所以 flag 永不清除。

失败链(复合条件,production 可达):

  1. 重启恢复的 ordinary 会话,restoresuppressRecoveryCard=truesession-manager.ts:2107/2309 无条件设置)且带 queuedActivationPending
  2. worker 死掉,用户发消息触发上述 queued refork;
  3. forkWorker 同步返回 true(异步 spawn),随后 worker 'error'queuedActivationTurnId
  4. scheduleTransientStartupRetryturnId 非空直接返回 false(worker-pool.ts:10068),不自治重试;
  5. notifyStartupFailuresuppressRecoveryCard 静默(仅写日志);fork 没抛同步错 → 两条路径的 catch 不触发 → 也没有 ordinary ingress notice。

结果:用户零反馈,且后续每条消息重复静默吞掉——体验上比"报错卡片"更糟(用户以为消息发出去了)。

建议修法(很小):在这两条 queued 路径用户触发 refork 时清 ds.suppressRecoveryCard = undefined(用户已介入 = 恢复结束)。注意不要改成补一个 rememberLastCliInput —— daemon.ts:20574-20578 的注释明确说明 rememberLastCliInput 必须放在 fork 成功之后,否则 fork throw 时 lastCliInput 会被当毒记录、后续 retry 误 --resume 破坏空启动契约。只清 suppressRecoveryCard、不碰 lastCliInput 最干净。补一个覆盖"queued 恢复 refork 后失败仍通知"的回归测试就更稳了。

🟡 可选 nit(不阻断)

  1. Fix4 migrate-to-chat 端点:从精确 === '::ffff:127.0.0.1' 改成走 isLoopbackPeer(内部用 endsWith)算轻微放宽。我们确认不可利用(IPC server 硬绑 127.0.0.1,内核不会投递公网 IPv6;且该端点还有 live-daemon 注册表 + owner union_id 双重防护),仅提示知悉。
  2. Fix2restart-shutdown-preflight.ts 里复制的那份 pm2JlistTimeoutMs() 的 clamp 上界没有独立测试(等价源 cli.ts 那份已被 pm2-jlist.test.ts 覆盖有效)。可选补一个。

✅ 其余确认无问题

  • Fix1(maxBuffer/ENOBUFS):7 处只影响输出超 1MiB 的失败路径,成功路径字节等价。
  • Fix2(jlist 超时):两份复制逻辑逐字节一致,注释说明了避循环依赖的理由。
  • Fix4(loopback-peers):IPv6/CIDR/IPv4-mapped 解析严格,默认行为与旧内联检查逐字节一致,fail-closed。
  • Fix5(版本错配):旧 daemon 无 version 字段、源码态 0.0.0 都正确跳过,向后兼容完备。
  • 6 个新测试 64/64 绿;相关认证 + worker-startup 等既有套件复跑无回归;pnpm build pass;对当前 master trial merge 0 冲突。

以上为自动评审的初步意见,供参考,最终以维护者审阅为准。 🙏

@deepcoldy

Copy link
Copy Markdown
Owner

补充一个 Fix3 修法的实现细节(供参考):清 ds.suppressRecoveryCard时序建议对齐主 refork 路径 daemon.ts:20579 的模式——放在 forkWorker(...) 被接受之后(同步 return、未 throw)再清,而不是 fork 之前。

  • fork 若同步 throw:flag 仍在,catch rethrow 走到 notifyOrdinaryIngressFailure,ingress notice 通道不受影响;
  • fork 被接受后:结束静默,后续 worker 异步失败会正常通知(清 flag 已在异步失败之前执行)。

核心 bug 场景(fork 被接受 + 异步失败)两种时序都能修好,放在 fork 后只是更干净、且和主路径保持一致。清 flag 后 streaming card 恢复是预期行为(用户已介入 = 恢复结束)。

重启恢复的 ordinary 会话带 suppressRecoveryCard=true 且有 queued
activation 时,queuedActivationPending / retainedQueuedActivation 两条
refork 路径 forkWorker 后直接 return,从不经过 rememberLastCliInput
(全仓唯一常规清 flag 点),flag 永不清除。复合失败链:worker 死后用户
发消息触发 queued refork → fork 同步被接受、随后异步 'error' 携带
queuedActivationTurnId → scheduleTransientStartupRetry 因 turnId 非空
不自治重试 → notifyStartupFailure 被静默 flag 吞掉、fork 未同步抛错
也没有 ingress notice——用户零反馈,且后续每条消息都被静默吞掉。

两条路径在 forkWorker 同步返回后(未 throw)显式
ds.suppressRecoveryCard = undefined:用户已介入即恢复静默结束;只清
恢复标志、不补 rememberLastCliInput(避免 lastCliInput 毒记录破坏空
启动 --resume 契约)。时序对齐主 refork 路径——fork 同步 throw 时
flag 保留,走 ingress notice 通道。

回归测试 3 条:两条 queued 路径 refork 后 flag 清除;fork 同步 throw
时 flag 保留(钉住清 flag 必须在接受之后的时序约定)。

Co-Authored-By: Claude <noreply@anthropic.com>
@ITcathyh

Copy link
Copy Markdown
Contributor Author

感谢评审,🟠 合前修已处理并推送(commit af7c48c):

Fix3:queued 两条 refork 路径清除 suppressRecoveryCard

认同失败链分析——这两条路径 forkWorker(...) 后直接 return,绕过了 rememberLastCliInput(唯一常规清 flag 点),导致恢复静默标志永久残留、异步启动失败被零反馈静默吞掉。

修法按建议实现:

  • queuedActivationPending(daemon.ts:20383)与 retainedQueuedActivation(daemon.ts:20411)两条路径,在 forkWorker(...) 同步返回后显式 ds.suppressRecoveryCard = undefined
  • 只清恢复标志,没有rememberLastCliInput(避免 fork throw 时 lastCliInput 毒记录破坏空启动 --resume 契约);
  • 时序对齐主 refork 路径与补充建议:清 flag 在 fork 被接受之后——fork 同步 throw 时 flag 保留,catch rethrow 走 ingress notice 通道;fork 被接受后异步失败正常通知。

回归测试加在 daemon-ordinary-ingress-failure-notice(复用真实 inbound 路由 + forkWorker mock):

  1. queuedActivationPending refork 后 flag 清除;
  2. retainedQueuedActivation refork 后 flag 清除;
  3. forkWorker 同步 throw 时 flag 保留(钉住「接受之后才清」的时序,防止有人把清 flag 挪到 fork 前)。

两个 🟡 nit:均为已知/可选,本 PR 不展开(migrate-to-chat 的 endsWith 放宽不可利用已确认;preflight clamp 上界后续可补独立测试)。

验证pnpm build 通过;daemon-ordinary-ingress-failure-notice 14/14(含 3 新增)、worker-startup-suppress-recovery 4/4、worker-startup-retry、session-lifecycle-start 133,共 151 相关全绿。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants