Skip to content

fix(serve): Optimize daemon NDJSON stream handling - #6263

Merged
doudouOUC merged 1 commit into
QwenLM:mainfrom
doudouOUC:codex/ndjson-daemon-perf-baseline
Jul 3, 2026
Merged

fix(serve): Optimize daemon NDJSON stream handling#6263
doudouOUC merged 1 commit into
QwenLM:mainfrom
doudouOUC:codex/ndjson-daemon-perf-baseline

Conversation

@doudouOUC

Copy link
Copy Markdown
Collaborator

What this PR does

This PR replaces the daemon-critical NDJSON stdio path between qwen serve and qwen --acp with a local incremental scanner that preserves the SDK stream contract while avoiding repeated whole-buffer split work on growing pending input. It wires daemon-only pipe hooks into the spawned child channel, records daemon child-pipe message sizes, starts event-loop lag monitors for the daemon and ACP child, exposes daemon-only performance snapshots in /daemon/status, and documents the new observability surface.

Why it's needed

Large ACP messages that span chunks could make receive-side work grow quadratically because pending data was repeatedly split as new chunks arrived. The daemon also lacked baseline visibility into event-loop lag and daemon-child pipe payload size, which made overload diagnosis harder. This keeps protocol behavior stable while making the hot daemon path linear and observable without adding ACP ext-methods or exposing child lag in /daemon/status.

Reviewer Test Plan

How to verify

Reviewers should confirm the daemon/child ACP stdio path now uses the local incremental NDJSON stream while non-daemon paths keep the SDK stream implementation. The focused ACP bridge tests verify empty-line handling, invalid JSON continuation, EOF tail dropping, UTF-8 split boundaries, payload byte hooks, newline framing, and write-error propagation. The core telemetry tests verify daemon/ACP event-loop gauges and daemon pipe histograms register with the expected metric names and attributes. The CLI tests verify ACP agent monitor cleanup, daemon monitor cleanup, and the additive /daemon/status perf shape.

Evidence (Before & After)

N/A

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

Node.js 22+ local development checkout on macOS. Validation used npm run build, npm run typecheck, focused Vitest suites for packages/acp-bridge, packages/core, and packages/cli, changed-file ESLint, and git diff --cached --check.

Risk & Scope

  • Main risk or tradeoff: The daemon and ACP child now use the local NDJSON implementation on their stdio path, so compatibility with the ACP SDK stream behavior matters; focused tests cover parse errors, CRLF, UTF-8 boundaries, large split messages, EOF tails, hooks, and write errors.
  • Not validated / out of scope: Manual telemetry backend inspection and manual qwen serve runtime smoke testing were not performed in this pass; non-daemon paths continue using the SDK stream implementation.
  • Breaking changes / migration notes: No protocol or status version change is intended; runtime.perf is optional and additive, and child event-loop lag remains visible only through OTel and forwarded stderr stall warnings.

Linked Issues

N/A

中文说明

本 PR 做了什么

本 PR 将 qwen serve daemon 与 qwen --acp child 之间的关键 NDJSON stdio 通路替换为本地增量扫描实现,在保持 SDK stream 契约兼容的同时,避免对不断增长的 pending 输入重复执行整段 split。它为 daemon 侧 spawned child channel 接入 pipe hooks,记录 daemon-child pipe 消息大小,启动 daemon 和 ACP child 的 event-loop lag monitor,在 /daemon/status 中暴露仅 daemon 进程的性能快照,并补充相关观测文档。

为什么需要

跨 chunk 的大型 ACP 消息过去可能让接收端工作量呈二次增长,因为每来一个新 chunk 都会对 pending 数据重复 split。daemon 也缺少 event-loop lag 和 daemon-child pipe payload size 的基础可见性,导致过载诊断更困难。这个改动在不新增 ACP ext-method、也不把 child lag 暴露到 /daemon/status 的前提下,保持协议行为稳定,同时让 daemon 热路径变为线性并具备基础观测能力。

审阅测试计划

如何验证

审阅者应确认 daemon/child ACP stdio 通路现在使用本地增量 NDJSON stream,而非 daemon 路径仍继续使用 SDK stream 实现。ACP bridge focused tests 验证空行处理、非法 JSON 后继续、EOF 尾巴丢弃、UTF-8 跨 chunk 边界、payload byte hooks、newline framing 和写失败传播。core telemetry tests 验证 daemon/ACP event-loop gauges 和 daemon pipe histograms 使用预期的指标名与 attributes 注册。CLI tests 验证 ACP agent monitor cleanup、daemon monitor cleanup,以及 /daemon/status 中 additive perf 字段形状。

证据(Before & After)

N/A

已测试平台

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

环境(可选)

macOS 本地 Node.js 22+ 开发 checkout。验证使用了 npm run build、npm run typecheck、packages/acp-bridge、packages/core 和 packages/cli 的 focused Vitest suites、changed-file ESLint,以及 git diff --cached --check。

风险与范围

  • 主要风险或取舍:daemon 和 ACP child 现在在 stdio 通路上使用本地 NDJSON 实现,因此与 ACP SDK stream 行为的兼容性很重要;focused tests 覆盖了 parse errors、CRLF、UTF-8 boundaries、大型 split messages、EOF tails、hooks 和 write errors。
  • 未验证 / 范围外:本次未手工检查 telemetry backend,也未手工运行 qwen serve runtime smoke test;非 daemon 路径继续使用 SDK stream 实现。
  • Breaking changes / 迁移说明:不预期协议或 status version 变化;runtime.perf 是可选且 additive 的字段,child event-loop lag 仍只通过 OTel 和 forwarded stderr stall warnings 可见。

关联 Issues

N/A

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC
doudouOUC marked this pull request as ready for review July 3, 2026 12:02
Copilot AI review requested due to automatic review settings July 3, 2026 12:02
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

On direction: daemon NDJSON parsing performance and baseline observability (event-loop lag, pipe byte histograms) are squarely within the project's core infrastructure needs. Claude Code's own CHANGELOG shows event-loop stall fixes and startup perf work — this area is clearly relevant. No direct reference for NDJSON streaming, but the problem (quadratic re-split on growing buffers) is a real and well-understood class of issue.

On approach: the scope is well-contained. Three focused pieces — local incremental NDJSON stream, event-loop lag monitor via monitorEventLoopDelay, and OTel metrics/status wiring — each solves a clear problem. The local ndJsonStream replacing the SDK's version only on daemon paths (not globally) is the right blast radius. Tests cover edge cases (UTF-8 splits, CRLF, EOF tails, hook errors, write failures). The perf snapshot is additive and optional in /daemon/status. No scope creep detected.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

方向:daemon NDJSON 解析性能和基础可观测性(event-loop lag、pipe byte 直方图)属于项目核心基础设施范畴。Claude Code 自己的 CHANGELOG 中也有 event-loop stall 修复和启动性能优化——这个方向明确相关。NDJSON 流式处理没有直接参考,但问题(对增长中的 buffer 重复 split 导致二次复杂度)是一类已知且真实的性能问题。

方案:范围收敛良好。三个聚焦的模块——本地增量 NDJSON stream、基于 monitorEventLoopDelay 的 event-loop lag 监控、OTel 指标/status 接入——各自解决明确的问题。本地 ndJsonStream 仅替换 daemon 路径上的 SDK 实现(非全局),爆炸半径合理。测试覆盖了边界情况(UTF-8 拆分、CRLF、EOF 尾巴、hook 错误、写失败)。perf 快照在 /daemon/status 中为可选且 additive。未发现范围蔓延。

进入代码审查 🔍

Qwen Code · qwen3.7-max

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR optimizes the daemon ↔ ACP-child NDJSON stdio transport by introducing a local incremental NDJSON scanner (to avoid repeated whole-buffer splits) and adds new observability for daemon/ACP event-loop lag and daemon-child pipe message sizes, surfacing a daemon-only perf snapshot via /daemon/status.

Changes:

  • Add a local ndJsonStream implementation (with byte-count hooks) and thread pipe hooks through spawned ACP channels.
  • Introduce event-loop lag monitoring + OpenTelemetry gauges for daemon and ACP roles, plus a daemon pipe payload-size histogram.
  • Extend /daemon/status (and docs) with an optional, daemon-only runtime.perf snapshot.

Reviewed changes

Copilot reviewed 25 out of 25 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/core/src/telemetry/index.ts Re-exports new event-loop lag utilities and daemon pipe message metric API.
packages/core/src/telemetry/event-loop-lag.ts Adds monitorEventLoopDelay-based lag monitor with snapshot + stall callback.
packages/core/src/telemetry/event-loop-lag.test.ts Tests event-loop lag monitor snapshot conversion and stall reporting behavior.
packages/core/src/telemetry/event-loop-lag-metrics.ts Adds OTel observable gauges for daemon/ACP event-loop lag with stat attrs.
packages/core/src/telemetry/event-loop-lag-metrics.test.ts Tests gauge registration and observation attributes/error swallowing.
packages/core/src/telemetry/daemon-metrics.ts Adds daemon pipe message bytes histogram + recordDaemonPipeMessage.
packages/core/src/telemetry/daemon-metrics.test.ts Tests daemon pipe message byte recording + metric initialization wiring.
packages/cli/vitest.config.ts Adds CLI test alias for new @qwen-code/acp-bridge/ndJsonStream entrypoint.
packages/cli/src/serve/server.ts Wires optional getPerfSnapshot through serve app deps for status route.
packages/cli/src/serve/run-qwen-serve.ts Starts daemon event-loop monitor, registers gauge, records pipe stats, injects perf snapshot provider, disposes on shutdown.
packages/cli/src/serve/run-qwen-serve.test.ts Adds coverage that daemon event-loop monitor is disposed on close.
packages/cli/src/serve/routes/daemon-status.ts Threads getPerfSnapshot into daemon status response builder.
packages/cli/src/serve/daemon-status.ts Defines DaemonPerfSnapshot and conditionally includes runtime.perf in status response.
packages/cli/src/serve/daemon-status.test.ts Tests inclusion/omission of runtime.perf in /daemon/status response shape.
packages/cli/src/acp-integration/acpAgent.worktree.test.ts Updates mocks to use local NDJSON stream and adds event-loop lag monitor mocks.
packages/cli/src/acp-integration/acpAgent.ts Switches stdio NDJSON to local implementation and adds ACP event-loop lag monitoring + gauge registration with cleanup.
packages/cli/src/acp-integration/acpAgent.test.ts Tests event-loop monitor registration/disposal and setup-failure cleanup.
packages/acp-bridge/src/spawnChannel.ts Switches spawned child stdio NDJSON to local stream and adds optional pipe hooks.
packages/acp-bridge/src/spawnChannel.test.ts Verifies NDJSON pipe hooks record sent/received payload byte sizes.
packages/acp-bridge/src/ndJsonStream.ts New incremental NDJSON scanner stream with hookable sent/received payload byte counts.
packages/acp-bridge/src/ndJsonStream.test.ts Tests framing, large split messages, UTF-8 boundaries, errors, EOF tail drop, hooks, and write-error propagation.
packages/acp-bridge/src/index.ts Exports ndJsonStream from the acp-bridge package surface.
packages/acp-bridge/package.json Adds package export entry for ./ndJsonStream.
docs/developers/qwen-serve-protocol.md Documents optional runtime.perf shape on /daemon/status.
docs/developers/daemon/19-observability.md Documents new OTel metrics and daemon-only perf snapshot behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +115 to +118
} catch (err) {
// eslint-disable-next-line no-console -- match ACP SDK parse-error behavior
console.error('Failed to parse JSON message:', trimmedLine, err);
}
Comment on lines +2315 to +2329
// Stdout is used to send messages to the client, so console.log/console.info
// messages to stderr so that they don't interfere with ACP.
console.log = console.error;
console.info = console.error;
console.debug = console.error;

const stream = ndJsonStream(stdout, stdin);
connection = new AgentSideConnection((conn) => {
acpConnection = conn;
agentInstance = new QwenAgent(config, settings, argv, conn);
return agentInstance;
}, stream);
} catch (err) {
eventLoopMonitor.dispose();
throw err;
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Code Review

The implementation is clean and follows project conventions. Three new modules (ndJsonStream, event-loop-lag, event-loop-lag-metrics) are focused and well-tested. The daemon wiring in run-qwen-serve.ts correctly starts the monitor after initializeDaemonMetrics(), threads pipe hooks through createSpawnChannelFactory, and disposes the monitor on both failure and normal shutdown paths.

Key observations:

  • The local ndJsonStream replaces the SDK version only on daemon spawn paths — non-daemon ACP paths keep the SDK implementation. Correct blast radius.
  • The incremental scanner avoids the quadratic re-split problem by scanning only the newly arrived chunk and accumulating partial lines in a pending array. When a newline is found, pending fragments are concatenated once.
  • monitorEventLoopDelay from node:perf_hooks is the standard Node.js API for this — proper ns→ms conversion, unref()'d interval for stall reporting, idempotent dispose.
  • OTel gauges use the existing meter infrastructure with per-stat attributes (stat=mean|p50|p99|max), matching the project's metric conventions.
  • runtime.perf in /daemon/status is optional and additive — no breaking change to the status response shape.

No critical blockers or AGENTS.md violations found.

Testing

Build & typecheck: both pass cleanly.

Unit tests (all green):

  • acp-bridge/ndJsonStream.test.ts: 10/10 (round-trip, multi-message, large split, UTF-8 boundaries, CRLF, invalid JSON, EOF tail, byte hooks, hook errors, write failures)
  • acp-bridge/spawnChannel.test.ts: 28/28 (including new pipe hooks threading test)
  • core/event-loop-lag.test.ts: 5/5 (zeroes before samples, ns→ms conversion, stall reporting, error swallowing, histogram lifecycle)
  • core/event-loop-lag-metrics.test.ts: 4/4 (gauge registration, stat attributes, dedup, error swallowing)
  • core/daemon-metrics.test.ts: 19/19 (including new recordDaemonPipeMessage histogram test)
  • cli/daemon-status.test.ts: 11/11 (perf present + perf absent cases)

Before (installed v0.19.1)

$ QWEN_SERVER_TOKEN=test-triage-token qwen serve --port 0
qwen serve listening on http://127.0.0.1:33299 (mode=http-bridge)
qwen serve: startup timing: processToListenMs=100 runQwenServeToListenMs=25

$ curl -H "Authorization: Bearer test-triage-token" http://127.0.0.1:33299/daemon/status | jq .runtime.perf
# → "NOT PRESENT" (no perf field in runtime)

After (this PR, v0.19.5)

$ node packages/cli/dist/index.js serve --port 0 --token test-triage-token
qwen serve listening on http://127.0.0.1:37653 (mode=http-bridge)
qwen serve: startup timing: processToListenMs=156 runQwenServeToListenMs=48

$ curl -H "Authorization: Bearer test-triage-token" http://127.0.0.1:37653/daemon/status | jq .runtime.perf
{
  "eventLoop": {
    "meanMs": 20.07,
    "p50Ms": 20.12,
    "p99Ms": 20.96,
    "maxMs": 24.99
  },
  "pipe": {
    "inbound": { "count": 1, "totalBytes": 524, "maxBytes": 524 },
    "outbound": { "count": 1, "totalBytes": 204, "maxBytes": 204 }
  }
}

The runtime.perf field appears with the exact shape documented in the PR — event loop lag stats plus pipe byte counters. The installed build (v0.19.1) does not include this field.

中文说明

代码审查

实现干净,符合项目规范。三个新模块(ndJsonStreamevent-loop-lagevent-loop-lag-metrics)聚焦且测试充分。run-qwen-serve.ts 中的 daemon 接线逻辑在 initializeDaemonMetrics() 之后启动 monitor,通过 createSpawnChannelFactory 传入 pipe hooks,并在失败和正常关闭路径上均正确 dispose。

关键观察:

  • 本地 ndJsonStream 仅替换 daemon spawn 路径上的 SDK 版本——非 daemon ACP 路径保持 SDK 实现。爆炸半径正确。
  • 增量扫描器通过只扫描新到达的 chunk 并在 pending 数组中累积部分行来避免二次 re-split 问题。找到换行符时,pending 片段只拼接一次。
  • 使用 node:perf_hooksmonitorEventLoopDelay——标准 Node.js API,正确的 ns→ms 转换,unref() 的 stall 报告 interval,幂等 dispose。
  • OTel gauges 使用现有 meter 基础设施,按 stat 属性分组(stat=mean|p50|p99|max),符合项目指标规范。
  • /daemon/status 中的 runtime.perf 是可选且 additive 的——不破坏 status 响应结构。

未发现关键阻塞项或 AGENTS.md 违规。

测试

构建 & 类型检查: 均通过。

单元测试(全部通过):ndJsonStream 10/10,spawnChannel 28/28,event-loop-lag 5/5,event-loop-lag-metrics 4/4,daemon-metrics 19/19,daemon-status 11/11。

真实场景测试: 已安装版本 (v0.19.1) 的 /daemon/status 没有 perf 字段;本 PR (v0.19.5) 的 /daemon/status 返回完整的 runtime.perf,包含 eventLoop lag 统计和 pipe 字节计数器,形状与 PR 文档完全一致。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

This PR does exactly what it says, does it well, and doesn't do anything it shouldn't.

The quadratic NDJSON re-split problem is real and the fix is minimal — a local incremental scanner that only touches the daemon hot path while leaving the SDK implementation untouched for other consumers. The event-loop lag and pipe byte observability fills a genuine gap (the docs previously listed "no metrics endpoint" as a known gap; this doesn't add Prometheus but it does add OTel gauges and a status-endpoint snapshot, which is the right incremental step).

The implementation is straightforward: ~300 lines of new production code across three focused modules, each with thorough tests covering edge cases (UTF-8 splits, CRLF, hook errors, EOF tails, idempotent registration). The daemon wiring correctly disposes on both failure and normal shutdown. The before/after testing confirms the runtime.perf field appears with the exact shape documented in the PR and protocol docs, while the installed build has no such field.

No concerns. Ships cleanly.

中文说明

本 PR 准确实现了所述功能,没有多余内容。

NDJSON 二次 re-split 问题是真实的,修复也很精简——仅在 daemon 热路径上使用本地增量扫描器,其他消费方保持 SDK 实现不变。event-loop lag 和 pipe 字节可观测性填补了一个真实的空白(文档此前将"无指标端点"列为已知缺失;本 PR 没有加 Prometheus,但加了 OTel gauges 和 status 快照,是正确的增量步骤)。

实现简洁:三个聚焦模块共约 300 行生产代码,每个都有充分的边界测试(UTF-8 拆分、CRLF、hook 错误、EOF 尾巴、幂等注册)。daemon 接线在失败和正常关闭路径上都正确 dispose。Before/after 测试确认 runtime.perf 字段以 PR 和协议文档中描述的精确形状出现,已安装版本没有该字段。

无顾虑,可以合入。

Qwen Code · qwen3.7-max

readChunk(value, pending, controller, textDecoder, hooks);
}
} finally {
reader.releaseLock();

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.

[Suggestion] Input stream errors are silently converted to EOF. The start() method uses try/finally with controller.close() but no catch block. If reader.read() throws (e.g., broken pipe), the finally block closes the controller before the async function rejects — and ReadableStream ignores the start() promise rejection. The consumer sees a clean EOF instead of an error, and any messages after the failure point are silently lost.

Suggested change
reader.releaseLock();
} catch (err) {
controller.error(err);
return;
} finally {
reader.releaseLock();
controller.close();
}

— qwen3.7-max via Qwen Code /review

/* event loop monitoring must not break the process */
}
}
};

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.

[Suggestion] The stall notification is suppressed permanently after a single large stall. histogram.max is monotonically non-decreasing (the all-time peak, never reset), so once lastReportedMaxMs is set to a large value (e.g., 5000ms during startup), all subsequent stalls below that threshold — even recurring 2-3 second stalls that degrade user-facing latency — produce no log line or callback. Consider periodically resetting the histogram and lastReportedMaxMs (e.g., every 60 seconds) so the stall callback reports against recent behavior rather than all-time highs.

— qwen3.7-max via Qwen Code /review

dispose(): void {
if (disposed) return;
disposed = true;
if (interval) {

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.

[Suggestion] snapshot() returns lifetime statistics — meanMs converges toward a constant over the process lifetime, and maxMs is the all-time peak. Neither reflects recent event-loop behavior. Operators relying on p50Ms or meanMs via /daemon/status or the OTel gauge will see values that become increasingly stale and unresponsive to current conditions. Consider calling histogram.reset() after reading values (making each observation interval independent), or documenting the lifetime semantics prominently so consumers know to compute deltas between consecutive snapshots.

— qwen3.7-max via Qwen Code /review

@qwen-code-ci-bot qwen-code-ci-bot 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.

No review findings. Downgraded from Approve to Comment: CI still running.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

wenshao added a commit to wenshao/qwen-code that referenced this pull request Jul 3, 2026
wenshao added a commit to wenshao/qwen-code that referenced this pull request Jul 3, 2026
@wenshao

wenshao commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — local build, benchmark & live E2E ✅

I re-built this PR from head 074a6e8 in a clean worktree and ran an independent verification, focused on the two things the automated review left open: an empirical measurement of the O(n²)→O(n) claim, and a byte-for-byte equivalence check of the new parser against the SDK parser it replaces. Environment: Node v22.22.2, macOS, npm ci + npm run build + npm run bundle.

Gate results

Gate Result
npm run build / npm run bundle ✅ exit 0
npm run typecheck (all workspaces) ✅ exit 0
ESLint (changed source files) ✅ 0 errors
Focused suites 375 passed — acp-bridge ndJsonStream 10, spawnChannel 28; core telemetry 28; cli daemon-status/run-qwen-serve/acpAgent(+worktree) 309

1. The headline claim is real — and measured

The old path used the SDK's ndJsonStream, which does content += decode(chunk); content.split("\n") on every chunk — so a large message spanning K chunks is re-split K times = O(n²). The PR scans only the new chunk (indexOf(0x0a)) and concatenates pending bytes once per completed line = O(n).

I fed one large ACP message through both real parsers (SDK dist vs the PR's ndJsonStream.ts) as many small pipe-sized chunks:

parser scaling benchmark

  • Experiment A (grow message, fixed 8 KiB chunk): SDK time ~quadruples each time the size doubles (SDKx ≈ 3.55–4.66 = quadratic); PR stays ~linear. Speedup climbs 12.8× → 119×.
  • Experiment B (fixed 8 MiB message, shrink the chunk — this holds JSON.parse cost constant, isolating pure framing overhead): the SDK degrades 47 ms → 1223 ms as the message is split into more chunks (65 → 2049), while the PR is flat at ~7 ms regardless of chunk count. That flat line is the proof the per-chunk re-split cost is gone.

2. Drop-in safe — contract is byte-identical to the SDK parser

A faster parser is only safe if it frames identically. I ran 10 adversarial framing cases through both parsers and diffed the emitted messages and the console.error calls:

single message + \n · two messages one chunk · message split across bytes ·
200KB msg / 4KB chunks · UTF-8 multibyte split across chunks · empty + CRLF lines ·
invalid JSON then valid · unterminated final line at EOF (dropped) ·
leading whitespace · CRLF between two messages
→ ALL 10 cases: message output byte-identical AND parse-error log identical

3. Live daemon E2E (real bundle, real spawned child, real pipe)

Stood up the real daemon; it spawned a genuine qwen --acp child, and the additive runtime.perf surface lit up with real numbers. SIGINT shut both down cleanly with no orphaned child.

live daemon status

4. On the automated-review flags — verified, none are blockers

  • "parse-error logs the full line" (Copilot) and "stream error → EOF, no catch" (ci-bot): my equivalence harness proves both behaviors are character-identical to the SDK parser this PR replaces — preserved behavior on a path that already behaved this way, not regressions introduced here. The parse-error log fires only on a malformed frame and goes to daemon stderr, not to clients. Redacting/truncating it is a reasonable small follow-up that would also improve the old SDK path.
  • console.log/info/debug rebind (Copilot): pre-existing — the diff only relocates those 3 lines into the new try block; behavior is unchanged and intentional (stdout carries ACP frames).
  • Stall alert only fires on a new all-time max (ci-bot) and snapshot() returns lifetime stats (ci-bot): accurate reads of the new code, but by design — the callback is literally onNewMaxStall (a high-water-mark alerter that avoids log spam), and runtime.perf is a since-boot health view. Both are additive observability. A rolling-window/reset variant would make them more actionable in real time and is the one refinement I'd genuinely suggest — as a follow-up, not a blocker.

Bottom line

From a correctness and performance standpoint this PR is verified sound: build/typecheck/lint/375 tests green, the new parser is byte-identical to the SDK on every framing edge case, and the O(n²)→O(n) win is real and measured (up to 166× on the isolation benchmark). The new surface (runtime.perf, 3 OTel metric names) is additive and backward-compatible. The remaining question is purely scope/direction — whether to land the parser fix + observability together or split them — which is a maintainer call, not a code-quality one. If merged as-is, nothing here is broken.

中文说明(完整对应)

Maintainer 验证 — 本地构建、基准测试与真实 E2E ✅

我在干净的 worktree 里从 PR head 074a6e8 重新构建,做了一次独立验证,重点补上自动 review 留下的两个空白:对 O(n²)→O(n) 声明的实测,以及新解析器与被替换的 SDK 解析器的逐字节等价性核验。环境:Node v22.22.2、macOS、npm ci + npm run build + npm run bundle

门禁结果

门禁 结果
npm run build / npm run bundle ✅ exit 0
npm run typecheck(全 workspace) ✅ exit 0
ESLint(改动的源文件) ✅ 0 error
Focused 套件 375 通过 — acp-bridge ndJsonStream 10、spawnChannel 28;core telemetry 28;cli daemon-status/run-qwen-serve/acpAgent(+worktree) 309

1. 头号声明是真的 — 而且被实测出来了

旧路径用 SDK 的 ndJsonStream,它每来一个 chunkcontent += decode(chunk); content.split("\n") — 于是一个跨 K 个 chunk 的大消息会被重复 split K 次 = O(n²)。本 PR 只扫新 chunk(indexOf(0x0a)),并且每完成一行才拼接一次 pending 字节 = O(n)

我把一个大 ACP 消息拆成很多个 pipe 大小的小 chunk,喂给两个真实解析器(SDK dist vs PR 的 ndJsonStream.ts):

parser scaling benchmark

  • 实验 A(增大消息,固定 8 KiB chunk):消息翻倍时 SDK 耗时约翻四倍SDKx ≈ 3.55–4.66 = 二次),PR 近似线性。加速比 12.8× → 119×
  • 实验 B(固定 8 MiB 消息,缩小 chunk — 这样 JSON.parse 成本恒定,隔离出纯 framing 开销):随着消息被切成更多 chunk(65 → 2049),SDK 从 47 ms 退化到 1223 ms,而 PR 无论多少 chunk 都稳定在 ~7 ms。这条平线就是"逐 chunk 重复 split 成本已消除"的证据。

2. 可安全直接替换 — 契约与 SDK 解析器逐字节一致

更快的解析器只有在 framing 完全一致时才安全。我用 10 个对抗性 framing 用例跑两个解析器,并 diff 了产出的消息 console.error 调用:

单条消息 + \n · 单 chunk 两条消息 · 消息按字节切开 ·
200KB 消息 / 4KB chunk · UTF-8 多字节跨 chunk 切开 · 空行 + CRLF 行 ·
非法 JSON 后接合法 · EOF 处未结束的最后一行(丢弃)·
前导空白 · 两条消息间 CRLF
→ 全部 10 个用例:消息产出逐字节一致,且 parse-error 日志一致

3. 真实 daemon E2E(真实 bundle、真实 spawned child、真实 pipe)

起了真实 daemon,它 spawn 了真正的 qwen --acp child,additive 的 runtime.perf 字段被真实数据点亮。SIGINT 后 daemon 与 child 都干净退出,无残留子进程。

live daemon status

4. 关于自动 review 的标记 — 已核验,均非 blocker

  • "parse-error 打印整行"(Copilot)"stream 出错 → EOF,无 catch"(ci-bot):我的等价性 harness 证明这两处行为与被本 PR 替换的 SDK 解析器逐字符一致 — 是一条本就如此的路径上的保留行为,不是本 PR 引入的回归。parse-error 日志只在坏帧时触发,且写到 daemon stderr,不发给客户端。对其做脱敏/截断是个合理的小 follow-up,同时也能改进旧的 SDK 路径。
  • console.log/info/debug 重绑定(Copilot):本就存在 — diff 只是把这 3 行挪进新的 try 块;行为不变,且是刻意的(stdout 承载 ACP 帧)。
  • stall 告警只在新的历史最大值时触发(ci-bot)snapshot() 返回生命周期统计(ci-bot):对新代码的解读准确,但这是设计使然 — 回调本身就叫 onNewMaxStall(一个避免刷屏的"历史高水位"告警器),而 runtime.perf 是"自启动以来"的健康视图。二者都是 additive 观测。改成滑动窗口/周期 reset 会让它们在实时诊断上更有用 —— 这是我唯一真心建议的改进点,但作为 follow-up,而非 blocker。

结论

正确性与性能角度,本 PR 已验证可靠:build/typecheck/lint/375 测试全绿,新解析器在每个 framing 边界用例上与 SDK 逐字节一致,O(n²)→O(n) 的收益真实且实测(隔离基准上最高 166×)。新增表面(runtime.perf、3 个 OTel metric 名)是 additive 且向后兼容的。剩下的问题纯粹是 scope/方向 —— 解析器修复与观测特性是一起进还是拆分 —— 这是 maintainer 的判断,不是代码质量问题。若原样合并,这里没有任何东西是坏的。

Copy link
Copy Markdown
Collaborator Author

Thanks for the direction check. I’m going to keep this PR intact and continue with the full PR1 scope.

The reason is that this change is intentionally framed as “NDJSON O(n) receive + daemon performance baseline”, not just the parser swap. The parser fix removes the hot-path O(n²) receive behavior, while the daemon/child event-loop lag and daemon pipe-size metrics provide the baseline needed to validate and diagnose this path after rollout. The public surface is intentionally narrow: no ACP ext-method is added, child lag is not exposed through /daemon/status, runtime.perf is optional/additive with v: 1 unchanged, and the new metric names are documented as part of the PR.

I agree that the public /daemon/status and OTel metric surface deserves reviewer attention, so I’ll keep the PR focused on the current PR1 contract and continue addressing any concrete review comments in that scope.

@wenshao

wenshao commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot 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.

LGTM, looks ready to ship. ✅

@doudouOUC
doudouOUC added this pull request to the merge queue Jul 3, 2026
Merged via the queue into QwenLM:main with commit 3911b1d Jul 3, 2026
93 checks passed
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Benchmark filling the "Before & After" evidence gap: one NDJSON-framed message fed through the receive side in 64 KiB chunks, timed until parsed (median of 7 runs, Node v24.12.0, Apple Silicon).

Message size SDK ndJsonStream (before) packages/acp-bridge incremental scan (after) speedup
1 MB (17 chunks) 3.6 ms 1.2 ms 2.9x
5 MB (81 chunks) 40.0 ms 6.0 ms 6.6x
10 MB (161 chunks) 152.1 ms 12.4 ms 12.3x

The SDK column grows ~quadratically with message size (5→10 MB: 2x size, ~3.8x time) because each incoming chunk re-scans and re-copies the whole buffered content; the replacement scans only the new chunk, so it stays linear. Also reported upstream: agentclientprotocol/typescript-sdk#206

🤖 Generated with Qwen Code

qwen-code-dev-bot pushed a commit to chiga0/qwen-code that referenced this pull request Jul 4, 2026
…ation (QwenLM#6310)

* perf(cli): cache LoadedSettings per workspace with stat-based invalidation

The ACP child under `qwen serve` is long-lived and re-runs a full
loadSettings() on the shared event loop for every session/new,
session/load and session/resume: four settings files read, parsed,
migration-checked and structuredClone'd, the .env tree walked, home
.env re-read, ${VAR} references re-resolved, and all scopes merged.
Same-cwd repeat sessions (the typical serve workload) pay full price
every time.

Add a process-level cache keyed by resolved workspace dir (LRU 64).
Freshness is checked deterministically on every access via a
fingerprint of every filesystem input: stat signatures
(mtimeMs:size:ino) of the four settings files, the re-discovered .env
file list with signatures, IDE trust, realpath(cwd) and
realpath(homedir). Any change -> full reload; fingerprint errors fail
open to a reload; loadSettings() throws propagate uncached.

Only the three hot ACP session handlers switch to loadSettingsCached();
all other loadSettings() callers (ext-methods write paths etc.) keep
their direct read semantics.

Known accepted differences (documented in the module doc): direct
process.env mutation without any file change does not re-bake ${VAR}
references on a hit; a .env edit racing the miss-path load itself is
the usual mtime-cache TOCTOU microsecond window; an in-place overwrite
preserving mtime+size+ino is invisible (self-writes go through
temp+rename, which changes the inode).

Part of the qwen serve multi-session performance work (QwenLM#6263).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* test(cli): add IDE trust flip invalidation test for settings cache

Covers the ideTrust fingerprint component, which is the only trust input
that can change within a live process (trustedFolders.json is a permanent
singleton, folder-trust toggles live in the settings files). Addresses a
Copilot review suggestion to guard against stale-cache trust regressions.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* refactor(cli): harden settings cache observability and fail-open coverage

Addresses three review suggestions:
- Warn comment on settingsFileSigs that the 4-scope path list must stay in
  sync with loadSettings() (unlike envFileSigs, it is enumerated separately).
- Add a createDebugLogger('SETTINGS_CACHE'), matching the SETTINGS /
  SETTINGS_WATCHER / CONFIG convention in neighbouring config modules, and
  log hit/miss, each fail-open catch (with the swallowed error), and eviction.
- Add a fault-injection test asserting the cache reloads (never throws) when
  the fingerprint check fails, then recovers once the fault clears.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
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.

4 participants