charmbracelet / charmbracelet/bubbletea
v2: cursed_renderer emits a one-tick stale frame on resize (race between resize() and the 60Hz ticker flush())
- Dominant language
- Go
- Stars
- 44.9k
- Forks
- 1.3k
- PR merge metrics
- No merged PRs in 30d
Description
## Summary
On `WindowSizeMsg`, the program calls `renderer.resize()` (which clears/resizes
the screen buffer and arms `pendingErase`) and, shortly after in the same
event-loop iteration, `p.render(model)` (which stores the new `tea.View` in
`s.view`). The renderer's 60 Hz ticker goroutine calls `renderer.flush(false)`
independently. If a tick fires **between** `resize()` and `render()`, `flush()`
proceeds with the armed `pendingErase` and the *new* `frameArea` but reads the
**previous** `s.view`, and draws the previous frame's content into the
freshly-cleared, newly-sized cell buffer. The next tick then draws the correct
frame as a diff on top. The user sees the old frame at the new terminal size for
~16 ms — a transient "ghost"/duplicate frame.
## Reproducer
Minimal standalone program (`repro.go`):
```go
package main
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
)
type model struct{ w, h int }
func (model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.w, m.h = msg.Width, msg.Height
case tea.KeyPressMsg:
if msg.String() == "q" {
return m, tea.Quit
}
}
return m, nil
}
// View lays out 5 columns that reflow with width, so the stale pre-resize View
// is visually distinct from the correct post-resize View.
func (m model) View() tea.View {
var v tea.View
v.AltScreen = true
if m.w == 0 {
v.SetContent("waiting for size...\n[q] quit")
return v
}
nameW := m.w * 3 / 8
srcW := m.w * 2 / 8
sizeW := m.w * 1 / 8
if nameW < 6 {
nameW = 6
}
if srcW < 6 {
srcW = 6
}
if sizeW < 8 {
sizeW = 8
}
pad := func(s string, n int) string {
if len(s) >= n {
return s[:n]
}
return s + strings.Repeat(" ", n-len(s))
}
header := fmt.Sprintf(" %-*s %-*s %-*s %-*s %s", 6, "TYPE", nameW, "NAME", srcW, "SOURCE", sizeW, "SIZE", "CREATED")
row := fmt.Sprintf(" %-6s %-*s %-*s %-*s %s", "rootfs", pad("debian-minimal", nameW), pad("debian", srcW), pad("58.3 MiB", sizeW), "2026-08-25")
var b strings.Builder
fmt.Fprintf(&b, "terminal: %dx%d\n", m.w, m.h)
b.WriteString(header)
b.WriteByte('\n')
for i := 0; i < 3; i++ {
b.WriteString(row)
b.WriteByte('\n')
}
b.WriteString("[q] quit")
v.SetContent(b.String())
return v
}
func main() {
_, _ = tea.NewProgram(model{}).Run()
}
```
### How to make the ghost visible
The ghost is one frame (~16 ms) and timing-dependent. To capture it
deterministically, run the program under a PTY, trigger a resize, and dump the
byte stream with timestamps. The stale frame appears as a `2J` clear sized for
the *new* dimensions but containing the *old* column layout, emitted ~one tick
before the correct frame.
A capture harness (`probe.go`, uses `github.com/creack/pty`):
```go
package main
import (
"bytes"
"fmt"
"os"
"os/exec"
"syscall"
"time"
"github.com/creack/pty"
)
func main() {
cmd := exec.Command("go", "run", ".")
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
ptmx, err := pty.Start(cmd)
if err != nil {
fmt.Println("err:", err)
os.Exit(1)
}
defer ptmx.Close()
pty.Setsize(ptmx, &pty.Winsize{Rows: 20, Cols: 100})
cmd.Process.Signal(syscall.SIGWINCH)
type chunk struct {
t time.Time
buf []byte
}
chunks := make(chan chunk, 1024)
go func() {
var buf [4096]byte
for {
n, err := ptmx.Read(buf[:])
if n > 0 {
chunks <- chunk{time.Now(), append([]byte(nil), buf[:n]...)}
}
if err != nil {
close(chunks)
return
}
}
}()
time.Sleep(2500 * time.Millisecond)
t0 := time.Now()
pty.Setsize(ptmx, &pty.Winsize{Rows: 20, Cols: 140})
cmd.Process.Signal(syscall.SIGWINCH)
all := []chunk{}
done := make(chan struct{})
go func() {
for c := range chunks {
all = append(all, c)
}
close(done)
}()
time.Sleep(500 * time.Millisecond)
ptmx.WriteString("q")
time.Sleep(300 * time.Millisecond)
cmd.Process.Signal(syscall.SIGTERM)
<-done
for _, c := range all {
dt := c.t.Sub(t0).Milliseconds()
has2J := bytes.Contains(c.buf, []byte{0x1b, '[', '2', 'J'})
fmt.Printf("t=%+5dms bytes=%4d 2J=%v | %q\n", dt, len(c.buf), has2J, string(c.buf))
}
}
```
### Observed (project artifacts panel, bubbletea v2.0.9)
A real app with a heavier `View()` (a `bubbles/table` that recomputes column
widths on resize) reproduces the race more readily than the minimal program
above. Captured on a resize from 100x30 to 120x40:
```
t= -830ms ... 2J=true layout: NAME[29X] SOURCE[17X] (100 cols — correct, initial)
t= +4ms ... 2J=true layout: NAME[29X] SOURCE[17X] <- GHOST: OLD layout
but preceded by \x1b[30d (cursor to row 30) which is only
valid at the NEW height of 40 → stale view at new size
t= +19ms ... 2J=false layout: NAME[36X] SOURCE[24X] (120 cols — correct, drawn as a diff over the ghost)
```
The `t=+4ms` frame is the racing ticker: `resize()` already set the new size
(hence the `\x1b[30d`/`[J` clear sequence sized for height 40) but `s.view` was
still the 100-column frame, so the old column widths are emitted at the new
size. `t=+19ms` is `render()`'s new view drawn as a diff.
## Root cause (code walkthrough, v2.0.9)
Two goroutines share `cursedRenderer.s.mu`:
1. **Event loop** (`tea.go`), on `WindowSizeMsg`:
- `tea.go:858`: `p.renderer.resize(msg.Width, msg.Height)`
- `cursed_renderer.go:675-679`: under `s.mu`, `s.scr.Erase()`; sets
`s.width,s.height = w,h`; `s.scr.Resize(...)`; `s.pendingErase = true`;
`s.mu.Unlock()`. **Does not touch `s.view`.**
- `tea.go:888`: `p.render(model)` → `cursed_renderer.go:627-631`: under
`s.mu`, `s.view = v`. Updates `s.view` **after** `resize()` released the
lock.
2. **Ticker goroutine** (`tea.go:1418-1430`): on `p.ticker.C` (~60 Hz), calls
`p.renderer.flush(false)`.
`flush()` (`cursed_renderer.go:290-...`):
- `:294`: `view := s.view` (may be the *previous* frame if it runs before
`render()`).
- `:320`: skips the no-change early-return when `s.pendingErase` is true or
`frameArea != s.cellbuf.Bounds()`.
- `:329-339`: on `frameArea != s.cellbuf.Bounds()` (true after `resize()`),
`s.scr.Erase()` + `s.cellbuf.Resize(...)` and draws `view` (the stale one)
into the resized buffer.
The race window is the gap between `resize()` releasing `s.mu` and `render()`
taking it again. Both run in the event loop, but the ticker goroutine can
acquire `s.mu` in that gap and flush the stale `s.view` with the armed
`pendingErase` at the new size.
## Why `tea.ClearScreen()` does not help
`tea.ClearScreen()` returns a `clearScreenMsg` that is queued to `p.msgs` and
processed on the **next** event-loop iteration, so it cannot prevent the ticker
flush that already fires between the current `resize()` and `render()`. It only
adds an extra full-screen clear one tick later; the ghost is unchanged. (We
verified this empirically by re-adding `ClearScreen()` to the resize handler.)
## Suggested fix
`resize()` should ensure that a `flush()` that runs before the next `render()`
does not draw stale content into the new buffer. Options:
- In `resize()`, set a flag (e.g. reuse/extend `pendingErase` or add
`pendingResize`) that makes `flush()` **only** emit the clear+resize and skip
drawing `s.view` until `render()` has stored the new view; or
- Invalidate `s.view` in `resize()` so `flush()` sees an empty view until
`render()` repopulates it; or
- Perform `resize()` and `render()` under a single held lock (or otherwise make
the resize→render step atomic w.r.t. the ticker).
## Environment
- `charm.land/bubbletea/v2 v2.0.9` (Go module path `charm.land/bubbletea/v2`)
- Go 1.24
- Linux, `TERM=xterm-256color`, repro over a `creack/pty` PTY
- Affects alt-screen programs whose `View()` content depends on terminal width
(e.g. tables that reflow columns). Programs whose `View()` is
width-independent do not show a visible ghost because the stale and correct
views are identical.
Contributor guide
Research direction
Start with cursed_renderer.go at flush and resize, then read tea.go around the WindowSizeMsg resize/render sequence and the ticker flush call. Run the provided repro.go and probe.go over a PTY. Done means resizing no longer emits a stale old-layout frame between the clear at the new size and the correct rendered frame.
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
- 55/100