detached 8040975

test(resize): guard reflow rendering performance

commit
804097528a61932b70ef35afc8e4dac0b513962d
author
brice · · 2026-07-15 15:34 UTC
parent
56e46d3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +66 -0
M internal/usecase/daemon/output_state_stream_test.go +30 -0
@@ -77,6 +77,36 @@ func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Fra
7777 return stream.frame(data, reset, echoAck), true, nil
7878 }
7979
80+// A resize is a state reset, but it must not turn subsequent idle or damaged
81+// renders into full frames.
82+func TestOutputStateStreamResizeFrameThenNoopAndDamageAreDifferential(t *testing.T) {
83+ screen := vt.NewScreen(4, 2)
84+ screen.Write([]byte("abcd"))
85+ screen.ClearDamage()
86+ screen.Resize(6, 2)
87+
88+ stream := newOutputStateStream()
89+ resized, ok, err := drawOutputState(t, stream, screen.Frame, screen.Damage(), true, 0)
90+ require.NoError(t, err)
91+ require.True(t, ok)
92+ resizeOutput, err := ports.UnmarshalOutput(resized.Payload)
93+ require.NoError(t, err)
94+ require.Zero(t, resizeOutput.BaseStateNum, "resize must emit the one reset frame")
95+ screen.ClearDamage()
96+
97+ _, ok, err = drawOutputState(t, stream, screen.Frame, screen.Damage(), false, 0)
98+ require.NoError(t, err)
99+ require.False(t, ok, "an unchanged render after resize must emit nothing")
100+
101+ screen.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
102+ damaged, ok, err := drawOutputState(t, stream, screen.Frame, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 0)
103+ require.NoError(t, err)
104+ require.True(t, ok)
105+ damageOutput, err := ports.UnmarshalOutput(damaged.Payload)
106+ require.NoError(t, err)
107+ require.Equal(t, resizeOutput.NewStateNum, damageOutput.BaseStateNum, "later damage must remain incremental")
108+}
109+
80110 func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) {
81111 require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding)
82112 require.Equal(t, uint64(8), newOutputStateStream(0).maxOutstanding)
@@ -77,6 +77,36 @@ func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Fra
77 return stream.frame(data, reset, echoAck), true, nil77 return stream.frame(data, reset, echoAck), true, nil
78 }78 }
79 79
80+// A resize is a state reset, but it must not turn subsequent idle or damaged
81+// renders into full frames.
82+func TestOutputStateStreamResizeFrameThenNoopAndDamageAreDifferential(t *testing.T) {
83+ screen := vt.NewScreen(4, 2)
84+ screen.Write([]byte("abcd"))
85+ screen.ClearDamage()
86+ screen.Resize(6, 2)
87+
88+ stream := newOutputStateStream()
89+ resized, ok, err := drawOutputState(t, stream, screen.Frame, screen.Damage(), true, 0)
90+ require.NoError(t, err)
91+ require.True(t, ok)
92+ resizeOutput, err := ports.UnmarshalOutput(resized.Payload)
93+ require.NoError(t, err)
94+ require.Zero(t, resizeOutput.BaseStateNum, "resize must emit the one reset frame")
95+ screen.ClearDamage()
96+
97+ _, ok, err = drawOutputState(t, stream, screen.Frame, screen.Damage(), false, 0)
98+ require.NoError(t, err)
99+ require.False(t, ok, "an unchanged render after resize must emit nothing")
100+
101+ screen.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
102+ damaged, ok, err := drawOutputState(t, stream, screen.Frame, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 0)
103+ require.NoError(t, err)
104+ require.True(t, ok)
105+ damageOutput, err := ports.UnmarshalOutput(damaged.Payload)
106+ require.NoError(t, err)
107+ require.Equal(t, resizeOutput.NewStateNum, damageOutput.BaseStateNum, "later damage must remain incremental")
108+}
109+
80 func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) {110 func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) {
81 require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding)111 require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding)
82 require.Equal(t, uint64(8), newOutputStateStream(0).maxOutstanding)112 require.Equal(t, uint64(8), newOutputStateStream(0).maxOutstanding)
M internal/usecase/daemon/transactional_resize_test.go +3 -0
@@ -357,6 +357,9 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi
357357 // through S2 immediately (subject to ACK/sync gates).
358358 frame := <-frames
359359 require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")
360+ output, err := ports.UnmarshalOutput(frame.Payload)
361+ require.NoError(t, err)
362+ require.Zero(t, output.BaseStateNum, "the accepted resize frame must reset the renderer state")
360363 select {
361364 case extra := <-frames:
362365 t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra)
@@ -357,6 +357,9 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi
357 // through S2 immediately (subject to ACK/sync gates).357 // through S2 immediately (subject to ACK/sync gates).
358 frame := <-frames358 frame := <-frames
359 require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")359 require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")
360+ output, err := ports.UnmarshalOutput(frame.Payload)
361+ require.NoError(t, err)
362+ require.Zero(t, output.BaseStateNum, "the accepted resize frame must reset the renderer state")
360 select {363 select {
361 case extra := <-frames:364 case extra := <-frames:
362 t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra)365 t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra)
M pkg/vt/benchmark_test.go +33 -0
@@ -1,6 +1,7 @@
11 package vt
22
33 import (
4+ "bytes"
45 "testing"
56
67 "github.com/bnema/vev/pkg/renderer"
@@ -159,6 +160,38 @@ func benchmarkHistory(t testing.TB, rows, width, chunkRows int) *History {
159160 return history
160161 }
161162
163+// BenchmarkScreenResizeReflowViewport verifies resize only visits the live
164+// viewport. The 10K-history variant must track the control rather than history
165+// length; history is deliberately populated without changing the viewport.
166+func BenchmarkScreenResizeReflowViewport(b *testing.B) {
167+ b.Run("control", func(b *testing.B) { benchmarkScreenResizeReflow(b, 0) })
168+ b.Run("10k-history", func(b *testing.B) { benchmarkScreenResizeReflow(b, 10_000) })
169+}
170+
171+func benchmarkScreenResizeReflow(b *testing.B, historyRows int) {
172+ b.Helper()
173+ s := NewScreenWithHistory(120, 40, HistoryConfig{MaxRows: historyRows + 500})
174+ if historyRows > 0 {
175+ for range historyRows {
176+ s.history.Append(make([]renderer.Cell, 120))
177+ }
178+ }
179+ s.Write(bytes.Repeat([]byte("x"), 120*39))
180+ s.ClearDamage()
181+ sizes := [2][2]int{{80, 40}, {120, 40}}
182+
183+ b.ReportAllocs()
184+ b.ResetTimer()
185+ for i := 0; b.Loop(); i++ {
186+ s.Resize(sizes[i%len(sizes)][0], sizes[i%len(sizes)][1])
187+ s.ClearDamage()
188+ }
189+ b.StopTimer()
190+ if got := s.History(); historyRows > 0 && got.Len() < historyRows {
191+ b.Fatalf("resize discarded retained history rows: got %d want at least %d", got.Len(), historyRows)
192+ }
193+}
194+
162195 func BenchmarkScreenShellRedrawBurst(b *testing.B) {
163196 chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C")
164197 b.ReportAllocs()
@@ -1,6 +1,7 @@
1 package vt1 package vt
2 2
3 import (3 import (
4+ "bytes"
4 "testing"5 "testing"
5 6
6 "github.com/bnema/vev/pkg/renderer"7 "github.com/bnema/vev/pkg/renderer"
@@ -159,6 +160,38 @@ func benchmarkHistory(t testing.TB, rows, width, chunkRows int) *History {
159 return history160 return history
160 }161 }
161 162
163+// BenchmarkScreenResizeReflowViewport verifies resize only visits the live
164+// viewport. The 10K-history variant must track the control rather than history
165+// length; history is deliberately populated without changing the viewport.
166+func BenchmarkScreenResizeReflowViewport(b *testing.B) {
167+ b.Run("control", func(b *testing.B) { benchmarkScreenResizeReflow(b, 0) })
168+ b.Run("10k-history", func(b *testing.B) { benchmarkScreenResizeReflow(b, 10_000) })
169+}
170+
171+func benchmarkScreenResizeReflow(b *testing.B, historyRows int) {
172+ b.Helper()
173+ s := NewScreenWithHistory(120, 40, HistoryConfig{MaxRows: historyRows + 500})
174+ if historyRows > 0 {
175+ for range historyRows {
176+ s.history.Append(make([]renderer.Cell, 120))
177+ }
178+ }
179+ s.Write(bytes.Repeat([]byte("x"), 120*39))
180+ s.ClearDamage()
181+ sizes := [2][2]int{{80, 40}, {120, 40}}
182+
183+ b.ReportAllocs()
184+ b.ResetTimer()
185+ for i := 0; b.Loop(); i++ {
186+ s.Resize(sizes[i%len(sizes)][0], sizes[i%len(sizes)][1])
187+ s.ClearDamage()
188+ }
189+ b.StopTimer()
190+ if got := s.History(); historyRows > 0 && got.Len() < historyRows {
191+ b.Fatalf("resize discarded retained history rows: got %d want at least %d", got.Len(), historyRows)
192+ }
193+}
194+
162 func BenchmarkScreenShellRedrawBurst(b *testing.B) {195 func BenchmarkScreenShellRedrawBurst(b *testing.B) {
163 chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C")196 chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C")
164 b.ReportAllocs()197 b.ReportAllocs()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close