From 0735b803a74a65ec7f6899518d723119121076b1 Mon Sep 17 00:00:00 2001 From: ChrisJr404 Date: Wed, 6 May 2026 11:58:40 -0400 Subject: [PATCH] fix: drain pending TTY input on shutdown to prevent DECRPM garbage 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 #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. --- drain_bsd.go | 31 +++++++++++++++++++++++++++++++ drain_other.go | 8 ++++++++ drain_posix.go | 9 +++++++++ drain_unix.go | 30 ++++++++++++++++++++++++++++++ drain_windows.go | 17 +++++++++++++++++ tty.go | 8 ++++++++ 6 files changed, 103 insertions(+) create mode 100644 drain_bsd.go create mode 100644 drain_other.go create mode 100644 drain_posix.go create mode 100644 drain_unix.go create mode 100644 drain_windows.go diff --git a/drain_bsd.go b/drain_bsd.go new file mode 100644 index 0000000000..f4bc1d2e4f --- /dev/null +++ b/drain_bsd.go @@ -0,0 +1,31 @@ +//go:build darwin || dragonfly || freebsd || netbsd || openbsd +// +build darwin dragonfly freebsd netbsd openbsd + +package tea + +import "golang.org/x/sys/unix" + +// drainInput discards any pending input on the TTY. It is called during +// shutdown to remove unsolicited terminal responses (e.g. DECRPM replies to +// mode 2026/2027 queries) that arrived after the input reader was cancelled. +// Without this, those bytes are read by the user's shell after exit and +// printed as garbage characters. +func (p *Program) drainInput() { + if p.ttyInput == nil { + return + } + fd := int(p.ttyInput.Fd()) + fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} //nolint:gosec // tty fd never overflows int32 + + // Responses can arrive in bursts, so flush, then poll, then flush + // again until nothing more arrives within the timeout window. + // FREAD (1) tells TIOCFLUSH to discard the read queue only. + for { + _ = unix.IoctlSetPointerInt(fd, unix.TIOCFLUSH, 1) + + n, err := unix.Poll(fds, drainTimeoutMs) + if err != nil || n <= 0 { + return + } + } +} diff --git a/drain_other.go b/drain_other.go new file mode 100644 index 0000000000..1f29006d21 --- /dev/null +++ b/drain_other.go @@ -0,0 +1,8 @@ +//go:build !windows && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !aix +// +build !windows,!darwin,!dragonfly,!freebsd,!linux,!netbsd,!openbsd,!solaris,!aix + +package tea + +// drainInput is a no-op on platforms where we don't have a portable way to +// discard pending TTY input. +func (p *Program) drainInput() {} diff --git a/drain_posix.go b/drain_posix.go new file mode 100644 index 0000000000..3d3549295e --- /dev/null +++ b/drain_posix.go @@ -0,0 +1,9 @@ +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || aix +// +build darwin dragonfly freebsd linux netbsd openbsd solaris aix + +package tea + +// drainTimeoutMs is how long we wait for additional bytes to arrive after +// flushing the input buffer. Local terminals reply within microseconds; this +// budget exists so SSH round-trips don't slip through. +const drainTimeoutMs = 200 diff --git a/drain_unix.go b/drain_unix.go new file mode 100644 index 0000000000..829fc11be2 --- /dev/null +++ b/drain_unix.go @@ -0,0 +1,30 @@ +//go:build linux || solaris || aix +// +build linux solaris aix + +package tea + +import "golang.org/x/sys/unix" + +// drainInput discards any pending input on the TTY. It is called during +// shutdown to remove unsolicited terminal responses (e.g. DECRPM replies to +// mode 2026/2027 queries) that arrived after the input reader was cancelled. +// Without this, those bytes are read by the user's shell after exit and +// printed as garbage characters. +func (p *Program) drainInput() { + if p.ttyInput == nil { + return + } + fd := int(p.ttyInput.Fd()) + fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} //nolint:gosec // tty fd never overflows int32 + + // Responses can arrive in bursts, so flush, then poll, then flush + // again until nothing more arrives within the timeout window. + for { + _ = unix.IoctlSetInt(fd, unix.TCFLSH, 0) // TCIFLUSH: discard input + + n, err := unix.Poll(fds, drainTimeoutMs) + if err != nil || n <= 0 { + return + } + } +} diff --git a/drain_windows.go b/drain_windows.go new file mode 100644 index 0000000000..523c9de4c2 --- /dev/null +++ b/drain_windows.go @@ -0,0 +1,17 @@ +//go:build windows +// +build windows + +package tea + +import "golang.org/x/sys/windows" + +// drainInput discards any pending console input events to remove unsolicited +// terminal responses that arrived after the input reader was cancelled. +// Without this, those bytes can be read by the user's shell after exit and +// printed as garbage characters. +func (p *Program) drainInput() { + if p.ttyInput == nil { + return + } + _ = windows.FlushConsoleInputBuffer(windows.Handle(p.ttyInput.Fd())) +} diff --git a/tty.go b/tty.go index 12b86493b3..791dc959f7 100644 --- a/tty.go +++ b/tty.go @@ -34,6 +34,14 @@ func (p *Program) restoreTerminalState() error { // Flush queued commands. _ = p.flush() + // Drain any pending terminal responses from the TTY input buffer before + // restoring it. The program may have sent capability queries (e.g. + // DECRQM for modes 2026/2027) whose responses arrive asynchronously and + // were not consumed by the cancelled input reader. Without this, those + // bytes are read by the user's shell after exit and printed as garbage + // characters. See issue #1590. + p.drainInput() + return p.restoreInput() }