Skip to content

Residual stdin/termios races behind the intermittent CPR warning + wedged prompt: ignored join/suspend timeouts, unguarded termios restore, buffered stdin reads, terminal_ui resume leak #467

Description

@mpfaffenberger

Severity: Medium (intermittent terminal lockup; root cause of the residual "CPR" warnings)

Follow-up to #460 (closed by a2b3998). Unifying the duplicate Ctrl+X listener removed the worst stdin-contention offender, but the diagnosis that led there turned up four more races in the same subsystem. Each one independently can produce the rare WARNING: your terminal doesn't support cursor position requests (CPR) followed by a prompt that ignores keystrokes until Ctrl+C.

Background: how the symptom happens

prompt_toolkit sends a CPR query (ESC[6n) when an Application/PromptSession starts and waits 2s for the terminal's reply (ESC[row;colR) on stdin (renderer.py: CPR_TIMEOUT = 2). If the key-listener thread is still reading stdin at that moment, it:

  1. eats the CPR reply → warning printed; and then
  2. on exit, its finally runs termios.tcsetattr(fd, TCSADRAIN, original_attrs) — restoring cooked mode underneath prompt_toolkit's raw mode. The prompt looks alive but receives nothing; Ctrl+C "fixes" it because the SIGINT path forces a redraw that re-enters raw mode.

The remaining races

1. _runtime.py: thread.join(timeout=1.0) result ignored

key_listener_handle.stop()
key_listener_handle.thread.join(timeout=1.0)   # ← returns silently on timeout

If the listener is slow to exit (blocked in emit_warning, a pause/cancel callback, or tcsetattr(TCSADRAIN) waiting for a large output flush to drain), the main REPL prompt starts while the zombie still owns stdin → both failure steps above. Fix: check thread.is_alive() after join, extend the wait, and never hand stdin to prompt_toolkit while a reader is provably alive.

2. _key_listeners.py: suspended_key_listener() swallows suspend failure

# Best-effort suspend; if it doesn't release in time we just
# carry on quietly rather than spamming the user with a warning.
handle.suspend(timeout=timeout)

If the listener doesn't park within 1s, the prompt_toolkit app launches anyway with two readers fighting. Should at minimum log; ideally retry/extend before proceeding.

3. Unguarded termios restore (zombie clobbers prompt_toolkit)

Both exit paths restore original_attrs unconditionally. A late-exiting listener should only restore if the current attrs still match the cbreak state it set (tcgetattr compare), so it can't stomp terminal state that another component (prompt_toolkit) has since taken over.

4. Buffered sys.stdin.read(1) amplifies every theft

sys.stdin is a TextIOWrapper; read(1) slurps all available bytes from the fd into a private buffer and returns one char. One unlucky wakeup steals the entire CPR reply plus any type-ahead, which then dies with the thread — and the fd stops selecting readable for data that's gone. os.read(fd, 1) is the unbuffered fix. (Also the amplifier behind #244's stranded escape-sequence bytes.)

Bonus: tools/ask_user_question/terminal_ui.py resume leak

listener_suspended = key_listener.suspend(timeout=1.0)
...
if listener_suspended and key_listener is not None:
    key_listener.resume()

If suspend() times out but the listener parks late, resume() is never called → suspend_event stays set forever → pause/cancel keys dead for the rest of the run. resume() is idempotent; it should be called in a finally.

Optional belt-and-suspenders

prompt_toolkit 3.0.52 honors PROMPT_TOOLKIT_NO_CPR=1 (output/vt100.py:722). Setting it at startup makes the entire CPR class of failure impossible, at the cost of prompt_toolkit assuming the cursor is at column 0 after external output.

Related: #244 (escape-sequence draining), #437 (async approval prompt missing suspended_key_listener), #428, #194.

Filed by Biscuit (code-puppy-8d2859) — CPR-warning diagnosis follow-up

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions