test(resize): guard reflow rendering performance
- commit
- 804097528a61932b70ef35afc8e4dac0b513962d
- author
- brice · · 2026-07-15 15:34 UTC
- parent
- 56e46d3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 77 | 77 | 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 | 110 | func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) { | |
| 81 | 111 | require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding) | |
| 82 | 112 | 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, nil | 77 | 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 | |||
| 357 | 357 | // through S2 immediately (subject to ACK/sync gates). | |
| 358 | 358 | frame := <-frames | |
| 359 | 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 | 363 | select { | |
| 361 | 364 | case extra := <-frames: | |
| 362 | 365 | 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 := <-frames | 358 | 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 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -159,6 +160,38 @@ func benchmarkHistory(t testing.TB, rows, width, chunkRows int) *History { | |||
| 159 | 160 | 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 | 195 | func BenchmarkScreenShellRedrawBurst(b *testing.B) { | |
| 163 | 196 | chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C") | |
| 164 | 197 | b.ReportAllocs() | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package vt | 1 | 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 history | 160 | 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() | ||