Skip to content

Commit 6ecb25b

Browse files
authored
Merge pull request #2269 from aheritier/fix/exit-hang-deadlock
Fix process hang on /exit due to bubbletea renderer deadlock
2 parents fd8c73f + f2cedae commit 6ecb25b

File tree

2 files changed

+299
-0
lines changed

2 files changed

+299
-0
lines changed

pkg/tui/tui.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2089,13 +2089,31 @@ func (m *appModel) windowTitle() string {
20892089
return title
20902090
}
20912091

2092+
// exitFunc is the function called by the shutdown safety net when the
2093+
// graceful exit times out. It defaults to os.Exit but can be replaced
2094+
// in tests.
2095+
var exitFunc = os.Exit
2096+
2097+
var shutdownTimeout = 5 * time.Second
2098+
20922099
// cleanupAll cleans up all sessions, editors, and resources.
20932100
func (m *appModel) cleanupAll() {
20942101
m.transcriber.Stop()
20952102
m.closeTranscriptCh()
20962103
for _, ed := range m.editors {
20972104
ed.Cleanup()
20982105
}
2106+
2107+
// Safety net: force-exit if bubbletea's shutdown gets stuck.
2108+
// This can happen when the renderer's flush goroutine blocks on a
2109+
// stdout write (terminal buffer full) while holding the renderer
2110+
// mutex, preventing the event loop from completing the render call
2111+
// that follows tea.Quit.
2112+
go func() {
2113+
time.Sleep(shutdownTimeout)
2114+
slog.Warn("Graceful shutdown timed out, forcing exit")
2115+
exitFunc(0)
2116+
}()
20992117
}
21002118

21012119
// persistedSessionID returns the session-store ID that should be used for

pkg/tui/tui_exit_test.go

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package tui
22

33
import (
4+
"bytes"
45
"reflect"
6+
"sync"
7+
"sync/atomic"
58
"testing"
9+
"time"
610

711
"charm.land/bubbles/v2/help"
812
"charm.land/bubbles/v2/key"
@@ -143,8 +147,19 @@ func newTestModel() (*appModel, *mockEditor) {
143147
return m, ed
144148
}
145149

150+
// neutralizeExitFunc replaces the package-level exitFunc with a no-op for the
151+
// duration of the test so that the safety-net goroutine spawned by cleanupAll
152+
// doesn't call os.Exit.
153+
func neutralizeExitFunc(t *testing.T) {
154+
t.Helper()
155+
orig := exitFunc
156+
exitFunc = func(int) {}
157+
t.Cleanup(func() { exitFunc = orig })
158+
}
159+
146160
func TestExitSessionMsg_ExitsImmediately(t *testing.T) {
147161
t.Parallel()
162+
neutralizeExitFunc(t)
148163

149164
m, ed := newTestModel()
150165

@@ -158,6 +173,7 @@ func TestExitSessionMsg_ExitsImmediately(t *testing.T) {
158173

159174
func TestExitConfirmedMsg_ExitsImmediately(t *testing.T) {
160175
t.Parallel()
176+
neutralizeExitFunc(t)
161177

162178
m, ed := newTestModel()
163179

@@ -168,3 +184,268 @@ func TestExitConfirmedMsg_ExitsImmediately(t *testing.T) {
168184
msgs := collectMsgs(cmd)
169185
assert.True(t, hasMsg[tea.QuitMsg](msgs), "should produce tea.QuitMsg")
170186
}
187+
188+
// blockingWriter is an io.Writer whose Write blocks until unblocked.
189+
type blockingWriter struct {
190+
mu sync.Mutex
191+
blocked chan struct{} // closed once the first Write starts blocking
192+
gate chan struct{} // Write blocks until this is closed
193+
}
194+
195+
func newBlockingWriter() *blockingWriter {
196+
return &blockingWriter{
197+
blocked: make(chan struct{}),
198+
gate: make(chan struct{}),
199+
}
200+
}
201+
202+
func (w *blockingWriter) Write(p []byte) (int, error) {
203+
w.mu.Lock()
204+
select {
205+
case <-w.blocked:
206+
default:
207+
close(w.blocked)
208+
}
209+
gate := w.gate
210+
w.mu.Unlock()
211+
212+
<-gate
213+
return len(p), nil
214+
}
215+
216+
// reblock installs a new gate so that subsequent writes block again.
217+
func (w *blockingWriter) reblock() {
218+
w.mu.Lock()
219+
w.gate = make(chan struct{})
220+
w.mu.Unlock()
221+
}
222+
223+
// unblock releases all pending and future writes.
224+
func (w *blockingWriter) unblock() {
225+
w.mu.Lock()
226+
select {
227+
case <-w.gate:
228+
default:
229+
close(w.gate)
230+
}
231+
w.mu.Unlock()
232+
}
233+
234+
// quitModel is a minimal bubbletea model that requests alt-screen output
235+
// and quits in response to a trigger message. An optional onQuit callback
236+
// runs inside Update before tea.Quit is returned.
237+
type quitModel struct {
238+
onQuit func()
239+
}
240+
241+
type triggerQuitMsg struct{}
242+
243+
func (m *quitModel) Init() tea.Cmd { return nil }
244+
245+
func (m *quitModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
246+
if _, ok := msg.(triggerQuitMsg); ok {
247+
if m.onQuit != nil {
248+
m.onQuit()
249+
}
250+
return m, tea.Quit
251+
}
252+
return m, nil
253+
}
254+
255+
func (m *quitModel) View() tea.View {
256+
v := tea.NewView("hello world")
257+
v.AltScreen = true
258+
return v
259+
}
260+
261+
// initBlockingBubbletea creates a bubbletea program whose output writer
262+
// blocks. It lets the initial render complete (so the event loop is ready)
263+
// then re-blocks the writer. Returns the program and the writer.
264+
func initBlockingBubbletea(t *testing.T, model tea.Model) (*tea.Program, *blockingWriter, <-chan struct{}) {
265+
t.Helper()
266+
267+
w := newBlockingWriter()
268+
var in bytes.Buffer
269+
270+
p := tea.NewProgram(model,
271+
tea.WithContext(t.Context()),
272+
tea.WithInput(&in),
273+
tea.WithOutput(w),
274+
)
275+
276+
runDone := make(chan struct{})
277+
go func() {
278+
defer close(runDone)
279+
_, _ = p.Run()
280+
}()
281+
282+
// Wait for the initial render to hit the blocking writer.
283+
select {
284+
case <-w.blocked:
285+
case <-time.After(5 * time.Second):
286+
t.Fatal("timed out waiting for initial write to block")
287+
}
288+
289+
// Let the initial writes through so the event loop starts.
290+
w.unblock()
291+
time.Sleep(200 * time.Millisecond)
292+
293+
// Re-block so the next renderer flush will stall.
294+
w.reblock()
295+
296+
return p, w, runDone
297+
}
298+
299+
// TestCleanupAll_SpawnsSafetyNet verifies that cleanupAll spawns a goroutine
300+
// that calls exitFunc after shutdownTimeout. Without the safety net, the
301+
// process would hang when bubbletea's renderer deadlocks on exit.
302+
func TestCleanupAll_SpawnsSafetyNet(t *testing.T) {
303+
origTimeout := shutdownTimeout
304+
origExitFunc := exitFunc
305+
t.Cleanup(func() {
306+
shutdownTimeout = origTimeout
307+
exitFunc = origExitFunc
308+
})
309+
shutdownTimeout = 200 * time.Millisecond
310+
311+
exitDone := make(chan int, 1)
312+
exitFunc = func(code int) {
313+
exitDone <- code
314+
}
315+
316+
m, _ := newTestModel()
317+
m.cleanupAll()
318+
319+
select {
320+
case code := <-exitDone:
321+
assert.Equal(t, 0, code)
322+
case <-time.After(shutdownTimeout + time.Second):
323+
t.Fatal("exitFunc was not called — safety net is missing from cleanupAll")
324+
}
325+
}
326+
327+
// TestExitDeadlock_BlockedStdout proves that bubbletea's p.Run() hangs when
328+
// stdout blocks during the final render after tea.Quit. This is the underlying
329+
// bug that the safety net in cleanupAll works around.
330+
func TestExitDeadlock_BlockedStdout(t *testing.T) {
331+
t.Parallel()
332+
333+
model := &quitModel{}
334+
p, w, runDone := initBlockingBubbletea(t, model)
335+
336+
// Trigger quit — the event loop will deadlock trying to render.
337+
p.Send(triggerQuitMsg{})
338+
339+
// Verify that p.Run() does NOT return within a reasonable window.
340+
select {
341+
case <-runDone:
342+
t.Skip("bubbletea returned without deadlocking; upstream fix may have landed")
343+
case <-time.After(2 * time.Second):
344+
// Expected: p.Run() is stuck.
345+
}
346+
347+
// Unblock everything to let goroutines drain.
348+
w.unblock()
349+
}
350+
351+
// TestExitSafetyNet_BlockedStdout verifies that when bubbletea's renderer
352+
// is stuck writing to stdout (terminal buffer full), the shutdown safety net
353+
// forces the process to exit.
354+
//
355+
// Background: bubbletea's cursed renderer holds a mutex during io.Copy to
356+
// stdout. If stdout blocks (e.g. full PTY buffer), the event loop's final
357+
// render call after tea.Quit deadlocks on the same mutex. Without the safety
358+
// net the process hangs forever.
359+
func TestExitSafetyNet_BlockedStdout(t *testing.T) {
360+
t.Parallel()
361+
362+
const safetyNetTimeout = 500 * time.Millisecond
363+
var exitCalled atomic.Bool
364+
exitDone := make(chan int, 1)
365+
testExitFunc := func(code int) {
366+
exitCalled.Store(true)
367+
exitDone <- code
368+
}
369+
370+
model := &quitModel{
371+
onQuit: func() {
372+
go func() {
373+
time.Sleep(safetyNetTimeout)
374+
testExitFunc(0)
375+
}()
376+
},
377+
}
378+
p, w, runDone := initBlockingBubbletea(t, model)
379+
defer w.unblock()
380+
381+
// Trigger quit — the model's onQuit starts the safety net.
382+
p.Send(triggerQuitMsg{})
383+
384+
select {
385+
case code := <-exitDone:
386+
assert.True(t, exitCalled.Load())
387+
assert.Equal(t, 0, code)
388+
case <-runDone:
389+
// p.Run() returned on its own — also acceptable.
390+
case <-time.After(safetyNetTimeout + 2*time.Second):
391+
t.Fatal("neither p.Run() returned nor safety-net exitFunc fired within the deadline")
392+
}
393+
}
394+
395+
// TestExitSafetyNet_GracefulShutdown verifies that when bubbletea shuts down
396+
// normally (no blocked stdout), p.Run() returns before the safety net fires.
397+
func TestExitSafetyNet_GracefulShutdown(t *testing.T) {
398+
t.Parallel()
399+
400+
const safetyNetTimeout = 2 * time.Second
401+
var exitCalled atomic.Bool
402+
testExitFunc := func(int) {
403+
exitCalled.Store(true)
404+
}
405+
406+
var mu sync.Mutex
407+
cleanupCalled := false
408+
409+
model := &quitModel{
410+
onQuit: func() {
411+
mu.Lock()
412+
cleanupCalled = true
413+
mu.Unlock()
414+
go func() {
415+
time.Sleep(safetyNetTimeout)
416+
testExitFunc(0)
417+
}()
418+
},
419+
}
420+
var buf bytes.Buffer
421+
var in bytes.Buffer
422+
423+
p := tea.NewProgram(model,
424+
tea.WithContext(t.Context()),
425+
tea.WithInput(&in),
426+
tea.WithOutput(&buf),
427+
)
428+
429+
runDone := make(chan error, 1)
430+
go func() {
431+
_, err := p.Run()
432+
runDone <- err
433+
}()
434+
435+
// Give bubbletea time to initialise.
436+
time.Sleep(200 * time.Millisecond)
437+
438+
p.Send(triggerQuitMsg{})
439+
440+
select {
441+
case err := <-runDone:
442+
require.NoError(t, err)
443+
case <-time.After(3 * time.Second):
444+
t.Fatal("p.Run() did not return within deadline for graceful shutdown")
445+
}
446+
447+
mu.Lock()
448+
assert.True(t, cleanupCalled, "cleanup should have been called")
449+
mu.Unlock()
450+
assert.False(t, exitCalled.Load(), "exitFunc should NOT fire during graceful shutdown")
451+
}

0 commit comments

Comments
 (0)