Bug: TUI stops reading stdin after `reptyr`-based PTY reattachment ("alive but deaf") — likely epoll registration silently dropped by `dup2`
Environment
| | |
|---|---|
| Claude Code version | 2.1.222 (claude --version) |
| Install path | /root/.local/share/claude/versions/2.1.222 (symlinked from ~/.local/bin/claude) |
| Runtime | Bun (not Node.js — see "Additional finding" below). GDB thread list shows Bun Pool 0-5, mi-scavenger (mimalloc), plus 6× tokio-rt-worker threads, no node in the binary path |
| OS | Ubuntu 26.04 LTS "Resolute Raccoon" |
| Kernel | Linux 7.0.0-28-generic #28-Ubuntu SMP PREEMPT_DYNAMIC x86_64 |
| Host environment | Hyper-V VM guest, GNOME/Wayland desktop, Enhanced Session Mode |
| Terminal multiplexer | None (plain PTY, no tmux/screen) |
| Reattach tool | reptyr 0.9.0-1build1 (Ubuntu package) |
Summary
Claude Code's keyboard on-screen input in a Hyper-V Enhanced Session GUI stopped responding (a separate, unrelated Hyper-V input bug). I tried to recover the running session over SSH using reptyr to move the claude process's controlling terminal to a fresh PTY. reptyr succeeded at the OS level — file descriptors 0/1/2 were correctly repointed to the new /dev/pts/N, the process kept running, and it remained the foreground process group of the new PTY with correct raw-mode terminal settings. However, the process never again read a single byte from stdin. It continued running its internal event loop (timers, config-file watchers, memory-stat polling) indefinitely, but silently stopped reacting to any keystroke. From the user's perspective the app looks fully hung; from strace, it looks like it's doing housekeeping work forever while completely ignoring the terminal it is attached to.
This report documents the process from after the working reptyr handoff, with the process still live and inspectable right now (PID 4856), so all diagnostics below were captured against a real, reproducible-looking hang, not a theory.
Steps to reproduce
- Start
claudeinteractively in a normal PTY (e.g. an SSH/bash session). - From another shell with sufficient privileges, run
reptyr <claude_pid>to move the process's stdio to the calling terminal's PTY. - Confirm via
/proc/<pid>/fd/{0,1,2}that the fds now point at the new PTY (they do). - Type any key in the new terminal.
Expected: Claude Code's Ink-based TUI redraws / responds to input as normal, exactly as if the PTY had never changed.
Observed: No response of any kind, indefinitely. The process is not crashed, not zombied, not blocked in an obvious syscall — it is simply never calling read() on fd 0 again.
Diagnostic evidence
All of the following was collected against the live, still-running process (PID 4856, now at 1 day 21+ hours uptime post-hang) so it directly reflects the hung state rather than a guess.
1. Terminal wiring is correct — this is not a "wrong fd" or "wrong foreground group" problem:
$ ls -la /proc/4856/fd/0 /proc/4856/fd/1 /proc/4856/fd/2
lrwx------ 1 root root 64 Aug 5 11:27 /proc/4856/fd/0 -> /dev/pts/14
lrwx------ 1 root root 64 Aug 5 11:27 /proc/4856/fd/1 -> /dev/pts/14
lrwx------ 1 root root 64 Aug 5 11:27 /proc/4856/fd/2 -> /dev/pts/14
$ ps -o pid,pgid,sid,tpgid,tty -p 4856
PID PGID SID TPGID TT
4856 4856 4856 4856 pts/14 # process IS the foreground pgrp — kernel would deliver input fine
$ stty -F /dev/pts/14
speed 38400 baud; line = 0;
-brkint -icrnl -imaxbel iutf8
-isig -icanon -iexten -echo # raw mode, as expected for an Ink TUI
2. The process is not deadlocked — it has a live, spinning event loop:
$ cat /proc/4856/task/*/status | grep State
State: S (sleeping) # x16 threads, all just idle-sleeping, no D-state, no zombies
5-second strace -p 4856 -f trace (full ~3200-line capture available on request) shows a healthy, continuously firing event loop: epoll_pwait2(3, ...), periodic pread64 on /proc/self/statm (fd 6, memory stats), statx() polling on /root/.claude.json and ~/.claude, and heavy inter-thread futex/sched_yield traffic consistent with Bun's thread pool. This rules out a classic deadlock or blocked syscall.
3. The critical finding — stdin is never touched at all:
$ strace -p 4856 -f -e trace=read,poll,epoll_wait,epoll_ctl,ioctl -T # while mashing keys on /dev/pts/14
Over the full trace window, zero occurrences of any of the following:
read(0, ...)ioctl(0, TCGETS, ...)/TIOCGWINSZ/ any ioctl on fd 0epoll_ctl(..., EPOLL_CTL_ADD/MOD, 0, ...)(i.e. fd 0 is never (re-)armed on any epoll instance)
The only read() activity at all is on fd 19, an inotify fd, once. No syscall in the capture ever references fd 0, 1, 2, 8, 9, or 10 (the fds that all point at the new /dev/pts/14), aside from the initial ls//proc snapshot showing they're open.
Kernel stack of the epoll-waiting thread (/proc/4856/task/<tid>/stack) confirms it's parked in a legitimate wait, just never one that includes fd 0:
[<0>] ep_poll+0x496/0x4c0
[<0>] do_epoll_wait+0x58/0xd0
[<0>] do_compat_epoll_pwait.part.0+0x12/0x90
[<0>] __x64_sys_epoll_pwait2+0xc4/0xf0
4. No network activity either (ss -tnp | grep 4856 — empty), so this is not "waiting on an API response."
Root cause hypothesis
This strongly matches a well-known Linux epoll + dup2 interaction rather than an application-level deadlock:
reptyr reattaches a process's terminal by dup2()-ing the new PTY's fd onto the target's existing fd numbers (0/1/2, plus internal libuv/Bun-runtime duplicates such as fds 8/9/10 seen here). Per epoll(7), closing a file descriptor's underlying open file description automatically removes any epoll registrations tied to it — and dup2(new, 0) implicitly closes fd 0's old file description before repointing fd 0 to the new one. The new file description is not automatically re-registered with any epoll instance just because it reuses the same fd number; that requires an explicit fresh epoll_ctl(EPOLL_CTL_ADD, 0, ...) from userspace.
Claude Code's TUI (built on Bun, using an Ink-like raw-stdin-reading input layer) registers its stdin readability interest with the process's internal epoll instance once, at startup. It has no code path that detects "the fd 0 I'm watching just had its underlying file description silently swapped out from under me by an external process" and no periodic self-check that would notice "I haven't gotten a single stdin readable event in N seconds despite being the terminal's foreground process group." So the epoll registration for stdin is silently dropped by the kernel at reptyr time and never restored — the process is "alive but deaf" from that point forward, exactly as observed.
This is likely a generic hazard for any libuv/epoll-based Node/Bun CLI moved between PTYs via reptyr (or any other external dup2-based reattachment, e.g. some tmux/screen recovery paths), not something unique to this codebase. Filing it here because (a) Claude Code is a long-running interactive session people are likely to try to rescue this way when a GUI/VM loses keyboard focus, and (b) unlike a normal CLI, there's no way to recover the session state otherwise — restarting loses conversation context.
Suggested fixes / mitigations
- On
SIGWINCH/SIGCONT, or on any detected terminal resize/reflow event, proactively re-epoll_ctl(EPOLL_CTL_MOD-or-ADD)(or fully re-open/re-wrap) the stdin stream instead of assuming the original registration is still valid. - Add a lightweight liveness heuristic: if stdin is the foreground TTY's controlling fd (verifiable via
TIOCGPGRP) but zero input events have been observed for an unusually long idle period, attempt to re-arm the stdin listener defensively. - At minimum, document this failure mode and/or detect it well enough to print a diagnostic ("stdin appears unresponsive after a terminal change; try restarting Claude Code") rather than hanging silently forever with no user-facing signal.
Additional finding (informational, not the bug)
While investigating, gdb's thread listing revealed the CLI runs on Bun, not Node.js as I'd assumed going in (thread names Bun Pool 0–5, mi-scavenger; plus 6 tokio-rt-worker threads suggesting an embedded Rust/Tokio component). Worth knowing for anyone else debugging via strace/gdb — searches for "Claude Code Node.js stdin" won't find much, since the actual runtime is Bun.
Why I believe this is worth fixing rather than a one-off environment quirk
The Hyper-V keyboard loss that started this was unrelated to Claude Code and out of scope here. But losing a session to a host-level input glitch and then discovering there is no recovery path — reptyr looks like it works (fds are correctly repointed, process survives) but the app never notices — turns a transient host issue into total loss of an in-progress Claude Code session. A resilience fix here would meaningfully help anyone doing remote/VM work who needs to reattach a long-running session.