detached 1364461

merge: frame-pacing (frame callbacks follow each output's refresh)

commit
136446199dceec83c818d7787b2e97c87ed5e06b
author
brice · · 2026-09-25 15:22 UTC
merge of
59256c2 + e38f0b1 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +290 -48
M internal/adapters/drm/output.go +6 -1
@@ -117,7 +117,8 @@ func (o *Output) Close() {
117117 }
118118
119119 // Run renders scenes and flips until ctx ends. active reports seat enable/disable.
120-func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error {
120+// Each completed page flip is reported on presented (dropped when full).
121+func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented) error {
121122 r, err := newRenderer(o.Width(), o.Height())
122123 if err != nil {
123124 return fmt.Errorf("create renderer: %w", err)
@@ -169,6 +170,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
169170 }
170171 o.pending = false
171172 o.flips += n
173+ select {
174+ case presented <- ports.OutputPresented{Output: o.conn.name}:
175+ default:
176+ }
172177 if d := time.Since(o.flipStart); d > 20*time.Millisecond {
173178 o.log.Info().Dur("flip_ms", d).Msg("slow flip")
174179 }
@@ -117,7 +117,8 @@ func (o *Output) Close() {
117 }117 }
118 118
119 // Run renders scenes and flips until ctx ends. active reports seat enable/disable.119 // Run renders scenes and flips until ctx ends. active reports seat enable/disable.
120-func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error {120+// Each completed page flip is reported on presented (dropped when full).
121+func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented) error {
121 r, err := newRenderer(o.Width(), o.Height())122 r, err := newRenderer(o.Width(), o.Height())
122 if err != nil {123 if err != nil {
123 return fmt.Errorf("create renderer: %w", err)124 return fmt.Errorf("create renderer: %w", err)
@@ -169,6 +170,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
169 }170 }
170 o.pending = false171 o.pending = false
171 o.flips += n172 o.flips += n
173+ select {
174+ case presented <- ports.OutputPresented{Output: o.conn.name}:
175+ default:
176+ }
172 if d := time.Since(o.flipStart); d > 20*time.Millisecond {177 if d := time.Since(o.flipStart); d > 20*time.Millisecond {
173 o.log.Info().Dur("flip_ms", d).Msg("slow flip")178 o.log.Info().Dur("flip_ms", d).Msg("slow flip")
174 }179 }
A internal/adapters/wayland/frames.go +180 -0
@@ -0,0 +1,180 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "slices"
6+ "time"
7+
8+ "github.com/bnema/purego-libwayland/protocol/wayland"
9+)
10+
11+// Frame callbacks (wl_surface.frame) tell a client when to draw its next
12+// frame. They are paced per output: on its page flip when it flips, else
13+// at its refresh rate, so a 165 Hz output lets clients draw 165 frames a
14+// second and a V-Sync game follows the screen. Surfaces with no output
15+// (hidden windows, cursors, unplaced surfaces) are paced at 60 Hz. The
16+// pacer sleeps while no callback waits.
17+
18+// defaultFramePeriod paces surfaces without an output (60 Hz).
19+const defaultFramePeriod = time.Second / 60
20+
21+// pace fires the due frame callbacks on page flips and deadlines.
22+func (s *Server) pace(ctx context.Context) {
23+ timer := time.NewTimer(defaultFramePeriod)
24+ defer timer.Stop()
25+ presented := s.channels.Presented
26+ for {
27+ flipped := map[string]bool{}
28+ select {
29+ case <-ctx.Done():
30+ return
31+ case <-s.display.Stopped():
32+ return
33+ case p := <-presented:
34+ flipped[p.Output] = true
35+ case <-s.frameReady:
36+ case <-timer.C:
37+ }
38+ // Coalesce flips queued while the display was busy: one burst of
39+ // callbacks per output, not one per stale flip.
40+ drain:
41+ for {
42+ select {
43+ case p := <-presented:
44+ flipped[p.Output] = true
45+ default:
46+ break drain
47+ }
48+ }
49+ wait, idle := defaultFramePeriod, false
50+ if !s.display.Do(func() {
51+ if ctx.Err() == nil {
52+ var due []string
53+ due, wait, idle = s.dueFrames(time.Now(), flipped)
54+ for _, name := range due {
55+ s.sendFrames(name)
56+ }
57+ }
58+ }) {
59+ return
60+ }
61+ if idle {
62+ // Nothing waits: sleep until a commit queues a callback
63+ // (frameReady) or a flip.
64+ timer.Stop()
65+ continue
66+ }
67+ timer.Reset(wait)
68+ }
69+}
70+
71+// frameOutput is the output showing a surface, "" when none does (a hidden
72+// window, a cursor): unlike outputOfSurface, no fallback to the focused one.
73+func (s *Server) frameOutput(surf *surface) string {
74+ surf = surf.root()
75+ for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {
76+ surf = surf.xdg.window.popup.parent.xdg.surface.root()
77+ }
78+ switch {
79+ case surf.xdg != nil && surf.xdg.window != nil:
80+ return surf.xdg.window.last.Output
81+ case surf.layer != nil && surf.layer.output != nil:
82+ return surf.layer.output.name()
83+ }
84+ return ""
85+}
86+
87+// queueFrames adds a committed surface's callbacks to its output's queue
88+// ("" when it has none) and wakes the pacer.
89+func (s *Server) queueFrames(name string, callbacks []*wayland.Callback) {
90+ s.awaiting[name] = append(s.awaiting[name], callbacks...)
91+ select {
92+ case s.frameReady <- struct{}{}:
93+ default:
94+ }
95+}
96+
97+// dueFrames returns the queues to fire now: flipped outputs and those past
98+// their deadline. wait is the time to the next deadline; idle is set when
99+// no callback waits after firing.
100+func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) {
101+ periods := make(map[string]time.Duration, len(s.outputs)+1)
102+ for _, o := range s.outputs {
103+ periods[o.name()] = framePeriod(o.place.Info.RefreshMilli)
104+ }
105+ // Callbacks of surfaces whose output is gone go with the outputless ones.
106+ for name, callbacks := range s.awaiting {
107+ if _, ok := periods[name]; !ok && name != "" {
108+ delete(s.awaiting, name)
109+ s.awaiting[""] = append(s.awaiting[""], callbacks...)
110+ }
111+ }
112+ periods[""] = defaultFramePeriod
113+ for name := range s.frameDue {
114+ if _, ok := periods[name]; !ok {
115+ delete(s.frameDue, name)
116+ }
117+ }
118+ for name, p := range periods {
119+ due, ok := s.frameDue[name]
120+ switch {
121+ case flipped[name] && name != "":
122+ // A flipping output is paced by its flips; its deadline only
123+ // catches a missed one.
124+ fire = append(fire, name)
125+ s.frameDue[name] = now.Add(p + p/2)
126+ case !ok || !now.Before(due):
127+ if len(s.awaiting[name]) == 0 {
128+ // Idle: the next callback goes out as soon as it comes.
129+ delete(s.frameDue, name)
130+ continue
131+ }
132+ fire = append(fire, name)
133+ // Keep the phase; after a stall, restart from now.
134+ next := due.Add(p)
135+ if !ok || !next.After(now) {
136+ next = now.Add(p)
137+ }
138+ s.frameDue[name] = next
139+ }
140+ }
141+ waiting := false
142+ wait = defaultFramePeriod
143+ for name, callbacks := range s.awaiting {
144+ if len(callbacks) == 0 || slices.Contains(fire, name) {
145+ continue
146+ }
147+ waiting = true
148+ if due, ok := s.frameDue[name]; ok {
149+ wait = min(wait, due.Sub(now))
150+ } else {
151+ wait = 0
152+ }
153+ }
154+ return fire, max(wait, time.Millisecond), !waiting
155+}
156+
157+func (s *Server) sendFrames(name string) {
158+ callbacks := s.awaiting[name]
159+ delete(s.awaiting, name)
160+ if len(callbacks) == 0 {
161+ return
162+ }
163+ ms := uint32(time.Since(s.started).Milliseconds())
164+ for _, cb := range callbacks {
165+ if !cb.Resource.Alive() {
166+ continue
167+ }
168+ cb.SendDone(ms)
169+ cb.Destroy()
170+ s.frames++
171+ }
172+}
173+
174+// framePeriod is one refresh at refreshMilli mHz, 60 Hz when unknown.
175+func framePeriod(refreshMilli int) time.Duration {
176+ if refreshMilli <= 0 {
177+ return defaultFramePeriod
178+ }
179+ return time.Duration(int64(time.Second) * 1000 / int64(refreshMilli))
180+}
@@ -0,0 +1,180 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "slices"
6+ "time"
7+
8+ "github.com/bnema/purego-libwayland/protocol/wayland"
9+)
10+
11+// Frame callbacks (wl_surface.frame) tell a client when to draw its next
12+// frame. They are paced per output: on its page flip when it flips, else
13+// at its refresh rate, so a 165 Hz output lets clients draw 165 frames a
14+// second and a V-Sync game follows the screen. Surfaces with no output
15+// (hidden windows, cursors, unplaced surfaces) are paced at 60 Hz. The
16+// pacer sleeps while no callback waits.
17+
18+// defaultFramePeriod paces surfaces without an output (60 Hz).
19+const defaultFramePeriod = time.Second / 60
20+
21+// pace fires the due frame callbacks on page flips and deadlines.
22+func (s *Server) pace(ctx context.Context) {
23+ timer := time.NewTimer(defaultFramePeriod)
24+ defer timer.Stop()
25+ presented := s.channels.Presented
26+ for {
27+ flipped := map[string]bool{}
28+ select {
29+ case <-ctx.Done():
30+ return
31+ case <-s.display.Stopped():
32+ return
33+ case p := <-presented:
34+ flipped[p.Output] = true
35+ case <-s.frameReady:
36+ case <-timer.C:
37+ }
38+ // Coalesce flips queued while the display was busy: one burst of
39+ // callbacks per output, not one per stale flip.
40+ drain:
41+ for {
42+ select {
43+ case p := <-presented:
44+ flipped[p.Output] = true
45+ default:
46+ break drain
47+ }
48+ }
49+ wait, idle := defaultFramePeriod, false
50+ if !s.display.Do(func() {
51+ if ctx.Err() == nil {
52+ var due []string
53+ due, wait, idle = s.dueFrames(time.Now(), flipped)
54+ for _, name := range due {
55+ s.sendFrames(name)
56+ }
57+ }
58+ }) {
59+ return
60+ }
61+ if idle {
62+ // Nothing waits: sleep until a commit queues a callback
63+ // (frameReady) or a flip.
64+ timer.Stop()
65+ continue
66+ }
67+ timer.Reset(wait)
68+ }
69+}
70+
71+// frameOutput is the output showing a surface, "" when none does (a hidden
72+// window, a cursor): unlike outputOfSurface, no fallback to the focused one.
73+func (s *Server) frameOutput(surf *surface) string {
74+ surf = surf.root()
75+ for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {
76+ surf = surf.xdg.window.popup.parent.xdg.surface.root()
77+ }
78+ switch {
79+ case surf.xdg != nil && surf.xdg.window != nil:
80+ return surf.xdg.window.last.Output
81+ case surf.layer != nil && surf.layer.output != nil:
82+ return surf.layer.output.name()
83+ }
84+ return ""
85+}
86+
87+// queueFrames adds a committed surface's callbacks to its output's queue
88+// ("" when it has none) and wakes the pacer.
89+func (s *Server) queueFrames(name string, callbacks []*wayland.Callback) {
90+ s.awaiting[name] = append(s.awaiting[name], callbacks...)
91+ select {
92+ case s.frameReady <- struct{}{}:
93+ default:
94+ }
95+}
96+
97+// dueFrames returns the queues to fire now: flipped outputs and those past
98+// their deadline. wait is the time to the next deadline; idle is set when
99+// no callback waits after firing.
100+func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) {
101+ periods := make(map[string]time.Duration, len(s.outputs)+1)
102+ for _, o := range s.outputs {
103+ periods[o.name()] = framePeriod(o.place.Info.RefreshMilli)
104+ }
105+ // Callbacks of surfaces whose output is gone go with the outputless ones.
106+ for name, callbacks := range s.awaiting {
107+ if _, ok := periods[name]; !ok && name != "" {
108+ delete(s.awaiting, name)
109+ s.awaiting[""] = append(s.awaiting[""], callbacks...)
110+ }
111+ }
112+ periods[""] = defaultFramePeriod
113+ for name := range s.frameDue {
114+ if _, ok := periods[name]; !ok {
115+ delete(s.frameDue, name)
116+ }
117+ }
118+ for name, p := range periods {
119+ due, ok := s.frameDue[name]
120+ switch {
121+ case flipped[name] && name != "":
122+ // A flipping output is paced by its flips; its deadline only
123+ // catches a missed one.
124+ fire = append(fire, name)
125+ s.frameDue[name] = now.Add(p + p/2)
126+ case !ok || !now.Before(due):
127+ if len(s.awaiting[name]) == 0 {
128+ // Idle: the next callback goes out as soon as it comes.
129+ delete(s.frameDue, name)
130+ continue
131+ }
132+ fire = append(fire, name)
133+ // Keep the phase; after a stall, restart from now.
134+ next := due.Add(p)
135+ if !ok || !next.After(now) {
136+ next = now.Add(p)
137+ }
138+ s.frameDue[name] = next
139+ }
140+ }
141+ waiting := false
142+ wait = defaultFramePeriod
143+ for name, callbacks := range s.awaiting {
144+ if len(callbacks) == 0 || slices.Contains(fire, name) {
145+ continue
146+ }
147+ waiting = true
148+ if due, ok := s.frameDue[name]; ok {
149+ wait = min(wait, due.Sub(now))
150+ } else {
151+ wait = 0
152+ }
153+ }
154+ return fire, max(wait, time.Millisecond), !waiting
155+}
156+
157+func (s *Server) sendFrames(name string) {
158+ callbacks := s.awaiting[name]
159+ delete(s.awaiting, name)
160+ if len(callbacks) == 0 {
161+ return
162+ }
163+ ms := uint32(time.Since(s.started).Milliseconds())
164+ for _, cb := range callbacks {
165+ if !cb.Resource.Alive() {
166+ continue
167+ }
168+ cb.SendDone(ms)
169+ cb.Destroy()
170+ s.frames++
171+ }
172+}
173+
174+// framePeriod is one refresh at refreshMilli mHz, 60 Hz when unknown.
175+func framePeriod(refreshMilli int) time.Duration {
176+ if refreshMilli <= 0 {
177+ return defaultFramePeriod
178+ }
179+ return time.Duration(int64(time.Second) * 1000 / int64(refreshMilli))
180+}
A internal/adapters/wayland/frames_test.go +76 -0
@@ -0,0 +1,76 @@
1+package wayland
2+
3+import (
4+ "slices"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+ "github.com/bnema/purego-libwayland/protocol/wayland"
10+)
11+
12+func TestFramePeriod(t *testing.T) {
13+ for _, tc := range []struct {
14+ milli int
15+ want time.Duration
16+ }{{0, time.Second / 60}, {60000, time.Second / 60}, {165058, 6058476}, {144000, 6944444}} {
17+ if got := framePeriod(tc.milli); got != tc.want {
18+ t.Errorf("%d: %v want %v", tc.milli, got, tc.want)
19+ }
20+ }
21+}
22+
23+// Each output is paced at its own refresh; a flip fires its queue and
24+// pushes its deadline back; the pacer idles with nothing queued.
25+func TestDueFrames(t *testing.T) {
26+ fast := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 165000}}}
27+ slow := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "HDMI-A-1", RefreshMilli: 60000}}}
28+ s := &Server{outputs: []*output{fast, slow}, frameDue: map[string]time.Time{}, awaiting: map[string][]*wayland.Callback{}}
29+ // dueFrames only counts the queued callbacks: nil ones stand for them.
30+ queue := func(name string) { s.awaiting[name] = append(s.awaiting[name], nil) }
31+ fired := func(fire []string) {
32+ for _, name := range fire {
33+ delete(s.awaiting, name)
34+ }
35+ }
36+ now := time.Unix(100, 0)
37+ if _, _, idle := s.dueFrames(now, nil); !idle {
38+ t.Fatal("not idle without callbacks")
39+ }
40+ // A first callback goes out at once, then one per refresh.
41+ queue("DP-2")
42+ queue("HDMI-A-1")
43+ fire, _, idle := s.dueFrames(now, nil)
44+ if len(fire) != 2 || !idle {
45+ t.Fatalf("first fire %v idle %v", fire, idle)
46+ }
47+ fired(fire)
48+ queue("DP-2")
49+ queue("HDMI-A-1")
50+ fire, wait, _ := s.dueFrames(now.Add(time.Millisecond), nil)
51+ if len(fire) != 0 || wait != framePeriod(165000)-time.Millisecond {
52+ t.Fatalf("early fire %v wait %v", fire, wait)
53+ }
54+ at := now.Add(framePeriod(165000))
55+ fire, wait, _ = s.dueFrames(at, nil)
56+ if !slices.Equal(fire, []string{"DP-2"}) || wait != time.Second/60-framePeriod(165000) {
57+ t.Fatalf("165 Hz fire %v wait %v", fire, wait)
58+ }
59+ fired(fire)
60+ // A flip fires its output early and pushes its deadline to 1.5 periods.
61+ queue("DP-2")
62+ flip := at.Add(time.Millisecond)
63+ fire, _, _ = s.dueFrames(flip, map[string]bool{"DP-2": true})
64+ if !slices.Equal(fire, []string{"DP-2"}) || s.frameDue["DP-2"] != flip.Add(framePeriod(165000)*3/2) {
65+ t.Fatalf("flip fire %v due %v", fire, s.frameDue["DP-2"].Sub(flip))
66+ }
67+ fired(fire)
68+ // A queue on an unplugged output moves to the outputless one (60 Hz).
69+ s.outputs = []*output{fast}
70+ queue("HDMI-A-1")
71+ fire, _, _ = s.dueFrames(flip, nil)
72+ fired(fire)
73+ if _, ok := s.frameDue["HDMI-A-1"]; ok || len(s.awaiting["HDMI-A-1"]) != 0 || !slices.Equal(fire, []string{""}) {
74+ t.Fatalf("unplugged: fire %v due %v", fire, s.frameDue)
75+ }
76+}
@@ -0,0 +1,76 @@
1+package wayland
2+
3+import (
4+ "slices"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+ "github.com/bnema/purego-libwayland/protocol/wayland"
10+)
11+
12+func TestFramePeriod(t *testing.T) {
13+ for _, tc := range []struct {
14+ milli int
15+ want time.Duration
16+ }{{0, time.Second / 60}, {60000, time.Second / 60}, {165058, 6058476}, {144000, 6944444}} {
17+ if got := framePeriod(tc.milli); got != tc.want {
18+ t.Errorf("%d: %v want %v", tc.milli, got, tc.want)
19+ }
20+ }
21+}
22+
23+// Each output is paced at its own refresh; a flip fires its queue and
24+// pushes its deadline back; the pacer idles with nothing queued.
25+func TestDueFrames(t *testing.T) {
26+ fast := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 165000}}}
27+ slow := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "HDMI-A-1", RefreshMilli: 60000}}}
28+ s := &Server{outputs: []*output{fast, slow}, frameDue: map[string]time.Time{}, awaiting: map[string][]*wayland.Callback{}}
29+ // dueFrames only counts the queued callbacks: nil ones stand for them.
30+ queue := func(name string) { s.awaiting[name] = append(s.awaiting[name], nil) }
31+ fired := func(fire []string) {
32+ for _, name := range fire {
33+ delete(s.awaiting, name)
34+ }
35+ }
36+ now := time.Unix(100, 0)
37+ if _, _, idle := s.dueFrames(now, nil); !idle {
38+ t.Fatal("not idle without callbacks")
39+ }
40+ // A first callback goes out at once, then one per refresh.
41+ queue("DP-2")
42+ queue("HDMI-A-1")
43+ fire, _, idle := s.dueFrames(now, nil)
44+ if len(fire) != 2 || !idle {
45+ t.Fatalf("first fire %v idle %v", fire, idle)
46+ }
47+ fired(fire)
48+ queue("DP-2")
49+ queue("HDMI-A-1")
50+ fire, wait, _ := s.dueFrames(now.Add(time.Millisecond), nil)
51+ if len(fire) != 0 || wait != framePeriod(165000)-time.Millisecond {
52+ t.Fatalf("early fire %v wait %v", fire, wait)
53+ }
54+ at := now.Add(framePeriod(165000))
55+ fire, wait, _ = s.dueFrames(at, nil)
56+ if !slices.Equal(fire, []string{"DP-2"}) || wait != time.Second/60-framePeriod(165000) {
57+ t.Fatalf("165 Hz fire %v wait %v", fire, wait)
58+ }
59+ fired(fire)
60+ // A flip fires its output early and pushes its deadline to 1.5 periods.
61+ queue("DP-2")
62+ flip := at.Add(time.Millisecond)
63+ fire, _, _ = s.dueFrames(flip, map[string]bool{"DP-2": true})
64+ if !slices.Equal(fire, []string{"DP-2"}) || s.frameDue["DP-2"] != flip.Add(framePeriod(165000)*3/2) {
65+ t.Fatalf("flip fire %v due %v", fire, s.frameDue["DP-2"].Sub(flip))
66+ }
67+ fired(fire)
68+ // A queue on an unplugged output moves to the outputless one (60 Hz).
69+ s.outputs = []*output{fast}
70+ queue("HDMI-A-1")
71+ fire, _, _ = s.dueFrames(flip, nil)
72+ fired(fire)
73+ if _, ok := s.frameDue["HDMI-A-1"]; ok || len(s.awaiting["HDMI-A-1"]) != 0 || !slices.Equal(fire, []string{""}) {
74+ t.Fatalf("unplugged: fire %v due %v", fire, s.frameDue)
75+ }
76+}
M internal/adapters/wayland/server.go +16 -42
@@ -35,6 +35,9 @@ type Channels struct {
3535 Commands <-chan ports.ClientCommand
3636 // Cursors receives the cursor the client under the pointer asks for.
3737 Cursors chan<- ports.CursorChange
38+ // Presented paces frame callbacks on the outputs' page flips; outputs
39+ // that do not flip (idle, headless) are paced at their refresh rate.
40+ Presented <-chan ports.OutputPresented
3841 }
3942 type Server struct {
4043 display *server.Display
@@ -44,13 +47,17 @@ type Server struct {
4447 cleanup func()
4548 log zerowrap.Logger
4649 channels Channels
47- awaiting []*wayland.Callback
48- frames uint64
49- started time.Time
50- surfaces map[*server.Resource]*surface
51- buffers map[*server.Resource]clientBuffer
52- dmabuf *dmabufGlobal
53- serial uint32
50+ // awaiting holds frame callbacks by output name, due at its next
51+ // frame (frameDue, or its page flip).
52+ awaiting map[string][]*wayland.Callback
53+ frameDue map[string]time.Time
54+ frameReady chan struct{}
55+ frames uint64
56+ started time.Time
57+ surfaces map[*server.Resource]*surface
58+ buffers map[*server.Resource]clientBuffer
59+ dmabuf *dmabufGlobal
60+ serial uint32
5461 // press is the serial of the last button or key press, sent to
5562 // pressClient: popup grabs must come from it.
5663 press uint32
@@ -136,7 +143,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
136143 d.Close()
137144 return nil, err
138145 }
139- s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
146+ s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
140147 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
141148 if opts.Keymap != "" {
142149 fd, size, e := keymapFile(opts.Keymap)
@@ -207,40 +214,7 @@ func (s *Server) Run(ctx context.Context) error {
207214 }
208215 }
209216 }()
210- go func() {
211- defer wg.Done()
212- ticker := time.NewTicker(16 * time.Millisecond)
213- defer ticker.Stop()
214- for {
215- select {
216- case <-ctx.Done():
217- return
218- case <-s.display.Stopped():
219- return
220- case <-ticker.C:
221- if ctx.Err() != nil {
222- return
223- }
224- if !s.display.Do(func() {
225- if ctx.Err() != nil {
226- return
227- }
228- callbacks := s.awaiting
229- s.awaiting = nil
230- for _, cb := range callbacks {
231- if !cb.Resource.Alive() {
232- continue
233- }
234- cb.SendDone(uint32(time.Since(s.started).Milliseconds()))
235- cb.Destroy()
236- s.frames++
237- }
238- }) {
239- return
240- }
241- }
242- }
243- }()
217+ go func() { defer wg.Done(); s.pace(ctx) }()
244218 err := s.display.Run(ctx)
245219 wg.Wait()
246220 return err
@@ -35,6 +35,9 @@ type Channels struct {
35 Commands <-chan ports.ClientCommand35 Commands <-chan ports.ClientCommand
36 // Cursors receives the cursor the client under the pointer asks for.36 // Cursors receives the cursor the client under the pointer asks for.
37 Cursors chan<- ports.CursorChange37 Cursors chan<- ports.CursorChange
38+ // Presented paces frame callbacks on the outputs' page flips; outputs
39+ // that do not flip (idle, headless) are paced at their refresh rate.
40+ Presented <-chan ports.OutputPresented
38 }41 }
39 type Server struct {42 type Server struct {
40 display *server.Display43 display *server.Display
@@ -44,13 +47,17 @@ type Server struct {
44 cleanup func()47 cleanup func()
45 log zerowrap.Logger48 log zerowrap.Logger
46 channels Channels49 channels Channels
47- awaiting []*wayland.Callback50+ // awaiting holds frame callbacks by output name, due at its next
48- frames uint6451+ // frame (frameDue, or its page flip).
49- started time.Time52+ awaiting map[string][]*wayland.Callback
50- surfaces map[*server.Resource]*surface53+ frameDue map[string]time.Time
51- buffers map[*server.Resource]clientBuffer54+ frameReady chan struct{}
52- dmabuf *dmabufGlobal55+ frames uint64
53- serial uint3256+ started time.Time
57+ surfaces map[*server.Resource]*surface
58+ buffers map[*server.Resource]clientBuffer
59+ dmabuf *dmabufGlobal
60+ serial uint32
54 // press is the serial of the last button or key press, sent to61 // press is the serial of the last button or key press, sent to
55 // pressClient: popup grabs must come from it.62 // pressClient: popup grabs must come from it.
56 press uint3263 press uint32
@@ -136,7 +143,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
136 d.Close()143 d.Close()
137 return nil, err144 return nil, err
138 }145 }
139- s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}146+ s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
140 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}147 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
141 if opts.Keymap != "" {148 if opts.Keymap != "" {
142 fd, size, e := keymapFile(opts.Keymap)149 fd, size, e := keymapFile(opts.Keymap)
@@ -207,40 +214,7 @@ func (s *Server) Run(ctx context.Context) error {
207 }214 }
208 }215 }
209 }()216 }()
210- go func() {217+ go func() { defer wg.Done(); s.pace(ctx) }()
211- defer wg.Done()
212- ticker := time.NewTicker(16 * time.Millisecond)
213- defer ticker.Stop()
214- for {
215- select {
216- case <-ctx.Done():
217- return
218- case <-s.display.Stopped():
219- return
220- case <-ticker.C:
221- if ctx.Err() != nil {
222- return
223- }
224- if !s.display.Do(func() {
225- if ctx.Err() != nil {
226- return
227- }
228- callbacks := s.awaiting
229- s.awaiting = nil
230- for _, cb := range callbacks {
231- if !cb.Resource.Alive() {
232- continue
233- }
234- cb.SendDone(uint32(time.Since(s.started).Milliseconds()))
235- cb.Destroy()
236- s.frames++
237- }
238- }) {
239- return
240- }
241- }
242- }
243- }()
244 err := s.display.Run(ctx)218 err := s.display.Run(ctx)
245 wg.Wait()219 wg.Wait()
246 return err220 return err
M internal/adapters/wayland/surface.go +3 -1
@@ -197,7 +197,9 @@ func (s *surface) Commit(*wayland.Surface) {
197197 s.pending = nil
198198 s.attached = false
199199 }
200- s.server.awaiting = append(s.server.awaiting, s.callbacks...)
200+ if len(s.callbacks) > 0 {
201+ s.server.queueFrames(s.server.frameOutput(s), s.callbacks)
202+ }
201203 s.callbacks = nil
202204 if s.role != nil {
203205 s.role(s.current != nil)
@@ -197,7 +197,9 @@ func (s *surface) Commit(*wayland.Surface) {
197 s.pending = nil197 s.pending = nil
198 s.attached = false198 s.attached = false
199 }199 }
200- s.server.awaiting = append(s.server.awaiting, s.callbacks...)200+ if len(s.callbacks) > 0 {
201+ s.server.queueFrames(s.server.frameOutput(s), s.callbacks)
202+ }
201 s.callbacks = nil203 s.callbacks = nil
202 if s.role != nil {204 if s.role != nil {
203 s.role(s.current != nil)205 s.role(s.current != nil)
M internal/app/app.go +3 -2
@@ -83,6 +83,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
8383 renderScenes := make(chan []ports.Scene, 1)
8484 contents := make(chan ports.SurfaceContent, 64)
8585 cursorChanges := make(chan ports.CursorChange, 1)
86+ presented := make(chan ports.OutputPresented, 8)
8687 ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
8788 c, err := core.New(opts.Config, ch)
8889 if err != nil {
@@ -97,7 +98,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
9798 // Every output renders on the same GPU: its formats are the clients'.
9899 dmabuf := vulkan.Probe()
99100 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
100- server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges}, logging.For(ctx, "wayland"))
101+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges, Presented: presented}, logging.For(ctx, "wayland"))
101102 if err != nil {
102103 km.Close()
103104 return err
@@ -208,7 +209,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
208209 if hw != nil {
209210 done <- safe("output", func() error {
210211 want := func() drm.Want { return wantFromConfig(opts.Config.Outputs) }
211- return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, curs, newRenderer, logging.For(ctx, "drm"))
212+ return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, presented, curs, newRenderer, logging.For(ctx, "drm"))
212213 })
213214 return
214215 }
@@ -83,6 +83,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
83 renderScenes := make(chan []ports.Scene, 1)83 renderScenes := make(chan []ports.Scene, 1)
84 contents := make(chan ports.SurfaceContent, 64)84 contents := make(chan ports.SurfaceContent, 64)
85 cursorChanges := make(chan ports.CursorChange, 1)85 cursorChanges := make(chan ports.CursorChange, 1)
86+ presented := make(chan ports.OutputPresented, 8)
86 ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}87 ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
87 c, err := core.New(opts.Config, ch)88 c, err := core.New(opts.Config, ch)
88 if err != nil {89 if err != nil {
@@ -97,7 +98,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
97 // Every output renders on the same GPU: its formats are the clients'.98 // Every output renders on the same GPU: its formats are the clients'.
98 dmabuf := vulkan.Probe()99 dmabuf := vulkan.Probe()
99 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")100 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
100- server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges}, logging.For(ctx, "wayland"))101+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges, Presented: presented}, logging.For(ctx, "wayland"))
101 if err != nil {102 if err != nil {
102 km.Close()103 km.Close()
103 return err104 return err
@@ -208,7 +209,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
208 if hw != nil {209 if hw != nil {
209 done <- safe("output", func() error {210 done <- safe("output", func() error {
210 want := func() drm.Want { return wantFromConfig(opts.Config.Outputs) }211 want := func() drm.Want { return wantFromConfig(opts.Config.Outputs) }
211- return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, curs, newRenderer, logging.For(ctx, "drm"))212+ return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, presented, curs, newRenderer, logging.For(ctx, "drm"))
212 })213 })
213 return214 return
214 }215 }
M internal/app/drm.go +2 -2
@@ -108,7 +108,7 @@ func (b *drmBackend) close() {
108108 // one flip reader per card, and a udev watcher that rescans connectors on
109109 // hotplug. Core learns about outputs through events. It returns when ctx
110110 // ends, after every output is closed.
111-func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
111+func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
112112 ctx, cancel := context.WithCancel(ctx)
113113 defer cancel()
114114 var readers sync.WaitGroup
@@ -180,7 +180,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
180180 defer b.seat.Unsubscribe(active)
181181 return safe("output "+name, func() error {
182182 defer o.Close()
183- return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu)
183+ return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, presented)
184184 })
185185 })
186186 send(ports.OutputAdded{Info: o.Info()})
@@ -108,7 +108,7 @@ func (b *drmBackend) close() {
108 // one flip reader per card, and a udev watcher that rescans connectors on108 // one flip reader per card, and a udev watcher that rescans connectors on
109 // hotplug. Core learns about outputs through events. It returns when ctx109 // hotplug. Core learns about outputs through events. It returns when ctx
110 // ends, after every output is closed.110 // ends, after every output is closed.
111-func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {111+func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
112 ctx, cancel := context.WithCancel(ctx)112 ctx, cancel := context.WithCancel(ctx)
113 defer cancel()113 defer cancel()
114 var readers sync.WaitGroup114 var readers sync.WaitGroup
@@ -180,7 +180,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
180 defer b.seat.Unsubscribe(active)180 defer b.seat.Unsubscribe(active)
181 return safe("output "+name, func() error {181 return safe("output "+name, func() error {
182 defer o.Close()182 defer o.Close()
183- return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu)183+ return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, presented)
184 })184 })
185 })185 })
186 send(ports.OutputAdded{Info: o.Info()})186 send(ports.OutputAdded{Info: o.Info()})
M internal/ports/messages.go +4 -0
@@ -278,6 +278,10 @@ type OutputAdded struct{ Info OutputInfo }
278278
279279 func (OutputAdded) outputEvent() {}
280280
281+// OutputPresented carries output → wayland that a frame reached the screen
282+// (a page flip completed): frame callbacks of the surfaces on it are due.
283+type OutputPresented struct{ Output string }
284+
281285 // OutputRemoved carries output → core an unplugged display.
282286 type OutputRemoved struct{ Name string }
283287
@@ -278,6 +278,10 @@ type OutputAdded struct{ Info OutputInfo }
278 278
279 func (OutputAdded) outputEvent() {}279 func (OutputAdded) outputEvent() {}
280 280
281+// OutputPresented carries output → wayland that a frame reached the screen
282+// (a page flip completed): frame callbacks of the surfaces on it are due.
283+type OutputPresented struct{ Output string }
284+
281 // OutputRemoved carries output → core an unplugged display.285 // OutputRemoved carries output → core an unplugged display.
282 type OutputRemoved struct{ Name string }286 type OutputRemoved struct{ Name string }
283 287

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