From d0377ed4dcd67a6158d296a927e4fc791eaac6da Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Mon, 24 Aug 2026 00:40:06 +0900 Subject: [PATCH] Wait for rest of escape sequence before parsing (#4901) 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 | 45 ++++++++++++++++++++++++++++++ src/tui/light_escape_test.go | 53 ++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/tui/light_escape_test.go diff --git a/src/tui/light.go b/src/tui/light.go index a917cadf..c425a570 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 ) @@ -338,6 +339,45 @@ 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 { + // 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 tail[start+1] { + case '[': + for _, b := range tail[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(tail)-start < 3 + } + return false +} + func (r *LightRenderer) getBytes(cancellable bool) ([]byte, getCharResult, error) { return r.getBytesInternal(cancellable, r.buffer, false) } @@ -378,6 +418,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 00000000..f83d3ac4 --- /dev/null +++ b/src/tui/light_escape_test.go @@ -0,0 +1,53 @@ +package tui + +import ( + "strings" + "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}, + + // 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}, + {"\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) + } + } +}