detached 4d3f681

fix(wayland): synced subsurface commits no longer tick the pacer

A synchronized child commit waits for its parent, so ticking for it only
cost CPU. forwardContents waits on contentReady instead of allocating a
notify channel per publication. The pacer timer is created only by Run,
and the inline pacer tests receive from the timer instead of racing Stop.
commit
4d3f68196d254abec39c8a914b7fca279f0cfe6a
author
brice · · 2026-10-10 14:48 UTC
committer
brice · · 2026-10-10 15:39 UTC
parent
d17a46f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +36 -30
M internal/adapters/wayland/alloc_hotpath_test.go +1 -5
@@ -132,13 +132,9 @@ func TestEmitContentWakesWaiter(t *testing.T) {
132132 damage: map[ports.WindowID][]ports.SeqDamage{},
133133 contentReady: make(chan struct{}, 1),
134134 }
135- s.contentMu.Lock()
136- s.contentNotify = make(chan struct{})
137- notify := s.contentNotify
138- s.contentMu.Unlock()
139135 s.emitContent(ports.SurfaceContent{ID: 1, Solid: &ports.SolidColor{}}, damage{full: true})
140136 select {
141- case <-notify:
137+ case <-s.contentReady:
142138 default:
143139 t.Fatal("waiter not woken")
144140 }
@@ -132,13 +132,9 @@ func TestEmitContentWakesWaiter(t *testing.T) {
132 damage: map[ports.WindowID][]ports.SeqDamage{},132 damage: map[ports.WindowID][]ports.SeqDamage{},
133 contentReady: make(chan struct{}, 1),133 contentReady: make(chan struct{}, 1),
134 }134 }
135- s.contentMu.Lock()
136- s.contentNotify = make(chan struct{})
137- notify := s.contentNotify
138- s.contentMu.Unlock()
139 s.emitContent(ports.SurfaceContent{ID: 1, Solid: &ports.SolidColor{}}, damage{full: true})135 s.emitContent(ports.SurfaceContent{ID: 1, Solid: &ports.SolidColor{}}, damage{full: true})
140 select {136 select {
141- case <-notify:137+ case <-s.contentReady:
142 default:138 default:
143 t.Fatal("waiter not woken")139 t.Fatal("waiter not woken")
144 }140 }
M internal/adapters/wayland/frames.go +1 -4
@@ -33,10 +33,7 @@ const maxPacePasses = 3
3333 // ticks, which all run there, so a commit that ticks inline (paceNow) never
3434 // races the pacer for it.
3535 func (s *Server) pace(ctx context.Context) {
36- if s.paceTimer == nil {
37- s.paceTimer = time.NewTimer(defaultFramePeriod)
38- }
39- timer := s.paceTimer
36+ timer := s.paceTimer // created by Run before the pacer starts
4037 defer timer.Stop()
4138 presented := s.channels.Presented
4239 if s.flipped == nil {
@@ -33,10 +33,7 @@ const maxPacePasses = 3
33 // ticks, which all run there, so a commit that ticks inline (paceNow) never33 // ticks, which all run there, so a commit that ticks inline (paceNow) never
34 // races the pacer for it.34 // races the pacer for it.
35 func (s *Server) pace(ctx context.Context) {35 func (s *Server) pace(ctx context.Context) {
36- if s.paceTimer == nil {36+ timer := s.paceTimer // created by Run before the pacer starts
37- s.paceTimer = time.NewTimer(defaultFramePeriod)
38- }
39- timer := s.paceTimer
40 defer timer.Stop()37 defer timer.Stop()
41 presented := s.channels.Presented38 presented := s.channels.Presented
42 if s.flipped == nil {39 if s.flipped == nil {
M internal/adapters/wayland/pace_inline_test.go +27 -5
@@ -44,9 +44,7 @@ func TestCommitTicksPacerInline(t *testing.T) {
4444 if len(s.frameReady) != 0 {
4545 t.Fatal("commit woke the pacer goroutine")
4646 }
47- if !s.paceTimer.Stop() {
48- t.Fatal("inline tick did not arm the pacer timer for the next deadline")
49- }
47+ waitArmed(t, s, "inline tick did not arm the pacer timer for the next deadline")
5048 if s.paceDepth != 0 || s.paceDirty {
5149 t.Fatalf("pace state leaked: depth %d dirty %v", s.paceDepth, s.paceDirty)
5250 }
@@ -87,7 +85,31 @@ func TestCommitWaitingOnBarrierArmsTimer(t *testing.T) {
8785 if len(surf.queue) != 1 || len(s.frameReady) != 0 {
8886 t.Fatalf("queued %d, pacer wakes %d", len(surf.queue), len(s.frameReady))
8987 }
90- if !s.paceTimer.Stop() {
91- t.Fatal("timer not armed for the barrier deadline")
88+ waitArmed(t, s, "timer not armed for the barrier deadline")
89+}
90+
91+// waitArmed asserts the timer was armed: it fires within the test timeout.
92+// A receive works even when the deadline passed before the check.
93+func waitArmed(t *testing.T, s *Server, msg string) {
94+ t.Helper()
95+ select {
96+ case <-s.paceTimer.C:
97+ case <-time.After(2 * time.Second):
98+ t.Fatal(msg)
99+ }
100+}
101+
102+// A synchronized subsurface commit waits for its parent's commit: it does
103+// not tick the pacer, so a burst of child commits costs no ticks.
104+func TestSyncedCommitDoesNotTick(t *testing.T) {
105+ s, surf := paceFixture(true)
106+ surf.sub.parent = &surface{server: s}
107+ surf.sub.synced = true
108+ surf.queueAndTick()
109+ if len(surf.queue) != 1 || !surf.queue[0].synced {
110+ t.Fatalf("synced commit not queued: %d", len(surf.queue))
111+ }
112+ if len(s.frameReady) != 0 || s.paceTimer.Stop() {
113+ t.Fatal("synced commit ticked the pacer")
92114 }
93115 }
@@ -44,9 +44,7 @@ func TestCommitTicksPacerInline(t *testing.T) {
44 if len(s.frameReady) != 0 {44 if len(s.frameReady) != 0 {
45 t.Fatal("commit woke the pacer goroutine")45 t.Fatal("commit woke the pacer goroutine")
46 }46 }
47- if !s.paceTimer.Stop() {47+ waitArmed(t, s, "inline tick did not arm the pacer timer for the next deadline")
48- t.Fatal("inline tick did not arm the pacer timer for the next deadline")
49- }
50 if s.paceDepth != 0 || s.paceDirty {48 if s.paceDepth != 0 || s.paceDirty {
51 t.Fatalf("pace state leaked: depth %d dirty %v", s.paceDepth, s.paceDirty)49 t.Fatalf("pace state leaked: depth %d dirty %v", s.paceDepth, s.paceDirty)
52 }50 }
@@ -87,7 +85,31 @@ func TestCommitWaitingOnBarrierArmsTimer(t *testing.T) {
87 if len(surf.queue) != 1 || len(s.frameReady) != 0 {85 if len(surf.queue) != 1 || len(s.frameReady) != 0 {
88 t.Fatalf("queued %d, pacer wakes %d", len(surf.queue), len(s.frameReady))86 t.Fatalf("queued %d, pacer wakes %d", len(surf.queue), len(s.frameReady))
89 }87 }
90- if !s.paceTimer.Stop() {88+ waitArmed(t, s, "timer not armed for the barrier deadline")
91- t.Fatal("timer not armed for the barrier deadline")89+}
90+
91+// waitArmed asserts the timer was armed: it fires within the test timeout.
92+// A receive works even when the deadline passed before the check.
93+func waitArmed(t *testing.T, s *Server, msg string) {
94+ t.Helper()
95+ select {
96+ case <-s.paceTimer.C:
97+ case <-time.After(2 * time.Second):
98+ t.Fatal(msg)
99+ }
100+}
101+
102+// A synchronized subsurface commit waits for its parent's commit: it does
103+// not tick the pacer, so a burst of child commits costs no ticks.
104+func TestSyncedCommitDoesNotTick(t *testing.T) {
105+ s, surf := paceFixture(true)
106+ surf.sub.parent = &surface{server: s}
107+ surf.sub.synced = true
108+ surf.queueAndTick()
109+ if len(surf.queue) != 1 || !surf.queue[0].synced {
110+ t.Fatalf("synced commit not queued: %d", len(surf.queue))
111+ }
112+ if len(s.frameReady) != 0 || s.paceTimer.Stop() {
113+ t.Fatal("synced commit ticked the pacer")
92 }114 }
93 }115 }
M internal/adapters/wayland/server.go +4 -14
@@ -235,7 +235,6 @@ type Server struct {
235235 clock ports.Clock
236236 powers []*outputPower
237237 outputsOff map[string]bool
238- contentNotify chan struct{}
239238 contentReady chan struct{}
240239 outputs []*output
241240 outputManagers []*outputManager
@@ -666,12 +665,6 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) {
666665 }
667666 c.DamageHistory = h[:len(h):len(h)]
668667 s.contents[c.ID] = c
669- if s.contentNotify != nil {
670- // Only forwardContents waits on it, and it creates the channel when
671- // it does: no channel is allocated per commit.
672- close(s.contentNotify)
673- s.contentNotify = nil
674- }
675668 s.contentMu.Unlock()
676669 select {
677670 case s.contentReady <- struct{}{}:
@@ -686,16 +679,11 @@ func (s *Server) forwardContents(ctx context.Context) {
686679 var c ports.SurfaceContent
687680 found := false
688681 var seq uint64
689- var notify <-chan struct{}
690682 for id, c = range s.contents {
691683 found = true
692684 seq = s.contentSeq[id]
693685 break
694686 }
695- if s.contentNotify == nil {
696- s.contentNotify = make(chan struct{})
697- }
698- notify = s.contentNotify
699687 s.contentMu.Unlock()
700688 if !found {
701689 select {
@@ -705,11 +693,13 @@ func (s *Server) forwardContents(ctx context.Context) {
705693 continue
706694 }
707695 }
708- // Do not hold a stale frame while the receiver is busy: wake on newer data.
696+ // Do not hold a stale frame while the receiver is busy: wake on newer
697+ // data. contentReady is signalled after every publication; a stale
698+ // signal only costs one more loop.
709699 select {
710700 case <-ctx.Done():
711701 return
712- case <-notify:
702+ case <-s.contentReady:
713703 continue
714704 case s.channels.Contents <- c:
715705 s.contentMu.Lock()
@@ -235,7 +235,6 @@ type Server struct {
235 clock ports.Clock235 clock ports.Clock
236 powers []*outputPower236 powers []*outputPower
237 outputsOff map[string]bool237 outputsOff map[string]bool
238- contentNotify chan struct{}
239 contentReady chan struct{}238 contentReady chan struct{}
240 outputs []*output239 outputs []*output
241 outputManagers []*outputManager240 outputManagers []*outputManager
@@ -666,12 +665,6 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) {
666 }665 }
667 c.DamageHistory = h[:len(h):len(h)]666 c.DamageHistory = h[:len(h):len(h)]
668 s.contents[c.ID] = c667 s.contents[c.ID] = c
669- if s.contentNotify != nil {
670- // Only forwardContents waits on it, and it creates the channel when
671- // it does: no channel is allocated per commit.
672- close(s.contentNotify)
673- s.contentNotify = nil
674- }
675 s.contentMu.Unlock()668 s.contentMu.Unlock()
676 select {669 select {
677 case s.contentReady <- struct{}{}:670 case s.contentReady <- struct{}{}:
@@ -686,16 +679,11 @@ func (s *Server) forwardContents(ctx context.Context) {
686 var c ports.SurfaceContent679 var c ports.SurfaceContent
687 found := false680 found := false
688 var seq uint64681 var seq uint64
689- var notify <-chan struct{}
690 for id, c = range s.contents {682 for id, c = range s.contents {
691 found = true683 found = true
692 seq = s.contentSeq[id]684 seq = s.contentSeq[id]
693 break685 break
694 }686 }
695- if s.contentNotify == nil {
696- s.contentNotify = make(chan struct{})
697- }
698- notify = s.contentNotify
699 s.contentMu.Unlock()687 s.contentMu.Unlock()
700 if !found {688 if !found {
701 select {689 select {
@@ -705,11 +693,13 @@ func (s *Server) forwardContents(ctx context.Context) {
705 continue693 continue
706 }694 }
707 }695 }
708- // Do not hold a stale frame while the receiver is busy: wake on newer data.696+ // Do not hold a stale frame while the receiver is busy: wake on newer
697+ // data. contentReady is signalled after every publication; a stale
698+ // signal only costs one more loop.
709 select {699 select {
710 case <-ctx.Done():700 case <-ctx.Done():
711 return701 return
712- case <-notify:702+ case <-s.contentReady:
713 continue703 continue
714 case s.channels.Contents <- c:704 case s.channels.Contents <- c:
715 s.contentMu.Lock()705 s.contentMu.Lock()
M internal/adapters/wayland/surface.go +3 -2
@@ -385,11 +385,12 @@ func (s *surface) queueAndTick() {
385385 srv.paceDepth++
386386 s.queueUpdate()
387387 srv.tickFifo(time.Now(), nil)
388- if len(s.queue) > 0 {
388+ if n := len(s.queue); n > 0 && !s.queue[n-1].synced {
389389 // The commit waits for a barrier, a fence or a refresh: the pacer
390390 // must time it. A commit applied at once needs no tick of its own;
391391 // its callbacks, barrier and held buffers wake the pacer where
392- // they are queued.
392+ // they are queued. A synchronized commit waits for its parent's
393+ // commit, which ticks for it.
393394 srv.wakePacer()
394395 }
395396 srv.paceDepth--
@@ -385,11 +385,12 @@ func (s *surface) queueAndTick() {
385 srv.paceDepth++385 srv.paceDepth++
386 s.queueUpdate()386 s.queueUpdate()
387 srv.tickFifo(time.Now(), nil)387 srv.tickFifo(time.Now(), nil)
388- if len(s.queue) > 0 {388+ if n := len(s.queue); n > 0 && !s.queue[n-1].synced {
389 // The commit waits for a barrier, a fence or a refresh: the pacer389 // The commit waits for a barrier, a fence or a refresh: the pacer
390 // must time it. A commit applied at once needs no tick of its own;390 // must time it. A commit applied at once needs no tick of its own;
391 // its callbacks, barrier and held buffers wake the pacer where391 // its callbacks, barrier and held buffers wake the pacer where
392- // they are queued.392+ // they are queued. A synchronized commit waits for its parent's
393+ // commit, which ticks for it.
393 srv.wakePacer()394 srv.wakePacer()
394 }395 }
395 srv.paceDepth--396 srv.paceDepth--

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 26

Tags 10

↑↓ moveenter openesc close