detached c9850c5

fix(wayland): keep flips when the presentation ring overflows

A full ring now folds the oldest report into the next report of the same
output (the incoming one included): a flip-less successor carries the flip,
a successor with a flip merges it. Nothing is dropped when no successor
exists. Merges are counted and warned about at most every 10 s. The
test-only drain counter goes; tests observe frame callbacks instead.
commit
c9850c5042b6a949df630b66a80927505e065c96
author
brice · · 2026-10-13 17:58 UTC
parent
b76d5bc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +439 -74
M internal/adapters/wayland/presentation_test.go +7 -1
@@ -47,11 +47,17 @@ func (p *feedbackEvents) Dispatch(e *wlturbo.Event) {
4747
4848 // presentServer is a server whose flips the test sends.
4949 func presentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, <-chan ports.SurfaceContent, ports.PresentationSink, string) {
50+ t.Helper()
51+ return presentServerOn(t, testOutputs)
52+}
53+
54+// presentServerOn is presentServer with its own output layout.
55+func presentServerOn(t *testing.T, layout ports.Layout) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, <-chan ports.SurfaceContent, ports.PresentationSink, string) {
5056 t.Helper()
5157 dir := t.TempDir()
5258 events := make(chan ports.ClientEvent, 16)
5359 commands := make(chan ports.ClientCommand, 16)
54- s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland"))
60+ s, err := New(Options{RuntimeDir: dir, Outputs: layout}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland"))
5561 if err != nil {
5662 t.Fatal(err)
5763 }
@@ -47,11 +47,17 @@ func (p *feedbackEvents) Dispatch(e *wlturbo.Event) {
47 47
48 // presentServer is a server whose flips the test sends.48 // presentServer is a server whose flips the test sends.
49 func presentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, <-chan ports.SurfaceContent, ports.PresentationSink, string) {49 func presentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, <-chan ports.SurfaceContent, ports.PresentationSink, string) {
50+ t.Helper()
51+ return presentServerOn(t, testOutputs)
52+}
53+
54+// presentServerOn is presentServer with its own output layout.
55+func presentServerOn(t *testing.T, layout ports.Layout) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, <-chan ports.SurfaceContent, ports.PresentationSink, string) {
50 t.Helper()56 t.Helper()
51 dir := t.TempDir()57 dir := t.TempDir()
52 events := make(chan ports.ClientEvent, 16)58 events := make(chan ports.ClientEvent, 16)
53 commands := make(chan ports.ClientCommand, 16)59 commands := make(chan ports.ClientCommand, 16)
54- s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland"))60+ s, err := New(Options{RuntimeDir: dir, Outputs: layout}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland"))
55 if err != nil {61 if err != nil {
56 t.Fatal(err)62 t.Fatal(err)
57 }63 }
M internal/adapters/wayland/presented.go +74 -31
@@ -3,29 +3,36 @@ package wayland
33 import (
44 "slices"
55 "sync"
6- "sync/atomic"
6+ "time"
77
88 "github.com/bnema/neferwl/internal/adapters/outputkit/presented"
99 "github.com/bnema/neferwl/internal/ports"
1010 )
1111
12-// maxPresentRing bounds the reports waiting for the display goroutine. Each
13-// output already bounds its own queue (presented.MaxUnsent), so the ring only
14-// fills when the display loop is stalled.
12+// maxPresentRing bounds the reports waiting for the display goroutine. Output
13+// queues are flushed every loop turn and hold no backlog, so the ring only
14+// fills when the display loop is stalled; this sink alone then merges flips.
1515 const maxPresentRing = 64
1616
17+// mergeWarnEvery spaces the warnings about merged flips.
18+const mergeWarnEvery = 10 * time.Second
19+
1720 // presentSink is the ports.PresentationSink wayland offers to the outputs:
1821 // output owner goroutines Push reports, the display goroutine drains them and
1922 // paces frames. The ring is a transport queue between those goroutines, not
20-// window state: the mutex guards only the two slices and the pending flag.
23+// window state: the mutex guards only the slices, the pending flag and the
24+// merge counters.
2125 type presentSink struct {
2226 s *Server
2327
24- mu sync.Mutex
25- ring, spare []ports.OutputPresented
26- pending bool // a drain is posted and has not taken the ring yet
28+ mu sync.Mutex
29+ ring, spare []ports.OutputPresented
30+ pending bool // a drain is posted and has not taken the ring yet
31+ // merged counts flips merged since the last warning;
32+ // lastWarn is when that warning was logged.
33+ merged int
34+ lastWarn time.Time
2735 drainFn func()
28- drains atomic.Uint64 // finished drains
2936 scratchFlips map[string]bool
3037 }
3138
@@ -44,46 +51,83 @@ func newPresentSink(s *Server) *presentSink {
4451 func (s *Server) Presentation() ports.PresentationSink { return s.sink }
4552
4653 // Push queues a report for the display goroutine and wakes it; one wake
47-// serves every report pushed before the drain runs.
54+// serves every report pushed before the drain runs. When the ring is full the
55+// flip of the oldest report is carried or merged forward; merges are counted
56+// and warned about at most every mergeWarnEvery.
4857 func (p *presentSink) Push(r ports.OutputPresented) {
4958 p.mu.Lock()
50- if len(p.ring) >= maxPresentRing {
51- p.dropOldestLocked()
52- }
53- p.ring = append(p.ring, r)
59+ warn, merged := p.enqueueLocked(r)
5460 post := !p.pending
5561 p.pending = true
5662 p.mu.Unlock()
63+ if warn {
64+ p.s.log.Warn().Int("merged", merged).Msg("outputs report faster than the display loop drains; flips merged")
65+ }
5766 if post {
5867 p.s.display.Post(p.drainFn)
5968 }
6069 }
6170
62-// dropOldestLocked makes room: the oldest report that a later report of the
63-// same output follows is folded into it (flips merge as in presented.Queue).
64-func (p *presentSink) dropOldestLocked() {
65- victim := 0
71+// enqueueLocked appends r, making room first when the ring is full. It
72+// reports whether to warn now and how many flips were merged since the last
73+// warning.
74+func (p *presentSink) enqueueLocked(r ports.OutputPresented) (warn bool, merged int) {
75+ if len(p.ring) >= maxPresentRing {
76+ if p.makeRoomLocked(&r) {
77+ p.merged++
78+ }
79+ if now := p.s.clock.Now(); p.merged > 0 && now.Sub(p.lastWarn) >= mergeWarnEvery {
80+ warn, merged = true, p.merged
81+ p.merged, p.lastWarn = 0, now
82+ }
83+ }
84+ p.ring = append(p.ring, r)
85+ return warn, merged
86+}
87+
88+// makeRoomLocked frees a ring slot for incoming without losing a flip: the
89+// oldest report that a later report of the same output follows (in the ring,
90+// or incoming itself) is removed and its flip carried into that report. A
91+// flip-less successor takes the flip over; a successor with a flip merges it
92+// (presented.Merge: the older feedback is discarded), which is what the
93+// result counts.
94+//
95+// When no report has a successor, every ring entry and incoming belong to
96+// distinct outputs: only possible with more than maxPresentRing outputs.
97+// Nothing is dropped then; the ring grows by one, bounded by the number of
98+// outputs, because the next report of any of them finds a successor.
99+func (p *presentSink) makeRoomLocked(incoming *ports.OutputPresented) (merged bool) {
66100 for i := range p.ring {
67- if next := p.nextOfOutputLocked(i); next > 0 {
68- if old, nf := p.ring[i].Flip, p.ring[next].Flip; old != nil && nf != nil {
69- presented.Merge(old, nf)
70- }
71- victim = i
72- break
101+ next := p.successorLocked(i, incoming)
102+ if next == nil {
103+ continue
104+ }
105+ switch old := p.ring[i].Flip; {
106+ case old == nil:
107+ case next.Flip == nil:
108+ next.Flip = old
109+ default:
110+ presented.Merge(old, next.Flip)
111+ merged = true
73112 }
113+ p.ring = slices.Delete(p.ring, i, i+1)
114+ return merged
74115 }
75- p.ring = slices.Delete(p.ring, victim, victim+1)
116+ return false
76117 }
77118
78-// nextOfOutputLocked returns the index of the next report of i's output, 0
79-// when none follows.
80-func (p *presentSink) nextOfOutputLocked(i int) int {
119+// successorLocked returns the next report of ring[i]'s output: a later ring
120+// entry, else incoming; nil when none follows.
121+func (p *presentSink) successorLocked(i int, incoming *ports.OutputPresented) *ports.OutputPresented {
81122 for j := i + 1; j < len(p.ring); j++ {
82123 if p.ring[j].Output == p.ring[i].Output {
83- return j
124+ return &p.ring[j]
84125 }
85126 }
86- return 0
127+ if incoming.Output == p.ring[i].Output {
128+ return incoming
129+ }
130+ return nil
87131 }
88132
89133 // drain runs on the display goroutine: it paces frames for every report that
@@ -108,5 +152,4 @@ func (p *presentSink) drain() {
108152 p.mu.Lock()
109153 p.spare = batch[:0]
110154 p.mu.Unlock()
111- p.drains.Add(1)
112155 }
@@ -3,29 +3,36 @@ package wayland
3 import (3 import (
4 "slices"4 "slices"
5 "sync"5 "sync"
6- "sync/atomic"6+ "time"
7 7
8 "github.com/bnema/neferwl/internal/adapters/outputkit/presented"8 "github.com/bnema/neferwl/internal/adapters/outputkit/presented"
9 "github.com/bnema/neferwl/internal/ports"9 "github.com/bnema/neferwl/internal/ports"
10 )10 )
11 11
12-// maxPresentRing bounds the reports waiting for the display goroutine. Each12+// maxPresentRing bounds the reports waiting for the display goroutine. Output
13-// output already bounds its own queue (presented.MaxUnsent), so the ring only13+// queues are flushed every loop turn and hold no backlog, so the ring only
14-// fills when the display loop is stalled.14+// fills when the display loop is stalled; this sink alone then merges flips.
15 const maxPresentRing = 6415 const maxPresentRing = 64
16 16
17+// mergeWarnEvery spaces the warnings about merged flips.
18+const mergeWarnEvery = 10 * time.Second
19+
17 // presentSink is the ports.PresentationSink wayland offers to the outputs:20 // presentSink is the ports.PresentationSink wayland offers to the outputs:
18 // output owner goroutines Push reports, the display goroutine drains them and21 // output owner goroutines Push reports, the display goroutine drains them and
19 // paces frames. The ring is a transport queue between those goroutines, not22 // paces frames. The ring is a transport queue between those goroutines, not
20-// window state: the mutex guards only the two slices and the pending flag.23+// window state: the mutex guards only the slices, the pending flag and the
24+// merge counters.
21 type presentSink struct {25 type presentSink struct {
22 s *Server26 s *Server
23 27
24- mu sync.Mutex28+ mu sync.Mutex
25- ring, spare []ports.OutputPresented29+ ring, spare []ports.OutputPresented
26- pending bool // a drain is posted and has not taken the ring yet30+ pending bool // a drain is posted and has not taken the ring yet
31+ // merged counts flips merged since the last warning;
32+ // lastWarn is when that warning was logged.
33+ merged int
34+ lastWarn time.Time
27 drainFn func()35 drainFn func()
28- drains atomic.Uint64 // finished drains
29 scratchFlips map[string]bool36 scratchFlips map[string]bool
30 }37 }
31 38
@@ -44,46 +51,83 @@ func newPresentSink(s *Server) *presentSink {
44 func (s *Server) Presentation() ports.PresentationSink { return s.sink }51 func (s *Server) Presentation() ports.PresentationSink { return s.sink }
45 52
46 // Push queues a report for the display goroutine and wakes it; one wake53 // Push queues a report for the display goroutine and wakes it; one wake
47-// serves every report pushed before the drain runs.54+// serves every report pushed before the drain runs. When the ring is full the
55+// flip of the oldest report is carried or merged forward; merges are counted
56+// and warned about at most every mergeWarnEvery.
48 func (p *presentSink) Push(r ports.OutputPresented) {57 func (p *presentSink) Push(r ports.OutputPresented) {
49 p.mu.Lock()58 p.mu.Lock()
50- if len(p.ring) >= maxPresentRing {59+ warn, merged := p.enqueueLocked(r)
51- p.dropOldestLocked()
52- }
53- p.ring = append(p.ring, r)
54 post := !p.pending60 post := !p.pending
55 p.pending = true61 p.pending = true
56 p.mu.Unlock()62 p.mu.Unlock()
63+ if warn {
64+ p.s.log.Warn().Int("merged", merged).Msg("outputs report faster than the display loop drains; flips merged")
65+ }
57 if post {66 if post {
58 p.s.display.Post(p.drainFn)67 p.s.display.Post(p.drainFn)
59 }68 }
60 }69 }
61 70
62-// dropOldestLocked makes room: the oldest report that a later report of the71+// enqueueLocked appends r, making room first when the ring is full. It
63-// same output follows is folded into it (flips merge as in presented.Queue).72+// reports whether to warn now and how many flips were merged since the last
64-func (p *presentSink) dropOldestLocked() {73+// warning.
65- victim := 074+func (p *presentSink) enqueueLocked(r ports.OutputPresented) (warn bool, merged int) {
75+ if len(p.ring) >= maxPresentRing {
76+ if p.makeRoomLocked(&r) {
77+ p.merged++
78+ }
79+ if now := p.s.clock.Now(); p.merged > 0 && now.Sub(p.lastWarn) >= mergeWarnEvery {
80+ warn, merged = true, p.merged
81+ p.merged, p.lastWarn = 0, now
82+ }
83+ }
84+ p.ring = append(p.ring, r)
85+ return warn, merged
86+}
87+
88+// makeRoomLocked frees a ring slot for incoming without losing a flip: the
89+// oldest report that a later report of the same output follows (in the ring,
90+// or incoming itself) is removed and its flip carried into that report. A
91+// flip-less successor takes the flip over; a successor with a flip merges it
92+// (presented.Merge: the older feedback is discarded), which is what the
93+// result counts.
94+//
95+// When no report has a successor, every ring entry and incoming belong to
96+// distinct outputs: only possible with more than maxPresentRing outputs.
97+// Nothing is dropped then; the ring grows by one, bounded by the number of
98+// outputs, because the next report of any of them finds a successor.
99+func (p *presentSink) makeRoomLocked(incoming *ports.OutputPresented) (merged bool) {
66 for i := range p.ring {100 for i := range p.ring {
67- if next := p.nextOfOutputLocked(i); next > 0 {101+ next := p.successorLocked(i, incoming)
68- if old, nf := p.ring[i].Flip, p.ring[next].Flip; old != nil && nf != nil {102+ if next == nil {
69- presented.Merge(old, nf)103+ continue
70- }104+ }
71- victim = i105+ switch old := p.ring[i].Flip; {
72- break106+ case old == nil:
107+ case next.Flip == nil:
108+ next.Flip = old
109+ default:
110+ presented.Merge(old, next.Flip)
111+ merged = true
73 }112 }
113+ p.ring = slices.Delete(p.ring, i, i+1)
114+ return merged
74 }115 }
75- p.ring = slices.Delete(p.ring, victim, victim+1)116+ return false
76 }117 }
77 118
78-// nextOfOutputLocked returns the index of the next report of i's output, 0119+// successorLocked returns the next report of ring[i]'s output: a later ring
79-// when none follows.120+// entry, else incoming; nil when none follows.
80-func (p *presentSink) nextOfOutputLocked(i int) int {121+func (p *presentSink) successorLocked(i int, incoming *ports.OutputPresented) *ports.OutputPresented {
81 for j := i + 1; j < len(p.ring); j++ {122 for j := i + 1; j < len(p.ring); j++ {
82 if p.ring[j].Output == p.ring[i].Output {123 if p.ring[j].Output == p.ring[i].Output {
83- return j124+ return &p.ring[j]
84 }125 }
85 }126 }
86- return 0127+ if incoming.Output == p.ring[i].Output {
128+ return incoming
129+ }
130+ return nil
87 }131 }
88 132
89 // drain runs on the display goroutine: it paces frames for every report that133 // drain runs on the display goroutine: it paces frames for every report that
@@ -108,5 +152,4 @@ func (p *presentSink) drain() {
108 p.mu.Lock()152 p.mu.Lock()
109 p.spare = batch[:0]153 p.spare = batch[:0]
110 p.mu.Unlock()154 p.mu.Unlock()
111- p.drains.Add(1)
112 }155 }
M internal/adapters/wayland/presented_test.go +354 -41
@@ -1,60 +1,314 @@
11 package wayland
22
33 import (
4- "runtime"
4+ "context"
5+ "encoding/binary"
6+ "strings"
57 "testing"
68 "time"
79
810 "github.com/bnema/go-wayland-bindings/server/fifo"
911 "github.com/bnema/go-wayland-bindings/server/wayland"
12+ "github.com/bnema/neferwl/internal/adapters/logging"
13+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1014 "github.com/bnema/neferwl/internal/ports"
15+ "github.com/bnema/wlturbo"
16+ "github.com/bnema/zerowrap"
17+ "golang.org/x/sys/unix"
1118 )
1219
13-// Flips pushed while the display goroutine is busy are paced by one drain:
14-// one tick, one burst of frame callbacks per output, not one per stale flip.
20+// slowOutputs has two outputs refreshing once a second: their deadlines never
21+// fire inside a test, so only flips pace their frame callbacks.
22+var slowOutputs = ports.Layout{
23+ {Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 1920, Height: 1080, RefreshMilli: 1000}, Width: 1920, Height: 1080, Scale: 1},
24+ {Info: ports.OutputInfo{Name: "HEADLESS-2", Width: 1920, Height: 1080, RefreshMilli: 1000}, X: 1920, Width: 1920, Height: 1080, Scale: 1},
25+}
26+
27+// frameDoneProxy records the time of a wl_callback.done.
28+type frameDoneProxy struct {
29+ wlturbo.BaseProxy
30+ done chan uint32
31+}
32+
33+func (p *frameDoneProxy) Dispatch(e *wlturbo.Event) {
34+ if e.Opcode == uint16(wayland.CallbackEventDone) {
35+ p.done <- binary.NativeEndian.Uint32(e.Data())
36+ }
37+}
38+
39+// requestFrame commits the surface with a new frame callback.
40+func requestFrame(t *testing.T, c *wlturbo.Display, surf uint32) *frameDoneProxy {
41+ t.Helper()
42+ cb := c.AllocateID()
43+ p := &frameDoneProxy{done: make(chan uint32, 2)}
44+ p.SetID(cb)
45+ registerWireProxy(c, p)
46+ requestProtocol(t, c, surf, wayland.SurfaceRequestFrame, cb)
47+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
48+ if err := c.Roundtrip(); err != nil {
49+ t.Fatal(err)
50+ }
51+ return p
52+}
53+
54+// awaitFrame waits for the callback to be answered and returns its time.
55+func awaitFrame(t *testing.T, c *wlturbo.Display, p *frameDoneProxy) uint32 {
56+ t.Helper()
57+ for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
58+ if err := c.Roundtrip(); err != nil {
59+ t.Fatal(err)
60+ }
61+ select {
62+ case ms := <-p.done:
63+ return ms
64+ case <-time.After(2 * time.Millisecond):
65+ }
66+ }
67+ t.Fatal("frame callback not answered")
68+ return 0
69+}
70+
71+// placeWindow puts the window on an output and waits until wayland applied it.
72+func placeWindow(t *testing.T, s *Server, commands chan<- ports.ClientCommand, w ports.WindowMapped, output string) {
73+ t.Helper()
74+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: output, Visible: true}
75+ for deadline := time.Now().Add(2 * time.Second); ; {
76+ var got string
77+ s.display.Do(func() { got = s.frameOutput(s.windows[w.ID].xdg.surface) })
78+ if got == output {
79+ return
80+ }
81+ if time.Now().After(deadline) {
82+ t.Fatalf("window not placed on %s", output)
83+ }
84+ time.Sleep(time.Millisecond)
85+ }
86+}
87+
88+// Flips pushed while the display goroutine is busy are paced by one tick per
89+// output, not one per stale flip. The frame callback of each output is
90+// answered once, with the time of that output's last flip: a tick per flip
91+// would have answered it with the first flip's time.
1592 func TestPresentationPushCoalescesWhileDisplayBusy(t *testing.T) {
16- s, _, _, _, sink, _ := presentServer(t)
17- release, started := make(chan struct{}), make(chan struct{})
18- finished := make(chan struct{})
93+ s, events, commands, _, sink, dir := presentServerOn(t, slowOutputs)
94+ c := protocolClient(t, s, dir)
95+ mk := surfaceMapper(t, c, events)
96+ outputs := []string{"HEADLESS-1", "HEADLESS-2"}
97+ surfs := make([]uint32, len(outputs))
98+ for i, name := range outputs {
99+ w, surf, _ := mk()
100+ placeWindow(t, s, commands, w, name)
101+ surfs[i] = surf
102+ // The first callback of an idle output is answered at once and
103+ // starts its one-second period: the next one waits for a flip.
104+ awaitFrame(t, c, requestFrame(t, c, surf))
105+ }
106+ waiting := make([]*frameDoneProxy, len(outputs))
107+ for i := range outputs {
108+ waiting[i] = requestFrame(t, c, surfs[i])
109+ select {
110+ case ms := <-waiting[i].done:
111+ t.Fatalf("%s answered before any flip (at %d ms)", outputs[i], ms)
112+ default:
113+ }
114+ }
115+ var base time.Duration
116+ release, started, finished := make(chan struct{}), make(chan struct{}), make(chan struct{})
19117 go func() {
20- s.display.Do(func() { close(started); <-release })
118+ s.display.Do(func() { base = monotonic(s.started); close(started); <-release })
21119 close(finished)
22120 }()
23121 <-started
24- for i := range 5 {
25- sink.Push(ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{Seq: uint64(i + 1), HardwareClock: true}})
122+ const flips = 5
123+ const step = 100 * time.Millisecond
124+ for i := range flips {
125+ for _, name := range outputs {
126+ sink.Push(ports.OutputPresented{Output: name, Flip: &ports.FlipInfo{When: base + time.Duration(i+1)*step, Seq: uint64(i + 1), HardwareClock: true}})
127+ }
26128 }
27129 close(release)
28130 <-finished
29- var drains, seq uint64
131+ last := uint32((flips * step).Milliseconds())
132+ for i, name := range outputs {
133+ ms := awaitFrame(t, c, waiting[i])
134+ // base is read on the display goroutine a little before the server
135+ // answers: a few ms of slack, far below one step.
136+ if ms < last-5 || ms > last+5 {
137+ t.Fatalf("%s answered at %d ms, want the last flip's %d ms (the first flip is at %d)", name, ms, last, step.Milliseconds())
138+ }
139+ }
140+ var seq uint64
30141 var reported bool
31142 s.display.Do(func() {
32- drains = s.sink.drains.Load()
33143 seq = s.flips["HEADLESS-1"].Seq
34144 _, reported = s.reports["HEADLESS-1"]
35145 })
36- if drains != 1 {
37- t.Fatalf("%d drains for 5 flips pushed while busy, want 1", drains)
38- }
39- if !reported || seq != 5 {
146+ if !reported || seq != flips {
40147 t.Fatalf("last flip %d, reported %v: every flip must still be answered in order", seq, reported)
41148 }
42149 }
43150
44-// A full ring merges the oldest flip of an output into its next one.
151+func ringOf(outputs ...string) *presentSink {
152+ p := &presentSink{ring: make([]ports.OutputPresented, 0, maxPresentRing+1)}
153+ for _, o := range outputs {
154+ p.ring = append(p.ring, ports.OutputPresented{Output: o})
155+ }
156+ return p
157+}
158+
159+// fill pads the ring with reports of distinct outputs up to the bound.
160+func (p *presentSink) fill() {
161+ for i := 0; len(p.ring) < maxPresentRing; i++ {
162+ p.ring = append(p.ring, ports.OutputPresented{Output: "pad" + string(rune('A'+i/26)) + string(rune('a'+i%26))})
163+ }
164+}
165+
166+func flipOf(seq uint64, shows ...ports.WindowID) *ports.FlipInfo {
167+ f := &ports.FlipInfo{Seq: seq}
168+ if len(shows) > 0 {
169+ f.Shows = map[ports.WindowID]uint64{}
170+ for _, id := range shows {
171+ f.Shows[id] = seq
172+ }
173+ }
174+ return f
175+}
176+
177+// A full ring merges the oldest flip of an output into its next flip.
45178 func TestPresentationRingMergesPastBound(t *testing.T) {
46- p := &presentSink{ring: make([]ports.OutputPresented, 0, maxPresentRing)}
179+ p := ringOf()
47180 for i := range maxPresentRing {
48- p.ring = append(p.ring, ports.OutputPresented{Output: "A", Flip: &ports.FlipInfo{Seq: uint64(i + 1)}})
181+ p.ring = append(p.ring, ports.OutputPresented{Output: "A", Flip: flipOf(uint64(i + 1))})
182+ }
183+ in := ports.OutputPresented{Output: "A", Flip: flipOf(maxPresentRing + 1)}
184+ if !p.makeRoomLocked(&in) {
185+ t.Fatal("two flips merged but not reported")
49186 }
50- p.mu.Lock()
51- p.dropOldestLocked()
52- p.mu.Unlock()
53187 if len(p.ring) != maxPresentRing-1 || p.ring[0].Flip.Seq != 2 || p.ring[0].Flip.Merged != 1 {
54188 t.Fatalf("ring %d, first seq %d merged %d", len(p.ring), p.ring[0].Flip.Seq, p.ring[0].Flip.Merged)
55189 }
56190 }
57191
192+// A flip followed by a flip-less report of the same output is not lost: the
193+// later report carries it.
194+func TestPresentationRingKeepsFlipBeforeFliplessReport(t *testing.T) {
195+ p := ringOf("A", "A")
196+ p.ring[0].Flip = flipOf(7, 3)
197+ p.fill()
198+ p.ring = p.ring[:maxPresentRing]
199+ in := ports.OutputPresented{Output: "B", Flip: flipOf(9)}
200+ if p.makeRoomLocked(&in) {
201+ t.Fatal("no flips merged, yet reported")
202+ }
203+ if len(p.ring) != maxPresentRing-1 || p.ring[0].Output != "A" || p.ring[0].Flip == nil || p.ring[0].Flip.Seq != 7 || p.ring[0].Flip.Merged != 0 {
204+ t.Fatalf("flip lost: ring %d, first %+v", len(p.ring), p.ring[0])
205+ }
206+}
207+
208+// With several outputs interleaved, the victim is the oldest report that has
209+// a successor of its own output; other outputs' reports are untouched.
210+func TestPresentationRingInterleavedOutputs(t *testing.T) {
211+ p := ringOf("A", "B", "A", "B")
212+ p.ring[0].Flip, p.ring[1].Flip = flipOf(1, 10), flipOf(2, 20)
213+ p.ring[2].Flip, p.ring[3].Flip = flipOf(3, 11), nil
214+ p.fill()
215+ in := ports.OutputPresented{Output: "C"}
216+ if !p.makeRoomLocked(&in) {
217+ t.Fatal("merge not reported")
218+ }
219+ if p.ring[0].Output != "B" || p.ring[0].Flip.Seq != 2 || p.ring[0].Flip.Merged != 0 {
220+ t.Fatalf("B's flip touched: %+v", p.ring[0])
221+ }
222+ a := p.ring[1]
223+ if a.Output != "A" || a.Flip.Seq != 3 || a.Flip.Merged != 1 || a.Flip.Shows[10] != 1 || a.Flip.Shows[11] != 3 {
224+ t.Fatalf("A's flips not merged: %+v", a.Flip)
225+ }
226+ if p.ring[2].Output != "B" || p.ring[2].Flip != nil {
227+ t.Fatalf("B's flip-less report moved: %+v", p.ring[2])
228+ }
229+}
230+
231+// The incoming report counts as the successor of a ring report.
232+func TestPresentationRingIncomingTakesOverFlip(t *testing.T) {
233+ p := ringOf("A")
234+ p.ring[0].Flip = flipOf(4)
235+ p.fill()
236+ in := ports.OutputPresented{Output: "A"}
237+ if p.makeRoomLocked(&in) {
238+ t.Fatal("no merge expected")
239+ }
240+ if in.Flip == nil || in.Flip.Seq != 4 || len(p.ring) != maxPresentRing-1 {
241+ t.Fatalf("incoming %+v, ring %d", in.Flip, len(p.ring))
242+ }
243+}
244+
245+// With no successor for any report (more outputs than ring slots) nothing is
246+// dropped: the ring grows by one and every flip is still delivered.
247+func TestPresentationRingWithoutSuccessorKeepsEverything(t *testing.T) {
248+ p := ringOf()
249+ for i := range maxPresentRing {
250+ p.ring = append(p.ring, ports.OutputPresented{Output: "o" + string(rune('A'+i/26)) + string(rune('a'+i%26)), Flip: flipOf(uint64(i + 1))})
251+ }
252+ in := ports.OutputPresented{Output: "other", Flip: flipOf(99)}
253+ if p.makeRoomLocked(&in) || len(p.ring) != maxPresentRing {
254+ t.Fatalf("merged or dropped: ring %d", len(p.ring))
255+ }
256+}
257+
258+// Merges are counted, and warned about at most once per mergeWarnEvery.
259+func TestPresentationRingWarnsRateLimited(t *testing.T) {
260+ now := time.Unix(1000, 0)
261+ clk := portsmocks.NewMockClock(t)
262+ clk.EXPECT().Now().RunAndReturn(func() time.Time { return now })
263+ p := ringOf()
264+ p.s = &Server{clock: clk}
265+ for i := range maxPresentRing {
266+ p.ring = append(p.ring, ports.OutputPresented{Output: "A", Flip: flipOf(uint64(i + 1))})
267+ }
268+ push := func(seq uint64) (bool, int) {
269+ return p.enqueueLocked(ports.OutputPresented{Output: "A", Flip: flipOf(seq)})
270+ }
271+ if warn, n := push(100); !warn || n != 1 {
272+ t.Fatalf("first merge: warn %v merged %d", warn, n)
273+ }
274+ for i := range 3 {
275+ now = now.Add(time.Second)
276+ if warn, _ := push(uint64(101 + i)); warn {
277+ t.Fatalf("warned again after %v", time.Duration(i+1)*time.Second)
278+ }
279+ }
280+ now = now.Add(mergeWarnEvery)
281+ if warn, n := push(200); !warn || n != 4 {
282+ t.Fatalf("after the interval: warn %v merged %d, want the 4 merges since", warn, n)
283+ }
284+}
285+
286+// A stalled display loop makes outputs merge flips; wayland says so once.
287+func TestPresentationMergeWarnsInLog(t *testing.T) {
288+ var out lockedBuffer
289+ log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &out}).WithField("component", "wayland")
290+ s, _, _, _, _ := contentServerWith(t, Options{}, log)
291+ release, started, finished := make(chan struct{}), make(chan struct{}), make(chan struct{})
292+ go func() {
293+ s.display.Do(func() { close(started); <-release })
294+ close(finished)
295+ }()
296+ <-started
297+ for i := range 3 * maxPresentRing {
298+ s.Presentation().Push(ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{Seq: uint64(i + 1)}})
299+ }
300+ close(release)
301+ <-finished
302+ s.display.Do(func() {})
303+ got := out.String()
304+ if n := strings.Count(got, "outputs report faster than the display loop drains; flips merged"); n != 1 {
305+ t.Fatalf("%d warnings: %s", n, got)
306+ }
307+ if !strings.Contains(got, `"component":"wayland"`) || !strings.Contains(got, `"merged":`) {
308+ t.Fatalf("warning lacks fields: %s", got)
309+ }
310+}
311+
58312 // Pushing a report and draining it on the display goroutine allocates nothing
59313 // in steady state. The count includes the display goroutine: AllocsPerRun
60314 // reads the global malloc counter.
@@ -64,16 +318,20 @@ func TestPresentationPushAllocations(t *testing.T) {
64318 }
65319 s, _, _, _, sink, _ := presentServer(t)
66320 r := ports.OutputPresented{Output: "HEADLESS-1"}
321+ // Posts run in order: once the marker ran, the drain before it did.
322+ // The marker is bound once and the channel is reused, so waiting for it
323+ // allocates nothing.
324+ ran := make(chan struct{}, 1)
325+ marker := func() { ran <- struct{}{} }
67326 for range 4 {
68327 sink.Push(r)
69- s.display.Do(func() {})
328+ s.display.Post(marker)
329+ <-ran
70330 }
71331 allocs := testing.AllocsPerRun(200, func() {
72- want := s.sink.drains.Load() + 1
73332 sink.Push(r)
74- for s.sink.drains.Load() < want { // wait for the drain without allocating
75- runtime.Gosched()
76- }
333+ s.display.Post(marker)
334+ <-ran
77335 })
78336 if allocs != 0 {
79337 t.Errorf("push + drain: %.1f allocs, want 0", allocs)
@@ -81,18 +339,84 @@ func TestPresentationPushAllocations(t *testing.T) {
81339 }
82340
83341 // A fifo barrier on an output that does not flip clears at barrierAt+period,
84-// driven by the pace timerfd: the next frame is not late by a timer tick.
342+// driven by the pace timerfd. On a one-second output (so nothing expires during
343+// the test) the timerfd must be armed, no later than that deadline (other waits,
344+// like held buffers, may be earlier); and its callback, run once the deadline
345+// has passed, applies the queued commit.
346+func TestPaceTimerfdArmedForBarrierDeadline(t *testing.T) {
347+ s, events, commands, _, dir := contentServerWith(t, Options{Outputs: slowOutputs}, testLog())
348+ c := protocolClient(t, s, dir)
349+ w, surf, _ := surfaceMapper(t, c, events)()
350+ placeWindow(t, s, commands, w, "HEADLESS-1")
351+ fifoQueue(t, c, surf)
352+ var remaining, want, period time.Duration
353+ var queued, left int
354+ var barrier bool
355+ var err error
356+ s.display.Do(func() {
357+ root := s.windows[w.ID].xdg.surface
358+ period, queued = s.outputPeriod(s.fifoOutput(root)), len(root.queue)
359+ var spec unix.ItimerSpec
360+ err = unix.TimerfdGettime(s.paceFD, &spec)
361+ remaining = time.Duration(spec.Value.Nano())
362+ want = time.Until(root.barrierAt.Add(period))
363+ // The deadline passes: the timerfd callback clears the barrier.
364+ root.barrierAt = time.Now().Add(-period)
365+ s.onPaceTimer()
366+ barrier, left = root.barrier, len(root.queue)
367+ })
368+ if err != nil {
369+ t.Fatal(err)
370+ }
371+ if queued != 1 || period != time.Second {
372+ t.Fatalf("queued %d commits, period %v: the second commit must wait for the barrier", queued, period)
373+ }
374+ if remaining <= 0 || remaining > want+50*time.Millisecond {
375+ t.Fatalf("pace timerfd fires in %v, want armed at or before barrierAt+period (%v)", remaining, want)
376+ }
377+ if barrier || left != 0 {
378+ t.Fatalf("timer callback after the deadline: barrier %v, %d queued", barrier, left)
379+ }
380+}
381+
382+// End to end on a 20 Hz output: the queued commit is applied at barrierAt+50 ms
383+// (the timerfd), not at the next 100 ms held-buffer tick; the bound is generous
384+// (half a period) against scheduler noise.
85385 func TestPaceTimerfdClearsBarrierOnDeadline(t *testing.T) {
86- s, events, _, contents, dir := contentServer(t)
386+ out := ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 1920, Height: 1080, RefreshMilli: 20000}, Width: 1920, Height: 1080, Scale: 1}}
387+ s, events, commands, contents, dir := contentServerWith(t, Options{Outputs: out}, testLog())
87388 c := protocolClient(t, s, dir)
88389 w, surf, _ := surfaceMapper(t, c, events)()
390+ placeWindow(t, s, commands, w, "HEADLESS-1")
391+ nextContent(t, contents, w.ID) // the mapping commit
392+ fifoQueue(t, c, surf)
393+ var barrierAt time.Time
394+ var period time.Duration
395+ s.display.Do(func() {
396+ root := s.windows[w.ID].xdg.surface
397+ barrierAt, period = root.barrierAt, s.outputPeriod(s.fifoOutput(root))
398+ })
399+ nextContent(t, contents, w.ID) // the commit that set the barrier
400+ late := nextContent(t, contents, w.ID).Sub(barrierAt.Add(period))
401+ // Never early; late by far less than the period (not by a polling tick).
402+ if late < -time.Millisecond || late > period/2 {
403+ t.Fatalf("queued commit applied %v after barrierAt+%v", late, period)
404+ }
405+}
406+
407+func testLog() zerowrap.Logger {
408+ return logging.For(context.Background(), "wayland")
409+}
410+
411+// fifoQueue sets a barrier with one commit and queues a second commit behind
412+// wait_barrier.
413+func fifoQueue(t *testing.T, c *wlturbo.Display, surf uint32) {
414+ t.Helper()
89415 manager := bindProtocol(t, c, "wp_fifo_manager_v1")
90416 f := c.AllocateID()
91417 registerProtocol(t, c, f)
92418 requestProtocol(t, c, manager, fifo.WpFifoManagerV1RequestGetFifo, f, surf)
93419 buf := shmBuffer(t, c)
94- drainContents(contents)
95-
96420 requestProtocol(t, c, f, fifo.WpFifoV1RequestSetBarrier)
97421 requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
98422 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
@@ -102,15 +426,4 @@ func TestPaceTimerfdClearsBarrierOnDeadline(t *testing.T) {
102426 if err := c.Roundtrip(); err != nil {
103427 t.Fatal(err)
104428 }
105- var barrierAt time.Time
106- var period time.Duration
107- s.display.Do(func() {
108- root := s.windows[w.ID].xdg.surface
109- barrierAt, period = root.barrierAt, s.outputPeriod(s.fifoOutput(root))
110- })
111- nextContent(t, contents, w.ID) // the first commit
112- late := nextContent(t, contents, w.ID).Sub(barrierAt.Add(period))
113- if late < -time.Millisecond || late > 2*time.Millisecond {
114- t.Fatalf("queued commit applied %v after barrierAt+%v", late, period)
115- }
116429 }
@@ -1,60 +1,314 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4- "runtime"4+ "context"
5+ "encoding/binary"
6+ "strings"
5 "testing"7 "testing"
6 "time"8 "time"
7 9
8 "github.com/bnema/go-wayland-bindings/server/fifo"10 "github.com/bnema/go-wayland-bindings/server/fifo"
9 "github.com/bnema/go-wayland-bindings/server/wayland"11 "github.com/bnema/go-wayland-bindings/server/wayland"
12+ "github.com/bnema/neferwl/internal/adapters/logging"
13+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10 "github.com/bnema/neferwl/internal/ports"14 "github.com/bnema/neferwl/internal/ports"
15+ "github.com/bnema/wlturbo"
16+ "github.com/bnema/zerowrap"
17+ "golang.org/x/sys/unix"
11 )18 )
12 19
13-// Flips pushed while the display goroutine is busy are paced by one drain:20+// slowOutputs has two outputs refreshing once a second: their deadlines never
14-// one tick, one burst of frame callbacks per output, not one per stale flip.21+// fire inside a test, so only flips pace their frame callbacks.
22+var slowOutputs = ports.Layout{
23+ {Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 1920, Height: 1080, RefreshMilli: 1000}, Width: 1920, Height: 1080, Scale: 1},
24+ {Info: ports.OutputInfo{Name: "HEADLESS-2", Width: 1920, Height: 1080, RefreshMilli: 1000}, X: 1920, Width: 1920, Height: 1080, Scale: 1},
25+}
26+
27+// frameDoneProxy records the time of a wl_callback.done.
28+type frameDoneProxy struct {
29+ wlturbo.BaseProxy
30+ done chan uint32
31+}
32+
33+func (p *frameDoneProxy) Dispatch(e *wlturbo.Event) {
34+ if e.Opcode == uint16(wayland.CallbackEventDone) {
35+ p.done <- binary.NativeEndian.Uint32(e.Data())
36+ }
37+}
38+
39+// requestFrame commits the surface with a new frame callback.
40+func requestFrame(t *testing.T, c *wlturbo.Display, surf uint32) *frameDoneProxy {
41+ t.Helper()
42+ cb := c.AllocateID()
43+ p := &frameDoneProxy{done: make(chan uint32, 2)}
44+ p.SetID(cb)
45+ registerWireProxy(c, p)
46+ requestProtocol(t, c, surf, wayland.SurfaceRequestFrame, cb)
47+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
48+ if err := c.Roundtrip(); err != nil {
49+ t.Fatal(err)
50+ }
51+ return p
52+}
53+
54+// awaitFrame waits for the callback to be answered and returns its time.
55+func awaitFrame(t *testing.T, c *wlturbo.Display, p *frameDoneProxy) uint32 {
56+ t.Helper()
57+ for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
58+ if err := c.Roundtrip(); err != nil {
59+ t.Fatal(err)
60+ }
61+ select {
62+ case ms := <-p.done:
63+ return ms
64+ case <-time.After(2 * time.Millisecond):
65+ }
66+ }
67+ t.Fatal("frame callback not answered")
68+ return 0
69+}
70+
71+// placeWindow puts the window on an output and waits until wayland applied it.
72+func placeWindow(t *testing.T, s *Server, commands chan<- ports.ClientCommand, w ports.WindowMapped, output string) {
73+ t.Helper()
74+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: output, Visible: true}
75+ for deadline := time.Now().Add(2 * time.Second); ; {
76+ var got string
77+ s.display.Do(func() { got = s.frameOutput(s.windows[w.ID].xdg.surface) })
78+ if got == output {
79+ return
80+ }
81+ if time.Now().After(deadline) {
82+ t.Fatalf("window not placed on %s", output)
83+ }
84+ time.Sleep(time.Millisecond)
85+ }
86+}
87+
88+// Flips pushed while the display goroutine is busy are paced by one tick per
89+// output, not one per stale flip. The frame callback of each output is
90+// answered once, with the time of that output's last flip: a tick per flip
91+// would have answered it with the first flip's time.
15 func TestPresentationPushCoalescesWhileDisplayBusy(t *testing.T) {92 func TestPresentationPushCoalescesWhileDisplayBusy(t *testing.T) {
16- s, _, _, _, sink, _ := presentServer(t)93+ s, events, commands, _, sink, dir := presentServerOn(t, slowOutputs)
17- release, started := make(chan struct{}), make(chan struct{})94+ c := protocolClient(t, s, dir)
18- finished := make(chan struct{})95+ mk := surfaceMapper(t, c, events)
96+ outputs := []string{"HEADLESS-1", "HEADLESS-2"}
97+ surfs := make([]uint32, len(outputs))
98+ for i, name := range outputs {
99+ w, surf, _ := mk()
100+ placeWindow(t, s, commands, w, name)
101+ surfs[i] = surf
102+ // The first callback of an idle output is answered at once and
103+ // starts its one-second period: the next one waits for a flip.
104+ awaitFrame(t, c, requestFrame(t, c, surf))
105+ }
106+ waiting := make([]*frameDoneProxy, len(outputs))
107+ for i := range outputs {
108+ waiting[i] = requestFrame(t, c, surfs[i])
109+ select {
110+ case ms := <-waiting[i].done:
111+ t.Fatalf("%s answered before any flip (at %d ms)", outputs[i], ms)
112+ default:
113+ }
114+ }
115+ var base time.Duration
116+ release, started, finished := make(chan struct{}), make(chan struct{}), make(chan struct{})
19 go func() {117 go func() {
20- s.display.Do(func() { close(started); <-release })118+ s.display.Do(func() { base = monotonic(s.started); close(started); <-release })
21 close(finished)119 close(finished)
22 }()120 }()
23 <-started121 <-started
24- for i := range 5 {122+ const flips = 5
25- sink.Push(ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{Seq: uint64(i + 1), HardwareClock: true}})123+ const step = 100 * time.Millisecond
124+ for i := range flips {
125+ for _, name := range outputs {
126+ sink.Push(ports.OutputPresented{Output: name, Flip: &ports.FlipInfo{When: base + time.Duration(i+1)*step, Seq: uint64(i + 1), HardwareClock: true}})
127+ }
26 }128 }
27 close(release)129 close(release)
28 <-finished130 <-finished
29- var drains, seq uint64131+ last := uint32((flips * step).Milliseconds())
132+ for i, name := range outputs {
133+ ms := awaitFrame(t, c, waiting[i])
134+ // base is read on the display goroutine a little before the server
135+ // answers: a few ms of slack, far below one step.
136+ if ms < last-5 || ms > last+5 {
137+ t.Fatalf("%s answered at %d ms, want the last flip's %d ms (the first flip is at %d)", name, ms, last, step.Milliseconds())
138+ }
139+ }
140+ var seq uint64
30 var reported bool141 var reported bool
31 s.display.Do(func() {142 s.display.Do(func() {
32- drains = s.sink.drains.Load()
33 seq = s.flips["HEADLESS-1"].Seq143 seq = s.flips["HEADLESS-1"].Seq
34 _, reported = s.reports["HEADLESS-1"]144 _, reported = s.reports["HEADLESS-1"]
35 })145 })
36- if drains != 1 {146+ if !reported || seq != flips {
37- t.Fatalf("%d drains for 5 flips pushed while busy, want 1", drains)
38- }
39- if !reported || seq != 5 {
40 t.Fatalf("last flip %d, reported %v: every flip must still be answered in order", seq, reported)147 t.Fatalf("last flip %d, reported %v: every flip must still be answered in order", seq, reported)
41 }148 }
42 }149 }
43 150
44-// A full ring merges the oldest flip of an output into its next one.151+func ringOf(outputs ...string) *presentSink {
152+ p := &presentSink{ring: make([]ports.OutputPresented, 0, maxPresentRing+1)}
153+ for _, o := range outputs {
154+ p.ring = append(p.ring, ports.OutputPresented{Output: o})
155+ }
156+ return p
157+}
158+
159+// fill pads the ring with reports of distinct outputs up to the bound.
160+func (p *presentSink) fill() {
161+ for i := 0; len(p.ring) < maxPresentRing; i++ {
162+ p.ring = append(p.ring, ports.OutputPresented{Output: "pad" + string(rune('A'+i/26)) + string(rune('a'+i%26))})
163+ }
164+}
165+
166+func flipOf(seq uint64, shows ...ports.WindowID) *ports.FlipInfo {
167+ f := &ports.FlipInfo{Seq: seq}
168+ if len(shows) > 0 {
169+ f.Shows = map[ports.WindowID]uint64{}
170+ for _, id := range shows {
171+ f.Shows[id] = seq
172+ }
173+ }
174+ return f
175+}
176+
177+// A full ring merges the oldest flip of an output into its next flip.
45 func TestPresentationRingMergesPastBound(t *testing.T) {178 func TestPresentationRingMergesPastBound(t *testing.T) {
46- p := &presentSink{ring: make([]ports.OutputPresented, 0, maxPresentRing)}179+ p := ringOf()
47 for i := range maxPresentRing {180 for i := range maxPresentRing {
48- p.ring = append(p.ring, ports.OutputPresented{Output: "A", Flip: &ports.FlipInfo{Seq: uint64(i + 1)}})181+ p.ring = append(p.ring, ports.OutputPresented{Output: "A", Flip: flipOf(uint64(i + 1))})
182+ }
183+ in := ports.OutputPresented{Output: "A", Flip: flipOf(maxPresentRing + 1)}
184+ if !p.makeRoomLocked(&in) {
185+ t.Fatal("two flips merged but not reported")
49 }186 }
50- p.mu.Lock()
51- p.dropOldestLocked()
52- p.mu.Unlock()
53 if len(p.ring) != maxPresentRing-1 || p.ring[0].Flip.Seq != 2 || p.ring[0].Flip.Merged != 1 {187 if len(p.ring) != maxPresentRing-1 || p.ring[0].Flip.Seq != 2 || p.ring[0].Flip.Merged != 1 {
54 t.Fatalf("ring %d, first seq %d merged %d", len(p.ring), p.ring[0].Flip.Seq, p.ring[0].Flip.Merged)188 t.Fatalf("ring %d, first seq %d merged %d", len(p.ring), p.ring[0].Flip.Seq, p.ring[0].Flip.Merged)
55 }189 }
56 }190 }
57 191
192+// A flip followed by a flip-less report of the same output is not lost: the
193+// later report carries it.
194+func TestPresentationRingKeepsFlipBeforeFliplessReport(t *testing.T) {
195+ p := ringOf("A", "A")
196+ p.ring[0].Flip = flipOf(7, 3)
197+ p.fill()
198+ p.ring = p.ring[:maxPresentRing]
199+ in := ports.OutputPresented{Output: "B", Flip: flipOf(9)}
200+ if p.makeRoomLocked(&in) {
201+ t.Fatal("no flips merged, yet reported")
202+ }
203+ if len(p.ring) != maxPresentRing-1 || p.ring[0].Output != "A" || p.ring[0].Flip == nil || p.ring[0].Flip.Seq != 7 || p.ring[0].Flip.Merged != 0 {
204+ t.Fatalf("flip lost: ring %d, first %+v", len(p.ring), p.ring[0])
205+ }
206+}
207+
208+// With several outputs interleaved, the victim is the oldest report that has
209+// a successor of its own output; other outputs' reports are untouched.
210+func TestPresentationRingInterleavedOutputs(t *testing.T) {
211+ p := ringOf("A", "B", "A", "B")
212+ p.ring[0].Flip, p.ring[1].Flip = flipOf(1, 10), flipOf(2, 20)
213+ p.ring[2].Flip, p.ring[3].Flip = flipOf(3, 11), nil
214+ p.fill()
215+ in := ports.OutputPresented{Output: "C"}
216+ if !p.makeRoomLocked(&in) {
217+ t.Fatal("merge not reported")
218+ }
219+ if p.ring[0].Output != "B" || p.ring[0].Flip.Seq != 2 || p.ring[0].Flip.Merged != 0 {
220+ t.Fatalf("B's flip touched: %+v", p.ring[0])
221+ }
222+ a := p.ring[1]
223+ if a.Output != "A" || a.Flip.Seq != 3 || a.Flip.Merged != 1 || a.Flip.Shows[10] != 1 || a.Flip.Shows[11] != 3 {
224+ t.Fatalf("A's flips not merged: %+v", a.Flip)
225+ }
226+ if p.ring[2].Output != "B" || p.ring[2].Flip != nil {
227+ t.Fatalf("B's flip-less report moved: %+v", p.ring[2])
228+ }
229+}
230+
231+// The incoming report counts as the successor of a ring report.
232+func TestPresentationRingIncomingTakesOverFlip(t *testing.T) {
233+ p := ringOf("A")
234+ p.ring[0].Flip = flipOf(4)
235+ p.fill()
236+ in := ports.OutputPresented{Output: "A"}
237+ if p.makeRoomLocked(&in) {
238+ t.Fatal("no merge expected")
239+ }
240+ if in.Flip == nil || in.Flip.Seq != 4 || len(p.ring) != maxPresentRing-1 {
241+ t.Fatalf("incoming %+v, ring %d", in.Flip, len(p.ring))
242+ }
243+}
244+
245+// With no successor for any report (more outputs than ring slots) nothing is
246+// dropped: the ring grows by one and every flip is still delivered.
247+func TestPresentationRingWithoutSuccessorKeepsEverything(t *testing.T) {
248+ p := ringOf()
249+ for i := range maxPresentRing {
250+ p.ring = append(p.ring, ports.OutputPresented{Output: "o" + string(rune('A'+i/26)) + string(rune('a'+i%26)), Flip: flipOf(uint64(i + 1))})
251+ }
252+ in := ports.OutputPresented{Output: "other", Flip: flipOf(99)}
253+ if p.makeRoomLocked(&in) || len(p.ring) != maxPresentRing {
254+ t.Fatalf("merged or dropped: ring %d", len(p.ring))
255+ }
256+}
257+
258+// Merges are counted, and warned about at most once per mergeWarnEvery.
259+func TestPresentationRingWarnsRateLimited(t *testing.T) {
260+ now := time.Unix(1000, 0)
261+ clk := portsmocks.NewMockClock(t)
262+ clk.EXPECT().Now().RunAndReturn(func() time.Time { return now })
263+ p := ringOf()
264+ p.s = &Server{clock: clk}
265+ for i := range maxPresentRing {
266+ p.ring = append(p.ring, ports.OutputPresented{Output: "A", Flip: flipOf(uint64(i + 1))})
267+ }
268+ push := func(seq uint64) (bool, int) {
269+ return p.enqueueLocked(ports.OutputPresented{Output: "A", Flip: flipOf(seq)})
270+ }
271+ if warn, n := push(100); !warn || n != 1 {
272+ t.Fatalf("first merge: warn %v merged %d", warn, n)
273+ }
274+ for i := range 3 {
275+ now = now.Add(time.Second)
276+ if warn, _ := push(uint64(101 + i)); warn {
277+ t.Fatalf("warned again after %v", time.Duration(i+1)*time.Second)
278+ }
279+ }
280+ now = now.Add(mergeWarnEvery)
281+ if warn, n := push(200); !warn || n != 4 {
282+ t.Fatalf("after the interval: warn %v merged %d, want the 4 merges since", warn, n)
283+ }
284+}
285+
286+// A stalled display loop makes outputs merge flips; wayland says so once.
287+func TestPresentationMergeWarnsInLog(t *testing.T) {
288+ var out lockedBuffer
289+ log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &out}).WithField("component", "wayland")
290+ s, _, _, _, _ := contentServerWith(t, Options{}, log)
291+ release, started, finished := make(chan struct{}), make(chan struct{}), make(chan struct{})
292+ go func() {
293+ s.display.Do(func() { close(started); <-release })
294+ close(finished)
295+ }()
296+ <-started
297+ for i := range 3 * maxPresentRing {
298+ s.Presentation().Push(ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{Seq: uint64(i + 1)}})
299+ }
300+ close(release)
301+ <-finished
302+ s.display.Do(func() {})
303+ got := out.String()
304+ if n := strings.Count(got, "outputs report faster than the display loop drains; flips merged"); n != 1 {
305+ t.Fatalf("%d warnings: %s", n, got)
306+ }
307+ if !strings.Contains(got, `"component":"wayland"`) || !strings.Contains(got, `"merged":`) {
308+ t.Fatalf("warning lacks fields: %s", got)
309+ }
310+}
311+
58 // Pushing a report and draining it on the display goroutine allocates nothing312 // Pushing a report and draining it on the display goroutine allocates nothing
59 // in steady state. The count includes the display goroutine: AllocsPerRun313 // in steady state. The count includes the display goroutine: AllocsPerRun
60 // reads the global malloc counter.314 // reads the global malloc counter.
@@ -64,16 +318,20 @@ func TestPresentationPushAllocations(t *testing.T) {
64 }318 }
65 s, _, _, _, sink, _ := presentServer(t)319 s, _, _, _, sink, _ := presentServer(t)
66 r := ports.OutputPresented{Output: "HEADLESS-1"}320 r := ports.OutputPresented{Output: "HEADLESS-1"}
321+ // Posts run in order: once the marker ran, the drain before it did.
322+ // The marker is bound once and the channel is reused, so waiting for it
323+ // allocates nothing.
324+ ran := make(chan struct{}, 1)
325+ marker := func() { ran <- struct{}{} }
67 for range 4 {326 for range 4 {
68 sink.Push(r)327 sink.Push(r)
69- s.display.Do(func() {})328+ s.display.Post(marker)
329+ <-ran
70 }330 }
71 allocs := testing.AllocsPerRun(200, func() {331 allocs := testing.AllocsPerRun(200, func() {
72- want := s.sink.drains.Load() + 1
73 sink.Push(r)332 sink.Push(r)
74- for s.sink.drains.Load() < want { // wait for the drain without allocating333+ s.display.Post(marker)
75- runtime.Gosched()334+ <-ran
76- }
77 })335 })
78 if allocs != 0 {336 if allocs != 0 {
79 t.Errorf("push + drain: %.1f allocs, want 0", allocs)337 t.Errorf("push + drain: %.1f allocs, want 0", allocs)
@@ -81,18 +339,84 @@ func TestPresentationPushAllocations(t *testing.T) {
81 }339 }
82 340
83 // A fifo barrier on an output that does not flip clears at barrierAt+period,341 // A fifo barrier on an output that does not flip clears at barrierAt+period,
84-// driven by the pace timerfd: the next frame is not late by a timer tick.342+// driven by the pace timerfd. On a one-second output (so nothing expires during
343+// the test) the timerfd must be armed, no later than that deadline (other waits,
344+// like held buffers, may be earlier); and its callback, run once the deadline
345+// has passed, applies the queued commit.
346+func TestPaceTimerfdArmedForBarrierDeadline(t *testing.T) {
347+ s, events, commands, _, dir := contentServerWith(t, Options{Outputs: slowOutputs}, testLog())
348+ c := protocolClient(t, s, dir)
349+ w, surf, _ := surfaceMapper(t, c, events)()
350+ placeWindow(t, s, commands, w, "HEADLESS-1")
351+ fifoQueue(t, c, surf)
352+ var remaining, want, period time.Duration
353+ var queued, left int
354+ var barrier bool
355+ var err error
356+ s.display.Do(func() {
357+ root := s.windows[w.ID].xdg.surface
358+ period, queued = s.outputPeriod(s.fifoOutput(root)), len(root.queue)
359+ var spec unix.ItimerSpec
360+ err = unix.TimerfdGettime(s.paceFD, &spec)
361+ remaining = time.Duration(spec.Value.Nano())
362+ want = time.Until(root.barrierAt.Add(period))
363+ // The deadline passes: the timerfd callback clears the barrier.
364+ root.barrierAt = time.Now().Add(-period)
365+ s.onPaceTimer()
366+ barrier, left = root.barrier, len(root.queue)
367+ })
368+ if err != nil {
369+ t.Fatal(err)
370+ }
371+ if queued != 1 || period != time.Second {
372+ t.Fatalf("queued %d commits, period %v: the second commit must wait for the barrier", queued, period)
373+ }
374+ if remaining <= 0 || remaining > want+50*time.Millisecond {
375+ t.Fatalf("pace timerfd fires in %v, want armed at or before barrierAt+period (%v)", remaining, want)
376+ }
377+ if barrier || left != 0 {
378+ t.Fatalf("timer callback after the deadline: barrier %v, %d queued", barrier, left)
379+ }
380+}
381+
382+// End to end on a 20 Hz output: the queued commit is applied at barrierAt+50 ms
383+// (the timerfd), not at the next 100 ms held-buffer tick; the bound is generous
384+// (half a period) against scheduler noise.
85 func TestPaceTimerfdClearsBarrierOnDeadline(t *testing.T) {385 func TestPaceTimerfdClearsBarrierOnDeadline(t *testing.T) {
86- s, events, _, contents, dir := contentServer(t)386+ out := ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 1920, Height: 1080, RefreshMilli: 20000}, Width: 1920, Height: 1080, Scale: 1}}
387+ s, events, commands, contents, dir := contentServerWith(t, Options{Outputs: out}, testLog())
87 c := protocolClient(t, s, dir)388 c := protocolClient(t, s, dir)
88 w, surf, _ := surfaceMapper(t, c, events)()389 w, surf, _ := surfaceMapper(t, c, events)()
390+ placeWindow(t, s, commands, w, "HEADLESS-1")
391+ nextContent(t, contents, w.ID) // the mapping commit
392+ fifoQueue(t, c, surf)
393+ var barrierAt time.Time
394+ var period time.Duration
395+ s.display.Do(func() {
396+ root := s.windows[w.ID].xdg.surface
397+ barrierAt, period = root.barrierAt, s.outputPeriod(s.fifoOutput(root))
398+ })
399+ nextContent(t, contents, w.ID) // the commit that set the barrier
400+ late := nextContent(t, contents, w.ID).Sub(barrierAt.Add(period))
401+ // Never early; late by far less than the period (not by a polling tick).
402+ if late < -time.Millisecond || late > period/2 {
403+ t.Fatalf("queued commit applied %v after barrierAt+%v", late, period)
404+ }
405+}
406+
407+func testLog() zerowrap.Logger {
408+ return logging.For(context.Background(), "wayland")
409+}
410+
411+// fifoQueue sets a barrier with one commit and queues a second commit behind
412+// wait_barrier.
413+func fifoQueue(t *testing.T, c *wlturbo.Display, surf uint32) {
414+ t.Helper()
89 manager := bindProtocol(t, c, "wp_fifo_manager_v1")415 manager := bindProtocol(t, c, "wp_fifo_manager_v1")
90 f := c.AllocateID()416 f := c.AllocateID()
91 registerProtocol(t, c, f)417 registerProtocol(t, c, f)
92 requestProtocol(t, c, manager, fifo.WpFifoManagerV1RequestGetFifo, f, surf)418 requestProtocol(t, c, manager, fifo.WpFifoManagerV1RequestGetFifo, f, surf)
93 buf := shmBuffer(t, c)419 buf := shmBuffer(t, c)
94- drainContents(contents)
95-
96 requestProtocol(t, c, f, fifo.WpFifoV1RequestSetBarrier)420 requestProtocol(t, c, f, fifo.WpFifoV1RequestSetBarrier)
97 requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))421 requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
98 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)422 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
@@ -102,15 +426,4 @@ func TestPaceTimerfdClearsBarrierOnDeadline(t *testing.T) {
102 if err := c.Roundtrip(); err != nil {426 if err := c.Roundtrip(); err != nil {
103 t.Fatal(err)427 t.Fatal(err)
104 }428 }
105- var barrierAt time.Time
106- var period time.Duration
107- s.display.Do(func() {
108- root := s.windows[w.ID].xdg.surface
109- barrierAt, period = root.barrierAt, s.outputPeriod(s.fifoOutput(root))
110- })
111- nextContent(t, contents, w.ID) // the first commit
112- late := nextContent(t, contents, w.ID).Sub(barrierAt.Add(period))
113- if late < -time.Millisecond || late > 2*time.Millisecond {
114- t.Fatalf("queued commit applied %v after barrierAt+%v", late, period)
115- }
116 }429 }
M internal/adapters/wayland/scale_test.go +4 -1
@@ -63,7 +63,10 @@ func contentServerWith(t *testing.T, opts Options, log zerowrap.Logger) (*Server
6363 dir := t.TempDir()
6464 events := make(chan ports.ClientEvent, 16)
6565 commands := make(chan ports.ClientCommand, 16)
66- opts.RuntimeDir, opts.Outputs = dir, testOutputs
66+ opts.RuntimeDir = dir
67+ if opts.Outputs == nil {
68+ opts.Outputs = testOutputs
69+ }
6770 s, err := New(opts, Channels{Events: events, Commands: commands}, log)
6871 if err != nil {
6972 t.Fatal(err)
@@ -63,7 +63,10 @@ func contentServerWith(t *testing.T, opts Options, log zerowrap.Logger) (*Server
63 dir := t.TempDir()63 dir := t.TempDir()
64 events := make(chan ports.ClientEvent, 16)64 events := make(chan ports.ClientEvent, 16)
65 commands := make(chan ports.ClientCommand, 16)65 commands := make(chan ports.ClientCommand, 16)
66- opts.RuntimeDir, opts.Outputs = dir, testOutputs66+ opts.RuntimeDir = dir
67+ if opts.Outputs == nil {
68+ opts.Outputs = testOutputs
69+ }
67 s, err := New(opts, Channels{Events: events, Commands: commands}, log)70 s, err := New(opts, Channels{Events: events, Commands: commands}, log)
68 if err != nil {71 if err != nil {
69 t.Fatal(err)72 t.Fatal(err)

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