fix(render): bound clipped-frame reservations and secure ioctl pointer lifetimes
- commit
- c1d1834ee2bbf4bb5654fd29192e2cf90f460d20
- author
- brice · · 2026-09-30 04:22 UTC
- parent
- b807617
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +98 -32
M internal/layout/layout.go +12 -13
| @@ -835,12 +835,7 @@ func countCommands(n *Node, r *Result) (c counts) { | |||
| 835 | 835 | total++ | |
| 836 | 836 | } | |
| 837 | 837 | if n.Kind == Text { | |
| 838 | - | for i := range r.Lines { | |
| 839 | - | c.runs += len(r.Lines[i].Runs) | |
| 840 | - | for j := range r.Lines[i].Runs { | |
| 841 | - | c.glyphs += len(r.Lines[i].Runs[j].Glyphs) | |
| 842 | - | } | |
| 843 | - | } | |
| 838 | + | c.runs, c.glyphs = textCounts(r) | |
| 844 | 839 | } | |
| 845 | 840 | j := 0 | |
| 846 | 841 | for _, child := range n.Children { | |
| @@ -862,6 +857,16 @@ func countCommands(n *Node, r *Result) (c counts) { | |||
| 862 | 857 | ||
| 863 | 858 | type counts struct{ commands, shadows, runs, glyphs int } | |
| 864 | 859 | ||
| 860 | + | func textCounts(r *Result) (runs, glyphs int) { | |
| 861 | + | for i := range r.Lines { | |
| 862 | + | runs += len(r.Lines[i].Runs) | |
| 863 | + | for j := range r.Lines[i].Runs { | |
| 864 | + | glyphs += len(r.Lines[i].Runs[j].Glyphs) | |
| 865 | + | } | |
| 866 | + | } | |
| 867 | + | return runs, glyphs | |
| 868 | + | } | |
| 869 | + | ||
| 865 | 870 | func (c context) paint(n *Node, r *Result, out *[]Command) { | |
| 866 | 871 | if r == nil { | |
| 867 | 872 | return | |
| @@ -936,13 +941,7 @@ func (c context) paint(n *Node, r *Result, out *[]Command) { | |||
| 936 | 941 | cmd.Rect = r.Content | |
| 937 | 942 | cmd.Text = n.Content | |
| 938 | 943 | cmd.Color = s.Color | |
| 939 | - | runs, glyphs := 0, 0 | |
| 940 | - | for i := range r.Lines { | |
| 941 | - | runs += len(r.Lines[i].Runs) | |
| 942 | - | for j := range r.Lines[i].Runs { | |
| 943 | - | glyphs += len(r.Lines[i].Runs[j].Glyphs) | |
| 944 | - | } | |
| 945 | - | } | |
| 944 | + | runs, glyphs := textCounts(r) | |
| 946 | 945 | cmd.Runs = c.runs.take(runs) | |
| 947 | 946 | backing := c.glyphs.take(glyphs) | |
| 948 | 947 | for _, line := range r.Lines { | |
| @@ -835,12 +835,7 @@ func countCommands(n *Node, r *Result) (c counts) { | |||||
| 835 | total++ | 835 | total++ | ||
| 836 | } | 836 | } | ||
| 837 | if n.Kind == Text { | 837 | if n.Kind == Text { | ||
| 838 | - | for i := range r.Lines { | 838 | + | c.runs, c.glyphs = textCounts(r) |
| 839 | - | c.runs += len(r.Lines[i].Runs) | |||
| 840 | - | for j := range r.Lines[i].Runs { | |||
| 841 | - | c.glyphs += len(r.Lines[i].Runs[j].Glyphs) | |||
| 842 | - | } | |||
| 843 | - | } | |||
| 844 | } | 839 | } | ||
| 845 | j := 0 | 840 | j := 0 | ||
| 846 | for _, child := range n.Children { | 841 | for _, child := range n.Children { | ||
| @@ -862,6 +857,16 @@ func countCommands(n *Node, r *Result) (c counts) { | |||||
| 862 | 857 | ||||
| 863 | type counts struct{ commands, shadows, runs, glyphs int } | 858 | type counts struct{ commands, shadows, runs, glyphs int } | ||
| 864 | 859 | ||||
| 860 | + | func textCounts(r *Result) (runs, glyphs int) { | |||
| 861 | + | for i := range r.Lines { | |||
| 862 | + | runs += len(r.Lines[i].Runs) | |||
| 863 | + | for j := range r.Lines[i].Runs { | |||
| 864 | + | glyphs += len(r.Lines[i].Runs[j].Glyphs) | |||
| 865 | + | } | |||
| 866 | + | } | |||
| 867 | + | return runs, glyphs | |||
| 868 | + | } | |||
| 869 | + | ||||
| 865 | func (c context) paint(n *Node, r *Result, out *[]Command) { | 870 | func (c context) paint(n *Node, r *Result, out *[]Command) { | ||
| 866 | if r == nil { | 871 | if r == nil { | ||
| 867 | return | 872 | return | ||
| @@ -936,13 +941,7 @@ func (c context) paint(n *Node, r *Result, out *[]Command) { | |||||
| 936 | cmd.Rect = r.Content | 941 | cmd.Rect = r.Content | ||
| 937 | cmd.Text = n.Content | 942 | cmd.Text = n.Content | ||
| 938 | cmd.Color = s.Color | 943 | cmd.Color = s.Color | ||
| 939 | - | runs, glyphs := 0, 0 | 944 | + | runs, glyphs := textCounts(r) |
| 940 | - | for i := range r.Lines { | |||
| 941 | - | runs += len(r.Lines[i].Runs) | |||
| 942 | - | for j := range r.Lines[i].Runs { | |||
| 943 | - | glyphs += len(r.Lines[i].Runs[j].Glyphs) | |||
| 944 | - | } | |||
| 945 | - | } | |||
| 946 | cmd.Runs = c.runs.take(runs) | 945 | cmd.Runs = c.runs.take(runs) | ||
| 947 | backing := c.glyphs.take(glyphs) | 946 | backing := c.glyphs.take(glyphs) | ||
| 948 | for _, line := range r.Lines { | 947 | for _, line := range r.Lines { | ||
M internal/layout/paint_test.go +8 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package layout | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "image" | |
| 4 | 5 | "reflect" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| @@ -104,6 +105,13 @@ func TestPaintRunAndGlyphCapacityIsolated(t *testing.T) { | |||
| 104 | 105 | // guarantee silently degrades. | |
| 105 | 106 | func TestPaintCountsMatch(t *testing.T) { | |
| 106 | 107 | root, opts := paintFixture(t, 10) | |
| 108 | + | root.Children = append(root.Children, | |
| 109 | + | nil, | |
| 110 | + | &Node{ID: "hidden", Kind: Text, Content: "hidden", Style: &css.Style{Display: css.KeywordNone}}, | |
| 111 | + | &Node{ID: "image", Kind: Image, Image: image.NewRGBA(image.Rect(0, 0, 2, 2)), Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20)}}, | |
| 112 | + | &Node{ID: "outline", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), OutlineStyle: css.KeywordSolid, OutlineWidth: px(2)}}, | |
| 113 | + | &Node{ID: "radius", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), BorderRadius: css.Radii{TopLeft: px(2)}}}, | |
| 114 | + | ) | |
| 107 | 115 | ctx := context{options: opts} | |
| 108 | 116 | tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) | |
| 109 | 117 | if err != nil { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package layout | 1 | package layout | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "image" | |||
| 4 | "reflect" | 5 | "reflect" | ||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| @@ -104,6 +105,13 @@ func TestPaintRunAndGlyphCapacityIsolated(t *testing.T) { | |||||
| 104 | // guarantee silently degrades. | 105 | // guarantee silently degrades. | ||
| 105 | func TestPaintCountsMatch(t *testing.T) { | 106 | func TestPaintCountsMatch(t *testing.T) { | ||
| 106 | root, opts := paintFixture(t, 10) | 107 | root, opts := paintFixture(t, 10) | ||
| 108 | + | root.Children = append(root.Children, | |||
| 109 | + | nil, | |||
| 110 | + | &Node{ID: "hidden", Kind: Text, Content: "hidden", Style: &css.Style{Display: css.KeywordNone}}, | |||
| 111 | + | &Node{ID: "image", Kind: Image, Image: image.NewRGBA(image.Rect(0, 0, 2, 2)), Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20)}}, | |||
| 112 | + | &Node{ID: "outline", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), OutlineStyle: css.KeywordSolid, OutlineWidth: px(2)}}, | |||
| 113 | + | &Node{ID: "radius", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), BorderRadius: css.Radii{TopLeft: px(2)}}}, | |||
| 114 | + | ) | |||
| 107 | ctx := context{options: opts} | 115 | ctx := context{options: opts} | ||
| 108 | tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) | 116 | tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) | ||
| 109 | if err != nil { | 117 | if err != nil { | ||
M internal/presentation/session/tickframe_test.go +15 -9
| @@ -4,8 +4,10 @@ package session | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "image" | |
| 7 | + | "runtime" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "testing" | |
| 10 | + | "weak" | |
| 9 | 11 | ||
| 10 | 12 | "github.com/bnema/nefergui/internal/render" | |
| 11 | 13 | ) | |
| @@ -13,15 +15,19 @@ import ( | |||
| 13 | 15 | // An unsupported quad is rejected before any GPU work, and the borrowed list | |
| 14 | 16 | // buffer is released so it never pins the frame's image. | |
| 15 | 17 | func TestTickFrameRejectsUnsupportedAndReleasesImages(t *testing.T) { | |
| 16 | - | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | |
| 17 | 18 | s := &Session{} | |
| 18 | - | _, err := s.tickFrame(render.Frame{Quads: []render.Quad{{Op: "image", Image: img}, {Op: "bogus"}}}) | |
| 19 | - | if err == nil || !strings.Contains(err.Error(), "unsupported list operations") { | |
| 20 | - | t.Fatalf("err=%v", err) | |
| 19 | + | ref := func() weak.Pointer[image.RGBA] { | |
| 20 | + | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | |
| 21 | + | ref := weak.Make(img) | |
| 22 | + | _, err := s.tickFrame(render.Frame{Quads: []render.Quad{{Op: "image", Image: img}, {Op: "bogus"}}}) | |
| 23 | + | if err == nil || !strings.Contains(err.Error(), "unsupported list operations") { | |
| 24 | + | t.Fatalf("err=%v", err) | |
| 25 | + | } | |
| 26 | + | return ref | |
| 27 | + | }() | |
| 28 | + | runtime.GC() | |
| 29 | + | if ref.Value() != nil { | |
| 30 | + | t.Fatal("idle list buffer retains the frame image") | |
| 21 | 31 | } | |
| 22 | - | instances, batches, _ := s.list.List(render.Frame{}) | |
| 23 | - | if len(instances) != 0 || len(batches) != 0 { | |
| 24 | - | t.Fatal("stale list state") | |
| 25 | - | } | |
| 26 | - | s.list.Release() | |
| 32 | + | runtime.KeepAlive(s) | |
| 27 | 33 | } | |
| @@ -4,8 +4,10 @@ package session | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "image" | 6 | "image" | ||
| 7 | + | "runtime" | |||
| 7 | "strings" | 8 | "strings" | ||
| 8 | "testing" | 9 | "testing" | ||
| 10 | + | "weak" | |||
| 9 | 11 | ||||
| 10 | "github.com/bnema/nefergui/internal/render" | 12 | "github.com/bnema/nefergui/internal/render" | ||
| 11 | ) | 13 | ) | ||
| @@ -13,15 +15,19 @@ import ( | |||||
| 13 | // An unsupported quad is rejected before any GPU work, and the borrowed list | 15 | // An unsupported quad is rejected before any GPU work, and the borrowed list | ||
| 14 | // buffer is released so it never pins the frame's image. | 16 | // buffer is released so it never pins the frame's image. | ||
| 15 | func TestTickFrameRejectsUnsupportedAndReleasesImages(t *testing.T) { | 17 | func TestTickFrameRejectsUnsupportedAndReleasesImages(t *testing.T) { | ||
| 16 | - | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | |||
| 17 | s := &Session{} | 18 | s := &Session{} | ||
| 18 | - | _, err := s.tickFrame(render.Frame{Quads: []render.Quad{{Op: "image", Image: img}, {Op: "bogus"}}}) | 19 | + | ref := func() weak.Pointer[image.RGBA] { |
| 19 | - | if err == nil || !strings.Contains(err.Error(), "unsupported list operations") { | 20 | + | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) |
| 20 | - | t.Fatalf("err=%v", err) | 21 | + | ref := weak.Make(img) |
| 22 | + | _, err := s.tickFrame(render.Frame{Quads: []render.Quad{{Op: "image", Image: img}, {Op: "bogus"}}}) | |||
| 23 | + | if err == nil || !strings.Contains(err.Error(), "unsupported list operations") { | |||
| 24 | + | t.Fatalf("err=%v", err) | |||
| 25 | + | } | |||
| 26 | + | return ref | |||
| 27 | + | }() | |||
| 28 | + | runtime.GC() | |||
| 29 | + | if ref.Value() != nil { | |||
| 30 | + | t.Fatal("idle list buffer retains the frame image") | |||
| 21 | } | 31 | } | ||
| 22 | - | instances, batches, _ := s.list.List(render.Frame{}) | 32 | + | runtime.KeepAlive(s) |
| 23 | - | if len(instances) != 0 || len(batches) != 0 { | |||
| 24 | - | t.Fatal("stale list state") | |||
| 25 | - | } | |||
| 26 | - | s.list.Release() | |||
| 27 | } | 33 | } | ||
M internal/presentation/syncobj/syncobj.go +7 -5
| @@ -75,8 +75,10 @@ func (n *Node) Close() error { | |||
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | 77 | // ioctlErrno performs the ioctl and returns the raw errno without allocating. | |
| 78 | - | // arg must stay reachable by the caller until this returns (callers use | |
| 79 | - | // runtime.KeepAlive when arg embeds uintptr-encoded pointers). | |
| 78 | + | // Embedded addresses must not move: no split-checked call may occur between | |
| 79 | + | // encoding them and the syscall. KeepAlive alone does not prevent stack moves. | |
| 80 | + | // | |
| 81 | + | //go:nosplit | |
| 80 | 82 | func (n *Node) ioctlErrno(request uintptr, arg unsafe.Pointer) unix.Errno { | |
| 81 | 83 | _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(n.fd), request, uintptr(arg)) | |
| 82 | 84 | return errno | |
| @@ -216,11 +218,11 @@ func (n *Node) Query(handle uint32) (uint64, error) { | |||
| 216 | 218 | h := handle | |
| 217 | 219 | var point uint64 | |
| 218 | 220 | a := queryArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&point))), Count: 1} | |
| 219 | - | err := n.ioctl(ioctlQuery, unsafe.Pointer(&a)) | |
| 221 | + | errno := n.ioctlErrno(ioctlQuery, unsafe.Pointer(&a)) | |
| 220 | 222 | runtime.KeepAlive(h) | |
| 221 | 223 | runtime.KeepAlive(point) | |
| 222 | - | if err != nil { | |
| 223 | - | return 0, err | |
| 224 | + | if errno != 0 { | |
| 225 | + | return 0, ioctlError(ioctlQuery, errno) | |
| 224 | 226 | } | |
| 225 | 227 | return point, nil | |
| 226 | 228 | } | |
| @@ -75,8 +75,10 @@ func (n *Node) Close() error { | |||||
| 75 | } | 75 | } | ||
| 76 | 76 | ||||
| 77 | // ioctlErrno performs the ioctl and returns the raw errno without allocating. | 77 | // ioctlErrno performs the ioctl and returns the raw errno without allocating. | ||
| 78 | - | // arg must stay reachable by the caller until this returns (callers use | 78 | + | // Embedded addresses must not move: no split-checked call may occur between |
| 79 | - | // runtime.KeepAlive when arg embeds uintptr-encoded pointers). | 79 | + | // encoding them and the syscall. KeepAlive alone does not prevent stack moves. |
| 80 | + | // | |||
| 81 | + | //go:nosplit | |||
| 80 | func (n *Node) ioctlErrno(request uintptr, arg unsafe.Pointer) unix.Errno { | 82 | func (n *Node) ioctlErrno(request uintptr, arg unsafe.Pointer) unix.Errno { | ||
| 81 | _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(n.fd), request, uintptr(arg)) | 83 | _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(n.fd), request, uintptr(arg)) | ||
| 82 | return errno | 84 | return errno | ||
| @@ -216,11 +218,11 @@ func (n *Node) Query(handle uint32) (uint64, error) { | |||||
| 216 | h := handle | 218 | h := handle | ||
| 217 | var point uint64 | 219 | var point uint64 | ||
| 218 | a := queryArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&point))), Count: 1} | 220 | a := queryArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&point))), Count: 1} | ||
| 219 | - | err := n.ioctl(ioctlQuery, unsafe.Pointer(&a)) | 221 | + | errno := n.ioctlErrno(ioctlQuery, unsafe.Pointer(&a)) |
| 220 | runtime.KeepAlive(h) | 222 | runtime.KeepAlive(h) | ||
| 221 | runtime.KeepAlive(point) | 223 | runtime.KeepAlive(point) | ||
| 222 | - | if err != nil { | 224 | + | if errno != 0 { |
| 223 | - | return 0, err | 225 | + | return 0, ioctlError(ioctlQuery, errno) |
| 224 | } | 226 | } | ||
| 225 | return point, nil | 227 | return point, nil | ||
| 226 | } | 228 | } | ||
M internal/render/prepare.go +9 -5
| @@ -74,9 +74,12 @@ func scaled(e layout.Edges, scale float64) layout.Edges { | |||
| 74 | 74 | return layout.Edges{Top: e.Top * scale, Right: e.Right * scale, Bottom: e.Bottom * scale, Left: e.Left * scale} | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | - | // quadCapacity is an upper bound on the quads Prepare can emit: one per glyph | |
| 78 | - | // and one per drawable command. Clipped-out primitives make it a slight | |
| 79 | - | // over-estimate, never an under-estimate, so appends do not reallocate. | |
| 77 | + | // maxQuadPrealloc bounds speculative storage for mostly clipped documents. | |
| 78 | + | // Visible frames larger than this grow normally as their quads are emitted. | |
| 79 | + | const maxQuadPrealloc = 2048 | |
| 80 | + | ||
| 81 | + | // quadCapacity estimates one quad per glyph and drawable command, capped so | |
| 82 | + | // offscreen content cannot force a large allocation before culling. | |
| 80 | 83 | func quadCapacity(commands []layout.Command) int { | |
| 81 | 84 | n := 0 | |
| 82 | 85 | for i := range commands { | |
| @@ -90,7 +93,7 @@ func quadCapacity(commands []layout.Command) int { | |||
| 90 | 93 | n++ | |
| 91 | 94 | } | |
| 92 | 95 | } | |
| 93 | - | return n | |
| 96 | + | return min(n, maxQuadPrealloc) | |
| 94 | 97 | } | |
| 95 | 98 | ||
| 96 | 99 | // Prepare converts logical rectangles once, using layout.Physical as the sole | |
| @@ -107,7 +110,8 @@ func (p *Preparer) Prepare(commands []layout.Command, scale float64, width, heig | |||
| 107 | 110 | frame := Frame{} | |
| 108 | 111 | if n := quadCapacity(commands); n > 0 { | |
| 109 | 112 | // Frames are returned detached and may outlive later Prepare calls, so | |
| 110 | - | // the backing array is never reused; size it once instead of regrowing. | |
| 113 | + | // the backing array is never reused. Bound the initial reservation; | |
| 114 | + | // larger visible frames grow through append after culling. | |
| 111 | 115 | frame.Quads = make([]Quad, 0, n) | |
| 112 | 116 | } | |
| 113 | 117 | clip := layout.Rect{W: float64(width), H: float64(height)} | |
| @@ -74,9 +74,12 @@ func scaled(e layout.Edges, scale float64) layout.Edges { | |||||
| 74 | return layout.Edges{Top: e.Top * scale, Right: e.Right * scale, Bottom: e.Bottom * scale, Left: e.Left * scale} | 74 | return layout.Edges{Top: e.Top * scale, Right: e.Right * scale, Bottom: e.Bottom * scale, Left: e.Left * scale} | ||
| 75 | } | 75 | } | ||
| 76 | 76 | ||||
| 77 | - | // quadCapacity is an upper bound on the quads Prepare can emit: one per glyph | 77 | + | // maxQuadPrealloc bounds speculative storage for mostly clipped documents. |
| 78 | - | // and one per drawable command. Clipped-out primitives make it a slight | 78 | + | // Visible frames larger than this grow normally as their quads are emitted. |
| 79 | - | // over-estimate, never an under-estimate, so appends do not reallocate. | 79 | + | const maxQuadPrealloc = 2048 |
| 80 | + | ||||
| 81 | + | // quadCapacity estimates one quad per glyph and drawable command, capped so | |||
| 82 | + | // offscreen content cannot force a large allocation before culling. | |||
| 80 | func quadCapacity(commands []layout.Command) int { | 83 | func quadCapacity(commands []layout.Command) int { | ||
| 81 | n := 0 | 84 | n := 0 | ||
| 82 | for i := range commands { | 85 | for i := range commands { | ||
| @@ -90,7 +93,7 @@ func quadCapacity(commands []layout.Command) int { | |||||
| 90 | n++ | 93 | n++ | ||
| 91 | } | 94 | } | ||
| 92 | } | 95 | } | ||
| 93 | - | return n | 96 | + | return min(n, maxQuadPrealloc) |
| 94 | } | 97 | } | ||
| 95 | 98 | ||||
| 96 | // Prepare converts logical rectangles once, using layout.Physical as the sole | 99 | // Prepare converts logical rectangles once, using layout.Physical as the sole | ||
| @@ -107,7 +110,8 @@ func (p *Preparer) Prepare(commands []layout.Command, scale float64, width, heig | |||||
| 107 | frame := Frame{} | 110 | frame := Frame{} | ||
| 108 | if n := quadCapacity(commands); n > 0 { | 111 | if n := quadCapacity(commands); n > 0 { | ||
| 109 | // Frames are returned detached and may outlive later Prepare calls, so | 112 | // Frames are returned detached and may outlive later Prepare calls, so | ||
| 110 | - | // the backing array is never reused; size it once instead of regrowing. | 113 | + | // the backing array is never reused. Bound the initial reservation; |
| 114 | + | // larger visible frames grow through append after culling. | |||
| 111 | frame.Quads = make([]Quad, 0, n) | 115 | frame.Quads = make([]Quad, 0, n) | ||
| 112 | } | 116 | } | ||
| 113 | clip := layout.Rect{W: float64(width), H: float64(height)} | 117 | clip := layout.Rect{W: float64(width), H: float64(height)} | ||
A internal/render/prepare_culling_test.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | package render | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefergui/internal/layout" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestPrepareBoundsReservationForClippedGlyphs(t *testing.T) { | |
| 10 | + | for _, op := range []string{"text", "preedit-text"} { | |
| 11 | + | t.Run(op, func(t *testing.T) { | |
| 12 | + | cmd := glyphCommand(t, "a") | |
| 13 | + | cmd.Op = op | |
| 14 | + | run := cmd.Runs[0] | |
| 15 | + | glyph := run.Glyphs[0] | |
| 16 | + | run.Glyphs = make([]layout.Glyph, 50000) | |
| 17 | + | for i := range run.Glyphs { | |
| 18 | + | run.Glyphs[i] = glyph | |
| 19 | + | run.Glyphs[i].Y = 10000 | |
| 20 | + | } | |
| 21 | + | cmd.Runs = []layout.Run{run} | |
| 22 | + | p, err := NewPreparer(256, 2) | |
| 23 | + | if err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | f, err := p.Prepare([]layout.Command{cmd}, 1, 100, 100) | |
| 27 | + | if err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | if len(f.Quads) != 0 || cap(f.Quads) > maxQuadPrealloc { | |
| 31 | + | t.Fatalf("clipped glyphs: len=%d cap=%d", len(f.Quads), cap(f.Quads)) | |
| 32 | + | } | |
| 33 | + | }) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestPrepareGrowsBeyondReservationForVisibleQuads(t *testing.T) { | |
| 38 | + | p, err := NewPreparer(256, 2) | |
| 39 | + | if err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | cmds := rectCommands(maxQuadPrealloc + 100) | |
| 43 | + | f, err := p.Prepare(cmds, 1, 100, len(cmds)+1) | |
| 44 | + | if err != nil || len(f.Quads) != len(cmds) { | |
| 45 | + | t.Fatalf("visible quads: got %d want %d, error %v", len(f.Quads), len(cmds), err) | |
| 46 | + | } | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | package render | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/nefergui/internal/layout" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestPrepareBoundsReservationForClippedGlyphs(t *testing.T) { | |||
| 10 | + | for _, op := range []string{"text", "preedit-text"} { | |||
| 11 | + | t.Run(op, func(t *testing.T) { | |||
| 12 | + | cmd := glyphCommand(t, "a") | |||
| 13 | + | cmd.Op = op | |||
| 14 | + | run := cmd.Runs[0] | |||
| 15 | + | glyph := run.Glyphs[0] | |||
| 16 | + | run.Glyphs = make([]layout.Glyph, 50000) | |||
| 17 | + | for i := range run.Glyphs { | |||
| 18 | + | run.Glyphs[i] = glyph | |||
| 19 | + | run.Glyphs[i].Y = 10000 | |||
| 20 | + | } | |||
| 21 | + | cmd.Runs = []layout.Run{run} | |||
| 22 | + | p, err := NewPreparer(256, 2) | |||
| 23 | + | if err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | f, err := p.Prepare([]layout.Command{cmd}, 1, 100, 100) | |||
| 27 | + | if err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | if len(f.Quads) != 0 || cap(f.Quads) > maxQuadPrealloc { | |||
| 31 | + | t.Fatalf("clipped glyphs: len=%d cap=%d", len(f.Quads), cap(f.Quads)) | |||
| 32 | + | } | |||
| 33 | + | }) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestPrepareGrowsBeyondReservationForVisibleQuads(t *testing.T) { | |||
| 38 | + | p, err := NewPreparer(256, 2) | |||
| 39 | + | if err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 42 | + | cmds := rectCommands(maxQuadPrealloc + 100) | |||
| 43 | + | f, err := p.Prepare(cmds, 1, 100, len(cmds)+1) | |||
| 44 | + | if err != nil || len(f.Quads) != len(cmds) { | |||
| 45 | + | t.Fatalf("visible quads: got %d want %d, error %v", len(f.Quads), len(cmds), err) | |||
| 46 | + | } | |||
| 47 | + | } | |||