Skip to content

fix(session): stop restored scrollback from leaking terminal state - #236

Merged
arzafran merged 2 commits into
mainfrom
fix/terminal-state-leak-on-restart
Aug 3, 2026
Merged

fix(session): stop restored scrollback from leaking terminal state#236
arzafran merged 2 commits into
mainfrom
fix/terminal-state-leak-on-restart

Conversation

@arzafran

@arzafran arzafran commented Aug 3, 2026

Copy link
Copy Markdown
Member

What this does

When Programa restarts, the terminal comes back wrong. Two ways people have seen it:

  • the prompt fills with junk like 35;16;54M35;33;53M...
  • the layout is broken, and resizing the window fixes it for some people and does nothing at all for others

Both are the same bug. On restart we replay saved scrollback into a brand new terminal. Those saved bytes are a raw tail of live output, so if a program was killed mid-run its "turn mouse reporting on" is in there but its "turn it back off" never happened. Replaying switches the mode on for good.

Mouse reporting is the one you can see. The rest wreck the layout, and that is where the split between people comes from: resizing the window clears the scrolling region but not the alternate screen, the wraparound flag or the charset. tmux leans on the scrolling region, so tmux users drag the window and it fixes itself. If your vim or htop died on the alternate screen, you can resize all day and nothing happens.

Two more problems found along the way:

  • the replay ran before the terminal knew how big it was, so it was laid out against a placeholder size instead of the real pane. Wrapped lines sort themselves out on the next resize. Anything drawn at an exact position does not.
  • the display scale was read once when the terminal was created. If an external monitor is still waking up at that moment we read the wrong screen, and you get the right number of pixels with the wrong idea of how big they are, so the grid is wrong. Resizing cannot fix that, because the size was never the problem. Nothing was listening for the displays changing, so it stayed wrong for the rest of the session.

Nothing resets if a program survived the restart and is still using the terminal. Turning off a live vim's alt screen would be just as broken as turning off its mouse.

Review order

  1. Sources/SessionPersistence.swift - the reset sequence. Worth reading the comments, the order of those bytes is the fix
  2. Sources/TerminalSurface.swift - size the terminal before replaying, and the check for whether a program is still using it
  3. Sources/GhosttyNSView.swift - watch for the displays changing
  4. programaTests/SessionPersistenceTests.swift - the tests

Two commits on purpose: the tests land first and go red, the fix turns them green.

Two things in here that nearly shipped as new bugs

Both found in review, both worth a second pair of eyes:

  • I first used 1049l to leave the alternate screen. Ghostty restores the cursor whenever 1049 is switched off, and restoring with nothing saved sends it to the top left. Most restarts never touch the alternate screen, so that would have dropped the prompt on top of your restored scrollback every single time. Uses 1047l now, which does nothing when there is no alternate screen to leave.
  • ESC[r moves the cursor too. It is wrapped in a save and restore now, with the charset and origin cleaned up first, because the restore puts those back the way they were saved.

Test plan

  • CI green on the second commit, red on the first
  • Restart with an update while sitting at a plain prompt, confirm no 35;16;54M junk
  • Open vim, quit it badly, restart, confirm the prompt is not stuck on the alt screen and lands below the restored output rather than on top of it
  • Restart with vim still open, confirm its mouse and alt screen still work
  • Someone on an external monitor restarts and confirms the layout is right without touching the window

Not verified

I have not reproduced any of this end to end. Tests run on CI, not locally, and the tests here only cover the fresh shell path because the other one needs a live terminal. The external monitor fix is reasoned from the source, not from a repro, so that last test plan item is the one that matters most.

…restart

Restart replays saved scrollback into a fresh terminal. Those bytes are a
raw tail of live pty output, so any DECSET in them is unbalanced: the
program that would have sent the matching DECRST was killed by the
restart. Replaying re-arms the mode permanently.

These three tests describe what the replay text has to do about that.
They fail on this commit and pass on the next one.

- mouse tracking left armed reports motion at the prompt
- alt screen, autowrap and charset left armed break the layout, and a
  window resize clears none of them
- the cursor-moving resets have to be bracketed so the prompt does not
  land on top of the restored output
Three things went wrong when the app restarted.

The replayed transcript re-armed whatever modes the killed program left
set. Mouse tracking was the visible one: the prompt filled with literal
motion reports. The rest broke the layout. A window resize clears the
scrolling region but not the active screen, the wraparound bit or the
charset, which is why resizing rescued some people and did nothing at
all for others.

The replay also ran before the surface was sized, so the transcript was
parsed against ghostty's 800x600 placeholder grid instead of the real
pane. Wrapped lines recover on a later resize. Absolute cursor
addressing in a captured TUI redraw does not.

Content scale was read once at surface creation. When an external
display enumerates late, that read lands on the wrong screen and the
grid is computed with the wrong DPI while the pixel size stays correct,
so resizing cannot fix it. Nothing watched for the display
configuration changing, so it never corrected itself.

Size the surface before replaying, disarm the modes the transcript
leaves behind, and re-assert display and scale when the screen
configuration changes.

Two notes on the reset sequence, both load-bearing:

1047l is used instead of 1049l. Ghostty restores the cursor
unconditionally when 1049 is disabled, and restoring with nothing saved
homes it to 0,0. Most restarts never touch the alternate screen, so
1049l would have put the prompt on top of the restored output every
time. 1047's switch is skipped when the screen does not change.

ESC[r homes the cursor, so it is bracketed in DECSC/DECRC. The charset
and origin resets come before the save, because DECRC restores both
from it.

None of it runs when a program survived the restart and still owns the
terminal. Resetting a live vim's alt screen would be as wrong as
killing its mouse.
@arzafran
arzafran merged commit 4d5ca0f into main Aug 3, 2026
7 of 8 checks passed
@arzafran
arzafran deleted the fix/terminal-state-leak-on-restart branch August 3, 2026 13: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