From 0c6af780981388459e0db295918e28dd33c893e3 Mon Sep 17 00:00:00 2001 From: Liam Galvin Date: Tue, 17 Mar 2026 09:12:32 +0000 Subject: [PATCH] fix(core): stop spinning on terminal read errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- emacs.go | 3 ++ internal/core/keys.go | 25 +++++++++++--- internal/core/keys_test.go | 63 +++++++++++++++++++++++++++++++++++ internal/core/keys_unix.go | 4 +-- internal/core/keys_windows.go | 4 +-- readline.go | 4 +++ 6 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 internal/core/keys_test.go diff --git a/emacs.go b/emacs.go index 13dcdabd..5b42738c 100644 --- a/emacs.go +++ b/emacs.go @@ -458,6 +458,9 @@ func (rl *Shell) bracketedPasteBegin() { key, empty := core.PopKey(rl.Keys) if empty { core.WaitAvailableKeys(rl.Keys, rl.Config) + if rl.Keys.IsEOF() || rl.Keys.ReadError() != nil { + return + } continue } diff --git a/internal/core/keys.go b/internal/core/keys.go index fc37eb0f..f822764f 100644 --- a/internal/core/keys.go +++ b/internal/core/keys.go @@ -36,9 +36,10 @@ type Keys struct { cursor chan []byte // Cursor coordinates has been read on stdin. resize chan bool // Resize events on Windows are sent on stdin. USED IN WINDOWS - eof bool // EOF has been reached. - cfg *inputrc.Config // Configuration file used for meta key settings - mutex sync.RWMutex // Concurrency safety + eof bool // EOF has been reached. + readErr error // Non-EOF input failure, e.g. revoked tty. + cfg *inputrc.Config // Configuration file used for meta key settings + mutex sync.RWMutex // Concurrency safety } // WaitAvailableKeys waits until an input key is either read from standard input, @@ -71,8 +72,14 @@ func WaitAvailableKeys(keys *Keys, cfg *inputrc.Config) { // We will either read keyBuf from user, or an EOF // send by ourselves, because we pause reading. keyBuf, err := keys.readInputFiltered() - if err != nil && errors.Is(err, io.EOF) { - keys.eof = true + if err != nil { + keys.mutex.Lock() + if errors.Is(err, io.EOF) { + keys.eof = true + } else if keys.readErr == nil { + keys.readErr = err + } + keys.mutex.Unlock() return } @@ -109,6 +116,14 @@ func (k *Keys) IsEOF() bool { return k.eof } +// ReadError returns the first non-EOF input error observed while reading keys. +func (k *Keys) ReadError() error { + k.mutex.RLock() + defer k.mutex.RUnlock() + + return k.readErr +} + // PeekKey returns the first key in the stack, without removing it. func PeekKey(keys *Keys) (key byte, empty bool) { switch { diff --git a/internal/core/keys_test.go b/internal/core/keys_test.go new file mode 100644 index 00000000..26687992 --- /dev/null +++ b/internal/core/keys_test.go @@ -0,0 +1,63 @@ +package core + +import ( + "errors" + "io" + "testing" + + "github.com/reeflective/readline/inputrc" +) + +type stubReadCloser struct { + read func([]byte) (int, error) +} + +func (s stubReadCloser) Read(p []byte) (int, error) { + return s.read(p) +} + +func (s stubReadCloser) Close() error { + return nil +} + +func TestWaitAvailableKeysMarksEOF(t *testing.T) { + original := Stdin + Stdin = stubReadCloser{ + read: func([]byte) (int, error) { + return 0, io.EOF + }, + } + defer func() { Stdin = original }() + + keys := &Keys{} + WaitAvailableKeys(keys, &inputrc.Config{}) + + if !keys.IsEOF() { + t.Fatal("expected EOF to be recorded") + } + if err := keys.ReadError(); err != nil { + t.Fatalf("expected no non-EOF read error, got %v", err) + } +} + +func TestWaitAvailableKeysRecordsNonEOFReadError(t *testing.T) { + want := errors.New("read failed") + + original := Stdin + Stdin = stubReadCloser{ + read: func([]byte) (int, error) { + return 0, want + }, + } + defer func() { Stdin = original }() + + keys := &Keys{} + WaitAvailableKeys(keys, &inputrc.Config{}) + + if keys.IsEOF() { + t.Fatal("expected non-EOF read failure not to be marked as EOF") + } + if err := keys.ReadError(); !errors.Is(err, want) { + t.Fatalf("expected read error %v, got %v", want, err) + } +} diff --git a/internal/core/keys_unix.go b/internal/core/keys_unix.go index c830d220..e69e30fe 100644 --- a/internal/core/keys_unix.go +++ b/internal/core/keys_unix.go @@ -3,9 +3,7 @@ package core import ( - "errors" "fmt" - "io" "os" "strconv" @@ -96,7 +94,7 @@ func (k *Keys) readInputFiltered() (keys []byte, err error) { buf := make([]byte, keyScanBufSize) read, err := Stdin.Read(buf) - if err != nil && errors.Is(err, io.EOF) { + if err != nil { return } diff --git a/internal/core/keys_windows.go b/internal/core/keys_windows.go index 83e0a5bc..41883c18 100644 --- a/internal/core/keys_windows.go +++ b/internal/core/keys_windows.go @@ -4,8 +4,6 @@ package core import ( - "errors" - "io" "unsafe" "github.com/reeflective/readline/inputrc" @@ -72,7 +70,7 @@ func (k *Keys) readInputFiltered() (keys []byte, err error) { buf := make([]byte, keyScanBufSize) read, err := Stdin.Read(buf) - if err != nil && errors.Is(err, io.EOF) { + if err != nil { return keys, err } diff --git a/readline.go b/readline.go index 3cb81363..323979ed 100644 --- a/readline.go +++ b/readline.go @@ -96,6 +96,10 @@ func (rl *Shell) Readline() (string, error) { // the macro engine has fed some keys in bulk when running one. core.WaitAvailableKeys(rl.Keys, rl.Config) + if err := rl.Keys.ReadError(); err != nil { + return "", err + } + // If the input is closed, we must return the line // and the error so that the caller can handle it. if rl.Keys.IsEOF() {