Skip to content

Commit 6d3f85c

Browse files
goertzenatorclaude
andauthored
Fix escape sequences fragmenting across reads on slow links (#161)
When the byte buffer ends in a strict prefix of a known key sequence, the input loop now waits up to keyseqTimeoutMs for the rest to arrive before returning. Mirrors GNU Readline's keyseq-timeout. - New `keyseqTimeoutMs :: Word` field in Prefs (default 50ms; settable from ~/.haskeline via `keyseqtimeoutms`). - Implemented with hWaitForInput; no new dependencies. - The read loop carries decoded [Key] alongside the [Char] partial, so per-tick work is bounded by the in-flight sequence rather than the whole accrued buffer. - lookupChars no longer drops a shorter match when a longer attempt fails (e.g. \ESC[AC now lexes as up-arrow + 'C'). Fixes #160 (and probably #77). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9f13c15 commit 6d3f85c

4 files changed

Lines changed: 124 additions & 31 deletions

File tree

Changelog

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ Changed in unreleased:
77
Haskeline against caller-supplied input/output handles (e.g. a serial
88
console or a PTY pair other than the controlling terminal). POSIX only.
99

10+
* On POSIX, fixed escape sequences breaking when their bytes arrive in
11+
separate reads (e.g. on slow serial links). Added `keyseqTimeoutMs`
12+
to `Prefs` to configure the wait, mirroring GNU Readline's
13+
`keyseq-timeout`.
14+
1015
Changed in version 0.8.4.1:
1116

1217
* Implemented ; and , movements and enabled them for d, c, and y actions.

System/Console/Haskeline.hs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ module System.Console.Haskeline(
6464
setComplete,
6565
-- ** User preferences
6666
Prefs(),
67+
keyseqTimeoutMs,
6768
readPrefs,
6869
defaultPrefs,
6970
runInputTWithPrefs,

System/Console/Haskeline/Backend/Posix.hsc

Lines changed: 104 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -202,15 +202,54 @@ lexKeys baseMap ('\ESC':cs)
202202
= metaKey k : ks
203203
lexKeys baseMap (c:cs) = simpleChar c : lexKeys baseMap cs
204204

205-
lookupChars :: TreeMap Char Key -> [Char] -> Maybe (Key,[Char])
206-
lookupChars _ [] = Nothing
207-
lookupChars (TreeMap tm) (c:cs) = case Map.lookup c tm of
208-
Nothing -> Nothing
209-
Just (Nothing,t) -> lookupChars t cs
210-
Just (Just k, t@(TreeMap tm2))
211-
| not (null cs) && not (Map.null tm2) -- ?? lookup d tm2?
212-
-> lookupChars t cs
213-
| otherwise -> Just (k, cs)
205+
-- | Walk @cs@ through the tree, returning the /longest/ key found along
206+
-- the path together with whatever bytes follow it.
207+
--
208+
-- If a longer match is attempted but doesn't pan out (the next byte
209+
-- isn't in the deeper subtree), we fall back to the most recent shorter
210+
-- key we passed. This avoids the well-known sharp edge where input
211+
-- like @\\ESC[AC@ — with @\\ESC[A@ registered (up-arrow) and the @A@
212+
-- node having children that don't include @C@ — would previously be
213+
-- reported as no match at all, instead of @up-arrow + 'C'@.
214+
--
215+
-- 'Nothing' is returned only when no key is reached anywhere along the
216+
-- walk.
217+
lookupChars :: TreeMap Char Key -> [Char] -> Maybe (Key, [Char])
218+
lookupChars _ [] = Nothing
219+
lookupChars tree cs = go Nothing tree cs
220+
where
221+
-- 'best' holds the longest committed match so far (or Nothing if
222+
-- we haven't passed a key node yet). When the next byte doesn't
223+
-- extend the path we return 'best'.
224+
go best (TreeMap m) (c:cs') = case Map.lookup c m of
225+
Nothing -> best
226+
Just (mKey, sub) ->
227+
let best' = maybe best (\k -> Just (k, cs')) mKey
228+
in case cs' of
229+
_:_ -> go best' sub cs'
230+
[] -> best'
231+
go best _ [] = best -- unreachable: top-level [] handled above
232+
233+
-- | Greedily peel complete keys off the front of @cs@. Returns the
234+
-- decoded keys (in forward order) and the leftover bytes that didn't
235+
-- (yet) form a complete tree match.
236+
peelCompleteKeys :: TreeMap Char Key -> [Char] -> ([Key], [Char])
237+
peelCompleteKeys tree = go id
238+
where
239+
go acc cs = case lookupChars tree cs of
240+
Just (k, rest) -> go (acc . (k:)) rest
241+
Nothing -> (acc [], cs)
242+
243+
-- | True if @cs@ matches a /strict/ (non-terminal) prefix of some path
244+
-- in the @TreeMap@ — i.e. it could still be extended into a complete
245+
-- key sequence if more bytes arrive.
246+
isStrictTreePrefix :: TreeMap Char b -> [Char] -> Bool
247+
isStrictTreePrefix _ [] = False
248+
isStrictTreePrefix (TreeMap m) (c:cs) = case Map.lookup c m of
249+
Nothing -> False
250+
Just (_, sub@(TreeMap sm)) -> case cs of
251+
[] -> not (Map.null sm)
252+
_ -> isStrictTreePrefix sub cs
214253

215254
-----------------------------
216255

@@ -219,8 +258,9 @@ withPosixGetEvent :: (MonadIO m, MonadMask m, MonadReader Prefs m)
219258
-> (m Event -> m a) -> m a
220259
withPosixGetEvent eventChan h termKeys f = wrapTerminalOps h $ do
221260
baseMap <- getKeySequences (ehIn h) termKeys
261+
timeoutMs <- asks (fromIntegral . keyseqTimeoutMs :: Prefs -> Int)
222262
withWindowHandler eventChan
223-
$ f $ liftIO $ getEvent (ehIn h) baseMap eventChan
263+
$ f $ liftIO $ getEvent (ehIn h) timeoutMs baseMap eventChan
224264

225265
withWindowHandler :: (MonadIO m, MonadMask m) => TChan Event -> m a -> m a
226266
withWindowHandler eventChan = withHandler windowChange $
@@ -238,27 +278,62 @@ withHandler signal handler f = do
238278
old_handler <- liftIO $ installHandler signal handler Nothing
239279
f `finally` liftIO (installHandler signal old_handler Nothing)
240280

241-
getEvent :: Handle -> TreeMap Char Key -> TChan Event -> IO Event
242-
getEvent h baseMap = keyEventLoop $ do
243-
cs <- getBlockOfChars h
244-
return [KeyInput $ lexKeys baseMap cs]
245-
246-
-- Read at least one character of input, and more if immediately
247-
-- available. In particular the characters making up a control sequence
248-
-- will all be available at once, so they can be processed together
249-
-- (with Posix.lexKeys).
250-
getBlockOfChars :: Handle -> IO String
251-
getBlockOfChars h = do
281+
getEvent :: Handle -> Int -> TreeMap Char Key -> TChan Event -> IO Event
282+
getEvent h timeoutMs baseMap = keyEventLoop $ do
283+
ks <- getBlockOfKeys h baseMap timeoutMs
284+
return [KeyInput ks]
285+
286+
-- | Read at least one key of input, and more if available.
287+
--
288+
-- A multi-byte control sequence (e.g. @\\ESC[A@ for arrow-up) may not
289+
-- arrive in a single read on a slow link such as a low-bandwidth serial
290+
-- port: the @\\ESC@ can land before the @[A@. When the buffer ends in
291+
-- bytes that are a strict prefix of some known key sequence, we wait up
292+
-- to @timeoutMs@ for the rest before returning. Otherwise we return as
293+
-- soon as nothing more is buffered. This mirrors GNU Readline's
294+
-- @keyseq-timeout@.
295+
--
296+
-- The loop tracks bytes and decoded keys side by side: every time we
297+
-- run out of immediately-available input we peel complete keys from the
298+
-- front of the byte buffer and shrink it down to just its trailing
299+
-- (possibly partial) prefix. That keeps the per-decision work bounded
300+
-- by the size of the in-flight sequence rather than the whole accrued
301+
-- buffer, which matters when bytes trickle in slowly.
302+
--
303+
-- Win32 is unaffected: that backend reads structured @INPUT_RECORD@
304+
-- key events rather than raw bytes, so escape sequences are never
305+
-- fragmented in the first place.
306+
getBlockOfKeys :: Handle -> TreeMap Char Key -> Int -> IO [Key]
307+
getBlockOfKeys h baseMap timeoutMs = do
252308
c <- hGetChar h
253-
loop [c]
309+
loop [c] []
254310
where
255-
loop cs = do
256-
isReady <- hReady h
257-
if not isReady
258-
then return $ reverse cs
259-
else do
260-
c <- hGetChar h
261-
loop (c:cs)
311+
-- Both lists hold values in reverse (newest first) so that consing a
312+
-- new element is O(1). We reverse only at decision points or on
313+
-- return.
314+
loop bytesRev keysRev = do
315+
ready <- hWaitForInput h 0
316+
if ready
317+
then do
318+
c <- hGetChar h
319+
loop (c:bytesRev) keysRev
320+
else
321+
-- Drain whatever complete keys are sitting at the head of
322+
-- the byte buffer; what's left is either empty (done) or a
323+
-- still-in-flight partial sequence.
324+
let (peeled, partial) = peelCompleteKeys baseMap (reverse bytesRev)
325+
keysRev' = reverse peeled ++ keysRev
326+
in if null partial
327+
then pure (reverse keysRev')
328+
else if isStrictTreePrefix baseMap partial
329+
then do
330+
arrived <- hWaitForInput h timeoutMs
331+
if arrived
332+
then do
333+
c <- hGetChar h
334+
loop (c : reverse partial) keysRev'
335+
else pure (reverse keysRev' ++ lexKeys baseMap partial)
336+
else pure (reverse keysRev' ++ lexKeys baseMap partial)
262337

263338
stdinTTYHandles, ttyHandles :: MaybeT IO Handles
264339
stdinTTYHandles = do

System/Console/Haskeline/Prefs.hs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import Control.Exception (IOException)
1414
import Data.Char(isSpace,toLower)
1515
import Data.List(foldl')
1616
import qualified Data.Map as Map
17+
import Data.Word (Word)
1718
import System.Console.Haskeline.Key
1819

1920
{- |
@@ -48,7 +49,16 @@ data Prefs = Prefs { bellStyle :: !BellStyle,
4849
-- presses @TAB@ again.
4950
customBindings :: Map.Map Key [Key],
5051
-- (termName, keysequence, key)
51-
customKeySequences :: [(Maybe String, String,Key)]
52+
customKeySequences :: [(Maybe String, String,Key)],
53+
keyseqTimeoutMs :: !Word
54+
-- ^ After an @ESC@ byte is read, how long to wait
55+
-- (in milliseconds) for the rest of an escape
56+
-- sequence before treating the @ESC@ as a
57+
-- standalone keypress. Matters on slow links
58+
-- (e.g. low-bandwidth serial), where the bytes of
59+
-- a sequence such as @\\ESC[A@ may not arrive in
60+
-- a single read. Mirrors GNU Readline's
61+
-- @keyseq-timeout@.
5262
}
5363
deriving Show
5464

@@ -77,7 +87,8 @@ defaultPrefs = Prefs {bellStyle = AudibleBell,
7787
listCompletionsImmediately = True,
7888
historyDuplicates = AlwaysAdd,
7989
customBindings = Map.empty,
80-
customKeySequences = []
90+
customKeySequences = [],
91+
keyseqTimeoutMs = 50
8192
}
8293

8394
mkSettor :: Read a => (a -> Prefs -> Prefs) -> String -> Prefs -> Prefs
@@ -98,6 +109,7 @@ settors = [("bellstyle", mkSettor $ \x p -> p {bellStyle = x})
98109
,("completionpromptlimit", mkSettor $ \x p -> p {completionPromptLimit = x})
99110
,("listcompletionsimmediately", mkSettor $ \x p -> p {listCompletionsImmediately = x})
100111
,("historyduplicates", mkSettor $ \x p -> p {historyDuplicates = x})
112+
,("keyseqtimeoutms", mkSettor $ \x p -> p {keyseqTimeoutMs = x})
101113
,("bind", addCustomBinding)
102114
,("keyseq", addCustomKeySequence)
103115
]

0 commit comments

Comments
 (0)