Skip to content

Fix TUI agent-pane live-stream TOCTOU race (BUG-075) - #916

Merged
anutron merged 1 commit into
masterfrom
argus/pty-garble-investigate
Jul 30, 2026
Merged

Fix TUI agent-pane live-stream TOCTOU race (BUG-075)#916
anutron merged 1 commit into
masterfrom
argus/pty-garble-investigate

Conversation

@anutron

@anutron anutron commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

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 read totalWritten via sess.TotalWritten() at the top of the function, then later read raw via a separate sess.RecentOutput() call, several lines down. readLoop is a live, independent goroutine — for an actively streaming session, new bytes can land in the ring in the gap between those two unsynchronized calls, so raw (sampled later) can be longer than the earlier totalWritten accounts for.

The feed line, raw[len(raw)-int(newBytes):], slices against that stale newBytes — which now misaligns with what raw actually 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, while emuFedTotal gets 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 /output HTTP 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 one sess.RecentOutputTailWithTotal(256*1024) call and recompute newBytes from that same snapshot — but only when !emuMissing. The emuMissing (fresh-attach) branch is unconditionally a full replay regardless of raw/newBytes (short-circuited by the ||) and calls readLiveRebuildHistory, 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 — new raceAdapter mock whose TotalWritten() always reports an earlier snapshot than RecentOutputTailWithTotal(), simulating readLoop advancing 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.
  • Updated countingAdapter/TestTerminalPane_RenderLiveSkipsCopyWhenIdle/TestTerminalPane_PaintCacheReplay to instrument RecentOutputTailWithTotal (the method the live-feed path now actually calls) instead of RecentOutput — 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=1 green.
  • make pre-pr — build/vet/fmt-check/lint-pr/test-cover-gate clean. vuln fails only on pre-existing stdlib CVEs (CI continue-on-error). 2 pre-existing internal/agent profile-env tests fail only due to this hera-worker sandbox's own ARGUS_* env leaking into the test subprocess — unrelated to this diff.
  • context/knowledge/gotchas/pty-terminal.md updated (BUG-075).

🤖 Generated with Claude Code

Co-authored-by: Claude noreply@anthropic.com

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
anutron force-pushed the argus/pty-garble-investigate branch from 4dcae6e to d7b177f Compare July 30, 2026 18:57
@github-actions

Copy link
Copy Markdown

Merging this branch will not change overall coverage

Impacted Packages Coverage Δ 🤖
github.com/drn/argus/internal/tui/terminal 95.88% (+0.00%) 👍

Coverage by file

Changed files (no unit tests)

Changed File Coverage Δ Total Covered Missed 🤖
github.com/drn/argus/internal/tui/terminal/terminalpane.go 96.05% (+0.00%) 811 (+1) 779 (+1) 32 👍

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

  • github.com/drn/argus/internal/tui/terminal/terminalpane_test.go

@anutron
anutron merged commit 0d19848 into master Jul 30, 2026
1 check passed
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