Skip to content

fix: dedicate a non-blocking fd for terminal input to stop TTY-reader event-loop freezes - #247

Merged
bkrabach merged 2 commits into
mainfrom
fix/dedicated-tty-input-freeze
Aug 2, 2026
Merged

fix: dedicate a non-blocking fd for terminal input to stop TTY-reader event-loop freezes#247
bkrabach merged 2 commits into
mainfrom
fix/dedicated-tty-input-freeze

Conversation

@bkrabach

@bkrabach bkrabach commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

The CLI could freeze completely — no output, no timers, for hours — until the user pressed a key. Root cause: prompt_toolkit's PosixStdinReader.read() does a non-atomic select-then-read on a blocking fd 0:

if not select.select([self.stdin_fd], [], [], 0)[0]:   # readiness check
    return ""
...
data = os.read(self.stdin_fd, count)                   # BLOCKS

fd 0 has no O_NONBLOCK and the tty is raw with VMIN=1, so if any other process consumes the pending byte between those two lines, os.read parks forever. It is registered via loop.add_reader (input/vt100.py:169), so it runs on the asyncio event-loop threadrun_forever cannot advance and no timer fires.

This gives prompt_toolkit its own dedicated /dev/tty fd opened O_RDONLY|O_NONBLOCK. A fresh os.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. PosixStdinReader already swallows OSError (BlockingIOError is 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: 35 returned "Command timed out after 35 seconds" 4h 04m later.

Loop-thread stack captured mid-stall:

prompt_toolkit/input/posix_utils.py:87 in read      <-- os.read BLOCKED
prompt_toolkit/input/vt100.py:98 in read_keys
prompt_toolkit/application/application.py:686 in read_from_input
asyncio/events.py:89 in _run
asyncio/base_events.py:2050 in _run_once
asyncio/base_events.py:683 in run_forever

Competing readers observed in real sessions: ssh without -n, amplifier-digital-twin exec (incus exec), and nested amplifier run / amplifier tool invoke. 16 of the 21 events contain one.

Controlled A/B, n=20 per arm, real pty + live PromptSession + heartbeat watchdog:

Arm max stall runs stalling >2s
baseline 40.73s 7 / 20
with this fix 2.00s 0 / 20

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 fake select, so the test does not depend on winning a ~38% probabilistic race.
  • tests/test_interactive_chat_tty_teardown.py — fd closed on interactive_chat teardown; close is idempotent and cannot mask an original error from a finally block.
  • Full suite: 1252 passed, 14 failed. The 14 were verified pre-existing by checking out origin/main and re-running the same 5 files: 14 failed / 42 passed on both. Zero regressions.
  • Graceful fallback verified for Windows, non-tty stdin, and unopenable /dev/tty (CI/containers) — returns None and prior behavior applies.
  • python_check parity 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 from interactive_chat() (which does) — fixed in 531973c. Code-quality review: APPROVED, zero critical or important issues.

In-situ verification (real CLI, real project directory)

Verified against the real amplifier CLI in the reporter's actual project directory using py-spy dump at 10 Hz. A sample counts as frozen only when the loop thread's TOP frame is the blocking read:

read                       (prompt_toolkit/input/posix_utils.py:87)   <-- BLOCKED in os.read
read_keys                  (prompt_toolkit/input/vt100.py:98)
read_from_input            (application.py:686)
callback_wrapper           (vt100.py:166)                             <-- add_reader callback
_run_once                  (asyncio/base_events.py:2050)
run_forever                (asyncio/base_events.py:683)

Idle-in-epoll is scored separately and never counted as a freeze.

Arm Trials Samples Frozen samples Trials that froze Frozen wall-time
baseline (no fix) 12 3600 395 12 / 12 39.5s
with this fix 12 3600 0 0 / 12 0s

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[6n are 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.

  • No competitor -> 0 frozen samples in 24/24 trials, both arms. The competing reader is the cause.
  • Contention was real in the fixed arm too: the competitor won 99.6% of tty bytes with the fix vs 90.1% without. Bytes were stolen in both arms; only the unfixed arm wedged.
  • Arm identity proven from inside each process: baseline raises ModuleNotFoundError for dedicated_tty_input; fixed arm resolves it and holds an extra /dev/tty fd with O_NONBLOCK set. 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

… 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>
@bkrabach
bkrabach merged commit e793151 into main Aug 2, 2026
1 check passed
@bkrabach
bkrabach deleted the fix/dedicated-tty-input-freeze branch August 2, 2026 11:09
bkrabach pushed a commit that referenced this pull request Aug 3, 2026
… 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>
@bkrabach

bkrabach commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

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 PosixStdinReader is a genuine event-loop freeze, and the production forensics in the description hold up. But os.open("/dev/tty", O_RDONLY|O_NONBLOCK) is not pollable by kqueue. On macOS the open succeeds and loop.add_reader() then raises OSError(EINVAL) at attach time — inside prompt_toolkit, well past this module's fallback. Every interactive session on macOS died at the first prompt: silent exit right after the banner on prompt_toolkit >= 3.0.53 (which converts that OSError to EOFError), an error loop on <= 3.0.52.

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 pty.fork():

branch fd opened result
this PR (e793151) /dev/tty FAIL — OSError 22, REPL error loop every iteration
#250 (7063fde) /dev/ttys008 OK
#251 /dev/ttys008 OK

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:

  1. The fallback validated the wrong property. It checked "can I open this fd?" and never "can the event loop poll it?" This module's own docstring says a broken state should fail loudly rather than proceed degraded — instead it proceeded silently dead. fix: probe tty fd pollability instead of platform-checking; add CI (Linux + macOS) #251 adds a pollability probe so an openable-but-unpollable fd falls back to a working prompt and logs a warning.
  2. ## Known gap: The fix has not yet been exercised in a live interactive session was in this PR's body at merge time. That gap is exactly where the bug was. When the body says the thing isn't proven, that's the merge blocker, not a footnote.
  3. There was no CI in this repo at all. 875 lines through the interactive input path, admin-merged 39 minutes after opening, with no automated gate on any platform. fix: probe tty fd pollability instead of platform-checking; add CI (Linux + macOS) #251 adds ubuntu + macOS runners.

The diagnosis in this PR was excellent work. The gap was between "proven on the platform I was on" and "shipped to every platform."

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants