Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions vt/safe_emulator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
86 changes: 86 additions & 0 deletions vt/safe_emulator_view_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}