charmbracelet / charmbracelet/bubbletea

v2: cursed_renderer emits a one-tick stale frame on resize (race between resize() and the 60Hz ticker flush())

Open
#1,780 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

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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.