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() }