Skip to content

fix(core): bound foreground shell output capture#4524

Open
Jerry2003826 wants to merge 2 commits into
QwenLM:mainfrom
Jerry2003826:Jiarui/fix-large-foreground-stdout
Open

fix(core): bound foreground shell output capture#4524
Jerry2003826 wants to merge 2 commits into
QwenLM:mainfrom
Jerry2003826:Jiarui/fix-large-foreground-stdout

Conversation

@Jerry2003826

@Jerry2003826 Jerry2003826 commented May 26, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Bounds foreground shell output retained in memory and adds a capture-limit notice when the retained output is truncated.

Why it's needed

Very large foreground stdout/stderr can consume excessive memory and make the session unstable if retained unbounded. This is separate from higher-level tool-output truncation: the process still drains and streamed output still reaches live consumers, but the final retained rawOutput / decoded result is bounded.

Reviewer Test Plan

How to verify

Run the regression checks:

npm test --workspace=packages/core -- src/services/shellExecutionService.test.ts -t "buffer|capture-limit|maxBufferedOutputBytes"
npm test --workspace=packages/core -- src/services/shellExecutionService.test.ts
npx eslint packages/core/src/services/shellExecutionService.ts packages/core/src/services/shellExecutionService.test.ts
npx prettier --check packages/core/src/services/shellExecutionService.ts packages/core/src/services/shellExecutionService.test.ts
npm run typecheck --workspace=packages/core

Minimal manual reproduction on macOS/Linux:

# Terminal 1: run a foreground command that emits a large stdout stream.
qwen -p 'run: cat /dev/urandom | base64 | head -c 50000000'

# Terminal 2: watch the qwen/node process memory while the command runs.
while true; do ps -o pid,rss,command -p "$(pgrep -f 'qwen' | head -n 1)"; sleep 1; done

Equivalent cross-platform payload for direct shell-service/manual testing:

node -e "process.stdout.write('a'.repeat(50 * 1024 * 1024))"

On Windows, monitor node RSS with:

Get-Process node | Sort-Object WorkingSet64 -Descending | Select-Object -First 5 Id,@{n='RSSMB';e={[math]::Round($_.WorkingSet64/1MB,1)}},ProcessName

Evidence (Before & After)

Before this PR, foreground output capture appended every stdout/stderr chunk into retained output buffers. A command emitting 50 MB retained roughly 50 MB for final rawOutput / decoded result; longer streams continued growing toward V8 string/memory limits.

After this PR, retained final output is bounded by maxBufferedOutputBytes, while the child process is still fully drained and live streaming behavior remains unchanged.

Maintainer real-I/O verification on the PR branch used a 200 MB stdout stream with maxBufferedOutputBytes = 1 MB:

Scenario Input size Capture cap Result
PTY / child_process large stdout 200 MB 1 MB rawOutput.length === 1,048,576 bytes
Capture-limit notice 200 MB 1 MB notice reports captured first 1.0 MB of 200.0 MB
Heap headroom 200 MB 1 MB heap delta approximately 0 MB while process drains
Exact boundary 1 MB 1 MB no false capture-limit notice
Streaming stdout 5 MB 1 MB live consumer receives full stream; final retained output is capped

The 2 MB normal-use case is not regressed: 2 MB is well below the 64 MB default cap, so it remains fully retained unless a user explicitly configures a smaller cap.

Cap value rationale

The default maxBufferedOutputBytes is 64 MB because it is:

  • high enough for normal command output, logs, and examples such as a 2 MB git log to remain intact;
  • low enough to prevent runaway foreground commands from retaining hundreds of MB or approaching V8 string limits;
  • only a cap on retained final output, not on process execution or live streamed output;
  • configurable for users who need a larger or smaller retained-output budget.

Invalid values (0, negative numbers, NaN, Infinity, non-numbers, or undefined) fall back to this default.

Tested on

OS Status
macOS GitHub Actions passed; maintainer real-I/O verification passed
Windows Tested locally; GitHub Actions passed
Linux GitHub Actions passed

Environment (optional)

Local Windows/PowerShell checkout with repository npm workspaces. The memory-specific reproduction is numeric rather than TUI-screenshot based because the behavior is core shell-service retention, not a visible TUI layout change.

Risk & Scope

  • Main risk or tradeoff: very large final retained output is now represented by a bounded prefix plus capture-limit notice.
  • Streamed output and process execution semantics are unchanged.
  • Not validated / out of scope: unrelated shell behavior, public API expansion, UI redesigns, or behavior outside the linked issue scope.
  • Breaking changes / migration notes: none expected; rawOutput JSDoc documents that it is a bounded prefix when the capture limit is exceeded.

Linked Issues

Fixes #4364

@Jerry2003826 Jerry2003826 marked this pull request as ready for review May 26, 2026 00:35

@pomelo-nwu pomelo-nwu 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.

Hi @Jerry2003826, thank you for your continued contributions — 9 PRs in a short time is impressive! 🎉

As we review your changes, we'd like to ask you to update each PR to follow the latest PR template on the main branch. The most important section is the Reviewer Test Plan, which significantly accelerates the review and merge process.

Specifically, for each PR please include:

  • How to verify — clear reproduction steps so a reviewer can confirm the fix/feature
  • Evidence (Before & After) — use the tmux-real-user-testing skill (or manual tmux capture) to show before/after screenshots of the TUI behavior. Side-by-side evidence makes it much faster for maintainers to validate and merge
  • Tested on — fill in the OS table (macOS / Windows / Linux)

PRs with a complete Reviewer Test Plan are prioritized for review — without it, review may be delayed.

You can see the full template at: .github/pull_request_template.md

Thanks again for your effort — looking forward to getting these merged! 🚀

中文说明

你好 @Jerry2003826,感谢你的持续贡献——短时间内提交了 9 个 PR,非常高效!🎉

在 review 过程中,我们希望你能按照 main 分支上最新的 PR 模版更新每个 PR 的描述。其中最关键的部分是 Reviewer Test Plan,它能显著加速审核和合并流程。

具体来说,请为每个 PR 补充:

  • How to verify — 清晰的复现步骤,让 reviewer 能确认修复/功能的效果
  • Evidence (Before & After) — 使用 tmux-real-user-testing skill(或手动 tmux 截取)展示修改前后的 TUI 截图对比,前后对比能让维护者更快地验证和合并
  • Tested on — 填写操作系统测试表格(macOS / Windows / Linux)

有完整 Reviewer Test Plan 的 PR 会被优先审核——缺少该部分可能会导致审核延迟。

完整模版见:.github/pull_request_template.md

再次感谢你的付出,期待尽快把这些 PR 合并!🚀

— Qwen Code

@wenshao wenshao 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.

[Suggestion] rawOutput JSDoc (line 129) says "The raw, unprocessed output buffer" but it's now bounded to maxBufferedOutputBytes. Consider updating the JSDoc to note that the buffer may be incomplete when the limit is exceeded, and optionally adding a rawOutputTruncated?: boolean field to ShellExecutionResult so consumers can detect truncation programmatically.

— qwen3.7-max via Qwen Code /review

{ ...shellExecutionConfig, maxBufferedOutputBytes: 10 },
);

expect(result.rawOutput.length).toBeLessThanOrEqual(10);

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] toBeLessThanOrEqual(10) would pass even if 0 bytes were captured, masking a total capture failure. With 15 bytes sent and limit=10, exactly 10 bytes should be captured.

Suggested change
expect(result.rawOutput.length).toBeLessThanOrEqual(10);
expect(result.rawOutput.length).toBe(10);

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated in 9910ff4. The PTY bounded-output test now asserts rawOutput.length === 10, so a total capture failure cannot pass.


const result = await handle.result;

expect(result.rawOutput.length).toBeLessThanOrEqual(10);

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] Same weak assertion as the PTY test above — toBeLessThanOrEqual(10) should be toBe(10).

Suggested change
expect(result.rawOutput.length).toBeLessThanOrEqual(10);
expect(result.rawOutput.length).toBe(10);

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated in 9910ff4. The child_process bounded-output test now asserts rawOutput.length === 10 exactly.

let outputCaptureLimitExceeded = false;

const captureOutputData = (data: Buffer): Buffer | null => {
totalOutputBytes += data.length;

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] totalOutputBytes is incremented inside captureOutputData, but this closure is only called when !streamStdout. In streaming mode totalOutputBytes stays at 0 forever. The PTY path correctly tracks totalBytesReceived unconditionally in handleOutput (line 1422). Recommend hoisting totalOutputBytes += data.length out of captureOutputData and into handleOutput before the streamStdout branch, matching the PTY pattern. Also consider renaming to totalBytesReceived for consistency.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9910ff4. child_process now increments total byte accounting before the streamStdout branch and captures a bounded raw buffer for streaming output as well. Added a regression test for streamStdout: true with maxBufferedOutputBytes: 1 that asserts the data event still streams while rawOutput is bounded and result.output includes the capture-limit notice.

totalOutputBytes += data.length;

if (capturedOutputBytes >= maxBufferedOutputBytes) {
outputCaptureLimitExceeded = true;

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] No debugLogger.warn() is emitted when the output capture limit is exceeded. The only signal is a bracketed notice string appended to result.output. At 3AM, truncation is invisible to log-based debugging — grepping logs for "truncat", "exceeded", or "discarded" returns nothing.

Consider adding a one-time log when outputCaptureLimitExceeded first transitions to true:

if (captured.length < data.length) {
  outputCaptureLimitExceeded = true;
  debugLogger.warn(
    `Shell output capture exceeded maxBufferedOutputBytes ` +
    `(${maxBufferedOutputBytes} bytes). Total bytes: ${totalOutputBytes}. ` +
    `Discarding excess.`,
  );
}

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 9910ff4. Both PTY and child_process paths now emit a one-time debugLogger.warn() when output capture first exceeds maxBufferedOutputBytes, including the configured limit and total bytes observed.

@@ -337,6 +338,25 @@ describe('ShellExecutionService', () => {
expect(result.output.trim()).toBe('你好');
});

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 two new tests cover the "two chunks, second partially captured" happy path, but three important scenarios are untested:

  1. Exact-boundary case: send exactly maxBufferedOutputBytes (10) bytes, assert no truncation notice appears. Guards against off-by-one regressions in > vs >= comparisons.
  2. getMaxBufferedOutputBytes validation: 6 input classes (0, -1, NaN, Infinity, 'abc', undefined) should all return the 64MB default. Zero coverage on the validation fallback path.
  3. appendOutputCaptureLimitNotice with empty output: the output ? \${output}\n\n${notice}` : notice` branch is untested.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 9910ff4. The test file now covers exact-boundary behavior, invalid maxBufferedOutputBytes fallback values (0, -1, NaN, Infinity, abc, undefined), and the empty stripped-output branch where the capture-limit notice is returned by itself.

@pomelo-nwu pomelo-nwu self-assigned this May 26, 2026
@Jerry2003826 Jerry2003826 force-pushed the Jiarui/fix-large-foreground-stdout branch 2 times, most recently from 25d47d6 to 9910ff4 Compare May 26, 2026 02:01
@Jerry2003826

Copy link
Copy Markdown
Contributor Author

Updated in 9910ff4. This addresses the remaining review items: exact rawOutput length assertions, child_process streaming byte accounting and capture-limit notice, one-time capture-limit debug warning, exact-boundary/default-config/empty-output tests, and the rawOutput JSDoc / PR template update.

Validation run locally on Windows:

npm run test --workspace=@qwen-code/qwen-code-core -- src/services/shellExecutionService.test.ts -t "capture-limit|bounds buffered|default capture|exact"
npm run test --workspace=@qwen-code/qwen-code-core -- src/services/shellExecutionService.test.ts
npx prettier --check packages/core/src/services/shellExecutionService.ts packages/core/src/services/shellExecutionService.test.ts
npx eslint packages/core/src/services/shellExecutionService.ts packages/core/src/services/shellExecutionService.test.ts
npm run typecheck --workspace=@qwen-code/qwen-code-core
git diff --check

@wenshao wenshao added the type/bug Something isn't working as expected label May 26, 2026
@Jerry2003826

Copy link
Copy Markdown
Contributor Author

The JSDoc part is already updated in 9910ff4: rawOutput now documents that the buffer is bounded by maxBufferedOutputBytes and may contain only the retained prefix when the capture limit is exceeded.

I kept rawOutputTruncated out of this PR to avoid expanding the public result shape in a small safety fix. The user-visible decoded output already carries the truncation notice, and this PR's scope is bounding memory growth without changing the API contract.

@Jerry2003826

Copy link
Copy Markdown
Contributor Author

Cleaned up the PR description to match the latest template, removed the stale details block, and updated the Tested on table now that GitHub Actions is green across macOS, Windows, and Linux. Code is unchanged.

wenshao
wenshao previously approved these changes May 26, 2026

@wenshao wenshao 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.

All 5 Suggestions from the prior review (at 29917ead) are addressed in this revision. The bounded capture implementation is sound across all exit paths and execution modes. 274 tests pass. LGTM! ✅ — qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented May 27, 2026

Copy link
Copy Markdown
Collaborator

本地验证报告(maintainer review)

把 PR head 直接 git merge 到当前 origin/main641a1a739)做 "合并后状态" 模拟。Merge 干净,diff 仍是 3 文件 +373/-25,与 PR 描述一致。

结论

✅ 建议 MERGE。PR 自带的全部单测在 macOS 本机复现通过;用真实 child_process / PTY 流过 200MB 输出做了 memory-headroom 实测 —— 在 cap=1MB 配置下,rawOutput 严格止于 1MB、堆增量 ≈0MB,capture-limit notice 精确报告 "captured the first 1.0 MB of 200.0 MB"。Issue #4364 描述的 "foreground 输出累计直至 V8 字符串上限" 路径被这次改动彻底切断。


验证矩阵

检查 结果 备注
packages/core shellExecutionService.test.ts 全量 95 / 95 含本 PR 新增 8 条 capture-limit / fallback / streaming 用例
packages/core shellExecutionService.test.ts 仅新增(-t "capture-limit|bounds buffered|default capture|exact|empty captured|streaming" 12 / 12 PR 描述列出的 reviewer 验证点全部对位
packages/core config.test.ts 179 / 179 maxBufferedOutputBytes 通过 getShellExecutionConfig 链进 shellExecutionConfig 无回归
packages/core npm run typecheck 0 errors tsc --noEmit 全过
packages/core npm run lint 0 errors eslint 全过
npx prettier --check 三个改动文件 All matched files use Prettier code style!
npx eslint 三个改动文件 0 issues
git diff origin/main --check clean 无 trailing whitespace / mixed indent
npm run build(root,全 workspace) ✅ pass 1373 deps;dist 产物正常生成;非 PR-相关 vscode-ide-companion 中已存在 15 warnings 与本 PR 无关

真机 E2E(直接驱动 ShellExecutionService.execute(...),非 mock)

跑在 tmux 会话 pr4524,driver 在 /private/tmp/pr4524-merged/.qwen/scripts/pr4524/

realio-shell-bound.mjs(17 / 17 pass,1.3s)

# 场景 期望 实测
1 PTY + dd ...| tr '\0' 'a' 5MB(无 \n,避开 PTY \n→\r\n 扩展),cap=1MB rawOutput.length=1MB + capture-limit notice ✅ rawOutput 精确 1048576 bytes;notice = Output exceeded the maximum captured size of 1.0 MB; captured the first 1.0 MB of X MB ...
2 PTY 1MB 无换行流,cap=1MB 不附 notice(精确边界) ✅ rawOutput=1MB,无 notice
3 child_process yes a | head -c 5MB,cap=1MB rawOutput 限制 + notice + 总 size 准确 ✅ rawOutput=1MB,notice 报告 "of 5.0 MB"
4 child_process maxBufferedOutputBytes=-1(非法值),1MB 输出 回退默认 64MB → 不附 notice ✅ rawOutput=1MB,无 notice
5 child_process streaming streamStdout: true,cap=1MB,5MB 输出 live consumer 拿到全量、rawOutput 仍受限、final result 含 notice ✅ live 收到 ≥ 5MB-4KB;rawOutput=1MB;最终 output 含 notice
6 child_process 默认配置(不传 maxBufferedOutputBytes),1MB 输出 远低于 64MB 默认 → 不附 notice

memory-headroom.mjs(200MB 输入 → 1MB cap,4 / 4 pass)

指标
输入命令 dd if=/dev/zero bs=1M count=200 | tr '\0' 'a'(200 MB 真实流)
maxBufferedOutputBytes 1 MB
rawOutput.length 1048576 bytes (1.00 MB) ← 严格止住
capture-limit notice Output exceeded the maximum captured size of 1.0 MB; captured the first 1.0 MB of 200.0 MB and discarded the rest...
heap before / peak 57 MB / 57 MB(Δ=0 MB
RSS before / peak 158 MB / 197 MB(Δ ≈ 40 MB,多数是 dd/tr 子进程 + 临时 buffer)
elapsed 9.1 s
exitCode 0

要点:把 cap 设成 1MB 后,200MB 输入 流过来,进程依然完整 drain(exitCode=0),但 V8 堆几乎纹丝不动。这是这个 PR 价值的硬证据 —— issue #4364 的"V8 max-string blowup"路径已被切断。


代码评审要点

  • maxBufferedOutputBytes 配置贯通完整Config.constructor + getShellExecutionConfig 两处都加了对称传递,CLI 端调 setShellExecutionConfig 时也能下发;179 / 179 config 测试无回归。
  • getMaxBufferedOutputBytes() 的非法值兜底很严typeof === 'number' && Number.isFinite() && > 0 同时通过才采纳,否则一律回 64MB 默认。it.each([0, -1, NaN, Infinity, 'abc', undefined]) 单测 + 真机 -1 实测都验证回退正确,避免 用户写错配置时 cap 变成 0 或 -1 导致 captureOutputData 死锁。
  • sniff buffer 与最终 buffer 解耦:旧实现 outputChunks 同时承担 binary-sniff + 最终 result decode,导致 sniff 完后必须强制清空 outputChunks.length = 0;新实现拆出 sniffChunksoutputChunks 单纯做 capture-budget 内 buffer。逻辑边界更干净。
  • PTY background-promote 路径同步限制shellExecutionService.ts:1911 的 background-promote snapshot 也走 appendOutputCaptureLimitNotice(与正常 exit 用同一辅助函数共 4 处调用::748:868:1567:1911),进程被 promote 成后台时前 1MB 仍准确;JSDoc 把 rawOutput 说成 "bounded prefix buffer" 与运行时一致。
  • streaming text mode 的处理细腻streamStdout: true 时 live consumer 仍拿到全量 chunk(不会因为 cap 截断 UI 流),但 rawOutput 与 final result output 仍受 cap 约束。e2e 实测确认两路都对。
  • debug warning 一次性outputCaptureLimitWarningEmitted 守卫,避免日志洪泛。
  • exact-boundary 不假阳性captured.length < data.length 判断只在确实裁剪时 mark exceeded —— 避免 1MB 数据 + cap=1MB 时错误标记。单测 + PTY/child_process 真机各覆盖一遍。

风险与遗留

  • 向后兼容maxBufferedOutputBytes 是新增的可选字段,未配置时走 64MB 默认;现有调用零行为变化(除非命令真的产出 > 64MB,那本来就有崩溃风险)。
  • rawOutput 语义变更已显式记入文档:JSDoc 改为"bounded by maxBufferedOutputBytes, may contain only the retained prefix when the capture limit is exceeded",调用方需要全量 bytes 时应自行 streaming,符合 PR 的 risk note。
  • ⚠️ 仅 macOS arm64 真机验证(Node 22.17);Linux / Windows 走 GitHub Actions 已绿。shellExecutionService 在 PTY 路径有平台分叉但本 PR 改动是 buffer accounting,不涉及 syscall。
  • ⚠️ background-promote 的 capture-limit 处理走的是和正常 exit 同一套 appendOutputCaptureLimitNotice,但本机未单独构造 promote 触发的 e2e(单测 + 代码 review 已覆盖)。
  • 没有触及 truncateToolOutputThreshold:PR 明确把"上层 tool-output truncation"和"底层 buffer cap"区分开,不重叠。

验证环境:macOS Darwin 25.4.0 arm64,Node v22.17.0;tmux 会话 pr4524;merged worktree /private/tmp/pr4524-merged(branch pr-4524-test = PR head + git merge origin/main,merge commit d60603000);driver 脚本 realio-shell-bound.mjs 17 assertion + memory-headroom.mjs 4 assertion 全绿。— wenshao

@yiliang114

Copy link
Copy Markdown
Collaborator

Hi @Jerry2003826, thanks for the fix direction — bounding foreground shell output is definitely needed for long-running sessions.

To help us move this forward, could you add a concrete reproduction case? Specifically:

  1. Reproduction steps — a minimal command sequence that triggers the unbounded growth. For example:

    qwen -p "run: cat /dev/urandom | base64 | head -c 50000000"

    Then observe RSS / heap growth in another terminal (ps aux | grep node).

  2. Before/After evidence — show the memory difference with and without your cap. Even a simple process.memoryUsage() log or ps output comparison would work. TUI screenshots are not needed for core logic, but the numbers are.

  3. Cap value rationale — why did you choose the specific maxBufferedOutputBytes default? Is it based on a typical terminal buffer, a V8 string limit, or something else?

The code itself looks reasonable, but without repro evidence we can't validate the threshold or confirm it doesn't regress normal use cases (e.g., a legitimate 2 MB git log output that the user needs to see in full).

Thanks!

@Jerry2003826

Copy link
Copy Markdown
Contributor Author

Updated the PR description with the requested reviewer evidence:

  • added a minimal reproduction command for large foreground stdout plus ps / PowerShell RSS monitoring commands
  • added before/after evidence using the maintainer real-I/O verification: 200 MB stdout with a 1 MB cap retains
    awOutput.length === 1,048,576, reports the capture-limit notice, and keeps heap growth approximately flat while draining the process
  • clarified the 64 MB default: it keeps normal multi-MB output such as a 2 MB git log intact, while bounding runaway retained output far below V8 string/memory limits; users can still configure a different retained-output budget

No code changes were needed for this update; existing CI and the maintainer verification remain applicable.

BZ-D
BZ-D previously approved these changes Jun 1, 2026

@BZ-D BZ-D 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. Output capture bounding logic is clean — sniff/capture separation is correct, streaming vs buffered paths are well-handled, and the appendOutputCaptureLimitNotice helper keeps the notice consistent across PTY, child_process, and background-promote paths. Tests cover boundary conditions (exact limit, invalid config values, streaming mode) thoroughly.

disableDynamicLineTrimming?: boolean;
}

function getMaxBufferedOutputBytes(config: ShellExecutionConfig): number {

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] getMaxBufferedOutputBytes validates that the value is a positive finite number but imposes no upper ceiling. A user (or a corrupted settings file) setting maxBufferedOutputBytes to a value near Node.js's buffer.constants.MAX_LENGTH (~2 GB) would cause Buffer.concat(outputChunks) to throw at finalization, and values in the hundreds of MB could trigger V8 string-length overflow in the child_process path's stdout += decodedChunk concatenation. Since the PR's stated purpose is bounding memory, the configurable limit itself can defeat that purpose when misconfigured.

Consider clamping to a safe ceiling:

const SAFE_CEILING = 256 * 1024 * 1024; // 256 MB
return Math.min(Math.floor(configured), SAFE_CEILING);

— qwen-latest-series-invite-beta-v38 via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Covered in the current branch. getMaxBufferedOutputBytes() now clamps positive finite values to MAX_BUFFERED_OUTPUT_BYTES_CEILING (256 MiB), so pathological user settings cannot request multi-GB retained buffers. Verified with the targeted shellExecutionService test, Prettier, and ESLint.

// Ignore fallback rendering errors and resolve with empty text.
}
}
fullOutput = appendOutputCaptureLimitNotice(

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] In the PTY finalize path, when the primary replayTerminalOutput (line 1552) throws, the catch fallback at line 1562 calls serializeTerminalToText(headlessTerminal). The headless terminal received every byte via headlessTerminal.write(data) regardless of the capture limit, so this fallback produces unbounded output — yet the appendOutputCaptureLimitNotice call right below still appends a notice saying "captured the first 64 MB ... and discarded the rest." The notice becomes misleading when the output actually contains more data.

The same pattern exists in the background-promote drain path (line 1891 fallback + line 1908 notice).

Consider decoding finalBuffer (the bounded buffer) in the fallback instead of serializing the full headless terminal:

} catch {
  try {
    const fallbackEncoding = getCachedEncodingForBuffer(finalBuffer);
    fullOutput = new TextDecoder(fallbackEncoding).decode(finalBuffer);
  } catch {
    // Ignore fallback rendering errors and resolve with empty text.
  }
}

— qwen-latest-series-invite-beta-v38 via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Covered in the current branch. The PTY replay fallback no longer serializes the full headlessTerminal; it falls back to decoding the bounded captured finalBuffer, then appends the capture-limit notice. Added keeps PTY replay fallback bounded after the capture limit is exceeded. Verified with the targeted shellExecutionService test, Prettier, and ESLint.

@Jerry2003826 Jerry2003826 dismissed stale reviews from BZ-D and wenshao via 16f2e34 June 1, 2026 14:59
@Jerry2003826 Jerry2003826 force-pushed the Jiarui/fix-large-foreground-stdout branch from 9910ff4 to 16f2e34 Compare June 1, 2026 14:59
wenshao
wenshao previously approved these changes Jun 2, 2026

@wenshao wenshao 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 issues found. LGTM! ✅ — qwen-latest-series-invite-beta-v38 via Qwen Code /review

BZ-D
BZ-D previously approved these changes Jun 2, 2026

@BZ-D BZ-D 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.

Reviewed the diff and ran the targeted shell execution tests; no blocking issues found.

@Jerry2003826 Jerry2003826 dismissed stale reviews from BZ-D and wenshao via 1f03e74 June 4, 2026 09:22
@Jerry2003826

Copy link
Copy Markdown
Contributor Author

Resolved the merge conflict with latest main and pushed the updated branch.

Conflict resolution:

  • Kept the existing bounded foreground shell output capture changes.
  • Preserved the new upstream shell context env import/path in shellExecutionService.ts.

Validation run locally:

  • npm run test --workspace=@qwen-code/qwen-code-core -- src/services/shellExecutionService.test.ts -t "capture-limit|bounds buffered|default capture|exact"
  • npm run test --workspace=@qwen-code/qwen-code-core -- src/services/shellExecutionService.test.ts
  • npx prettier --check packages/core/src/services/shellExecutionService.ts packages/core/src/services/shellExecutionService.test.ts
  • npx eslint packages/core/src/services/shellExecutionService.ts packages/core/src/services/shellExecutionService.test.ts
  • npm run typecheck --workspace=@qwen-code/qwen-code-core
  • git diff --check

Note: after merging latest main, I refreshed dependencies with npm install --ignore-scripts and manually applied npx patch-package so local typecheck used the current lockfile dependency versions.

@Jerry2003826

Copy link
Copy Markdown
Contributor Author

Cleaned up the remaining garbled details block in the PR description. The requested reproduction steps, before/after memory evidence, cap rationale, and OS table remain in the Reviewer Test Plan. Code unchanged; current CI is green.

DragonnZhang
DragonnZhang previously approved these changes Jun 8, 2026

@DragonnZhang DragonnZhang 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.

Reviewed the full diff (3 files, +407/-30) and the prior review thread.

Summary: Bounds foreground shell output retained in memory via maxBufferedOutputBytes (default 64 MB, ceiling 256 MB). The process is still fully drained and streaming consumers still receive the complete output; only the final retained rawOutput / decoded result is bounded. A capture-limit notice is appended when truncation occurs.

Assessment:

  • Bounded capture is correctly applied across all exit paths: normal child_process exit, normal PTY exit, PTY background-promote snapshot, and PTY replay fallback.
  • Sniff/capture separation is clean — sniffChunks for binary detection, outputChunks for bounded capture. The old shared-array bug (recomputing sniffedBytes from slice(0, 20) on every call) is fixed as a side-effect.
  • getMaxBufferedOutputBytes validates input thoroughly: rejects 0, negative, NaN, Infinity, non-numbers; clamps to 256 MB ceiling.
  • One-time debugLogger.warn() fires when the capture limit is first exceeded — good for observability.
  • Test coverage is comprehensive: exact-boundary behavior, invalid config fallback (6 values), empty stripped-output, streaming + capture-limit, PTY replay fallback, and child_process streaming byte accounting.
  • All 7 prior inline review comments are addressed in this revision.
  • Deterministic checks (tsc, eslint): 0 findings attributed to this PR's changes.

No blocking issues found.

— qwen-code via Qwen Code /review

const configured = config.maxBufferedOutputBytes;
return typeof configured === 'number' &&
Number.isFinite(configured) &&
configured > 0

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] A fractional value like 0.5 passes the > 0 guard but Math.floor(0.5) produces 0, making captureOutputData immediately return null on every chunk — all output is silently discarded. This is inconsistent with 0 itself, which correctly falls back to the 64 MB default.

Floor first, then check positivity:

Suggested change
configured > 0
function getMaxBufferedOutputBytes(config: ShellExecutionConfig): number {
const configured = config.maxBufferedOutputBytes;
const floored =
typeof configured === 'number' && Number.isFinite(configured)
? Math.floor(configured)
: 0;
return floored > 0
? Math.min(floored, MAX_BUFFERED_OUTPUT_BYTES_CEILING)
: DEFAULT_MAX_BUFFERED_OUTPUT_BYTES;
}

— qwen3.7-plus via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Handled in the current branch. getMaxBufferedOutputBytes() now floors finite numeric input before checking positivity, so fractional values below 1 fall back to the default capture limit instead of becoming a zero-byte limit.

Added 0.5 to the invalid maxBufferedOutputBytes regression cases.

Validated:

  • npm run test --workspace=packages/core -- src/services/shellExecutionService.test.ts -t "invalid maxBufferedOutputBytes"
  • npm run test --workspace=packages/core -- src/services/shellExecutionService.test.ts
  • npx prettier --check packages/core/src/services/shellExecutionService.ts packages/core/src/services/shellExecutionService.test.ts
  • npx eslint packages/core/src/services/shellExecutionService.ts packages/core/src/services/shellExecutionService.test.ts

…ground-stdout

# Conflicts:
#	packages/core/src/services/shellExecutionService.ts

@DragonnZhang DragonnZhang 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.

Reviewed the full diff (3 files, +417/-30) and prior review thread.

Summary: Bounds foreground shell output retained in memory via maxBufferedOutputBytes (default 64 MB, ceiling 256 MB). The process is still fully drained and live streaming is unaffected; only the final retained rawOutput / decoded result is capped.

What looks good:

  • Clean separation of sniff and capture buffers, so binary detection and bounded capture are independent concerns.
  • getMaxBufferedOutputBytes handles all invalid-input edge cases (0, negative, NaN, Infinity, fractional, non-number, undefined) with a single coherent validation path.
  • Capture-limit notice is applied consistently across all four resolution paths: child_process normal exit, child_process background-promote, PTY normal exit, and PTY background-promote.
  • PTY replay fallback now decodes the bounded captured buffer instead of serializing the full headless terminal, closing the last unbounded path.
  • One-time debugLogger.warn() on capture-limit breach leaves a diagnostic trail without log spam.
  • Test coverage is thorough: exact-boundary, invalid-config parametrized, streaming with capture limit, empty-stripped-output, and replay-fallback scenarios.

All prior review suggestions have been addressed. CI green (24/24). LGTM.

— qwen3-coder via Qwen Code /review

@Jerry2003826

Copy link
Copy Markdown
Contributor Author

Added the concrete reproduction details to the Reviewer Test Plan in the PR description.

It now includes:

  • a minimal foreground-output reproduction command;
  • cross-platform direct shell-service payload;
  • Windows RSS monitoring command;
  • before/after evidence with the 200 MB stdout / 1 MB cap verification;
  • OS test table.

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

Labels

type/bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multi-GiB foreground stdout can fail with V8 string-length fatal or empty stdout

6 participants