detached d1933fc

test: cover stale release notification, old-generation pending waits and sticky failure

Use cmp.Compare, check Signal errors, assert the unchanged Render path and
poll Pending in the allocation guard instead of sleeping blindly.
commit
d1933fcb2deafdb3773cf327138ca5d1229539e5
author
brice · · 2026-10-01 17:40 UTC
parent
84a151f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +97 -29
M internal/presentation/session/session_test.go +30 -0
@@ -3,8 +3,10 @@
33 package session
44
55 import (
6+ "errors"
67 "testing"
78
9+ "github.com/bnema/nefergui/internal/presentation/buffers"
810 "github.com/bnema/nefergui/internal/presentation/vkdevice"
911 )
1012
@@ -24,3 +26,31 @@ func TestInstallReleaseWaitDefersUnreadySubmission(t *testing.T) {
2426 t.Fatalf("failed replacement: installed=%v err=%v slot=%+v", installed, err, slot)
2527 }
2628 }
29+
30+// A resize while a release wait is outstanding leaves old-generation slots to
31+// retire: finishPending must not touch them, even if their frame is not ready.
32+func TestTargetFinishPendingSkipsOldGenerations(t *testing.T) {
33+ pool, _ := buffers.New(1)
34+ old := pool.Buffers[0]
35+ if err := pool.Resize(1); err != nil {
36+ t.Fatal(err)
37+ }
38+ // A nil Frame would panic if the old slot were examined.
39+ tg := &Target{Pool: pool, Slots: map[uint64]*Slot{old.ID: {Buffer: old, PendingWait: &vkdevice.BinaryFence{}}}}
40+ pending, err := tg.finishPending()
41+ if err != nil || pending {
42+ t.Fatalf("pending=%v err=%v", pending, err)
43+ }
44+ if old.State != buffers.Available || tg.Slots[old.ID].PendingWait == nil {
45+ t.Fatal("old-generation slot was modified")
46+ }
47+}
48+
49+func TestTargetBrokenIsSticky(t *testing.T) {
50+ tg := &Target{wantW: 1, wantH: 1}
51+ boom := errors.New("boom")
52+ first := tg.fail(boom)
53+ if _, err := tg.Draw(nil, 1, &Output{}); !errors.Is(err, boom) || err != first {
54+ t.Fatalf("Draw after failure: %v", err)
55+ }
56+}
@@ -3,8 +3,10 @@
3 package session3 package session
4 4
5 import (5 import (
6+ "errors"
6 "testing"7 "testing"
7 8
9+ "github.com/bnema/nefergui/internal/presentation/buffers"
8 "github.com/bnema/nefergui/internal/presentation/vkdevice"10 "github.com/bnema/nefergui/internal/presentation/vkdevice"
9 )11 )
10 12
@@ -24,3 +26,31 @@ func TestInstallReleaseWaitDefersUnreadySubmission(t *testing.T) {
24 t.Fatalf("failed replacement: installed=%v err=%v slot=%+v", installed, err, slot)26 t.Fatalf("failed replacement: installed=%v err=%v slot=%+v", installed, err, slot)
25 }27 }
26 }28 }
29+
30+// A resize while a release wait is outstanding leaves old-generation slots to
31+// retire: finishPending must not touch them, even if their frame is not ready.
32+func TestTargetFinishPendingSkipsOldGenerations(t *testing.T) {
33+ pool, _ := buffers.New(1)
34+ old := pool.Buffers[0]
35+ if err := pool.Resize(1); err != nil {
36+ t.Fatal(err)
37+ }
38+ // A nil Frame would panic if the old slot were examined.
39+ tg := &Target{Pool: pool, Slots: map[uint64]*Slot{old.ID: {Buffer: old, PendingWait: &vkdevice.BinaryFence{}}}}
40+ pending, err := tg.finishPending()
41+ if err != nil || pending {
42+ t.Fatalf("pending=%v err=%v", pending, err)
43+ }
44+ if old.State != buffers.Available || tg.Slots[old.ID].PendingWait == nil {
45+ t.Fatal("old-generation slot was modified")
46+ }
47+}
48+
49+func TestTargetBrokenIsSticky(t *testing.T) {
50+ tg := &Target{wantW: 1, wantH: 1}
51+ boom := errors.New("boom")
52+ first := tg.fail(boom)
53+ if _, err := tg.Draw(nil, 1, &Output{}); !errors.Is(err, boom) || err != first {
54+ t.Fatalf("Draw after failure: %v", err)
55+ }
56+}
M internal/ui/renderer_gpu_test.go +52 -16
@@ -3,6 +3,7 @@
33 package ui
44
55 import (
6+ "cmp"
67 "os"
78 "slices"
89 "testing"
@@ -39,7 +40,7 @@ func gpuRenderer(t testing.TB) (*Renderer, *session.Target) {
3940 for m := range mods {
4041 formats = append(formats, Format{FourCC: vkdevice.XRGB8888, Modifier: m})
4142 }
42- slices.SortFunc(formats, func(a, b Format) int { return int(a.Modifier>>1) - int(b.Modifier>>1) })
43+ slices.SortFunc(formats, func(a, b Format) int { return cmp.Compare(a.Modifier, b.Modifier) })
4344 vf := make([]vkdevice.Format, len(formats))
4445 for i, f := range formats {
4546 vf[i] = vkdevice.Format{FourCC: f.FourCC, Modifier: f.Modifier}
@@ -205,24 +206,19 @@ func TestAllocRendererSteadyFrame(t *testing.T) {
205206 step := func() {
206207 n++
207208 r.Invalidate()
208- // A false result can only mean the GPU still works on the previous
209- // frame of every free buffer; the application retries shortly.
210- ok := false
211- for tries := 0; !ok && tries < 1000; tries++ {
212- var err error
213- if ok, err = r.Render(&out, &n, view); err != nil {
214- t.Fatal(err)
215- }
216- if !ok {
217- time.Sleep(time.Millisecond)
218- }
209+ ok, err := r.Render(&out, &n, view)
210+ for tries := 0; err == nil && !ok && r.Pending() && tries < 5000; tries++ {
211+ time.Sleep(200 * time.Microsecond)
212+ ok, err = r.Render(&out, &n, view)
219213 }
220- if !ok {
221- t.Fatal("render never found a ready buffer")
214+ if err != nil || !ok {
215+ t.Fatalf("render: ok=%v err=%v pending=%v", ok, err, r.Pending())
222216 }
223217 slot := tg.Slots[out.Buffer]
224- _ = tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint)
225- if err := r.Released(out.Buffer); err != nil {
218+ if err = tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint); err != nil {
219+ t.Fatal(err)
220+ }
221+ if err = r.Released(out.Buffer); err != nil {
226222 t.Fatal(err)
227223 }
228224 }
@@ -235,3 +231,43 @@ func TestAllocRendererSteadyFrame(t *testing.T) {
235231 t.Fatalf("allocs per steady frame = %v, baseline %v", got, allocBaselineRenderer)
236232 }
237233 }
234+
235+// A readable eventfd whose point is not signaled (a stale notification) must be
236+// drained without spinning, and the live registration must still work.
237+func TestRendererGPUStaleReleaseNotification(t *testing.T) {
238+ r, tg := gpuRenderer(t)
239+ var out Output
240+ var m struct{}
241+ view := func(f *Frame, _ *struct{}) { f.Root().Box(Inline("width:10px;height:10px;background:#fff")) }
242+ r.Resize(64, 64, 1)
243+ if ok, err := r.Render(&out, &m, view); !ok || err != nil {
244+ t.Fatalf("ok=%v err=%v", ok, err)
245+ }
246+ one := [8]byte{1}
247+ if _, err := unix.Write(out.ReleaseFD, one[:]); err != nil {
248+ t.Fatal(err)
249+ }
250+ if !readable(t, out.ReleaseFD, time.Second) {
251+ t.Fatal("test setup: eventfd not readable")
252+ }
253+ if err := r.Released(out.Buffer); err != nil {
254+ t.Fatal(err)
255+ }
256+ if readable(t, out.ReleaseFD, 0) {
257+ t.Fatal("stale notification left the eventfd readable: the caller would spin")
258+ }
259+ if tg.Slots[out.Buffer].Buffer.State == "available" {
260+ t.Fatal("buffer reused before the compositor released it")
261+ }
262+ signalRelease(t, tg, &out)
263+ if !readable(t, out.ReleaseFD, time.Second) {
264+ t.Fatal("live registration lost after a stale notification")
265+ }
266+ if err := r.Released(out.Buffer); err != nil {
267+ t.Fatal(err)
268+ }
269+ // Released for an unknown or idle buffer is a no-op that still drains.
270+ if err := r.Released(999); err != nil {
271+ t.Fatal(err)
272+ }
273+}
@@ -3,6 +3,7 @@
3 package ui3 package ui
4 4
5 import (5 import (
6+ "cmp"
6 "os"7 "os"
7 "slices"8 "slices"
8 "testing"9 "testing"
@@ -39,7 +40,7 @@ func gpuRenderer(t testing.TB) (*Renderer, *session.Target) {
39 for m := range mods {40 for m := range mods {
40 formats = append(formats, Format{FourCC: vkdevice.XRGB8888, Modifier: m})41 formats = append(formats, Format{FourCC: vkdevice.XRGB8888, Modifier: m})
41 }42 }
42- slices.SortFunc(formats, func(a, b Format) int { return int(a.Modifier>>1) - int(b.Modifier>>1) })43+ slices.SortFunc(formats, func(a, b Format) int { return cmp.Compare(a.Modifier, b.Modifier) })
43 vf := make([]vkdevice.Format, len(formats))44 vf := make([]vkdevice.Format, len(formats))
44 for i, f := range formats {45 for i, f := range formats {
45 vf[i] = vkdevice.Format{FourCC: f.FourCC, Modifier: f.Modifier}46 vf[i] = vkdevice.Format{FourCC: f.FourCC, Modifier: f.Modifier}
@@ -205,24 +206,19 @@ func TestAllocRendererSteadyFrame(t *testing.T) {
205 step := func() {206 step := func() {
206 n++207 n++
207 r.Invalidate()208 r.Invalidate()
208- // A false result can only mean the GPU still works on the previous209+ ok, err := r.Render(&out, &n, view)
209- // frame of every free buffer; the application retries shortly.210+ for tries := 0; err == nil && !ok && r.Pending() && tries < 5000; tries++ {
210- ok := false211+ time.Sleep(200 * time.Microsecond)
211- for tries := 0; !ok && tries < 1000; tries++ {212+ ok, err = r.Render(&out, &n, view)
212- var err error
213- if ok, err = r.Render(&out, &n, view); err != nil {
214- t.Fatal(err)
215- }
216- if !ok {
217- time.Sleep(time.Millisecond)
218- }
219 }213 }
220- if !ok {214+ if err != nil || !ok {
221- t.Fatal("render never found a ready buffer")215+ t.Fatalf("render: ok=%v err=%v pending=%v", ok, err, r.Pending())
222 }216 }
223 slot := tg.Slots[out.Buffer]217 slot := tg.Slots[out.Buffer]
224- _ = tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint)218+ if err = tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint); err != nil {
225- if err := r.Released(out.Buffer); err != nil {219+ t.Fatal(err)
220+ }
221+ if err = r.Released(out.Buffer); err != nil {
226 t.Fatal(err)222 t.Fatal(err)
227 }223 }
228 }224 }
@@ -235,3 +231,43 @@ func TestAllocRendererSteadyFrame(t *testing.T) {
235 t.Fatalf("allocs per steady frame = %v, baseline %v", got, allocBaselineRenderer)231 t.Fatalf("allocs per steady frame = %v, baseline %v", got, allocBaselineRenderer)
236 }232 }
237 }233 }
234+
235+// A readable eventfd whose point is not signaled (a stale notification) must be
236+// drained without spinning, and the live registration must still work.
237+func TestRendererGPUStaleReleaseNotification(t *testing.T) {
238+ r, tg := gpuRenderer(t)
239+ var out Output
240+ var m struct{}
241+ view := func(f *Frame, _ *struct{}) { f.Root().Box(Inline("width:10px;height:10px;background:#fff")) }
242+ r.Resize(64, 64, 1)
243+ if ok, err := r.Render(&out, &m, view); !ok || err != nil {
244+ t.Fatalf("ok=%v err=%v", ok, err)
245+ }
246+ one := [8]byte{1}
247+ if _, err := unix.Write(out.ReleaseFD, one[:]); err != nil {
248+ t.Fatal(err)
249+ }
250+ if !readable(t, out.ReleaseFD, time.Second) {
251+ t.Fatal("test setup: eventfd not readable")
252+ }
253+ if err := r.Released(out.Buffer); err != nil {
254+ t.Fatal(err)
255+ }
256+ if readable(t, out.ReleaseFD, 0) {
257+ t.Fatal("stale notification left the eventfd readable: the caller would spin")
258+ }
259+ if tg.Slots[out.Buffer].Buffer.State == "available" {
260+ t.Fatal("buffer reused before the compositor released it")
261+ }
262+ signalRelease(t, tg, &out)
263+ if !readable(t, out.ReleaseFD, time.Second) {
264+ t.Fatal("live registration lost after a stale notification")
265+ }
266+ if err := r.Released(out.Buffer); err != nil {
267+ t.Fatal(err)
268+ }
269+ // Released for an unknown or idle buffer is a no-op that still drains.
270+ if err := r.Released(999); err != nil {
271+ t.Fatal(err)
272+ }
273+}
M internal/ui/renderer_test.go +15 -13
@@ -78,19 +78,9 @@ func TestRendererRenderBeforeResizeAndWhenUnchanged(t *testing.T) {
7878 if out.Buffer != 1 || out.Width != 100 || len(out.Damage) != 1 || out.Damage[0] != (Rect{Width: 100, Height: 50}) {
7979 t.Fatalf("output: %+v", out)
8080 }
81- // The follow-up frame a Build requests after settling still draws once; after that nothing changes.
82- for i := 0; i < 3; i++ {
83- ok, err := r.Render(&out, &m, testView)
84- if err != nil {
85- t.Fatal(err)
86- }
87- if ok {
88- drawOK(tg, nil).Maybe()
89- }
90- }
91- // tg asserts no Draw beyond the allowed ones when unchanged.
92- if ok, _ := r.Render(&out, &m, testView); ok {
93- t.Fatal("unchanged view drew")
81+ // Nothing changed: no rebuild result, so no Draw (the mock has no further expectation).
82+ if ok, err := r.Render(&out, &m, testView); ok || err != nil {
83+ t.Fatalf("unchanged view: ok=%v err=%v", ok, err)
9484 }
9585 }
9686
@@ -101,9 +91,13 @@ func TestRendererKeepsFrameWhenNoBufferFree(t *testing.T) {
10191 tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
10292 r.Resize(100, 50, 1)
10393 tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Once()
94+ tg.EXPECT().Waiting().Return(true).Once()
10495 if ok, err := r.Render(&out, &m, testView); ok || err != nil {
10596 t.Fatalf("no buffer: ok=%v err=%v", ok, err)
10697 }
98+ if !r.Pending() {
99+ t.Fatal("a built frame blocked on the GPU must report Pending")
100+ }
107101 drawOK(tg, nil).Once()
108102 if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
109103 t.Fatalf("retry: ok=%v err=%v", ok, err)
@@ -249,6 +243,14 @@ func TestRendererForwardsReleasedAndClose(t *testing.T) {
249243 if err := r.Close(); err != nil {
250244 t.Fatal(err)
251245 }
246+ // After Close nothing reaches the target (the mock has no expectation).
247+ r.Resize(10, 10, 1)
248+ if r.Input(&Input{Kind: InputPointerMotion}) {
249+ t.Fatal("Input after Close requested a redraw")
250+ }
251+ if err := r.Released(3); err == nil {
252+ t.Fatal("Released after Close succeeded")
253+ }
252254 if err := r.Close(); err != nil { // second Close is a no-op
253255 t.Fatal(err)
254256 }
@@ -78,19 +78,9 @@ func TestRendererRenderBeforeResizeAndWhenUnchanged(t *testing.T) {
78 if out.Buffer != 1 || out.Width != 100 || len(out.Damage) != 1 || out.Damage[0] != (Rect{Width: 100, Height: 50}) {78 if out.Buffer != 1 || out.Width != 100 || len(out.Damage) != 1 || out.Damage[0] != (Rect{Width: 100, Height: 50}) {
79 t.Fatalf("output: %+v", out)79 t.Fatalf("output: %+v", out)
80 }80 }
81- // The follow-up frame a Build requests after settling still draws once; after that nothing changes.81+ // Nothing changed: no rebuild result, so no Draw (the mock has no further expectation).
82- for i := 0; i < 3; i++ {82+ if ok, err := r.Render(&out, &m, testView); ok || err != nil {
83- ok, err := r.Render(&out, &m, testView)83+ t.Fatalf("unchanged view: ok=%v err=%v", ok, err)
84- if err != nil {
85- t.Fatal(err)
86- }
87- if ok {
88- drawOK(tg, nil).Maybe()
89- }
90- }
91- // tg asserts no Draw beyond the allowed ones when unchanged.
92- if ok, _ := r.Render(&out, &m, testView); ok {
93- t.Fatal("unchanged view drew")
94 }84 }
95 }85 }
96 86
@@ -101,9 +91,13 @@ func TestRendererKeepsFrameWhenNoBufferFree(t *testing.T) {
101 tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()91 tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
102 r.Resize(100, 50, 1)92 r.Resize(100, 50, 1)
103 tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Once()93 tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Once()
94+ tg.EXPECT().Waiting().Return(true).Once()
104 if ok, err := r.Render(&out, &m, testView); ok || err != nil {95 if ok, err := r.Render(&out, &m, testView); ok || err != nil {
105 t.Fatalf("no buffer: ok=%v err=%v", ok, err)96 t.Fatalf("no buffer: ok=%v err=%v", ok, err)
106 }97 }
98+ if !r.Pending() {
99+ t.Fatal("a built frame blocked on the GPU must report Pending")
100+ }
107 drawOK(tg, nil).Once()101 drawOK(tg, nil).Once()
108 if ok, err := r.Render(&out, &m, testView); !ok || err != nil {102 if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
109 t.Fatalf("retry: ok=%v err=%v", ok, err)103 t.Fatalf("retry: ok=%v err=%v", ok, err)
@@ -249,6 +243,14 @@ func TestRendererForwardsReleasedAndClose(t *testing.T) {
249 if err := r.Close(); err != nil {243 if err := r.Close(); err != nil {
250 t.Fatal(err)244 t.Fatal(err)
251 }245 }
246+ // After Close nothing reaches the target (the mock has no expectation).
247+ r.Resize(10, 10, 1)
248+ if r.Input(&Input{Kind: InputPointerMotion}) {
249+ t.Fatal("Input after Close requested a redraw")
250+ }
251+ if err := r.Released(3); err == nil {
252+ t.Fatal("Released after Close succeeded")
253+ }
252 if err := r.Close(); err != nil { // second Close is a no-op254 if err := r.Close(); err != nil { // second Close is a no-op
253 t.Fatal(err)255 t.Fatal(err)
254 }256 }

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