charmbracelet / charmbracelet/bubbletea

standardRenderer can stay frozen after tea.Exec: stop() does not wait for the render goroutine, so its ticker.Stop() can land after start()'s ticker.Reset() (v1.3.10 and v2.0.9)

Open
#1,778 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Go
Stars
44.9k
Forks
1.3k
PR merge metrics
No merged PRs in 30d

Description

## Summary

After `tea.Exec` (or `ReleaseTerminal`/`RestoreTerminal`) the program can keep running normally — every message delivered, every `View()` computed — while nothing is written to the terminal anymore until the program quits. The window is the duration of the executed command: a command that returns quickly (a no-op, or a real command that fails to start, e.g. `$EDITOR` set to a binary that is not on the PATH) hits it most of the time.

**Affected:** v1.3.10 (latest v1) and v2.0.9 (`charm.land/bubbletea/v2`, current line). The pattern moved from `standard_renderer.go` to `Program.startRenderer`/`stopRenderer` in v2 unchanged. Go 1.26.5, linux/amd64; also reproduced under `-race`.

## Mechanism

v1.3.10, `standard_renderer.go`:

- `stop()` does `r.once.Do(func() { r.done <- struct{}{} })` and returns without waiting for `listen()` to exit.
- `listen()`, on receiving `done`, calls `r.ticker.Stop()` and returns.
- `start()` (called from `RestoreTerminal`) calls `r.ticker.Reset(r.framerate)` and spawns a new `listen()`.

v2.0.9, `tea.go` (`startRenderer` / `stopRenderer`): same shape — `stopRenderer` sends on `p.rendererDone` under `p.once` and does not wait; the goroutine spawned by `startRenderer` calls `p.ticker.Stop()` when it receives; `startRenderer` calls `p.ticker.Reset(framerate)`.

Nothing orders the old goroutine's `ticker.Stop()` against the new `ticker.Reset()`. When `Stop()` runs after `Reset()`, the ticker is stopped for good, the new goroutine blocks on `ticker.C` forever, and nothing flushes again (only that goroutine and the final stop call flush). Instrumenting a local copy of v1.3.10 with prints on both calls gave a 100 % correlation over 15 runs: every frozen run had `Reset()` before the old `Stop()`, every healthy run the reverse.

## Minimal reproduction

`Init` returns `tea.Exec` with a no-op command; afterwards `tea.Tick` bumps a counter every 20 ms, 20 times, then quits; output goes to an in-memory writer, 50 programs in a row. `-settle` makes the no-op command sleep 100 ms before returning (control: gives the old goroutine time to run). `-noexec` (v2 program) skips the exec entirely (control for the detector).

Detection: for v1 a run is frozen when neither `after exec: tick 5` nor `after exec: tick 10` reach the output (the final stop flushes the last frame, so `tick 19` is not a signal). v2's renderer writes cell diffs rather than lines, so for v2 the program counts the erase-to-end-of-screen sequences (`ESC [J`, one per flush) written after the last terminal restore (`ESC [?2004h`); the no-exec control produces ~25, a frozen run produces the final one.

| run | v1.3.10 | v2.0.9 |
|---|---|---|
| `go run .` | 50 of 50 frozen | 6–19 of 50 frozen (varies per run) |
| `go run . -settle` | 0 of 50 | 0 of 50 |
| `go run -race .` | 32 of 50 | 1–19 of 50 |
| `go run -race . -settle` | 0 of 50 | 0 of 50 |
| `go run . -noexec` | — | 0 of 50 |

The `-settle` control shows that letting the old goroutine run before the restart is what makes the difference, consistent with the mechanism above.

## Possible fix

Have the stop wait for the renderer goroutine to exit (e.g. the goroutine closes a `done` channel on return and the stop receives from it after signalling), or move `ticker.Stop()` out of the goroutine into the stop itself after the signal has been received. Either makes `Stop()` happen-before the next `Reset()`.

## Program (v1.3.10)

`go.mod`: `module repro` / `go 1.24` / `require github.com/charmbracelet/bubbletea v1.3.10`

```go
// Minimal reproduction: after tea.Exec with a command that returns immediately,
// the standard renderer may never flush again (bubbletea v1.3.10).
package main

import (
"bytes"
"fmt"
"io"
"os"
"strings"
"sync"
"time"

tea "github.com/charmbracelet/bubbletea"
)

// settle, when set, sleeps after the command so the old renderer goroutine has certainly
// stopped its ticker before RestoreTerminal resets it. This is the control run.
var settle time.Duration

type noop struct{}

func (noop) Run() error { time.Sleep(settle); return nil }
func (noop) SetStdin(io.Reader) {}
func (noop) SetStdout(io.Writer) {}
func (noop) SetStderr(io.Writer) {}

type tick struct{}
type execDone struct{}

type model struct {
n int
after bool
}

func (m model) Init() tea.Cmd { return tea.Exec(noop{}, func(error) tea.Msg { return execDone{} }) }

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case execDone:
m.after = true
return m, tea.Tick(20*time.Millisecond, func(time.Time) tea.Msg { return tick{} })
case tick:
m.n++
if m.n >= 20 {
return m, tea.Quit
}
return m, tea.Tick(20*time.Millisecond, func(time.Time) tea.Msg { return tick{} })
}
return m, nil
}

func (m model) View() string {
if !m.after {
return "before exec\n"
}
return fmt.Sprintf("after exec: tick %d\n", m.n)
}

type lockedBuffer struct {
mu sync.Mutex
b bytes.Buffer
}

func (l *lockedBuffer) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.b.Write(p)
}
func (l *lockedBuffer) String() string { l.mu.Lock(); defer l.mu.Unlock(); return l.b.String() }

func main() {
if len(os.Args) > 1 && os.Args[1] == "-settle" {
settle = 100 * time.Millisecond
}
frozen := 0
const runs = 50
for i := 0; i < runs; i++ {
out := &lockedBuffer{}
p := tea.NewProgram(model{}, tea.WithInput(&bytes.Buffer{}), tea.WithOutput(out), tea.WithoutSignals())
if _, err := p.Run(); err != nil {
panic(err)
}
// The program ran 20 ticks after the exec and updated its view on each one. The
// final stop() flushes the very last frame, so "tick 19" always appears; what a
// dead ticker loses are the frames in between.
drawn := out.String()
if !strings.Contains(drawn, "after exec: tick 5") && !strings.Contains(drawn, "after exec: tick 10") {
frozen++
}
}
fmt.Fprintf(os.Stderr, "frozen after exec: %d of %d runs\n", frozen, runs)
}
```

## Program (v2.0.9)

`go.mod`: `module reprov2` / `go 1.24` / `require charm.land/bubbletea/v2 v2.0.9`. Same program, with the import path, `View() tea.View`/`tea.NewView`, the `-noexec` control and the flush-count detector:

```go
// Minimal reproduction: after tea.Exec with a command that returns immediately,
// the renderer may never flush again (bubbletea v2.0.9, same pattern as v1.3.10).
package main

import (
"bytes"
"fmt"
"io"
"os"
"strings"
"sync"
"time"

tea "charm.land/bubbletea/v2"
)

// settle, when set, sleeps after the command so the old renderer goroutine has certainly
// stopped its ticker before RestoreTerminal resets it. This is the control run.
var settle time.Duration
var noExec bool

type noop struct{}

func (noop) Run() error { time.Sleep(settle); return nil }
func (noop) SetStdin(io.Reader) {}
func (noop) SetStdout(io.Writer) {}
func (noop) SetStderr(io.Writer) {}

type tick struct{}
type execDone struct{}

type model struct {
n int
after bool
}

func (m model) Init() tea.Cmd {
if noExec {
return func() tea.Msg { return execDone{} }
}
return tea.Exec(noop{}, func(error) tea.Msg { return execDone{} })
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case execDone:
m.after = true
return m, tea.Tick(20*time.Millisecond, func(time.Time) tea.Msg { return tick{} })
case tick:
m.n++
if m.n >= 20 {
return m, tea.Quit
}
return m, tea.Tick(20*time.Millisecond, func(time.Time) tea.Msg { return tick{} })
}
return m, nil
}

func (m model) View() tea.View {
if !m.after {
return tea.NewView("before exec\n")
}
return tea.NewView(fmt.Sprintf("after exec: tick %d\n", m.n))
}

type lockedBuffer struct {
mu sync.Mutex
b bytes.Buffer
}

func (l *lockedBuffer) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.b.Write(p)
}
func (l *lockedBuffer) String() string { l.mu.Lock(); defer l.mu.Unlock(); return l.b.String() }

func main() {
for _, a := range os.Args[1:] {
switch a {
case "-settle":
settle = 100 * time.Millisecond
case "-noexec":
noExec = true
}
}
frozen := 0
const runs = 50
for i := 0; i < runs; i++ {
out := &lockedBuffer{}
p := tea.NewProgram(model{}, tea.WithInput(&bytes.Buffer{}), tea.WithOutput(out), tea.WithoutSignals())
if _, err := p.Run(); err != nil {
panic(err)
}
// The program ran 20 ticks after the exec and updated its view on each one. The
// final stop() flushes the very last frame, so "tick 19" always appears; what a
// dead ticker loses are the frames in between.
drawn := out.String()
// v2's renderer writes cell diffs, not lines, so the text is not searched for.
// Every flush still ends with an erase-to-end-of-screen sequence; the frames
// written AFTER the terminal was restored are exactly what a dead ticker loses.
// The no-exec control run shows ~25 of them; a frozen run shows the final one.
after := drawn
if at := strings.LastIndex(drawn, "\x1b[?2004h"); at >= 0 {
after = drawn[at:]
}
if strings.Count(after, "\x1b[J") < 10 {
frozen++
}
}
fmt.Fprintf(os.Stderr, "frozen after exec: %d of %d runs\n", frozen, runs)
}
```

Contributor guide

Open the contributing guide

Research direction

Start with standard_renderer.go for v1 and tea.go's startRenderer/stopRenderer for v2, tracing the renderer goroutine, done signal, ticker.Stop, and ticker.Reset ordering. Run the supplied v1 and v2 minimal reproductions, including the settle and no-exec controls. Done means post-exec ticks continue flushing output reliably, including under -race, without freezing.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
cli
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.