Skip to content

fix(session): stop restored scrollback from garbling in split-pane workspaces - #242

Merged
arzafran merged 3 commits into
mainfrom
fix/restore-replay-divider-race
Aug 4, 2026
Merged

fix(session): stop restored scrollback from garbling in split-pane workspaces#242
arzafran merged 3 commits into
mainfrom
fix/restore-replay-divider-race

Conversation

@arzafran

@arzafran arzafran commented Aug 4, 2026

Copy link
Copy Markdown
Member

What this does

Fixes the scattered garbled spinner output after session restore (the repeated corrupted 'thinking with high effort' screenshots from the team thread). Two remaining holes after #240, both worse or exclusive to split-pane workspaces:

  1. Divider race: restore creates every pane at bonsplit's default 50/50 fractions and applies persisted divider positions only afterwards, but the one-shot scrollback seed fired on the first layout pass, i.e. at the wrong width for any non-50/50 split. Spinner output rewrites itself in place with carriage returns; replayed at a narrower width every frame wraps, the overwrite misses its row, and every frame stays visible. Single-pane workspaces never hit this, which matches exactly who does and does not see the bug. Each restore pass now opens a generation-tagged gate that holds revive seeds until divider positions are applied plus one main-queue turn. The gate blocks seed consumption only, never resizing, so surfaces are at final width when the seed fires. A bounded (6s) re-arming fallback timer remains as a force-through safety net, and each pass clears exactly its own generation so back-to-back workspace restores at relaunch cannot strand each other.

  2. Torn escapes on the fallback path: the no-frame fallback (session younger than the capture interval, or walGeneration mismatch after rotation) capped the WAL tail with a raw suffix cut and decoded it without trimming, leaking orphaned CSI/UTF-8 fragments as literal text (the 't*inking' / 'eff5rt' corruption flavor). The cut is now trimmed like readFrameAndDelta already does, and only when a cut actually occurred, so genuine leading text that merely looks like a torn escape survives.

Review order

  1. Commit 1: test + extraction of the fallback decode into SessionWALCore.decodeFallbackScrollbackText (behavior unchanged, CI red proves the test bites)
  2. Commit 2: the conditional trim (CI green)
  3. Commit 3: the divider-settle gate (TerminalSurface.swift + Workspace+Persistence.swift)

Test plan

  • CI: commit 1 red on the new test, commits 2-3 green
  • SessionWALCoreTests 7/7 locally
  • Manual: restore a workspace with a non-50/50 split running a spinner-heavy agent; scrollback must come back clean

Gate itself has no unit test: driving createSurface's revive path plus updateSize needs a live ghostty surface and there is no seam for it today.

Extracts the plain-tail fallback's concat+cap+decode step out of the
singleton store into SessionWALCore.decodeFallbackScrollbackText (pure,
unit-testable; behavior unchanged) and adds the regression test: a
suffix-cap cut landing mid-CSI or mid-UTF-8 must not leak orphaned
escape/continuation bytes as literal replayed text, and uncut data must
survive untouched even when its genuine leading bytes look like a torn
CSI tail.

readFrameAndDelta already guards its cut sites this way; the fallback
branch (no usable frame: session younger than the capture interval, or
walGeneration mismatch after rotation) was missing the guard. This
commit is test-only by design — the trim lands next, so CI shows the
test failing without it.

Symptom: corrupted characters in restored scrollback ("t*inking",
"eff5rt") reported by the team.
Apply trimTornLeadingBytes when — and only when — the fallback path's
suffix cap actually cut the byte stream. Turns the previous commit's
regression test green.

Unconditional trimming would be wrong the other way: at a genuine stream
beginning the torn-CSI heuristic cannot distinguish legitimate leading
text like "196mVISIBLE" from an orphaned parameter run, so uncut data
skips the trim entirely (covered by the same test).
Multi-pane restore raced the one-shot scrollback seed against divider
restoration: restoreSessionSnapshot creates every pane at bonsplit's
default 50/50 fractions and applies persisted divider positions only
afterwards, while the revive seed fired on the FIRST post-creation
updateSize (or a 0.15s timer) — i.e. at the wrong width for any
non-50/50 split. Replaying \r-overwritten spinner output at a
narrower-than-capture width wraps every frame, the in-place overwrite
misses its row, and every frame stays visible — the scattered repeated
"thinking with high effort" garbage the team screenshotted. Single-pane
workspaces never hit it, which matches who reported it.

Each restore pass now opens a generation-tagged settle gate before
creating panels: surfaces revived under the gate hold their seed until
the pass has applied divider positions plus one main-queue turn, then
seed immediately (the surface is already at final width by then — the
gate blocks seed consumption, never resizing). The 0.15s fallback timer
re-arms while gated, bounded at 6s as a force-through safety net.
Settling is a pass counter and each pass clears exactly its own
generation, so back-to-back workspace restores at app relaunch cannot
strand an earlier pass's seeds until the ceiling.

No unit test: driving createSurface's revive path plus updateSize
requires a live ghostty surface; there is no seam for it today. The
fallback-trim half of this branch carries the regression test.
@arzafran
arzafran merged commit b6a69b5 into main Aug 4, 2026
10 checks passed
@arzafran
arzafran deleted the fix/restore-replay-divider-race branch August 4, 2026 14:26
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