detached 5db4900

perf(wayland): tick the pacer inline on client commits

Every pacer wake cost a display.Do round trip (channel send, eventfd
write, wait) even when the trigger already ran on the display goroutine:
a commit called wakePacer from queueUpdate, the pacer goroutine woke and
immediately Do'd back to the goroutine that woke it.

pace now only collects flips and sleeps; the work moved to paceTick, which
runs on the display goroutine and arms the pacer timer itself. A commit
(queueAndTick) runs the tick inline when something needs the pacer, and
wakePacer calls made while a commit or tick runs are recorded (paceDirty)
and coalesced into one extra pass instead of one channel wake each.
queueUpdate no longer wakes the pacer: a commit that applies at once needs
no tick beyond what its callbacks and held buffers request, and one that
stays queued (barrier, fence, too early) requests it from queueAndTick.
The Do closure is a method value built once. Wakes from other goroutines
(sync waiter) use signalPacer, unchanged.

Tradeoff: the commit handler now does the frame/FIFO/release pass itself,
so a burst of commits costs a few microseconds of extra display-goroutine
work each, in exchange for no goroutine switch and no eventfd write per
commit. Wakes outside a commit (configure, desync, flips, fences) still go
through the pacer goroutine; flips need its channel anyway. Pacing
semantics (per-output flips, 60 Hz default, 1 Hz suspended, feedback
timeouts, held buffers, FIFO barriers) are unchanged; the timer is only
armed from ticks, all on the display goroutine.
commit
5db49007718ddc83d6fc3508a2f56f0aea1974c4
author
brice · · 2026-10-10 14:16 UTC
committer
brice · · 2026-10-10 15:39 UTC
parent
0f9d086
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +239 -54
M internal/adapters/wayland/fifo.go +13 -2
@@ -377,7 +377,6 @@ func (s *surface) queueUpdate() {
377377 }
378378 s.queue = append(s.queue, u)
379379 s.server.fifoSurfaces[s] = struct{}{}
380- s.server.wakePacer()
381380 }
382381
383382 // applyUpdate applies only the captured commit; later requests remain pending.
@@ -745,8 +744,20 @@ func sameBuffer(a, b *wayland.Buffer) bool {
745744 return a != nil && b != nil && a.Resource == b.Resource
746745 }
747746
748-// wakePacer makes the pacer look at its queues now.
747+// wakePacer makes the pacer look at its queues. Display goroutine only.
748+// While a commit or a tick runs (paceDepth) it only records the request: the
749+// outermost caller then ticks inline (paceNow), saving the round trip of
750+// waking the pacer goroutine just so that it calls Do back.
749751 func (s *Server) wakePacer() {
752+ if s.paceDepth > 0 {
753+ s.paceDirty = true
754+ return
755+ }
756+ s.signalPacer()
757+}
758+
759+// signalPacer wakes the pacer goroutine; safe from any goroutine.
760+func (s *Server) signalPacer() {
750761 select {
751762 case s.frameReady <- struct{}{}:
752763 default:
@@ -377,7 +377,6 @@ func (s *surface) queueUpdate() {
377 }377 }
378 s.queue = append(s.queue, u)378 s.queue = append(s.queue, u)
379 s.server.fifoSurfaces[s] = struct{}{}379 s.server.fifoSurfaces[s] = struct{}{}
380- s.server.wakePacer()
381 }380 }
382 381
383 // applyUpdate applies only the captured commit; later requests remain pending.382 // applyUpdate applies only the captured commit; later requests remain pending.
@@ -745,8 +744,20 @@ func sameBuffer(a, b *wayland.Buffer) bool {
745 return a != nil && b != nil && a.Resource == b.Resource744 return a != nil && b != nil && a.Resource == b.Resource
746 }745 }
747 746
748-// wakePacer makes the pacer look at its queues now.747+// wakePacer makes the pacer look at its queues. Display goroutine only.
748+// While a commit or a tick runs (paceDepth) it only records the request: the
749+// outermost caller then ticks inline (paceNow), saving the round trip of
750+// waking the pacer goroutine just so that it calls Do back.
749 func (s *Server) wakePacer() {751 func (s *Server) wakePacer() {
752+ if s.paceDepth > 0 {
753+ s.paceDirty = true
754+ return
755+ }
756+ s.signalPacer()
757+}
758+
759+// signalPacer wakes the pacer goroutine; safe from any goroutine.
760+func (s *Server) signalPacer() {
750 select {761 select {
751 case s.frameReady <- struct{}{}:762 case s.frameReady <- struct{}{}:
752 default:763 default:
M internal/adapters/wayland/frames.go +99 -44
@@ -24,14 +24,26 @@ const defaultFramePeriod = time.Second / 60
2424 const suspendedFramePeriod = time.Second
2525 const suspendedFrameQueue = "\x00suspended" // never a wl_output name
2626
27-// pace fires the due frame callbacks on page flips and deadlines.
27+// maxPacePasses bounds the passes one tick repeats while a pass queues
28+// more work; the rest goes back to the pacer goroutine.
29+const maxPacePasses = 3
30+
31+// pace sleeps until a flip, a wake or the deadline armed by the last tick,
32+// then runs a tick on the display goroutine. The timer is only armed from
33+// ticks, which all run there, so a commit that ticks inline (paceNow) never
34+// races the pacer for it.
2835 func (s *Server) pace(ctx context.Context) {
29- timer := time.NewTimer(defaultFramePeriod)
36+ if s.paceTimer == nil {
37+ s.paceTimer = time.NewTimer(defaultFramePeriod)
38+ }
39+ timer := s.paceTimer
3040 defer timer.Stop()
3141 presented := s.channels.Presented
3242 if s.flipped == nil {
3343 s.flipped = make(map[string]bool)
3444 }
45+ // One method value for the whole loop: no closure per wake.
46+ tick := s.paceFromPacer
3547 for {
3648 clear(s.flipped)
3749 flipped := s.flipped
@@ -61,52 +73,95 @@ func (s *Server) pace(ctx context.Context) {
6173 }
6274 }
6375 s.frameReports = reports
64- wait, idle := defaultFramePeriod, false
65- if !s.display.Do(func() {
66- if ctx.Err() == nil {
67- // Each flip answers its own presentation feedbacks, in
68- // order: flips are never merged here.
69- for _, p := range reports {
70- if p.Flip != nil {
71- s.flips[p.Output] = *p.Flip
72- s.presentFlip(p.Output, p.Flip)
73- }
74- }
75- s.dropFeedbacks(time.Now())
76- if len(s.feedbacks) > 0 {
77- wait = feedbackTimeout
78- }
79- var due []string
80- var dueWait time.Duration
81- due, dueWait, idle = s.dueFrames(time.Now(), flipped)
82- if len(s.feedbacks) > 0 {
83- // Unanswered feedbacks time out: keep ticking.
84- dueWait, idle = min(dueWait, feedbackTimeout), false
85- }
86- wait = dueWait
87- for _, name := range due {
88- s.sendFrames(name, flipped[name])
89- }
90- if s.releaseHeld(time.Now(), reports) && idle {
91- // Held buffers wait for a flip or heldTimeout.
92- wait, idle = heldTimeout, false
93- }
94- // Fifo barriers and queued commits wait for a refresh.
95- if fw, waiting := s.tickFifo(time.Now(), flipped); waiting {
96- wait, idle = min(wait, fw), false
97- }
98- }
99- }) {
76+ if !s.display.Do(tick) {
10077 return
10178 }
102- if idle {
103- // Nothing waits: sleep until a commit queues a callback
104- // (frameReady) or a flip.
105- timer.Stop()
106- continue
79+ }
80+}
81+
82+// paceFromPacer runs the pacer's collected flips (flipped, frameReports)
83+// on the display goroutine.
84+func (s *Server) paceFromPacer() { s.paceTick(s.flipped, s.frameReports) }
85+
86+// paceNow runs a tick from code already on the display goroutine (a client
87+// commit): the pacer goroutine would only call Do to get back here. Without
88+// a running pacer (no timer) it falls back to waking it.
89+func (s *Server) paceNow() {
90+ if s.paceTimer == nil {
91+ s.signalPacer()
92+ return
93+ }
94+ s.paceTick(nil, nil)
95+}
96+
97+// paceTick answers the flips in reports, fires the due frame callbacks,
98+// releases buffers and applies the ready FIFO commits, then arms the timer
99+// for the next deadline (stopped when nothing waits). Display goroutine only.
100+func (s *Server) paceTick(flipped map[string]bool, reports []ports.OutputPresented) {
101+ if s.ctx != nil && s.ctx.Err() != nil {
102+ return
103+ }
104+ wait, idle := defaultFramePeriod, false
105+ for pass := 0; ; pass++ {
106+ s.paceDirty = false
107+ s.paceDepth++
108+ wait, idle = s.pacePass(flipped, reports)
109+ s.paceDepth--
110+ if !s.paceDirty {
111+ break
107112 }
108- timer.Reset(wait)
113+ // The pass queued work behind its own checks (applied commits
114+ // queue callbacks, released buffers): look again, as a flip-less
115+ // tick, instead of waking the pacer for it.
116+ flipped, reports = nil, nil
117+ if pass+1 == maxPacePasses {
118+ s.paceDirty = false
119+ s.signalPacer()
120+ break
121+ }
122+ }
123+ if s.paceTimer == nil {
124+ return
125+ }
126+ if idle {
127+ // Nothing waits: sleep until a commit queues a callback
128+ // (wakePacer) or a flip.
129+ s.paceTimer.Stop()
130+ return
131+ }
132+ s.paceTimer.Reset(wait)
133+}
134+
135+// pacePass is one pass of paceTick; it returns the time to the next
136+// deadline and whether nothing waits.
137+func (s *Server) pacePass(flipped map[string]bool, reports []ports.OutputPresented) (wait time.Duration, idle bool) {
138+ // Each flip answers its own presentation feedbacks, in order: flips
139+ // are never merged here.
140+ for _, p := range reports {
141+ if p.Flip != nil {
142+ s.flips[p.Output] = *p.Flip
143+ s.presentFlip(p.Output, p.Flip)
144+ }
145+ }
146+ s.dropFeedbacks(time.Now())
147+ var due []string
148+ due, wait, idle = s.dueFrames(time.Now(), flipped)
149+ if len(s.feedbacks) > 0 {
150+ // Unanswered feedbacks time out: keep ticking.
151+ wait, idle = min(wait, feedbackTimeout), false
152+ }
153+ for _, name := range due {
154+ s.sendFrames(name, flipped[name])
155+ }
156+ if s.releaseHeld(time.Now(), reports) && idle {
157+ // Held buffers wait for a flip or heldTimeout.
158+ wait, idle = heldTimeout, false
159+ }
160+ // Fifo barriers and queued commits wait for a refresh.
161+ if fw, waiting := s.tickFifo(time.Now(), flipped); waiting {
162+ wait, idle = min(wait, fw), false
109163 }
164+ return wait, idle
110165 }
111166
112167 // toplevelRoot resolves subsurfaces and popups to their parent toplevel.
@@ -24,14 +24,26 @@ const defaultFramePeriod = time.Second / 60
24 const suspendedFramePeriod = time.Second24 const suspendedFramePeriod = time.Second
25 const suspendedFrameQueue = "\x00suspended" // never a wl_output name25 const suspendedFrameQueue = "\x00suspended" // never a wl_output name
26 26
27-// pace fires the due frame callbacks on page flips and deadlines.27+// maxPacePasses bounds the passes one tick repeats while a pass queues
28+// more work; the rest goes back to the pacer goroutine.
29+const maxPacePasses = 3
30+
31+// pace sleeps until a flip, a wake or the deadline armed by the last tick,
32+// then runs a tick on the display goroutine. The timer is only armed from
33+// ticks, which all run there, so a commit that ticks inline (paceNow) never
34+// races the pacer for it.
28 func (s *Server) pace(ctx context.Context) {35 func (s *Server) pace(ctx context.Context) {
29- timer := time.NewTimer(defaultFramePeriod)36+ if s.paceTimer == nil {
37+ s.paceTimer = time.NewTimer(defaultFramePeriod)
38+ }
39+ timer := s.paceTimer
30 defer timer.Stop()40 defer timer.Stop()
31 presented := s.channels.Presented41 presented := s.channels.Presented
32 if s.flipped == nil {42 if s.flipped == nil {
33 s.flipped = make(map[string]bool)43 s.flipped = make(map[string]bool)
34 }44 }
45+ // One method value for the whole loop: no closure per wake.
46+ tick := s.paceFromPacer
35 for {47 for {
36 clear(s.flipped)48 clear(s.flipped)
37 flipped := s.flipped49 flipped := s.flipped
@@ -61,52 +73,95 @@ func (s *Server) pace(ctx context.Context) {
61 }73 }
62 }74 }
63 s.frameReports = reports75 s.frameReports = reports
64- wait, idle := defaultFramePeriod, false76+ if !s.display.Do(tick) {
65- if !s.display.Do(func() {
66- if ctx.Err() == nil {
67- // Each flip answers its own presentation feedbacks, in
68- // order: flips are never merged here.
69- for _, p := range reports {
70- if p.Flip != nil {
71- s.flips[p.Output] = *p.Flip
72- s.presentFlip(p.Output, p.Flip)
73- }
74- }
75- s.dropFeedbacks(time.Now())
76- if len(s.feedbacks) > 0 {
77- wait = feedbackTimeout
78- }
79- var due []string
80- var dueWait time.Duration
81- due, dueWait, idle = s.dueFrames(time.Now(), flipped)
82- if len(s.feedbacks) > 0 {
83- // Unanswered feedbacks time out: keep ticking.
84- dueWait, idle = min(dueWait, feedbackTimeout), false
85- }
86- wait = dueWait
87- for _, name := range due {
88- s.sendFrames(name, flipped[name])
89- }
90- if s.releaseHeld(time.Now(), reports) && idle {
91- // Held buffers wait for a flip or heldTimeout.
92- wait, idle = heldTimeout, false
93- }
94- // Fifo barriers and queued commits wait for a refresh.
95- if fw, waiting := s.tickFifo(time.Now(), flipped); waiting {
96- wait, idle = min(wait, fw), false
97- }
98- }
99- }) {
100 return77 return
101 }78 }
102- if idle {79+ }
103- // Nothing waits: sleep until a commit queues a callback80+}
104- // (frameReady) or a flip.81+
105- timer.Stop()82+// paceFromPacer runs the pacer's collected flips (flipped, frameReports)
106- continue83+// on the display goroutine.
84+func (s *Server) paceFromPacer() { s.paceTick(s.flipped, s.frameReports) }
85+
86+// paceNow runs a tick from code already on the display goroutine (a client
87+// commit): the pacer goroutine would only call Do to get back here. Without
88+// a running pacer (no timer) it falls back to waking it.
89+func (s *Server) paceNow() {
90+ if s.paceTimer == nil {
91+ s.signalPacer()
92+ return
93+ }
94+ s.paceTick(nil, nil)
95+}
96+
97+// paceTick answers the flips in reports, fires the due frame callbacks,
98+// releases buffers and applies the ready FIFO commits, then arms the timer
99+// for the next deadline (stopped when nothing waits). Display goroutine only.
100+func (s *Server) paceTick(flipped map[string]bool, reports []ports.OutputPresented) {
101+ if s.ctx != nil && s.ctx.Err() != nil {
102+ return
103+ }
104+ wait, idle := defaultFramePeriod, false
105+ for pass := 0; ; pass++ {
106+ s.paceDirty = false
107+ s.paceDepth++
108+ wait, idle = s.pacePass(flipped, reports)
109+ s.paceDepth--
110+ if !s.paceDirty {
111+ break
107 }112 }
108- timer.Reset(wait)113+ // The pass queued work behind its own checks (applied commits
114+ // queue callbacks, released buffers): look again, as a flip-less
115+ // tick, instead of waking the pacer for it.
116+ flipped, reports = nil, nil
117+ if pass+1 == maxPacePasses {
118+ s.paceDirty = false
119+ s.signalPacer()
120+ break
121+ }
122+ }
123+ if s.paceTimer == nil {
124+ return
125+ }
126+ if idle {
127+ // Nothing waits: sleep until a commit queues a callback
128+ // (wakePacer) or a flip.
129+ s.paceTimer.Stop()
130+ return
131+ }
132+ s.paceTimer.Reset(wait)
133+}
134+
135+// pacePass is one pass of paceTick; it returns the time to the next
136+// deadline and whether nothing waits.
137+func (s *Server) pacePass(flipped map[string]bool, reports []ports.OutputPresented) (wait time.Duration, idle bool) {
138+ // Each flip answers its own presentation feedbacks, in order: flips
139+ // are never merged here.
140+ for _, p := range reports {
141+ if p.Flip != nil {
142+ s.flips[p.Output] = *p.Flip
143+ s.presentFlip(p.Output, p.Flip)
144+ }
145+ }
146+ s.dropFeedbacks(time.Now())
147+ var due []string
148+ due, wait, idle = s.dueFrames(time.Now(), flipped)
149+ if len(s.feedbacks) > 0 {
150+ // Unanswered feedbacks time out: keep ticking.
151+ wait, idle = min(wait, feedbackTimeout), false
152+ }
153+ for _, name := range due {
154+ s.sendFrames(name, flipped[name])
155+ }
156+ if s.releaseHeld(time.Now(), reports) && idle {
157+ // Held buffers wait for a flip or heldTimeout.
158+ wait, idle = heldTimeout, false
159+ }
160+ // Fifo barriers and queued commits wait for a refresh.
161+ if fw, waiting := s.tickFifo(time.Now(), flipped); waiting {
162+ wait, idle = min(wait, fw), false
109 }163 }
164+ return wait, idle
110 }165 }
111 166
112 // toplevelRoot resolves subsurfaces and popups to their parent toplevel.167 // toplevelRoot resolves subsurfaces and popups to their parent toplevel.
A internal/adapters/wayland/pace_inline_test.go +93 -0
@@ -0,0 +1,93 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// paceFixture is a server whose surface waits on an output whose next
12+// frame is an hour away: a commit queues its callback and nothing fires.
13+func paceFixture(timer bool) (*Server, *surface) {
14+ out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
15+ s := &Server{
16+ outputs: []*output{out},
17+ awaiting: map[string][]*wayland.Callback{},
18+ frameDue: map[string]time.Time{"DP-2": time.Now().Add(time.Hour)},
19+ fifoSurfaces: map[*surface]struct{}{},
20+ lastFlip: map[string]time.Time{},
21+ flips: map[string]ports.FlipInfo{},
22+ reports: map[string]ports.OutputPresented{},
23+ frameReady: make(chan struct{}, 1),
24+ }
25+ if timer {
26+ s.paceTimer = time.NewTimer(time.Hour)
27+ s.paceTimer.Stop()
28+ }
29+ w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}
30+ surf := &surface{server: s, xdg: &xdgSurface{window: w}}
31+ surf.next.callbacks = []*wayland.Callback{new(wayland.Callback)}
32+ return s, surf
33+}
34+
35+// A commit on the display goroutine ticks the pacer inline: it arms the
36+// pacer's timer itself and does not wake the pacer goroutine, which would
37+// only call display.Do to get back to the display goroutine.
38+func TestCommitTicksPacerInline(t *testing.T) {
39+ s, surf := paceFixture(true)
40+ surf.queueAndTick()
41+ if len(s.awaiting["DP-2"]) != 1 {
42+ t.Fatalf("callback not queued: %+v", s.awaiting)
43+ }
44+ if len(s.frameReady) != 0 {
45+ t.Fatal("commit woke the pacer goroutine")
46+ }
47+ if !s.paceTimer.Stop() {
48+ t.Fatal("inline tick did not arm the pacer timer for the next deadline")
49+ }
50+ if s.paceDepth != 0 || s.paceDirty {
51+ t.Fatalf("pace state leaked: depth %d dirty %v", s.paceDepth, s.paceDirty)
52+ }
53+}
54+
55+// Without a running pacer (no timer) the wake falls back to the channel.
56+func TestCommitWakesPacerWithoutTimer(t *testing.T) {
57+ s, surf := paceFixture(false)
58+ surf.queueAndTick()
59+ if len(s.frameReady) != 1 {
60+ t.Fatal("pacer not woken")
61+ }
62+}
63+
64+// Wakes inside a tick coalesce into one more pass, not one channel wake each.
65+func TestWakePacerDefersInsideTick(t *testing.T) {
66+ s, _ := paceFixture(true)
67+ s.paceDepth = 1
68+ s.wakePacer()
69+ s.wakePacer()
70+ if len(s.frameReady) != 0 || !s.paceDirty {
71+ t.Fatalf("wake inside a tick: queued %d dirty %v", len(s.frameReady), s.paceDirty)
72+ }
73+ s.paceDepth = 0
74+ s.wakePacer()
75+ if len(s.frameReady) != 1 {
76+ t.Fatal("wake outside a tick lost")
77+ }
78+}
79+
80+// A commit that waits (a fifo barrier) is timed by the inline tick.
81+func TestCommitWaitingOnBarrierArmsTimer(t *testing.T) {
82+ s, surf := paceFixture(true)
83+ surf.next.callbacks = nil
84+ surf.barrier, surf.barrierAt = true, time.Now()
85+ surf.next.wait = true
86+ surf.queueAndTick()
87+ if len(surf.queue) != 1 || len(s.frameReady) != 0 {
88+ t.Fatalf("queued %d, pacer wakes %d", len(surf.queue), len(s.frameReady))
89+ }
90+ if !s.paceTimer.Stop() {
91+ t.Fatal("timer not armed for the barrier deadline")
92+ }
93+}
@@ -0,0 +1,93 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// paceFixture is a server whose surface waits on an output whose next
12+// frame is an hour away: a commit queues its callback and nothing fires.
13+func paceFixture(timer bool) (*Server, *surface) {
14+ out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
15+ s := &Server{
16+ outputs: []*output{out},
17+ awaiting: map[string][]*wayland.Callback{},
18+ frameDue: map[string]time.Time{"DP-2": time.Now().Add(time.Hour)},
19+ fifoSurfaces: map[*surface]struct{}{},
20+ lastFlip: map[string]time.Time{},
21+ flips: map[string]ports.FlipInfo{},
22+ reports: map[string]ports.OutputPresented{},
23+ frameReady: make(chan struct{}, 1),
24+ }
25+ if timer {
26+ s.paceTimer = time.NewTimer(time.Hour)
27+ s.paceTimer.Stop()
28+ }
29+ w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}
30+ surf := &surface{server: s, xdg: &xdgSurface{window: w}}
31+ surf.next.callbacks = []*wayland.Callback{new(wayland.Callback)}
32+ return s, surf
33+}
34+
35+// A commit on the display goroutine ticks the pacer inline: it arms the
36+// pacer's timer itself and does not wake the pacer goroutine, which would
37+// only call display.Do to get back to the display goroutine.
38+func TestCommitTicksPacerInline(t *testing.T) {
39+ s, surf := paceFixture(true)
40+ surf.queueAndTick()
41+ if len(s.awaiting["DP-2"]) != 1 {
42+ t.Fatalf("callback not queued: %+v", s.awaiting)
43+ }
44+ if len(s.frameReady) != 0 {
45+ t.Fatal("commit woke the pacer goroutine")
46+ }
47+ if !s.paceTimer.Stop() {
48+ t.Fatal("inline tick did not arm the pacer timer for the next deadline")
49+ }
50+ if s.paceDepth != 0 || s.paceDirty {
51+ t.Fatalf("pace state leaked: depth %d dirty %v", s.paceDepth, s.paceDirty)
52+ }
53+}
54+
55+// Without a running pacer (no timer) the wake falls back to the channel.
56+func TestCommitWakesPacerWithoutTimer(t *testing.T) {
57+ s, surf := paceFixture(false)
58+ surf.queueAndTick()
59+ if len(s.frameReady) != 1 {
60+ t.Fatal("pacer not woken")
61+ }
62+}
63+
64+// Wakes inside a tick coalesce into one more pass, not one channel wake each.
65+func TestWakePacerDefersInsideTick(t *testing.T) {
66+ s, _ := paceFixture(true)
67+ s.paceDepth = 1
68+ s.wakePacer()
69+ s.wakePacer()
70+ if len(s.frameReady) != 0 || !s.paceDirty {
71+ t.Fatalf("wake inside a tick: queued %d dirty %v", len(s.frameReady), s.paceDirty)
72+ }
73+ s.paceDepth = 0
74+ s.wakePacer()
75+ if len(s.frameReady) != 1 {
76+ t.Fatal("wake outside a tick lost")
77+ }
78+}
79+
80+// A commit that waits (a fifo barrier) is timed by the inline tick.
81+func TestCommitWaitingOnBarrierArmsTimer(t *testing.T) {
82+ s, surf := paceFixture(true)
83+ surf.next.callbacks = nil
84+ surf.barrier, surf.barrierAt = true, time.Now()
85+ surf.next.wait = true
86+ surf.queueAndTick()
87+ if len(surf.queue) != 1 || len(s.frameReady) != 0 {
88+ t.Fatalf("queued %d, pacer wakes %d", len(surf.queue), len(s.frameReady))
89+ }
90+ if !s.paceTimer.Stop() {
91+ t.Fatal("timer not armed for the barrier deadline")
92+ }
93+}
M internal/adapters/wayland/presentation.go +1 -0
@@ -108,6 +108,7 @@ func (s *surface) commitFeedback(pending []*presentationtime.WpPresentationFeedb
108108 for _, fb := range pending {
109109 srv.feedbacks = append(srv.feedbacks, feedbackWait{fb: fb, surf: s, win: win, seq: seq, at: time.Now()})
110110 }
111+ srv.wakePacer() // an unanswered feedback times out: the pacer must keep ticking
111112 }
112113
113114 func discard(fb *presentationtime.WpPresentationFeedback) {
@@ -108,6 +108,7 @@ func (s *surface) commitFeedback(pending []*presentationtime.WpPresentationFeedb
108 for _, fb := range pending {108 for _, fb := range pending {
109 srv.feedbacks = append(srv.feedbacks, feedbackWait{fb: fb, surf: s, win: win, seq: seq, at: time.Now()})109 srv.feedbacks = append(srv.feedbacks, feedbackWait{fb: fb, surf: s, win: win, seq: seq, at: time.Now()})
110 }110 }
111+ srv.wakePacer() // an unanswered feedback times out: the pacer must keep ticking
111 }112 }
112 113
113 func discard(fb *presentationtime.WpPresentationFeedback) {114 func discard(fb *presentationtime.WpPresentationFeedback) {
M internal/adapters/wayland/release.go +1 -4
@@ -52,10 +52,7 @@ func (s *Server) releaseBuffer(surf *surface, b *wayland.Buffer, sync syncHold)
5252 s.contentMu.Unlock()
5353 s.held = append(s.held, h)
5454 // Wake the pacer: it releases the buffer on the next output report.
55- select {
56- case s.frameReady <- struct{}{}:
57- default:
58- }
55+ s.wakePacer()
5956 }
6057
6158 // release gives a buffer back: its release point is signalled when it
@@ -52,10 +52,7 @@ func (s *Server) releaseBuffer(surf *surface, b *wayland.Buffer, sync syncHold)
52 s.contentMu.Unlock()52 s.contentMu.Unlock()
53 s.held = append(s.held, h)53 s.held = append(s.held, h)
54 // Wake the pacer: it releases the buffer on the next output report.54 // Wake the pacer: it releases the buffer on the next output report.
55- select {55+ s.wakePacer()
56- case s.frameReady <- struct{}{}:
57- default:
58- }
59 }56 }
60 57
61 // release gives a buffer back: its release point is signalled when it58 // release gives a buffer back: its release point is signalled when it
M internal/adapters/wayland/server.go +10 -3
@@ -151,6 +151,12 @@ type Server struct {
151151 frameOwners map[*wayland.Callback]*surface
152152 frameDue map[string]time.Time
153153 frameReady chan struct{}
154+ // paceTimer is the pacer's deadline, armed by ticks on the display
155+ // goroutine; paceDepth/paceDirty defer wakePacer inside a commit or
156+ // tick (display goroutine only).
157+ paceTimer *time.Timer
158+ paceDepth int
159+ paceDirty bool
154160 // Pacer scratch: periods is used on the display goroutine, while
155161 // flipped and frameReports belong to pace and its synchronous Do call.
156162 framePeriods map[string]time.Duration
@@ -369,7 +375,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
369375 var node *renderNode
370376 if opts.syncDev != nil {
371377 s.syncDev = opts.syncDev
372- if s.syncWait, err = newSyncWaiter(s.wakePacer); err != nil {
378+ if s.syncWait, err = newSyncWaiter(s.signalPacer); err != nil {
373379 cleanup()
374380 d.Close()
375381 return nil, err
@@ -378,7 +384,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
378384 if node, err = openSyncobj(opts.SyncobjNode); err != nil {
379385 log.Info().Err(err).Msg("explicit sync off")
380386 node = nil
381- } else if s.syncWait, err = newSyncWaiter(s.wakePacer); err != nil {
387+ } else if s.syncWait, err = newSyncWaiter(s.signalPacer); err != nil {
382388 node.close()
383389 node = nil
384390 } else {
@@ -387,7 +393,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
387393 }
388394 // Implicit dma-buf fences also need this waiter when explicit sync is off.
389395 if s.syncWait == nil {
390- if s.syncWait, err = newSyncWaiter(s.wakePacer); err != nil {
396+ if s.syncWait, err = newSyncWaiter(s.signalPacer); err != nil {
391397 cleanup()
392398 d.Close()
393399 return nil, err
@@ -453,6 +459,7 @@ func (s *Server) Run(ctx context.Context) error {
453459 s.log.Info().Str("socket", s.name).Msg("starting wayland")
454460 s.started = time.Now()
455461 s.ctx = ctx
462+ s.paceTimer = time.NewTimer(defaultFramePeriod)
456463 var wg sync.WaitGroup
457464 wg.Add(15)
458465 go func() { defer wg.Done(); s.forwardBackendSecurity(ctx) }()
@@ -151,6 +151,12 @@ type Server struct {
151 frameOwners map[*wayland.Callback]*surface151 frameOwners map[*wayland.Callback]*surface
152 frameDue map[string]time.Time152 frameDue map[string]time.Time
153 frameReady chan struct{}153 frameReady chan struct{}
154+ // paceTimer is the pacer's deadline, armed by ticks on the display
155+ // goroutine; paceDepth/paceDirty defer wakePacer inside a commit or
156+ // tick (display goroutine only).
157+ paceTimer *time.Timer
158+ paceDepth int
159+ paceDirty bool
154 // Pacer scratch: periods is used on the display goroutine, while160 // Pacer scratch: periods is used on the display goroutine, while
155 // flipped and frameReports belong to pace and its synchronous Do call.161 // flipped and frameReports belong to pace and its synchronous Do call.
156 framePeriods map[string]time.Duration162 framePeriods map[string]time.Duration
@@ -369,7 +375,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
369 var node *renderNode375 var node *renderNode
370 if opts.syncDev != nil {376 if opts.syncDev != nil {
371 s.syncDev = opts.syncDev377 s.syncDev = opts.syncDev
372- if s.syncWait, err = newSyncWaiter(s.wakePacer); err != nil {378+ if s.syncWait, err = newSyncWaiter(s.signalPacer); err != nil {
373 cleanup()379 cleanup()
374 d.Close()380 d.Close()
375 return nil, err381 return nil, err
@@ -378,7 +384,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
378 if node, err = openSyncobj(opts.SyncobjNode); err != nil {384 if node, err = openSyncobj(opts.SyncobjNode); err != nil {
379 log.Info().Err(err).Msg("explicit sync off")385 log.Info().Err(err).Msg("explicit sync off")
380 node = nil386 node = nil
381- } else if s.syncWait, err = newSyncWaiter(s.wakePacer); err != nil {387+ } else if s.syncWait, err = newSyncWaiter(s.signalPacer); err != nil {
382 node.close()388 node.close()
383 node = nil389 node = nil
384 } else {390 } else {
@@ -387,7 +393,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
387 }393 }
388 // Implicit dma-buf fences also need this waiter when explicit sync is off.394 // Implicit dma-buf fences also need this waiter when explicit sync is off.
389 if s.syncWait == nil {395 if s.syncWait == nil {
390- if s.syncWait, err = newSyncWaiter(s.wakePacer); err != nil {396+ if s.syncWait, err = newSyncWaiter(s.signalPacer); err != nil {
391 cleanup()397 cleanup()
392 d.Close()398 d.Close()
393 return nil, err399 return nil, err
@@ -453,6 +459,7 @@ func (s *Server) Run(ctx context.Context) error {
453 s.log.Info().Str("socket", s.name).Msg("starting wayland")459 s.log.Info().Str("socket", s.name).Msg("starting wayland")
454 s.started = time.Now()460 s.started = time.Now()
455 s.ctx = ctx461 s.ctx = ctx
462+ s.paceTimer = time.NewTimer(defaultFramePeriod)
456 var wg sync.WaitGroup463 var wg sync.WaitGroup
457 wg.Add(15)464 wg.Add(15)
458 go func() { defer wg.Done(); s.forwardBackendSecurity(ctx) }()465 go func() { defer wg.Done(); s.forwardBackendSecurity(ctx) }()
M internal/adapters/wayland/surface.go +22 -1
@@ -371,8 +371,29 @@ func (s *surface) Commit(*wayland.Surface) {
371371 s.takeSyncPoints()
372372 // Each request creates an ordered content update. A synchronized update
373373 // can only be reached through a parent's committed dependency graph.
374+ s.queueAndTick()
375+}
376+
377+// queueAndTick queues the commit and applies what is ready. The pacer is
378+// woken at most once, after the work, and by a tick on this goroutine
379+// rather than by a wake that makes the pacer call Do back.
380+func (s *surface) queueAndTick() {
381+ srv := s.server
382+ srv.paceDepth++
374383 s.queueUpdate()
375- s.server.tickFifo(time.Now(), nil)
384+ srv.tickFifo(time.Now(), nil)
385+ if len(s.queue) > 0 {
386+ // The commit waits for a barrier, a fence or a refresh: the pacer
387+ // must time it. A commit applied at once needs no tick of its own;
388+ // its callbacks, barrier and held buffers wake the pacer where
389+ // they are queued.
390+ srv.wakePacer()
391+ }
392+ srv.paceDepth--
393+ if srv.paceDepth == 0 && srv.paceDirty {
394+ srv.paceDirty = false
395+ srv.paceNow()
396+ }
376397 }
377398
378399 // applyCommit makes only the captured update current. Live pending requests
@@ -371,8 +371,29 @@ func (s *surface) Commit(*wayland.Surface) {
371 s.takeSyncPoints()371 s.takeSyncPoints()
372 // Each request creates an ordered content update. A synchronized update372 // Each request creates an ordered content update. A synchronized update
373 // can only be reached through a parent's committed dependency graph.373 // can only be reached through a parent's committed dependency graph.
374+ s.queueAndTick()
375+}
376+
377+// queueAndTick queues the commit and applies what is ready. The pacer is
378+// woken at most once, after the work, and by a tick on this goroutine
379+// rather than by a wake that makes the pacer call Do back.
380+func (s *surface) queueAndTick() {
381+ srv := s.server
382+ srv.paceDepth++
374 s.queueUpdate()383 s.queueUpdate()
375- s.server.tickFifo(time.Now(), nil)384+ srv.tickFifo(time.Now(), nil)
385+ if len(s.queue) > 0 {
386+ // The commit waits for a barrier, a fence or a refresh: the pacer
387+ // must time it. A commit applied at once needs no tick of its own;
388+ // its callbacks, barrier and held buffers wake the pacer where
389+ // they are queued.
390+ srv.wakePacer()
391+ }
392+ srv.paceDepth--
393+ if srv.paceDepth == 0 && srv.paceDirty {
394+ srv.paceDirty = false
395+ srv.paceNow()
396+ }
376 }397 }
377 398
378 // applyCommit makes only the captured update current. Live pending requests399 // applyCommit makes only the captured update current. Live pending requests

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 25

Tags 10

↑↓ moveenter openesc close