detached d0781e6

perf(copy,streamframe): reuse copy-mode rows and drop the receive copy (#311)

Copy-mode row rendering borrows a pooled row instead of allocating one per
call, and the status line writes cells directly instead of concatenating
strings. streamframe.Recv reads the length prefix into a Framer field and
the body into its final buffer, removing the reused buffer plus copy.

The status line now advances one cell per rune; it previously indexed by
byte offset, which left gaps after non-ASCII search queries.

Copy scroll: -10..-14% B/op, 176 -> 166 allocs/op.
IPC receive: 2 -> 1 allocs/op; typed round trip: 90 -> 86 allocs/op.
commit
d0781e62dc2f1cf9ace4df70355545ba885775a0
author
bnema · · 2026-10-04 16:23 UTC
committer
GitHub · · 2026-10-04 16:23 UTC
parent
5ac8017
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
3 files · +110 -40
M internal/adapters/streamframe/streamframe.go +9 -14
@@ -74,8 +74,10 @@ type Framer struct {
7474 queueBytes uint64
7575
7676 // readMu serializes Recv so concurrent receivers each get one
77- // complete envelope instead of racing on the reused read buffer.
77+ // complete envelope instead of interleaving header and body reads.
7878 readMu sync.Mutex
79+ // header is the length-prefix buffer, guarded by readMu.
80+ header [4]byte
7981
8082 egressMu sync.Mutex
8183 egressClosing bool
@@ -87,8 +89,6 @@ type Framer struct {
8789 writerDone chan struct{}
8890 closeOnce sync.Once
8991 closeErr error
90-
91- readBuf []byte
9292 }
9393
9494 type sendRequest struct {
@@ -196,28 +196,23 @@ func (f *Framer) negotiatedBound() uint64 {
196196
197197 // recvLocked reads and validates one frame under readMu. The length prefix is
198198 // validated against bound before any body buffer exists, and the returned slice
199-// is a fresh copy the caller owns.
199+// is freshly allocated and owned by the caller.
200200 func (f *Framer) recvLocked(bound uint64) ([]byte, error) {
201- var header [4]byte
202- if _, err := io.ReadFull(f.r, header[:]); err != nil {
201+ header := f.header[:]
202+ if _, err := io.ReadFull(f.r, header); err != nil {
203203 if f.isClosed() {
204204 return nil, io.EOF
205205 }
206206 return nil, err
207207 }
208- length := binary.BigEndian.Uint32(header[:])
208+ length := binary.BigEndian.Uint32(header)
209209 if length == 0 {
210210 return nil, ErrZeroLength
211211 }
212212 if uint64(length) > bound {
213213 return nil, ErrTooLarge
214214 }
215- if cap(f.readBuf) < int(length) {
216- f.readBuf = make([]byte, length)
217- } else {
218- f.readBuf = f.readBuf[:length]
219- }
220- payload := f.readBuf
215+ payload := make([]byte, length)
221216 if _, err := io.ReadFull(f.r, payload); err != nil {
222217 // Best-effort erase: a truncated frame may hold a partial secret
223218 // (for example a bounded bootstrap auth record).
@@ -227,7 +222,7 @@ func (f *Framer) recvLocked(bound uint64) ([]byte, error) {
227222 }
228223 return nil, err
229224 }
230- return append([]byte(nil), payload...), nil
225+ return payload, nil
231226 }
232227
233228 // Close unblocks in-flight Send and Recv, fails queued payloads, waits for
@@ -74,8 +74,10 @@ type Framer struct {
74 queueBytes uint6474 queueBytes uint64
75 75
76 // readMu serializes Recv so concurrent receivers each get one76 // readMu serializes Recv so concurrent receivers each get one
77- // complete envelope instead of racing on the reused read buffer.77+ // complete envelope instead of interleaving header and body reads.
78 readMu sync.Mutex78 readMu sync.Mutex
79+ // header is the length-prefix buffer, guarded by readMu.
80+ header [4]byte
79 81
80 egressMu sync.Mutex82 egressMu sync.Mutex
81 egressClosing bool83 egressClosing bool
@@ -87,8 +89,6 @@ type Framer struct {
87 writerDone chan struct{}89 writerDone chan struct{}
88 closeOnce sync.Once90 closeOnce sync.Once
89 closeErr error91 closeErr error
90-
91- readBuf []byte
92 }92 }
93 93
94 type sendRequest struct {94 type sendRequest struct {
@@ -196,28 +196,23 @@ func (f *Framer) negotiatedBound() uint64 {
196 196
197 // recvLocked reads and validates one frame under readMu. The length prefix is197 // recvLocked reads and validates one frame under readMu. The length prefix is
198 // validated against bound before any body buffer exists, and the returned slice198 // validated against bound before any body buffer exists, and the returned slice
199-// is a fresh copy the caller owns.199+// is freshly allocated and owned by the caller.
200 func (f *Framer) recvLocked(bound uint64) ([]byte, error) {200 func (f *Framer) recvLocked(bound uint64) ([]byte, error) {
201- var header [4]byte201+ header := f.header[:]
202- if _, err := io.ReadFull(f.r, header[:]); err != nil {202+ if _, err := io.ReadFull(f.r, header); err != nil {
203 if f.isClosed() {203 if f.isClosed() {
204 return nil, io.EOF204 return nil, io.EOF
205 }205 }
206 return nil, err206 return nil, err
207 }207 }
208- length := binary.BigEndian.Uint32(header[:])208+ length := binary.BigEndian.Uint32(header)
209 if length == 0 {209 if length == 0 {
210 return nil, ErrZeroLength210 return nil, ErrZeroLength
211 }211 }
212 if uint64(length) > bound {212 if uint64(length) > bound {
213 return nil, ErrTooLarge213 return nil, ErrTooLarge
214 }214 }
215- if cap(f.readBuf) < int(length) {215+ payload := make([]byte, length)
216- f.readBuf = make([]byte, length)
217- } else {
218- f.readBuf = f.readBuf[:length]
219- }
220- payload := f.readBuf
221 if _, err := io.ReadFull(f.r, payload); err != nil {216 if _, err := io.ReadFull(f.r, payload); err != nil {
222 // Best-effort erase: a truncated frame may hold a partial secret217 // Best-effort erase: a truncated frame may hold a partial secret
223 // (for example a bounded bootstrap auth record).218 // (for example a bounded bootstrap auth record).
@@ -227,7 +222,7 @@ func (f *Framer) recvLocked(bound uint64) ([]byte, error) {
227 }222 }
228 return nil, err223 return nil, err
229 }224 }
230- return append([]byte(nil), payload...), nil225+ return payload, nil
231 }226 }
232 227
233 // Close unblocks in-flight Send and Recv, fails queued payloads, waits for228 // Close unblocks in-flight Send and Recv, fails queued payloads, waits for
M internal/usecase/copy/mode.go +48 -26
@@ -4,7 +4,9 @@ import (
44 "encoding/base64"
55 "math"
66 "slices"
7+ "strconv"
78 "strings"
9+ "sync"
810 "unicode"
911
1012 vt "github.com/bnema/vev-vt"
@@ -247,6 +249,10 @@ type Mode struct {
247249 SearchIndex int
248250 }
249251
252+// rowPool recycles the semantic rows lent to RenderRowsRange callbacks.
253+// Renderers snapshot Mode by value, so the buffer cannot live on Mode.
254+var rowPool sync.Pool
255+
250256 func NewMode(doc *Document) *Mode {
251257 m := &Mode{document: doc, SearchIndex: -1}
252258 if doc != nil && doc.Len() > 0 {
@@ -574,7 +580,13 @@ func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell),
574580 selectionBounds, hasSelectionBounds := m.selection.bounds(d)
575581 selection, hasSelection := optionalStyle(styles, 1)
576582 cursor, cursorValid := d.Normalize(m.navigator.Pos)
577- row := make([]renderer.Cell, d.Width())
583+ buf, _ := rowPool.Get().(*[]renderer.Cell)
584+ if buf == nil || cap(*buf) < d.Width() {
585+ row := make([]renderer.Cell, d.Width())
586+ buf = &row
587+ }
588+ defer rowPool.Put(buf)
589+ row := (*buf)[:d.Width()]
578590 for y := max(start, 0); y < min(end, d.Height()); y++ {
579591 src := m.ViewportTop + y
580592 for x := range row {
@@ -639,26 +651,49 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl
639651 for i := range row {
640652 row[i] = renderer.Cell{Rune: ' ', Style: style}
641653 }
642- text := " [SCROLL] "
654+ col := 0
655+ put := func(text string) {
656+ for _, r := range text {
657+ if col >= len(row) {
658+ return
659+ }
660+ row[col] = renderer.Cell{Rune: r, Style: style}
661+ col++
662+ }
663+ }
664+ var digits [20]byte
665+ putInt := func(n int) {
666+ for _, b := range strconv.AppendInt(digits[:0], int64(n), 10) {
667+ if col >= len(row) {
668+ return
669+ }
670+ row[col] = renderer.Cell{Rune: rune(b), Style: style}
671+ col++
672+ }
673+ }
643674 if m.selection.Enabled {
644- text = " [SELECT] "
675+ put(" [SELECT] ")
676+ } else {
677+ put(" [SCROLL] ")
645678 }
646679 if total > 0 {
647- text += strconvItoa(m.navigator.Pos.Row+1) + "/" + strconvItoa(total) + " "
680+ putInt(m.navigator.Pos.Row + 1)
681+ put("/")
682+ putInt(total)
683+ put(" ")
648684 } else {
649- text += "0/0 "
685+ put("0/0 ")
650686 }
651687 if m.SearchQuery != "" {
652688 if len(m.Searches) > 0 && m.SearchIndex >= 0 && m.SearchIndex < len(m.Searches) {
653- text += strconvItoa(m.SearchIndex+1) + "/" + strconvItoa(len(m.Searches)) + " "
654- }
655- text += "/" + m.SearchQuery + " "
656- }
657- for i, r := range text {
658- if i >= len(row) {
659- break
689+ putInt(m.SearchIndex + 1)
690+ put("/")
691+ putInt(len(m.Searches))
692+ put(" ")
660693 }
661- row[i] = renderer.Cell{Rune: r, Style: style}
694+ put("/")
695+ put(m.SearchQuery)
696+ put(" ")
662697 }
663698 }
664699
@@ -675,19 +710,6 @@ func OSC52FromBase64(b64 string) []byte {
675710 }
676711 return []byte("\x1b]52;c;" + b64 + "\x07")
677712 }
678-func strconvItoa(n int) string {
679- if n == 0 {
680- return "0"
681- }
682- var b [20]byte
683- i := len(b)
684- for n > 0 {
685- i--
686- b[i] = byte('0' + n%10)
687- n /= 10
688- }
689- return string(b[i:])
690-}
691713 func abs(n int) int {
692714 if n < 0 {
693715 return -n
@@ -4,7 +4,9 @@ import (
4 "encoding/base64"4 "encoding/base64"
5 "math"5 "math"
6 "slices"6 "slices"
7+ "strconv"
7 "strings"8 "strings"
9+ "sync"
8 "unicode"10 "unicode"
9 11
10 vt "github.com/bnema/vev-vt"12 vt "github.com/bnema/vev-vt"
@@ -247,6 +249,10 @@ type Mode struct {
247 SearchIndex int249 SearchIndex int
248 }250 }
249 251
252+// rowPool recycles the semantic rows lent to RenderRowsRange callbacks.
253+// Renderers snapshot Mode by value, so the buffer cannot live on Mode.
254+var rowPool sync.Pool
255+
250 func NewMode(doc *Document) *Mode {256 func NewMode(doc *Document) *Mode {
251 m := &Mode{document: doc, SearchIndex: -1}257 m := &Mode{document: doc, SearchIndex: -1}
252 if doc != nil && doc.Len() > 0 {258 if doc != nil && doc.Len() > 0 {
@@ -574,7 +580,13 @@ func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell),
574 selectionBounds, hasSelectionBounds := m.selection.bounds(d)580 selectionBounds, hasSelectionBounds := m.selection.bounds(d)
575 selection, hasSelection := optionalStyle(styles, 1)581 selection, hasSelection := optionalStyle(styles, 1)
576 cursor, cursorValid := d.Normalize(m.navigator.Pos)582 cursor, cursorValid := d.Normalize(m.navigator.Pos)
577- row := make([]renderer.Cell, d.Width())583+ buf, _ := rowPool.Get().(*[]renderer.Cell)
584+ if buf == nil || cap(*buf) < d.Width() {
585+ row := make([]renderer.Cell, d.Width())
586+ buf = &row
587+ }
588+ defer rowPool.Put(buf)
589+ row := (*buf)[:d.Width()]
578 for y := max(start, 0); y < min(end, d.Height()); y++ {590 for y := max(start, 0); y < min(end, d.Height()); y++ {
579 src := m.ViewportTop + y591 src := m.ViewportTop + y
580 for x := range row {592 for x := range row {
@@ -639,26 +651,49 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl
639 for i := range row {651 for i := range row {
640 row[i] = renderer.Cell{Rune: ' ', Style: style}652 row[i] = renderer.Cell{Rune: ' ', Style: style}
641 }653 }
642- text := " [SCROLL] "654+ col := 0
655+ put := func(text string) {
656+ for _, r := range text {
657+ if col >= len(row) {
658+ return
659+ }
660+ row[col] = renderer.Cell{Rune: r, Style: style}
661+ col++
662+ }
663+ }
664+ var digits [20]byte
665+ putInt := func(n int) {
666+ for _, b := range strconv.AppendInt(digits[:0], int64(n), 10) {
667+ if col >= len(row) {
668+ return
669+ }
670+ row[col] = renderer.Cell{Rune: rune(b), Style: style}
671+ col++
672+ }
673+ }
643 if m.selection.Enabled {674 if m.selection.Enabled {
644- text = " [SELECT] "675+ put(" [SELECT] ")
676+ } else {
677+ put(" [SCROLL] ")
645 }678 }
646 if total > 0 {679 if total > 0 {
647- text += strconvItoa(m.navigator.Pos.Row+1) + "/" + strconvItoa(total) + " "680+ putInt(m.navigator.Pos.Row + 1)
681+ put("/")
682+ putInt(total)
683+ put(" ")
648 } else {684 } else {
649- text += "0/0 "685+ put("0/0 ")
650 }686 }
651 if m.SearchQuery != "" {687 if m.SearchQuery != "" {
652 if len(m.Searches) > 0 && m.SearchIndex >= 0 && m.SearchIndex < len(m.Searches) {688 if len(m.Searches) > 0 && m.SearchIndex >= 0 && m.SearchIndex < len(m.Searches) {
653- text += strconvItoa(m.SearchIndex+1) + "/" + strconvItoa(len(m.Searches)) + " "689+ putInt(m.SearchIndex + 1)
654- }690+ put("/")
655- text += "/" + m.SearchQuery + " "691+ putInt(len(m.Searches))
656- }692+ put(" ")
657- for i, r := range text {
658- if i >= len(row) {
659- break
660 }693 }
661- row[i] = renderer.Cell{Rune: r, Style: style}694+ put("/")
695+ put(m.SearchQuery)
696+ put(" ")
662 }697 }
663 }698 }
664 699
@@ -675,19 +710,6 @@ func OSC52FromBase64(b64 string) []byte {
675 }710 }
676 return []byte("\x1b]52;c;" + b64 + "\x07")711 return []byte("\x1b]52;c;" + b64 + "\x07")
677 }712 }
678-func strconvItoa(n int) string {
679- if n == 0 {
680- return "0"
681- }
682- var b [20]byte
683- i := len(b)
684- for n > 0 {
685- i--
686- b[i] = byte('0' + n%10)
687- n /= 10
688- }
689- return string(b[i:])
690-}
691 func abs(n int) int {713 func abs(n int) int {
692 if n < 0 {714 if n < 0 {
693 return -n715 return -n
A internal/usecase/copy/status_test.go +53 -0
@@ -0,0 +1,53 @@
1+package copy
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt/ansi"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestDrawCopyStatus(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ width int
14+ lines []string
15+ query string
16+ want string
17+ }{
18+ {name: "empty document", width: 12, want: " [SCROLL] 0/"},
19+ {name: "position", width: 16, lines: []string{"a", "b"}, want: " [SCROLL] 2/2 "},
20+ {name: "truncated", width: 5, lines: []string{"a"}, want: " [SCR"},
21+ {name: "zero width", width: 0, lines: []string{"a"}, want: ""},
22+ {name: "multibyte query is contiguous", width: 22, lines: []string{"éa"}, query: "é", want: " [SCROLL] 1/1 1/1 /é "},
23+ }
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ m := modeFor(tt.lines, 1)
27+ if tt.query != "" {
28+ m.Search(tt.query)
29+ }
30+ row := make([]renderer.Cell, tt.width)
31+ drawCopyStatus(row, m, m.document.Len(), renderer.Style{})
32+ var got []rune
33+ for _, cell := range row {
34+ got = append(got, cell.Rune)
35+ }
36+ require.Equal(t, tt.want, string(got)[:len(tt.want)])
37+ require.Len(t, row, tt.width)
38+ })
39+ }
40+}
41+
42+// TestRenderRowsRangeReusesRowsAcrossWidths guards the pooled row buffer:
43+// a narrow render between two wide ones must not leak cells.
44+func TestRenderRowsRangeReusesRowsAcrossWidths(t *testing.T) {
45+ wide := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("abcdefgh")}, 8, 1), ""))
46+ narrow := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("xy")}, 2, 1), ""))
47+ for _, m := range []*Mode{wide, narrow, wide} {
48+ full := m.Render()
49+ m.RenderRowsRange(0, 1, func(y int, got []renderer.Cell) {
50+ require.Equal(t, full.Row(y), got)
51+ })
52+ }
53+}
@@ -0,0 +1,53 @@
1+package copy
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt/ansi"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestDrawCopyStatus(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ width int
14+ lines []string
15+ query string
16+ want string
17+ }{
18+ {name: "empty document", width: 12, want: " [SCROLL] 0/"},
19+ {name: "position", width: 16, lines: []string{"a", "b"}, want: " [SCROLL] 2/2 "},
20+ {name: "truncated", width: 5, lines: []string{"a"}, want: " [SCR"},
21+ {name: "zero width", width: 0, lines: []string{"a"}, want: ""},
22+ {name: "multibyte query is contiguous", width: 22, lines: []string{"éa"}, query: "é", want: " [SCROLL] 1/1 1/1 /é "},
23+ }
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ m := modeFor(tt.lines, 1)
27+ if tt.query != "" {
28+ m.Search(tt.query)
29+ }
30+ row := make([]renderer.Cell, tt.width)
31+ drawCopyStatus(row, m, m.document.Len(), renderer.Style{})
32+ var got []rune
33+ for _, cell := range row {
34+ got = append(got, cell.Rune)
35+ }
36+ require.Equal(t, tt.want, string(got)[:len(tt.want)])
37+ require.Len(t, row, tt.width)
38+ })
39+ }
40+}
41+
42+// TestRenderRowsRangeReusesRowsAcrossWidths guards the pooled row buffer:
43+// a narrow render between two wide ones must not leak cells.
44+func TestRenderRowsRangeReusesRowsAcrossWidths(t *testing.T) {
45+ wide := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("abcdefgh")}, 8, 1), ""))
46+ narrow := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("xy")}, 2, 1), ""))
47+ for _, m := range []*Mode{wide, narrow, wide} {
48+ full := m.Render()
49+ m.RenderRowsRange(0, 1, func(y int, got []renderer.Cell) {
50+ require.Equal(t, full.Row(y), got)
51+ })
52+ }
53+}

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