Skip to content

fix(runner): 输出分页不再跳过未交付的字节 - #498

Merged
Qiyuanqiii merged 1 commit into
mainfrom
Qiyuanqiii/runtime-page-cursor
Oct 27, 2026
Merged

Qiyuanqiii merged 1 commit into
mainfrom
Qiyuanqiii/runtime-page-cursor

Conversation

@Qiyuanqiii

Copy link
Copy Markdown
Member

commandRuntimeRing.read 在页预算落在一个多字节 rune 中间时,把这一帧的文本回退到完整 rune 再交付(internal/runner/command_runtime_job.go:1812-1819:data = data[:remaining],然后 for len(data) > 0 && !utf8.Valid(data) { data = data[:len(data)-1] },copyFrame.NextCursor = copyFrame.Cursor + uint64(len(data)))。回退之后 remaining 还剩 1–3 字节(used 只按实际交付的字节扣,:1824-1825),于是循环继续走到下一帧。而下一帧的 Cursor 已经落在当前 cursor 之后:循环头的两个守卫都拦不住它——:1795 只拦 frame.NextCursor <= cursor(整帧已交付)与 remaining <= 0,:1799 只在 cursor > copyFrame.Cursor(游标落在帧内部)时做 cut。起点在游标之后的帧因此被整帧从头交付,并把 page.NextCursor 推到它的末尾(:1826):中间那段字节从没返回给调用方,却已经被恢复游标声明为"已交付"。

实测(ring 级两条 append:第一帧 😀😀😀😀😀\n 21 字节,第二帧 x\n 2 字节,6 字节页预算),修复前:

--- FAIL: TestCommandRuntimeRingPageStaysContiguous
    page 0 from cursor 0 to 23 delivered "😀x\n", which is not the retained range "😀😀😀😀😀\nx\n"
--- FAIL: TestCommandRuntimeManagerPagedWaitKeepsMultiByteOutput
    page 0 from 0 to 23 delivered "😀x\n" instead of the retained range "😀😀😀😀😀\nx\n"

一页声明覆盖 [0,23) 却只返回 6 字节,17 字节被跳过;Wait 这一层(公开 API)同样复现,说明丢失不在测试夹具里,而在分页契约上。只有多字节输出会触发:纯 ASCII 下回退循环一步都不走,remaining 归 0,下一轮直接被 remaining <= 0 拦住,行为与现在完全一致。命令输出里 CJK 文本与框线进度条是常态,所以这条不需要畸形输入。

NextCursor 就是那个持久恢复游标,丢掉的字节不会补回来:

  • internal/application/command_runtime_operator.go:207-213 用 cursor = page.NextCursor 排空输出,它的健全性检查只拒绝"游标倒退"和"越过 EndCursor",对这种跳号是放行的,随后还会把这一帧之前的内容当成已读完。
  • internal/application/thread_application_services.go:269 把 page.NextCursor 原样投影成线程活动的 HTTP 游标,调用方按它续读。
  • 帧本身按 16 KiB 切(internal/runner/command_runtime_job.go:1737-1743),所以一次跳号最多吞掉接近一整帧的保留输出;页预算由调用方给(internal/toolgateway/command_runtime.go:83-85 限定在 [MinCommandRuntimeOutputRead, MaxCommandRuntimePageBytes],而 MinCommandRuntimeOutputRead = utf8.UTFMax,即 4 字节也合法),内联窗口最大 512 KiB(internal/runner/command_runtime.go:39)——小 max_bytes 轮询是正常用法,不是极端参数。

修法取最窄的条件:只有当预算真的把一帧截断(发生了回退式截断)时,就在交付这一帧后结束本页,未完成的那一帧留给下一页;下一页进来时游标落在该帧内部,走既有的 cut 分支(:1799-1810)继续。copyFrame.Text == "" 时原有的 break(:1820)保持不变,所以"预算装不下一个字符"的既有行为不受影响。我没有改成"下一帧起点不等于游标就 break"那种写法:那会把 cut 对齐推进(:1805-1809)也变成整页空返回,而现有那条空文本 break 的语义我不想一起改动。

验证:

  • go test -count=1 -timeout 380s -run "TestCommandRuntimeRingPageStaysContiguous|TestCommandRuntimeManagerPagedWaitKeepsMultiByteOutput" ./internal/runner/:修复前两条 FAIL(上面摘录),修复后 ok … 0.775s。红是用 git checkout -- 临时还原 command_runtime_job.go 得到的,跑完立刻恢复,没有留下临时文件。
  • 两条用例除了"页内容必须等于它声明的字节区间",还断言每页不超过字节预算、游标必须前进、以及把所有页拼接起来能完整恢复保留输出并一路读到 EndCursor(ring 级对照 ring.next,Wait 级对照 EndCursor)——即修复既不能少读,也不能把截断变成停滞。
  • go test -count=1 ./internal/runner/ ./internal/toolrun/ 全包通过:ok runner 9.007s、ok toolrun 0.278s。
  • go test -count=1 -run "CommandRuntime" ./internal/application/ ./internal/httpapi/ ./internal/store/ 通过:ok application 198.911s、ok httpapi 0.936s、ok store 10.738s(这三包按 -run 取与本改动相关的用例,因为全包在本机单包会撞默认超时)。
  • go vet ./internal/runner/ 通过;gofmt -l internal/runner/ 无输出;git diff --check 干净。我没有跑 go test ./... 全量:internal/application、internal/store 的单包全量在本机会撞 10 分钟默认超时(CI 拆 shard),全量以本 PR 的 CI 为准。
  • OCR delegate 复核(开 PR 前执行):ocr delegate preview --from 274fefb6 --to HEAD → 1 个可复核文件 / 共 2 个改动文件(+133/-0),command_runtime_output_page_continuity_test.go 被 default_path 规则排除,已人工复核(只用 ring 与公开 Wait,无跳过、无放宽既有期望,夹具是合成 emoji/ASCII)。按 **/*.go 规则组逐条核对:Collections/Slices/Numeric——新增只有一个 bool 与一处 break,切片下标仍由 remaining/len(data) 限界,utf8.Valid 回退逻辑未动;Errors/Contracts——未新增错误路径,CommandRuntimeOutputPage 字段语义不变,只是不再声明未交付的区间;Context/并发——read 在 ring 自己的互斥区内(未改锁范围),无新增 goroutine 或共享状态;资源生命周期/定时器——不涉及。无发现。

无 Surface 变化:不改 DTO 形状、HTTP 参数或协议版本,docs/openapi.json 与 protocols/registry.json 无需再生成;不触碰权限、Scope、沙箱、预算或执行判定;测试夹具为合成文本,不含凭据与本地运行数据。

commandRuntimeRing.read backed a page off inside a multi-byte rune to keep the
returned text valid UTF-8, then kept looping. The next frame starts past that
residue, and neither loop guard rejects a frame whose start is ahead of the
resume cursor, so the page delivered it in full and published a NextCursor over
bytes it never returned. A 6-byte page over two frames claimed [0,23) while
delivering 6 bytes, dropping 17.

NextCursor is the durable resume cursor for the operator drain, the thread
activity reader and receipt-based resumes, so the skipped bytes were reported as
already delivered. End the page where the bound trimmed a frame and let the next
page continue through the normal in-frame cut path.
@Qiyuanqiii
Qiyuanqiii force-pushed the Qiyuanqiii/runtime-page-cursor branch from 4b75e52 to fd374db Compare October 27, 2026 17:51
@Qiyuanqiii
Qiyuanqiii merged commit eab148c into main Oct 27, 2026
19 checks passed
@Qiyuanqiii
Qiyuanqiii deleted the Qiyuanqiii/runtime-page-cursor branch October 27, 2026 17:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants