Describe the bug
It looks as though there is a data race when mouse events are received, when using the cursed renderer (& potentially others). cursedRenderer stores the last rendered View in lastView so onMouse can forward MouseMsg to View.OnMouse. That field is written inside flush, which correctly holds s.mu from start to finish -- including the assignment s.lastView = &view. onMouse, however, reads s.lastView (and dereferences it) without acquiring s.mu.
Setup
Please complete the following information along with version numbers, if applicable.
- OS: n/a
- Shell: n/a
- Terminal Emulator: n/a
- Terminal Multiplexer: n/a
To Reproduce
This test reproduces:
package tea
import (
"fmt"
"io"
"testing"
"time"
)
// mouseRaceModel drives the real Bubble Tea renderer: every Update changes View
// content so flush rewrites lastView; MouseMode is set so mouse handling stays
// wired through cursedRenderer.onMouse (see go switch on MouseMsg).
type mouseRaceModel struct {
i int
}
func (m *mouseRaceModel) Init() Cmd { return nil }
func (m *mouseRaceModel) Update(msg Msg) (Model, Cmd) {
switch msg.(type) {
case MouseClickMsg, MouseMotionMsg, MouseWheelMsg:
m.i++
}
return m, nil
}
func (m *mouseRaceModel) View() View {
return View{
Content: fmt.Sprintf("tick-%d\n", m.i),
MouseMode: MouseModeCellMotion,
}
}
func TestBubbleTea_cursedRenderer_mouseVsFlush(t *testing.T) {
t.Parallel()
// Block the input read loop without feeding bytes (same idea as a PTY that
// has not yet sent data). EOF would tear the program down early.
pr, pw := io.Pipe()
defer func() { _ = pw.Close() }()
m := &mouseRaceModel{}
p := NewProgram(
m,
WithContext(t.Context()),
WithInput(pr),
WithOutput(io.Discard),
WithEnvironment([]string{
"TERM=xterm-256color",
"TERM_PROGRAM=Apple_Terminal",
}),
WithoutSignals(),
WithWindowSize(80, 24),
)
runDone := make(chan struct{})
go func() {
defer close(runDone)
_, _ = p.Run()
}()
// Wait for ticker + first frames (renderer started in Run).
time.Sleep(150 * time.Millisecond)
const iterations = 100
for i := range iterations {
switch i % 4 {
case 0:
p.Send(MouseClickMsg{X: i % 80, Y: i % 24, Button: MouseLeft})
case 1:
p.Send(MouseMotionMsg{X: i % 80, Y: i % 24})
case 2:
p.Send(MouseWheelMsg{X: 0, Y: 0, Button: MouseWheelUp})
default:
p.Send(MouseReleaseMsg{X: i % 80, Y: i % 24, Button: MouseLeft})
}
}
p.Quit()
select {
case <-runDone:
case <-time.After(5 * time.Second):
t.Fatal("program did not exit after Quit")
}
}
Expected behavior
No data races 😄
Additional context
go test -race output
May need to change -count as it's not always going to be 100% of the time.
$ go test -race -count 1 -run TestBubbleTea_cursedRenderer_mouseVsFlush ./...
==================
WARNING: DATA RACE
Read at 0x00c000288050 by goroutine 10:
charm.land/bubbletea/v2.(*cursedRenderer).onMouse()
fork-bubbletea/cursed_renderer.go:767 +0x44
charm.land/bubbletea/v2.(*Program).eventLoop()
fork-bubbletea/tea.go:805 +0x1727
charm.land/bubbletea/v2.(*Program).Run()
fork-bubbletea/tea.go:1144 +0x1479
charm.land/bubbletea/v2.TestBubbleTea_cursedRenderer_mouseVsFlush.func2()
fork-bubbletea/datarace_test.go:63 +0x7b
Previous write at 0x00c000288050 by goroutine 18:
charm.land/bubbletea/v2.(*cursedRenderer).flush()
fork-bubbletea/cursed_renderer.go:573 +0x30af
github.com/charmbracelet/ultraviolet.printString[go.shape.string]()
/home/liam/go/pkg/mod/github.com/charmbracelet/ultraviolet@v0.0.0-20260416155717-489999b90468/styled.go:164 +0xa0a
[...TRUNCATED...]
github.com/charmbracelet/ultraviolet.printString[go.shape.string]()
/home/liam/go/pkg/mod/github.com/charmbracelet/ultraviolet@v0.0.0-20260416155717-489999b90468/styled.go:164 +0xa0a
github.com/charmbracelet/ultraviolet.(*StyledString).Draw()
/home/liam/go/pkg/mod/github.com/charmbracelet/ultraviolet@v0.0.0-20260416155717-489999b90468/styled.go:61 +0x265
[...TRUNCATED...]
github.com/charmbracelet/ultraviolet.(*StyledString).Draw()
/home/liam/go/pkg/mod/github.com/charmbracelet/ultraviolet@v0.0.0-20260416155717-489999b90468/styled.go:54 +0xc4
charm.land/bubbletea/v2.(*cursedRenderer).flush()
fork-bubbletea/cursed_renderer.go:311 +0x890
charm.land/bubbletea/v2.(*Program).startRenderer.func1()
fork-bubbletea/tea.go:1418 +0x66
Goroutine 10 (running) created at:
charm.land/bubbletea/v2.TestBubbleTea_cursedRenderer_mouseVsFlush()
fork-bubbletea/datarace_test.go:61 +0x69c
testing.tRunner()
/usr/local/go/src/testing/testing.go:2036 +0x21c
testing.(*T).Run.gowrap1()
/usr/local/go/src/testing/testing.go:2101 +0x38
Goroutine 18 (running) created at:
charm.land/bubbletea/v2.(*Program).startRenderer()
fork-bubbletea/tea.go:1409 +0x1ab
charm.land/bubbletea/v2.(*Program).Run()
fork-bubbletea/tea.go:1107 +0x11d5
charm.land/bubbletea/v2.TestBubbleTea_cursedRenderer_mouseVsFlush.func2()
fork-bubbletea/datarace_test.go:63 +0x7b
==================
==================
WARNING: DATA RACE
Read at 0x00c0002fc2b0 by goroutine 10:
charm.land/bubbletea/v2.(*cursedRenderer).onMouse()
fork-bubbletea/cursed_renderer.go:767 +0x73
charm.land/bubbletea/v2.(*Program).eventLoop()
fork-bubbletea/tea.go:805 +0x1727
charm.land/bubbletea/v2.(*Program).Run()
fork-bubbletea/tea.go:1144 +0x1479
charm.land/bubbletea/v2.TestBubbleTea_cursedRenderer_mouseVsFlush.func2()
fork-bubbletea/datarace_test.go:63 +0x7b
Previous write at 0x00c0002fc2b0 by goroutine 18:
charm.land/bubbletea/v2.(*cursedRenderer).flush()
fork-bubbletea/cursed_renderer.go:261 +0x104
charm.land/bubbletea/v2.(*Program).startRenderer.func1()
fork-bubbletea/tea.go:1418 +0x66
Goroutine 10 (running) created at:
charm.land/bubbletea/v2.TestBubbleTea_cursedRenderer_mouseVsFlush()
fork-bubbletea/datarace_test.go:61 +0x69c
testing.tRunner()
/usr/local/go/src/testing/testing.go:2036 +0x21c
testing.(*T).Run.gowrap1()
/usr/local/go/src/testing/testing.go:2101 +0x38
Goroutine 18 (running) created at:
charm.land/bubbletea/v2.(*Program).startRenderer()
fork-bubbletea/tea.go:1409 +0x1ab
charm.land/bubbletea/v2.(*Program).Run()
fork-bubbletea/tea.go:1107 +0x11d5
charm.land/bubbletea/v2.TestBubbleTea_cursedRenderer_mouseVsFlush.func2()
fork-bubbletea/datarace_test.go:63 +0x7b
==================
--- FAIL: TestBubbleTea_cursedRenderer_mouseVsFlush (0.15s)
testing.go:1712: race detected during execution of test
Describe the bug
It looks as though there is a data race when mouse events are received, when using the cursed renderer (& potentially others).
cursedRendererstores the last renderedViewinlastViewsoonMousecan forwardMouseMsgtoView.OnMouse. That field is written insideflush, which correctly holdss.mufrom start to finish -- including the assignments.lastView = &view.onMouse, however, readss.lastView(and dereferences it) without acquirings.mu.Setup
Please complete the following information along with version numbers, if applicable.
To Reproduce
This test reproduces:
Expected behavior
No data races 😄
Additional context
go test -race output
May need to change
-countas it's not always going to be 100% of the time.