fix: open dedicated terminal input on os.ttyname(0), not /dev/tty, on macOS (instant REPL exit since #247) - #250
Conversation
… macOS 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 (microsoft#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>
|
@microsoft-github-policy-service agree |
✅ Independent Verification CompleteI have independently verified this fix on real hardware (macOS 26.6, Darwin 25.6.0, arm64, Python 3.12.12, prompt_toolkit 3.0.52) and can confirm: Isolated Probe (Mechanism Validation)The root cause diagnosis is exactly right. On macOS with KqueueSelector:
This confirms kqueue cannot poll the End-to-End Verification (Real Code Path)Tested the actual
The fix is correct and verified working on actual macOS hardware. Industry PrecedentThis diagnosis and fix align with established practice:
Implementation Quality
Future Work NotedThe review clearly articulates that a follow-up is needed: a general pollability probe so any openable-but-unpollable fd falls back gracefully, plus coverage for the BSDs (which also use kqueue but don't match Tests
Thank you to Joi for the precise diagnosis and solid implementation. The fix matches industry-standard precedent. |
…ck (#251) Background: PR #250 patched the macOS /dev/tty freeze by special-casing sys.platform == "darwin" to open os.ttyname(0) instead of /dev/tty. That fixes macOS but leaves the same defect for FreeBSD/OpenBSD/NetBSD, which also run on kqueue and don't match a hardcoded "darwin" string. The real defect: the function validated that the fd could be OPENED but never that it could be POLLED by the event loop's selector. Fix: add _fd_is_pollable(fd), which performs the exact selector.register() call loop.add_reader() will make later, using a throwaway selectors.DefaultSelector() (no running loop needed). Replace the darwin special-case with an ordered, platform-agnostic candidate list -- /dev/tty first (validated on Linux in production), then os.ttyname(0) as the kqueue-platform fallback -- and open the first candidate that BOTH opens successfully AND passes the pollability probe. No platform string is consulted anywhere in the decision. When the test seam (_TTY_DEVICE_PATH) is repointed, it stays the sole candidate (os.ttyname(0) is never appended), preserving existing seam-based test behavior. When every candidate is exhausted, the function logs a warning naming the candidates tried before returning None, so a degraded fallback announces itself rather than failing silently. No behavior change on the /dev/tty path when it's pollable (Linux): same device, same open() flags, same order. Tests (tests/test_dedicated_tty_input.py): added _fd_is_pollable unit tests (real pty slave -> True; a selector whose register() raises OSError -> False, with selector.close() still verified), a deterministic candidate-fallthrough test simulating the macOS scenario on any platform (monkeypatched os.open + _fd_is_pollable, verifies the winning fd is on os.ttyname(0) and the rejected /dev/tty candidate's fd was explicitly closed -- verified via a close() spy rather than fstat, since a just-closed fd number can be reused by the very next open()), an all-candidates-unpollable -> None + warning-log test (caplog), and a renamed/generalized repointed-seam test. Removed three darwin-specific tests whose assertions encoded the now-removed platform-check mechanism directly (they would fail unconditionally against the new candidate+probe design); their coverage is superseded by the new platform-agnostic tests. The darwin-gated real-kqueue integration test is unchanged. Full suite: 1261 passed, 15 pre-existing failures (confirmed identical with and without this change via git stash A/B comparison), 1 skipped (darwin-gated integration test, not applicable on Linux), 0 regressions. Also adds .github/workflows/ci.yml (the repo had no CI at all): matrix over ubuntu-latest/macos-latest x Python 3.11/3.12, uv sync + pytest. The macOS job now actually exercises the darwin-gated kqueue test instead of it being permanently unrunnable. The 15 pre-existing failures are excluded by explicit --deselect node IDs (not by file, and not via `|| true`) with a comment naming each file and its root cause, so a new regression in any of those files still fails the build. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-authored-by: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
|
Merged as 7063fde and now generalized by #251. #251 replaces the Same outcome as this PR on macOS, plus: the BSDs are covered (they use kqueue too but never match Instrumented on real hardware — this PR's diagnosis is exactly what the probe observes at runtime:
Your diagnosis and the libuv precedent were both right — thanks for catching this and turning it around fast. |
Summary
Since #247, every interactive
amplifiersession on macOS breaks at the first prompt. With prompt_toolkit 3.0.53+ installed, the CLI prints the session banner and immediately exits:Exit code 0, nothing on stderr, a normal
session:endin the event log. Reproduces in Terminal.app, ghostty, and tmux. Keystrokes typed at the (never-shown) prompt go nowhere. With the locked prompt_toolkit (<=3.0.52) the same root cause surfaces as a propagatedOSErrorinto the REPL's generic error handler instead — an error loop rather than a silent exit (details below).Root cause
#247 gives prompt_toolkit a dedicated non-blocking fd opened on
/dev/tty. On macOS, kqueue — the backend behind asyncio's defaultKqueueSelector— cannot poll the/dev/ttyalias device:loop.add_reader(fd)raisesOSError(22, 'Invalid argument')at kevent registration.What happens next depends on the installed prompt_toolkit:
_attached_input()(prompt_toolkit/input/vt100.py) catches(PermissionError, OSError)and converts it toEOFError— the REPL's firstprompt_async()"sees Ctrl-D" and exits silently.uv.lock): onlyPermissionErroris caught, so the rawOSErrorpropagates out ofprompt_async()into the REPL's genericexcept Exceptionhandler, which prints the error and retries — failing identically every iteration.Either way, interactive input is completely broken on macOS. The module's graceful fallback (
open_dedicated_tty_input()returningNone) cannot catch either shape:os.open("/dev/tty")succeeds; the failure only surfaces later, at event-loop attach time.Minimal repro on macOS (run in a real terminal):
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. This is the same tty workaround libuv carries for macOS. The open also addsO_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 #247 introduced (fresh open file description, private
O_NONBLOCK, non-inheritable fd) is unchanged, so the freeze protection it added is preserved rather than disabled. Thetty_path == "/dev/tty"guard keeps the_TTY_DEVICE_PATHtest seam authoritative when tests repoint it at a pty slave. Ifos.ttyname(0)fails, the code falls back toNone(prompt_toolkit's default input) rather than opening an alias device the event loop cannot poll.Considered alternatives: probing kqueue-pollability after opening (more robust, much more machinery), and using
ttyname(0)on all POSIX platforms (needless behavior change on Linux, where epoll polls/dev/ttyfine). FreeBSD likely shares the kqueue limitation but is untested here, so the gate is darwin-only.Verification
On macOS 15 (Darwin 25.5.0), Python 3.13, in tmux/ghostty/Terminal.app:
PromptSessionon the module's dedicated fd never receives keystrokes — instantEOFErrorunder prompt_toolkit 3.0.53, rawOSError(22)from kevent registration under the locked 3.0.52. The full CLI shows the banner then "Exiting...".GOT_INPUT='hello from the fixed fd'in an A/B harness loading the module file from git), the full CLI sits at the prompt, accepts input, and Ctrl-D still exits gracefully.tests/test_dedicated_tty_input.py: 11/11 pass — 7 existing, 3 new unit tests (darwin resolves the alias tottyname(0); darwin falls back toNonewhenttynamefails; a repointed seam is honored as-is), plus a darwin-only integration test that registers the resolved fd with the realKqueueSelectorevent loop viaInput.attach()and round-trips a byte. The integration test fails on the unfixed code both with a controlling terminal (OSErrorat attach) and without one (no dedicated input at all).1873aa9and this branch.🤖 Generated with Claude Code