fix(render): preserve scrollback bytes across superseded commit-throttle#10
Merged
Merged
Conversation
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
修一个 ChatInput cell-grid 渲染的竞态:多行流式消息 + 紧跟一次 frame height 收缩会让消息的最后几行从可见 scrollback 里消失,尽管
state.messages里这条消息是完整的。复现场景
#9 合并后我手跑
/review(无参),terminal 看到的是这样:模型实际输出的内容(debug.log 字符级证据):
turn.finish "reason=stop turn=2"—— 模型干净结束,bytes 在 buffer 里都对,只是没画到屏幕上。根因
debug.log 里两个相邻 frame 是 smoking gun:
具体链:
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)推迟。messages.length=5, writtenMessageCountRef.current=5→hasNewMessages=false,不会重收 msg5。commitThrottleRef.current !== null && heightChanged→ 走commit-throttle-superseded-by-height路径,clearTimeout杀掉 N 的 throttle。整条 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=trueclearTimeout(commitThrottleRef.current)(取消老的、过期的)→ 几何路径重新算 startRow / preScroll / 把 scrollbackContent 塞进 preBuf line 2690dt现在 ≥ 50ms,直接doFlush(),bytes + 新 frame 一次性原子写出去pendingScrollbackRef.current = '',bytes 消费完毕无 flicker(只有一次 stdout.write),无丢字。
/clear路径也补一行pendingScrollbackRef.current = '',因为清屏后那些 bytes 引用的 message 已经不存在(messages.length归 0)。Why this is safe
pendingScrollbackRef的生命周期不会出现"被吃掉但又被清空"的窗口:pendingScrollbackRef(viacollectWrite)的渲染都会让didCommitMessages=true,从而走 commit branch line 3227,强制clearTimeout老的 commitThrottle(line 3235),换上一个 fresh throttle 包含新 bytes。scrollbackContent局部变量等于此刻的pendingScrollbackRef.current——清空 ref 不会丢掉"窗口期内被另写的新 bytes",因为根本没那个窗口。Test plan
pnpm typecheck✓pnpm test356 passed / 8 skipped(无回归)pnpm build✓/review(无参) → 无 PR 时No open PRs in this repository...完整渲染;有 PR 时Here's the open PR:+ 列表 +Which PR would you like me to review?三段都在/clear后再问问题 → 不残留陈年 bytes