v2: cursed_renderer emits a one-tick stale frame on resize (race between resize() and the 60Hz ticker flush())
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):
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):
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:
Event loop (
tea.go), onWindowSizeMsg:tea.go:858:p.renderer.resize(msg.Width, msg.Height)cursed_renderer.go:675-679: unders.mu,s.scr.Erase(); setss.width,s.height = w,h;s.scr.Resize(...);s.pendingErase = true;s.mu.Unlock(). Does not touchs.view.tea.go:888:p.render(model)→cursed_renderer.go:627-631: unders.mu,s.view = v. Updatess.viewafterresize()released the lock.
Ticker goroutine (
tea.go:1418-1430): onp.ticker.C(~60 Hz), callsp.renderer.flush(false).
flush() (cursed_renderer.go:290-...):
:294:view := s.view(may be the previous frame if it runs beforerender()).:320: skips the no-change early-return whens.pendingEraseis true orframeArea != s.cellbuf.Bounds().:329-339: onframeArea != s.cellbuf.Bounds()(true afterresize()),s.scr.Erase()+s.cellbuf.Resize(...)and drawsview(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/extendpendingEraseor addpendingResize) that makesflush()only emit the clear+resize and skip drawings.viewuntilrender()has stored the new view; or - Invalidate
s.viewinresize()soflush()sees an empty view untilrender()repopulates it; or - Perform
resize()andrender()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 pathcharm.land/bubbletea/v2)- Go 1.24
- Linux,
TERM=xterm-256color, repro over acreack/ptyPTY - Affects alt-screen programs whose
View()content depends on terminal width (e.g. tables that reflow columns). Programs whoseView()is width-independent do not show a visible ghost because the stale and correct views are identical.
Source: charmbracelet/bubbletea