From 545ef4466f9d23c82c40dd6627c28d906c9099a6 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sun, 23 Aug 2026 20:34:50 +0900 Subject: [PATCH 1/2] Wait for rest of escape sequence before parsing Read loop dropped its escDelay retry budget after every successful byte, so a sequence split across reads reached the parser as a fragment, parsed as ALT-[ with the remainder left behind as query text. - fzf queries DECRQM at startup since dab626b, so a terminal answering late leaked "?2004;2$y" into the query - Same split leaked modified keys and mouse sequences: CTRL-UP left "5A", SGR mouse left "0;1;1M" - Bound unchanged, a stall longer than escDelay still falls back to ALT Fix #4899 --- src/tui/light.go | 38 +++++++++++++++++++++++++++++++ src/tui/light_escape_test.go | 44 ++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 src/tui/light_escape_test.go diff --git a/src/tui/light.go b/src/tui/light.go index a917cadfe8c..638f31ef9aa 100644 --- a/src/tui/light.go +++ b/src/tui/light.go @@ -338,6 +338,39 @@ func getEnv(name string, defaultValue int) int { return atoi(env, defaultValue) } +// Bytes of a CSI sequence: parameter and intermediate bytes continue it, a +// final byte ends it. Order is not enforced. Strictness would only make fzf +// give up on a sequence it could have framed. +// +// https://vt100.net/emu/dec_ansi_parser +func csiContinues(b byte) bool { return b >= 0x20 && b <= 0x3f } +func csiFinal(b byte) bool { return b >= 0x40 && b <= 0x7e } + +// incompleteEscape reports whether the buffer ends in an escape sequence that +// has not been terminated yet. The read loop keeps waiting in that case, so the +// parser is never handed a fragment to guess at. +func incompleteEscape(buffer []byte) bool { + start := bytes.LastIndexByte(buffer, Esc.Byte()) + if start < 0 || len(buffer)-start < 2 { + return false + } + switch buffer[start+1] { + case '[': + for _, b := range buffer[start+2:] { + if csiFinal(b) { + return false + } + if !csiContinues(b) { + return false // malformed, do not wait for a terminator + } + } + return true + case 'O': + return len(buffer)-start < 3 + } + return false +} + func (r *LightRenderer) getBytes(cancellable bool) ([]byte, getCharResult, error) { return r.getBytesInternal(cancellable, r.buffer, false) } @@ -378,6 +411,11 @@ func (r *LightRenderer) getBytesInternal(cancellable bool, buffer []byte, nonblo retries = 0 } buffer = append(buffer, byte(c)) + // Keep waiting while a sequence is still arriving. Dropping the budget + // after every byte left fzf parsing whatever the read happened to end on. + if retries == 0 && incompleteEscape(buffer) { + retries = r.escDelay / escPollInterval + } pc = c // This should never happen under normal conditions, diff --git a/src/tui/light_escape_test.go b/src/tui/light_escape_test.go new file mode 100644 index 00000000000..26f31d33179 --- /dev/null +++ b/src/tui/light_escape_test.go @@ -0,0 +1,44 @@ +package tui + +import "testing" + +func TestIncompleteEscape(t *testing.T) { + for _, c := range []struct { + buffer string + want bool + }{ + // Complete sequences: nothing to wait for + {"\x1b[A", false}, + {"\x1bOA", false}, + {"\x1b[1;5A", false}, + {"\x1b[200~", false}, + {"\x1b[<0;1;1M", false}, + {"\x1b[12;34R", false}, + {"\x1b[?2004;2$y", false}, + {"\x1b[?1;2c", false}, + + // Fragments: keep waiting + {"\x1b[", true}, + {"\x1b[?", true}, + {"\x1b[1;", true}, + {"\x1b[?2004;2$", true}, + {"\x1bO", true}, + {"\x1b[<0;1;", true}, + + // Only the trailing sequence matters + {"ab\x1b[?2004;2$", true}, + {"\x1b[A\x1b[", true}, + {"\x1b[A\x1b[B", false}, + + // Not a sequence fzf waits on + {"", false}, + {"abc", false}, + {"\x1b", false}, // lone ESC, handled by the existing escDelay branch + {"\x1ba", false}, // ALT-a + {"\x1b[\x01", false}, // malformed, do not stall on it + } { + if got := incompleteEscape([]byte(c.buffer)); got != c.want { + t.Errorf("incompleteEscape(%q) = %v, want %v", c.buffer, got, c.want) + } + } +} From 8ca1f677d2e84a01385c6542432701a2c4d4a6ff Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sun, 23 Aug 2026 23:32:09 +0900 Subject: [PATCH 2/2] Bound escape sequence lookback incompleteEscape runs once per byte read and scanned the whole buffer back to the last ESC, so a paste that accumulates in one read made input handling quadratic. 256KB paste took 15.3s against 3.8s before. - Scan only the last 256 bytes, well past any sequence fzf parses - Sequence longer than that is not waited for, same as before this branch - Window size does not affect throughput, 64 and 1024 measure the same Reported by Copilot on #4901 --- src/tui/light.go | 17 ++++++++++++----- src/tui/light_escape_test.go | 11 ++++++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/tui/light.go b/src/tui/light.go index 638f31ef9aa..c425a570aa2 100644 --- a/src/tui/light.go +++ b/src/tui/light.go @@ -26,6 +26,7 @@ const ( offsetPollTries = 10 queryTimeout = 500 * time.Millisecond maxInputBuffer = 1024 * 1024 + escapeLookback = 256 maxSelectTries = 100 ) @@ -350,13 +351,19 @@ func csiFinal(b byte) bool { return b >= 0x40 && b <= 0x7e } // has not been terminated yet. The read loop keeps waiting in that case, so the // parser is never handed a fragment to guess at. func incompleteEscape(buffer []byte) bool { - start := bytes.LastIndexByte(buffer, Esc.Byte()) - if start < 0 || len(buffer)-start < 2 { + // Only the tail can hold a sequence still arriving. This runs once per byte + // read, so scanning all of a large paste would make the read quadratic. + tail := buffer + if len(tail) > escapeLookback { + tail = tail[len(tail)-escapeLookback:] + } + start := bytes.LastIndexByte(tail, Esc.Byte()) + if start < 0 || len(tail)-start < 2 { return false } - switch buffer[start+1] { + switch tail[start+1] { case '[': - for _, b := range buffer[start+2:] { + for _, b := range tail[start+2:] { if csiFinal(b) { return false } @@ -366,7 +373,7 @@ func incompleteEscape(buffer []byte) bool { } return true case 'O': - return len(buffer)-start < 3 + return len(tail)-start < 3 } return false } diff --git a/src/tui/light_escape_test.go b/src/tui/light_escape_test.go index 26f31d33179..f83d3ac44db 100644 --- a/src/tui/light_escape_test.go +++ b/src/tui/light_escape_test.go @@ -1,6 +1,9 @@ package tui -import "testing" +import ( + "strings" + "testing" +) func TestIncompleteEscape(t *testing.T) { for _, c := range []struct { @@ -30,6 +33,12 @@ func TestIncompleteEscape(t *testing.T) { {"\x1b[A\x1b[", true}, {"\x1b[A\x1b[B", false}, + // Long buffers: only the tail is scanned, so an introducer further + // back than escapeLookback is not waited for + {strings.Repeat("a", 100000), false}, + {"\x1b[" + strings.Repeat("a", 100000), false}, + {strings.Repeat("a", 100000) + "\x1b[1;", true}, + // Not a sequence fzf waits on {"", false}, {"abc", false},