Skip to content

fix: drain pending TTY input on shutdown to prevent DECRPM garbage#1692

Open
ChrisJr404 wants to merge 1 commit into
charmbracelet:mainfrom
ChrisJr404:fix/issue-1590-drain-input-on-shutdown
Open

fix: drain pending TTY input on shutdown to prevent DECRPM garbage#1692
ChrisJr404 wants to merge 1 commit into
charmbracelet:mainfrom
ChrisJr404:fix/issue-1590-drain-input-on-shutdown

Conversation

@ChrisJr404
Copy link
Copy Markdown

Fixes #1590.

Background

During init, bubbletea queries the terminal for synchronized output (mode 2026) and unicode core (mode 2027) support:

p.execute(ansi.RequestModeSynchronizedOutput +
    ansi.RequestModeUnicodeCore)

The terminal replies asynchronously with DECRPM reports. The input reader normally consumes them, but in very short-lived programs, the replies can land after the reader has been cancelled and before the TTY is restored. The bytes then sit in the kernel TTY input queue and get read by the user's shell after the process exits, showing up as:

^[[?2026;2$y^[[?2027;1$y

Several people have hit this — it surfaces with the huh spinner once the action is fast, and with the package-manager example at low random delays. It's also reproducible on Ghostty / Windows Terminal / Wezterm + zsh/nushell with the small repro in the issue.

Fix

Add a per-platform drainInput() and call it from restoreTerminalState right before we hand the TTY back to the shell:

platform call
linux / solaris / aix ioctl(fd, TCFLSH, TCIFLUSH)
darwin / dragonfly / freebsd / netbsd / openbsd ioctl(fd, TIOCFLUSH, &FREAD)
windows FlushConsoleInputBuffer
anything else no-op

On unix the call sits in a flush-then-poll loop with a 200 ms budget. Local terminals reply within microseconds and the loop exits on the first iteration; the budget exists so SSH round-trips (which often deliver replies in bursts after the first flush) don't slip through. The output flush in restoreTerminalState is preserved so renderer reset sequences still make it out.

Verification

  • go test -race ./... passes locally on linux/amd64.
  • go vet ./... clean.
  • golangci-lint run clean for linux, darwin, freebsd, openbsd, netbsd, dragonfly, windows.
  • Cross-compile (CGO_ENABLED=0) succeeds on linux (amd64/arm64/arm/mips/mips64/ppc64/riscv64/s390x), darwin (amd64/arm64), freebsd, openbsd, netbsd, dragonfly, solaris, windows. AIX fails on an unrelated ultraviolet symbol (termios.GetWinsize), not on this change.
  • Repro behavior:
    • I ran the issue's repro under a PTY harness that delays the DECRPM replies until the program is mid-shutdown. With debug instrumentation in drainInput I observed the second iteration finding 22 bytes pending (the two DECRPM replies) and discarding them, then the poll timing out so the loop exits cleanly. Without the fix those 22 bytes remain in the slave's input queue.

Notes

  • The restoreTerminalState path is also reached via releaseTerminal (used by Suspend/Kill/ReleaseTerminal), so the drain runs there too — same race, same fix.
  • I deliberately kept the loop bounded by Poll returning <= 0 rather than a fixed iteration count: if a misbehaving terminal somehow streams data forever the worst case is one extra 200 ms wait per iteration, which is still bounded by the read traffic actually being there.

When bubbletea queries terminal capabilities (DECRQM for modes
2026/2027) during initialization, the terminal answers asynchronously.
For very short-lived programs, those replies can arrive after the input
reader has been cancelled but before the TTY has been restored. The
bytes then sit in the kernel TTY input queue and the user's shell reads
them once the program exits, producing visible garbage like:

  ^[[?2026;2$y^[[?2027;1$y

Reported in charmbracelet#1590, also seen with the spinner in charmbracelet/huh and
in package-manager-style examples whenever the run is fast enough.

Add platform-specific drainInput() called from restoreTerminalState()
right before restoring the original TTY state:

  - linux/solaris/aix: ioctl TCFLSH with TCIFLUSH (0)
  - darwin/*bsd:       ioctl TIOCFLUSH with FREAD (1)
  - windows:           FlushConsoleInputBuffer
  - other:             no-op fallback

On unix the call is wrapped in a flush-then-poll loop with a 200ms
budget so responses arriving in bursts (or over an SSH round trip) are
caught instead of slipping through.
@miekg
Copy link
Copy Markdown

miekg commented May 12, 2026

I've tried this fix and it works. LGTM

Comment thread drain_bsd.go
@@ -0,0 +1,31 @@
//go:build darwin || dragonfly || freebsd || netbsd || openbsd
// +build darwin dragonfly freebsd netbsd openbsd
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these // +build lines have long been deprecated

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.

If tea program quits too early, garbage chars are printed

2 participants