Repository navigation
getKeyPress swallows every read error → Listen busy-loops at 100%+ CPU after the terminal is gone #15
Description
Summary
getKeyPress turns every error from inputTTY.Read into an empty keys.Key{} with a nil error.
Once the terminal is gone (window/tab closed, but the process did not receive — or ignores — SIGHUP),
read(2) on /dev/tty stops blocking and returns EIO immediately, forever. Listen then spins:
read → EIO → swallowed → Key{} → callback → read → EIO → …
Consumers built on top (pterm InteractiveSelect / Multiselect / TextInput / Confirm / Continue)
never see an error, so Show() never returns and the process burns CPU until it is killed.
Affected: v0.2.9 and v0.2.10 — getKeyPress has not changed since June 2022 (see History below).
Real-world impact
- Internal CLI (pterm v0.12.81): a process that outlived its terminal (
TTY ??; parent either gone
or a shell still waiting on it) found after 10.5 h at 150–220 % CPU. - Same bug hit independently by testkube: ~253 % CPU, 19 h CPU time per process —
fix(cli): refuse prompts when stdin is not a terminal kubeshop/testkube#8386. They worked around it on their side (refuse prompts when stdin is not a TTY), which does not cover losing the terminal while a prompt is already open.
Environment
- macOS 26.6 arm64, Go 1.27.1; Linux behaves the same (
readon a hung-up tty returnsEIO) - atomicgo.dev/keyboard v0.2.9 and v0.2.10; pterm v0.12.81 / v0.12.83
Reproduction
package main
import (
"fmt"
"os"
"sync/atomic"
"time"
"atomicgo.dev/keyboard"
"atomicgo.dev/keyboard/keys"
)
func main() {
log, _ := os.Create("/tmp/kbtest.log")
var emptyKeys atomic.Int64
go func() {
for range time.Tick(time.Second) {
fmt.Fprintf(log, "%s pid=%d ppid=%d empty keys=%d\n",
time.Now().Format(time.TimeOnly), os.Getpid(), os.Getppid(), emptyKeys.Load())
}
}()
err := keyboard.Listen(func(key keys.Key) (bool, error) {
if key.Code == 0 && len(key.Runes) == 0 {
emptyKeys.Add(1)
}
return key.Code == keys.CtrlC, nil
})
fmt.Fprintf(log, "Listen returned: %v\n", err)
}go build -o /tmp/kbtest .- In a separate terminal window, with
SIGHUPignored (simulates IDE terminals, tmux, or any
terminal that does not deliver HUP):bash -c 'trap "" HUP; exec /tmp/kbtest' - Close that window.
tail -f /tmp/kbtest.logandps -o pid,ppid,%cpu,tty -p <pid>
Expected: Listen returns an error, the program exits.
Actual: empty keys grows by hundreds of thousands per second, Listen never returns, ~130 % CPU.
Evidence
Debugger stopped at if err != nil right after inputTTY.Read:
err = *fs.PathError{Op: "read", Path: "/dev/tty", Err: syscall.Errno(5)} // EIO
numBytes = 0
sample (macOS) over ~8 s, non-idle time only ≈ 1.33 cores:
- ~57 % in
read— the syscall returningEIOimmediately; - the rest in
errors.Is,(*PathError).Error,runtime.concatstrings(fromerr.Error() == "EOF"
on every iteration) and GC (scanObject,gcDrain,mallocgc) — hence more than one core.
Root cause
numBytes, err := inputTTY.Read(buf[:])
if err != nil {
if errors.Is(err, os.ErrClosed) {
return keys.Key{}, nil
}
if err.Error() == "EOF" {
return keys.Key{}, nil
} else if err.Error() == "invalid argument" {
return keys.Key{}, nil
}
return keys.Key{}, nil // ← every other error, including EIO
}EIOarrives wrapped in*fs.PathError, matches nothing, and falls through toreturn …, nil.- Only
io.EOFandos.ErrInvalidcome back unwrapped from(*os.File).Read, which is the only
reason the string comparisons ever matched anything. Key{}is not a neutral value:Code == 0iskeys.Null/keys.CtrlAt, so a consumer cannot tell
"read failed" from a real Ctrl+@ press. The library is the only place that can make this decision."invalid argument"isReadon anil*os.File(mocking on non-Windows in v0.2.9). Since v0.2.10
Listendoes not callgetKeyPresswheninputTTY == nil, so that branch is unreachable.
History — how this came to be
The swallowing was never error handling; it is the leftover half of a stop protocol plus test fixes:
- 2022-06-01 6e83a81d — original code:
if err != nil { return keys.Key{}, err }. Errors were propagated. - 2022-06-05 d587b599 — stop from the mock goroutine
closesinputTTYwhileReadis blocked →Readnow fails withErrClosedon every normal stop. - 2022-06-05 9e4bed46 /
3a7bcb15 —ErrClosed → Key{}, niladded, and the
empty key is used as a stop sentinel:Listencheckskey.Code == keys.Null(later
reflect.DeepEqual(key, keys.Key{})) and returns. - 2022-06-08 844fa89f —
"EOF" → niladded
("fixed error edge cases"). - 2022-06-08 7e000a07 —
"invalid argument" → niland
the catch-allreturn Key{}, niladded in the same commit that comments out the sentinel check inListen.
From this point on the producer of the sentinel exists but its consumer is gone, so every read error means
"loop again". Nothing ingetKeyPresshas changed since. - 2026-04-26 (v0.2.10, race fix) —
Listenrewritten withstopRoutine atomic.Booland the
inputTTY == nil → <-stoppedbranch;getKeyPressuntouched.
On a live terminal read on /dev/tty in raw mode practically never fails, which is why this survived for
four years; it only shows when the terminal disappears without a delivered SIGHUP.
Proposed fix
Return read errors from getKeyPress (as the original code did) and let Listen — which owns
stopRoutine — decide whether the failure is our own stop or a real error, restoring the console
before returning. PR: #16
Activity