From 5e158f90a7ca2c9fc79e3cf6ec3ce44aa6aa6d81 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 10 Apr 2026 15:14:38 +0530 Subject: [PATCH] Fix some responses from terminal sometimes leaking into shell on after kitten exit Always do a roundtrip at kitten exit, except for special purpose kittens. This slows down exit by one round trip time (capped at 2 seconds), however it ensures that we never get terminal response leak. Fixes #9839 --- docs/changelog.rst | 2 + kittens/clipboard/legacy.go | 1 + kittens/clipboard/read.go | 3 +- kittens/clipboard/write.go | 3 +- kittens/icat/detect.go | 3 +- kittens/notify/main.go | 3 +- kittens/query_terminal/main.go | 3 +- kitty_tests/__init__.py | 9 ++- tools/cmd/at/tty_io.go | 7 +- tools/tui/loop/api.go | 17 +++-- tools/tui/loop/read.go | 36 +++++---- tools/tui/loop/run.go | 133 ++++++++++++++++----------------- 12 files changed, 121 insertions(+), 99 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e128ba6cf..e2c896318 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -237,6 +237,8 @@ Detailed list of changes - A new option :opt:`macos_fullscreen_ignore_safe_area_insets` to control whether to ignore the notch space when using :opt:`macos_traditional_fullscreen` (:pull:`9841`) +- Fix some responses from terminal sometimes leaking into shell on after kitten exit (:iss:`9839`) + 0.46.2 [2026-03-21] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/kittens/clipboard/legacy.go b/kittens/clipboard/legacy.go index 285a01163..87f9a2040 100644 --- a/kittens/clipboard/legacy.go +++ b/kittens/clipboard/legacy.go @@ -114,6 +114,7 @@ func run_plain_text_loop(opts *Options) (err error) { if err != nil { return } + lp.NoRoundtripToTerminalOnExit = true dest := "c" if opts.UsePrimary { dest = "p" diff --git a/kittens/clipboard/read.go b/kittens/clipboard/read.go index c5f7dbb41..e4911ba2d 100644 --- a/kittens/clipboard/read.go +++ b/kittens/clipboard/read.go @@ -286,10 +286,11 @@ func parse_aliases(raw []string) (map[string][]string, error) { } func run_get_loop(opts *Options, args []string) (err error) { - lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking, loop.NoInBandResizeNotifications) + lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking, loop.NoInBandResizeNotifications, loop.NoFocusTracking) if err != nil { return err } + lp.NoRoundtripToTerminalOnExit = true var available_mimes []string var wg sync.WaitGroup var getting_data_for string diff --git a/kittens/clipboard/write.go b/kittens/clipboard/write.go index 7714ca6aa..3a49baab1 100644 --- a/kittens/clipboard/write.go +++ b/kittens/clipboard/write.go @@ -43,10 +43,11 @@ func (self *Input) has_mime_matching(predicate func(string) bool) bool { } func write_loop(inputs []*Input, opts *Options) (err error) { - lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking, loop.NoInBandResizeNotifications) + lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking, loop.NoInBandResizeNotifications, loop.NoFocusTracking) if err != nil { return err } + lp.NoRoundtripToTerminalOnExit = true var waiting_for_write loop.IdType var buf [4096]byte aliases, aerr := parse_aliases(opts.Alias) diff --git a/kittens/icat/detect.go b/kittens/icat/detect.go index 9d19d5cda..b514f822e 100644 --- a/kittens/icat/detect.go +++ b/kittens/icat/detect.go @@ -21,11 +21,12 @@ func DetectSupport(timeout time.Duration) (memory, files, direct bool, err error temp_files_to_delete := make([]string, 0, 8) shm_files_to_delete := make([]shm.MMap, 0, 8) var direct_query_id, file_query_id, memory_query_id uint32 - lp, e := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking, loop.NoInBandResizeNotifications) + lp, e := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking, loop.NoInBandResizeNotifications, loop.NoFocusTracking) if e != nil { err = e return } + lp.NoRoundtripToTerminalOnExit = true print_error := func(format string, args ...any) { lp.Println(fmt.Sprintf(format, args...)) } diff --git a/kittens/notify/main.go b/kittens/notify/main.go index 594f09ea8..86723392a 100644 --- a/kittens/notify/main.go +++ b/kittens/notify/main.go @@ -112,10 +112,11 @@ func (p *parsed_data) generate_chunks(callback func(string)) { } func (p *parsed_data) run_loop() (err error) { - lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking, loop.NoInBandResizeNotifications) + lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking, loop.NoInBandResizeNotifications, loop.NoFocusTracking) if err != nil { return err } + lp.NoRoundtripToTerminalOnExit = true activated := -1 prefix := ESC_CODE_PREFIX + "i=" + p.identifier diff --git a/kittens/query_terminal/main.go b/kittens/query_terminal/main.go index 1af344efc..dff0da3b2 100644 --- a/kittens/query_terminal/main.go +++ b/kittens/query_terminal/main.go @@ -26,10 +26,11 @@ func main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) { queries[i] = x } } - lp, err := loop.New(loop.NoAlternateScreen, loop.NoKeyboardStateChange, loop.NoMouseTracking, loop.NoRestoreColors, loop.NoInBandResizeNotifications) + lp, err := loop.New(loop.NoAlternateScreen, loop.NoKeyboardStateChange, loop.NoMouseTracking, loop.NoRestoreColors, loop.NoInBandResizeNotifications, loop.NoFocusTracking) if err != nil { return 1, err } + lp.NoRoundtripToTerminalOnExit = true timed_out := false lp.OnInitialize = func() (string, error) { lp.QueryTerminal(queries...) diff --git a/kitty_tests/__init__.py b/kitty_tests/__init__.py index 7e1da4b2c..f93cca340 100644 --- a/kitty_tests/__init__.py +++ b/kitty_tests/__init__.py @@ -145,7 +145,12 @@ class Callbacks: self.bell_count += 1 def on_da1(self) -> None: - payload = da1(get_options()) + opts = None + with suppress(RuntimeError): + opts = get_options() + if opts is None: + opts = defaults + payload = da1(opts) self.da1.append(payload) if self.pty and self.pty.needs_da1: self.pty.send_da1_response(payload) @@ -331,7 +336,7 @@ class PTY: def __init__( self, argv=None, rows=25, columns=80, scrollback=100, cell_width=10, cell_height=20, - cwd=None, env=None, stdin_fd=None, stdout_fd=None, needs_da1=False, + cwd=None, env=None, stdin_fd=None, stdout_fd=None, needs_da1=True, ): self.is_child = False if isinstance(argv, str): diff --git a/tools/cmd/at/tty_io.go b/tools/cmd/at/tty_io.go index ba352bf21..f874362d9 100644 --- a/tools/cmd/at/tty_io.go +++ b/tools/cmd/at/tty_io.go @@ -32,13 +32,14 @@ func do_chunked_io(io_data *rc_io_data) (serialized_response []byte, err error) // arrives, leading to the notification being sent to whatever is executed // after us. Similarly no focus tracking. lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoInBandResizeNotifications, loop.NoFocusTracking) + if err != nil { + return + } if io_data.on_key_event != nil { lp.FullKeyboardProtocol() } else { lp.NoKeyboardStateChange() - } - if err != nil { - return + lp.NoRoundtripToTerminalOnExit = true } const ( diff --git a/tools/tui/loop/api.go b/tools/tui/loop/api.go index 3743f2d80..a2003397b 100644 --- a/tools/tui/loop/api.go +++ b/tools/tui/loop/api.go @@ -58,11 +58,17 @@ type Loop struct { style_ctx style.Context atomic_update_active bool pointer_shapes []PointerShape - waiting_for_capabilities_response bool // Queried capabilities from terminal TerminalCapabilities TerminalCapabilities + // Set this to true to avoid doing a query response loop to the terminal at + // exit. This loop is needed for most kittens to ensure that in-flight + // responses such as in-band resize notifications, color queries, kitty + // keyboard events, etc. are not leaked to the shell. For some special + // purpose uses of the loop, this is not appropriate, hence this setting. + NoRoundtripToTerminalOnExit bool + // Suspend the loop restoring terminal state, and run the provided function. When it returns terminal state is // put back to what it was before suspending unless the function returns an error or an error occurs saving/restoring state. SuspendAndRun func(func() error) error @@ -333,7 +339,7 @@ func (self *Loop) Run() (err error) { os.Stderr.WriteString(err.Error()) os.Stderr.WriteString("\n") if is_terminal { - if term, err := tty.OpenControllingTerm(tty.SetRaw); err == nil { + if term, err := tty.OpenControllingTerm(tty.SetRaw); err != nil { defer term.RestoreAndClose() term.DebugPrintln(err.Error()) fmt.Println("Press any key to exit.\r") @@ -568,12 +574,7 @@ func (self *Loop) CurrentPointerShape() (ans PointerShape, has_shape bool) { // callback will be called once the query response is received. This // function should be called as early as possible ideally in OnInitialize. func (self *Loop) QueryCapabilities() { - if !self.waiting_for_capabilities_response { - self.waiting_for_capabilities_response = true - self.StartAtomicUpdate() - self.QueueWriteString("\x1b[?u\x1b[?996n\x1b[c") - self.EndAtomicUpdate() - } + self.QueueWriteString("\x1b[?u\x1b[?996n\x1b[c") } type Alignment int diff --git a/tools/tui/loop/read.go b/tools/tui/loop/read.go index 0a2c12e1a..9a1cfc24d 100644 --- a/tools/tui/loop/read.go +++ b/tools/tui/loop/read.go @@ -115,11 +115,17 @@ func has_da1_response(s string) bool { return pat.FindString(s) != "" } -func read_until_primary_device_attributes_response(term *tty.Term, initial_bytes []byte, timeout time.Duration) { - s := strings.Builder{} - if initial_bytes != nil { - s.Write(initial_bytes) +func do_roundtrip_to_terminal(term *tty.Term, timeout time.Duration) { + // ask for primary device attributes + for { + if err := term.WriteAllString("\x1b[c"); err != nil && !is_temporary_error(err) { + return + } else { + break + } } + s := strings.Builder{} + s.Grow(256) received := make(chan error) go func() { defer func() { @@ -128,17 +134,19 @@ func read_until_primary_device_attributes_response(term *tty.Term, initial_bytes received <- fmt.Errorf("%s", text) } }() - buf := make([]byte, 1024) - n, err := read_ignoring_temporary_errors(term, buf) - if n > 0 { - s.Write(buf[:n]) - if has_da1_response(s.String()) { - received <- nil - return + for { + buf := make([]byte, 1024) + n, err := read_ignoring_temporary_errors(term, buf) + if n > 0 { + s.Write(buf[:n]) + if has_da1_response(s.String()) { + received <- nil + return + } + } + if err != nil { + received <- err } - } - if err != nil { - received <- err } }() select { diff --git a/tools/tui/loop/run.go b/tools/tui/loop/run.go index 6fef699da..ed7bf05ad 100644 --- a/tools/tui/loop/run.go +++ b/tools/tui/loop/run.go @@ -87,38 +87,72 @@ func (self *Loop) update_screen_size() error { } func (self *Loop) handle_csi(raw []byte) (err error) { + if len(raw) == 0 { + return nil + } csi := string(raw) - if strings.HasSuffix(csi, "t") && strings.HasPrefix(csi, "48;") { - if parts := strings.Split(csi[3:len(csi)-1], ";"); len(parts) > 3 { - var parsed [4]int - ok := true - for i, x := range parts { - x, _, _ = strings.Cut(x, ":") - if parsed[i], err = strconv.Atoi(x); err != nil { - ok = false - break + switch raw[len(raw)-1] { + case 't': + if strings.HasSuffix(csi, "t") { + if parts := strings.Split(csi[3:len(csi)-1], ";"); len(parts) > 3 { + var parsed [4]int + ok := true + for i, x := range parts { + x, _, _ = strings.Cut(x, ":") + if parsed[i], err = strconv.Atoi(x); err != nil { + ok = false + break + } } - } - if ok { - self.seen_inband_resize = true - old_size := self.screen_size - s := &self.screen_size - s.updated = true - s.HeightCells, s.WidthCells = uint(parsed[0]), uint(parsed[1]) - s.HeightPx, s.WidthPx = uint(parsed[2]), uint(parsed[3]) - s.CellWidth = s.WidthPx / s.WidthCells - s.CellHeight = s.HeightPx / s.HeightCells - if self.OnResize != nil { - return self.OnResize(old_size, self.screen_size) + if ok { + self.seen_inband_resize = true + old_size := self.screen_size + s := &self.screen_size + s.updated = true + s.HeightCells, s.WidthCells = uint(parsed[0]), uint(parsed[1]) + s.HeightPx, s.WidthPx = uint(parsed[2]), uint(parsed[3]) + s.CellWidth = s.WidthPx / s.WidthCells + s.CellHeight = s.HeightPx / s.HeightCells + if self.OnResize != nil { + return self.OnResize(old_size, self.screen_size) + } + return nil } - return nil } } - } else if csi == "I" || csi == "O" { - if self.OnFocusChange != nil { - return self.OnFocusChange(csi == "I") + case 'I', 'O': + if len(csi) == 1 { + if self.OnFocusChange != nil { + return self.OnFocusChange(csi == "I") + } } return nil + case 'c': + if strings.HasPrefix(csi, "?") { + if self.OnCapabilitiesReceived != nil { + if err = self.OnCapabilitiesReceived(self.TerminalCapabilities); err != nil { + return err + } + } + } + case 'u': + if strings.HasPrefix(csi, "?") { + self.TerminalCapabilities.KeyboardProtocol = true + self.TerminalCapabilities.KeyboardProtocolResponseReceived = true + } + case 'n': + if strings.HasPrefix(csi, "?997;") { + switch csi[len(csi)-2] { + case '1': + self.TerminalCapabilities.ColorPreference = DARK_COLOR_PREFERENCE + case '2': + self.TerminalCapabilities.ColorPreference = LIGHT_COLOR_PREFERENCE + } + self.TerminalCapabilities.ColorPreferenceResponseReceived = true + if self.OnColorSchemeChange != nil { + return self.OnColorSchemeChange(self.TerminalCapabilities.ColorPreference) + } + } } ke := KeyEventFromCSI(csi) if ke != nil { @@ -131,38 +165,6 @@ func (self *Loop) handle_csi(raw []byte) (err error) { return self.handle_mouse_event(me) } } - if self.waiting_for_capabilities_response { - if strings.HasPrefix(csi, "?") && strings.HasSuffix(csi, "c") { - self.waiting_for_capabilities_response = false - if self.OnCapabilitiesReceived != nil { - if err = self.OnCapabilitiesReceived(self.TerminalCapabilities); err != nil { - return err - } - } - } else if strings.HasPrefix(csi, "?997;") && strings.HasSuffix(csi, "n") { - switch csi[len(csi)-2] { - case '1': - self.TerminalCapabilities.ColorPreference = DARK_COLOR_PREFERENCE - case '2': - self.TerminalCapabilities.ColorPreference = LIGHT_COLOR_PREFERENCE - } - self.TerminalCapabilities.ColorPreferenceResponseReceived = true - } else if strings.HasPrefix(csi, "?") && strings.HasSuffix(csi, "u") { - self.TerminalCapabilities.KeyboardProtocol = true - self.TerminalCapabilities.KeyboardProtocolResponseReceived = true - } - } else if self.terminal_options.color_scheme_change_notification && strings.HasPrefix(csi, "?997;") && strings.HasSuffix(csi, "n") { - switch csi[len(csi)-2] { - case '1': - self.TerminalCapabilities.ColorPreference = DARK_COLOR_PREFERENCE - case '2': - self.TerminalCapabilities.ColorPreference = LIGHT_COLOR_PREFERENCE - } - self.TerminalCapabilities.ColorPreferenceResponseReceived = true - if self.OnColorSchemeChange != nil { - return self.OnColorSchemeChange(self.TerminalCapabilities.ColorPreference) - } - } if self.OnEscapeCode != nil { return self.OnEscapeCode(CSI, raw) } @@ -446,19 +448,16 @@ func (self *Loop) run() (err error) { // wait for tty reader to exit cleanly for range tty_read_channel { } - if !self.waiting_for_capabilities_response { - close(tty_leftover_read_channel) - return - } - var pending_bytes []byte select { - case msg, ok := <-tty_leftover_read_channel: - if ok { - pending_bytes = msg - } + case <-tty_leftover_read_channel: default: } - read_until_primary_device_attributes_response(controlling_term, pending_bytes, 2*time.Second) + if !self.NoRoundtripToTerminalOnExit { + // ensure that any terminal responses such as kitty keyboard events, + // color scheme changes, in-band resize notifications, etc. do not + // bleed into the shell. + do_roundtrip_to_terminal(controlling_term, 2*time.Second) + } } defer func() {