1package wayland
2
3import (
4 "slices"
5 "sync"
6 "time"
7
8 "github.com/bnema/neferwl/internal/adapters/outputkit/presented"
9 "github.com/bnema/neferwl/internal/ports"
10)
11
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.
15const maxPresentRing = 64
16
17// mergeWarnEvery spaces the warnings about merged flips.
18const mergeWarnEvery = 10 * time.Second
19
20// presentSink is the ports.PresentationSink wayland offers to the outputs:
21// output owner goroutines Push reports, the display goroutine drains them and
22// paces frames. The ring is a transport queue between those goroutines, not
23// window state: the mutex guards only the slices, the pending flag and the
24// merge counters.
25type presentSink struct {
26 s *Server
27
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
35 drainFn func()
36 scratchFlips map[string]bool
37}
38
39func newPresentSink(s *Server) *presentSink {
40 p := &presentSink{
41 s: s,
42 ring: make([]ports.OutputPresented, 0, maxPresentRing),
43 spare: make([]ports.OutputPresented, 0, maxPresentRing),
44 scratchFlips: make(map[string]bool),
45 }
46 p.drainFn = p.drain
47 return p
48}
49
50// Presentation returns the sink outputs report to. Push never blocks.
51func (s *Server) Presentation() ports.PresentationSink { return s.sink }
52
53// Push queues a report for the display goroutine and wakes it; one wake
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.
57func (p *presentSink) Push(r ports.OutputPresented) {
58 p.mu.Lock()
59 warn, merged := p.enqueueLocked(r)
60 post := !p.pending
61 p.pending = true
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 }
66 if post {
67 p.s.display.Post(p.drainFn)
68 }
69}
70
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.
74func (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.
99func (p *presentSink) makeRoomLocked(incoming *ports.OutputPresented) (merged bool) {
100 for i := range p.ring {
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
112 }
113 p.ring = slices.Delete(p.ring, i, i+1)
114 return merged
115 }
116 return false
117}
118
119// successorLocked returns the next report of ring[i]'s output: a later ring
120// entry, else incoming; nil when none follows.
121func (p *presentSink) successorLocked(i int, incoming *ports.OutputPresented) *ports.OutputPresented {
122 for j := i + 1; j < len(p.ring); j++ {
123 if p.ring[j].Output == p.ring[i].Output {
124 return &p.ring[j]
125 }
126 }
127 if incoming.Output == p.ring[i].Output {
128 return incoming
129 }
130 return nil
131}
132
133// drain runs on the display goroutine: it paces frames for every report that
134// arrived since the last drain as one tick, so a burst of flips fires one
135// burst of frame callbacks per output.
136func (p *presentSink) drain() {
137 p.mu.Lock()
138 batch := p.ring
139 p.ring, p.spare = p.spare[:0], nil
140 p.pending = false
141 p.mu.Unlock()
142 flipped := p.scratchFlips
143 clear(flipped)
144 for _, r := range batch {
145 flipped[r.Output] = flipped[r.Output] || paces(r)
146 }
147 if len(batch) > 0 {
148 p.s.paceTick(flipped, batch)
149 }
150 // The reports carry maps: do not retain them in the spare batch.
151 clear(batch)
152 p.mu.Lock()
153 p.spare = batch[:0]
154 p.mu.Unlock()
155}