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
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:
- eats the CPR reply → warning printed; and then
- on exit, its
finallyrunstermios.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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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