fix: dedicate a non-blocking fd for terminal input to stop TTY-reader event-loop freezes - #247
Conversation
… freezes Root cause: prompt_toolkit's PosixStdinReader.read() does a non-atomic select-then-read on fd 0 (blocking, shared with the parent shell and any inherited children). If a competing reader (ssh without -n, a digital-twin exec, a nested amplifier invocation) drains the pending byte between the readiness check and the os.read() call, the read blocks forever -- ON THE EVENT LOOP THREAD, since it's registered via loop.add_reader(). This wedged real sessions for hours (21 confirmed freeze events across 19,028 session logs; one bash tool call with a 35s timeout returned 4h04m later), always resolving the instant Enter was pressed. Fix: amplifier_app_cli/dedicated_tty_input.py opens /dev/tty with a fresh O_RDONLY | O_NONBLOCK file description (never touching fd 0's own OFD, which would leak O_NONBLOCK to the parent shell) and builds a prompt_toolkit Vt100Input on it. PosixStdinReader.read() already swallows BlockingIOError (a subclass of OSError) from a non-blocking read, so the exact race degrades to an empty read instead of a hang -- verified directly, including a deterministic (non-probabilistic) reproduction of the select-says-ready-but-nothing-to-read race via a monkeypatched select. A fail-loud guard (_assert_posix_stdin_reader_degrades_nonblocking_reads) verifies that core prompt_toolkit assumption at construction time and raises, naming the installed version, if a future release changes it. Falls back to None (prompt_toolkit's own default, zero UX change) whenever the dedicated fd isn't available: Windows, stdin not a tty, or no controlling terminal / /dev/tty unopenable. Wired into both PromptSession construction sites that read fd 0 by default: the main REPL prompt (_create_prompt_session in main.py) and the steering prompt active during agent turns (SteeringInputManager.run in steering_input.py) -- the latter is the actual reader active when a bash tool spawns a competing TTY reader, matching the confirmed freeze scenario. Both share one process-wide dedicated fd (get_dedicated_tty_input), closed once during session teardown (close_dedicated_tty_input) so nothing leaks across sessions or spawned sub-sessions. Tests: tests/test_dedicated_tty_input.py -- attaches a real pty to fd 0 and proves (1) the dedicated fd is distinct and non-blocking, (2) fd 0's own OFD is left untouched (the leak-guard the fix specifically avoids), (3) a real os.read() on the dedicated fd with nothing pending raises BlockingIOError instead of blocking, (4) PosixStdinReader.read() degrades that race to '' deterministically, (5-7) graceful fallback for non-tty stdin, unopenable /dev/tty, and Windows, and (8) the fail-loud version guard. Full existing suite: 1248 passed (14 pre-existing failures, confirmed identical with and without this change via git stash comparison), 0 regressions. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
interactive_chat()'s finally block called initialized.cleanup() but never close_dedicated_tty_input() -- even though interactive_chat is the REPL path that actually opens the dedicated fd (via _create_prompt_session() and each turn's SteeringInputManager prompt). execute_single() had the close call wired but never opens the fd in the first place, so the close was backwards relative to the freeze scenario this fix targets. Add close_dedicated_tty_input() to interactive_chat()'s finally block, mirroring the existing call in execute_single(). The call is idempotent and safe even when the fd was never opened (e.g. non-tty stdin, no controlling terminal). Failing-test-first evidence: tests/test_interactive_chat_tty_teardown.py fails against pre-fix code (close_dedicated_tty_input never called from interactive_chat's finally block) and passes once the call is added. Full suite: 1252 passed (1248 baseline + 4 new), same 14 pre-existing failures, no new regressions. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
… macOS (#250) On macOS, kqueue -- the backend behind asyncio's default KqueueSelector -- cannot poll the /dev/tty alias device. loop.add_reader() on such an fd raises OSError(22, 'Invalid argument') at kevent registration, so the dedicated input fd introduced in e793151 (#247) breaks every interactive session on macOS at the first prompt. The visible symptom depends on the installed prompt_toolkit: 3.0.53+ catches the OSError in _attached_input() and converts it to EOFError, so the REPL "sees Ctrl-D" and exits silently right after the banner (exit code 0, nothing on stderr); on <=3.0.52 (the current lock) only PermissionError is caught, so the raw OSError propagates into the REPL's generic error handler, which prints the error and retries, failing identically every iteration. The module's graceful fallback cannot catch either shape because os.open("/dev/tty") succeeds; the failure only surfaces at event-loop attach time inside prompt_toolkit. Fix: on darwin, resolve the underlying slave device with os.ttyname(0) (e.g. /dev/ttys003) and open that instead of the alias -- kqueue polls the real slave device fine (the same tty workaround libuv carries for macOS). The open also adds O_NOCTTY (as libuv does) so a session leader without a controlling terminal can never accidentally acquire the device as one. Everything else about the mechanism (fresh open file description, private O_NONBLOCK, non-inheritable fd) is unchanged, and the _TTY_DEVICE_PATH test seam stays authoritative when repointed. Tests: three unit tests (darwin resolves the alias to ttyname(0); darwin falls back to None when ttyname fails; a repointed seam is honored as-is) plus a darwin-only integration test that registers the resolved fd with the real KqueueSelector event loop via Input.attach() and round-trips a byte -- it fails on the unfixed code both with a controlling terminal (OSError at attach) and without one (no dedicated input at all). Verified on macOS 15 (Darwin 25.5.0), Python 3.13, tmux + ghostty + Terminal.app: before, keystrokes never arrive and the CLI exits (or error-loops) at the first prompt; after, input round-trips and Ctrl-D still exits gracefully. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
Post-merge follow-up, recorded here for anyone who lands on this PR from a bisect. This change was correct on Linux and totally broke macOS. The mechanism it fixes is real — the non-atomic select-then-read in prompt_toolkit's Reproduced on real hardware (macOS 26.6, arm64, Python 3.12.12, prompt_toolkit 3.0.52), driving this module's actual code path under
Fixed by #250 (thanks @Joi) and generalized in #251. Three things worth carrying forward, since the code was right and the process is what failed:
The diagnosis in this PR was excellent work. The gap was between "proven on the platform I was on" and "shipped to every platform." |
Summary
The CLI could freeze completely — no output, no timers, for hours — until the user pressed a key. Root cause:
prompt_toolkit'sPosixStdinReader.read()does a non-atomic select-then-read on a blocking fd 0:fd 0 has no
O_NONBLOCKand the tty is raw withVMIN=1, so if any other process consumes the pending byte between those two lines,os.readparks forever. It is registered vialoop.add_reader(input/vt100.py:169), so it runs on the asyncio event-loop thread —run_forevercannot advance and no timer fires.This gives prompt_toolkit its own dedicated
/dev/ttyfd openedO_RDONLY|O_NONBLOCK. A freshos.open()creates a new open file description, so the flag is private to us and does not leak to fd 0, the parent shell, or inherited children.PosixStdinReaderalready swallowsOSError(BlockingIOErroris a subclass), so a would-block read degrades to an empty read instead of hanging.Evidence
Production forensics across 19,028 real session logs: 21 distinct freeze events between 2026-07-06 and 2026-08-02. Every one shows zero events across every session in the project during the gap — total process death. Worst case: a bash call with
timeout: 35returned "Command timed out after 35 seconds" 4h 04m later.Loop-thread stack captured mid-stall:
Competing readers observed in real sessions:
sshwithout-n,amplifier-digital-twin exec(incus exec), and nestedamplifier run/amplifier tool invoke. 16 of the 21 events contain one.Controlled A/B, n=20 per arm, real pty + live
PromptSession+ heartbeat watchdog:Baseline stalls included 14.2s, 24.7s, 24.7s, 29.2s, 29.2s, 33.2s, 40.7s. With the fix the worst observed stall is 2.00s.
Note this is distinct from #231 (
stdout_offload.py), which fixed the analogous freeze on the write side. Freezes continued after #231 landed on 2026-07-12. Same family — an unbounded syscall on the loop thread — opposite direction.Test Plan
tests/test_dedicated_tty_input.py— real pty attached to fd 0; asserts the dedicated fd is distinct and non-blocking while fd 0's OFD is not (the regression guard against the flag-leak trap), and that the race degrades to an empty read. The race is forced deterministically via a fakeselect, so the test does not depend on winning a ~38% probabilistic race.tests/test_interactive_chat_tty_teardown.py— fd closed oninteractive_chatteardown; close is idempotent and cannot mask an original error from afinallyblock.origin/mainand re-running the same 5 files: 14 failed / 42 passed on both. Zero regressions./dev/tty(CI/containers) — returnsNoneand prior behavior applies.python_checkparity with baseline; new files are fully clean.Reviews
Spec review found the fd teardown was wired into
execute_single()(which never opens the fd) but missing frominteractive_chat()(which does) — fixed in531973c. Code-quality review: APPROVED, zero critical or important issues.In-situ verification (real CLI, real project directory)
Verified against the real
amplifierCLI in the reporter's actual project directory usingpy-spy dumpat 10 Hz. A sample counts as frozen only when the loop thread's TOP frame is the blocking read:Idle-in-
epollis scored separately and never counted as a freeze.Fisher exact on 12/12 vs 0/12 ~= 3.7e-7. Arms interleaved (a,b,a,b...).
Instrument validity. Two earlier attempts were discarded as invalid and are not counted above: a pyte-based PTY that never answers CPR (CPR replies to
ESC[6nare the tty input the race is fought over -- a terminal that does not answer them cannot reproduce the bug), and a scripted-Enter approach that produced false "freezes" from unsubmitted prompts. The instrument used here answers CPR (39-41 replies per trial in both arms) and submits nothing -- no LLM turn runs and no keystroke timing is interpreted; the freeze is read directly off the stack.Controls.
ModuleNotFoundErrorfordedicated_tty_input; fixed arm resolves it and holds an extra/dev/ttyfd withO_NONBLOCKset. 12/12 probes each.Caveats. py-spy pauses the target ~21% of wall time, identically in both arms. Parks under ~100ms can be missed, so the baseline rate is a lower bound and the fixed arm's zero means "no park >=100ms in 3600 samples," not proof of impossibility. The baseline's 1.6s max streak is capped by the harness, not by reality -- production freezes run for hours because a frozen loop never redraws, so it never emits
ESC[6n, so no CPR reply ever arrives to release the read.Process note. This in-situ verification completed AFTER this PR was opened; the PR was opened on controlled-harness evidence alone. Nothing has been merged -- this evidence is on the record before the merge gate.
🤖 Generated with Amplifier