Skip to content

fix: open dedicated terminal input on os.ttyname(0), not /dev/tty, on macOS (instant REPL exit since #247) - #250

Merged
bkrabach merged 1 commit into
microsoft:mainfrom
Joi:fix/macos-devtty-kqueue-instant-eof
Aug 3, 2026
Merged

fix: open dedicated terminal input on os.ttyname(0), not /dev/tty, on macOS (instant REPL exit since #247)#250
bkrabach merged 1 commit into
microsoft:mainfrom
Joi:fix/macos-devtty-kqueue-instant-eof

Conversation

@Joi

@Joi Joi commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Since #247, every interactive amplifier session on macOS breaks at the first prompt. With prompt_toolkit 3.0.53+ installed, the CLI prints the session banner and immediately exits:

╭─────────────────────────────────────────────────────╮
│ Amplifier Interactive Session                       │
│ ...                                                 │
╰─────────────────────────────────────────────────────╯
Exiting...

Exit code 0, nothing on stderr, a normal session:end in 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 propagated OSError into 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 default KqueueSelector — cannot poll the /dev/tty alias device: loop.add_reader(fd) raises OSError(22, 'Invalid argument') at kevent registration.

What happens next depends on the installed prompt_toolkit:

  • 3.0.53+: _attached_input() (prompt_toolkit/input/vt100.py) catches (PermissionError, OSError) and converts it to EOFError — the REPL's first prompt_async() "sees Ctrl-D" and exits silently.
  • <=3.0.52 (the version in uv.lock): only PermissionError is caught, so the raw OSError propagates out of prompt_async() into the REPL's generic except Exception handler, 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() returning None) 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):

import asyncio, os

async def main():
    fd = os.open("/dev/tty", os.O_RDONLY | os.O_NONBLOCK)
    asyncio.get_running_loop().add_reader(fd, lambda: None)  # OSError(22) on macOS

asyncio.run(main())

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 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 #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. The tty_path == "/dev/tty" guard keeps the _TTY_DEVICE_PATH test seam authoritative when tests repoint it at a pty slave. If os.ttyname(0) fails, the code falls back to None (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/tty fine). 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:

  • Before: a PromptSession on the module's dedicated fd never receives keystrokes — instant EOFError under prompt_toolkit 3.0.53, raw OSError(22) from kevent registration under the locked 3.0.52. The full CLI shows the banner then "Exiting...".
  • After: input round-trips (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 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. The integration test fails on the unfixed code both with a controlling terminal (OSError at attach) and without one (no dedicated input at all).
  • The full suite's 16 pre-existing failures are byte-identical on base 1873aa9 and this branch.

🤖 Generated with Claude Code

… 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>
@Joi

Joi commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

@microsoft-github-policy-service agree

@bkrabach

bkrabach commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

✅ Independent Verification Complete

I 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:

  • os.open("/dev/tty", O_RDONLY|O_NONBLOCK)succeeds
  • loop.add_reader(fd)OSError [Errno 22] Invalid argument at kevent registration
  • os.open(os.ttyname(0))succeeds and registers pollably

This confirms kqueue cannot poll the /dev/tty alias device, but kqueue polls the real slave device (e.g. /dev/ttys008) fine.

End-to-End Verification (Real Code Path)

Tested the actual PromptSession(input=get_dedicated_tty_input()) code path (mirroring main.py:2772-2791) under pty.fork() so the pty is a genuine controlling terminal:

platform branch fd opened result
Linux main /dev/tty ✅ OK — prompt returned 'hello world'
Linux pr250 /dev/tty ✅ OK — prompt returned 'hello world' (no regression)
macOS main /dev/tty ❌ FAIL — OSError 22 → REPL error loop every iteration
macOS pr250 /dev/ttys008 ✅ OK — prompt returned 'hello world'

The fix is correct and verified working on actual macOS hardware.

Industry Precedent

This diagnosis and fix align with established practice:

  • prompt_toolkit 3.0.53 changelog: "Treats OSError on add_reader as EOFError (macOS kqueue)" — acknowledging the exact failure mode
  • crossterm issue #996, bun issue #24158 — both report the same /dev/tty + kqueue incompatibility
  • libuv's uv_tty_init carries the identical ttyname_r + O_NOCTTY workaround for macOS

Implementation Quality

  • O_NOCTTY addition is ungated (applies to Linux too) but is a POSIX no-op on the /dev/tty alias — Linux behavior verified unchanged
  • Freeze protection preserved — the fresh open file description and private O_NONBLOCK from fix: dedicate a non-blocking fd for terminal input to stop TTY-reader event-loop freezes #247 remain
  • Test seam honored_TTY_DEVICE_PATH guard keeps tests authoritative when repointed at a pty slave
  • Graceful fallback — if os.ttyname(0) fails, code falls back to None (prompt_toolkit's default input) rather than leaving the event loop unable to poll

Future Work Noted

The 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 sys.platform == "darwin"). This is the right design — fix the immediate macOS breakage now (tight, proven), generalize later.

Tests

  • All 14 tests pass, 1 skipped
  • No merge conflicts
  • Merging this PR is safe and correct.

Thank you to Joi for the precise diagnosis and solid implementation. The fix matches industry-standard precedent.

@bkrabach
bkrabach merged commit 7063fde into microsoft:main Aug 3, 2026
1 check passed
bkrabach added a commit that referenced this pull request Aug 3, 2026
…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>
@bkrabach

bkrabach commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Merged as 7063fde and now generalized by #251.

#251 replaces the sys.platform == "darwin" branch with a probe of the property that actually matters — whether selectors.DefaultSelector().register(fd, EVENT_READ) succeeds, which is the same syscall loop.add_reader() bottoms out in. Device selection becomes an ordered candidate list (/dev/tty, then os.ttyname(0)), first one that opens and probes pollable wins.

Same outcome as this PR on macOS, plus: the BSDs are covered (they use kqueue too but never match "darwin"), Linux keeps the exact device and flags it has today, and any future openable-but-unpollable environment degrades to a working prompt with a warning instead of a dead REPL.

Instrumented on real hardware — this PR's diagnosis is exactly what the probe observes at runtime:

platform tried rejected by probe using prompt
Linux /dev/tty none /dev/tty OK
macOS /dev/tty, /dev/ttys008 /dev/tty /dev/ttys008 OK

Your diagnosis and the libuv precedent were both right — thanks for catching this and turning it around fast.

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