#1778·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)

Author: matraketCreated Aug 24, 2026Updated Aug 24, 2026

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

// 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:

// 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)
}