11package tui
22
33import (
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+
146160func 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
159174func 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