fix(tui): restore terminal on Ctrl-C when bubbletea shutdown stalls#2842
Merged
Conversation
…seTerminal on timeout
…out/exitFunc
This fixes two issues found during code review:
1. Safety-net deadlock: program.ReleaseTerminal() was called inline in the
timeout branch but acquires the renderer mutex held by a wedged
stdout-write goroutine. Now calls ReleaseTerminal in its own goroutine
without waiting, allowing the safety net to proceed and force-exit.
Adds TestCleanupAll_WedgedStdoutFiresExit regression test.
2. Data-race in package globals: shutdownTimeout and exitFunc were read
inside the safety-net goroutine while tests mutate/restore them,
causing a data race. Now snapshots them into locals synchronously
in cleanupAll before spawning the safety-net goroutine.
Updates TestCleanupAll_GracefulShutdownSkipsExit to use p.Send(syncMsg{})
instead of time.Sleep for proper synchronization.
cleanupAll is invoked from several message handlers (ExitSessionMsg, ExitConfirmedMsg, ExitAfterFirstResponseMsg) and could be called more than once on the same model. Each call snapshotted exitFunc and armed its own deadline, so multiple safety nets could race to call exit(0) - harmless in production where exit is os.Exit, fatal in tests where exit is a channel close. Nil out m.program after capturing it so subsequent calls are no-ops. Also send triggerQuitMsg in TestCleanupAll_WedgedStdoutFiresExit so it actually drives the renderer into the final-flush deadlock path it claims to test, and add TestCleanupAll_MultipleCallsFireExitOnce as a regression test for the multi-call guard.
31e3d56 to
a184f1e
Compare
rumpl
approved these changes
May 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
When the user hits Ctrl-C in the TUI and confirms the exit, the terminal is sometimes left in a broken state — raw mode still on, alt-screen still active, mouse tracking on, cursor hidden, etc.
Root cause
The shutdown safety net in
appModel.cleanupAllwas implemented as:It exists because bubbletea's renderer can deadlock during shutdown when stdout is wedged (the final flush after
tea.Quitre-acquires the renderer mutex still held by a blocked previous flush —TestExitDeadlock_BlockedStdoutproves it). But:os.Exit(0)runs no defers, so the terminal stays in raw mode + alt-screen + mouse tracking + hidden cursor + kitty keyboard flags + …Fix
Race
program.Wait()against the deadline so a clean shutdown skips the force-exit path entirely:Program.ReleaseTerminal()is bubbletea's own terminal-restore primitive (out of raw mode, alt-screen exit, cursor on, mouse off, etc.) and is independent from the regular shutdown path. It can itself deadlock on the wedged renderer mutex, so it's run in a fire-and-forget goroutine — best-effort terminal restore, then exit no matter what.Side fix:
shutdownTimeoutandexitFuncare package globals that tests mutate; they're now snapshotted synchronously insidecleanupAllso the safety-net goroutine can't race witht.Cleanuprestoring the originals.Tests
New tests in
pkg/tui/tui_exit_test.go, all passing under-race:TestCleanupAll_SpawnsSafetyNet— stuck program →exitFuncfires.TestCleanupAll_GracefulShutdownSkipsExit— clean shutdown →exitFuncdoes NOT fire.TestCleanupAll_NilProgramIsSafe— no program → no safety-net spawned.TestCleanupAll_WedgedStdoutFiresExit— realistic regression test: bubbletea is stuck on a wedged stdout,Wait()never returns,ReleaseTerminal()would itself deadlock —exitFuncmust still fire. Verified to fail under the previous inlineReleaseTerminalcall.Validation
task lint✅task test✅go test ./pkg/tui -race✅