Skip to content

fix(tui): exit when the terminal hangs up instead of spinning forever - #258

Merged
daviddanialy merged 2 commits into
mainfrom
fix/tui-input-teardown-on-hangup
Oct 1, 2026
Merged

daviddanialy merged 2 commits into
mainfrom
fix/tui-input-teardown-on-hangup

Conversation

@daviddanialy

@daviddanialy daviddanialy commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Closing a terminal window or tab while kit tui is running leaves the process alive, reparented to launchd, and pinning a full CPU core until it is killed by hand. Nine of these had accumulated on one machine over two days, each at ~100% CPU.

This is a regression from #239, which introduced the dedicated kit-terminal-input reader thread. Kit 0.2.2 does not have it.

Cause

  1. On hangup the TUI tears down, and Events::drop sets the stop flag and joins the reader thread.
  2. The reader is inside crossterm 0.29's event::poll. Its tty read loop only breaks on WouldBlock; on a dead terminal read() returns EOF (or an error) immediately, so the loop retries forever and event::poll never returns.
  3. The reader therefore never rechecks the stop flag, and the join never completes.

A stack sample of an affected process shows the main thread in Events::drop → JoinHandle::join and the reader thread in read under crossterm's try_read.

The unconditional join was deliberate (it hands terminal ownership back before authentication or restore), but it assumed event::poll(25ms) always returns.

Fix

Bound the join. The reader thread holds a channel sender that is dropped when it returns or unwinds; Events::drop waits up to two seconds for that and otherwise detaches the thread so the process can exit.

  • A live reader still returns within one 25ms poll interval and is joined exactly as before, so the handoff to terminal authentication is unchanged.
  • A reader stuck on a dead terminal has no input left to steal, and is ended by process exit.

Fixing the join makes the rest of teardown reachable after a hangup, which exposed a second problem: leave calls ratatui::restore, which reports a failed restore with eprintln!. With stderr on the same dead terminal that panics, skipping the session close that follows and exiting with status 101. leave now calls ratatui::try_restore and reports the failure with a write whose error is ignored.

Testing

  • New hangup case in the PTY lifecycle test: the parent closes the PTY master, the child takes the SIGHUP through the real Stop boundary, drops Events, runs leave, and must exit with status 0. It times out without the join fix, exits 101 without the restore fix, and passes with both.
  • The test harness now marks the PTY master close-on-exec. Previously the child inherited it, so the parent could never hang the terminal up.
  • End to end on macOS: kit tui started in a PTY whose master is then closed exits in about 2s with status 0 with this change; the released 0.2.6 binary is still running after 15s.
  • mise run lint passes. mise run test -- --lib tui:: has two failures locally (catalog_returns_canonical_workspace_root, and intermittently connected_terminal_authentication_returns_the_process_exit_status); both fail the same way on main without this change.

Notes

  • Exit after a hangup now takes up to two seconds, during which the stuck reader still spins.
  • If a reader on a live terminal ever failed to stop within two seconds, it would be detached rather than hanging teardown, and could consume input until its current crossterm call returns. Before this change the same state hung teardown.
  • The first CI run failed in tui::keyboard_tests::negotiated_shifted_characters_reach_composer on Linux: its poll on the PTY master returned EINTR. That test does not go through the changed code.
  • The underlying spin is in crossterm's mio event source and is worth reporting upstream.

🤖 Generated with Claude Code

Closing a terminal window with `kit tui` running left the process alive,
orphaned, and pinning a CPU core until killed by hand.

On hangup the TUI tears down and `Events::drop` joins the input reader
thread. That thread sits in crossterm's `event::poll`, whose read loop
retries a dead terminal descriptor forever (it only breaks on WouldBlock),
so the poll never returns, the stop flag is never rechecked, and the join
never completes.

Bound the wait: the reader signals its exit by dropping a channel sender,
and teardown detaches a reader that has not exited within two seconds. A
live reader still returns within one 25ms poll interval and is joined as
before, so terminal ownership handoff to authentication is unchanged.

The PTY lifecycle test gains a hangup case that closes the master and
requires the child to exit. The master is now close-on-exec so the child
no longer holds its own terminal open.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
kit-code-agent[bot]
kit-code-agent Bot previously approved these changes Oct 1, 2026

@kit-code-agent kit-code-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. The changes look good to merge.

danielkov
danielkov previously approved these changes Oct 1, 2026
@daviddanialy
daviddanialy enabled auto-merge (squash) October 1, 2026 17:17
With the input reader no longer blocking teardown, a hangup now reaches
`leave`. `ratatui::restore` reports a failed restore with `eprintln!`,
which panics when stderr is the same dead terminal. The panic skipped the
session close that follows teardown and exited with status 101.

Report the failure with a write whose error is ignored instead. The PTY
hangup case now runs `leave` as well, so it fails if teardown panics.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@kit-code-agent kit-code-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. The changes look good to merge.

@daviddanialy
daviddanialy merged commit dd23d5b into main Oct 1, 2026
7 checks passed
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