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:
- eats the CPR reply → warning printed; and then
- 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
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 anApplication/PromptSessionstarts 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: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 ignoredIf the listener is slow to exit (blocked in
emit_warning, a pause/cancel callback, ortcsetattr(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: checkthread.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 failureIf 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_attrsunconditionally. A late-exiting listener should only restore if the current attrs still match the cbreak state it set (tcgetattrcompare), so it can't stomp terminal state that another component (prompt_toolkit) has since taken over.4. Buffered
sys.stdin.read(1)amplifies every theftsys.stdinis aTextIOWrapper;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.pyresume leakIf
suspend()times out but the listener parks late,resume()is never called →suspend_eventstays set forever → pause/cancel keys dead for the rest of the run.resume()is idempotent; it should be called in afinally.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