Skip to content

tui: fix shell-mode freeze/crash (unread emulator response pipe + duplicate PTY reader)#2

Open
Anand-rahul wants to merge 4 commits into
shivamstaq:masterfrom
Anand-rahul:fix/shell-mode-tui-deadlock
Open

tui: fix shell-mode freeze/crash (unread emulator response pipe + duplicate PTY reader)#2
Anand-rahul wants to merge 4 commits into
shivamstaq:masterfrom
Anand-rahul:fix/shell-mode-tui-deadlock

Conversation

@Anand-rahul

@Anand-rahul Anand-rahul commented May 27, 2026

Copy link
Copy Markdown

Problem

When entering shell mode in the TUI the app freezes (appearing to crash), particularly with zsh and fish.

Root cause 1 — tui/shell.go: unread vt.Emulator response pipe

vt.NewEmulator creates an unbuffered io.Pipe internally. The emulator writes terminal responses to the write-end of this pipe whenever the shell sends queries such as:

Query Sequence Response
Cursor Position Report \e[6n \e[y;xR
Primary Device Attributes \e[c \e[?62;1;6;22c
Secondary Device Attributes \e[>c \e[>1;10;0c
Mode Report \e[?2004$p \e[?2004;$y
OSC colour queries \e]10;?\a

Because io.Pipe is unbuffered and synchronous, these writes block until something reads the other end. Nothing in shell.go ever calls em.Read(), so every write hangs indefinitely inside emulator.Write().

That call sits directly in the bubbletea Update loop (via shellModel.handleOutput), so the entire TUI deadlocks.

Why zsh/fish specifically: shell.go sets TERM=xterm-256color explicitly. Both zsh (via readline) and fish query cursor position and device attributes on every prompt redraw under a capable TERM, while bash with TERM=dumb skips most capability queries.

Root cause 2 — tui/tui.go: duplicate PTY reader on re-focus

enterShell() called waitForOutput() even when a shell was already active for the same spec. This spawned a second goroutine reading from the same PTY file descriptor concurrently with the existing goroutine already chained in the Update loop, splitting the byte stream between them and garbling the emulator display.

Fix

tui/shell.go: after constructing the emulator, start one background goroutine that drains em.Read() and forwards each chunk to ptmx (the shell's PTY stdin). This unblocks the pipe, eliminates the deadlock, and correctly delivers terminal responses to the shell's readline. The goroutine exits automatically when emulator.Close() seals the pipe with io.EOF.

tui/tui.go: when re-focusing a shell that is already running for the same spec, just switch focus — do not schedule another waitForOutput().

🤖 Generated with Claude Code

Closes #3

Anand-rahul and others added 2 commits May 27, 2026 23:57
vt.NewEmulator creates an unbuffered io.Pipe internally. When the
emulator processes terminal queries emitted by the shell on startup
(cursor position \e[6n, device attributes \e[c/\e[>c, mode reports,
OSC colour queries, …) it writes responses to that pipe. Because the
pipe is synchronous and nobody ever reads from the read-end (e.pr),
every such write blocks indefinitely inside emulator.Write(). That
call happens on the hot path of shellModel.handleOutput, which is
invoked directly from the bubbletea Update loop — so the whole TUI
freezes, appearing to crash.

The problem is especially visible with zsh and fish: both shells send
cursor-position and device-attribute queries on every prompt redraw
when TERM=xterm-256color (which shell.go sets explicitly), whereas
bash with TERM=dumb skips most capability queries.

Fix 1 — tui/shell.go: after constructing the emulator, start a
background goroutine that drains em.Read() and forwards each chunk
to the shell's PTY stdin (ptmx). This unblocks the pipe, prevents
the deadlock, and correctly delivers terminal responses back to the
shell's readline so cursor-positioning works as expected. The goroutine
exits automatically when emulator.Close() seals the write-end of the
pipe with io.EOF.

Fix 2 — tui/tui.go: enterShell() was calling waitForOutput() a second
time when re-focusing a shell that was already active for the same
spec. That spawned a duplicate goroutine reading from the same PTY fd
concurrently with the existing one in the Update-loop chain, splitting
the byte stream between them and garbling the emulator display. When
the shell is already running, simply switch focus — no new reader
needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the TUI spawns a shell for a completed spec it uses the PATH from
the spec's recorded environment, which has binDir prepended so the
consumer's CLI binary is reachable. But t.Cleanup was deleting binDir
unconditionally at test teardown, so by the time the user entered shell
mode the binary was already gone and every command failed with 'command
not found'.

GOTIT_KEEP_HOMEDIR=1 is the existing signal that the TUI is tailing the
run and needs preserved state. Honour it for binDir too: skip the
Cleanup when the flag is set, leaving the binary on disk for as long as
the TUI session lasts (the TUI itself already owns the homeDir cleanup
via model.Cleanup).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Anand-rahul

Copy link
Copy Markdown
Author

Follow-up commit 1b36339: fix graph-harness (binary) not found in the shell.

The shell gets PATH=<binDir>:... from the spec's recorded env, but t.Cleanup was deleting binDir unconditionally at test teardown — before the user ever opens the shell pane. GOTIT_KEEP_HOMEDIR=1 already signals that the TUI needs preserved state; this commit honours it for binDir too.

Anand-rahul and others added 2 commits May 28, 2026 00:15
The embedded shell was spawned from $SHELL, which is the user's login
shell. On systems where the login shell is fish (or zsh with heavy
plugins), this caused two problems:

  * Fish ignores PS1 entirely, so the gotit prompt was never shown.
  * Fish and zsh query terminal capabilities (cursor position, device
    attributes, colour OSC) on every prompt redraw. Even with the
    response-pipe fix, the extra chatter made startup sluggish and
    produced spurious output in the emulator.

For a debug/inspection shell, bash is the right default: it starts in
milliseconds with a bare HOME, honours PS1 without any config file,
and only queries the terminal for features it actually needs.

Priority order: bash (from PATH) → $SHELL → /bin/sh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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.

Shell mode freezes / crashes the TUI and binary is not found inside the shell

1 participant