Skip to content

fix(render): preserve scrollback bytes across superseded commit-throttle#10

Merged
woai3c merged 1 commit into
mainfrom
fix/scrollback-dropped-on-throttle-supersede
May 7, 2026
Merged

fix(render): preserve scrollback bytes across superseded commit-throttle#10
woai3c merged 1 commit into
mainfrom
fix/scrollback-dropped-on-throttle-supersede

Conversation

@woai3c
Copy link
Copy Markdown
Owner

@woai3c woai3c commented May 7, 2026

Summary

修一个 ChatInput cell-grid 渲染的竞态:多行流式消息 + 紧跟一次 frame height 收缩会让消息的最后几行从可见 scrollback 里消失,尽管 state.messages 里这条消息是完整的。

复现场景

#9 合并后我手跑 /review(无参),terminal 看到的是这样:

> /review

  I need to check for open PRs first since no PR number was provided.

 ● PowerShell(gh pr list)
   ⎿  9 feat(cli): add /review slash command for PR review ...

  Here's the open PR:
  ────────────────────  ← 输入框边框直接画在了 PR #9 信息该在的位置
  >
  ────────────────────

模型实际输出的内容(debug.log 字符级证据):

Here's the open PR:

**PR #9** — `feat(cli): add /review slash command for PR review` — `feat/review-command`

Which PR would you like me to review?

turn.finish "reason=stop turn=2" —— 模型干净结束,bytes 在 buffer 里都对,只是没画到屏幕上。

根因

debug.log 里两个相邻 frame 是 smoking gun:

10:04:20.988  flush.commit-throttled  delay=1ms dt=49ms     ← N: 49ms < 50ms,setTimeout 推 1ms
10:04:20.989  flush.commit-throttle-superseded-by-height    ← N+1: height 5→4, clearTimeout 杀 N
              geom.shrink oldTop=17 newTop=17               ← 用陈旧的 frameTop 重画输入框

具体链:

  1. 渲染 N: hasNewMessages=true(msg5 = "PR feat(cli): add /review slash command for PR review #9" + "Which PR..." 两段)。writeMessageToStdout 把 bytes 收进本地变量 scrollbackContent,然后 writtenMessageCountRef 同步从 4 撞到 5。整 payload(BSU + scrollback + 几何 + frame diff + ESU)由于 dt=49ms < MIN_COMMIT_GAP_MS=50ms,被 setTimeout(doFlush, 1ms) 推迟。
  2. 渲染 N+1(turn 结束 spinner 消失,height 5→4): messages.length=5, writtenMessageCountRef.current=5hasNewMessages=false,不会重收 msg5。commitThrottleRef.current !== null && heightChanged → 走 commit-throttle-superseded-by-height 路径,clearTimeout 杀掉 N 的 throttle。
  3. 渲染 N+1 自己的 payload 只有"shrink the frame back"——没有 msg5 bytes。

整条 N 的 payload 永远没进 stdout。writtenMessageCountRef 已经撞到 5,以后所有渲染都觉得 msg5 已经"画过了",不会再重画。

修法

把"待写入 scrollback"从渲染本地变量改成跨 render 持久化的 ref(pendingScrollbackRef: useRef<string>(''))。writeMessageToStdout 仍按原方式写,只是写到 ref 里而不是局部 var。scrollbackContent = pendingScrollbackRef.current 仅作为本次 render 的快照供几何路径多次读。

doFlush只有 process.stdout.write(payload) 真的执行才清空 pendingScrollbackRef.current = ''——也就是说"撞 counter"是同步的、"消费 bytes"是延后的、两者解耦。

被取消的 throttle 走到下一轮渲染时:

  • pendingScrollbackRef.current 还是 "PR #9...\nWhich PR..."
  • scrollbackContent 快照非空 → didCommitMessages=true
  • 走 commit branch(line 3235): clearTimeout(commitThrottleRef.current)(取消老的、过期的)→ 几何路径重新算 startRow / preScroll / 把 scrollbackContent 塞进 preBuf line 2690
  • dt 现在 ≥ 50ms,直接 doFlush(),bytes + 新 frame 一次性原子写出去
  • pendingScrollbackRef.current = '',bytes 消费完毕

无 flicker(只有一次 stdout.write),无丢字。

/clear 路径也补一行 pendingScrollbackRef.current = '',因为清屏后那些 bytes 引用的 message 已经不存在(messages.length 归 0)。

Why this is safe

pendingScrollbackRef 的生命周期不会出现"被吃掉但又被清空"的窗口:

  • 任何会 mutate pendingScrollbackRef(via collectWrite)的渲染都会让 didCommitMessages=true,从而走 commit branch line 3227,强制 clearTimeout 老的 commitThrottle(line 3235),换上一个 fresh throttle 包含新 bytes。
  • 因此当某个 throttle 真的 fire 进 doFlush,它的 closure 里 scrollbackContent 局部变量等于此刻的 pendingScrollbackRef.current——清空 ref 不会丢掉"窗口期内被另写的新 bytes",因为根本没那个窗口。

Test plan

  • pnpm typecheck
  • pnpm test 356 passed / 8 skipped(无回归)
  • pnpm build
  • CLI 实测 /review(无参) → 无 PR 时 No open PRs in this repository... 完整渲染;有 PR 时 Here's the open PR: + 列表 + Which PR would you like me to review? 三段都在
  • CLI 实测任意流式回答收尾(spinner 消失触发 height 5→4 shrink)→ 末段不被吃掉
  • CLI 实测 /clear 后再问问题 → 不残留陈年 bytes

When two stdout writes would land within MIN_COMMIT_GAP_MS (50ms),
the second is throttled via setTimeout. If a height-changing render
arrives before that timer fires, the supersede path (`commit-throttle-
superseded-by-height`) calls clearTimeout — but the throttled render's
payload, which carried the new scrollback bytes, is then garbage
collected. `writtenMessageCountRef` had already been bumped synchronously,
so subsequent renders don't re-emit those bytes either. Net effect:
the message vanishes from the visible scrollback even though it lives
in `state.messages`.

Reproducing this required a multi-line commit followed by a frame
shrink (e.g. end-of-turn spinner removal). One observed case: streaming
"Here's the open PR:\n\n**PR #9**...\n\nWhich PR..." across two buffer
commits — the first commit drew "Here's the open PR:" then scheduled
a 1ms throttle for the rest. The shrink at finishReason=stop fired 1ms
later, supersede cancelled the throttle, and the final two paragraphs
were lost on screen.

Fix: hoist the per-render `scrollbackContent` accumulator into a
cross-render `pendingScrollbackRef`. doFlush clears it only after the
write actually lands. If the throttle is cancelled, the bytes survive
to the next render — `didCommitMessages` stays true (because
scrollbackContent is non-empty), the geometry path includes the bytes
in the new render's preBuf, and they reach stdout alongside the new
(smaller) frame in a single atomic write — no extra flicker, no lost
text.

Also clears the ref in the /clear path, since wiping scrollback makes
any pending bytes stale (their messages no longer exist).
@woai3c woai3c merged commit 5eb978d into main May 7, 2026
5 checks passed
@woai3c woai3c deleted the fix/scrollback-dropped-on-throttle-supersede branch May 7, 2026 10:43
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.

1 participant