Skip to content

bug: data race between received mouse events and using cursed renderer #1690

@lrstanley

Description

@lrstanley

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions