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
This commit is contained in:
Kovid Goyal
2026-04-10 15:14:38 +05:30
parent 4982173d3a
commit 5e158f90a7
12 changed files with 121 additions and 99 deletions

View File

@@ -237,6 +237,8 @@ Detailed list of changes
- A new option :opt:`macos_fullscreen_ignore_safe_area_insets` to control - 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`) 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] 0.46.2 [2026-03-21]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@@ -114,6 +114,7 @@ func run_plain_text_loop(opts *Options) (err error) {
if err != nil { if err != nil {
return return
} }
lp.NoRoundtripToTerminalOnExit = true
dest := "c" dest := "c"
if opts.UsePrimary { if opts.UsePrimary {
dest = "p" dest = "p"

View File

@@ -286,10 +286,11 @@ func parse_aliases(raw []string) (map[string][]string, error) {
} }
func run_get_loop(opts *Options, args []string) (err 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 { if err != nil {
return err return err
} }
lp.NoRoundtripToTerminalOnExit = true
var available_mimes []string var available_mimes []string
var wg sync.WaitGroup var wg sync.WaitGroup
var getting_data_for string var getting_data_for string

View File

@@ -43,10 +43,11 @@ func (self *Input) has_mime_matching(predicate func(string) bool) bool {
} }
func write_loop(inputs []*Input, opts *Options) (err error) { 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 { if err != nil {
return err return err
} }
lp.NoRoundtripToTerminalOnExit = true
var waiting_for_write loop.IdType var waiting_for_write loop.IdType
var buf [4096]byte var buf [4096]byte
aliases, aerr := parse_aliases(opts.Alias) aliases, aerr := parse_aliases(opts.Alias)

View File

@@ -21,11 +21,12 @@ func DetectSupport(timeout time.Duration) (memory, files, direct bool, err error
temp_files_to_delete := make([]string, 0, 8) temp_files_to_delete := make([]string, 0, 8)
shm_files_to_delete := make([]shm.MMap, 0, 8) shm_files_to_delete := make([]shm.MMap, 0, 8)
var direct_query_id, file_query_id, memory_query_id uint32 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 { if e != nil {
err = e err = e
return return
} }
lp.NoRoundtripToTerminalOnExit = true
print_error := func(format string, args ...any) { print_error := func(format string, args ...any) {
lp.Println(fmt.Sprintf(format, args...)) lp.Println(fmt.Sprintf(format, args...))
} }

View File

@@ -112,10 +112,11 @@ func (p *parsed_data) generate_chunks(callback func(string)) {
} }
func (p *parsed_data) run_loop() (err error) { 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 { if err != nil {
return err return err
} }
lp.NoRoundtripToTerminalOnExit = true
activated := -1 activated := -1
prefix := ESC_CODE_PREFIX + "i=" + p.identifier prefix := ESC_CODE_PREFIX + "i=" + p.identifier

View File

@@ -26,10 +26,11 @@ func main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) {
queries[i] = x 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 { if err != nil {
return 1, err return 1, err
} }
lp.NoRoundtripToTerminalOnExit = true
timed_out := false timed_out := false
lp.OnInitialize = func() (string, error) { lp.OnInitialize = func() (string, error) {
lp.QueryTerminal(queries...) lp.QueryTerminal(queries...)

View File

@@ -145,7 +145,12 @@ class Callbacks:
self.bell_count += 1 self.bell_count += 1
def on_da1(self) -> None: 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) self.da1.append(payload)
if self.pty and self.pty.needs_da1: if self.pty and self.pty.needs_da1:
self.pty.send_da1_response(payload) self.pty.send_da1_response(payload)
@@ -331,7 +336,7 @@ class PTY:
def __init__( def __init__(
self, argv=None, rows=25, columns=80, scrollback=100, cell_width=10, cell_height=20, 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 self.is_child = False
if isinstance(argv, str): if isinstance(argv, str):

View File

@@ -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 // arrives, leading to the notification being sent to whatever is executed
// after us. Similarly no focus tracking. // after us. Similarly no focus tracking.
lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoInBandResizeNotifications, loop.NoFocusTracking) lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoInBandResizeNotifications, loop.NoFocusTracking)
if err != nil {
return
}
if io_data.on_key_event != nil { if io_data.on_key_event != nil {
lp.FullKeyboardProtocol() lp.FullKeyboardProtocol()
} else { } else {
lp.NoKeyboardStateChange() lp.NoKeyboardStateChange()
} lp.NoRoundtripToTerminalOnExit = true
if err != nil {
return
} }
const ( const (

View File

@@ -58,11 +58,17 @@ type Loop struct {
style_ctx style.Context style_ctx style.Context
atomic_update_active bool atomic_update_active bool
pointer_shapes []PointerShape pointer_shapes []PointerShape
waiting_for_capabilities_response bool
// Queried capabilities from terminal // Queried capabilities from terminal
TerminalCapabilities TerminalCapabilities 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 // 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. // 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 SuspendAndRun func(func() error) error
@@ -333,7 +339,7 @@ func (self *Loop) Run() (err error) {
os.Stderr.WriteString(err.Error()) os.Stderr.WriteString(err.Error())
os.Stderr.WriteString("\n") os.Stderr.WriteString("\n")
if is_terminal { if is_terminal {
if term, err := tty.OpenControllingTerm(tty.SetRaw); err == nil { if term, err := tty.OpenControllingTerm(tty.SetRaw); err != nil {
defer term.RestoreAndClose() defer term.RestoreAndClose()
term.DebugPrintln(err.Error()) term.DebugPrintln(err.Error())
fmt.Println("Press any key to exit.\r") 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 // callback will be called once the query response is received. This
// function should be called as early as possible ideally in OnInitialize. // function should be called as early as possible ideally in OnInitialize.
func (self *Loop) QueryCapabilities() { func (self *Loop) QueryCapabilities() {
if !self.waiting_for_capabilities_response { self.QueueWriteString("\x1b[?u\x1b[?996n\x1b[c")
self.waiting_for_capabilities_response = true
self.StartAtomicUpdate()
self.QueueWriteString("\x1b[?u\x1b[?996n\x1b[c")
self.EndAtomicUpdate()
}
} }
type Alignment int type Alignment int

View File

@@ -115,11 +115,17 @@ func has_da1_response(s string) bool {
return pat.FindString(s) != "" return pat.FindString(s) != ""
} }
func read_until_primary_device_attributes_response(term *tty.Term, initial_bytes []byte, timeout time.Duration) { func do_roundtrip_to_terminal(term *tty.Term, timeout time.Duration) {
s := strings.Builder{} // ask for primary device attributes
if initial_bytes != nil { for {
s.Write(initial_bytes) 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) received := make(chan error)
go func() { go func() {
defer func() { defer func() {
@@ -128,17 +134,19 @@ func read_until_primary_device_attributes_response(term *tty.Term, initial_bytes
received <- fmt.Errorf("%s", text) received <- fmt.Errorf("%s", text)
} }
}() }()
buf := make([]byte, 1024) for {
n, err := read_ignoring_temporary_errors(term, buf) buf := make([]byte, 1024)
if n > 0 { n, err := read_ignoring_temporary_errors(term, buf)
s.Write(buf[:n]) if n > 0 {
if has_da1_response(s.String()) { s.Write(buf[:n])
received <- nil if has_da1_response(s.String()) {
return received <- nil
return
}
}
if err != nil {
received <- err
} }
}
if err != nil {
received <- err
} }
}() }()
select { select {

View File

@@ -87,38 +87,72 @@ func (self *Loop) update_screen_size() error {
} }
func (self *Loop) handle_csi(raw []byte) (err error) { func (self *Loop) handle_csi(raw []byte) (err error) {
if len(raw) == 0 {
return nil
}
csi := string(raw) csi := string(raw)
if strings.HasSuffix(csi, "t") && strings.HasPrefix(csi, "48;") { switch raw[len(raw)-1] {
if parts := strings.Split(csi[3:len(csi)-1], ";"); len(parts) > 3 { case 't':
var parsed [4]int if strings.HasSuffix(csi, "t") {
ok := true if parts := strings.Split(csi[3:len(csi)-1], ";"); len(parts) > 3 {
for i, x := range parts { var parsed [4]int
x, _, _ = strings.Cut(x, ":") ok := true
if parsed[i], err = strconv.Atoi(x); err != nil { for i, x := range parts {
ok = false x, _, _ = strings.Cut(x, ":")
break if parsed[i], err = strconv.Atoi(x); err != nil {
ok = false
break
}
} }
} if ok {
if ok { self.seen_inband_resize = true
self.seen_inband_resize = true old_size := self.screen_size
old_size := self.screen_size s := &self.screen_size
s := &self.screen_size s.updated = true
s.updated = true s.HeightCells, s.WidthCells = uint(parsed[0]), uint(parsed[1])
s.HeightCells, s.WidthCells = uint(parsed[0]), uint(parsed[1]) s.HeightPx, s.WidthPx = uint(parsed[2]), uint(parsed[3])
s.HeightPx, s.WidthPx = uint(parsed[2]), uint(parsed[3]) s.CellWidth = s.WidthPx / s.WidthCells
s.CellWidth = s.WidthPx / s.WidthCells s.CellHeight = s.HeightPx / s.HeightCells
s.CellHeight = s.HeightPx / s.HeightCells if self.OnResize != nil {
if self.OnResize != nil { return self.OnResize(old_size, self.screen_size)
return self.OnResize(old_size, self.screen_size) }
return nil
} }
return nil
} }
} }
} else if csi == "I" || csi == "O" { case 'I', 'O':
if self.OnFocusChange != nil { if len(csi) == 1 {
return self.OnFocusChange(csi == "I") if self.OnFocusChange != nil {
return self.OnFocusChange(csi == "I")
}
} }
return nil 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) ke := KeyEventFromCSI(csi)
if ke != nil { if ke != nil {
@@ -131,38 +165,6 @@ func (self *Loop) handle_csi(raw []byte) (err error) {
return self.handle_mouse_event(me) 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 { if self.OnEscapeCode != nil {
return self.OnEscapeCode(CSI, raw) return self.OnEscapeCode(CSI, raw)
} }
@@ -446,19 +448,16 @@ func (self *Loop) run() (err error) {
// wait for tty reader to exit cleanly // wait for tty reader to exit cleanly
for range tty_read_channel { for range tty_read_channel {
} }
if !self.waiting_for_capabilities_response {
close(tty_leftover_read_channel)
return
}
var pending_bytes []byte
select { select {
case msg, ok := <-tty_leftover_read_channel: case <-tty_leftover_read_channel:
if ok {
pending_bytes = msg
}
default: 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() { defer func() {