detached 5a76c9e

merge: scanout (direct scanout of fullscreen client buffers)

commit
5a76c9e45dad6b380afe73253eee9b28a2d09ef0
author
brice · · 2026-09-25 17:00 UTC
merge of
1364461 + 3ed810f · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +627 -39
M internal/adapters/drm/card.go +9 -1
@@ -5,6 +5,8 @@ import (
55 "errors"
66 "fmt"
77 "slices"
8+ "sync"
9+ "unsafe"
810
911 "github.com/bnema/zerowrap"
1012 "golang.org/x/sys/unix"
@@ -23,6 +25,10 @@ type Card struct {
2325 // outputs by connector name; owned by the goroutine that calls Scan.
2426 outputs map[string]*Output
2527 flips map[uint32]chan int // by CRTC; set before the reader starts
28+ // gemMu serialises GEM handle import and close on fd (outputs run on
29+ // their own goroutines); modifiers is DRM_CAP_ADDFB2_MODIFIERS.
30+ gemMu sync.Mutex
31+ modifiers bool
2632 }
2733
2834 // OpenCard reads the card's CRTCs. fd stays owned by the caller.
@@ -35,7 +41,9 @@ func OpenCard(fd int, path string, want Want, log zerowrap.Logger) (*Card, error
3541 for _, c := range crtcs {
3642 flips[c] = make(chan int, 4)
3743 }
38- return &Card{fd: fd, path: path, want: want, log: log, crtcs: crtcs, outputs: map[string]*Output{}, flips: flips}, nil
44+ cp := getCap{capability: capAddFB2Modifiers}
45+ mods := ioctl(fd, ioctlGetCap, unsafe.Pointer(&cp)) == nil && cp.value == 1
46+ return &Card{fd: fd, path: path, want: want, log: log, crtcs: crtcs, outputs: map[string]*Output{}, flips: flips, modifiers: mods}, nil
3947 }
4048
4149 // Path is the device path, e.g. /dev/dri/card1.
@@ -5,6 +5,8 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "slices"7 "slices"
8+ "sync"
9+ "unsafe"
8 10
9 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
10 "golang.org/x/sys/unix"12 "golang.org/x/sys/unix"
@@ -23,6 +25,10 @@ type Card struct {
23 // outputs by connector name; owned by the goroutine that calls Scan.25 // outputs by connector name; owned by the goroutine that calls Scan.
24 outputs map[string]*Output26 outputs map[string]*Output
25 flips map[uint32]chan int // by CRTC; set before the reader starts27 flips map[uint32]chan int // by CRTC; set before the reader starts
28+ // gemMu serialises GEM handle import and close on fd (outputs run on
29+ // their own goroutines); modifiers is DRM_CAP_ADDFB2_MODIFIERS.
30+ gemMu sync.Mutex
31+ modifiers bool
26 }32 }
27 33
28 // OpenCard reads the card's CRTCs. fd stays owned by the caller.34 // OpenCard reads the card's CRTCs. fd stays owned by the caller.
@@ -35,7 +41,9 @@ func OpenCard(fd int, path string, want Want, log zerowrap.Logger) (*Card, error
35 for _, c := range crtcs {41 for _, c := range crtcs {
36 flips[c] = make(chan int, 4)42 flips[c] = make(chan int, 4)
37 }43 }
38- return &Card{fd: fd, path: path, want: want, log: log, crtcs: crtcs, outputs: map[string]*Output{}, flips: flips}, nil44+ cp := getCap{capability: capAddFB2Modifiers}
45+ mods := ioctl(fd, ioctlGetCap, unsafe.Pointer(&cp)) == nil && cp.value == 1
46+ return &Card{fd: fd, path: path, want: want, log: log, crtcs: crtcs, outputs: map[string]*Output{}, flips: flips, modifiers: mods}, nil
39 }47 }
40 48
41 // Path is the device path, e.g. /dev/dri/card1.49 // Path is the device path, e.g. /dev/dri/card1.
M internal/adapters/drm/kms.go +2 -0
@@ -119,6 +119,8 @@ type Want struct {
119119 Disabled map[string]bool
120120 // Modes maps a connector to W, H and Hz (0: highest refresh at W x H).
121121 Modes map[string][3]float64
122+ // NoScanout disables direct scanout (render.direct-scanout = off).
123+ NoScanout bool
122124 }
123125
124126 // usable reports whether a connector should be driven.
@@ -119,6 +119,8 @@ type Want struct {
119 Disabled map[string]bool119 Disabled map[string]bool
120 // Modes maps a connector to W, H and Hz (0: highest refresh at W x H).120 // Modes maps a connector to W, H and Hz (0: highest refresh at W x H).
121 Modes map[string][3]float64121 Modes map[string][3]float64
122+ // NoScanout disables direct scanout (render.direct-scanout = off).
123+ NoScanout bool
122 }124 }
123 125
124 // usable reports whether a connector should be driven.126 // usable reports whether a connector should be driven.
M internal/adapters/drm/kms_test.go +3 -0
@@ -20,6 +20,9 @@ func TestStructSizesMatchIoctls(t *testing.T) {
2020 {"page_flip", unsafe.Sizeof(pageFlip{}), ioctlPageFlip},
2121 {"create_dumb", unsafe.Sizeof(createDumb{}), ioctlCreateDumb},
2222 {"map_dumb", unsafe.Sizeof(mapDumb{}), ioctlMapDumb},
23+ {"fb_cmd2", unsafe.Sizeof(fbCmd2{}), ioctlAddFB2},
24+ {"prime_handle", unsafe.Sizeof(primeHandle{}), ioctlPrimeFDToHandle},
25+ {"gem_close", unsafe.Sizeof(gemClose{}), ioctlGemClose},
2326 } {
2427 if want := (tc.req >> 16) & 0x3fff; tc.got != want {
2528 t.Errorf("%s: size %d, ioctl encodes %d", tc.name, tc.got, want)
@@ -20,6 +20,9 @@ func TestStructSizesMatchIoctls(t *testing.T) {
20 {"page_flip", unsafe.Sizeof(pageFlip{}), ioctlPageFlip},20 {"page_flip", unsafe.Sizeof(pageFlip{}), ioctlPageFlip},
21 {"create_dumb", unsafe.Sizeof(createDumb{}), ioctlCreateDumb},21 {"create_dumb", unsafe.Sizeof(createDumb{}), ioctlCreateDumb},
22 {"map_dumb", unsafe.Sizeof(mapDumb{}), ioctlMapDumb},22 {"map_dumb", unsafe.Sizeof(mapDumb{}), ioctlMapDumb},
23+ {"fb_cmd2", unsafe.Sizeof(fbCmd2{}), ioctlAddFB2},
24+ {"prime_handle", unsafe.Sizeof(primeHandle{}), ioctlPrimeFDToHandle},
25+ {"gem_close", unsafe.Sizeof(gemClose{}), ioctlGemClose},
23 } {26 } {
24 if want := (tc.req >> 16) & 0x3fff; tc.got != want {27 if want := (tc.req >> 16) & 0x3fff; tc.got != want {
25 t.Errorf("%s: size %d, ioctl encodes %d", tc.name, tc.got, want)28 t.Errorf("%s: size %d, ioctl encodes %d", tc.name, tc.got, want)
M internal/adapters/drm/output.go +110 -8
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+ "maps"
78 "slices"
89 "time"
910
@@ -29,6 +30,14 @@ type Output struct {
2930 monitor Monitor
3031 cursor *Cursor
3132 log zerowrap.Logger
33+ // Direct scanout: client framebuffers by DMABuf ID, GEM handle counts,
34+ // and the buffer on screen and queued (0: the composed image).
35+ card *Card
36+ scanout bool
37+ clientFBs map[uint64]*clientFB
38+ shown, queued uint64
39+ reason string // why the last frame was composed ("" = scanout)
40+ unsent ports.OutputPresented
3241 }
3342
3443 // CursorLoader returns the image of a cursor at an output scale, at most
@@ -38,7 +47,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.Cu
3847 // newOutput allocates buffers and the cursor for a connector on crtc.
3948 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
4049 log := card.log
41- o := &Output{fd: card.fd, flipped: card.flips[crtc], crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name)}
50+ o := &Output{fd: card.fd, flipped: card.flips[crtc], crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), card: card, scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start"}
4251 var err error
4352 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
4453 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
@@ -76,6 +85,7 @@ func (o *Output) Height() int { return int(o.mode.VDisplay) }
7685 // modeset shows the front buffer; needed at start and after every VT resume.
7786 func (o *Output) modeset() error {
7887 o.pending = false
88+ o.shown, o.queued = 0, 0
7989 front := o.bufs[1-o.back]
8090 if err := setCrtc(o.fd, o.crtc, o.conn.id, front.fbID, &o.mode); err != nil {
8191 return fmt.Errorf("set crtc: %w", err)
@@ -92,10 +102,50 @@ func (o *Output) present(r ports.Renderer) error {
92102 return err
93103 }
94104 o.pending, o.flipStart = true, time.Now()
105+ o.queued = 0
95106 o.back = 1 - o.back
96107 return nil
97108 }
98109
110+// tryScanout flips a fullscreen client buffer directly. It reports false,
111+// with the reason logged on change, when the frame must be composed.
112+func (o *Output) tryScanout(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (bool, error) {
113+ reason := "disabled"
114+ var c ports.SurfaceContent
115+ if o.scanout {
116+ c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())
117+ }
118+ var fb uint32
119+ if reason == "" {
120+ fb, reason = o.scanoutFB(c.DMABuf, time.Now())
121+ }
122+ if reason != o.reason {
123+ o.log.Info().Bool("direct_scanout", reason == "").Str("reason", reason).Str("connector", o.conn.name).Msg("scanout")
124+ o.reason = reason
125+ }
126+ if reason != "" {
127+ return false, nil
128+ }
129+ if o.shown == c.DMABuf.ID && o.queued == 0 {
130+ // Already on screen: nothing to flip.
131+ return true, nil
132+ }
133+ if err := flip(o.fd, o.crtc, fb); err != nil {
134+ if errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE) {
135+ // KMS refuses this buffer on the plane: compose it instead.
136+ o.log.Info().Err(err).Uint32("format", c.DMABuf.Format).Uint64("modifier", c.DMABuf.Modifier).Msg("scanout flip refused")
137+ o.clientFBs[c.DMABuf.ID].failed = "flip_refused"
138+ o.reason = "flip_refused"
139+ o.log.Info().Bool("direct_scanout", false).Str("reason", o.reason).Str("connector", o.conn.name).Msg("scanout")
140+ return false, nil
141+ }
142+ return true, err
143+ }
144+ o.pending, o.flipStart = true, time.Now()
145+ o.queued = c.DMABuf.ID
146+ return true, nil
147+}
148+
99149 // Close restores the CRTC state found at startup, when it had a mode, and
100150 // frees buffers.
101151 func (o *Output) Close() {
@@ -114,6 +164,8 @@ func (o *Output) Close() {
114164 b.destroy(o.fd)
115165 }
116166 }
167+ o.shown, o.queued = 0, 0
168+ o.dropClientFBs(time.Now(), true)
117169 }
118170
119171 // Run renders scenes and flips until ctx ends. active reports seat enable/disable.
@@ -144,8 +196,26 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
144196 frame := 0
145197 stats := time.NewTicker(10 * time.Second)
146198 defer stats.Stop()
199+ // seen is the latest content Seq per window, reported to wayland with
200+ // what the output scans out, after each frame decision.
201+ seen := map[ports.WindowID]uint64{}
202+ reportDirty := false
203+ flipped := false
147204 for {
205+ if flipped || reportDirty {
206+ o.report(presented, flipped, seen)
207+ flipped, reportDirty = false, false
208+ } else {
209+ o.flushReport(presented)
210+ }
211+ // An unsent report is retried soon, not only on the next event.
212+ var retry <-chan time.Time
213+ if o.unsent.Output != "" {
214+ retry = time.After(time.Millisecond)
215+ }
148216 select {
217+ case <-retry:
218+ continue
149219 case <-ctx.Done():
150220 return nil
151221 case on := <-active:
@@ -170,10 +240,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
170240 }
171241 o.pending = false
172242 o.flips += n
173- select {
174- case presented <- ports.OutputPresented{Output: o.conn.name}:
175- default:
176- }
243+ o.shown = o.queued
244+ o.queued = 0
245+ flipped = true
177246 if d := time.Since(o.flipStart); d > 20*time.Millisecond {
178247 o.log.Info().Dur("flip_ms", d).Msg("slow flip")
179248 }
@@ -196,9 +265,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
196265 } else {
197266 surfaces[c.ID] = c
198267 }
268+ if c.Seq > seen[c.ID] {
269+ seen[c.ID] = c.Seq
270+ reportDirty = true
271+ }
199272 // Windows on other outputs or workspaces do not need a frame.
200273 dirty = dirty || scene.Shows(c.ID)
201274 case <-stats.C:
275+ o.dropClientFBs(time.Now(), false)
202276 ev := o.log.Info().Int("frames", frame).Int("flips", o.flips)
203277 if o.cursor != nil {
204278 cs := o.cursor.TakeStats()
@@ -210,11 +284,15 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
210284 continue
211285 }
212286 start := time.Now()
213- if err := r.Render(scene, surfaces); err != nil {
214- return fmt.Errorf("render frame: %w", err)
287+ direct, err := o.tryScanout(scene, surfaces)
288+ if !direct {
289+ if err := r.Render(scene, surfaces); err != nil {
290+ return fmt.Errorf("render frame: %w", err)
291+ }
292+ err = o.present(r)
215293 }
216294 copyStart := time.Now()
217- if err := o.present(r); err != nil {
295+ if err != nil {
218296 if errors.Is(err, unix.EBUSY) {
219297 // A flip is still in flight; retry after its event.
220298 o.pending, o.flipStart = true, time.Now()
@@ -230,6 +308,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
230308 return fmt.Errorf("page flip: %w", err)
231309 }
232310 dirty = false
311+ reportDirty = true
233312 frame++
234313 o.log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("render", time.Since(start)).Dur("copy", time.Since(copyStart)).Msg("frame")
235314 }
@@ -247,3 +326,26 @@ func (o *Output) setCursor(load CursorLoader, c ports.CursorChange, scale float6
247326 }
248327 o.log.Debug().Float64("scale", scale).Str("shape", c.Shape).Bool("client", c.Image != nil).Int("w", img.W).Int("h", img.H).Msg("cursor image")
249328 }
329+
330+// report tells wayland what the output shows and has read, so replaced
331+// client buffers can be released. It must follow the frame decision: a
332+// buffer is safe once a later content is seen and it is neither shown nor
333+// queued. A report the channel cannot take waits in unsent, replaced by
334+// newer ones (Flip kept), and is retried on the next loop.
335+func (o *Output) report(presented chan<- ports.OutputPresented, flip bool, seen map[ports.WindowID]uint64) {
336+ r := ports.OutputPresented{Output: o.conn.name, Flip: flip || o.unsent.Flip, Shown: o.shown, Queued: o.queued, Seen: maps.Clone(seen)}
337+ o.unsent = r
338+ o.flushReport(presented)
339+}
340+
341+// flushReport sends the unsent report, if any, without blocking.
342+func (o *Output) flushReport(presented chan<- ports.OutputPresented) {
343+ if o.unsent.Output == "" {
344+ return
345+ }
346+ select {
347+ case presented <- o.unsent:
348+ o.unsent = ports.OutputPresented{}
349+ default:
350+ }
351+}
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7+ "maps"
7 "slices"8 "slices"
8 "time"9 "time"
9 10
@@ -29,6 +30,14 @@ type Output struct {
29 monitor Monitor30 monitor Monitor
30 cursor *Cursor31 cursor *Cursor
31 log zerowrap.Logger32 log zerowrap.Logger
33+ // Direct scanout: client framebuffers by DMABuf ID, GEM handle counts,
34+ // and the buffer on screen and queued (0: the composed image).
35+ card *Card
36+ scanout bool
37+ clientFBs map[uint64]*clientFB
38+ shown, queued uint64
39+ reason string // why the last frame was composed ("" = scanout)
40+ unsent ports.OutputPresented
32 }41 }
33 42
34 // CursorLoader returns the image of a cursor at an output scale, at most43 // CursorLoader returns the image of a cursor at an output scale, at most
@@ -38,7 +47,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.Cu
38 // newOutput allocates buffers and the cursor for a connector on crtc.47 // newOutput allocates buffers and the cursor for a connector on crtc.
39 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {48 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
40 log := card.log49 log := card.log
41- o := &Output{fd: card.fd, flipped: card.flips[crtc], crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name)}50+ o := &Output{fd: card.fd, flipped: card.flips[crtc], crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), card: card, scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start"}
42 var err error51 var err error
43 if o.saved, err = getCrtc(card.fd, crtc); err != nil {52 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
44 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")53 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
@@ -76,6 +85,7 @@ func (o *Output) Height() int { return int(o.mode.VDisplay) }
76 // modeset shows the front buffer; needed at start and after every VT resume.85 // modeset shows the front buffer; needed at start and after every VT resume.
77 func (o *Output) modeset() error {86 func (o *Output) modeset() error {
78 o.pending = false87 o.pending = false
88+ o.shown, o.queued = 0, 0
79 front := o.bufs[1-o.back]89 front := o.bufs[1-o.back]
80 if err := setCrtc(o.fd, o.crtc, o.conn.id, front.fbID, &o.mode); err != nil {90 if err := setCrtc(o.fd, o.crtc, o.conn.id, front.fbID, &o.mode); err != nil {
81 return fmt.Errorf("set crtc: %w", err)91 return fmt.Errorf("set crtc: %w", err)
@@ -92,10 +102,50 @@ func (o *Output) present(r ports.Renderer) error {
92 return err102 return err
93 }103 }
94 o.pending, o.flipStart = true, time.Now()104 o.pending, o.flipStart = true, time.Now()
105+ o.queued = 0
95 o.back = 1 - o.back106 o.back = 1 - o.back
96 return nil107 return nil
97 }108 }
98 109
110+// tryScanout flips a fullscreen client buffer directly. It reports false,
111+// with the reason logged on change, when the frame must be composed.
112+func (o *Output) tryScanout(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (bool, error) {
113+ reason := "disabled"
114+ var c ports.SurfaceContent
115+ if o.scanout {
116+ c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())
117+ }
118+ var fb uint32
119+ if reason == "" {
120+ fb, reason = o.scanoutFB(c.DMABuf, time.Now())
121+ }
122+ if reason != o.reason {
123+ o.log.Info().Bool("direct_scanout", reason == "").Str("reason", reason).Str("connector", o.conn.name).Msg("scanout")
124+ o.reason = reason
125+ }
126+ if reason != "" {
127+ return false, nil
128+ }
129+ if o.shown == c.DMABuf.ID && o.queued == 0 {
130+ // Already on screen: nothing to flip.
131+ return true, nil
132+ }
133+ if err := flip(o.fd, o.crtc, fb); err != nil {
134+ if errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE) {
135+ // KMS refuses this buffer on the plane: compose it instead.
136+ o.log.Info().Err(err).Uint32("format", c.DMABuf.Format).Uint64("modifier", c.DMABuf.Modifier).Msg("scanout flip refused")
137+ o.clientFBs[c.DMABuf.ID].failed = "flip_refused"
138+ o.reason = "flip_refused"
139+ o.log.Info().Bool("direct_scanout", false).Str("reason", o.reason).Str("connector", o.conn.name).Msg("scanout")
140+ return false, nil
141+ }
142+ return true, err
143+ }
144+ o.pending, o.flipStart = true, time.Now()
145+ o.queued = c.DMABuf.ID
146+ return true, nil
147+}
148+
99 // Close restores the CRTC state found at startup, when it had a mode, and149 // Close restores the CRTC state found at startup, when it had a mode, and
100 // frees buffers.150 // frees buffers.
101 func (o *Output) Close() {151 func (o *Output) Close() {
@@ -114,6 +164,8 @@ func (o *Output) Close() {
114 b.destroy(o.fd)164 b.destroy(o.fd)
115 }165 }
116 }166 }
167+ o.shown, o.queued = 0, 0
168+ o.dropClientFBs(time.Now(), true)
117 }169 }
118 170
119 // Run renders scenes and flips until ctx ends. active reports seat enable/disable.171 // Run renders scenes and flips until ctx ends. active reports seat enable/disable.
@@ -144,8 +196,26 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
144 frame := 0196 frame := 0
145 stats := time.NewTicker(10 * time.Second)197 stats := time.NewTicker(10 * time.Second)
146 defer stats.Stop()198 defer stats.Stop()
199+ // seen is the latest content Seq per window, reported to wayland with
200+ // what the output scans out, after each frame decision.
201+ seen := map[ports.WindowID]uint64{}
202+ reportDirty := false
203+ flipped := false
147 for {204 for {
205+ if flipped || reportDirty {
206+ o.report(presented, flipped, seen)
207+ flipped, reportDirty = false, false
208+ } else {
209+ o.flushReport(presented)
210+ }
211+ // An unsent report is retried soon, not only on the next event.
212+ var retry <-chan time.Time
213+ if o.unsent.Output != "" {
214+ retry = time.After(time.Millisecond)
215+ }
148 select {216 select {
217+ case <-retry:
218+ continue
149 case <-ctx.Done():219 case <-ctx.Done():
150 return nil220 return nil
151 case on := <-active:221 case on := <-active:
@@ -170,10 +240,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
170 }240 }
171 o.pending = false241 o.pending = false
172 o.flips += n242 o.flips += n
173- select {243+ o.shown = o.queued
174- case presented <- ports.OutputPresented{Output: o.conn.name}:244+ o.queued = 0
175- default:245+ flipped = true
176- }
177 if d := time.Since(o.flipStart); d > 20*time.Millisecond {246 if d := time.Since(o.flipStart); d > 20*time.Millisecond {
178 o.log.Info().Dur("flip_ms", d).Msg("slow flip")247 o.log.Info().Dur("flip_ms", d).Msg("slow flip")
179 }248 }
@@ -196,9 +265,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
196 } else {265 } else {
197 surfaces[c.ID] = c266 surfaces[c.ID] = c
198 }267 }
268+ if c.Seq > seen[c.ID] {
269+ seen[c.ID] = c.Seq
270+ reportDirty = true
271+ }
199 // Windows on other outputs or workspaces do not need a frame.272 // Windows on other outputs or workspaces do not need a frame.
200 dirty = dirty || scene.Shows(c.ID)273 dirty = dirty || scene.Shows(c.ID)
201 case <-stats.C:274 case <-stats.C:
275+ o.dropClientFBs(time.Now(), false)
202 ev := o.log.Info().Int("frames", frame).Int("flips", o.flips)276 ev := o.log.Info().Int("frames", frame).Int("flips", o.flips)
203 if o.cursor != nil {277 if o.cursor != nil {
204 cs := o.cursor.TakeStats()278 cs := o.cursor.TakeStats()
@@ -210,11 +284,15 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
210 continue284 continue
211 }285 }
212 start := time.Now()286 start := time.Now()
213- if err := r.Render(scene, surfaces); err != nil {287+ direct, err := o.tryScanout(scene, surfaces)
214- return fmt.Errorf("render frame: %w", err)288+ if !direct {
289+ if err := r.Render(scene, surfaces); err != nil {
290+ return fmt.Errorf("render frame: %w", err)
291+ }
292+ err = o.present(r)
215 }293 }
216 copyStart := time.Now()294 copyStart := time.Now()
217- if err := o.present(r); err != nil {295+ if err != nil {
218 if errors.Is(err, unix.EBUSY) {296 if errors.Is(err, unix.EBUSY) {
219 // A flip is still in flight; retry after its event.297 // A flip is still in flight; retry after its event.
220 o.pending, o.flipStart = true, time.Now()298 o.pending, o.flipStart = true, time.Now()
@@ -230,6 +308,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
230 return fmt.Errorf("page flip: %w", err)308 return fmt.Errorf("page flip: %w", err)
231 }309 }
232 dirty = false310 dirty = false
311+ reportDirty = true
233 frame++312 frame++
234 o.log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("render", time.Since(start)).Dur("copy", time.Since(copyStart)).Msg("frame")313 o.log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("render", time.Since(start)).Dur("copy", time.Since(copyStart)).Msg("frame")
235 }314 }
@@ -247,3 +326,26 @@ func (o *Output) setCursor(load CursorLoader, c ports.CursorChange, scale float6
247 }326 }
248 o.log.Debug().Float64("scale", scale).Str("shape", c.Shape).Bool("client", c.Image != nil).Int("w", img.W).Int("h", img.H).Msg("cursor image")327 o.log.Debug().Float64("scale", scale).Str("shape", c.Shape).Bool("client", c.Image != nil).Int("w", img.W).Int("h", img.H).Msg("cursor image")
249 }328 }
329+
330+// report tells wayland what the output shows and has read, so replaced
331+// client buffers can be released. It must follow the frame decision: a
332+// buffer is safe once a later content is seen and it is neither shown nor
333+// queued. A report the channel cannot take waits in unsent, replaced by
334+// newer ones (Flip kept), and is retried on the next loop.
335+func (o *Output) report(presented chan<- ports.OutputPresented, flip bool, seen map[ports.WindowID]uint64) {
336+ r := ports.OutputPresented{Output: o.conn.name, Flip: flip || o.unsent.Flip, Shown: o.shown, Queued: o.queued, Seen: maps.Clone(seen)}
337+ o.unsent = r
338+ o.flushReport(presented)
339+}
340+
341+// flushReport sends the unsent report, if any, without blocking.
342+func (o *Output) flushReport(presented chan<- ports.OutputPresented) {
343+ if o.unsent.Output == "" {
344+ return
345+ }
346+ select {
347+ case presented <- o.unsent:
348+ o.unsent = ports.OutputPresented{}
349+ default:
350+ }
351+}
A internal/adapters/drm/scanout.go +189 -0
@@ -0,0 +1,189 @@
1+package drm
2+
3+import (
4+ "errors"
5+ "time"
6+ "unsafe"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+)
10+
11+// Direct scanout (ADR 006): when one fullscreen window covers the output
12+// with a GPU buffer of the output's size, the output flips to that buffer
13+// instead of compositing it. The buffer becomes a KMS framebuffer once
14+// (PRIME import + ADDFB2) and is cached by DMABuf ID while the client
15+// keeps using it. KMS waits on the buffer's implicit fences before showing
16+// it. Anything else on screen, a scale, or a buffer KMS refuses falls back
17+// to composition.
18+
19+const (
20+ ioctlGemClose = 0x40086409
21+ ioctlPrimeFDToHandle = 0xC00C642E
22+ ioctlAddFB2 = 0xC06864B8
23+
24+ fbModifiers = 1 << 1
25+ capAddFB2Modifiers = 0x10
26+ modInvalid = 0x00ffffffffffffff
27+ scanoutIdleTTL = 5 * time.Second // a cached framebuffer survives unused
28+)
29+
30+type primeHandle struct {
31+ handle, flags uint32
32+ fd int32
33+}
34+
35+type gemClose struct{ handle, pad uint32 }
36+
37+type fbCmd2 struct {
38+ fbID, width, height, format, flags uint32
39+ handles, pitches, offsets [4]uint32
40+ modifiers [4]uint64
41+}
42+
43+// scanoutCandidate returns the window content the output can scan out
44+// directly, or a reason why it cannot. w, h are the output's physical size.
45+func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int) (ports.SurfaceContent, string) {
46+ var full *ports.SceneWindow
47+ for i := range s.Windows {
48+ win := &s.Windows[i]
49+ if win.Hidden || win.Rect.W <= 0 || win.Rect.H <= 0 {
50+ continue
51+ }
52+ covers := win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight
53+ if !win.Fullscreen || !covers || full != nil {
54+ return ports.SurfaceContent{}, "other_windows"
55+ }
56+ full = win
57+ }
58+ if full == nil {
59+ return ports.SurfaceContent{}, "no_fullscreen"
60+ }
61+ for _, l := range s.Layers {
62+ if l.Layer == ports.LayerOverlay && l.Rect.W > 0 && l.Rect.H > 0 {
63+ return ports.SurfaceContent{}, "overlay_surface"
64+ }
65+ }
66+ c := surfaces[full.ID]
67+ switch {
68+ case c.DMABuf == nil:
69+ return c, "not_dmabuf"
70+ case len(c.Children) > 0:
71+ return c, "subsurfaces"
72+ case len(c.DMABuf.Planes) != 1:
73+ return c, "multi_plane"
74+ case c.Width != w || c.Height != h:
75+ return c, "size_mismatch"
76+ case c.LogicalW != full.Rect.W || c.LogicalH != full.Rect.H:
77+ return c, "logical_mismatch"
78+ case !scanoutable(c.DMABuf.Format):
79+ return c, "format"
80+ case c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0 || c.Geometry.W != c.LogicalW || c.Geometry.H != c.LogicalH):
81+ return c, "geometry_crop"
82+ }
83+ return c, ""
84+}
85+
86+// clientFB is a client buffer imported as a KMS framebuffer.
87+type clientFB struct {
88+ fbID uint32
89+ last time.Time // when last wanted on screen
90+ // failed is the reason KMS refused the buffer; it is not retried.
91+ failed string
92+}
93+
94+// scanoutFB returns the framebuffer of a client buffer, importing it on
95+// first use. A buffer KMS refuses is remembered with its reason.
96+func (o *Output) scanoutFB(b *ports.DMABuf, now time.Time) (uint32, string) {
97+ if fb := o.clientFBs[b.ID]; fb != nil {
98+ fb.last = now
99+ return fb.fbID, fb.failed
100+ }
101+ fb := &clientFB{last: now}
102+ o.clientFBs[b.ID] = fb
103+ id, err := o.card.addClientFB(b)
104+ if err != nil {
105+ o.log.Info().Err(err).Uint32("format", b.Format).Uint64("modifier", b.Modifier).Msg("scanout import failed")
106+ fb.failed = "import_failed"
107+ return 0, fb.failed
108+ }
109+ fb.fbID = id
110+ return id, ""
111+}
112+
113+// scanoutFormat is the framebuffer format for a client format. The primary
114+// plane shows no alpha, and a legacy page flip keeps the format of the
115+// composed image (XRGB8888): alpha formats are scanned out as their X
116+// variant, others are refused.
117+func scanoutFormat(f uint32) (uint32, bool) {
118+ switch f {
119+ case fourccXRGB, fourccARGB:
120+ return fourccXRGB, true
121+ }
122+ return 0, false
123+}
124+
125+const (
126+ fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24
127+ fourccARGB = 'A' | 'R'<<8 | '2'<<16 | '4'<<24
128+)
129+
130+// addClientFB imports a dmabuf as a framebuffer. The GEM handle belongs to
131+// the card fd and is shared by every import of the same buffer, so import,
132+// ADDFB2 and close run under the card lock and the handle is closed right
133+// away: the framebuffer keeps its own reference to the buffer.
134+func (c *Card) addClientFB(b *ports.DMABuf) (uint32, error) {
135+ format, ok := scanoutFormat(b.Format)
136+ if !ok {
137+ return 0, errors.New("format not scanned out")
138+ }
139+ raw, err := b.Planes[0].File.SyscallConn()
140+ if err != nil {
141+ return 0, err
142+ }
143+ c.gemMu.Lock()
144+ defer c.gemMu.Unlock()
145+ var p primeHandle
146+ var ioErr error
147+ if err := raw.Control(func(fd uintptr) {
148+ p.fd = int32(fd)
149+ ioErr = ioctl(c.fd, ioctlPrimeFDToHandle, unsafe.Pointer(&p))
150+ }); err != nil {
151+ return 0, err
152+ }
153+ if ioErr != nil {
154+ return 0, errors.Join(errors.New("prime import"), ioErr)
155+ }
156+ defer func() {
157+ g := gemClose{handle: p.handle}
158+ _ = ioctl(c.fd, ioctlGemClose, unsafe.Pointer(&g))
159+ }()
160+ cmd := fbCmd2{width: uint32(b.Width), height: uint32(b.Height), format: format}
161+ cmd.handles[0], cmd.pitches[0], cmd.offsets[0] = p.handle, b.Planes[0].Stride, b.Planes[0].Offset
162+ if b.Modifier != modInvalid && (b.Modifier != 0 || c.modifiers) {
163+ cmd.flags, cmd.modifiers[0] = fbModifiers, b.Modifier
164+ }
165+ if err := ioctl(c.fd, ioctlAddFB2, unsafe.Pointer(&cmd)); err != nil {
166+ return 0, errors.Join(errors.New("add fb2"), err)
167+ }
168+ return cmd.fbID, nil
169+}
170+
171+// dropClientFBs frees framebuffers not wanted for scanoutIdleTTL, or all
172+// of them, except those on screen or queued.
173+func (o *Output) dropClientFBs(now time.Time, all bool) {
174+ for id, fb := range o.clientFBs {
175+ if id == o.shown || id == o.queued {
176+ continue
177+ }
178+ if !all && now.Sub(fb.last) < scanoutIdleTTL {
179+ continue
180+ }
181+ if fb.fbID != 0 {
182+ v := fb.fbID
183+ _ = ioctl(o.fd, ioctlRmFB, unsafe.Pointer(&v))
184+ }
185+ delete(o.clientFBs, id)
186+ }
187+}
188+
189+func scanoutable(f uint32) bool { _, ok := scanoutFormat(f); return ok }
@@ -0,0 +1,189 @@
1+package drm
2+
3+import (
4+ "errors"
5+ "time"
6+ "unsafe"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+)
10+
11+// Direct scanout (ADR 006): when one fullscreen window covers the output
12+// with a GPU buffer of the output's size, the output flips to that buffer
13+// instead of compositing it. The buffer becomes a KMS framebuffer once
14+// (PRIME import + ADDFB2) and is cached by DMABuf ID while the client
15+// keeps using it. KMS waits on the buffer's implicit fences before showing
16+// it. Anything else on screen, a scale, or a buffer KMS refuses falls back
17+// to composition.
18+
19+const (
20+ ioctlGemClose = 0x40086409
21+ ioctlPrimeFDToHandle = 0xC00C642E
22+ ioctlAddFB2 = 0xC06864B8
23+
24+ fbModifiers = 1 << 1
25+ capAddFB2Modifiers = 0x10
26+ modInvalid = 0x00ffffffffffffff
27+ scanoutIdleTTL = 5 * time.Second // a cached framebuffer survives unused
28+)
29+
30+type primeHandle struct {
31+ handle, flags uint32
32+ fd int32
33+}
34+
35+type gemClose struct{ handle, pad uint32 }
36+
37+type fbCmd2 struct {
38+ fbID, width, height, format, flags uint32
39+ handles, pitches, offsets [4]uint32
40+ modifiers [4]uint64
41+}
42+
43+// scanoutCandidate returns the window content the output can scan out
44+// directly, or a reason why it cannot. w, h are the output's physical size.
45+func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int) (ports.SurfaceContent, string) {
46+ var full *ports.SceneWindow
47+ for i := range s.Windows {
48+ win := &s.Windows[i]
49+ if win.Hidden || win.Rect.W <= 0 || win.Rect.H <= 0 {
50+ continue
51+ }
52+ covers := win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight
53+ if !win.Fullscreen || !covers || full != nil {
54+ return ports.SurfaceContent{}, "other_windows"
55+ }
56+ full = win
57+ }
58+ if full == nil {
59+ return ports.SurfaceContent{}, "no_fullscreen"
60+ }
61+ for _, l := range s.Layers {
62+ if l.Layer == ports.LayerOverlay && l.Rect.W > 0 && l.Rect.H > 0 {
63+ return ports.SurfaceContent{}, "overlay_surface"
64+ }
65+ }
66+ c := surfaces[full.ID]
67+ switch {
68+ case c.DMABuf == nil:
69+ return c, "not_dmabuf"
70+ case len(c.Children) > 0:
71+ return c, "subsurfaces"
72+ case len(c.DMABuf.Planes) != 1:
73+ return c, "multi_plane"
74+ case c.Width != w || c.Height != h:
75+ return c, "size_mismatch"
76+ case c.LogicalW != full.Rect.W || c.LogicalH != full.Rect.H:
77+ return c, "logical_mismatch"
78+ case !scanoutable(c.DMABuf.Format):
79+ return c, "format"
80+ case c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0 || c.Geometry.W != c.LogicalW || c.Geometry.H != c.LogicalH):
81+ return c, "geometry_crop"
82+ }
83+ return c, ""
84+}
85+
86+// clientFB is a client buffer imported as a KMS framebuffer.
87+type clientFB struct {
88+ fbID uint32
89+ last time.Time // when last wanted on screen
90+ // failed is the reason KMS refused the buffer; it is not retried.
91+ failed string
92+}
93+
94+// scanoutFB returns the framebuffer of a client buffer, importing it on
95+// first use. A buffer KMS refuses is remembered with its reason.
96+func (o *Output) scanoutFB(b *ports.DMABuf, now time.Time) (uint32, string) {
97+ if fb := o.clientFBs[b.ID]; fb != nil {
98+ fb.last = now
99+ return fb.fbID, fb.failed
100+ }
101+ fb := &clientFB{last: now}
102+ o.clientFBs[b.ID] = fb
103+ id, err := o.card.addClientFB(b)
104+ if err != nil {
105+ o.log.Info().Err(err).Uint32("format", b.Format).Uint64("modifier", b.Modifier).Msg("scanout import failed")
106+ fb.failed = "import_failed"
107+ return 0, fb.failed
108+ }
109+ fb.fbID = id
110+ return id, ""
111+}
112+
113+// scanoutFormat is the framebuffer format for a client format. The primary
114+// plane shows no alpha, and a legacy page flip keeps the format of the
115+// composed image (XRGB8888): alpha formats are scanned out as their X
116+// variant, others are refused.
117+func scanoutFormat(f uint32) (uint32, bool) {
118+ switch f {
119+ case fourccXRGB, fourccARGB:
120+ return fourccXRGB, true
121+ }
122+ return 0, false
123+}
124+
125+const (
126+ fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24
127+ fourccARGB = 'A' | 'R'<<8 | '2'<<16 | '4'<<24
128+)
129+
130+// addClientFB imports a dmabuf as a framebuffer. The GEM handle belongs to
131+// the card fd and is shared by every import of the same buffer, so import,
132+// ADDFB2 and close run under the card lock and the handle is closed right
133+// away: the framebuffer keeps its own reference to the buffer.
134+func (c *Card) addClientFB(b *ports.DMABuf) (uint32, error) {
135+ format, ok := scanoutFormat(b.Format)
136+ if !ok {
137+ return 0, errors.New("format not scanned out")
138+ }
139+ raw, err := b.Planes[0].File.SyscallConn()
140+ if err != nil {
141+ return 0, err
142+ }
143+ c.gemMu.Lock()
144+ defer c.gemMu.Unlock()
145+ var p primeHandle
146+ var ioErr error
147+ if err := raw.Control(func(fd uintptr) {
148+ p.fd = int32(fd)
149+ ioErr = ioctl(c.fd, ioctlPrimeFDToHandle, unsafe.Pointer(&p))
150+ }); err != nil {
151+ return 0, err
152+ }
153+ if ioErr != nil {
154+ return 0, errors.Join(errors.New("prime import"), ioErr)
155+ }
156+ defer func() {
157+ g := gemClose{handle: p.handle}
158+ _ = ioctl(c.fd, ioctlGemClose, unsafe.Pointer(&g))
159+ }()
160+ cmd := fbCmd2{width: uint32(b.Width), height: uint32(b.Height), format: format}
161+ cmd.handles[0], cmd.pitches[0], cmd.offsets[0] = p.handle, b.Planes[0].Stride, b.Planes[0].Offset
162+ if b.Modifier != modInvalid && (b.Modifier != 0 || c.modifiers) {
163+ cmd.flags, cmd.modifiers[0] = fbModifiers, b.Modifier
164+ }
165+ if err := ioctl(c.fd, ioctlAddFB2, unsafe.Pointer(&cmd)); err != nil {
166+ return 0, errors.Join(errors.New("add fb2"), err)
167+ }
168+ return cmd.fbID, nil
169+}
170+
171+// dropClientFBs frees framebuffers not wanted for scanoutIdleTTL, or all
172+// of them, except those on screen or queued.
173+func (o *Output) dropClientFBs(now time.Time, all bool) {
174+ for id, fb := range o.clientFBs {
175+ if id == o.shown || id == o.queued {
176+ continue
177+ }
178+ if !all && now.Sub(fb.last) < scanoutIdleTTL {
179+ continue
180+ }
181+ if fb.fbID != 0 {
182+ v := fb.fbID
183+ _ = ioctl(o.fd, ioctlRmFB, unsafe.Pointer(&v))
184+ }
185+ delete(o.clientFBs, id)
186+ }
187+}
188+
189+func scanoutable(f uint32) bool { _, ok := scanoutFormat(f); return ok }
A internal/adapters/drm/scanout_test.go +70 -0
@@ -0,0 +1,70 @@
1+package drm
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+)
8+
9+func TestScanoutCandidate(t *testing.T) {
10+ full := ports.SceneWindow{ID: 1, Rect: ports.Rect{W: 100, H: 50}, Fullscreen: true}
11+ scene := ports.Scene{OutputWidth: 100, OutputHeight: 50, Windows: []ports.SceneWindow{full}}
12+ buf := &ports.DMABuf{ID: 9, Width: 200, Height: 100, Format: fourccARGB, Planes: []ports.DMABufPlane{{}}}
13+ game := ports.SurfaceContent{ID: 1, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, DMABuf: buf}
14+ for _, tc := range []struct {
15+ name string
16+ scene func(s *ports.Scene)
17+ cont func(c *ports.SurfaceContent)
18+ reason string
19+ }{
20+ {"scanout", nil, nil, ""},
21+ {"hidden others ignored", func(s *ports.Scene) {
22+ s.Windows = append(s.Windows, ports.SceneWindow{ID: 2, Hidden: true})
23+ }, nil, ""},
24+ {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"},
25+ {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"},
26+ {"float above", func(s *ports.Scene) {
27+ s.Windows = append(s.Windows, ports.SceneWindow{ID: 2, Rect: ports.Rect{W: 10, H: 10}})
28+ }, nil, "other_windows"},
29+ {"not covering", func(s *ports.Scene) { s.Windows[0].Rect.W = 90 }, nil, "other_windows"},
30+ {"overlay layer", func(s *ports.Scene) {
31+ s.Layers = []ports.SceneLayer{{ID: 3, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 10, H: 10}}}
32+ }, nil, "overlay_surface"},
33+ {"top layer covered", func(s *ports.Scene) {
34+ s.Layers = []ports.SceneLayer{{ID: 3, Layer: ports.LayerTop, Rect: ports.Rect{W: 10, H: 10}}}
35+ }, nil, ""},
36+ {"shm", nil, func(c *ports.SurfaceContent) { c.DMABuf, c.SHM = nil, &ports.SHMBuffer{} }, "not_dmabuf"},
37+ {"subsurface", nil, func(c *ports.SurfaceContent) { c.Children = []ports.Subsurface{{}} }, "subsurfaces"},
38+ {"scaled", nil, func(c *ports.SurfaceContent) { c.Width = 100 }, "size_mismatch"},
39+ {"cropped", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{X: 5, W: 90, H: 50} }, "geometry_crop"},
40+ {"client ignores scale", nil, func(c *ports.SurfaceContent) { c.LogicalW = 200 }, "logical_mismatch"},
41+ {"unsupported format", nil, func(c *ports.SurfaceContent) {
42+ d := *c.DMABuf
43+ d.Format = 'N' | 'V'<<8 | '1'<<16 | '2'<<24
44+ c.DMABuf = &d
45+ }, "format"},
46+ {"whole geometry", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{W: 100, H: 50} }, ""},
47+ } {
48+ s := scene
49+ s.Windows = append([]ports.SceneWindow(nil), scene.Windows...)
50+ c := game
51+ if tc.scene != nil {
52+ tc.scene(&s)
53+ }
54+ if tc.cont != nil {
55+ tc.cont(&c)
56+ }
57+ if _, reason := scanoutCandidate(s, map[ports.WindowID]ports.SurfaceContent{1: c}, 200, 100); reason != tc.reason {
58+ t.Errorf("%s: reason %q, want %q", tc.name, reason, tc.reason)
59+ }
60+ }
61+}
62+
63+func TestScanoutFormat(t *testing.T) {
64+ for f, want := range map[uint32]bool{fourccXRGB: true, fourccARGB: true, 'A' | 'B'<<8 | '2'<<16 | '4'<<24: false} {
65+ got, ok := scanoutFormat(f)
66+ if ok != want || ok && got != fourccXRGB {
67+ t.Errorf("%x: %x %v", f, got, ok)
68+ }
69+ }
70+}
@@ -0,0 +1,70 @@
1+package drm
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+)
8+
9+func TestScanoutCandidate(t *testing.T) {
10+ full := ports.SceneWindow{ID: 1, Rect: ports.Rect{W: 100, H: 50}, Fullscreen: true}
11+ scene := ports.Scene{OutputWidth: 100, OutputHeight: 50, Windows: []ports.SceneWindow{full}}
12+ buf := &ports.DMABuf{ID: 9, Width: 200, Height: 100, Format: fourccARGB, Planes: []ports.DMABufPlane{{}}}
13+ game := ports.SurfaceContent{ID: 1, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, DMABuf: buf}
14+ for _, tc := range []struct {
15+ name string
16+ scene func(s *ports.Scene)
17+ cont func(c *ports.SurfaceContent)
18+ reason string
19+ }{
20+ {"scanout", nil, nil, ""},
21+ {"hidden others ignored", func(s *ports.Scene) {
22+ s.Windows = append(s.Windows, ports.SceneWindow{ID: 2, Hidden: true})
23+ }, nil, ""},
24+ {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"},
25+ {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"},
26+ {"float above", func(s *ports.Scene) {
27+ s.Windows = append(s.Windows, ports.SceneWindow{ID: 2, Rect: ports.Rect{W: 10, H: 10}})
28+ }, nil, "other_windows"},
29+ {"not covering", func(s *ports.Scene) { s.Windows[0].Rect.W = 90 }, nil, "other_windows"},
30+ {"overlay layer", func(s *ports.Scene) {
31+ s.Layers = []ports.SceneLayer{{ID: 3, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 10, H: 10}}}
32+ }, nil, "overlay_surface"},
33+ {"top layer covered", func(s *ports.Scene) {
34+ s.Layers = []ports.SceneLayer{{ID: 3, Layer: ports.LayerTop, Rect: ports.Rect{W: 10, H: 10}}}
35+ }, nil, ""},
36+ {"shm", nil, func(c *ports.SurfaceContent) { c.DMABuf, c.SHM = nil, &ports.SHMBuffer{} }, "not_dmabuf"},
37+ {"subsurface", nil, func(c *ports.SurfaceContent) { c.Children = []ports.Subsurface{{}} }, "subsurfaces"},
38+ {"scaled", nil, func(c *ports.SurfaceContent) { c.Width = 100 }, "size_mismatch"},
39+ {"cropped", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{X: 5, W: 90, H: 50} }, "geometry_crop"},
40+ {"client ignores scale", nil, func(c *ports.SurfaceContent) { c.LogicalW = 200 }, "logical_mismatch"},
41+ {"unsupported format", nil, func(c *ports.SurfaceContent) {
42+ d := *c.DMABuf
43+ d.Format = 'N' | 'V'<<8 | '1'<<16 | '2'<<24
44+ c.DMABuf = &d
45+ }, "format"},
46+ {"whole geometry", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{W: 100, H: 50} }, ""},
47+ } {
48+ s := scene
49+ s.Windows = append([]ports.SceneWindow(nil), scene.Windows...)
50+ c := game
51+ if tc.scene != nil {
52+ tc.scene(&s)
53+ }
54+ if tc.cont != nil {
55+ tc.cont(&c)
56+ }
57+ if _, reason := scanoutCandidate(s, map[ports.WindowID]ports.SurfaceContent{1: c}, 200, 100); reason != tc.reason {
58+ t.Errorf("%s: reason %q, want %q", tc.name, reason, tc.reason)
59+ }
60+ }
61+}
62+
63+func TestScanoutFormat(t *testing.T) {
64+ for f, want := range map[uint32]bool{fourccXRGB: true, fourccARGB: true, 'A' | 'B'<<8 | '2'<<16 | '4'<<24: false} {
65+ got, ok := scanoutFormat(f)
66+ if ok != want || ok && got != fourccXRGB {
67+ t.Errorf("%x: %x %v", f, got, ok)
68+ }
69+ }
70+}
M internal/adapters/headless/output.go +39 -0
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "image"
77 "image/png"
8+ "maps"
89 "os"
910 "path/filepath"
1011 "time"
@@ -22,6 +23,10 @@ type Options struct {
2223 ScreenshotDir string
2324 Log zerowrap.Logger
2425 NewRenderer func(w, h int) (ports.Renderer, error)
26+ // Name and Presented report what the output has read after each
27+ // frame, so wayland can release client buffers (nil: no reports).
28+ Name string
29+ Presented chan<- ports.OutputPresented
2530 }
2631
2732 func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error {
@@ -36,7 +41,11 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
3641 frame := 0
3742 var want ports.CursorChange
3843 cursorScale := -1.0 // not loaded yet
44+ seen := map[ports.WindowID]uint64{}
45+ // pending is a report the channel could not take, retried soon.
46+ var pending *ports.OutputPresented
3947 update := func(c ports.SurfaceContent) {
48+ seen[c.ID] = max(seen[c.ID], c.Seq)
4049 dirty = dirty || scene.Shows(c.ID)
4150 if c.Empty() {
4251 delete(surfaces, c.ID)
@@ -48,9 +57,16 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
4857 if ctx.Err() != nil {
4958 return nil
5059 }
60+ var retry <-chan time.Time
61+ if pending != nil {
62+ retry = time.After(time.Millisecond)
63+ }
5164 select {
5265 case <-ctx.Done():
5366 return nil
67+ case <-retry:
68+ pending = opts.send(pending)
69+ continue
5470 case s, ok := <-scenes:
5571 if !ok {
5672 scenes = nil
@@ -88,6 +104,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
88104 }
89105 }
90106 if !haveScene || !dirty {
107+ // Contents not drawn are still read: report them.
108+ pending = opts.report(pending, seen)
91109 continue
92110 }
93111 dirty = false
@@ -104,6 +122,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
104122 return fmt.Errorf("render frame: %w", err)
105123 }
106124 frame++
125+ pending = opts.report(pending, seen)
107126 if opts.ScreenshotDir != "" {
108127 shot := r.Pixels()
109128 if opts.Cursor != nil {
@@ -135,3 +154,23 @@ func writePNG(path string, img *image.RGBA) error {
135154 }
136155 return os.Rename(f.Name(), path)
137156 }
157+
158+// report sends what the output read; a full channel drops it.
159+// report sends what the output read, or returns it to retry when the
160+// channel is full. Headless frames are not paced by a screen: Flip stays
161+// false and frame callbacks follow the refresh timer.
162+func (opts Options) report(_ *ports.OutputPresented, seen map[ports.WindowID]uint64) *ports.OutputPresented {
163+ if opts.Presented == nil {
164+ return nil
165+ }
166+ return opts.send(&ports.OutputPresented{Output: opts.Name, Seen: maps.Clone(seen)})
167+}
168+
169+func (opts Options) send(r *ports.OutputPresented) *ports.OutputPresented {
170+ select {
171+ case opts.Presented <- *r:
172+ return nil
173+ default:
174+ return r
175+ }
176+}
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 "image"6 "image"
7 "image/png"7 "image/png"
8+ "maps"
8 "os"9 "os"
9 "path/filepath"10 "path/filepath"
10 "time"11 "time"
@@ -22,6 +23,10 @@ type Options struct {
22 ScreenshotDir string23 ScreenshotDir string
23 Log zerowrap.Logger24 Log zerowrap.Logger
24 NewRenderer func(w, h int) (ports.Renderer, error)25 NewRenderer func(w, h int) (ports.Renderer, error)
26+ // Name and Presented report what the output has read after each
27+ // frame, so wayland can release client buffers (nil: no reports).
28+ Name string
29+ Presented chan<- ports.OutputPresented
25 }30 }
26 31
27 func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error {32 func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error {
@@ -36,7 +41,11 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
36 frame := 041 frame := 0
37 var want ports.CursorChange42 var want ports.CursorChange
38 cursorScale := -1.0 // not loaded yet43 cursorScale := -1.0 // not loaded yet
44+ seen := map[ports.WindowID]uint64{}
45+ // pending is a report the channel could not take, retried soon.
46+ var pending *ports.OutputPresented
39 update := func(c ports.SurfaceContent) {47 update := func(c ports.SurfaceContent) {
48+ seen[c.ID] = max(seen[c.ID], c.Seq)
40 dirty = dirty || scene.Shows(c.ID)49 dirty = dirty || scene.Shows(c.ID)
41 if c.Empty() {50 if c.Empty() {
42 delete(surfaces, c.ID)51 delete(surfaces, c.ID)
@@ -48,9 +57,16 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
48 if ctx.Err() != nil {57 if ctx.Err() != nil {
49 return nil58 return nil
50 }59 }
60+ var retry <-chan time.Time
61+ if pending != nil {
62+ retry = time.After(time.Millisecond)
63+ }
51 select {64 select {
52 case <-ctx.Done():65 case <-ctx.Done():
53 return nil66 return nil
67+ case <-retry:
68+ pending = opts.send(pending)
69+ continue
54 case s, ok := <-scenes:70 case s, ok := <-scenes:
55 if !ok {71 if !ok {
56 scenes = nil72 scenes = nil
@@ -88,6 +104,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
88 }104 }
89 }105 }
90 if !haveScene || !dirty {106 if !haveScene || !dirty {
107+ // Contents not drawn are still read: report them.
108+ pending = opts.report(pending, seen)
91 continue109 continue
92 }110 }
93 dirty = false111 dirty = false
@@ -104,6 +122,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
104 return fmt.Errorf("render frame: %w", err)122 return fmt.Errorf("render frame: %w", err)
105 }123 }
106 frame++124 frame++
125+ pending = opts.report(pending, seen)
107 if opts.ScreenshotDir != "" {126 if opts.ScreenshotDir != "" {
108 shot := r.Pixels()127 shot := r.Pixels()
109 if opts.Cursor != nil {128 if opts.Cursor != nil {
@@ -135,3 +154,23 @@ func writePNG(path string, img *image.RGBA) error {
135 }154 }
136 return os.Rename(f.Name(), path)155 return os.Rename(f.Name(), path)
137 }156 }
157+
158+// report sends what the output read; a full channel drops it.
159+// report sends what the output read, or returns it to retry when the
160+// channel is full. Headless frames are not paced by a screen: Flip stays
161+// false and frame callbacks follow the refresh timer.
162+func (opts Options) report(_ *ports.OutputPresented, seen map[ports.WindowID]uint64) *ports.OutputPresented {
163+ if opts.Presented == nil {
164+ return nil
165+ }
166+ return opts.send(&ports.OutputPresented{Output: opts.Name, Seen: maps.Clone(seen)})
167+}
168+
169+func (opts Options) send(r *ports.OutputPresented) *ports.OutputPresented {
170+ select {
171+ case opts.Presented <- *r:
172+ return nil
173+ default:
174+ return r
175+ }
176+}
M internal/adapters/wayland/frames.go +11 -2
@@ -5,6 +5,8 @@ import (
55 "slices"
66 "time"
77
8+ "github.com/bnema/nefertty/internal/ports"
9+
810 "github.com/bnema/purego-libwayland/protocol/wayland"
911 )
1012
@@ -25,13 +27,15 @@ func (s *Server) pace(ctx context.Context) {
2527 presented := s.channels.Presented
2628 for {
2729 flipped := map[string]bool{}
30+ var reports []ports.OutputPresented
2831 select {
2932 case <-ctx.Done():
3033 return
3134 case <-s.display.Stopped():
3235 return
3336 case p := <-presented:
34- flipped[p.Output] = true
37+ flipped[p.Output] = flipped[p.Output] || p.Flip
38+ reports = append(reports, p)
3539 case <-s.frameReady:
3640 case <-timer.C:
3741 }
@@ -41,7 +45,8 @@ func (s *Server) pace(ctx context.Context) {
4145 for {
4246 select {
4347 case p := <-presented:
44- flipped[p.Output] = true
48+ flipped[p.Output] = flipped[p.Output] || p.Flip
49+ reports = append(reports, p)
4550 default:
4651 break drain
4752 }
@@ -54,6 +59,10 @@ func (s *Server) pace(ctx context.Context) {
5459 for _, name := range due {
5560 s.sendFrames(name)
5661 }
62+ if s.releaseHeld(time.Now(), reports) && idle {
63+ // Held buffers wait for a flip or heldTimeout.
64+ wait, idle = heldTimeout, false
65+ }
5766 }
5867 }) {
5968 return
@@ -5,6 +5,8 @@ import (
5 "slices"5 "slices"
6 "time"6 "time"
7 7
8+ "github.com/bnema/nefertty/internal/ports"
9+
8 "github.com/bnema/purego-libwayland/protocol/wayland"10 "github.com/bnema/purego-libwayland/protocol/wayland"
9 )11 )
10 12
@@ -25,13 +27,15 @@ func (s *Server) pace(ctx context.Context) {
25 presented := s.channels.Presented27 presented := s.channels.Presented
26 for {28 for {
27 flipped := map[string]bool{}29 flipped := map[string]bool{}
30+ var reports []ports.OutputPresented
28 select {31 select {
29 case <-ctx.Done():32 case <-ctx.Done():
30 return33 return
31 case <-s.display.Stopped():34 case <-s.display.Stopped():
32 return35 return
33 case p := <-presented:36 case p := <-presented:
34- flipped[p.Output] = true37+ flipped[p.Output] = flipped[p.Output] || p.Flip
38+ reports = append(reports, p)
35 case <-s.frameReady:39 case <-s.frameReady:
36 case <-timer.C:40 case <-timer.C:
37 }41 }
@@ -41,7 +45,8 @@ func (s *Server) pace(ctx context.Context) {
41 for {45 for {
42 select {46 select {
43 case p := <-presented:47 case p := <-presented:
44- flipped[p.Output] = true48+ flipped[p.Output] = flipped[p.Output] || p.Flip
49+ reports = append(reports, p)
45 default:50 default:
46 break drain51 break drain
47 }52 }
@@ -54,6 +59,10 @@ func (s *Server) pace(ctx context.Context) {
54 for _, name := range due {59 for _, name := range due {
55 s.sendFrames(name)60 s.sendFrames(name)
56 }61 }
62+ if s.releaseHeld(time.Now(), reports) && idle {
63+ // Held buffers wait for a flip or heldTimeout.
64+ wait, idle = heldTimeout, false
65+ }
57 }66 }
58 }) {67 }) {
59 return68 return
A internal/adapters/wayland/release.go +108 -0
@@ -0,0 +1,108 @@
1+package wayland
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+ "github.com/bnema/purego-libwayland/protocol/wayland"
8+)
9+
10+// Buffer release. Outputs read client buffers in place: the renderer
11+// samples them when it draws, and an output scanning out a client buffer
12+// (ADR 006) shows it until the next flip. A replaced wl_buffer therefore
13+// goes back to the client (release) only when the output showing its
14+// window reports (ports.OutputPresented) that it has a later content of
15+// that window and neither shows nor queues the buffer. Outputs keep only
16+// the latest content of a window, so they never draw an older buffer
17+// again. heldTimeout guards against an output that stopped reporting
18+// (switched away, stalled): a buffer it does not scan out is released then.
19+
20+// heldTimeout releases held buffers of an output that stopped reporting.
21+const heldTimeout = 100 * time.Millisecond
22+
23+type heldBuffer struct {
24+ res *wayland.Buffer
25+ window ports.WindowID
26+ id uint64 // DMABuf ID, 0 for wl_shm
27+ after uint64 // the window's content Seq when the buffer was replaced
28+ at time.Time
29+}
30+
31+// releaseBuffer releases a replaced buffer, or holds it until the output
32+// showing its window no longer reads it.
33+func (s *Server) releaseBuffer(surf *surface, b *wayland.Buffer) {
34+ if !b.Resource.Alive() {
35+ return
36+ }
37+ window, name := surf.root().windowID(), s.frameOutput(surf)
38+ if window == 0 || name == "" {
39+ // Not drawn (unmapped, hidden, a cursor): nothing reads it.
40+ b.SendRelease()
41+ return
42+ }
43+ h := heldBuffer{res: b, window: window, at: time.Now()}
44+ if d, ok := s.buffers[b.Resource].(*dmabufBuffer); ok {
45+ h.id = d.buf.ID
46+ }
47+ s.contentMu.Lock()
48+ h.after = s.contentSeq[window]
49+ s.contentMu.Unlock()
50+ s.held = append(s.held, h)
51+ // Wake the pacer: it releases the buffer on the next output report.
52+ select {
53+ case s.frameReady <- struct{}{}:
54+ default:
55+ }
56+}
57+
58+// releaseHeld records the output reports and releases the buffers no
59+// output reads. Every output reads every window's content, so a buffer
60+// waits until no live output shows or queues it and all of them have seen
61+// a later content of its window (or heldTimeout passed). It reports
62+// whether buffers are still held.
63+func (s *Server) releaseHeld(now time.Time, reports []ports.OutputPresented) bool {
64+ for _, r := range reports {
65+ s.reports[r.Output] = r
66+ }
67+ live := make([]ports.OutputPresented, 0, len(s.outputs))
68+ for name := range s.reports {
69+ if s.outputByNameExact(name) == nil {
70+ delete(s.reports, name)
71+ }
72+ }
73+ for _, r := range s.reports {
74+ live = append(live, r)
75+ }
76+ kept := s.held[:0]
77+ for _, h := range s.held {
78+ if keepHeld(h, live, now) {
79+ kept = append(kept, h)
80+ continue
81+ }
82+ if h.res.Resource.Alive() {
83+ h.res.SendRelease()
84+ }
85+ }
86+ clear(s.held[len(kept):])
87+ s.held = kept
88+ return len(s.held) > 0
89+}
90+
91+// keepHeld reports whether an output may still read a held buffer: one
92+// scans it out or queued it, or one has not got a later content of its
93+// window yet and heldTimeout has not passed.
94+func keepHeld(h heldBuffer, reports []ports.OutputPresented, now time.Time) bool {
95+ seen := true
96+ for _, r := range reports {
97+ if h.id != 0 && (r.Shown == h.id || r.Queued == h.id) {
98+ return true
99+ }
100+ if r.Seen[h.window] <= h.after {
101+ seen = false
102+ }
103+ }
104+ if len(reports) == 0 {
105+ seen = false
106+ }
107+ return !seen && now.Sub(h.at) < heldTimeout
108+}
@@ -0,0 +1,108 @@
1+package wayland
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+ "github.com/bnema/purego-libwayland/protocol/wayland"
8+)
9+
10+// Buffer release. Outputs read client buffers in place: the renderer
11+// samples them when it draws, and an output scanning out a client buffer
12+// (ADR 006) shows it until the next flip. A replaced wl_buffer therefore
13+// goes back to the client (release) only when the output showing its
14+// window reports (ports.OutputPresented) that it has a later content of
15+// that window and neither shows nor queues the buffer. Outputs keep only
16+// the latest content of a window, so they never draw an older buffer
17+// again. heldTimeout guards against an output that stopped reporting
18+// (switched away, stalled): a buffer it does not scan out is released then.
19+
20+// heldTimeout releases held buffers of an output that stopped reporting.
21+const heldTimeout = 100 * time.Millisecond
22+
23+type heldBuffer struct {
24+ res *wayland.Buffer
25+ window ports.WindowID
26+ id uint64 // DMABuf ID, 0 for wl_shm
27+ after uint64 // the window's content Seq when the buffer was replaced
28+ at time.Time
29+}
30+
31+// releaseBuffer releases a replaced buffer, or holds it until the output
32+// showing its window no longer reads it.
33+func (s *Server) releaseBuffer(surf *surface, b *wayland.Buffer) {
34+ if !b.Resource.Alive() {
35+ return
36+ }
37+ window, name := surf.root().windowID(), s.frameOutput(surf)
38+ if window == 0 || name == "" {
39+ // Not drawn (unmapped, hidden, a cursor): nothing reads it.
40+ b.SendRelease()
41+ return
42+ }
43+ h := heldBuffer{res: b, window: window, at: time.Now()}
44+ if d, ok := s.buffers[b.Resource].(*dmabufBuffer); ok {
45+ h.id = d.buf.ID
46+ }
47+ s.contentMu.Lock()
48+ h.after = s.contentSeq[window]
49+ s.contentMu.Unlock()
50+ s.held = append(s.held, h)
51+ // Wake the pacer: it releases the buffer on the next output report.
52+ select {
53+ case s.frameReady <- struct{}{}:
54+ default:
55+ }
56+}
57+
58+// releaseHeld records the output reports and releases the buffers no
59+// output reads. Every output reads every window's content, so a buffer
60+// waits until no live output shows or queues it and all of them have seen
61+// a later content of its window (or heldTimeout passed). It reports
62+// whether buffers are still held.
63+func (s *Server) releaseHeld(now time.Time, reports []ports.OutputPresented) bool {
64+ for _, r := range reports {
65+ s.reports[r.Output] = r
66+ }
67+ live := make([]ports.OutputPresented, 0, len(s.outputs))
68+ for name := range s.reports {
69+ if s.outputByNameExact(name) == nil {
70+ delete(s.reports, name)
71+ }
72+ }
73+ for _, r := range s.reports {
74+ live = append(live, r)
75+ }
76+ kept := s.held[:0]
77+ for _, h := range s.held {
78+ if keepHeld(h, live, now) {
79+ kept = append(kept, h)
80+ continue
81+ }
82+ if h.res.Resource.Alive() {
83+ h.res.SendRelease()
84+ }
85+ }
86+ clear(s.held[len(kept):])
87+ s.held = kept
88+ return len(s.held) > 0
89+}
90+
91+// keepHeld reports whether an output may still read a held buffer: one
92+// scans it out or queued it, or one has not got a later content of its
93+// window yet and heldTimeout has not passed.
94+func keepHeld(h heldBuffer, reports []ports.OutputPresented, now time.Time) bool {
95+ seen := true
96+ for _, r := range reports {
97+ if h.id != 0 && (r.Shown == h.id || r.Queued == h.id) {
98+ return true
99+ }
100+ if r.Seen[h.window] <= h.after {
101+ seen = false
102+ }
103+ }
104+ if len(reports) == 0 {
105+ seen = false
106+ }
107+ return !seen && now.Sub(h.at) < heldTimeout
108+}
A internal/adapters/wayland/release_test.go +43 -0
@@ -0,0 +1,43 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+)
9+
10+func TestKeepHeld(t *testing.T) {
11+ now := time.Unix(100, 0)
12+ dma := heldBuffer{window: 1, id: 7, after: 5, at: now}
13+ shm := heldBuffer{window: 1, after: 5, at: now}
14+ out := func(shown, queued, seq uint64) ports.OutputPresented {
15+ return ports.OutputPresented{Shown: shown, Queued: queued, Seen: map[ports.WindowID]uint64{1: seq}}
16+ }
17+ type R = []ports.OutputPresented
18+ for _, tc := range []struct {
19+ name string
20+ h heldBuffer
21+ reports R
22+ after time.Duration
23+ want bool
24+ }{
25+ {"no report yet", dma, nil, 0, true},
26+ {"no report times out", dma, nil, heldTimeout, false},
27+ {"output has only this content", dma, R{out(0, 0, 5)}, 0, true},
28+ {"later content seen", dma, R{out(0, 0, 6)}, 0, false},
29+ {"scanned out", dma, R{out(7, 0, 6)}, time.Second, true},
30+ {"queued for scanout", dma, R{out(0, 7, 6)}, time.Second, true},
31+ {"another buffer scanned out", dma, R{out(8, 0, 6)}, 0, false},
32+ {"silent output times out", dma, R{out(0, 0, 5)}, heldTimeout, false},
33+ {"shm follows content", shm, R{out(0, 0, 6)}, 0, false},
34+ // A window moved off output A: A still scans the buffer out.
35+ {"other output scans it out", dma, R{out(0, 0, 6), out(7, 0, 6)}, time.Second, true},
36+ {"other output lags", dma, R{out(0, 0, 6), out(0, 0, 5)}, 0, true},
37+ {"all outputs moved on", dma, R{out(0, 0, 6), out(0, 0, 6)}, 0, false},
38+ } {
39+ if got := keepHeld(tc.h, tc.reports, now.Add(tc.after)); got != tc.want {
40+ t.Errorf("%s: %v", tc.name, got)
41+ }
42+ }
43+}
@@ -0,0 +1,43 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+)
9+
10+func TestKeepHeld(t *testing.T) {
11+ now := time.Unix(100, 0)
12+ dma := heldBuffer{window: 1, id: 7, after: 5, at: now}
13+ shm := heldBuffer{window: 1, after: 5, at: now}
14+ out := func(shown, queued, seq uint64) ports.OutputPresented {
15+ return ports.OutputPresented{Shown: shown, Queued: queued, Seen: map[ports.WindowID]uint64{1: seq}}
16+ }
17+ type R = []ports.OutputPresented
18+ for _, tc := range []struct {
19+ name string
20+ h heldBuffer
21+ reports R
22+ after time.Duration
23+ want bool
24+ }{
25+ {"no report yet", dma, nil, 0, true},
26+ {"no report times out", dma, nil, heldTimeout, false},
27+ {"output has only this content", dma, R{out(0, 0, 5)}, 0, true},
28+ {"later content seen", dma, R{out(0, 0, 6)}, 0, false},
29+ {"scanned out", dma, R{out(7, 0, 6)}, time.Second, true},
30+ {"queued for scanout", dma, R{out(0, 7, 6)}, time.Second, true},
31+ {"another buffer scanned out", dma, R{out(8, 0, 6)}, 0, false},
32+ {"silent output times out", dma, R{out(0, 0, 5)}, heldTimeout, false},
33+ {"shm follows content", shm, R{out(0, 0, 6)}, 0, false},
34+ // A window moved off output A: A still scans the buffer out.
35+ {"other output scans it out", dma, R{out(0, 0, 6), out(7, 0, 6)}, time.Second, true},
36+ {"other output lags", dma, R{out(0, 0, 6), out(0, 0, 5)}, 0, true},
37+ {"all outputs moved on", dma, R{out(0, 0, 6), out(0, 0, 6)}, 0, false},
38+ } {
39+ if got := keepHeld(tc.h, tc.reports, now.Add(tc.after)); got != tc.want {
40+ t.Errorf("%s: %v", tc.name, got)
41+ }
42+ }
43+}
M internal/adapters/wayland/server.go +13 -8
@@ -52,12 +52,16 @@ type Server struct {
5252 awaiting map[string][]*wayland.Callback
5353 frameDue map[string]time.Time
5454 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
55+ // held buffers wait until no output reads them (release.go); reports
56+ // are the outputs' latest ports.OutputPresented.
57+ held []heldBuffer
58+ reports map[string]ports.OutputPresented
59+ frames uint64
60+ started time.Time
61+ surfaces map[*server.Resource]*surface
62+ buffers map[*server.Resource]clientBuffer
63+ dmabuf *dmabufGlobal
64+ serial uint32
6165 // press is the serial of the last button or key press, sent to
6266 // pressClient: popup grabs must come from it.
6367 press uint32
@@ -143,7 +147,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
143147 d.Close()
144148 return nil, err
145149 }
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}
150+ s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, 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}
147151 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
148152 if opts.Keymap != "" {
149153 fd, size, e := keymapFile(opts.Keymap)
@@ -262,8 +266,9 @@ func (s *Server) emitContent(c ports.SurfaceContent) {
262266 return
263267 }
264268 s.contentMu.Lock()
265- s.contents[c.ID] = c
266269 s.contentSeq[c.ID]++
270+ c.Seq = s.contentSeq[c.ID]
271+ s.contents[c.ID] = c
267272 close(s.contentNotify)
268273 s.contentNotify = make(chan struct{})
269274 s.contentMu.Unlock()
@@ -52,12 +52,16 @@ type Server struct {
52 awaiting map[string][]*wayland.Callback52 awaiting map[string][]*wayland.Callback
53 frameDue map[string]time.Time53 frameDue map[string]time.Time
54 frameReady chan struct{}54 frameReady chan struct{}
55- frames uint6455+ // held buffers wait until no output reads them (release.go); reports
56- started time.Time56+ // are the outputs' latest ports.OutputPresented.
57- surfaces map[*server.Resource]*surface57+ held []heldBuffer
58- buffers map[*server.Resource]clientBuffer58+ reports map[string]ports.OutputPresented
59- dmabuf *dmabufGlobal59+ frames uint64
60- serial uint3260+ started time.Time
61+ surfaces map[*server.Resource]*surface
62+ buffers map[*server.Resource]clientBuffer
63+ dmabuf *dmabufGlobal
64+ serial uint32
61 // press is the serial of the last button or key press, sent to65 // press is the serial of the last button or key press, sent to
62 // pressClient: popup grabs must come from it.66 // pressClient: popup grabs must come from it.
63 press uint3267 press uint32
@@ -143,7 +147,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
143 d.Close()147 d.Close()
144 return nil, err148 return nil, err
145 }149 }
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}150+ s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, 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}
147 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}151 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
148 if opts.Keymap != "" {152 if opts.Keymap != "" {
149 fd, size, e := keymapFile(opts.Keymap)153 fd, size, e := keymapFile(opts.Keymap)
@@ -262,8 +266,9 @@ func (s *Server) emitContent(c ports.SurfaceContent) {
262 return266 return
263 }267 }
264 s.contentMu.Lock()268 s.contentMu.Lock()
265- s.contents[c.ID] = c
266 s.contentSeq[c.ID]++269 s.contentSeq[c.ID]++
270+ c.Seq = s.contentSeq[c.ID]
271+ s.contents[c.ID] = c
267 close(s.contentNotify)272 close(s.contentNotify)
268 s.contentNotify = make(chan struct{})273 s.contentNotify = make(chan struct{})
269 s.contentMu.Unlock()274 s.contentMu.Unlock()
M internal/adapters/wayland/surface.go +3 -5
@@ -150,8 +150,8 @@ func (s *surface) Destroy(*wayland.Surface) {
150150 }
151151 s.callbacks = nil
152152 // The client may reuse the buffer on another surface.
153- if s.current != nil && s.current.Resource.Alive() {
154- s.current.SendRelease()
153+ if s.current != nil {
154+ s.server.releaseBuffer(s, s.current)
155155 }
156156 s.current, s.pending = nil, nil
157157 if s.role != nil {
@@ -189,9 +189,7 @@ func (s *surface) Commit(*wayland.Surface) {
189189 }
190190 if s.attached {
191191 if s.current != nil && (s.pending == nil || s.current.Resource != s.pending.Resource) {
192- if s.current.Resource.Alive() {
193- s.current.SendRelease()
194- }
192+ s.server.releaseBuffer(s, s.current)
195193 }
196194 s.current = s.pending
197195 s.pending = nil
@@ -150,8 +150,8 @@ func (s *surface) Destroy(*wayland.Surface) {
150 }150 }
151 s.callbacks = nil151 s.callbacks = nil
152 // The client may reuse the buffer on another surface.152 // The client may reuse the buffer on another surface.
153- if s.current != nil && s.current.Resource.Alive() {153+ if s.current != nil {
154- s.current.SendRelease()154+ s.server.releaseBuffer(s, s.current)
155 }155 }
156 s.current, s.pending = nil, nil156 s.current, s.pending = nil, nil
157 if s.role != nil {157 if s.role != nil {
@@ -189,9 +189,7 @@ func (s *surface) Commit(*wayland.Surface) {
189 }189 }
190 if s.attached {190 if s.attached {
191 if s.current != nil && (s.pending == nil || s.current.Resource != s.pending.Resource) {191 if s.current != nil && (s.pending == nil || s.current.Resource != s.pending.Resource) {
192- if s.current.Resource.Alive() {192+ s.server.releaseBuffer(s, s.current)
193- s.current.SendRelease()
194- }
195 }193 }
196 s.current = s.pending194 s.current = s.pending
197 s.pending = nil195 s.pending = nil
M internal/app/app.go +4 -4
@@ -64,7 +64,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
6464 var hw *drmBackend
6565 if opts.Backend == "drm" {
6666 var err error
67- if hw, err = openDRM(ctx, opts.Config.Outputs); err != nil {
67+ if hw, err = openDRM(ctx, opts.Config.Outputs, opts.Config.Render.DirectScanout); err != nil {
6868 return err
6969 }
7070 defer hw.close()
@@ -83,7 +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)
86+ presented := make(chan ports.OutputPresented, 64)
8787 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}
8888 c, err := core.New(opts.Config, ch)
8989 if err != nil {
@@ -208,12 +208,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
208208 }
209209 if hw != nil {
210210 done <- safe("output", func() error {
211- want := func() drm.Want { return wantFromConfig(opts.Config.Outputs) }
211+ want := func() drm.Want { return wantFromConfig(opts.Config.Outputs, opts.Config.Render.DirectScanout) }
212212 return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, presented, curs, newRenderer, logging.For(ctx, "drm"))
213213 })
214214 return
215215 }
216- done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, curs, newRenderer, logging.For(ctx, "render"))
216+ done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, presented, curs, newRenderer, logging.For(ctx, "render"))
217217 }()
218218
219219 if hw != nil {
@@ -64,7 +64,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
64 var hw *drmBackend64 var hw *drmBackend
65 if opts.Backend == "drm" {65 if opts.Backend == "drm" {
66 var err error66 var err error
67- if hw, err = openDRM(ctx, opts.Config.Outputs); err != nil {67+ if hw, err = openDRM(ctx, opts.Config.Outputs, opts.Config.Render.DirectScanout); err != nil {
68 return err68 return err
69 }69 }
70 defer hw.close()70 defer hw.close()
@@ -83,7 +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+ presented := make(chan ports.OutputPresented, 64)
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 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}
88 c, err := core.New(opts.Config, ch)88 c, err := core.New(opts.Config, ch)
89 if err != nil {89 if err != nil {
@@ -208,12 +208,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
208 }208 }
209 if hw != nil {209 if hw != nil {
210 done <- safe("output", func() error {210 done <- safe("output", func() error {
211- want := func() drm.Want { return wantFromConfig(opts.Config.Outputs) }211+ want := func() drm.Want { return wantFromConfig(opts.Config.Outputs, opts.Config.Render.DirectScanout) }
212 return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, presented, curs, newRenderer, logging.For(ctx, "drm"))212 return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, presented, curs, newRenderer, logging.For(ctx, "drm"))
213 })213 })
214 return214 return
215 }215 }
216- done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, curs, newRenderer, logging.For(ctx, "render"))216+ done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, presented, curs, newRenderer, logging.For(ctx, "render"))
217 }()217 }()
218 218
219 if hw != nil {219 if hw != nil {
M internal/app/drm.go +4 -4
@@ -42,14 +42,14 @@ func safe(name string, fn func() error) (err error) {
4242
4343 // openDRM opens the seat and every card with a connected display. Outputs
4444 // are found by the first Scan of each card.
45-func openDRM(ctx context.Context, outputs []ports.OutputConfig) (*drmBackend, error) {
45+func openDRM(ctx context.Context, outputs []ports.OutputConfig, scanout bool) (*drmBackend, error) {
4646 log := logging.For(ctx, "drm")
4747 s, err := seat.Open(ctx, logging.For(ctx, "seat"))
4848 if err != nil {
4949 log.Error().Err(err).Msg("open seat")
5050 return nil, fmt.Errorf("open seat: %w", err)
5151 }
52- want := wantFromConfig(outputs)
52+ want := wantFromConfig(outputs, scanout)
5353 paths, _ := filepath.Glob("/dev/dri/card[0-9]*")
5454 sort.Strings(paths)
5555 b := &drmBackend{seat: s}
@@ -79,8 +79,8 @@ func openDRM(ctx context.Context, outputs []ports.OutputConfig) (*drmBackend, er
7979 }
8080
8181 // wantFromConfig turns output.<name> entries into connector choices.
82-func wantFromConfig(outputs []ports.OutputConfig) drm.Want {
83- want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}}
82+func wantFromConfig(outputs []ports.OutputConfig, scanout bool) drm.Want {
83+ want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}, NoScanout: !scanout}
8484 for _, o := range outputs {
8585 if o.Off {
8686 want.Disabled[o.Name] = true
@@ -42,14 +42,14 @@ func safe(name string, fn func() error) (err error) {
42 42
43 // openDRM opens the seat and every card with a connected display. Outputs43 // openDRM opens the seat and every card with a connected display. Outputs
44 // are found by the first Scan of each card.44 // are found by the first Scan of each card.
45-func openDRM(ctx context.Context, outputs []ports.OutputConfig) (*drmBackend, error) {45+func openDRM(ctx context.Context, outputs []ports.OutputConfig, scanout bool) (*drmBackend, error) {
46 log := logging.For(ctx, "drm")46 log := logging.For(ctx, "drm")
47 s, err := seat.Open(ctx, logging.For(ctx, "seat"))47 s, err := seat.Open(ctx, logging.For(ctx, "seat"))
48 if err != nil {48 if err != nil {
49 log.Error().Err(err).Msg("open seat")49 log.Error().Err(err).Msg("open seat")
50 return nil, fmt.Errorf("open seat: %w", err)50 return nil, fmt.Errorf("open seat: %w", err)
51 }51 }
52- want := wantFromConfig(outputs)52+ want := wantFromConfig(outputs, scanout)
53 paths, _ := filepath.Glob("/dev/dri/card[0-9]*")53 paths, _ := filepath.Glob("/dev/dri/card[0-9]*")
54 sort.Strings(paths)54 sort.Strings(paths)
55 b := &drmBackend{seat: s}55 b := &drmBackend{seat: s}
@@ -79,8 +79,8 @@ func openDRM(ctx context.Context, outputs []ports.OutputConfig) (*drmBackend, er
79 }79 }
80 80
81 // wantFromConfig turns output.<name> entries into connector choices.81 // wantFromConfig turns output.<name> entries into connector choices.
82-func wantFromConfig(outputs []ports.OutputConfig) drm.Want {82+func wantFromConfig(outputs []ports.OutputConfig, scanout bool) drm.Want {
83- want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}}83+ want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}, NoScanout: !scanout}
84 for _, o := range outputs {84 for _, o := range outputs {
85 if o.Off {85 if o.Off {
86 want.Disabled[o.Name] = true86 want.Disabled[o.Name] = true
M internal/app/drm_test.go +1 -1
@@ -7,7 +7,7 @@ import (
77 )
88
99 func TestWantFromConfig(t *testing.T) {
10- want := wantFromConfig([]ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true}, {Name: "HDMI-A-1", Mode: "1920x1080@60"}, {Name: "DP-3", Off: true}})
10+ want := wantFromConfig([]ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true}, {Name: "HDMI-A-1", Mode: "1920x1080@60"}, {Name: "DP-3", Off: true}}, true)
1111 if m := want.Modes["HDMI-A-1"]; m != [3]float64{1920, 1080, 60} || !want.Disabled["DP-3"] || len(want.Modes) != 1 {
1212 t.Fatalf("%+v", want)
1313 }
@@ -7,7 +7,7 @@ import (
7 )7 )
8 8
9 func TestWantFromConfig(t *testing.T) {9 func TestWantFromConfig(t *testing.T) {
10- want := wantFromConfig([]ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true}, {Name: "HDMI-A-1", Mode: "1920x1080@60"}, {Name: "DP-3", Off: true}})10+ want := wantFromConfig([]ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true}, {Name: "HDMI-A-1", Mode: "1920x1080@60"}, {Name: "DP-3", Off: true}}, true)
11 if m := want.Modes["HDMI-A-1"]; m != [3]float64{1920, 1080, 60} || !want.Disabled["DP-3"] || len(want.Modes) != 1 {11 if m := want.Modes["HDMI-A-1"]; m != [3]float64{1920, 1080, 60} || !want.Disabled["DP-3"] || len(want.Modes) != 1 {
12 t.Fatalf("%+v", want)12 t.Fatalf("%+v", want)
13 }13 }
M internal/app/headless.go +2 -2
@@ -13,7 +13,7 @@ import (
1313
1414 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...
1515 // With several outputs, screenshots go to a subdirectory per output.
16-func runHeadless(ctx context.Context, sizes [][2]int, shots string, 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 {
16+func runHeadless(ctx context.Context, sizes [][2]int, shots string, 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 {
1717 set := newOutputSet()
1818 for i, size := range sizes {
1919 name := fmt.Sprintf("HEADLESS-%d", i+1)
@@ -26,7 +26,7 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<
2626 }
2727 cur := &headless.Cursor{}
2828 curs.set(name, cur)
29- opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, Log: log, NewRenderer: newRenderer}
29+ opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, Log: log, NewRenderer: newRenderer, Name: name, Presented: presented}
3030 set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange) error {
3131 return headless.Run(octx, opts, sc, cc, cu)
3232 })
@@ -13,7 +13,7 @@ import (
13 13
14 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...14 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...
15 // With several outputs, screenshots go to a subdirectory per output.15 // With several outputs, screenshots go to a subdirectory per output.
16-func runHeadless(ctx context.Context, sizes [][2]int, shots string, 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 {16+func runHeadless(ctx context.Context, sizes [][2]int, shots string, 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 {
17 set := newOutputSet()17 set := newOutputSet()
18 for i, size := range sizes {18 for i, size := range sizes {
19 name := fmt.Sprintf("HEADLESS-%d", i+1)19 name := fmt.Sprintf("HEADLESS-%d", i+1)
@@ -26,7 +26,7 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<
26 }26 }
27 cur := &headless.Cursor{}27 cur := &headless.Cursor{}
28 curs.set(name, cur)28 curs.set(name, cur)
29- opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, Log: log, NewRenderer: newRenderer}29+ opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, Log: log, NewRenderer: newRenderer, Name: name, Presented: presented}
30 set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange) error {30 set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange) error {
31 return headless.Run(octx, opts, sc, cc, cu)31 return headless.Run(octx, opts, sc, cc, cu)
32 })32 })
M internal/ports/messages.go +16 -4
@@ -278,9 +278,19 @@ 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 }
281+// OutputPresented carries output → wayland what an output shows and reads.
282+// Flip is set when a page flip completed: frame callbacks of the surfaces
283+// on it are due. Shown and Queued are the DMABuf IDs scanned out directly
284+// (0: a composed image); Seen is the latest content Seq the output got per
285+// window. A replaced client buffer is released once every output that
286+// reports has seen a later content of its window and neither shows nor
287+// queues it.
288+type OutputPresented struct {
289+ Output string
290+ Flip bool
291+ Shown, Queued uint64
292+ Seen map[WindowID]uint64
293+}
284294
285295 // OutputRemoved carries output → core an unplugged display.
286296 type OutputRemoved struct{ Name string }
@@ -520,7 +530,9 @@ type SceneLayer struct {
520530 // Geometry is the part of the surface that is the window (xdg window
521531 // geometry), the rest being client shadows.
522532 type SurfaceContent struct {
523- ID WindowID
533+ ID WindowID
534+ // Seq counts the window's contents (wayland sets it), for release.
535+ Seq uint64
524536 Width, Height int
525537 LogicalW, LogicalH int
526538 Opaque bool // x formats: ignore the alpha byte
@@ -278,9 +278,19 @@ 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 screen281+// OutputPresented carries output → wayland what an output shows and reads.
282-// (a page flip completed): frame callbacks of the surfaces on it are due.282+// Flip is set when a page flip completed: frame callbacks of the surfaces
283-type OutputPresented struct{ Output string }283+// on it are due. Shown and Queued are the DMABuf IDs scanned out directly
284+// (0: a composed image); Seen is the latest content Seq the output got per
285+// window. A replaced client buffer is released once every output that
286+// reports has seen a later content of its window and neither shows nor
287+// queues it.
288+type OutputPresented struct {
289+ Output string
290+ Flip bool
291+ Shown, Queued uint64
292+ Seen map[WindowID]uint64
293+}
284 294
285 // OutputRemoved carries output → core an unplugged display.295 // OutputRemoved carries output → core an unplugged display.
286 type OutputRemoved struct{ Name string }296 type OutputRemoved struct{ Name string }
@@ -520,7 +530,9 @@ type SceneLayer struct {
520 // Geometry is the part of the surface that is the window (xdg window530 // Geometry is the part of the surface that is the window (xdg window
521 // geometry), the rest being client shadows.531 // geometry), the rest being client shadows.
522 type SurfaceContent struct {532 type SurfaceContent struct {
523- ID WindowID533+ ID WindowID
534+ // Seq counts the window's contents (wayland sets it), for release.
535+ Seq uint64
524 Width, Height int536 Width, Height int
525 LogicalW, LogicalH int537 LogicalW, LogicalH int
526 Opaque bool // x formats: ignore the alpha byte538 Opaque bool // x formats: ignore the alpha byte

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