From bd2951b2f015acf1872e20e4462e5846ee40b478 Mon Sep 17 00:00:00 2001 From: lxxero Date: Fri, 5 Jun 2026 23:50:00 -0600 Subject: [PATCH] feat(vt): add SafeEmulator.View/Update for batched locked access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SafeEmulator.CellAt takes an RLock (plus its defer) on every call. A renderer walking the cell grid each frame pays that per cell — tens of thousands of lock acquisitions per frame on a large grid. Profiling a real terminal application (xerotty) under continuous redraw showed SafeEmulator.CellAt and its defer wrapper as the top CPU entry (~16% of total) once other per-cell costs were removed. View(fn) / Update(fn) expose the underlying Emulator under a single read/write lock acquisition, bbolt-style, so bulk reads (render passes, snapshots) and bulk writes (cell-by-cell replay) batch into one lock each: BenchmarkCellAtGridWalk-16 162933 14081 ns/op BenchmarkViewGridWalk-16 1885207 1255 ns/op (80x24 grid walk, 11x. The gap widens with grid size.) Includes -race tests for both, with concurrent Write pressure. --- vt/safe_emulator.go | 26 +++++++++++ vt/safe_emulator_view_test.go | 86 +++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 vt/safe_emulator_view_test.go diff --git a/vt/safe_emulator.go b/vt/safe_emulator.go index 73d743b3..6bb5eb66 100644 --- a/vt/safe_emulator.go +++ b/vt/safe_emulator.go @@ -62,6 +62,32 @@ func (se *SafeEmulator) CellAt(x, y int) *uv.Cell { return se.Emulator.CellAt(x, y) } +// View runs fn while holding the emulator's read lock, giving it +// direct access to the underlying Emulator for the duration. Use it +// to batch many reads under a single lock acquisition — a renderer +// walking the full cell grid pays one RLock here instead of a +// per-cell RLock+defer through CellAt, which profiles as a +// significant share of frame time in real applications (thousands of +// lock acquisitions per frame on a large grid). fn must not retain +// the *Emulator or any cell pointers past its return, and must not +// call the SafeEmulator's own locking methods (deadlock). +func (se *SafeEmulator) View(fn func(*Emulator)) { + se.mu.RLock() + defer se.mu.RUnlock() + fn(se.Emulator) +} + +// Update is View's write-locked counterpart: fn gets exclusive +// access to the underlying Emulator, letting callers batch many +// mutations (e.g. replaying a snapshot cell by cell via SetCell) +// under one lock acquisition. The same retention and reentrancy +// rules as View apply. +func (se *SafeEmulator) Update(fn func(*Emulator)) { + se.mu.Lock() + defer se.mu.Unlock() + fn(se.Emulator) +} + // SendKey sends a key event to the emulator in a concurrency-safe manner. func (se *SafeEmulator) SendKey(key uv.KeyEvent) { se.mu.Lock() diff --git a/vt/safe_emulator_view_test.go b/vt/safe_emulator_view_test.go new file mode 100644 index 00000000..45fae7cb --- /dev/null +++ b/vt/safe_emulator_view_test.go @@ -0,0 +1,86 @@ +package vt + +import ( + "sync" + "testing" + + uv "github.com/charmbracelet/ultraviolet" +) + +// TestSafeEmulatorViewUpdate verifies View/Update give locked access +// to the underlying emulator and exclude concurrent writers. +func TestSafeEmulatorViewUpdate(t *testing.T) { + se := NewSafeEmulator(80, 24) + se.Update(func(e *Emulator) { + c := &uv.Cell{Content: "x", Width: 1} + e.SetCell(0, 0, c) + }) + var got string + se.View(func(e *Emulator) { + if c := e.CellAt(0, 0); c != nil { + got = c.Content + } + }) + if got != "x" { + t.Fatalf("View read %q, want x", got) + } +} + +// TestSafeEmulatorViewConcurrent runs grid-walking Views against +// concurrent Writes under -race: batched reads must be as safe as +// per-cell CellAt. +func TestSafeEmulatorViewConcurrent(t *testing.T) { + se := NewSafeEmulator(80, 24) + var wg sync.WaitGroup + stop := make(chan struct{}) + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + _, _ = se.Write([]byte("hello world\r\n")) + } + }() + for range 100 { + se.View(func(e *Emulator) { + for y := 0; y < e.Height(); y++ { + for x := 0; x < e.Width(); x++ { + _ = e.CellAt(x, y) + } + } + }) + } + close(stop) + wg.Wait() +} + +// BenchmarkCellAtGridWalk is the per-cell locking cost View exists +// to avoid: an 80x24 grid read through CellAt. +func BenchmarkCellAtGridWalk(b *testing.B) { + se := NewSafeEmulator(80, 24) + for b.Loop() { + for y := 0; y < 24; y++ { + for x := 0; x < 80; x++ { + _ = se.CellAt(x, y) + } + } + } +} + +// BenchmarkViewGridWalk is the same read batched under one lock. +func BenchmarkViewGridWalk(b *testing.B) { + se := NewSafeEmulator(80, 24) + for b.Loop() { + se.View(func(e *Emulator) { + for y := 0; y < 24; y++ { + for x := 0; x < 80; x++ { + _ = e.CellAt(x, y) + } + } + }) + } +}