Skip to content

getKeyPress swallows every read error → Listen busy-loops at 100%+ CPU after the terminal is gone #15

Open

Description

@ruslsalimov

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 (read on a hung-up tty returns EIO)
  • 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)
}
  1. go build -o /tmp/kbtest .
  2. In a separate terminal window, with SIGHUP ignored (simulates IDE terminals, tmux, or any
    terminal that does not deliver HUP): bash -c 'trap "" HUP; exec /tmp/kbtest'
  3. Close that window.
  4. tail -f /tmp/kbtest.log and ps -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 returning EIO immediately;
  • the rest in errors.Is, (*PathError).Error, runtime.concatstrings (from err.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
}
  • EIO arrives wrapped in *fs.PathError, matches nothing, and falls through to return …, nil.
  • Only io.EOF and os.ErrInvalid come back unwrapped from (*os.File).Read, which is the only
    reason the string comparisons ever matched anything.
  • Key{} is not a neutral value: Code == 0 is keys.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" is Read on a nil *os.File (mocking on non-Windows in v0.2.9). Since v0.2.10
    Listen does not call getKeyPress when inputTTY == 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
    closes inputTTY while Read is blocked → Read now fails with ErrClosed on every normal stop.
  • 2022-06-05 9e4bed46 /
    3a7bcb15 — ErrClosed → Key{}, nil added, and the
    empty key is used as a stop sentinel: Listen checks key.Code == keys.Null (later
    reflect.DeepEqual(key, keys.Key{})) and returns.
  • 2022-06-08 844fa89f — "EOF" → nil added
    ("fixed error edge cases").
  • 2022-06-08 7e000a07 — "invalid argument" → nil and
    the catch-all return Key{}, nil added in the same commit that comments out the sentinel check in Listen.
    From this point on the producer of the sentinel exists but its consumer is gone, so every read error means
    "loop again". Nothing in getKeyPress has changed since.
  • 2026-04-26 (v0.2.10, race fix) — Listen rewritten with stopRoutine atomic.Bool and the
    inputTTY == nil → <-stopped branch; getKeyPress untouched.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions