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