1package wayland
2
3import (
4 "os"
5 "slices"
6 "testing"
7 "time"
8
9 "github.com/bnema/go-wayland-bindings/server/wayland"
10 "github.com/bnema/neferwl/internal/ports"
11 "github.com/stretchr/testify/mock"
12 "golang.org/x/sys/unix"
13)
14
15// The steady-state hot paths that run for every frame or commit allocate
16// nothing (or one pooled object per damage-history generation).
17
18func TestReleaseHeldAllocations(t *testing.T) {
19 out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
20 s := &Server{outputs: []*output{out}, reports: map[string]ports.OutputPresented{}}
21 reports := []ports.OutputPresented{{Output: "DP-2", Seen: map[ports.WindowID]uint64{1: 1}}}
22 now := time.Unix(100, 0)
23 s.releaseHeld(now, reports)
24 if allocs := testing.AllocsPerRun(100, func() { s.releaseHeld(now, reports) }); allocs != 0 {
25 t.Errorf("releaseHeld: %.1f allocs, want 0", allocs)
26 }
27}
28
29func TestCommitDamageAllocations(t *testing.T) {
30 if raceEnabled {
31 // AllocsPerRun counts every goroutine, and the race runtime
32 // allocates on its own now and then. perf-check runs it without.
33 t.Skip("the race detector allocates in the background")
34 }
35 srv := &Server{fifoSurfaces: map[*surface]struct{}{}}
36 surf := &surface{server: srv}
37 commit := func() {
38 surf.Damage(nil, 0, 0, 4, 4)
39 surf.DamageBuffer(nil, 1, 1, 2, 2)
40 surf.queueUpdate()
41 surf.queue[0].applyGraph()
42 }
43 commit()
44 if allocs := testing.AllocsPerRun(100, commit); allocs != 0 {
45 t.Errorf("damage requests + queueUpdate + apply: %.1f allocs, want 0", allocs)
46 }
47 // The queued update keeps its rects while the next commit collects its own.
48 surf.Damage(nil, 0, 0, 1, 1)
49 surf.queueUpdate()
50 first := surf.queue[0]
51 surf.Damage(nil, 5, 5, 2, 2)
52 if len(first.damage) != 1 || first.damage[0].W != 1 || len(surf.next.damage) != 1 || surf.next.damage[0].W != 2 {
53 t.Fatalf("damage shared between commits: queued %v pending %v", first.damage, surf.next.damage)
54 }
55 surf.queueUpdate()
56 if second := surf.queue[1]; len(second.damage) != 1 || second.damage[0].W != 2 || first.damage[0].W != 1 {
57 t.Fatalf("damage mixed up: %v %v", first.damage, second.damage)
58 }
59}
60
61// An explicit-sync commit moves its points into the update: the commit
62// itself allocates no commitSync, and a pending one is not overwritten.
63func TestCommitSyncStorageIsPerUpdate(t *testing.T) {
64 dev := newMocksyncobjDevice(t)
65 dev.EXPECT().signalled(mock.Anything, mock.Anything).Return(true, nil)
66 srv := &Server{fifoSurfaces: map[*surface]struct{}{}}
67 sw, err := newSyncWaiter(func() {})
68 if err != nil {
69 t.Fatal(err)
70 }
71 defer sw.discard()
72 defer sw.close()
73 srv.syncWait = sw
74 tl := &timeline{dev: dev, handle: 7, alive: true}
75 surf := &surface{server: srv, sync: &syncState{}}
76 take := func(point uint64) {
77 surf.sync.acquire, surf.sync.release = syncPoint{tl: tl, point: point}, syncPoint{tl: tl, point: point + 1}
78 surf.takeSyncPoints()
79 surf.queueUpdate()
80 }
81 take(10)
82 take(20)
83 a, b := surf.queue[0], surf.queue[1]
84 if a.sync == nil || b.sync == nil || a.sync == b.sync || a.sync.acquire.point != 10 || b.sync.acquire.point != 20 || surf.next.sync != nil {
85 t.Fatalf("sync points mixed up: %+v %+v pending %+v", a.sync, b.sync, surf.next.sync)
86 }
87 // The commit's points live in the surface until queueUpdate: no heap
88 // commitSync per commit (the mocked device's own allocations would drown
89 // an AllocsPerRun count here).
90 surf.sync.acquire, surf.sync.release = syncPoint{tl: tl, point: 30}, syncPoint{tl: tl, point: 31}
91 surf.takeSyncPoints()
92 if surf.next.sync != &surf.syncScratch {
93 t.Fatal("commit points not held in the surface scratch")
94 }
95}
96
97func TestEmitContentAllocations(t *testing.T) {
98 s := &Server{
99 contentSeq: map[ports.WindowID]uint64{},
100 damage: map[ports.WindowID][]ports.SeqDamage{},
101 latest: map[ports.WindowID]ports.SurfaceContent{},
102 }
103 c := ports.SurfaceContent{ID: 1, Solid: &ports.SolidColor{}}
104 var held []ports.SurfaceContent // readers retaining published histories
105 var snapshots [][]ports.SeqDamage
106 for range 3 * damageHistoryCap {
107 s.emitContent(c, damage{full: true})
108 got := s.latest[1]
109 held = append(held, got)
110 snapshots = append(snapshots, append([]ports.SeqDamage(nil), got.DamageHistory...))
111 }
112 for i, h := range held {
113 // A published history never changes, nor can its owner append to it.
114 if len(h.DamageHistory) != len(snapshots[i]) || cap(h.DamageHistory) != len(h.DamageHistory) {
115 t.Fatalf("history %d: len %d cap %d", i, len(h.DamageHistory), cap(h.DamageHistory))
116 }
117 for j, d := range h.DamageHistory {
118 if d.Seq != snapshots[i][j].Seq || d.Full != snapshots[i][j].Full {
119 t.Fatalf("history %d changed at %d: %+v want %+v", i, j, d, snapshots[i][j])
120 }
121 }
122 if n := len(h.DamageHistory); h.DamageHistory[n-1].Seq != h.Seq || n > damageHistory {
123 t.Fatalf("history %d: %+v", i, h.DamageHistory)
124 }
125 }
126 allocs := testing.AllocsPerRun(100, func() { s.emitContent(c, damage{full: true}) })
127 if allocs != 0 {
128 t.Errorf("emitContent: %.2f allocs, want 0 (amortized)", allocs)
129 }
130 t.Logf("emitContent: %.2f allocs", allocs)
131}
132
133// Firing an output's callbacks keeps its queue's backing array, so the next
134// commit's queueFrames appends in place (before: the queue was deleted by
135// every fire and each commit grew a fresh slice). An emptied queue reads
136// like an absent one, and pins no fired callback.
137func TestFrameQueueKeepsBackingArray(t *testing.T) {
138 s, events, commands, _, _, dir := presentServerOn(t, testOutputs)
139 c := protocolClient(t, s, dir)
140 w, surf, _ := surfaceMapper(t, c, events)()
141 placeWindow(t, s, commands, w, "HEADLESS-1")
142 awaitFrame(t, c, requestFrame(t, c, surf))
143 awaitFrame(t, c, requestFrame(t, c, surf))
144 var first *wayland.Callback
145 var head **wayland.Callback
146 s.display.Do(func() {
147 // The pacer fired the queue during the awaitFrame calls above.
148 queue := s.awaiting["HEADLESS-1"]
149 if len(queue) != 0 || cap(queue) == 0 {
150 t.Errorf("fired queue: len %d cap %d", len(queue), cap(queue))
151 return
152 }
153 first, head = queue[:1][0], &queue[:1][0]
154 })
155 if first != nil {
156 t.Fatal("fired queue pins a callback")
157 }
158 awaitFrame(t, c, requestFrame(t, c, surf))
159 s.display.Do(func() {
160 if again := s.awaiting["HEADLESS-1"]; cap(again) == 0 || &again[:1][0] != head {
161 t.Error("queue reallocated between frames")
162 }
163 })
164}
165
166// dueFrames hands out one scratch slice for every tick.
167func TestDueFramesFireScratch(t *testing.T) {
168 out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
169 s := &Server{outputs: []*output{out}, frameDue: map[string]time.Time{}, awaiting: map[string][]*wayland.Callback{"DP-2": {nil}}}
170 now := time.Unix(100, 0)
171 flipped := map[string]bool{"DP-2": true}
172 fire, _, _ := s.dueFrames(now, flipped)
173 if !slices.Equal(fire, []string{"DP-2"}) {
174 t.Fatalf("fire %v", fire)
175 }
176 if allocs := testing.AllocsPerRun(100, func() { s.dueFrames(now, flipped) }); allocs != 0 {
177 t.Errorf("firing dueFrames: %.1f allocs, want 0", allocs)
178 }
179}
180
181// A buffer sends the same SHMBuffer on every commit and still checks the
182// file's size each time: a client may truncate it.
183func TestBufferContentAllocations(t *testing.T) {
184 fd, err := unix.MemfdCreate("content-allocs", unix.MFD_CLOEXEC)
185 if err != nil {
186 t.Fatal(err)
187 }
188 f := os.NewFile(uintptr(fd), "content-allocs")
189 defer f.Close()
190 if err := f.Truncate(28); err != nil {
191 t.Fatal(err)
192 }
193 b := &buffer{pool: &pool{id: 3, file: f, size: 28}, offset: 4, width: 2, height: 2, stride: 16}
194 first, ok := b.content(1)
195 if !ok {
196 t.Fatal("no content")
197 }
198 if allocs := testing.AllocsPerRun(100, func() { b.content(1) }); allocs != 0 {
199 t.Errorf("buffer.content: %.1f allocs, want 0", allocs)
200 }
201 if again, _ := b.content(2); again.SHM != first.SHM {
202 t.Fatal("SHMBuffer rebuilt per commit")
203 }
204 if err := f.Truncate(27); err != nil {
205 t.Fatal(err)
206 }
207 if _, ok := b.content(1); ok {
208 t.Fatal("content from a truncated pool")
209 }
210}
211
212// Forwarding a command allocates nothing on the push path: queueing a
213// command, folding a motion into the queued one and draining the ring. (Only
214// applying a merged motion boxes the summed value again, once per merged run,
215// as the old batch coalescing did once per merge; that is excluded here.)
216func TestCommandForwardAllocations(t *testing.T) {
217 if raceEnabled {
218 t.Skip("the race detector makes the purego wake callback allocate")
219 }
220 s, _, _, _, _, _ := presentServer(t)
221 r := newCommandRing(s.display, func(queuedCommand) {})
222 var button ports.ClientCommand = ports.PointerButtonTo{ID: 1, Button: 272, Pressed: true}
223 var motion ports.ClientCommand = ports.PointerMotionTo{ID: 1, DX: 1}
224 state := ports.SecurityState{Generation: 2, Protected: true}
225 var wrapped ports.ClientCommand = ports.SecurityCommand{State: state, Command: ports.PointerMotionTo{ID: 1, DX: 1}}
226 ran := make(chan struct{}, 1)
227 marker := func() { ran <- struct{}{} }
228 var stats commandStats
229 cycle := func() {
230 r.Push(queue(button))
231 r.Push(queue(motion))
232 r.Push(queue(motion)) // folded into the previous one
233 r.Push(queue(wrapped))
234 r.Push(queue(wrapped)) // enveloped, same state: folded as well
235 s.display.Post(marker)
236 <-ran
237 // The latency stats path runs per command and per report.
238 stats.observe(time.Time{}, time.Now())
239 stats.take()
240 r.takeStats()
241 }
242 for range 4 {
243 cycle()
244 }
245 if allocs := testing.AllocsPerRun(200, cycle); allocs != 0 {
246 t.Errorf("command push + merge + drain + stats: %.1f allocs, want 0", allocs)
247 }
248}