Skip to content

fix(pm2): 修复大注册表读取截断 - #1026

Open
zjdznl wants to merge 3 commits into
deepcoldy:masterfrom
zjdznl:fix/pm2-jlist-stdout-flush
Open

fix(pm2): 修复大注册表读取截断#1026
zjdznl wants to merge 3 commits into
deepcoldy:masterfrom
zjdznl:fix/pm2-jlist-stdout-flush

Conversation

@zjdznl

@zjdznl zjdznl commented Aug 26, 2026

Copy link
Copy Markdown

改了什么

修复两类由显式 process.exit() 触发的大输出截断,并保留原有协议和退出码。

  • PM2 只读 jlist helper:等待 stdout 写入完成后,才断开 RPC 并退出;
  • botmux ask --json:用户自由文本回答没有长度上限,先完整写出 JSON 再按既有结果退出;
  • sandbox send relay:宿主子进程的 stdout 和 stderr 均写完后,再镜像退出码。

原因

Node 的 pipe 写入是异步的;调用 write() 后立即退出,可能丢失尚未排空的尾部字节。PM2 registry 和 ask --jsoncomment 都可超过该边界,后者已用真实子进程复现。

影响范围

  • PM2:仅影响 read-only jlist helper 及依赖它做安全校验的 start、restart、stop 路径;
  • CLI:仅改 ask 的输出完成边界和 sandbox relay 的结果镜像边界,不改变 ask 请求格式、回答内容、退出码或 relay 授权逻辑;
  • 平台/会话:未改动 daemon、CLI adapter、会话后端或飞书事件处理;该 pipe 竞态适用于 macOS 与 Linux。

验证

  • 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:通过,包含 TypeScript、脚本类型检查、dashboard bundle 和 dist audit;
  • ask 回归测试使用真实 CLI 子进程与 fake daemon,返回 500,000 字符的 comment,并断言 stdout JSON 可完整解析且 comment 全量一致;
  • PM2 回归测试保留真实 helper 与 parent pipe 的大 registry 覆盖。

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

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

另外顺带说明,仓库里其余同形状的站点我都核过是安全的:supervisor-shutdown-client 的 helper 没有显式 exit(靠事件循环自然排空,实测 878 KB 全量到达);cli.ts:9014zellij-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
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
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
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
Author

已基于最新 origin/master8c489709)完成复核并推送 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".

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 👍 / 👎.

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