detached c1d1834

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
835835 total++
836836 }
837837 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)
844839 }
845840 j := 0
846841 for _, child := range n.Children {
@@ -862,6 +857,16 @@ func countCommands(n *Node, r *Result) (c counts) {
862857
863858 type counts struct{ commands, shadows, runs, glyphs int }
864859
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+
865870 func (c context) paint(n *Node, r *Result, out *[]Command) {
866871 if r == nil {
867872 return
@@ -936,13 +941,7 @@ func (c context) paint(n *Node, r *Result, out *[]Command) {
936941 cmd.Rect = r.Content
937942 cmd.Text = n.Content
938943 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)
946945 cmd.Runs = c.runs.take(runs)
947946 backing := c.glyphs.take(glyphs)
948947 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 := 0840 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 return872 return
@@ -936,13 +941,7 @@ func (c context) paint(n *Node, r *Result, out *[]Command) {
936 cmd.Rect = r.Content941 cmd.Rect = r.Content
937 cmd.Text = n.Content942 cmd.Text = n.Content
938 cmd.Color = s.Color943 cmd.Color = s.Color
939- runs, glyphs := 0, 0944+ 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 @@
11 package layout
22
33 import (
4+ "image"
45 "reflect"
56 "testing"
67
@@ -104,6 +105,13 @@ func TestPaintRunAndGlyphCapacityIsolated(t *testing.T) {
104105 // guarantee silently degrades.
105106 func TestPaintCountsMatch(t *testing.T) {
106107 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+ )
107115 ctx := context{options: opts}
108116 tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
109117 if err != nil {
@@ -1,6 +1,7 @@
1 package layout1 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
44
55 import (
66 "image"
7+ "runtime"
78 "strings"
89 "testing"
10+ "weak"
911
1012 "github.com/bnema/nefergui/internal/render"
1113 )
@@ -13,15 +15,19 @@ import (
1315 // An unsupported quad is rejected before any GPU work, and the borrowed list
1416 // buffer is released so it never pins the frame's image.
1517 func TestTickFrameRejectsUnsupportedAndReleasesImages(t *testing.T) {
16- img := image.NewRGBA(image.Rect(0, 0, 1, 1))
1718 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")
2131 }
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)
2733 }
@@ -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 list15 // 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 {
7575 }
7676
7777 // 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
8082 func (n *Node) ioctlErrno(request uintptr, arg unsafe.Pointer) unix.Errno {
8183 _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(n.fd), request, uintptr(arg))
8284 return errno
@@ -216,11 +218,11 @@ func (n *Node) Query(handle uint32) (uint64, error) {
216218 h := handle
217219 var point uint64
218220 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))
220222 runtime.KeepAlive(h)
221223 runtime.KeepAlive(point)
222- if err != nil {
223- return 0, err
224+ if errno != 0 {
225+ return 0, ioctlError(ioctlQuery, errno)
224226 }
225227 return point, nil
226228 }
@@ -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 use78+// 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 errno84 return errno
@@ -216,11 +218,11 @@ func (n *Node) Query(handle uint32) (uint64, error) {
216 h := handle218 h := handle
217 var point uint64219 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, err225+ return 0, ioctlError(ioctlQuery, errno)
224 }226 }
225 return point, nil227 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 {
7474 return layout.Edges{Top: e.Top * scale, Right: e.Right * scale, Bottom: e.Bottom * scale, Left: e.Left * scale}
7575 }
7676
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.
8083 func quadCapacity(commands []layout.Command) int {
8184 n := 0
8285 for i := range commands {
@@ -90,7 +93,7 @@ func quadCapacity(commands []layout.Command) int {
9093 n++
9194 }
9295 }
93- return n
96+ return min(n, maxQuadPrealloc)
9497 }
9598
9699 // 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
107110 frame := Frame{}
108111 if n := quadCapacity(commands); n > 0 {
109112 // 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.
111115 frame.Quads = make([]Quad, 0, n)
112116 }
113117 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 glyph77+// maxQuadPrealloc bounds speculative storage for mostly clipped documents.
78-// and one per drawable command. Clipped-out primitives make it a slight78+// 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 := 084 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 n96+ return min(n, maxQuadPrealloc)
94 }97 }
95 98
96 // Prepare converts logical rectangles once, using layout.Physical as the sole99 // 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, so112 // 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+}

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 1

Tags 12

↑↓ moveenter openesc close