feat/hints a471fa4

alloc_hotpath_test.go RawHistoryFolder

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}

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 23

Tags 10

↑↓ moveenter openesc close