bubbletea icon indicating copy to clipboard operation
bubbletea copied to clipboard

progress - Is it racy or am i using it wrong?

Open fasmide opened this issue 6 months ago • 1 comments

Describe the bug Having multiple go routines sending events to a tea app, using tea.Program.Send() - sometimes triggers the race detector.

Setup Fairly standard ubuntu install

  • Ubuntu
  • foot

To Reproduce

Based on progress-animated, modified with two progress bars, and two go routines that simultaneously updates state

package main

import (
	"fmt"
	"os"
	"time"

	"github.com/charmbracelet/bubbles/progress"
	tea "github.com/charmbracelet/bubbletea"
)

func main() {
	prog := progress.New(progress.WithDefaultGradient())
	prog2 := progress.New(progress.WithScaledGradient("#000000", "#00ff00"))

	m := &model{
		progress:  &prog,
		progress2: &prog2,
	}

	p := tea.NewProgram(m)

	go func() {
		i := 0
		for {
			i++
			p.Send(tickMsg{})
			time.Sleep(time.Second)

		}
	}()

	go func() {
		i := 0
		for {
			i++
			p.Send(tickMsg{})
			time.Sleep(time.Second)

		}
	}()

	if _, err := p.Run(); err != nil {
		fmt.Println("Oh no!", err)
		os.Exit(1)
	}
}

type tickMsg struct{}

type model struct {
	progress  *progress.Model
	progress2 *progress.Model
}

func (m *model) Init() tea.Cmd {
	return nil
}

func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

	switch msg := msg.(type) {
	case tea.KeyMsg:
		return m, tea.Quit

	case tickMsg:
		if m.progress.Percent() == 1.0 {
			return m, tea.Quit
		}

		return m, tea.Batch(
			m.progress.IncrPercent(0.1),
			m.progress2.IncrPercent(0.1),
		)

	// FrameMsg is sent when the progress bar wants to animate itself
	case progress.FrameMsg:
		progressModel, cmd := m.progress.Update(msg)
		if cmd != nil {
			p := progressModel.(progress.Model)
			m.progress = &p
		}

		progressModel2, cmd2 := m.progress2.Update(msg)
		if cmd2 != nil {
			p2 := progressModel2.(progress.Model)
			m.progress2 = &p2
		}
		return m, tea.Batch(cmd, cmd2)

	default:
		return m, nil
	}
}

func (m *model) View() string {
	return "\n" +
		m.progress.View() + "\n\n" +
		m.progress2.View() + "\n\n" +
		"Press any key to quit"
}

go.mod

module asd

go 1.24.2

require (
	github.com/charmbracelet/bubbles v0.21.0
	github.com/charmbracelet/bubbletea v1.3.5
)

require (
	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
	github.com/charmbracelet/colorprofile v0.3.1 // indirect
	github.com/charmbracelet/harmonica v0.2.0 // indirect
	github.com/charmbracelet/lipgloss v1.1.0 // indirect
	github.com/charmbracelet/x/ansi v0.9.2 // indirect
	github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
	github.com/charmbracelet/x/term v0.2.1 // indirect
	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/mattn/go-localereader v0.0.1 // indirect
	github.com/mattn/go-runewidth v0.0.16 // indirect
	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
	github.com/muesli/cancelreader v0.2.2 // indirect
	github.com/muesli/termenv v0.16.0 // indirect
	github.com/rivo/uniseg v0.4.7 // indirect
	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
	golang.org/x/sync v0.15.0 // indirect
	golang.org/x/sys v0.33.0 // indirect
	golang.org/x/text v0.26.0 // indirect
)

Running the above, triggers the race detector from time to time, i would say 1/2 times on my machine.

https://github.com/user-attachments/assets/14a44d81-99da-41e2-9a3b-f91155e35888

==================
WARNING: DATA RACE
Read at 0x00c000367c10 by goroutine 83:
  github.com/charmbracelet/bubbles/progress.(*Model).SetPercent.(*Model).nextFrame.func1()
      /home/fas/go/pkg/mod/github.com/charmbracelet/[email protected]/progress/progress.go:289 +0x4c
  github.com/charmbracelet/bubbletea.Tick.func1()
      /home/fas/go/pkg/mod/github.com/charmbracelet/[email protected]/commands.go:156 +0xe8
  github.com/charmbracelet/bubbletea.(*Program).handleCommands.func1.1()
      /home/fas/go/pkg/mod/github.com/charmbracelet/[email protected]/tea.go:365 +0xd9

Previous write at 0x00c000367c10 by main goroutine:
  github.com/charmbracelet/bubbles/progress.(*Model).SetPercent()
      /home/fas/go/pkg/mod/github.com/charmbracelet/[email protected]/progress/progress.go:252 +0xdc
  github.com/charmbracelet/bubbles/progress.(*Model).IncrPercent()
      /home/fas/go/pkg/mod/github.com/charmbracelet/[email protected]/progress/progress.go:261 +0x754
  main.(*model).Update()
      /home/fas/github.com/charmbracelet/bubbletea/examples/progress-animated/main.go:73 +0x672
  github.com/charmbracelet/bubbletea.(*Program).eventLoop()
      /home/fas/go/pkg/mod/github.com/charmbracelet/[email protected]/tea.go:525 +0xb53
  github.com/charmbracelet/bubbletea.(*Program).Run()
      /home/fas/go/pkg/mod/github.com/charmbracelet/[email protected]/tea.go:679 +0x1590
  main.main()
      /home/fas/github.com/charmbracelet/bubbletea/examples/progress-animated/main.go:43 +0x4b7

Goroutine 83 (running) created at:
  github.com/charmbracelet/bubbletea.(*Program).handleCommands.func1()
      /home/fas/go/pkg/mod/github.com/charmbracelet/[email protected]/tea.go:355 +0x24d
==================
==================
WARNING: DATA RACE
Read at 0x00c000372710 by goroutine 82:
  github.com/charmbracelet/bubbles/progress.(*Model).SetPercent.(*Model).nextFrame.func1()
      /home/fas/go/pkg/mod/github.com/charmbracelet/[email protected]/progress/progress.go:289 +0x4c
  github.com/charmbracelet/bubbletea.Tick.func1()
      /home/fas/go/pkg/mod/github.com/charmbracelet/[email protected]/commands.go:156 +0xe8
  github.com/charmbracelet/bubbletea.(*Program).handleCommands.func1.1()
      /home/fas/go/pkg/mod/github.com/charmbracelet/[email protected]/tea.go:365 +0xd9

Previous write at 0x00c000372710 by main goroutine:
  github.com/charmbracelet/bubbles/progress.(*Model).SetPercent()
      /home/fas/go/pkg/mod/github.com/charmbracelet/[email protected]/progress/progress.go:252 +0xdc
  github.com/charmbracelet/bubbles/progress.(*Model).IncrPercent()
      /home/fas/go/pkg/mod/github.com/charmbracelet/[email protected]/progress/progress.go:261 +0x6d4
  main.(*model).Update()
      /home/fas/github.com/charmbracelet/bubbletea/examples/progress-animated/main.go:72 +0x650
  github.com/charmbracelet/bubbletea.(*Program).eventLoop()
      /home/fas/go/pkg/mod/github.com/charmbracelet/[email protected]/tea.go:525 +0xb53
  github.com/charmbracelet/bubbletea.(*Program).Run()
      /home/fas/go/pkg/mod/github.com/charmbracelet/[email protected]/tea.go:679 +0x1590
  main.main()
      /home/fas/github.com/charmbracelet/bubbletea/examples/progress-animated/main.go:43 +0x4b7

Goroutine 82 (running) created at:
  github.com/charmbracelet/bubbletea.(*Program).handleCommands.func1()
      /home/fas/go/pkg/mod/github.com/charmbracelet/[email protected]/tea.go:355 +0x24d
==================
Found 2 data race(s)
exit status 66

fasmide avatar Jun 13 '25 11:06 fasmide

It might have something to do with the pointer receivers - this for example never triggers the race detector

package main

import (
	"fmt"
	"os"
	"time"

	"github.com/charmbracelet/bubbles/progress"
	tea "github.com/charmbracelet/bubbletea"
)

func main() {
	m := &model{
		progress:  progress.New(progress.WithDefaultGradient()),
		progress2: progress.New(progress.WithScaledGradient("#000000", "#00ff00")),
	}

	p := tea.NewProgram(m)

	go func() {
		for {
			p.Send(tickMsg{})
			time.Sleep(time.Second)

		}
	}()

	go func() {
		for {
			p.Send(tickMsg{})
			time.Sleep(time.Second)

		}
	}()

	if _, err := p.Run(); err != nil {
		fmt.Println("Oh no!", err)
		os.Exit(1)
	}
}

type tickMsg struct{}

type model struct {
	progress  progress.Model
	progress2 progress.Model
}

func (m model) Init() tea.Cmd {
	return nil
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyMsg:
		return m, tea.Quit

	case tickMsg:
		if m.progress.Percent() == 1.0 {
			return m, tea.Quit
		}

		return m, tea.Batch(
			m.progress.IncrPercent(0.1),
			m.progress2.IncrPercent(0.15),
		)

	// FrameMsg is sent when the progress bar wants to animate itself
	case progress.FrameMsg:
		progressModel, cmd := m.progress.Update(msg)
		if cmd != nil {
			p := progressModel.(progress.Model)
			m.progress = p
		}

		progressModel2, cmd2 := m.progress2.Update(msg)
		if cmd2 != nil {
			p := progressModel2.(progress.Model)
			m.progress2 = p
		}
		return m, tea.Batch(cmd, cmd2)

	default:
		return m, nil
	}
}

func (m model) View() string {
	return "\n" +
		m.progress.View() + "\n\n" +
		m.progress2.View() + "\n\n" +
		"Press any key to quit"
}

fasmide avatar Jun 13 '25 11:06 fasmide