fix: return an error when the tty is gone - #1067
Conversation
f8ee30c to
8971b97
Compare
|
Independent corroboration of this, on Linux — the mio path specifically. Your description of that loop matches what I landed on exactly, so mostly I want to add a second platform and one detail about the impact that I think is sharper than "high CPU". Repro: crossterm 0.29.0, ratatui TUI in a tmux pane on Linux 6.17, killed with The reason my app was still alive to hit this is its own fault, not crossterm's: it installs a SIGHUP handler for config reload, which replaces SIGHUP's default action of terminating. So it survived losing its terminal. That's a downstream bug and I've fixed it on my side. But it's also exactly the shape of program that reaches this loop — anything with a SIGHUP handler, anything under The detail I'd add: That's what made this hard to find from the outside, and it's why a caller can't defend against it: I assumed my Still reproduces on master as of today; the Probably also fixes #793, which has been open since 2023 with the same stack trace. |
|
Thanks for this — I hit the same loop in a downstream consumer (Codex CLI pins a crossterm fork) and tested your branch. The What happens after this patchThe background wake thread runs: if let Ok(true) = internal::poll(None, &EventFilter) {
break;
}It observes the new error exactly once and discards it — Foreground polls cannot recover it, for two independent reasons:
MeasurementsmacOS 26.5.2 (Darwin 25.5.0, arm64), tokio current-thread consumer, pty torn down by closing the last master fd,
So the patch does remove the CPU burn — that part works. But the stream stays pending indefinitely unless some other event or shutdown path intervenes. To check whether an active executor recovers on its own, I instrumented Breaking the wake loop on
|
|
Following up on my analysis above — I went ahead and implemented the
What it does. The background wake task no longer discards the error. It stores it in a per- Also included: On the test needing a real pty. A socketpair On keeping the state per-instance. Deliberate. I also built and measured a process-global terminal condition, and it is worse for consumers that recreate the stream on error: every freshly constructed stream errors immediately, and since Verified in a real consumer. Codex CLI (which pins a crossterm fork) goes from burning a full core after a pty hangup to exiting cleanly — measured with
It needed a change on its own side too — its event broker classified EOF as "restartable" and re-armed a fresh stream — which is exactly why I think the library should report the condition and stop there. Cherry-pick it, take the diff, or ignore it entirely — whichever is least friction for you. I have not opened a competing PR and don't intend to. |
The previous commit makes the unix event sources report a dead tty as an
error, but the async `EventStream` still drops it. The background wake
task runs `if let Ok(true) = internal::poll(None, ..) { break }`, so an
`Err` matches no arm and it loops straight back into `poll(None)`.
The foreground poll cannot recover it either: `poll_next` uses a
zero-timeout `internal::poll`, which returns `Ok(false)` whenever the
global `EVENT_READER` lock is held by the blocking wake task, and mio
registers the descriptor with `EV_CLEAR`, so the readiness notification
has already been consumed. `event-stream` consumers therefore traded a
busy loop for a silent stall.
Carry the condition across the hand-off instead: the wake task stores the
error in a per-`EventStream` state machine (Active -> Error -> Terminated),
and `poll_next` yields it once before ending the stream.
The state is deliberately per-instance rather than process-global. A
global terminal condition makes every freshly constructed stream error
immediately, and since `EventStream::default()` spawns its wake thread
unconditionally, that turns a one-core spin into thread churn for
consumers that recreate the stream on error. Reopen policy belongs to the
consumer.
The test needs a real pty: `tty_fd()` only uses stdin when `isatty(0)`,
so a socketpair dup2'd onto fd 0 is never picked up. It uses
`rustix-openpty`, matching the non-optional `rustix` dependency, so the
test runs in the default configuration rather than only when the optional
`libc` feature is enabled.
Co-authored-by: Nikita <me@rznz.ru>
8971b97 to
c006ee6
Compare
|
@roazanas Rebased and merged yours in. |
|
Proqi maintainer here. This change would materially help our terminal lifecycle architecture. Proqi currently uses an outer supervisor and nested Crossterm reader because Returning EOF and nonretryable terminal errors would let us classify terminal revocation and perform owned cleanup instead of detaching a potentially live reader. We track the downstream work in oborchers/proqi#52 and have deterministic PTY coverage for macOS and Linux ready to qualify a published release. For accuracy, this PR addresses a major part of our requirement, but we will also verify bounded return during continuous input, unfinished escape or bracketed-paste sequences, and resize handling before removing the supervisor. Thank you for working on this. |
I left
atuin search -i(a ratatui app) open in a terminal tab, and after the session went away the process pegged a CPU core. A spindump put all 1001 samples insidecursor::position(), 975 of them in back-to-backselect(2)andread(2)calls with no sleeping.A dead tty stays permanently readable while
readreturns 0 or fails, and three loops respond by retrying immediately:read_position_rawswallowspollerrors (Err(_) => {}) and retries with no delay.use-dev-ttyevent source conflates EOF withWouldBlock, sotry_readre-polls a permanently readable descriptor until its timeout expires, or forever when it has no timeout.Ok(0)and swallows read errors other thanWouldBlock/Interrupted.I propose to report the dead tty as an error:
read_position_rawpropagatespollerrors, and both unix event sources returnUnexpectedEofat EOF (the mio source also propagates other read errors). This changes behavior forevent::poll/event::readon a vanished terminal: instead of timing out, hanging, or spinning, they fail with an error the caller can act on.This also affects piped or redirected input that reaches EOF, not just a vanished terminal:
event::poll/event::readnow returnUnexpectedEofthere too instead of timing out. It's the same permanent-readable-at-EOF condition, so the same fix applies.