mpfaffenberger / mpfaffenberger/code_puppy

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

Open
#467 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
814
Forks
278
Avg merge
2d 5h
Merged PRs (30d)
76

Description

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

Follow-up to #460 (closed by a2b39984). 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

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reading the listener shutdown and suspension paths in _runtime.py and _key_listeners.py, then inspect tools/ask_user_question/terminal_ui.py and the termios/stdin handling described in the issue. Done means listener ownership is settled before prompt_toolkit starts, late cleanup cannot overwrite newer terminal state, reads do not drain buffered input, and suspension is always resumed.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
cli
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.