Fix TUI agent-pane live-stream TOCTOU race (BUG-075) - #916
Merged
Conversation
renderLive's incremental-feed path read totalWritten via a separate, earlier sess.TotalWritten() call, then later read raw via a separate sess.RecentOutput() call. readLoop is a live, independent goroutine, so for an actively streaming session bytes can land in the gap between the two calls, making raw longer than the earlier totalWritten accounts for. Slicing raw[len(raw)-newBytes:] against that stale newBytes then feeds the wrong suffix: it silently skips the true next bytes (dropped characters) and instead feeds bytes from further ahead, understating emuFedTotal — the next frame then re-feeds that same already-fed tail (a duplicated recent phrase). This is distinct from BUG-068/BUG-073/BUG-074, all of which fire only around a bind/resize/rebuild event — this race is reachable on a pane that's actively being watched with no such event involved at all, which made it easy to mistake for those bind-time reconstruction defects. agent.Session.RecentOutputTailWithTotal's own doc comment already named this exact hazard (for the /output HTTP endpoint's cursor); renderLive's live-feed path just hadn't been updated to use it. Fix: fetch (raw, totalWritten) together via a single RecentOutputTailWithTotal call and recompute newBytes from that same snapshot, skipped only when emuMissing (that branch is unconditionally a full replay regardless of raw/newBytes, and calls readLiveRebuildHistory, which does its own independent ring read). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
anutron
force-pushed
the
argus/pty-garble-investigate
branch
from
July 30, 2026 18:57
4dcae6e to
d7b177f
Compare
Merging this branch will not change overall coverage
Coverage by fileChanged files (no unit tests)
Please note that the "Total", "Covered", and "Missed" counts above refer to code statements instead of lines of code. The value in brackets refers to the test coverage of that file in the old version of the code. Changed unit test files
|
6 tasks
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
Follow-up in the same investigation as #912 (BUG-073) and #913 (BUG-074). After both shipped, garbling frequency dropped from constant to rare — but one report didn't fit the "known deferred row-margin gap" explanation I initially reached for: a duplicated recent sentence + a few dropped characters + an old stale label, appearing in a coordinator pane that was actively being watched and streaming, with no pane switch, no resize, and no bind event involved at all. The coordinator pushed back on my first (too-quick) explanation and asked me to actually re-investigate rather than stretch the deferred-gap story to cover it. That re-investigation found a genuinely distinct, previously-unidentified bug.
Root cause
TerminalPane.renderLive's incremental-feed path readtotalWrittenviasess.TotalWritten()at the top of the function, then later readrawvia a separatesess.RecentOutput()call, several lines down.readLoopis a live, independent goroutine — for an actively streaming session, new bytes can land in the ring in the gap between those two unsynchronized calls, soraw(sampled later) can be longer than the earliertotalWrittenaccounts for.The feed line,
raw[len(raw)-int(newBytes):], slices against that stalenewBytes— which now misaligns with whatrawactually contains. The result: it silently skips the true next bytes (a few dropped characters — e.g. "Independent" renders as "Indepe dent") and instead feeds bytes from further ahead, whileemuFedTotalgets recorded short of what was actually fed. The very next frame then re-feeds that same already-fed tail — a duplicated recent phrase. Both artifacts land together, which is exactly the reported signature, and required no bind/resize event at all — a plain TOCTOU race in the steady-state live-feed path, not a reconstruction-from-history problem like BUG-068/073/074.Notably,
agent.Session.RecentOutputTailWithTotal's own doc comment already named this exact hazard — it was built for the/outputHTTP endpoint's cursor for precisely this reason.renderLive's live-feed path just hadn't been updated to use it.Fix
Fetch
(raw, totalWritten)together via onesess.RecentOutputTailWithTotal(256*1024)call and recomputenewBytesfrom that same snapshot — but only when!emuMissing. TheemuMissing(fresh-attach) branch is unconditionally a full replay regardless ofraw/newBytes(short-circuited by the||) and callsreadLiveRebuildHistory, which does its own independent ring read — fetching here too would just be a wasted extra 256KB copy on every fresh attach.Test plan
TestRenderLive_LiveFeedUsesAtomicSnapshotNotStaleTotal— newraceAdaptermock whoseTotalWritten()always reports an earlier snapshot thanRecentOutputTailWithTotal(), simulatingreadLoopadvancing in between. Verified it fails on the pre-fix code with exactly the predicted garble ("HELLOLD "instead of"HELLOWORLD"— the wrong 2-byte tail fed, "WOR" dropped) before restoring the fix.countingAdapter/TestTerminalPane_RenderLiveSkipsCopyWhenIdle/TestTerminalPane_PaintCacheReplayto instrumentRecentOutputTailWithTotal(the method the live-feed path now actually calls) instead ofRecentOutput— confirms the "skip the expensive copy when nothing changed" invariant still holds, and that the fix doesn't introduce a redundant copy on fresh attach.go test ./internal/tui/... -race -count=1green.make pre-pr— build/vet/fmt-check/lint-pr/test-cover-gate clean.vulnfails only on pre-existing stdlib CVEs (CI continue-on-error). 2 pre-existinginternal/agentprofile-env tests fail only due to this hera-worker sandbox's ownARGUS_*env leaking into the test subprocess — unrelated to this diff.context/knowledge/gotchas/pty-terminal.mdupdated (BUG-075).🤖 Generated with Claude Code
Co-authored-by: Claude noreply@anthropic.com