Skip to content

fix(cli): 大输出写完 stdout 再退出,避免 ask/hook/relay 截断 - #1026

Merged
deepcoldy merged 1 commit into
deepcoldy:masterfrom
zjdznl:fix/pm2-jlist-stdout-flush
Oct 13, 2026
Merged

deepcoldy merged 1 commit into
deepcoldy:masterfrom
zjdznl:fix/pm2-jlist-stdout-flush

Conversation

@zjdznl

@zjdznl zjdznl commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

原因(原作者背景)

Node 的 pipe 写入是异步的;调用 write() 后立即退出,可能丢失尚未排空的尾部字节。PM2 registry 和 ask --json 的 comment 都可超过该边界,后者已用真实子进程复现。该 pipe 竞态在 macOS 与 Linux 上均存在(实测越过阈值后父进程恒定只收到 146,176 字节,position 146176 即该 bug 的指纹)。

维护者跟进

本 PR 最初修复的 PM2 只读 jlist 截断,已由 master 的 #956 以 write(payload, cb) 形式落地。因此本次 rebase 到最新 master 时 pm2-readonly-client.ts 取 master 版本,PR 核心改为补齐其余「write 后直接 exit」的大输出路径,标题随之调整。

改了什么

  • 新增 src/cli/stdout-flush.ts 的 writeAndFlush:以 write 完成回调为「写完」边界;
  • botmux ask:等结果写完再 ack;写失败时不 ack、exit 1,daemon 仍可回收回执;
  • botmux hook:claude/dsh/opencode 的 hook directive 会内嵌用户文字作答(长度无上限),改为写完再 exit 0(写失败仍 exit 0,不阻塞 CLI);
  • sandbox relay:send 与 dispatch 两处(原 PR 只覆盖了 send)统一走 mirrorRelayResponseAndExit,stdout/stderr 均写完再镜像原退出码(send 默认 0、dispatch 默认 1,与原行为一致);
  • cli.ts 其余 stdout/stderr 写入已排查:要么输出很小,要么只设 process.exitCode(自然排空),要么已是 write(payload, () => exit),无需改动。

影响面

只改 CLI 子命令(ask / hook / sandbox relay)的输出完成时机;协议、输出内容、退出码不变(新增:ask 写 stdout 失败时 exit 1 且不 ack)。不涉及 daemon、CLI 适配器、会话后端、飞书事件链路。

测试

  • test/stdout-flush.test.ts(新增):经 test/helpers/ts-runner.ts 起子进程,经 pipe 写 2 MiB 后立刻 process.exit,父进程延后读取制造背压,断言字节完整;
  • test/ask-cli.test.ts:真实 CLI 子进程 + fake daemon,50 万字符 comment 的 ask --json 端到端回归;
  • test/pm2-readonly-jlist.test.ts:按 desktop 的真实调用形态直接起 pm2-readonly-client.ts jlist 走 pipe(原依赖的 src/cli/pm2-readonly.ts 已在 master 删除),6 进程 × 30 KB env、输出 >200 KB,钉住 master 已有修复;PM2 用 resolveNodeExecutable() 起真实 Node(bun 下 process.execPath 是 bun)。

实际运行:

  • bun run build:通过;
  • npx vitest run --project unit 12 个相关文件(stdout-flush、ask-cli、pm2-readonly-jlist、sandbox-relay-watcher、cli-send-hook-context、ask-hook-{claude,codex,dsh,opencode}、ask-resume-contract、desktop/desktop-pm2-apps、sandbox-session-data-dir):158 通过、3 跳过;
  • bun test(bun 1.4.2)三个新增/改动的测试文件:1 / 1 / 17 通过;
  • 反向变异:pm2 helper 改回「write 后立即 exit」→ pm2 测试红(expected 146176 to be greater than 200000);让 writeAndFlush 不等回调 → stdout-flush 红(146176 / 2097152)、ask 红(292352 字节)。

未做:全量 bun run test;未做飞书 live 验证(改动仅限 CLI 子命令输出边界,由上述子进程测试覆盖)。

🤖 Generated with Claude Code

@zjdznl
zjdznl requested a review from deepcoldy as a code owner August 26, 2026 14:46
@zjdznl

zjdznl commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@zjdznl

zjdznl commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 26eb981c7c

ℹ️ 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".

@DeepColds DeepColds left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

结论:通过,未发现阻塞问题。

  • 修复点与根因匹配:以 stdout.write 的 completion callback 作为完整写入边界,避免大于 pipe 高水位时 process.exit(0) 截断尾部。
  • 先完成 stdout 写入,再断开 PM2 RPC 并退出;未改变只读 observer 与 fail-closed 语义。
  • 改动范围集中在 jlist 输出路径,不影响 status/logs、CLI adapter、会话后端或飞书链路。

本地验证:

  • pnpm build:通过。
  • pnpm exec vitest run --project unit test/stdout-flush.test.ts test/desktop/desktop-pm2-apps.test.ts test/pm2-jlist.test.ts test/shutdown-supervisor-contract.test.ts:4 个文件、48 个测试通过。
  • 额外以子进程 pipe 写入 4 MiB payload:实际收到 4,194,304 / 4,194,304 bytes,退出码 0。

完整 pnpm test 也已执行;本环境中有 6 个与本 PR 无关的既有/环境敏感失败,其中 PM2 cgroup、进程名和 redirect 环境项可在当前 master 复现,不影响本次结论。

@deepcoldy

Copy link
Copy Markdown
Owner

感谢这个修复 🙏 根因分析非常准确,修法也是最小改动。我在本地做了独立验证,确认这是个真实的生产 bug,先说结论:方向、根因、修法都没问题,只有新增测试的有效性想和你商量一下。

复现与修复效果都已坐实

因为是 fork PR 不跑 CI,我在 Linux / Node 22 上复刻了 captureReadonlyPm2Jlist 的 spawnSync + pipe 形态,用 878 KB payload 做 A/B:

写法 父进程实收 严格 JSON 校验
原 write() 后立即 exit 146,176 B ❌ 失败
本 PR writeAndFlush 878,891 B(全量) ✅ 通过

我还把因果链闭合到了用户看到的那句报错:把真实 payload 截到 146176 字节喂进真的 parsePm2JlistOutputStrict(),抛出的正是 pm2 jlist returned malformed output,与 issue 里的字符串逐字一致。

回归面也确认干净:tsc --noEmit 通过,PM2 相关 6 个测试文件 37/37 全绿(pm2-readonly-cli / pm2-jlist / pm2-command / pm2-existing-client / desktop-pm2-apps / 新增 stdout-flush)。

另外顺手枚举了全仓同类写法,可以确认没有第二个未修实例:supervisor-shutdown-client 的同类 helper 没有显式 exit,靠事件循环自然排空(我实测 878 KB 全量到达,安全);而 src/cli.ts:13120 早已是 write(payload, () => process.exit(0)) —— 说明这个正确写法仓库里已有先例,这个 PR 是把它补齐到漏掉的这一处,方向完全正确。

一个补充:影响面可能比描述里更广

描述提到「在 macOS 上首次稳定复现」。我实测 Linux 下同样必中(上表数据就是 Linux 跑的)。因为 daemon 生产环境跑在 Linux,这其实是生产路径上的真实 bug,不只是 mac 本地开发的困扰 —— 这点或许值得在描述里改得更准,也更能说明这个修复的价值。

主要建议:test/stdout-flush.test.ts 目前锁不住这个回归

这是我唯一想请你考虑调整的地方。我做了一次反向变异实验:把 pm2-readonly-client.ts 改回有 bug 的原写法(也就是把这个 PR 的核心修改整个撤掉),只保留 stdout-flush.ts 和新测试 —— 测试依然通过。

原因是它用手写 mock stream 验证的是 writeAndFlush 这个新 helper 自身「会不会等 callback」,而真正的 bug 在于调用点有没有去等。所以将来如果有人把那两行改回 write() + 立即 exit,这个测试不会变红。

顺带也解释了这个 bug 为什么一直没被发现:现有的 test/pm2-readonly-cli.test.ts:85-88 其实已经端到端 spawn 了真实 helper,但断言的是 toEqual([]) —— 空注册表根本走不到 64 KiB 那条路径。

一个已验证可行的补法,供你参考:直接在既有的 test/pm2-readonly-cli.test.ts 里加一例(那个文件已经 describe.runIf(process.platform === 'linux')、也已经 spawn 真 helper,环境是现成的)——起 6 个 idle 进程,并给每个进程灌入较大的 env 把 jlist 输出顶过 64 KiB,然后 JSON.parse(captureReadonlyPm2Jlist(...)) 断言解析成功且 length === 6。

我双向实跑过它确实有牙:

  • 打上你的修复 → ✅ 通过(约 2.1s)
  • 撤掉修复 → ❌ SyntaxError: Unterminated string in JSON at position 146176,正是线上症状

这样这个 fix 就有了真正的回归保护。

两条小观察(不影响正确性,仅供参考)

  1. 改完之后多了一种理论上的挂起可能:以前无论如何都会 exit,现在若父进程完全不排空 stdout,子进程会阻塞在写上。不过两个真实消费方都是持续排空 + 带 timeout(spawnSync 10s;desktop 侧 child.stdout.on('data') + 定时器 kill),所以实际是有界的、不构成缺陷。只是 cli.ts:13120 那处先例额外加了 1s 兜底 timer,这里没加,一致性上可以考虑对齐。
  2. terminal-renderer.ts 里已有一个同名的 writeAndFlush(语义不同,是 xterm buffer 刷新)。不是问题,只是同名不同义未来可能造成阅读时的混淆。

以上是自动评审的初步意见,仅供参考,最终以维护者审阅结论为准。核心修复我认为是对的、值得合入,主要就是希望测试能真正锁住这个回归 🙏

@deepcoldy

deepcoldy commented Aug 27, 2026 •

Copy link
Copy Markdown
Owner

补充两点更正与更精确的信息(复审交叉后得到,其中一条是修正我上一条评论里的说法):

1. 更正:真实截断阈值是 ~146 KiB,不是我上面说的 64 KiB

我上一条评论按「超过 pipe 高水位 64 KiB 就会截断」来描述,这不够准确。我做了逐档实测(Linux / Node 22.21.1,spawnSync + pipe):

子进程写入 父进程实收 严格 JSON
60,075 60,075 ✅
100,075 100,075 ✅
146,075 146,075 ✅
150,075 146,176 ❌
200K / 500K / 1M / 3M 146,176(恒定) ❌

两个反直觉的点:

  • 64 KiB 只是背压信号(write() 从此开始返回 false),但 Node 在 exit() 之前仍能同步冲出远超它的量。真正的成败线是「exit 前 libuv 能冲掉多少」,实测在 ~146,176 字节(≈143 KiB)。
  • 越过阈值后父进程收到的字节数恒定 146,176,与 payload 大小无关 —— 1 MB 和 3 MB 都砍到同一个数字。所以 position 146176 基本是这个 bug 的指纹。

这不改变这个 PR 的结论(jlist 实测 474 KB ~ 878 KB,远超阈值,必中),但对写测试有实际影响:如果只把 payload 顶到「刚过 64 KiB」,测试会假绿(因为那个区间其实是安全的)。所以测试必须确保输出稳稳越过 ~146 KiB。

我上一条给的写法(6 进程 × 12 个 4000 字节 env)实测约 474 KB,是安全的;不过下面这个等价写法更简洁,也同样越过真实阈值(实测 474,752 字节),双向验证过有牙 —— 建议直接用这版:

// 加进 test/pm2-readonly-cli.test.ts 的 describe 内(需补 writeFileSync import)
// 6 × 30KB env → jlist 实测 474,752 字节,稳稳越过 ~146 KiB 的同步冲刷阈值。
it('delivers a jlist past the synchronous flush ceiling without truncation', () => {
  const home = tempHome();
  const pm2Home = join(home, '.botmux', 'pm2');
  mkdirSync(pm2Home, { recursive: true });
  expect(spawnSync(process.execPath, [PM2_PATH, 'status'], {
    env: { ...process.env, PM2_HOME: pm2Home },
    stdio: 'ignore',
    timeout: 10_000,
  }).status).toBe(0);

  const idleScript = join(home, 'idle.js');
  writeFileSync(idleScript, 'setInterval(() => {}, 1000);\n');
  for (let i = 0; i < 6; i++) {
    const started = spawnSync(process.execPath, [PM2_PATH, 'start', idleScript, '--name', `flush-probe-${i}`], {
      env: { ...process.env, PM2_HOME: pm2Home, BOTMUX_FLUSH_PROBE: 'x'.repeat(30_000) },
      stdio: 'ignore',
      timeout: 20_000,
    });
    expect(started.status).toBe(0);
  }

  const parsed = JSON.parse(captureReadonlyPm2Jlist({
    pkgRoot: dirname(dirname(CLI_PATH)),
    home: pm2Home,
  })) as unknown[];
  expect(parsed).toHaveLength(6);
}, 90_000);

双向验证:打上修复 ✅ 通过(约 2.2s);撤掉修复 ❌ 死在 position 146176。

2. 更正:同模式还有第二处(不阻断本 PR,建议另开 follow-up)

我上一条说「没有发现第二个未修实例」,这句话说过头了,交叉复审时被指出还有一处:

src/cli.ts:12652(botmux ask --json 的输出) —— process.stdout.write(JSON.stringify(out) + '\n') 后紧跟 switch → process.exit(0),同样没等 callback。其中 comment 字段是用户在 ask 卡片里的文字回复,来自 ask-broker.ts:624 的 submitCustomReply,那里只做 text.trim(),从飞书消息一路到 CLI 输出没有任何长度 cap,结构上可以越过阈值。

不过按上面测出的真实阈值,触发需要用户粘贴 ~14 万字符以上,比我原本估计的更低频。所以:不阻断这个 PR,建议单独记个 follow-up issue 即可。

另外顺带说明,仓库里其余同形状的站点我都核过是安全的:supervisor-shutdown-client 的 helper 没有显式 exit(靠事件循环自然排空,实测 878 KB 全量到达);cli.ts:9014、zellij-socket-probe.ts:133 的 payload 都是几字节到几 KB;cli.ts:13120 早就是 write(payload, () => exit(0)) + 1s 兜底的正确写法。


同样是自动评审的补充意见,最终以维护者审阅为准。核心修复的结论不变:真 bug、修法正确、值得合入,只是测试希望能真正锁住回归 🙏

@zjdznl

zjdznl commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

已根据评论更新并推送 bfe372f。

  • 删除只验证 mock stream 的 standalone 测试;
  • 新增真实 PM2 helper 与 parent pipe 的大输出回归测试:6 个独立进程、每个 30 KB 环境变量,输出必须超过 200 KB,随后严格 JSON 解析并校验 6 个条目;
  • 变异验证已确认:恢复旧的 write 后立即 exit 行为时,父进程仅收到 65,536 B,新增测试失败;恢复修复后通过;
  • PR 描述已改为 macOS/Linux 均受影响,并更正了不准确的 64 KiB 截断表述;
  • 已完成相关 PM2 测试与 pnpm build。

@zjdznl

zjdznl commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: bfe372f0f9

ℹ️ 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".

@zjdznl
zjdznl force-pushed the fix/pm2-jlist-stdout-flush branch from bfe372f to 03c4d58 Compare August 27, 2026 02:13
@zjdznl

zjdznl commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

已基于最新 master(8c489709)完成 rebase 并强制更新分支。

冲突来自上游删除旧的 pm2-readonly-cli 生命周期测试;该测试绑定了已不再采用的 cgroup 生命周期假设。此次没有恢复旧测试,而是将本 PR 的大输出回归覆盖迁为独立的 test/pm2-readonly-jlist.test.ts,仅覆盖当前仍被桌面和插件调用的 read-only jlist helper。

已完成相关测试与 pnpm build;PR 描述中的测试路径和结果也已同步更新。

@zjdznl

zjdznl commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

已基于最新 origin/master(8c489709)完成复核并推送 20cc0bd4。

本次补充修复了复审中确认的同类 CLI 输出截断:botmux ask --json 在大文本 comment 下会在 stdout 写入后立即退出。新增真实子进程回归测试以 500,000 字符 comment 验证完整 JSON 输出;同时将 sandbox relay 的 stdout/stderr 镜像改为等待两条流完成。

已实际运行:

  • pnpm exec vitest run --project unit test/ask-cli.test.ts test/pm2-readonly-jlist.test.ts test/pm2-command.test.ts test/pm2-existing-client.test.ts test/desktop/desktop-pm2-apps.test.ts test/shutdown-supervisor-contract.test.ts:32 通过,2 个 Linux 专用用例在 macOS 跳过;
  • pnpm build:通过。

@codex review

@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: 20cc0bd426

ℹ️ 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 test/pm2-readonly-jlist.test.ts Outdated
afterEach(() => {
for (const home of homes.splice(0)) {
const pm2Home = join(home, '.botmux', 'pm2');
spawnSync(process.execPath, [PM2_PATH, 'kill'], {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Route PM2 subprocesses through the test runner helper

The new regression test invokes PM2 directly with spawnSync(process.execPath, ...) here and again for status and start, bypassing the repository’s mandatory runtime-aware subprocess abstraction. This leaves the test and its teardown outside the supported Node/Bun launch contract, so they can fail or exercise different behavior when the suite runs under Bun; use spawnSyncTsScript from test/helpers/ts-runner.ts for all three invocations.

AGENTS.md reference: AGENTS.md:L28-L28

Useful? React with 👍 / 👎.

@deepcoldy
deepcoldy force-pushed the fix/pm2-jlist-stdout-flush branch from 20cc0bd to a29634b Compare October 13, 2026 08:25
@deepcoldy deepcoldy changed the title fix(pm2): 修复大注册表读取截断 fix(cli): 大输出写完 stdout 再退出,避免 ask/hook/relay 截断 Oct 13, 2026
@deepcoldy

Copy link
Copy Markdown
Owner

感谢原作者的根因分析与修复 🙏 已 rebase 到最新 master 并推送 a29634ba5:

  • PM2 jlist 核心修复已由 feat(worktree): 完善话题工作区生命周期 #956 在 master 落地,pm2-readonly-client.ts 取 master 版本,保留大注册表回归测试钉住它(改为直接起 helper 走 pipe,PM2 用真实 Node 启动);
  • 保留 writeAndFlush 与 ask --json 修复,ask 写失败时不再 ack;
  • 补齐 sandbox dispatch relay(此前只改了 send relay),两处统一走 mirrorRelayResponseAndExit;
  • 新增覆盖 botmux hook:directive 内嵌用户文字作答,同样会截断;
  • 新增 2 MiB 管道子进程回归测试。

验证:bun run build 通过;vitest 12 个相关文件 158 通过 / 3 跳过;bun test 新增/改动文件全绿;反向变异下三个回归测试均变红。标题已相应改为 fix(cli): …。

stdout 接 pipe 时 write() 是异步的,紧跟显式 process.exit() 会丢掉未排空
的尾部(实测父进程只收到约 146 KiB)。PM2 只读 jlist 的同类问题已在 master
修复,本提交补齐其余仍会输出大文本的退出路径:

- 新增 src/cli/stdout-flush.ts 的 writeAndFlush:以 write 完成回调为边界
- botmux ask:写完结果再 ack;写失败时不 ack、exit 1,daemon 仍可回收回执
- botmux hook:directive 内嵌用户文字作答,同样等写完再 exit 0
- sandbox relay:send 与 dispatch 两处统一走 mirrorRelayResponseAndExit,
  stdout/stderr 均写完再镜像退出码
- 测试:writeAndFlush 子进程 2 MiB 管道回归;ask --json 50 万字符 comment
  端到端回归;PM2 只读 helper 大注册表(>200 KB)回归,钉住 master 的修复。
  PM2 用真实 Node 启动(bun 下 execPath 不是 node)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@deepcoldy
deepcoldy force-pushed the fix/pm2-jlist-stdout-flush branch from a29634b to f6b0e1a Compare October 13, 2026 12:19
@deepcoldy
deepcoldy merged commit f8e62c5 into deepcoldy:master Oct 13, 2026
deepcoldy added a commit that referenced this pull request Oct 13, 2026
#1026 引入的 test/pm2-readonly-jlist.test.ts 在 CI 上冷启动真实 pm2 God 时确定性失败(warmup `pm2 status` exit 1,node/bun 两腿首跑及 rerun 均复现,10403 其它用例全过;本地无法复现)。

仓库测试惯例不冷启动真实 God(legacy-pm2-reaper 用 fake、desktop-pm2-apps 用 mock spawn);pm2 已非直接依赖、该只读 helper 属待删遗留桌面路径。删除该用例(-79,零生产改动):编排逻辑仍由 desktop-pm2-apps 覆盖,「写完再退出」通用契约由 stdout-flush 2 MiB 管道回归与 ask 50 万字符端到端回归守住(均 CI 绿)。

#1875 自身 CI 14/14 全绿(test 三分片 + bun-test)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
deepcoldy added a commit that referenced this pull request Oct 13, 2026
#1026 修了 sandbox relay 的 stdout/stderr 截断(mirrorRelayResponseAndExit),
但 relay 调用点没有大载荷回归测试。本例起 mock host watcher 回 300KB stdout +
200KB stderr + exit 7,断言子进程逐字节送达且退出码透传。走 dispatch relay
路径(send/dispatch 共用 mirrorRelayResponseAndExit,一例盖共享逻辑)。

双向验证:修复版通过;撤掉 mirror 修复(改回 write-then-exit)立刻变红。
@github-actions

Copy link
Copy Markdown

🚀 Released in v3.43.0

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.

3 participants