Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 46 additions & 10 deletions pkg/console/spinner.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
// # Implementation
//
// This spinner uses idiomatic Bubble Tea patterns with tea.NewProgram() for proper
// message handling and rendering pipeline integration. This approach:
// - Simplified state management (single enabled flag, no running state)
// - No mutex required (Bubble Tea handles concurrency via message passing)
// - Leverages Bubble Tea's framerate optimization
// - Provides standard architecture consistent with other console components
// message handling and rendering pipeline integration. It includes thread-safe
// lifecycle management:
// - Thread-safe start/stop tracking with mutex protection
// - Safe to call Stop/StopWithMessage before Start (no-op or message-only)
// - Prevents multiple concurrent Start calls
// - No deadlock when stopping before goroutine initializes
// - Leverages Bubble Tea's message passing for updates
//
// # Usage Example
//
Expand All @@ -37,6 +39,7 @@ package console
import (
"fmt"
"os"
"sync"

"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
Expand Down Expand Up @@ -77,6 +80,8 @@ func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
type SpinnerWrapper struct {
program *tea.Program
enabled bool
running bool
mu sync.Mutex
}

// NewSpinner creates a new spinner with the given message using MiniDot style.
Expand All @@ -97,27 +102,58 @@ func NewSpinner(message string) *SpinnerWrapper {

func (s *SpinnerWrapper) Start() {
if s.enabled && s.program != nil {
s.mu.Lock()
if s.running {
s.mu.Unlock()
return
}
s.running = true
s.mu.Unlock()
go func() { _, _ = s.program.Run() }()
}
}

func (s *SpinnerWrapper) Stop() {
if s.enabled && s.program != nil {
s.program.Quit()
fmt.Fprint(os.Stderr, "\r\033[K")
s.mu.Lock()
if s.running {
s.running = false
s.mu.Unlock()
s.program.Quit()
fmt.Fprint(os.Stderr, "\r\033[K")
} else {
s.mu.Unlock()
}
}
}

func (s *SpinnerWrapper) StopWithMessage(msg string) {
if s.enabled && s.program != nil {
s.program.Quit()
fmt.Fprintf(os.Stderr, "\r\033[K%s\n", msg)
s.mu.Lock()
if s.running {
s.running = false
s.mu.Unlock()
s.program.Quit()
fmt.Fprintf(os.Stderr, "\r\033[K%s\n", msg)
} else {
s.mu.Unlock()
// Still print the message even if spinner wasn't running
fmt.Fprintf(os.Stderr, "%s\n", msg)
}
} else if msg != "" {
// If spinner is disabled, still print the message for user feedback
fmt.Fprintf(os.Stderr, "%s\n", msg)
}
}

func (s *SpinnerWrapper) UpdateMessage(message string) {
if s.enabled && s.program != nil {
s.program.Send(updateMessageMsg(message))
s.mu.Lock()
running := s.running
s.mu.Unlock()
if running {
s.program.Send(updateMessageMsg(message))
}
}
}

Expand Down
22 changes: 22 additions & 0 deletions pkg/console/spinner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,25 @@ func TestSpinnerStopWithoutStart(t *testing.T) {
spinner.Stop()
spinner.StopWithMessage("Message")
}

// 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/githubnext/gh-aw/issues/XXX
func TestSpinnerStopBeforeStartRaceCondition(t *testing.T) {
// Create a spinner that will be enabled (we need to simulate TTY for this test)
// Since we can't control TTY in tests, we'll test the mutex logic directly
spinner := NewSpinner("Test message")

// Even if spinner is disabled in test environment, test the logic
// by verifying that multiple Start/Stop cycles don't panic
for i := 0; i < 100; i++ {
spinner.Start()
spinner.Stop()
}

// Also test StopWithMessage immediately after Start
for i := 0; i < 100; i++ {
spinner.Start()
spinner.StopWithMessage("Done")
}
}
Loading