diff --git a/pkg/console/spinner.go b/pkg/console/spinner.go index 8b902efc877..1d8d6adaf2b 100644 --- a/pkg/console/spinner.go +++ b/pkg/console/spinner.go @@ -139,6 +139,16 @@ func (s *SpinnerWrapper) Start() { spinnerLog.Print("Starting spinner") go func() { defer s.wg.Done() + defer func() { + s.mu.Lock() + s.running = false + s.mu.Unlock() + }() + defer func() { + if r := recover(); r != nil { + spinnerLog.Printf("Panic in spinner program (recovered): %v", r) + } + }() _, _ = s.program.Run() }() } diff --git a/pkg/console/spinner_test.go b/pkg/console/spinner_test.go index 1766fd13a97..e3f0ab2ea98 100644 --- a/pkg/console/spinner_test.go +++ b/pkg/console/spinner_test.go @@ -3,9 +3,12 @@ package console import ( + "io" "os" "testing" "time" + + tea "charm.land/bubbletea/v2" ) func TestNewSpinner(t *testing.T) { @@ -200,6 +203,58 @@ func TestSpinnerStopWithoutStart(t *testing.T) { spinner.StopWithMessage("Message") } +type panicInitModel struct{} + +func (panicInitModel) Init() tea.Cmd { + panic("spinner test panic") +} + +func (m panicInitModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m, nil +} + +func (panicInitModel) View() tea.View { return tea.View{} } + +func TestSpinnerStartRecoversFromProgramPanic(t *testing.T) { + newPanicProgram := func() *tea.Program { + return tea.NewProgram( + panicInitModel{}, + tea.WithOutput(io.Discard), + tea.WithoutRenderer(), + tea.WithInput(nil), + ) + } + + spinner := &SpinnerWrapper{ + enabled: true, + program: newPanicProgram(), + } + + spinner.Start() + waitForSpinnerStopped(t, spinner) + + spinner.program = newPanicProgram() + spinner.Start() + waitForSpinnerStopped(t, spinner) +} + +func waitForSpinnerStopped(t *testing.T, spinner *SpinnerWrapper) { + t.Helper() + + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + spinner.mu.Lock() + if !spinner.running { + spinner.mu.Unlock() + return + } + spinner.mu.Unlock() + time.Sleep(10 * time.Millisecond) + } + + t.Fatal("spinner did not reset running state after panic") +} + // TestSpinnerStopBeforeStartRaceCondition tests that calling Stop immediately // after Start (before the goroutine initializes) does not cause a deadlock. // This reproduces the issue from https://github.com/github/gh-aw/issues/XXX