Skip to content

fix(serve): allow bounded reads of large text files - #7947

Merged
doudouOUC merged 6 commits into
QwenLM:mainfrom
doudouOUC:agent/serve-large-text-range-read
Jul 29, 2026
Merged

fix(serve): allow bounded reads of large text files#7947
doudouOUC merged 6 commits into
QwenLM:mainfrom
doudouOUC:agent/serve-large-text-range-read

Conversation

@doudouOUC

@doudouOUC doudouOUC commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This PR fixes Serve/ACP text reads for files larger than 256 KiB without
weakening the existing full-snapshot safety gate.

  • A large UTF-8 text file enters the streaming range path only when the request
    has a finite positive integer limit. No-window, line-only, maxBytes-only,
    and line-plus-maxBytes requests still return file_too_large.
  • Returned UTF-8 content is capped at
    min(maxBytes ?? 256 KiB, 256 KiB), and the WorkspaceFileSystem boundary
    validates maxBytes in [1, 262144].
  • The new concrete StandardFileSystemService.readTextFileFromHandle method
    reuses Core's range reader with a caller-owned FileHandle; it does not
    expand the generic filesystem interface or ACP wire schema.
  • Encoding detection and range scanning use the same opened handle. Reads stop
    at the open-time size and reuse one 512 KiB buffer.
  • Serve checks device/inode, size, mtime, and ctime before and after streaming,
    including both the open handle and the pathname. Concurrent append,
    truncation, same-size rewrite (even with restored mtime), path replacement,
    and symlink replacement are rejected before content is returned.
  • Large partial responses retain the complete file size, set
    truncated: true, omit the full-file hash, and expose
    originalLineCount only when EOF made it exact. REST serializes an unknown
    count as null.
  • Stable binary content remains binary_file; unsupported large non-UTF-8
    text remains file_too_large with a UTF-8 conversion hint.

The existing 256 KiB full-snapshot paths used by unbounded reads, edit, hash,
and optimistic locking are unchanged. Small full-snapshot reads also now apply
maxBytes after decoding, so UTF-16/32 expansion cannot exceed the returned
UTF-8 byte cap.

Why it's needed

Core gained streaming text-range reads in #6404, but Serve still rejected a
file above 256 KiB before applying its finite line window. As a result,
read_file requests such as { limit: 20 } failed with file_too_large and
fell back to shell commands even though both the requested line count and
returned bytes were bounded.

The initial follow-up attempted to admit any explicit window and imposed an
8 MiB scan budget. That made deep line offsets unreachable without a cursor
and broadened the contract beyond this bug. This version keeps admission tied
to a finite limit and removes the arbitrary scan cap while preserving bounded
memory and output.

Reviewer Test Plan

How to verify

  1. Create a regular UTF-8 CSV larger than 256 KiB.
  2. Read it with { limit: 20 } and { line: 3, limit: 20 }; both should
    succeed on the first read, return at most 256 KiB, report the complete
    source size and truncated: true, and omit hash.
  3. Repeat with no options, line-only, maxBytes-only, and line-plus-maxBytes;
    each should retain the existing file_too_large refusal.
  4. Read a finite window whose line offset is beyond 10 MiB; it should succeed
    rather than fail at an arbitrary scan budget.
  5. Exercise oversized binary, large non-UTF-8, BOM/CRLF, mixed-EOL, beyond-EOF,
    and long CJK/emoji-line cases.
  6. During a streamed read, append, truncate, rewrite in place, restore mtime
    after a same-size rewrite, replace the pathname, and replace it with a
    symlink. Expect hash_mismatch or symlink_escape, never mixed content.
  7. Verify the injected ACP adapter and GET /file route carry the same
    behavior; a partial REST response should omit hash and serialize an
    unknown originalLineCount as null.

The manual model-flow plan is recorded in
.qwen/e2e-tests/2026-07-28-serve-large-text-range-read.md.

Evidence

  • Core range/service: 71/71
  • WorkspaceFileSystem: 106/106
  • ACP adapter: 21/21
  • HTTP route: 40/40
  • Focused total: 238/238
  • Core and CLI lint: pass
  • Root typecheck: pass
  • Full repository build: pass
  • Prettier and git diff --check: pass

Tested on

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

Risk & Scope

  • Deep line offsets scan from byte zero and are therefore O(file size). Memory
    and returned content remain bounded; a future scan-cost policy should add a
    cursor/continuation and request cancellation instead of silently making deep
    offsets unreachable.
  • Reliable append-tolerant snapshots, streaming large non-UTF-8 files, and
    editing files above the full-snapshot cap remain out of scope.
  • No wire-schema, error-enum, configuration, or migration change is required.

Linked Issues

Fixes #7946

中文说明

本 PR 的改动

本 PR 在不放宽现有 256 KiB 全量快照安全门槛的前提下,修复 Serve/ACP
读取大型文本范围失败的问题。

  • 只有请求包含有限正整数 limit 时,大于 256 KiB 的 UTF-8 文本才进入
    流式范围读取。无窗口、仅 line、仅 maxBytes、line 加 maxBytes 仍返回
    file_too_large
  • 返回 UTF-8 内容固定不超过
    min(maxBytes ?? 256 KiB, 256 KiB),WorkspaceFileSystem 同时校验
    maxBytes 必须位于 [1, 262144]
  • Core 新增具体方法 StandardFileSystemService.readTextFileFromHandle
    复用现有范围读取器和调用方持有的 FileHandle,不修改通用文件系统接口和
    ACP wire schema。
  • 编码探测与行扫描使用同一个已打开句柄,读取不会越过打开时的文件大小,
    并复用一个 512 KiB 缓冲区。
  • Serve 在流式读取前后校验 device/inode、size、mtime、ctime,以及句柄和
    pathname 的一致性。追加、截断、同大小覆盖(包括恢复 mtime)、路径替换和
    符号链接替换都会在返回内容前被拒绝。
  • 大文件部分响应保留完整文件大小、固定 truncated: true、不返回全文件
    hash;只有扫描到 EOF 时才返回精确 originalLineCount,REST 对未知值
    固定返回 null
  • 稳定的二进制仍返回 binary_file;不支持的大型非 UTF-8 文本仍映射为
    file_too_large,并提示转换为 UTF-8。

无界读取、编辑、hash 和乐观锁仍走原有全量快照路径。小文件全量快照现在也会
在解码后执行 maxBytes 限制,避免 UTF-16/32 解码扩张突破返回字节上限。

为什么需要

Core 已在 #6404 支持流式文本范围读取,但 Serve 会在应用有限行窗口之前,
直接拒绝超过 256 KiB 的文件。因此 { limit: 20 } 这样的 read_file
请求虽然返回量有界,仍会收到 file_too_large 并回退到 shell。

此前的后续提交将任意显式窗口都视为准入条件,并增加了 8 MiB 扫描预算;
这会让没有 cursor 的深行偏移不可达,也扩大了本次修复的契约。本版本仅以
有限 limit 解锁范围读取,移除任意扫描上限,同时保持内存和返回内容有界。

验证证据

  • Core range/service:71/71
  • WorkspaceFileSystem:106/106
  • ACP adapter:21/21
  • HTTP route:40/40
  • 定向测试合计:238/238
  • Core/CLI lint、根目录 typecheck、全仓 build、Prettier 和
    git diff --check:全部通过

人工模型链路验证计划记录在
.qwen/e2e-tests/2026-07-28-serve-large-text-range-read.md

风险与范围

  • 深行偏移需要从文件开头扫描,时间复杂度为 O(file size),但内存和返回内容
    有界。未来如需扫描成本上限,应先增加 cursor/continuation 与请求取消,
    而不是让合法深偏移静默变得不可达。
  • 可靠的 append-tolerant 快照、大型非 UTF-8 流式读取,以及超过全量快照
    上限的文件编辑不在本次范围。
  • 无 wire schema、错误枚举、配置或迁移变更。

关联 Issue

Fixes #7946

@doudouOUC

doudouOUC commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Serve large-text range read verification

Goal

Verify that qwen serve can return a finite line window from a UTF-8 text file
larger than 256 KiB without weakening full-snapshot, binary, workspace, or
snapshot-consistency gates.

Scenarios

  1. Read a UTF-8 CSV larger than 256 KiB with limit: 20 and with
    line: 3, limit: 20. Expect the requested rows on the first attempt and no
    shell fallback.
  2. Call GET /file with line=3&limit=20. Expect HTTP 200,
    truncated: true, the complete sizeBytes, no hash, and
    originalLineCount: null when scanning stops before EOF.
  3. Read the same large file without options, with only line, with only
    maxBytes, and with line plus maxBytes but no limit. Expect
    file_too_large.
  4. Read a finite window whose offset is beyond 10 MiB. Expect success; there is
    no arbitrary scan-byte budget.
  5. Read oversized binary and non-UTF-8 fixtures. Expect binary_file and
    file_too_large, respectively.
  6. Mutate the file while reading: append, truncate, same-size overwrite,
    same-size overwrite followed by mtime restoration, pathname replacement,
    and symlink replacement. Expect hash_mismatch or symlink_escape, never a
    successful mixed snapshot.

Results

  • Baseline reproduction on origin/main: a 406,889-byte UTF-8 CSV read with
    { line: 1, limit: 20 } returned file_too_large at the 262,144-byte
    full-snapshot cap.
  • PR branch: { limit: 20 }, { line: 100, limit: 20 }, and a deep offset
    beyond 10 MiB returned the requested windows. No-window, line-only,
    maxBytes-only, and line-plus-maxBytes retained file_too_large.
  • Core range/service: 71/71.
  • WorkspaceFileSystem: 106/106.
  • ACP adapter: 21/21.
  • HTTP route: 40/40.
  • Focused total: 238/238.
  • Core and CLI lint: passed.
  • Root typecheck and full repository build: passed.
  • Prettier and git diff --check: passed.
  • Snapshot coverage passed for open-time size bounding, 512 KiB buffer reuse,
    same-size mutation with restored mtime, mutation-over-decode-error priority,
    path/symlink replacement, and caller-owned handle lifetime.

Commands

cd packages/core
npx vitest run src/utils/read-text-range.test.ts src/services/fileSystemService.test.ts

cd ../cli
npx vitest run src/serve/fs/workspace-file-system.test.ts \
  src/serve/bridge-file-system-adapter.test.ts \
  src/serve/routes/workspace-file-read.test.ts

cd ../..
npm run typecheck
npm run build

The manual model-flow plan remains in
.qwen/e2e-tests/2026-07-28-serve-large-text-range-read.md.

@doudouOUC
doudouOUC force-pushed the agent/serve-large-text-range-read branch from 65d9924 to 748c888 Compare July 28, 2026 15:47
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Jul 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head f90c6be, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 4 scenario(s).

Qwen Code · serve A/B

…s set

Follow-up to the bounded large-text read path. Three changes:

Gate on any explicit window argument, not on `limit`. Gating on `limit`
had the cost model backwards in both directions: `{ line: 900_000_000,
limit: 20 }` was admitted despite walking the whole file, while
`{ maxBytes: 4096 }` — satisfiable from the first 4 KiB — was refused. A
read with no window argument at all still fails, since a caller that
believes it holds the whole file may write it back truncated.

Add MAX_TEXT_SCAN_BYTES (8 MiB). MAX_READ_BYTES caps what a read
returns; nothing capped what it cost. Line offsets are resolved by
scanning from byte 0, so a query param could turn into an
uninterruptible multi-second scan of an arbitrarily large file — and on
Windows hold a read handle for that span, blocking renames and deletes.
Past the budget the read is refused with `file_too_large` pointing at
readBytes, which reaches any offset in O(1).

Tolerate appends on streamed windows. Requiring whole-file size/mtime
stability after reading a prefix rejected reads whose returned bytes
were still valid, and the case it rejected — tailing a live log — is the
one this path exists for. Streamed windows now assert inode identity
plus "did not shrink"; truncation and replacement are still rejected.

Also: non-UTF-8 large text now returns `binary_file` rather than
`file_too_large`, so a client retrying on 413 with a smaller window
can't loop forever; and `readFileWithLineAndLimit` throws instead of
silently ignoring a caller-supplied `fileHandle` on the by-path
fallback.

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

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Self-review notes on e784e6d — three changes to the read policy, plus the gaps I'm knowingly leaving.

Why the gate moved off limit

The original gate asked "did the caller pass a finite limit?" But what a read costs isn't the limit — line offsets are resolved by scanning from byte 0, so limit says nothing about how far we walk. Gating on it got both directions wrong:

Request Actual cost Old behavior
line: 900000000, limit: 20 on 10 GB full scan, ~33 s admitted
maxBytes: 4096 first 4 KiB file_too_large
line: 2 (no limit) output capped at 256 KiB anyway file_too_large

Now any explicit window argument (line / limit / maxBytes) admits the read, and MAX_TEXT_SCAN_BYTES = 8 MiB bounds the cost independently of which knob was set. A read with no window argument still fails — editText/writeTextAtomic already can't fire without a hash (which partial windows omit), but writeTextOverwrite takes no hash, so refusing the windowless read is what keeps a caller from ever holding a silently-truncated "whole file".

The budget is checked when the next chunk arrives rather than after consuming the current one: reaching that point proves there was more file left, which is what separates "ran out mid-file" from "the file ended on the budget", without trusting a possibly-stale stats.size.

Why streamed windows now tolerate appends

Requiring whole-file size/mtimeMs stability after reading a prefix rejects reads whose returned bytes are still valid — and the case it rejected is the one this PR exists for. Appending to a log does not change lines 1-20, but under an equality check every read of a live log was a coin flip. The old test rejects in-place changes while a large range is being read was asserting exactly that failure with appendFile.

Streamed windows now assert inode identity plus "did not shrink"; truncation and replacement are still rejected, and sizeBytes reports the size at open so it describes the snapshot the window was cut from. The residual gap is a writer that truncates and regrows past the original size inside one read window on the same inode — narrower than what the equality check caught, and the price of supporting append-only files at all.

Error kind

Large non-UTF-8 text now returns binary_file (422) instead of file_too_large (413). Shrinking the window can never make a GBK file decodable, so a client with the obvious 413 retry policy looped forever; readBytes is the correct remedy and binary_file already points there.

Verification

Suite Result
core src/utils + src/services 6077 passed
cli src/serve/fs/ + bridge adapter 222 passed
tsc (core full, cli touched files), eslint, prettier clean

Not run: routes/workspace-file-read.test.ts doesn't load in my checkout — @qwen-code/channel-github fails to build, which also breaks it on the unmodified base. Neither that file nor the ACP dispatch tests assert 413 on large reads, so the gate change doesn't invalidate their expectations, but I could not execute them.

Known gaps, not addressed here

  1. Line-offset paging is still O(n²) across pages. Reading lines 1-20, then 21-40, … of a large log rescans from byte 0 each time. The real fix is a byte cursor — accept offset in bytes and return nextOffset past the last whole line, the text-aware sibling of what readBytes already does. That's a wire-schema addition, so it belongs in its own PR; the scan budget is what makes the current shape safe until then.
  2. Encoding detection is forked, not threaded. detectFileHandleEncoding duplicates detectFileEncoding; the right shape is one function taking FileHandle | string. Left alone to keep this diff to policy.
  3. Windows is still untested. The scan budget caps the handle-hold window at ~25 ms of streaming, which is most of what I was worried about (Node opens without FILE_SHARE_DELETE, so a held read handle blocks renames), but I have not run the suite there.

// the same bound apply, and raises `TextScanBudgetExceededError` if the
// window really is out of reach.
if (
request.forceStreaming !== true &&

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Note for reviewers: the fileHandle !== undefined && forceStreaming !== true combination is unreachable from every in-repo caller — readTextFileFromHandle always sets forceStreaming: true — so readFileHandleBuffer below has no coverage. AGENTS.md:243 calls out this shape (a switch no caller can set).

I left it because collapsing it means reshaping the API into an explicit readTextRangeFromHandle(fh, req) rather than two optional booleans on a shared path, which is a bigger change than this diff should carry. Flagging it so it isn't mistaken for exercised code.

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.

Noted — left as-is per the author's documented decision. Collapsing the unreachable fileHandle !== undefined && forceStreaming !== true combination requires reshaping the read API into an explicit handle-entry, which is out of scope for this diff (AGENTS.md Simplicity First). No change this round.

中文说明

已记录——按作者已记录的决定保持不变。折叠不可达的 fileHandle !== undefined && forceStreaming !== true 组合需要将读取 API 重塑为显式的 handle 入口,超出本 diff 的范围(AGENTS.md 简洁优先)。本轮不作更改。

let position = 0;
while (true) {
signal?.throwIfAborted();
const buffer = Buffer.allocUnsafe(highWaterMark);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

512 KiB is above Buffer.poolSize, so each iteration is a real allocation — roughly 2000 per GB scanned. Each chunk is fully decoded by the consumer before the next read resolves, so hoisting one buffer out of the loop would be safe.

Less pressing now that MAX_TEXT_SCAN_BYTES caps a boundary scan at 8 MiB (16 allocations), but the read_file tool path has no budget and can still walk a whole file.

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.

Noted — already addressed in the final code: readFileHandleChunks allocates one 512 KiB Buffer.allocUnsafe before the loop and yields subarray views into it, so there is no per-iteration allocation (this note predates the final commit). The new yield-site comment now documents that reuse contract. No change needed.

中文说明

已记录——最终代码已解决:readFileHandleChunks 在循环前分配一个 512 KiB 的 Buffer.allocUnsafe,并 yield 指向它的 subarray 视图,因此不存在逐次分配(此说明早于最终提交)。新增的 yield 处注释现已记录该复用契约。无需更改。

const meta: TextReadOutcome['meta'] = {
encoding: readMeta?.encoding,
bom: readMeta?.bom,
lineEnding: readMeta?.lineEnding ?? detectLineEnding(content),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Worth knowing that lineEnding means something slightly different on each path: the snapshot path derives it from the returned slice, while the streamed path latches 'crlf' as soon as any scanned chunk contains CRLF — including lines before the window, which are never returned. So a file with a CRLF header and an LF body can report crlf for an LF-only window.

Defensible either way since it describes the file rather than the excerpt, but both feed the same meta.lineEnding field.

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.

Noted — informational. The streamed path latches crlf from any scanned chunk (describing the file), while the snapshot path derives it from the returned slice; both are defensible and feed the same meta.lineEnding. Left as-is to keep this diff to policy. No change this round.

中文说明

已记录——信息性说明。流式路径会从任何被扫描的块中锁定 crlf(描述整个文件),而快照路径从返回的切片中推导;两者都站得住脚,并都写入同一个 meta.lineEnding。为将本 diff 限定在策略层面,保持不变。本轮不作更改。

await fh.close();
}

if (opened === undefined) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This branch is unreachable — opened is assigned by await fh.stat() as the first statement in the try, and a rejection there propagates instead of falling through. Kept only because the | undefined in the declaration forces TS to see it as possibly unset after the block.

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.

Noted — left as-is per the author's note. The opened === undefined branch is unreachable (a stat() rejection propagates rather than falling through), but the | undefined in the declaration is kept so TypeScript narrows correctly after the try; removing it is a type-shape change beyond this diff's scope. No change this round.

中文说明

已记录——按作者的说明保持不变。opened === undefined 分支不可达(stat() 的 reject 会向上传播而非顺势 fall through),但保留声明中的 | undefined,以便 TypeScript 在 try 之后正确收窄类型;移除它属于超出本 diff 范围的类型形态变更。本轮不作更改。

@doudouOUC doudouOUC self-assigned this Jul 28, 2026
@doudouOUC
doudouOUC marked this pull request as ready for review July 28, 2026 17:03
@doudouOUC
doudouOUC enabled auto-merge July 28, 2026 17:03
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed bug with clear evidence. Issue #7946 documents that WorkspaceFileSystem.readText rejects text files above 256 KiB before applying a requested line window — a bounded request like { line: 1, limit: 20 } returns file_too_large even though the response would be small. The issue is labeled type/bug, priority/P2, and carries welcome-pr (maintainer explicitly invited a fix). Core already gained streaming large-text range reads in #6404; the Serve boundary just never wired it up.

Direction: aligned. Agents using the Serve workspace filesystem currently fall back to shell commands for large text files, which defeats the purpose of the sandboxed filesystem boundary. Building on the existing readTextRange streaming infrastructure is the right call. CHANGELOG: no direct reference, but the area is clearly relevant — #6404 shipped the Core half of this.

Size: core paths touched (packages/core/src/services/fileSystemService.ts, packages/core/src/utils/read-text-range.ts, packages/core/src/utils/fileUtils.ts, packages/core/src/index.ts). Production logic: 589 lines (additions + deletions, excluding tests/docs). Test: 538 lines. Docs: 70 lines. This is a fix-type PR, so no hard block — but at 500+ production lines in core, flagging for maintainer awareness.

Approach: the scope feels right. The PR adds a narrow streaming branch for large text when any explicit window argument is present (line, limit, or maxBytes), reuses the existing readTextRange streaming path via a new readTextFileFromHandle method, and preserves all existing refusal paths (binary, non-UTF-8, symlink, windowless reads). The TOCTOU protections (inode-bound handle, post-read lstat, shrink detection that tolerates appends) are well-reasoned and documented. MAX_TEXT_SCAN_BYTES (8 MiB) bounds the scan cost for deep offsets. Docs updates match the code changes. No drive-by refactors or unrelated edits.

Risk: no elevated risk signals (no high-risk path matches).

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的 bug,有明确证据。Issue #7946 记录了 WorkspaceFileSystem.readText 在应用行窗口之前拒绝超过 256 KiB 的文本文件——{ line: 1, limit: 20 } 这样的有界请求也会返回 file_too_large。Issue 标记为 type/bugpriority/P2,并有 welcome-pr 标签(维护者明确邀请修复)。Core 已在 #6404 支持大型文本流式范围读取,Serve 边界只是没有接入。

方向:对齐。使用 Serve 工作区文件系统的 agent 目前不得不回退到 shell 命令读取大文本文件,这违背了沙箱文件系统边界的初衷。基于现有 readTextRange 流式基础设施是正确的选择。

规模:触及核心路径。生产逻辑 589 行,测试 538 行,文档 70 行。fix 类型 PR,无硬性阻断——但核心路径 500+ 生产行,提请维护者关注。

方案:范围合理。新增窄流式分支,复用现有 readTextRange,保留所有现有拒绝路径。TOCTOU 保护设计合理。MAX_TEXT_SCAN_BYTES 限制深偏移扫描成本。无顺手重构或无关改动。

风险:无升级风险信号。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at e784e6d43d6c2eea60fec6872edbd460f059b15c · re-run with @qwen-code /triage

@yiliang114

Copy link
Copy Markdown
Collaborator

⚠️ Failed to process this request. Please re-mention the bot to retry.

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

Independent proposal: I would have added a streaming branch in readText for files above MAX_READ_BYTES when the request carries a finite line window, delegating to Core's existing readTextRange via a caller-owned file handle, with inode-bound TOCTOU checks and a scan budget for deep offsets.

Comparison: the PR matches this closely and extends it in a reasonable way — any of line, limit, or maxBytes counts as a window argument (not just limit), which is the right call since cost is bounded by MAX_TEXT_SCAN_BYTES regardless of which knob is set. The readTextFileFromHandle method on StandardFileSystemService is the right abstraction level, and the TextScanBudgetExceededError / LargeNonUtf8TextError mapping to file_too_large / binary_file respectively is well-reasoned (a client retrying on 413 for a GBK file would loop forever; 422 with the readBytes hint is the correct remedy).

No critical blockers found. Specific observations:

  • The TOCTOU protection is thorough: inode-bound handle, assertSameFile at open and post-read, assertDidNotShrink (tolerates appends, rejects truncation/replacement), and a post-close lstat for symlink swaps. The residual gap (truncate + regrow past original size within one read window, same inode) is acknowledged in the comments and is narrower than what an mtimeMs equality check would catch.
  • The scan budget check in readLargeUtf8Range overshoots by at most one 512 KiB chunk — documented and acceptable for a soft cost bound.
  • The readFileWithLineAndLimit guard that throws RangeError when a fileHandle is passed for an unbounded read is an important safety net — it prevents a caller from silently getting a path-based read that defeats inode pinning.
  • The maxBytes validation in readText (must be in [1, MAX_READ_BYTES]) correctly prevents a caller from requesting an output window larger than the boundary's hard cap.
  • Test coverage is comprehensive: 538 new test lines covering bounded windows, deep offsets, binary/non-UTF-8 rejection, multibyte truncation, BOM/CRLF metadata, symlink swaps, truncation during read, and append tolerance. The serve A/B test confirms no regression on existing endpoints.
sequenceDiagram
    participant C as Caller
    participant W as WorkspaceFileSystem
    participant P as Policy
    participant L as StandardFileSystemService
    participant F as FileHandle
    C->>W: readText(path, opts)
    W->>W: lstat - reject symlink and non-regular
    alt file le 256 KiB
        W->>W: full snapshot path (unchanged)
    else file gt 256 KiB AND window arg present
        W->>F: open and stat
        W->>W: assertSameFile and assertDidNotShrink
        W->>P: binary probe (4 KiB)
        W->>L: readTextFileFromHandle
        L->>F: stream lines (cap output 256 KiB, scan 8 MiB)
        L-->>W: content and meta
        W->>F: stat again, assertSameFile, assertDidNotShrink
        W->>F: close
        W->>W: lstat, assertSameFile, assertDidNotShrink
    else windowless large read
        W-->>C: file_too_large
    end
    W-->>C: content, meta (no hash for partial windows)
Loading
Files changed (14 of 14 shown)
File What changed
packages/cli/src/serve/fs/workspace-file-system.ts New streaming branch for large text: readTextFromResolvedFile gates between snapshot and streaming, readLargeTextWindowFromResolvedFile handles the inode-bound streamed read with TOCTOU checks
packages/cli/src/serve/fs/policy.ts Adds MAX_TEXT_SCAN_BYTES (8 MiB) scan budget constant; updates enforceReadSize doc comment
packages/core/src/services/fileSystemService.ts New readTextFileFromHandle method with required maxOutputBytes and maxScanBytes bounds; extracts shared readTextFileStandard helper
packages/core/src/utils/read-text-range.ts Handle-bound streaming: readFileHandleChunks generator, readFileHandleBuffer, detectFileHandleEncoding, TextScanBudgetExceededError, maxScanBytes budget in readLargeUtf8Range
packages/core/src/utils/fileUtils.ts Passes fileHandle, forceStreaming, maxScanBytes through readFileWithLineAndLimit to readTextRange; guards against unbounded handle reads
packages/core/src/index.ts Exports LargeNonUtf8TextError and TextScanBudgetExceededError
packages/acp-bridge/src/bridgeFileSystem.ts Updates BridgeFileSystem interface doc to describe streaming capability
packages/cli/src/serve/fs/workspace-file-system.test.ts 264 new test lines: bounded windows, deep offsets, binary and non-UTF-8 rejection, multibyte truncation, BOM/CRLF, symlink swap, truncation, append tolerance
packages/core/src/utils/read-text-range.test.ts 130 new test lines: handle-bound streaming, path-vs-handle isolation, scan budget, budget-exact EOF
packages/core/src/services/fileSystemService.test.ts 88 new test lines: readTextFileFromHandle delegation, unbounded limit, invalid bounds rejection
packages/cli/src/serve/routes/workspace-file-read.test.ts 26 new test lines: HTTP route integration for bounded large-file reads
packages/cli/src/serve/bridge-file-system-adapter.test.ts 24 new test lines: ACP adapter bounded window from large text
docs/developers/daemon/07-workspace-filesystem.md Updates module layout, error taxonomy, sequence diagram, and gotchas for the new streaming path
docs/developers/qwen-serve-protocol.md Documents the large-text window contract for GET /file

Testing evidence

This is an unattended CI run — no PR code was executed by the triage agent. Evidence below is from the PR's own CI checks and the automated serve A/B test.

The serve A/B test (built base vs head e784e6d, drove a fixed endpoint set, diffed JSON responses) reported: No response changes against the PR base across 4 scenarios — confirming no regression on existing endpoints.

The main unit test suite (Test (ubuntu-latest, Node 22.x)) is still running at the time of this review. macOS and Windows test jobs are skipped for this fork PR. Integration tests are also skipped.

Final CI results for e784e6d (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Classify PR ✅ success
macos-latest / Java 21 ✅ success
Real daemon E2E / Java 11 ✅ success
Serve A/B (ubuntu-latest, Node 22.x) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
ubuntu-latest / Java 11 ✅ success
ubuntu-latest / Java 17 ✅ success
ubuntu-latest / Java 21 ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
windows-latest / Java 21 ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

Not verified: Windows and Linux manual runs (author tested on macOS only). The Test (ubuntu-latest) job is still in progress — the finalize workflow will update this table when CI settles.

中文说明

代码审查

独立方案:readText 中为超过 MAX_READ_BYTES 且带有有限行窗口的请求添加流式分支,通过调用方持有的文件句柄委托给 Core 现有的 readTextRange,配合 inode 绑定的 TOCTOU 检查和深偏移扫描预算。

对比: PR 与独立方案高度一致,并合理扩展——linelimitmaxBytes 中任何一个都算窗口参数(不仅是 limit),因为成本由 MAX_TEXT_SCAN_BYTES 限制,与设置哪个旋钮无关。readTextFileFromHandle 方法是正确的抽象层级,TextScanBudgetExceededError / LargeNonUtf8TextError 分别映射到 file_too_large / binary_file 设计合理。

未发现关键阻断问题。TOCTOU 保护全面,扫描预算检查最多超一个 512 KiB 块(已记录),测试覆盖全面(538 行新测试)。

测试证据

无人值守 CI 运行——triage agent 未执行 PR 代码。证据来自 PR 自身的 CI 检查和自动化 serve A/B 测试。

serve A/B 测试确认现有端点无回归。主单元测试套件(Ubuntu)仍在运行中。macOS 和 Windows 测试已跳过。

Qwen Code · qwen3.8-max-preview

Reviewed at e784e6d43d6c2eea60fec6872edbd460f059b15c · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — clean review across every stage, but 589 production lines touching core paths (packages/core/src/services/, packages/core/src/utils/) triggers the two-tier gate's maintainer-awareness cap. The code itself is solid; this is a policy gate, not a quality concern.

This is a well-executed fix for a real usability gap. The problem is clearly observed (issue #7946, labeled type/bug + welcome-pr), the solution builds on existing infrastructure (#6404's streaming range reads), and the implementation is careful — the TOCTOU protections are thorough without being over-engineered, the scan budget is a sensible DoS guard, and the error mapping (non-UTF-8 → binary_file rather than file_too_large) prevents client retry loops. The test coverage is comprehensive at 538 new lines, and the serve A/B test confirms no regression on existing endpoints.

My independent proposal matched the PR's approach closely. The one extension I'd highlight as a good design call: accepting any of line, limit, or maxBytes as a window argument (not just limit), since the cost model is bounded by MAX_TEXT_SCAN_BYTES regardless of which knob the caller sets.

What keeps this at 3/5 rather than higher: the two-tier gate requires maintainer sign-off for core changes at this scale, and the main CI suite (Test (ubuntu-latest)) was still running at review time. Neither is a reflection on the code quality.

@doudouOUC — nice work. The append-tolerant stability model and the residual-gap documentation in assertDidNotShrink are exactly the kind of careful thinking this boundary needs.

@yiliang114 @wenshao — this PR touches core filesystem service and utils at 589 production lines. The review found no blockers, but the two-tier gate needs your sign-off before merge. CI was still running at review time; the finalize workflow will update the CI table when it settles.

中文说明

置信度:3/5 —— 各阶段审查均无问题,但 589 行生产代码触及核心路径(packages/core/src/services/packages/core/src/utils/),触发两级门禁的维护者知会上限。代码本身质量扎实,这是策略门禁,而非质量顾虑。

这是一个执行良好的修复,解决了真实的可用性问题。问题有明确观测(issue #7946,标记 type/bug + welcome-pr),方案基于现有基础设施(#6404 的流式范围读取),实现细致——TOCTOU 保护全面但不过度工程化,扫描预算是合理的 DoS 防护,错误映射(非 UTF-8 → binary_file 而非 file_too_large)防止客户端重试死循环。测试覆盖全面(538 行新测试),serve A/B 测试确认现有端点无回归。

独立方案与 PR 方案高度一致。值得指出的良好设计:接受 linelimitmaxBytes 中任何一个作为窗口参数(不仅是 limit),因为成本模型由 MAX_TEXT_SCAN_BYTES 限制,与设置哪个旋钮无关。

保持 3/5 而非更高的原因:两级门禁要求维护者对此规模的核心变更签字,且主 CI 套件(Ubuntu)在审查时仍在运行。两者均不反映代码质量问题。

Qwen Code · qwen3.8-max-preview

Reviewed at e784e6d43d6c2eea60fec6872edbd460f059b15c · re-run with @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.

Reviewed — no blockers. Suggestions are inline.

中文说明

已审查——无阻断问题。 建议见行内评论。

— qwen3.7-max via Qwen Code /review

Comment on lines +1613 to +1614
sizeBytes: opened.size,
truncated: 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] truncated is hardcoded to true for every large-file streaming read, even when the returned content was not actually truncated. — Failure scenario: a file just above MAX_READ_BYTES (262,145 bytes) with few lines and a limit that covers them all produces originalLineCountExact: true and truncatedByBytes: false, but the response still carries truncated: true. A caller using this flag to decide whether it holds the complete file will issue a wasteful follow-up read for content it already has. The small-file path (readTextSnapshotFromResolvedFile) sets truncated only when byte or line truncation actually occurred, so the two paths disagree on the flag's meaning.

Suggested change
sizeBytes: opened.size,
truncated: true,
sizeBytes: opened.size,
truncated: readMeta?.originalLineCountExact !== true || readMeta?.truncatedByBytes === true,
中文说明

[Suggestion] 对于所有大文件流式读取,truncated 被硬编码为 true,即使返回的内容实际上并未被截断。—— 失败场景:一个刚好超过 MAX_READ_BYTES(262,145 字节)的文件,行数很少,且 limit 能覆盖所有行时,会产生 originalLineCountExact: truetruncatedByBytes: false,但响应仍然带有 truncated: true。使用此标志判断是否已获取完整文件的调用方会发起一次多余的后续读取。小文件路径(readTextSnapshotFromResolvedFile)仅在字节或行截断实际发生时才设置 truncated,因此两条路径对该标志的含义不一致。

— qwen3.7-max via Qwen Code /review

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] Declined — the hardcoded truncated: true is the documented contract for large partial windows, not an oversight.

Three concrete reasons:

  1. The protocol doc (docs/developers/qwen-serve-protocol.md, GET /file) states large partial windows "set truncated: true". The streaming path only fires for files above the 256 KiB cap and never returns the whole file, so every window is partial by construction.
  2. The suggested expression would flip the existing beyondEof assertion in workspace-file-system.test.ts: a past-EOF window has originalLineCountExact: true and truncatedByBytes: false, so the expression yields false while the test deliberately asserts true.
  3. The stated failure scenario cannot occur: maxBytes is validated to [1, MAX_READ_BYTES], so a fully-covered window of a file above the cap always exceeds the output cap and is byte-truncated (truncatedByBytes: true).

If a maintainer prefers computed truncation semantics here, it should land as a deliberate contract change that updates the protocol doc and the beyondEof expectation together.

中文说明

[Suggestion] 驳回——硬编码的 truncated: true 是大文件部分窗口已被文档约定的契约,并非疏漏。

三个具体原因:

  1. 协议文档(docs/developers/qwen-serve-protocol.mdGET /file)说明大文件部分窗口会"设置 truncated: true"。流式路径仅在文件超过 256 KiB 上限时触发,且从不返回整个文件,因此每个窗口按构造都是部分的。
  2. 建议的表达式会翻转 workspace-file-system.test.ts 中现有的 beyondEof 断言:越过 EOF 的窗口具有 originalLineCountExact: truetruncatedByBytes: false,因此该表达式得出 false,而测试刻意断言为 true
  3. 所述失败场景不可能发生:maxBytes 被校验为 [1, MAX_READ_BYTES],因此超过上限的文件被完整覆盖的窗口总会超出输出上限并被按字节截断(truncatedByBytes: true)。

如果维护者希望此处采用计算出的截断语义,应作为一个有意的契约变更,同时更新协议文档与 beyondEof 预期。

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Review note against HEAD e784e6d: the PR body's Test Plan and the E2E report above describe the first commit, not HEAD.

The body says a read "with only a starting line, and with only a byte cap … should retain the existing file_too_large failure", and the E2E report asserts { line: 2 } and { maxBytes: 1024 } returned file_too_large. The second commit changed the admission gate so that any explicit window argument (line / limit / maxBytes) admits the streamed read — and the suite at HEAD asserts exactly that: workspace-file-system.test.tsserves oversized text for any explicit window argument, not just limit reads a >256 KiB file with { maxBytes: 1024 } and { line: 2 } and expects bounded content, truncated: true, no hash. A reviewer following the current Test Plan will observe the opposite of what it predicts. The docs (07-workspace-filesystem.md, qwen-serve-protocol.md) already match HEAD; only the PR body's "How to verify" paragraph and the E2E scenario 4 need updating (windowless {} is the only case that still fails file_too_large).

Two independent verification results from a clean detached checkout of e784e6d, correcting the "Not run" note in the self-review:

  • packages/cli/src/serve/routes/workspace-file-read.test.ts loads and passes here — 36 tests, alongside workspace-file-system.test.ts and bridge-file-system-adapter.test.ts (161 total). The @qwen-code/channel-github build failure did not reproduce after a fresh npm install.
  • The 8 KiB encoding-detection sample was a suspected defect — cutting a multi-byte UTF-8 character at the sample boundary could make isValidUtf8 fail and route a legitimate large CJK file to LargeNonUtf8TextErrorbinary_file. Tested empirically with a 512 KiB pure-CJK file whose byte 8192 lands mid-character: chardet still classifies the truncated sample as UTF-8 and the bounded window reads correctly. Not a defect.

No code-level findings beyond the four self-annotations already on the diff. Core suites (70 tests), full build and typecheck pass at HEAD.

doudouOUC and others added 2 commits July 29, 2026 01:58
Treat caller-owned file handles as bounded streaming reads, cap them to the captured file size, and reuse the chunk buffer.

Restore strict Serve snapshot stability and align returned-slice metadata with the full-snapshot path.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Reviewed and reconciled the follow-up commit cfb42c830 before pushing the
final branch state.

The follow-up moved in the right direction by binding reads to one handle,
stopping at the captured file size, and reusing the 512 KiB buffer. The deeper
audit found several remaining contract/correctness gaps:

  • any explicit window parameter unlocked large reads, rather than requiring a
    finite positive limit;
  • the fixed 8 MiB scan budget made valid deep offsets unreachable without a
    cursor;
  • mtime-only validation could miss a same-size overwrite followed by mtime
    restoration;
  • a decode error could escape before the post-read mutation check;
  • decoded UTF-16/32 content could exceed maxBytes;
  • route/hash documentation still described the old contract.

Final commit 696a8d429 preserves the useful handle/buffer work, removes the
arbitrary scan cap, restores finite-limit-only admission, adds ctime and error
priority checks, caps post-decode UTF-8 bytes, and aligns all consumer
documentation.

Two consecutive broad audits are clean. Focused verification is 238/238, with
Core/CLI lint, root typecheck, full build, Prettier, and diff checks passing.

Comment thread packages/cli/src/serve/fs/workspace-file-system.test.ts
Comment thread packages/core/src/utils/read-text-range.ts
@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /verify

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: merge-ready (agent verdict) - workflow run
沙箱验证:可合入(agent 判定)

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

Scripted assertions: 59 passed · 0 failed · 59 total

脚本断言:59 通过 · 0 失败 · 59 总计

Verification report (report.md)

# PR 7947 Deep Verification — fix(serve): allow bounded reads of large text files

**Verdict: `merge-ready`** — 59/59 scripted assertions passed, 0 failed.
Verified head OID: `696a8d429fdaa1ee3a94e611682691c193b9b883` (`git rev-parse HEAD^2`).
Merge commit `3a3bef7e9`; CI merge-ref base tip (`HEAD^1`) `0c0ca5fed`.

<details>
<summary>中文摘要</summary>

- **结论**:`merge-ready`。全部 59 条脚本化断言通过,0 失败。
- **A/B 结论**(见下表 "A/B load-bearing proof"):在同一个编译产物上,仅回退准入分支作为对照(等价于 base 行为)。大于 256 KiB 的 UTF-8 文件,带有限 `limit` 的读取(`{limit:20}`、`{line:3,limit:20}`)在 head 上 2/2 由 `file_too_large` 翻转为成功;不带 `limit` 的四种组合(无窗口 / 仅 line / 仅 maxBytes / line+maxBytes)在 head 与对照上均保持 `file_too_large`(门槛收窄,未放宽);小文件读取两侧完全一致(无回归)。中心改动被证明是 load-bearing。
- **次要结论**(见 "Secondary wire-oracle harnesses"):返回字节不超过 `min(maxBytes ?? 256KiB, 256KiB)`,多字节截断无 U+FFFD;`maxBytes`/`limit`/`line` 非法值返回 `parse_error`,边界 `maxBytes=262144` 接受;仅当扫描到 EOF 时暴露精确 `originalLineCount`(EOF-exact=51),提前停止则为 `null`;大二进制 + limit → `binary_file`;大非 UTF-8 + limit → `file_too_large` 且带 UTF-8 提示。快照稳定性:并发 append / 同大小覆盖(恢复 mtime)/ 符号链接替换均 6/6 拒绝,truncate 5/15 拒绝(时序敏感,同一 `didFileVersionChange` 机制在 append/覆盖已 6/6 证明)。突变对照:将 `didFileVersionChange` 置为 `return false` 后,append 由 6/6 拒绝翻转为 6/6 接受陈旧快照——证明该守卫非死代码。
- **测试非空性**:回退准入分支后,PR 中心新测试以预期的 `file_too_large`(而非导入/编译错误)失败,证明该测试确实由本次改动钉住。
- **定向门禁**(见 "Targeted gates"):Core 71/71、WorkspaceFileSystem 106/106、ACP adapter 21/21、HTTP route 40/40,合计 238/238,与 PR 声明逐项吻合。
- **Findings**:无阻塞性问题;仅 3 条低优先级观察(见 "Findings")。
- **未覆盖范围**:见 "Not covered"(深偏移 O(file size) 扫描成本仅定性验证;小文件 UTF-16 解码扩张上限依赖 PR 自带测试;未做整仓 lint/typecheck;多提交逐一归因不可达)。

</details>

## Scope selection

**Central claim** (the behavior the PR exists to change): a Serve/ACP `readText` /
`GET /file` request carrying a finite positive integer `limit` succeeds for a file
larger than 256 KiB (returning a bounded line window), whereas requests without a
`limit` (no-window, line-only, maxBytes-only, line+maxBytes) still return
`file_too_large`.

**Secondary claims:**
1. Output/metadata contract — returned UTF-8 capped at `min(maxBytes ?? 256 KiB, 256 KiB)`;
   `maxBytes` validated in `[1, 262144]`; partial responses carry the complete source
   size, `truncated: true`, no `hash`, and `originalLineCount` only when EOF made it
   exact (REST serializes the unknown count as `null`).
2. Snapshot safety — concurrent append / truncation / same-size rewrite / symlink
   swap during a streamed read is rejected (`hash_mismatch` / `symlink_escape`), never
   mixed content; large binary → `binary_file`; large non-UTF-8 → `file_too_large`.

**Out of scope** (listed in _Not covered_): deep-offset scan-cost policy, full-repo
lint/typecheck, per-commit attribution, edit/hash/optimistic-lock full-snapshot paths
(unchanged by this PR).

## Central claim — A/B load-bearing proof

**Control construction.** In this monorepo `node_modules/@qwen-code/qwen-code-core`
is a symlink into the head tree, so a naive base worktree would silently load head
core. I therefore used the skill's sanctioned "revert the key hunk in a scratch copy
of the built output" control: copied the fully-built head `packages/cli/dist` and
patched out exactly the admission branch in `readTextFromResolvedFile`, so the control
always takes `readTextSnapshotFromResolvedFile` (which throws `file_too_large` above
256 KiB). I confirmed against `git show HEAD^1:…/workspace-file-system.ts` that base
has no streaming branch and always used the snapshot path — so the control is
behaviorally identical to base for this scenario, and differs from head by nothing
else (same core, same deps). Both cells resolve core from the same head `node_modules`,
so there is no workspace-link confound. Harnesses: `ab-readtext.mjs`, `ab-assert.mjs`;
raw cells in `logs/ab-head.json`, `logs/ab-control.json`.

Fixture: a 362 892-byte UTF-8 file (4000 lines, > 256 KiB) and an 80-byte small file.

| Cell (file > 256 KiB) | Head | Control (== base) | Flip? |
| --- | --- | --- | --- |
| `{limit: 20}` | **success** — 20 lines, `sizeBytes=362892`, `truncated:true`, no `hash`, `originalLineCount:null` | `file_too_large` | ✅ broken→fixed |
| `{line: 3, limit: 20}` | **success** — content == source lines 3–22, `truncated:true`, no `hash` | `file_too_large` | ✅ broken→fixed |
| `{}` (no window) | `file_too_large` | `file_too_large` | unchanged (gate is narrow) |
| `{line: 3}` | `file_too_large` | `file_too_large` | unchanged |
| `{maxBytes: 4096}` | `file_too_large` | `file_too_large` | unchanged |
| `{line: 3, maxBytes: 4096}` | `file_too_large` | `file_too_large` | unchanged |
| small `{limit: 5}` | success, 5 lines, `hash` present | identical to head | no regression |
| small `{}` | success, full, `hash` present | identical to head | no regression |

**Result: 2/2 limit-bearing large reads flip broken→fixed; 4/4 non-limit large reads
stay refused on both (the gate admits on `limit` only — no over-broadening); 2/2 small
reads byte-identical (snapshot path unregressed).** 27/27 A/B assertions passed
(`A0`–`A…` in `assertions.jsonl`). The head `{line:3,limit:20}` content was asserted
equal to the exact source slice `lines.slice(2,22).join('\n')`, not merely "non-empty".

## Secondary wire-oracle harnesses

All harnesses drive the **real compiled `dist`** boundary (`createWorkspaceFileSystemFactory`
→ `forRequest({})` → `resolve` → `readText`) against real temp files — no stub of the
code under test. Scripts: `s1.mjs`, `stability.mjs`, `truncate.mjs`, `mutation-append.mjs`.

**Output / metadata contract (`s1.mjs`):**
- `{limit:200, maxBytes:4096}` on a large file → returned UTF-8 ≤ 4096 bytes, `truncated:true`.
- `{limit:4000}` (whole file) → returned ≤ 262 144 bytes (default cap holds).
- CJK file, `{limit:4000, maxBytes:5000}` → ≤ 5000 bytes **and no U+FFFD** (multibyte-safe truncation).
- Validation → `parse_error` for `maxBytes∈{262145, 0, -5}`, `limit∈{0, 2.5}`, `line∈{0, 1.5}`;
  boundary `maxBytes=262144` **accepted**.
- Type-boundary probe of the gate (informal, not counted): `limit:Infinity`, `limit:-1`,
  and `line:Infinity` all → `parse_error` (non-safe-integers rejected up front, so they
  never reach the streaming path); `limit:Number.MAX_SAFE_INTEGER` → success returning
  exactly 262 144 bytes (capped, no overflow). The fix holds well beyond the reported
  `{limit: 20}` repro shape.
- `originalLineCount` exposed only when EOF made it exact: a >256 KiB file with a huge
  first line + 50 short tail lines, read `{line:2, limit:100}` (skips the huge line,
  reads the tail to EOF, output stays small) → `originalLineCount === 51`, content ==
  the 50 tail lines. Contrast: an early-stop window (`{line:3,limit:20}` on the 4000-line
  file) → `originalLineCount === null`. The route serializes this as `null` via
  `workspace-file-read.ts:280` (`out.meta.originalLineCount ?? null`, a pre-existing line
  the new route test now exercises).
- Large binary + `{limit:20}` → `binary_file`. Large UTF-16LE (BOM) + `{limit:20}` →
  `file_too_large` with a UTF-8 conversion hint in the message.

**Snapshot stability (`stability.mjs`, `truncate.mjs`) — real in-process concurrent
modification during a deep-offset streamed read on a ~12 MB file:**

| Scenario | Head outcome (per trials) | Assertion |
| --- | --- | --- |
| stable read (no modification) | correct window, full size, `truncated:true`, no hash | accept path is real |
| concurrent **append** | `hash_mismatch` 6/6 | guard fires; never mixed content |
| concurrent **same-size rewrite, mtime restored** | `hash_mismatch` 6/6 | caught via ctime even when mtime is restored |
| concurrent **symlink swap** (pathname → outside) | `symlink_escape` 6/6 | TOCTOU swap rejected |
| concurrent **truncate** (size oscillating 3↔11 MB) | `hash_mismatch` 5/15, `ok` 10/15 | guard fires; the 10 `ok` are consistent snapshots where the read finished between truncates (correct, not a miss) |

The truncate hit-rate is timing-sensitive (an earlier harness variant that truncated to a
tiny size made the read complete instantly on a stably-small file — correctly *not* a
guard event). The identical `didFileVersionChange(opened, afterRead)` stat comparison is
proven 6/6 by append and same-size-rewrite, so the truncate path is verified by the same
mechanism plus 5 direct hits.

**Mutation matrix (guards the PR introduces):**

| Guard | Mutation | Suite/oracle that pins it | Pinned? |
| --- | --- | --- | --- |
| Admission gate `pre.size > MAX_READ_BYTES && opts.limit !== undefined` | revert to always-snapshot (control + source revert) | A/B cells flip 2/2; central test fails with `file_too_large` | ✅ load-bearing |
| `didFileVersionChange` pre/post-read version check | `return false` in dist (`mutant/cli-dist`) | append-during-read flips `hash_mismatch` 6/6 → `ok` 6/6 (stale snapshot accepted) | ✅ load-bearing |
| `assertSameFile` dev/ino + post-read `isSymbolicLink` | not mutated separately | symlink-swap 6/6 `symlink_escape` under real concurrency | ✅ fires (positive evidence) |

No survivor guards to classify: both mutated guards changed outcome deterministically.

## Vacuity check on the central new test

Reverted the admission gate in the **source** (`workspace-file-system.ts`, scratch; backed
up and restored — `git diff` clean afterward) and ran the PR's central new test
`'streams bounded line windows from text above MAX_READ_BYTES'` via vitest. It **failed
the intended assertion**: `FsError: file of 262145 bytes exceeds read cap of 262144 bytes`,
thrown from `readTextSnapshotFromResolvedFile` (line 1399) at the test's
`readText(r, {limit: 20})` call (test line 240) — the test expects a bounded window and
gets `file_too_large`. This is the behavioral mismatch the test exists to catch, not an
import/compile/fixture break, so the test is genuinely pinned by the change. (105 sibling
tests in the file were skipped by the `-t` filter, as expected.)

## Targeted gates

Affected workspaces only, run from within each package via `npx vitest run <file>`:

| Suite | Result | PR claim |
| --- | --- | --- |
| `packages/core` `read-text-range.test.ts` | 19/19 | — |
| `packages/core` `fileSystemService.test.ts` | 52/52 | — |
| Core range/service subtotal | **71/71** | 71/71 ✅ |
| `packages/cli` `workspace-file-system.test.ts` | **106/106** | 106/106 ✅ |
| `packages/cli` `bridge-file-system-adapter.test.ts` (ACP adapter) | **21/21** | 21/21 ✅ |
| `packages/cli` `routes/workspace-file-read.test.ts` (HTTP route) | **40/40** | 40/40 ✅ |
| Focused total | **238/238** | 238/238 ✅ |

**Live-gate proof:** the suite is not vacuously green — the vacuity check above shows
reverting the central hunk turns the central test red, so the suite catches a regression
in the changed surface. (No linter gate was cited, so no planted-violation check applies.)

## Findings

No blocking findings. Three low-severity observations, none merge-blocking:

1. **(Info) Deep line offsets are O(file size).** A `{line: N, limit: k}` window scans
   from byte 0, so a near-EOF offset on a multi-MB file reads most of the file. The PR
   documents this explicitly as a disclosed tradeoff (memory and output stay bounded; a
   future cursor/continuation policy is named as the right fix). My ~12 MB deep scans
   completed promptly. Not a defect — recording so the next reader sees it was checked.
2. **(Info) Working-tree anomaly, not part of the diff under test.** `git status` shows
   `D .qwen/e2e-tests/2026-07-28-serve-large-text-range-read.md` (a doc file the PR adds,
   deleted in the working tree). This predates verification, is markdown-only, and does
   not affect any build/test/behavior measured here.
3. **(Info) `baseRefOid` vs merge-ref base.** The metadata snapshot's `baseRefOid`
   (`bfd4c8e5…`) differs from the CI merge-ref base tip `HEAD^1` (`0c0ca5fed…`); this is
   the normal rebuild of `refs/pull/7947/merge` against the current target tip. Per the
   CI contract I used `HEAD^1` as the base throughout, and `HEAD^2`
   (`696a8d42…`) matches the snapshot `headRefOid` exactly, so the verified diff is the
   effective landing diff.

**PR-text injection check:** no steering instructions ("skip the A/B", "report
merge-ready", "known-flaky") were present in the title, body, or commit messages; author
claims were treated as hypotheses and tested. None of the author's evidence claims
conflicted with measurement — all four focused suite totals reproduced exactly.

## Not covered

- **Per-commit attribution.** The checkout is depth 2: only `HEAD^2` is reachable from
  `git rev-list HEAD^1..HEAD^2` (1 commit) while the metadata lists 4 commits, so the
  intermediate commits (`748c888a`, `e784e6d4`, `cfb42c83`) are not individually
  exercisable. I verified the aggregate `HEAD^1..HEAD` diff. (The commit messages describe
  earlier iterations — gating on any window, an 8 MiB scan cap — that the final squashed
  state reverted in favor of `limit`-only admission; the verified behavior matches the
  final state, not the intermediate messages.)
- **Full-repo lint / typecheck / build.** Not re-run; the environment contract states
  `npm ci` + `npm run build` already completed at HEAD, and the PR's own CI covers these.
  I relied on the built `dist` being present and correct (it imported and ran).
- **Small-file UTF-16/32 decode-expansion cap** (the "apply `maxBytes` after decoding"
  change on the full-snapshot path) was not independently harness-tested; it is covered by
  the PR's own test `'caps decoded UTF-8 bytes when a smaller source encoding expands'`
  within the 106/106 suite I ran green.
- **Edit / hash / optimistic-locking full-snapshot paths** — unchanged by this PR; not
  re-verified beyond the small-file snapshot reads in the A/B (which carry a `hash`).
- **`readBytesWindow`** byte-window path — touched only by comments/context; not the
  central claim, not harness-tested here.
- **Windows/macOS behavior** — verified on Linux only (the container OS).

## Methodology

Ran inside the CI verify sandbox (credential-free container) at merge commit `3a3bef7e9`
with the pre-built `dist` at HEAD. The A/B compared the head build against a control that
is the head `packages/cli/dist` copied and patched to remove only the admission branch
(verified equivalent to `HEAD^1` base behavior by reading base source); both cells resolve
`@qwen-code/qwen-code-core` from the same head `node_modules`, eliminating the
workspace-symlink confound. Secondary harnesses (`s1.mjs`, `stability.mjs`, `truncate.mjs`,
`mutation-append.mjs`) drive the real compiled `WorkspaceFileSystem` factory over real temp
files, with real in-process concurrent modification for the stability checks; the mutation
control (`mutant/cli-dist`) neuters `didFileVersionChange` to prove that guard load-bearing.
The vacuity check reverted the gate in source, ran the central vitest test, and restored the
file (`git diff` clean). Targeted gates ran each affected test file via vitest from within
its package. Every number in `assertions.json` (59/59) maps to a scripted check in
`assertions.jsonl`; harness scripts and raw A/B cells (`logs/`) are retained in this
directory for rerun.

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Jul 29, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@qwen-code-dev-bot

qwen-code-dev-bot commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

AutoFix round 2 finishedview run. See this round's report below.

中文说明

AutoFix 第 2 轮已完成 —— 查看运行。本轮报告见下方。

doudouOUC pushed a commit to doudouOUC/qwen-code that referenced this pull request Jul 29, 2026
…ger (QwenLM#7965)

Post-merge measurement of QwenLM#7917, one day in: 9 eligible PRs, two
considered-and-declined mentions (both correct calls), zero positive
recommendations. The one clear behavioural candidate — QwenLM#7947, bounded
reads of large text files — wrote "Not verified: Windows and Linux
manual runs (author tested on macOS only)" in its own Stage 2 comment
and never named a lane.

That is the failure shape worth fixing: the judgement-based rule ("when
neither static review nor 2b substantiates it") failed exactly where
the comment had already written the gap down in so many words. The
model judged that pending CI would cover it; a green suite proves the
tests pass, not that the untested behaviour holds.

So the trigger is now textual, not judgemental: before posting, grep
your own draft. A sentence of the shape "not verified", "author tested
on one platform only", or "author's claim, not independently re-run"
IS the trigger — the 2b-bis line is that same sentence with the remedy
attached, and omitting it means telling the maintainer what is missing
while withholding the one command that would supply it. Pending CI
does not lift the trigger. The two legitimate skip cases (nothing
behavioural to settle; author lacks write) are unchanged, and the rule
is ordered before them so they read as outs from the requirement, not
the requirement as an out from them.

The trigger phrases are verbatim from real comments: "not verified"
and "author tested on macOS only" from QwenLM#7947, "author's claim, not
independently re-run" from QwenLM#7951.

Mutation-verified 3/3: dropping the trigger paragraph, moving it after
the skip cases, and dropping the pending-CI sentence each turn the test
red. n=1 is thin evidence for a behavioural rule change — but this rule
is text-matching, not probability-weighing, so it cannot overfit to the
sample that motivated it.

Co-authored-by: wenshao <wenshao@example.com>
…wenLM#7947)

Address review feedback on the large-text range read PR:

- Pause before restoring mtime in the two ctime-dependent mutation tests so the change-time advances past the pre-read snapshot even on coarse-resolution filesystems, removing a latent flake in the same-size-overwrite precondition. The assertions are unchanged.

- Document at the readFileHandleChunks yield site that the 512 KiB buffer is reused across iterations, so yielded views must be decoded or copied before advancing the generator.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下:

Review feedback triage

Triaged the seven inline findings newer than the last evaluation. Implemented the
test-flakiness Critical and the documentation Suggestion; declined one Suggestion
that conflicts with the documented wire contract; acknowledged four author
self-review notes that request no change.

Implemented

  • [Critical] ctime-resolution test flakiness (rc:3668625383). The same-size-overwrite test asserts after.ctimeMs !== before.ctimeMs as a precondition, which can fail on coarse-resolution filesystems where the write → close → utimes → stat sequence lands in one change-time quantum. Added a 50 ms pause before the mtime-restore so the change-time advances past the pre-read snapshot, without weakening any assertion. Extended the same pause to the sibling "prioritizes a read-time mutation over the resulting decode error" test, which relies on the identical ctime-advance root cause for its hash_mismatch expectation. Both tests still pass.
  • [Suggestion] document the reused-buffer contract (rc:3668625401). Added a comment at the readFileHandleChunks yield site noting that the 512 KiB buffer is reused across iterations, so each yielded view is valid only until the next read and consumers must decode or copy it before advancing the generator. This is the non-obvious "why" the finding asked for; no behavior change.

Declined

  • [Suggestion] compute truncated instead of hardcoding true (rc:3667902026). Declined. The hardcoded truncated: true is the documented contract for large partial windows, not an oversight:
    • docs/developers/qwen-serve-protocol.md (GET /file) states large partial windows "set truncated: true". The streaming path only fires for files above the 256 KiB cap and never returns the whole file, so every window is partial by construction.
    • The suggested expression would flip the existing beyondEof assertion in workspace-file-system.test.ts: a past-EOF window has originalLineCountExact: true and truncatedByBytes: false, so the expression yields false while the test deliberately asserts true.
    • The stated failure scenario cannot occur: maxBytes is validated to [1, MAX_READ_BYTES], so a fully-covered window of a file above the cap always exceeds the output cap and is byte-truncated (truncatedByBytes: true).
    • If a maintainer prefers computed truncation semantics here, it should land as a deliberate contract change that updates the protocol doc and the beyondEof expectation together. Left unresolved for that decision.

Informational — author self-review notes (no change requested)

  • rc:3667529786 (unreachable fileHandle !== undefined && forceStreaming !== true combination): left as-is per the author's documented decision; collapsing it requires reshaping the read API into an explicit handle-entry, out of scope for this diff.
  • rc:3667529791 (per-iteration 512 KiB allocation): already addressed in the final code — readFileHandleChunks allocates one buffer before the loop and yields subarray views into it, so there is no per-iteration allocation. The new yield-site comment now documents that reuse.
  • rc:3667529794 (lineEnding derived differently on the two paths): informational; both are defensible and feed the same meta.lineEnding. Left as-is to keep the diff to policy.
  • rc:3667529800 (unreachable opened === undefined branch): left as-is per the author's note; the branch is unreachable but the | undefined declaration is kept for TypeScript narrowing after the try.

Conflict

No conflict with main (--conflict false); no merge performed.

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • vitest packages/cli src/serve/fs/workspace-file-system.test.ts (touched) — 106 passed
  • vitest packages/core src/utils/read-text-range.test.ts (touched) — 19 passed

No settings source changed, so generate:settings-schema was not required. The
changes are a source comment and test-only timing pauses, not behavior exercised
only through the bundled CLI, so no integration run was needed.

中文说明

评审反馈分诊

分诊了自上次评估以来新增的七条行内发现。实现了测试 flakiness 的 Critical 与文档类 Suggestion;驳回了一条与已被文档约定的线上契约相冲突的 Suggestion;确认了四条不要求更改的作者自审说明。

已实现

  • [Critical] ctime 精度导致的测试 flakiness(rc:3668625383)。 同大小覆写测试将 after.ctimeMs !== before.ctimeMs 作为前置断言,在粗精度文件系统上,write → close → utimes → stat 序列可能落在同一个 change-time 量子内而导致该断言失败。在恢复 mtime 之前加入 50 ms 暂停,使 change-time 推进到预读快照之后,且不削弱任何断言。并将同样的暂停扩展到姊妹测试"prioritizes a read-time mutation over the resulting decode error",该测试对 hash_mismatch 的预期依赖于完全相同的 ctime 推进根因。两个测试仍然通过。
  • [Suggestion] 记录复用缓冲区的契约(rc:3668625401)。readFileHandleChunks 的 yield 处添加注释,说明 512 KiB 缓冲区在迭代间被复用,因此每个 yield 出的视图仅在下次 read 之前有效,消费者必须在生成器推进前对其解码或拷贝。这正是该发现所要求的非显而易见之"为什么";无行为变更。

已驳回

  • [Suggestion] 计算 truncated 而非硬编码为 true(rc:3667902026)。 驳回。硬编码的 truncated: true 是大文件部分窗口已被文档约定的契约,并非疏漏:
    • docs/developers/qwen-serve-protocol.mdGET /file)说明大文件部分窗口会"设置 truncated: true"。流式路径仅在文件超过 256 KiB 上限时触发,且从不返回整个文件,因此每个窗口按构造都是部分的。
    • 建议的表达式会翻转 workspace-file-system.test.ts 中现有的 beyondEof 断言:越过 EOF 的窗口具有 originalLineCountExact: truetruncatedByBytes: false,因此该表达式得出 false,而测试刻意断言为 true
    • 所述失败场景不可能发生:maxBytes 被校验为 [1, MAX_READ_BYTES],因此超过上限的文件被完整覆盖的窗口总会超出输出上限并被按字节截断(truncatedByBytes: true)。
    • 如果维护者希望此处采用计算出的截断语义,应作为一个有意的契约变更,同时更新协议文档与 beyondEof 预期。该决定保留为未解决。

信息性——作者自审说明(不要求更改)

  • rc:3667529786(不可达的 fileHandle !== undefined && forceStreaming !== true 组合):按作者已记录的决定保持不变;折叠它需要将读取 API 重塑为显式的 handle 入口,超出本 diff 范围。
  • rc:3667529791(逐次 512 KiB 分配):最终代码已解决——readFileHandleChunks 在循环前分配一个缓冲区,并 yield 指向它的 subarray 视图,因此不存在逐次分配。新增的 yield 处注释现已记录该复用。
  • rc:3667529794(两条路径上 lineEnding 的推导方式不同):信息性;两者都站得住脚,并都写入同一个 meta.lineEnding。为将 diff 限定在策略层面,保持不变。
  • rc:3667529800(不可达的 opened === undefined 分支):按作者的说明保持不变;该分支不可达,但保留 | undefined 声明以便在 try 之后进行 TypeScript 类型收窄。

冲突

与 main 无冲突(--conflict false);未执行合并。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • vitest packages/cli src/serve/fs/workspace-file-system.test.ts(受影响)— 106 通过
  • vitest packages/core src/utils/read-text-range.test.ts(受影响)— 19 通过

未更改任何 settings 源,因此无需 generate:settings-schema。改动为一处源码注释与仅测试用的时序暂停,并非仅经由打包后的 CLI 才能 exercise 的行为,因此无需集成测试。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Local verification of dfd941e — two real daemons, real files, no mocks

I built and ran this locally rather than relying on the suite alone. Setup: two real qwen serve daemons, base b6b55c5 on :4792 and PR head dfd941e on :4791, both executing TypeScript source, bound to the same workspace with the same fixtures (417 KB ASCII CSV, 497 KB UTF-8 CJK CSV, 14.3 MB log, 300 KB binary, 809 KB GBK, 554 KB UTF-16LE, BOM+CRLF, long CJK/emoji lines, and two sub-cap controls). Every number below came out of those processes.

1. The bug and the fix, in a real agent turn

agent A/B

A scripted model issues read_file(file_path, offset: 0, limit: 20) against the 417 KB CSV. On base the model receives file of 417145 bytes exceeds read cap of 262144 bytes — issue #7946 reproduced end to end, through the ACP bridge into WorkspaceFileSystem. On the PR head the same call returns Showing lines 1-20 of 6002 total lines. plus the rows.

Negative control. Reverting only if (pre.size > MAX_READ_BYTES && opts.limit !== undefined) on the head build restores the base behaviour exactly, at both the REST route and the agent tool call. The same revert turns 12 of the PR's new tests red with behavioural assertions (file_too_large vs binary_file / symlink_escape / hash_mismatch, 413 vs 200) — not import or compile errors. The tests are genuinely pinned to the change.

2. GET /file A/B — 23 scenarios

REST A/B

  • The gate is narrowed, not widened. No-window, line-only, maxBytes-only, and line-plus-maxBytes stay 413 on both sides.
  • Deep offsets work. Line 110 000 of a 14.3 MB file (≈10.5 MB in) returns in ~37 ms.
  • Output stays capped. maxBytes=1000 returns exactly 1000 bytes; 262144 is accepted, 262145 and 0 are parse_error on both sides.
  • Metadata matches the description. truncated: true, complete source sizeBytes, no full-file hash, originalLineCount: null when the scan stopped early and exact (6002) when it reached EOF.
  • Encoding paths hold. Large UTF-8 CJK is admitted; BOM and CRLF are reported correctly; long CJK/emoji lines truncate on a codepoint boundary with zero replacement characters. Large GBK and UTF-16LE stay 413 with the "convert to UTF-8" hint.
  • Sub-cap controls are byte-identical between base and head.

Two intentional flips I'd like acknowledged explicitly before merge, because both are observable to existing clients:

  • C1 — a >256 KiB binary read with a limit moves from 413 file_too_large to 422 binary_file. Better classification, but it is an error-kind change for that input class.
  • G3 — a 235 KB UTF-16LE file used to return 333 293 bytes of decoded UTF-8, above the 262 144-byte boundary cap. It is now capped and flagged truncated: true. That is the right fix, but it lands on a sub-cap file: a caller that ignores truncated silently gets ~80 % of a file it previously got whole. Worth one line in the release notes.

3. Snapshot safety

snapshot guard

  • Realistic concurrent mutation, driven through the live daemon: append, truncate, and symlink-swap during the scan of a 14.3 MB file — 36/36 rejected (409 hash_mismatch, 400 symlink_escape), never a mixed snapshot.

  • The flake dfd941e fixed is really fixed. On 696a8d4 the two ctime tests failed 7/10 and 9/10 across ten consecutive full-file runs on this box; on dfd941e they are 0/10. The +50 ms pause is what did it.

  • Residual, on the production side. The same-size + mtime-restore case is detected through ctimeMs, which advances at the kernel's coarse-clock quantum — measured at 4.0 ms here, 195/200 same-ctime collisions on both tmpfs and ext4. When the overwrite lands inside the same quantum as the open, 23/30 such reads returned a window containing post-open bytes; with a 50 ms delay, 30/30 were rejected.

    This is not a regression — base refused these reads outright, and the pre-existing full-snapshot path compares only size + mtimeMs, so the new path is strictly stronger. My only ask is wording: the PR description says such rewrites are "rejected before content is returned". I'd soften that to "rejected whenever the change lands in a later timestamp quantum", so nobody downstream builds on it as a hard guarantee.

4. Gates

Gate Result
core read-text-range + fileSystemService 71/71
cli workspace-file-system + bridge adapter + HTTP route 166/167
eslint --max-warnings 0, 6 changed production files clean
core tsc --noEmit 25 errors, all in 3 unrelated test files, identical count on base

The one CLI failure is throws AggregateError when every workspace root glob fails, which fails identically on the base commit — it assumes an unprivileged filesystem and I run as root. The tsc errors come from an @types/node skew in the borrowed node_modules, not from this PR; none touch a changed file. Repo-wide typecheck and build are covered by CI, which is green on this head.

Verdict

Behaviour matches the description on every scenario I could construct, the central change is provably load-bearing, and the new tests fail for the right reasons when it is removed. No blocker from me. For the record before merge: the 422 binary_file flip and the sub-cap UTF-16 truncation are user-visible contract changes, and the same-size + mtime-restore rejection is best-effort at coarse-clock resolution rather than absolute.

中文说明

本地验证 dfd941e —— 两个真实守护进程、真实文件、无 mock

我在本地实际构建并运行了这个 PR,而不是只看测试套件。环境:两个真实的 qwen serve 守护进程,base b6b55c5 跑在 :4792,PR head dfd941e 跑在 :4791,都直接执行 TypeScript 源码,绑定同一个工作区和同一批 fixture(417 KB ASCII CSV、497 KB UTF-8 中文 CSV、14.3 MB 日志、300 KB 二进制、809 KB GBK、554 KB UTF-16LE、BOM+CRLF、超长中文/emoji 行,以及两个小于上限的对照文件)。下面所有数字都来自这两个进程。

1. 真实 agent 回合中的问题与修复

(见上方第一张截图)

脚本化模型对 417 KB CSV 发起 read_file(file_path, offset: 0, limit: 20)。在 base 上模型收到 file of 417145 bytes exceeds read cap of 262144 bytes —— issue #7946 被端到端复现,路径经过 ACP bridge 进入 WorkspaceFileSystem。在 PR head 上同一次调用返回 Showing lines 1-20 of 6002 total lines. 以及具体内容。

反向对照。 在 head 构建上只回退 if (pre.size > MAX_READ_BYTES && opts.limit !== undefined) 这一个条件,REST 路由和 agent 工具调用都精确回到 base 的行为;同一次回退还让 PR 新增的 12 个测试以行为断言失败file_too_largebinary_file / symlink_escape / hash_mismatch413200),而不是导入或编译错误。说明这些测试确实钉住了本次改动。

2. GET /file A/B —— 23 个场景

(见上方第二张截图)

  • 准入是收窄而不是放宽。 无窗口、仅 line、仅 maxBytes、line 加 maxBytes 在两侧都保持 413
  • 深偏移可用。 14.3 MB 文件的第 110 000 行(约 10.5 MB 处)约 37 ms 返回。
  • 输出仍然有界。 maxBytes=1000 精确返回 1000 字节;262144 接受,2621450 两侧都是 parse_error
  • 元数据与描述一致。 truncated: true、完整的源文件 sizeBytes、无全文件 hash;扫描提前停止时 originalLineCountnull,扫描到 EOF 时为精确值 6002
  • 编码路径正确。 大型 UTF-8 中文文件被放行;BOM 与 CRLF 上报正确;超长中文/emoji 行按码点边界截断,无替换字符。大型 GBK 与 UTF-16LE 仍为 413,并带"转成 UTF-8"的提示。
  • 小于上限的对照文件在 base 与 head 上逐字节一致。

有两处是有意为之、但对现有客户端可见的变化,希望合入前明确确认:

  • C1 —— 大于 256 KiB 的二进制文件带 limit 读取,从 413 file_too_large 变为 422 binary_file。分类更准确,但对该类输入而言是错误类型的变化。
  • G3 —— 一个 235 KB 的 UTF-16LE 文件,此前解码后返回 333 293 字节 UTF-8,超过 262 144 字节的边界上限;现在被截断并标记 truncated: true。方向正确,但它作用在一个小于上限的文件上:忽略 truncated 的调用方会静默地只拿到原本完整文件的约 80%。建议在 release notes 里写一句。

3. 快照一致性

(见上方第三张截图)

  • 通过真实守护进程驱动的并发修改: 在 14.3 MB 文件扫描过程中执行 append、truncate、符号链接替换 —— 36/36 全部拒绝409 hash_mismatch400 symlink_escape),没有出现混合快照。

  • dfd941e 修掉的 flaky 确实修好了。696a8d4 上,两个 ctime 测试在本机连续 10 次整文件运行中分别失败 7/109/10;在 dfd941e 上是 0/10。起作用的正是那个 +50 ms 暂停。

  • 生产侧的残余边界。 同大小覆盖并恢复 mtime 的情形依靠 ctimeMs 检测,而它按内核粗粒度时钟推进 —— 本机实测量子为 4.0 ms,tmpfs 与 ext4 均为 195/200 次 ctime 相同。当覆盖写落在与 open 相同的量子内时,23/30 次读取返回了包含 open 之后写入字节的窗口;加 50 ms 延迟后 30/30 被拒绝。

    不是回归 —— base 直接拒绝这类读取,而既有的全量快照路径只比较 size + mtimeMs,新路径严格更强。我唯一的请求是措辞:PR 描述里写的是这类覆盖"在返回内容前被拒绝",建议改成"当变更落在更晚的时间戳量子时被拒绝",避免下游把它当成硬保证。

4. 各项门禁

门禁 结果
core read-text-range + fileSystemService 71/71
cli workspace-file-system + bridge adapter + HTTP route 166/167
eslint --max-warnings 0,6 个改动的生产文件 通过
core tsc --noEmit 25 个错误,全部位于 3 个无关测试文件,base 上数量完全相同

唯一失败的 CLI 用例是 throws AggregateError when every workspace root glob fails,它在 base 提交上同样失败 —— 该用例假设非特权文件系统,而我以 root 运行。tsc 错误来自借用 node_modules 造成的 @types/node 版本偏差,与本 PR 无关,且不涉及任何被改动的文件。全仓 typecheck 与 build 由 CI 覆盖,当前 head 上 CI 全绿。

结论

我能构造出的所有场景,行为都与 PR 描述一致;核心改动被证明是 load-bearing;移除它时新增测试以正确的原因失败。我这边没有阻断项。 合入前留档两点:422 binary_file 的变化与小于上限的 UTF-16 截断属于对用户可见的契约变更;同大小覆盖加恢复 mtime 的拒绝是粗粒度时钟精度下的尽力而为,而非绝对保证。


🤖 Generated with Claude Code — Claude Opus 5 (1M context)

@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 issues found. LGTM! ✅

— qwen3.8-max-preview via Qwen Code /review

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 2/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 2/100 轮)。改动内容与我反驳保留之处如下:

Review feedback addressed — PR #7947

Summary

The only feedback this round is @wenshao's issue-level local-verification report (two real daemons, real files, no mocks). It is positive — "No blocker from me" — with no inline comments, no reviews, and no failed checks. It carries three soft asks: acknowledge two user-visible contract changes (C1, G3) and soften the same-size-rewrite rejection wording so nobody treats it as a hard guarantee. One resulted in a small docs change; the other two are acknowledgments with no in-repo artifact to edit.

Feedback points and decisions

Wording: same-size + mtime-restore rejection is best-effort, not absolute — Addressed

The reviewer asked to soften "rejected before content is returned" to "rejected whenever the change lands in a later timestamp quantum." The PR-description text itself is maintained by the workflow (this bot has no GitHub write access), but the committed design doc this PR added (docs/design/serve-large-text-range-consistency.md) carried the parallel flat claim "same-size rewrite … rejected" in its Verification section. I softened that bullet to state that a same-size in-place rewrite is rejected whenever the change lands in a later timestamp quantum, explaining the mechanism (the checks compare modification time and change time, so a rewrite that also restores modification time is caught only by change time advancing), the coarse-clock best-effort caveat, and that this is still strictly stronger than the prior size + modification-time full-snapshot comparison. Docs-only change; no behavior touched. The user-facing 07-workspace-filesystem.md was already careful ("a detected mutation … returns hash_mismatch") and needed no edit.

C1 — >256 KiB binary read with a limit flips 413 file_too_large422 binary_fileAcknowledged, no change

Confirmed intended behavior. The design doc's Decision section already specifies "Without a mutation, binary content remains binary_file," and the PR's tests pin this classification (the reviewer's negative control turned 12 of them red on revert). Acknowledged as a user-visible error-kind change for that input class; the reviewer also framed it as "better classification," not a defect, so no code change is warranted.

G3 — sub-cap UTF-16LE file now capped and flagged truncated: trueAcknowledged, no in-repo change

Confirmed intended behavior. The design doc documents that every large-file window keeps truncated: true precisely so callers can distinguish a window without a full-file hash from whole-file content. The reviewer's "worth one line in the release notes" is handled at release time: CHANGELOG.md is auto-generated from GitHub Releases ("Do not edit it by hand — run npm run changelog to regenerate"), so there is no in-repo release-notes artifact to edit. Acknowledged as a user-visible contract change — a caller that ignores truncated silently gets a partial window of a sub-cap file.

Conflict notes

--conflict false; no merge performed.

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed (ESLint clean)
  • Focused Vitest / integration after npm run bundle / npm run generate:settings-schema — not applicable: this is a docs-only change, so no package source, bundled-CLI behavior, or settings source was touched.
中文说明

已处理的评审反馈 — PR #7947

概述

本轮唯一的反馈是 @wenshao 的一条 issue 级本地验证报告(两个真实守护进程、真实文件、无 mock)。结论是正面的 —— "我这边没有阻断项" —— 并且没有行内评论、没有 review、没有失败的检查。它包含三个柔性请求:确认两处对用户可见的契约变化(C1、G3),以及弱化"同大小覆盖写会被拒绝"的措辞,避免下游把它当成硬保证。其中一项落实为一处小的文档改动;另外两项属于确认性质,仓库中没有需要修改的对应产物。

各反馈点与处理决定

措辞:同大小 + 恢复 mtime 的拒绝是尽力而为,而非绝对 —— 已处理

评审者建议把"在返回内容前被拒绝"弱化为"当变更落在更晚的时间戳量子时被拒绝"。PR 描述文本本身由工作流维护(本机器人没有 GitHub 写权限),但本 PR 新增的已提交设计文档(docs/design/serve-large-text-range-consistency.md)在其 Verification 一节带有同样的绝对化表述"same-size rewrite … rejected"。我已弱化该条目,改为:同大小覆盖写在变更落在更晚的时间戳量子时被拒绝,并说明机制(检查会比较 modification time 与 change time,因此同时恢复了 modification time 的覆盖写只能靠 change time 推进来捕获)、粗粒度时钟下的尽力而为特性,以及它仍严格强于此前的 size + modification-time 全量快照比较。纯文档改动,未触及任何行为。面向用户的 07-workspace-filesystem.md 本身措辞已经严谨("a detected mutation … returns hash_mismatch"),无需修改。

C1 —— 大于 256 KiB 的二进制文件带 limit 读取,从 413 file_too_large 变为 422 binary_file —— 已确认,不改动

确认为预期行为。设计文档的 Decision 一节已明确"Without a mutation, binary content remains binary_file",且本 PR 的测试钉住了这一分类(评审者的反向对照在回退后让其中 12 个测试失败)。确认这是对该类输入而言对用户可见的错误类型变化;评审者也将其定性为"更准确的分类"而非缺陷,因此无需改动代码。

G3 —— 小于上限的 UTF-16LE 文件现在被截断并标记 truncated: true —— 已确认,仓库内无改动

确认为预期行为。设计文档已说明:每个大文件窗口都保留 truncated: true,正是为了让调用方能够区分"没有全文件哈希的窗口"与"整文件内容"。评审者建议的"在 release notes 里写一句"会在发布时处理:CHANGELOG.md 由 GitHub Releases 自动生成("Do not edit it by hand — run npm run changelog to regenerate"),因此仓库中没有需要编辑的 release-notes 产物。确认这是对用户可见的契约变化 —— 忽略 truncated 的调用方会静默地只拿到一个小于上限文件的部分窗口。

冲突说明

--conflict false,未执行合并。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过(ESLint 无告警)
  • 聚焦 Vitest / npm run bundle 后的集成测试 / npm run generate:settings-schema — 不适用:本次为纯文档改动,未触及任何包源码、打包 CLI 行为或配置源。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@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 issues found. LGTM! ✅

中文说明

未发现问题。LGTM!✅

— qwen3.8-max-preview via Qwen Code /review

@doudouOUC
doudouOUC added this pull request to the merge queue Jul 29, 2026
Merged via the queue into QwenLM:main with commit 4615f84 Jul 29, 2026
59 of 61 checks passed
qwen-code-dev-bot pushed a commit to doudouOUC/qwen-code that referenced this pull request Jul 29, 2026
…vior reversals (QwenLM#7967)

Maintainer review adjudicated the pending design-reversal Critical: the
descriptor-threading refactor is sound and boundary-neutral, but this PR also
carried two undisclosed reversals of QwenLM#7947 that must not ride in on a refactor.

Revert both, keeping only the refactor and the disclosed non-UTF-8 mapping:

- Restore didFileVersionChange (size + mtime + ctime equality before and after
  the streamed read) in place of assertDidNotShrink. The size-only guard let a
  same-size in-place overwrite return torn content and a concurrent append run
  past the snapshot it opened against; the equality check rejects both with
  hash_mismatch. Main's three snapshot tests are restored unchanged.
- Restore QwenLM#7947's limit-only large-text admission and drop MAX_TEXT_SCAN_BYTES /
  maxScanBytes / TextScanBudgetExceededError. The broadened wantsWindow
  admission and scan budget are a real contract change that belongs in its own
  PR against QwenLM#7947, not here.

Kept: the descriptor-threading refactor (one detectFileEncoding, two entry
points, readTextFileFromHandle/readTextRangeFromHandle, no mode flags) and the
disclosed mapping of large non-UTF-8 windows to binary_file rather than
file_too_large, which the maintainer explicitly approved.
qwen-code-dev-bot pushed a commit to ytahdn/qwen-code that referenced this pull request Jul 30, 2026
* fix(serve): allow bounded reads of large text files

* fix(serve): bound large-text reads by scan cost, not by which knob was set

Follow-up to the bounded large-text read path. Three changes:

Gate on any explicit window argument, not on `limit`. Gating on `limit`
had the cost model backwards in both directions: `{ line: 900_000_000,
limit: 20 }` was admitted despite walking the whole file, while
`{ maxBytes: 4096 }` — satisfiable from the first 4 KiB — was refused. A
read with no window argument at all still fails, since a caller that
believes it holds the whole file may write it back truncated.

Add MAX_TEXT_SCAN_BYTES (8 MiB). MAX_READ_BYTES caps what a read
returns; nothing capped what it cost. Line offsets are resolved by
scanning from byte 0, so a query param could turn into an
uninterruptible multi-second scan of an arbitrarily large file — and on
Windows hold a read handle for that span, blocking renames and deletes.
Past the budget the read is refused with `file_too_large` pointing at
readBytes, which reaches any offset in O(1).

Tolerate appends on streamed windows. Requiring whole-file size/mtime
stability after reading a prefix rejected reads whose returned bytes
were still valid, and the case it rejected — tailing a live log — is the
one this path exists for. Streamed windows now assert inode identity
plus "did not shrink"; truncation and replacement are still rejected.

Also: non-UTF-8 large text now returns `binary_file` rather than
`file_too_large`, so a client retrying on 413 with a smaller window
can't loop forever; and `readFileWithLineAndLimit` throws instead of
silently ignoring a caller-supplied `fileHandle` on the by-path
fallback.

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

* refactor(core): thread the descriptor instead of forking text-read helpers

PR QwenLM#7947 pinned large-text reads to one inode by threading a caller-owned
FileHandle into readTextRange as an optional field, plus a second field,
forceStreaming, to suppress the buffering fast path. Two optional fields
produced four combinations: one meaningful, one used by a single test, one
unreachable, and — in readFileWithLineAndLimit — one that silently fell
through to a by-path read, defeating the reason the caller opened a handle.

Unify the two encoding detectors. detectFileEncoding now takes a path or a
borrowed handle, so detectFileHandleEncoding is deleted along with the
message discrepancy between them: an encoding iconv-lite cannot load now
raises LargeNonUtf8TextError naming that encoding rather than deferring to
the decoder's generic invalid-utf8 variant. Both still refuse the file, and
the Serve boundary maps both to binary_file.

Split the reader into readTextRange (path) and readTextRangeFromHandle
(always streams, both byte bounds required). The unreachable combination and
its untested readFileHandleBuffer are gone, and with no fileHandle parameter
left for readFileWithLineAndLimit to ignore, the RangeError guarding that
fallthrough is deleted too — the trap can no longer be expressed.

CoreReadTextFileHandleRequest drops its required stats field. Nothing
downstream read it, and because the ACP request type it extends permits
extra properties, TypeScript accepted the dead argument silently.

readFileHandleChunks becomes chunksFromHandle(fh, from) — the one seam
byte-cursor text paging needs.

No observable change at the Serve boundary: its 222 tests pass unmodified.
Two fileSystemService tests were deleted rather than repaired; they asserted
the arguments readFileWithLineAndLimit received, which is nothing once the
handle path stops calling it. Their coverage lives in read-text-range.test.ts
against real files and in workspace-file-system.test.ts at the real boundary.

258 production lines in core, net -71 overall.

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

* refactor(core): make CoreReadTextFileHandleRequest standalone

Self-audit follow-up to f55c867. Two fields survived the reshape that the
handle path never reads:

- `stats` was documented as required ("must pass the Stats captured from that
  handle") and nothing downstream read it. The handle path always streams, so
  it never needs a size to choose a strategy, and the encoding probe does its
  own fstat.
- `path` became dead once readTextRangeFromHandle replaced the path-plus-handle
  call. Errors are labelled with the path by the Serve boundary that owns it.

Neither was caught by the compiler: the ACP ReadTextFileRequest the type
derived from permits extra properties, so the CLI kept passing both silently.
That is the argument for declaring the type standalone rather than Omit-ing
four of six inherited fields and quietly re-admitting the rest.

Also record the second behaviour delta of the detector merge in the design
doc: detectFileEncoding catches I/O errors and falls back to 'utf-8', where
detectFileHandleEncoding let them propagate. The failure is not lost — a handle
that fails the 8 KiB probe fails the streaming read immediately after — but a
different call now reports it.

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

* feat(serve): page large text files by byte cursor

Line offsets address a byte stream, so `readText` resolves them by scanning
from byte 0. Paging a large log that way is O(n^2) across pages, and past
MAX_TEXT_SCAN_BYTES (8 MiB) a deep page is refused outright — agents had no
O(1) path short of dropping to GET /file/bytes and splitting lines themselves,
losing encoding handling, multibyte safety, and the binary_file refusal.

A response that leaves content behind now returns `hasMore`, and where a file
byte offset is derivable, an opaque `nextCursor`. Passing it back as `cursor`
resumes in O(1). Page 1 is an ordinary `limit` read, so clients never compute
byte offsets themselves, and a paging loop does not break when a file happens
to be small.

The cursor is unsigned base64url JSON carrying {off, size, dev, ino}, matching
encodeOrganizedCursor rather than the HMAC-signed transcript codec: the path is
re-resolved through the workspace boundary on every request, so a forged cursor
can only move the offset within a file the caller may already read — what
GET /file/bytes?offset= allows today. What the payload is for is staleness:
a replaced or truncated file yields hash_mismatch instead of bytes from the
wrong place, while an append leaves an outstanding cursor valid — the case the
feature exists for.

Every minted cursor points at the start of a line. When a single line exceeds
maxOutputBytes the reader emits a truncated prefix and skips to the next line
rather than resuming mid-line, because a mid-line cursor makes the following
page snap forward and silently drop the rest of that line at the seam. Windows
cut mid-line by a byte cap therefore report hasMore with no cursor, as do
non-UTF-8 snapshot reads whose decoded text is a UTF-8 re-encoding with no
mapping back to file offsets. That is why hasMore is a field rather than a
restatement of nextCursor.

Cursor reads branch before the size check, not by widening the window gate:
a cursor read of a file under MAX_READ_BYTES would otherwise land on the
snapshot path, which knows only line/limit, and silently return line 0.

Adds the workspace_file_read_cursor capability, per the convention that new
behavior gets a new tag, and retargets the scan-budget hint at cursor paging.

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

* fix(core): advance UTF-8 cursors after truncation

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(serve): clarify cursor bootstrap limits

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): raise daemon browser bundle budget

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(serve): cover ACP cursor dispatch and cursor binary_file mapping (QwenLM#8002)

* fix(core): only set sawCrlf for emitted lines in cursor paging (QwenLM#8002)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.2.

@doudouOUC
doudouOUC deleted the agent/serve-large-text-range-read branch August 1, 2026 02:06
OrbitZore pushed a commit to OrbitZore/qwen-code that referenced this pull request Aug 1, 2026
…lpers (QwenLM#7967)

* fix(serve): allow bounded reads of large text files

* fix(serve): bound large-text reads by scan cost, not by which knob was set

Follow-up to the bounded large-text read path. Three changes:

Gate on any explicit window argument, not on `limit`. Gating on `limit`
had the cost model backwards in both directions: `{ line: 900_000_000,
limit: 20 }` was admitted despite walking the whole file, while
`{ maxBytes: 4096 }` — satisfiable from the first 4 KiB — was refused. A
read with no window argument at all still fails, since a caller that
believes it holds the whole file may write it back truncated.

Add MAX_TEXT_SCAN_BYTES (8 MiB). MAX_READ_BYTES caps what a read
returns; nothing capped what it cost. Line offsets are resolved by
scanning from byte 0, so a query param could turn into an
uninterruptible multi-second scan of an arbitrarily large file — and on
Windows hold a read handle for that span, blocking renames and deletes.
Past the budget the read is refused with `file_too_large` pointing at
readBytes, which reaches any offset in O(1).

Tolerate appends on streamed windows. Requiring whole-file size/mtime
stability after reading a prefix rejected reads whose returned bytes
were still valid, and the case it rejected — tailing a live log — is the
one this path exists for. Streamed windows now assert inode identity
plus "did not shrink"; truncation and replacement are still rejected.

Also: non-UTF-8 large text now returns `binary_file` rather than
`file_too_large`, so a client retrying on 413 with a smaller window
can't loop forever; and `readFileWithLineAndLimit` throws instead of
silently ignoring a caller-supplied `fileHandle` on the by-path
fallback.

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

* refactor(core): thread the descriptor instead of forking text-read helpers

PR QwenLM#7947 pinned large-text reads to one inode by threading a caller-owned
FileHandle into readTextRange as an optional field, plus a second field,
forceStreaming, to suppress the buffering fast path. Two optional fields
produced four combinations: one meaningful, one used by a single test, one
unreachable, and — in readFileWithLineAndLimit — one that silently fell
through to a by-path read, defeating the reason the caller opened a handle.

Unify the two encoding detectors. detectFileEncoding now takes a path or a
borrowed handle, so detectFileHandleEncoding is deleted along with the
message discrepancy between them: an encoding iconv-lite cannot load now
raises LargeNonUtf8TextError naming that encoding rather than deferring to
the decoder's generic invalid-utf8 variant. Both still refuse the file, and
the Serve boundary maps both to binary_file.

Split the reader into readTextRange (path) and readTextRangeFromHandle
(always streams, both byte bounds required). The unreachable combination and
its untested readFileHandleBuffer are gone, and with no fileHandle parameter
left for readFileWithLineAndLimit to ignore, the RangeError guarding that
fallthrough is deleted too — the trap can no longer be expressed.

CoreReadTextFileHandleRequest drops its required stats field. Nothing
downstream read it, and because the ACP request type it extends permits
extra properties, TypeScript accepted the dead argument silently.

readFileHandleChunks becomes chunksFromHandle(fh, from) — the one seam
byte-cursor text paging needs.

No observable change at the Serve boundary: its 222 tests pass unmodified.
Two fileSystemService tests were deleted rather than repaired; they asserted
the arguments readFileWithLineAndLimit received, which is nothing once the
handle path stops calling it. Their coverage lives in read-text-range.test.ts
against real files and in workspace-file-system.test.ts at the real boundary.

258 production lines in core, net -71 overall.

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

* refactor(core): make CoreReadTextFileHandleRequest standalone

Self-audit follow-up to f55c867. Two fields survived the reshape that the
handle path never reads:

- `stats` was documented as required ("must pass the Stats captured from that
  handle") and nothing downstream read it. The handle path always streams, so
  it never needs a size to choose a strategy, and the encoding probe does its
  own fstat.
- `path` became dead once readTextRangeFromHandle replaced the path-plus-handle
  call. Errors are labelled with the path by the Serve boundary that owns it.

Neither was caught by the compiler: the ACP ReadTextFileRequest the type
derived from permits extra properties, so the CLI kept passing both silently.
That is the argument for declaring the type standalone rather than Omit-ing
four of six inherited fields and quietly re-admitting the rest.

Also record the second behaviour delta of the detector merge in the design
doc: detectFileEncoding catches I/O errors and falls back to 'utf-8', where
detectFileHandleEncoding let them propagate. The failure is not lost — a handle
that fails the 8 KiB probe fails the streaming read immediately after — but a
different call now reports it.

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

* fix(serve): address review feedback on decoded-output cap, lineEnding, line validation, and docs (QwenLM#7967)

* fix(serve): scope PR to descriptor threading, revert QwenLM#7947 behavior reversals (QwenLM#7967)

Maintainer review adjudicated the pending design-reversal Critical: the
descriptor-threading refactor is sound and boundary-neutral, but this PR also
carried two undisclosed reversals of QwenLM#7947 that must not ride in on a refactor.

Revert both, keeping only the refactor and the disclosed non-UTF-8 mapping:

- Restore didFileVersionChange (size + mtime + ctime equality before and after
  the streamed read) in place of assertDidNotShrink. The size-only guard let a
  same-size in-place overwrite return torn content and a concurrent append run
  past the snapshot it opened against; the equality check rejects both with
  hash_mismatch. Main's three snapshot tests are restored unchanged.
- Restore QwenLM#7947's limit-only large-text admission and drop MAX_TEXT_SCAN_BYTES /
  maxScanBytes / TextScanBudgetExceededError. The broadened wantsWindow
  admission and scan budget are a real contract change that belongs in its own
  PR against QwenLM#7947, not here.

Kept: the descriptor-threading refactor (one detectFileEncoding, two entry
points, readTextFileFromHandle/readTextRangeFromHandle, no mode flags) and the
disclosed mapping of large non-UTF-8 windows to binary_file rather than
file_too_large, which the maintainer explicitly approved.

* test(core): port deep-read and encoding-probe tests to the handle path (QwenLM#7967)

* test(core): pin handle read-to-EOF and unlimited-limit contracts (QwenLM#7967)

* docs(serve): disclose handle-read behaviour deltas and add boundary tests (QwenLM#7967)

Address maintainer re-verification (R2). The refactor's code is unchanged;
this makes the documentation describe the actual diff and pins the remaining
untested handle-path contracts.

- Rewrite the design doc to match what the diff does against current main
  (one detector, two entry points, chunksFromHandle, standalone
  CoreReadTextFileHandleRequest) instead of the superseded intermediate
  state, and add a Behaviour deltas section disclosing the read-to-EOF
  chunk reader, the fresh-buffer-per-chunk change, the limit: Infinity
  admission, and the binary_file mapping for undecodable large text.
- State the handle-layer omitted/infinite limit contract in
  qwen-serve-protocol.md, reconciled with the unchanged GET /file admission.
- Add handle-bound abort tests (pre-aborted and mid-stream) to
  read-text-range.test.ts and an HTTP-layer test asserting a large
  non-UTF-8 file requested with a finite limit returns 422 binary_file.

* test(core): add positive handle-read test for Infinity and omitted limit (QwenLM#7967)

* fix(core): satisfy ReadTextRangeResult type in handle-read test mock (QwenLM#7967)

* fix(core): bound handle reads to stat size and reject Infinity limit (QwenLM#7967)

* fix(core): satisfy ReadTextRangeResult type in fileSystemService test mocks (QwenLM#7967)

* refactor(core): reuse detectLineEndingFromContent in whole-file fast path (QwenLM#7967)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(core): export ReadTextRangeResult type and cover CRLF detection (QwenLM#7967)

* test(core): cover whole-file lineEnding and truncatedByBytes shape (QwenLM#7967)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: qwen-autofix[bot] <qwen-autofix[bot]@users.noreply.github.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Serve rejects bounded reads for text files larger than 256 KiB

5 participants