detached 34cdd04

fix(drm): release client buffers on output reports, share GEM handles per card

commit
34cdd04391b48d29cae792a80be16e455207526f
author
brice · · 2026-09-25 16:49 UTC
parent
4472471
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +267 -167
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/output.go +36 -16
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+ "maps"
78 "slices"
89 "time"
910
@@ -31,9 +32,9 @@ type Output struct {
3132 log zerowrap.Logger
3233 // Direct scanout: client framebuffers by DMABuf ID, GEM handle counts,
3334 // and the buffer on screen and queued (0: the composed image).
35+ card *Card
3436 scanout bool
3537 clientFBs map[uint64]*clientFB
36- handles map[uint32]int
3738 shown, queued uint64
3839 reason string // why the last frame was composed ("" = scanout)
3940 }
@@ -45,7 +46,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.Cu
4546 // newOutput allocates buffers and the cursor for a connector on crtc.
4647 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
4748 log := card.log
48- o := &Output{fd: card.fd, flipped: card.flips[crtc], crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, handles: map[uint32]int{}, reason: "start"}
49+ 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"}
4950 var err error
5051 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
5152 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
@@ -115,13 +116,7 @@ func (o *Output) tryScanout(scene ports.Scene, surfaces map[ports.WindowID]ports
115116 }
116117 var fb uint32
117118 if reason == "" {
118- var err error
119- if fb, err = o.scanoutFB(c.DMABuf); err != nil {
120- if !errors.Is(err, errScanoutRefused) {
121- o.log.Info().Err(err).Uint32("format", c.DMABuf.Format).Uint64("modifier", c.DMABuf.Modifier).Msg("scanout import failed")
122- }
123- reason = "import_failed"
124- }
119+ fb, reason = o.scanoutFB(c.DMABuf, time.Now())
125120 }
126121 if reason != o.reason {
127122 o.log.Info().Bool("direct_scanout", reason == "").Str("reason", reason).Str("connector", o.conn.name).Msg("scanout")
@@ -138,8 +133,9 @@ func (o *Output) tryScanout(scene ports.Scene, surfaces map[ports.WindowID]ports
138133 if errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE) {
139134 // KMS refuses this buffer on the plane: compose it instead.
140135 o.log.Info().Err(err).Uint32("format", c.DMABuf.Format).Uint64("modifier", c.DMABuf.Modifier).Msg("scanout flip refused")
141- o.clientFBs[c.DMABuf.ID].failed = true
136+ o.clientFBs[c.DMABuf.ID].failed = "flip_refused"
142137 o.reason = "flip_refused"
138+ o.log.Info().Bool("direct_scanout", false).Str("reason", o.reason).Str("connector", o.conn.name).Msg("scanout")
143139 return false, nil
144140 }
145141 return true, err
@@ -168,7 +164,7 @@ func (o *Output) Close() {
168164 }
169165 }
170166 o.shown, o.queued = 0, 0
171- o.dropClientFBs(true)
167+ o.dropClientFBs(time.Now(), true)
172168 }
173169
174170 // Run renders scenes and flips until ctx ends. active reports seat enable/disable.
@@ -199,7 +195,16 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
199195 frame := 0
200196 stats := time.NewTicker(10 * time.Second)
201197 defer stats.Stop()
198+ // seen is the latest content Seq per window, reported to wayland with
199+ // what the output scans out, after each frame decision.
200+ seen := map[ports.WindowID]uint64{}
201+ reportDirty := false
202+ flipped := false
202203 for {
204+ if flipped || reportDirty {
205+ o.report(presented, flipped, seen)
206+ flipped, reportDirty = false, false
207+ }
203208 select {
204209 case <-ctx.Done():
205210 return nil
@@ -226,11 +231,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
226231 o.pending = false
227232 o.flips += n
228233 o.shown = o.queued
229- o.dropClientFBs(false)
230- select {
231- case presented <- ports.OutputPresented{Output: o.conn.name, Scanout: o.shown}:
232- default:
233- }
234+ o.queued = 0
235+ flipped = true
234236 if d := time.Since(o.flipStart); d > 20*time.Millisecond {
235237 o.log.Info().Dur("flip_ms", d).Msg("slow flip")
236238 }
@@ -253,9 +255,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
253255 } else {
254256 surfaces[c.ID] = c
255257 }
258+ if c.Seq > seen[c.ID] {
259+ seen[c.ID] = c.Seq
260+ reportDirty = true
261+ }
256262 // Windows on other outputs or workspaces do not need a frame.
257263 dirty = dirty || scene.Shows(c.ID)
258264 case <-stats.C:
265+ o.dropClientFBs(time.Now(), false)
259266 ev := o.log.Info().Int("frames", frame).Int("flips", o.flips)
260267 if o.cursor != nil {
261268 cs := o.cursor.TakeStats()
@@ -291,6 +298,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
291298 return fmt.Errorf("page flip: %w", err)
292299 }
293300 dirty = false
301+ reportDirty = true
294302 frame++
295303 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")
296304 }
@@ -308,3 +316,15 @@ func (o *Output) setCursor(load CursorLoader, c ports.CursorChange, scale float6
308316 }
309317 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")
310318 }
319+
320+// report tells wayland what the output shows and has read, so replaced
321+// client buffers can be released. It must follow the frame decision: a
322+// buffer is safe once a later content is seen and it is neither shown nor
323+// queued. A full channel drops the report; the next one carries the state.
324+func (o *Output) report(presented chan<- ports.OutputPresented, flip bool, seen map[ports.WindowID]uint64) {
325+ r := ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: maps.Clone(seen)}
326+ select {
327+ case presented <- r:
328+ default:
329+ }
330+}
@@ -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
@@ -31,9 +32,9 @@ type Output struct {
31 log zerowrap.Logger32 log zerowrap.Logger
32 // Direct scanout: client framebuffers by DMABuf ID, GEM handle counts,33 // Direct scanout: client framebuffers by DMABuf ID, GEM handle counts,
33 // and the buffer on screen and queued (0: the composed image).34 // and the buffer on screen and queued (0: the composed image).
35+ card *Card
34 scanout bool36 scanout bool
35 clientFBs map[uint64]*clientFB37 clientFBs map[uint64]*clientFB
36- handles map[uint32]int
37 shown, queued uint6438 shown, queued uint64
38 reason string // why the last frame was composed ("" = scanout)39 reason string // why the last frame was composed ("" = scanout)
39 }40 }
@@ -45,7 +46,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.Cu
45 // newOutput allocates buffers and the cursor for a connector on crtc.46 // newOutput allocates buffers and the cursor for a connector on crtc.
46 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {47 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
47 log := card.log48 log := card.log
48- o := &Output{fd: card.fd, flipped: card.flips[crtc], crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, handles: map[uint32]int{}, reason: "start"}49+ 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"}
49 var err error50 var err error
50 if o.saved, err = getCrtc(card.fd, crtc); err != nil {51 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
51 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")52 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
@@ -115,13 +116,7 @@ func (o *Output) tryScanout(scene ports.Scene, surfaces map[ports.WindowID]ports
115 }116 }
116 var fb uint32117 var fb uint32
117 if reason == "" {118 if reason == "" {
118- var err error119+ fb, reason = o.scanoutFB(c.DMABuf, time.Now())
119- if fb, err = o.scanoutFB(c.DMABuf); err != nil {
120- if !errors.Is(err, errScanoutRefused) {
121- o.log.Info().Err(err).Uint32("format", c.DMABuf.Format).Uint64("modifier", c.DMABuf.Modifier).Msg("scanout import failed")
122- }
123- reason = "import_failed"
124- }
125 }120 }
126 if reason != o.reason {121 if reason != o.reason {
127 o.log.Info().Bool("direct_scanout", reason == "").Str("reason", reason).Str("connector", o.conn.name).Msg("scanout")122 o.log.Info().Bool("direct_scanout", reason == "").Str("reason", reason).Str("connector", o.conn.name).Msg("scanout")
@@ -138,8 +133,9 @@ func (o *Output) tryScanout(scene ports.Scene, surfaces map[ports.WindowID]ports
138 if errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE) {133 if errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE) {
139 // KMS refuses this buffer on the plane: compose it instead.134 // KMS refuses this buffer on the plane: compose it instead.
140 o.log.Info().Err(err).Uint32("format", c.DMABuf.Format).Uint64("modifier", c.DMABuf.Modifier).Msg("scanout flip refused")135 o.log.Info().Err(err).Uint32("format", c.DMABuf.Format).Uint64("modifier", c.DMABuf.Modifier).Msg("scanout flip refused")
141- o.clientFBs[c.DMABuf.ID].failed = true136+ o.clientFBs[c.DMABuf.ID].failed = "flip_refused"
142 o.reason = "flip_refused"137 o.reason = "flip_refused"
138+ o.log.Info().Bool("direct_scanout", false).Str("reason", o.reason).Str("connector", o.conn.name).Msg("scanout")
143 return false, nil139 return false, nil
144 }140 }
145 return true, err141 return true, err
@@ -168,7 +164,7 @@ func (o *Output) Close() {
168 }164 }
169 }165 }
170 o.shown, o.queued = 0, 0166 o.shown, o.queued = 0, 0
171- o.dropClientFBs(true)167+ o.dropClientFBs(time.Now(), true)
172 }168 }
173 169
174 // Run renders scenes and flips until ctx ends. active reports seat enable/disable.170 // Run renders scenes and flips until ctx ends. active reports seat enable/disable.
@@ -199,7 +195,16 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
199 frame := 0195 frame := 0
200 stats := time.NewTicker(10 * time.Second)196 stats := time.NewTicker(10 * time.Second)
201 defer stats.Stop()197 defer stats.Stop()
198+ // seen is the latest content Seq per window, reported to wayland with
199+ // what the output scans out, after each frame decision.
200+ seen := map[ports.WindowID]uint64{}
201+ reportDirty := false
202+ flipped := false
202 for {203 for {
204+ if flipped || reportDirty {
205+ o.report(presented, flipped, seen)
206+ flipped, reportDirty = false, false
207+ }
203 select {208 select {
204 case <-ctx.Done():209 case <-ctx.Done():
205 return nil210 return nil
@@ -226,11 +231,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
226 o.pending = false231 o.pending = false
227 o.flips += n232 o.flips += n
228 o.shown = o.queued233 o.shown = o.queued
229- o.dropClientFBs(false)234+ o.queued = 0
230- select {235+ flipped = true
231- case presented <- ports.OutputPresented{Output: o.conn.name, Scanout: o.shown}:
232- default:
233- }
234 if d := time.Since(o.flipStart); d > 20*time.Millisecond {236 if d := time.Since(o.flipStart); d > 20*time.Millisecond {
235 o.log.Info().Dur("flip_ms", d).Msg("slow flip")237 o.log.Info().Dur("flip_ms", d).Msg("slow flip")
236 }238 }
@@ -253,9 +255,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
253 } else {255 } else {
254 surfaces[c.ID] = c256 surfaces[c.ID] = c
255 }257 }
258+ if c.Seq > seen[c.ID] {
259+ seen[c.ID] = c.Seq
260+ reportDirty = true
261+ }
256 // Windows on other outputs or workspaces do not need a frame.262 // Windows on other outputs or workspaces do not need a frame.
257 dirty = dirty || scene.Shows(c.ID)263 dirty = dirty || scene.Shows(c.ID)
258 case <-stats.C:264 case <-stats.C:
265+ o.dropClientFBs(time.Now(), false)
259 ev := o.log.Info().Int("frames", frame).Int("flips", o.flips)266 ev := o.log.Info().Int("frames", frame).Int("flips", o.flips)
260 if o.cursor != nil {267 if o.cursor != nil {
261 cs := o.cursor.TakeStats()268 cs := o.cursor.TakeStats()
@@ -291,6 +298,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
291 return fmt.Errorf("page flip: %w", err)298 return fmt.Errorf("page flip: %w", err)
292 }299 }
293 dirty = false300 dirty = false
301+ reportDirty = true
294 frame++302 frame++
295 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")303 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")
296 }304 }
@@ -308,3 +316,15 @@ func (o *Output) setCursor(load CursorLoader, c ports.CursorChange, scale float6
308 }316 }
309 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")317 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")
310 }318 }
319+
320+// report tells wayland what the output shows and has read, so replaced
321+// client buffers can be released. It must follow the frame decision: a
322+// buffer is safe once a later content is seen and it is neither shown nor
323+// queued. A full channel drops the report; the next one carries the state.
324+func (o *Output) report(presented chan<- ports.OutputPresented, flip bool, seen map[ports.WindowID]uint64) {
325+ r := ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: maps.Clone(seen)}
326+ select {
327+ case presented <- r:
328+ default:
329+ }
330+}
M internal/adapters/drm/scanout.go +73 -60
@@ -2,7 +2,7 @@ package drm
22
33 import (
44 "errors"
5- "os"
5+ "time"
66 "unsafe"
77
88 "github.com/bnema/nefertty/internal/ports"
@@ -21,9 +21,10 @@ const (
2121 ioctlPrimeFDToHandle = 0xC00C642E
2222 ioctlAddFB2 = 0xC06864B8
2323
24- fbModifiers = 1 << 1
25- modInvalid = 0x00ffffffffffffff
26- scanoutIdleTTL = 240 // flips a cached framebuffer survives unused
24+ fbModifiers = 1 << 1
25+ capAddFB2Modifiers = 0x10
26+ modInvalid = 0x00ffffffffffffff
27+ scanoutIdleTTL = 5 * time.Second // a cached framebuffer survives unused
2728 )
2829
2930 type primeHandle struct {
@@ -72,6 +73,10 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
7273 return c, "multi_plane"
7374 case c.Width != w || c.Height != h:
7475 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"
7580 case c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0 || c.Geometry.W != c.LogicalW || c.Geometry.H != c.LogicalH):
7681 return c, "geometry_crop"
7782 }
@@ -80,97 +85,105 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
8085
8186 // clientFB is a client buffer imported as a KMS framebuffer.
8287 type clientFB struct {
83- fbID, handle uint32
84- last int // flip count when last shown
85- failed bool
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
8692 }
8793
8894 // scanoutFB returns the framebuffer of a client buffer, importing it on
89-// first use. A buffer KMS refuses is remembered and never retried.
90-func (o *Output) scanoutFB(b *ports.DMABuf) (uint32, error) {
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) {
9197 if fb := o.clientFBs[b.ID]; fb != nil {
92- fb.last = o.flips
93- if fb.failed {
94- return 0, errScanoutRefused
95- }
96- return fb.fbID, nil
98+ fb.last = now
99+ return fb.fbID, fb.failed
97100 }
98- fb := &clientFB{last: o.flips}
101+ fb := &clientFB{last: now}
99102 o.clientFBs[b.ID] = fb
100- handle, err := o.importHandle(b.Planes[0].File)
103+ id, err := o.card.addClientFB(b)
101104 if err != nil {
102- fb.failed = true
103- return 0, errors.Join(errors.New("prime import"), err)
104- }
105- cmd := fbCmd2{width: uint32(b.Width), height: uint32(b.Height), format: b.Format}
106- cmd.handles[0], cmd.pitches[0], cmd.offsets[0] = handle, b.Planes[0].Stride, b.Planes[0].Offset
107- if b.Modifier != modInvalid {
108- cmd.flags, cmd.modifiers[0] = fbModifiers, b.Modifier
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
109108 }
110- if err := ioctl(o.fd, ioctlAddFB2, unsafe.Pointer(&cmd)); err != nil {
111- o.closeHandle(handle)
112- fb.failed = true
113- return 0, errors.Join(errors.New("add fb2"), err)
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
114121 }
115- fb.fbID, fb.handle = cmd.fbID, handle
116- return fb.fbID, nil
122+ return 0, false
117123 }
118124
119-var errScanoutRefused = errors.New("buffer refused for scanout")
125+const (
126+ fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24
127+ fourccARGB = 'A' | 'R'<<8 | '2'<<16 | '4'<<24
128+)
120129
121-// importHandle turns a dmabuf into a GEM handle on the card. The kernel
122-// returns the same handle for the same buffer object: handles are counted
123-// and closed with their last framebuffer.
124-func (o *Output) importHandle(f *os.File) (uint32, error) {
125- raw, err := f.SyscallConn()
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()
126140 if err != nil {
127141 return 0, err
128142 }
143+ c.gemMu.Lock()
144+ defer c.gemMu.Unlock()
129145 var p primeHandle
130146 var ioErr error
131147 if err := raw.Control(func(fd uintptr) {
132148 p.fd = int32(fd)
133- ioErr = ioctl(o.fd, ioctlPrimeFDToHandle, unsafe.Pointer(&p))
149+ ioErr = ioctl(c.fd, ioctlPrimeFDToHandle, unsafe.Pointer(&p))
134150 }); err != nil {
135151 return 0, err
136152 }
137153 if ioErr != nil {
138- return 0, ioErr
154+ return 0, errors.Join(errors.New("prime import"), ioErr)
139155 }
140- o.handles[p.handle]++
141- return p.handle, nil
142-}
143-
144-func (o *Output) closeHandle(handle uint32) {
145- if o.handles[handle]--; o.handles[handle] > 0 {
146- return
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)
147167 }
148- delete(o.handles, handle)
149- c := gemClose{handle: handle}
150- _ = ioctl(o.fd, ioctlGemClose, unsafe.Pointer(&c))
168+ return cmd.fbID, nil
151169 }
152170
153-// dropClientFBs frees framebuffers unused for scanoutIdleTTL flips, or all
171+// dropClientFBs frees framebuffers not wanted for scanoutIdleTTL, or all
154172 // of them, except those on screen or queued.
155-func (o *Output) dropClientFBs(all bool) {
173+func (o *Output) dropClientFBs(now time.Time, all bool) {
156174 for id, fb := range o.clientFBs {
157175 if id == o.shown || id == o.queued {
158176 continue
159177 }
160- if !all && o.flips-fb.last < scanoutIdleTTL {
178+ if !all && now.Sub(fb.last) < scanoutIdleTTL {
161179 continue
162180 }
163- o.freeFB(fb)
181+ if fb.fbID != 0 {
182+ v := fb.fbID
183+ _ = ioctl(o.fd, ioctlRmFB, unsafe.Pointer(&v))
184+ }
164185 delete(o.clientFBs, id)
165186 }
166187 }
167188
168-func (o *Output) freeFB(fb *clientFB) {
169- if fb.fbID != 0 {
170- id := fb.fbID
171- _ = ioctl(o.fd, ioctlRmFB, unsafe.Pointer(&id))
172- }
173- if fb.handle != 0 {
174- o.closeHandle(fb.handle)
175- }
176-}
189+func scanoutable(f uint32) bool { _, ok := scanoutFormat(f); return ok }
@@ -2,7 +2,7 @@ package drm
2 2
3 import (3 import (
4 "errors"4 "errors"
5- "os"5+ "time"
6 "unsafe"6 "unsafe"
7 7
8 "github.com/bnema/nefertty/internal/ports"8 "github.com/bnema/nefertty/internal/ports"
@@ -21,9 +21,10 @@ const (
21 ioctlPrimeFDToHandle = 0xC00C642E21 ioctlPrimeFDToHandle = 0xC00C642E
22 ioctlAddFB2 = 0xC06864B822 ioctlAddFB2 = 0xC06864B8
23 23
24- fbModifiers = 1 << 124+ fbModifiers = 1 << 1
25- modInvalid = 0x00ffffffffffffff25+ capAddFB2Modifiers = 0x10
26- scanoutIdleTTL = 240 // flips a cached framebuffer survives unused26+ modInvalid = 0x00ffffffffffffff
27+ scanoutIdleTTL = 5 * time.Second // a cached framebuffer survives unused
27 )28 )
28 29
29 type primeHandle struct {30 type primeHandle struct {
@@ -72,6 +73,10 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
72 return c, "multi_plane"73 return c, "multi_plane"
73 case c.Width != w || c.Height != h:74 case c.Width != w || c.Height != h:
74 return c, "size_mismatch"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"
75 case c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0 || c.Geometry.W != c.LogicalW || c.Geometry.H != c.LogicalH):80 case c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0 || c.Geometry.W != c.LogicalW || c.Geometry.H != c.LogicalH):
76 return c, "geometry_crop"81 return c, "geometry_crop"
77 }82 }
@@ -80,97 +85,105 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
80 85
81 // clientFB is a client buffer imported as a KMS framebuffer.86 // clientFB is a client buffer imported as a KMS framebuffer.
82 type clientFB struct {87 type clientFB struct {
83- fbID, handle uint3288+ fbID uint32
84- last int // flip count when last shown89+ last time.Time // when last wanted on screen
85- failed bool90+ // failed is the reason KMS refused the buffer; it is not retried.
91+ failed string
86 }92 }
87 93
88 // scanoutFB returns the framebuffer of a client buffer, importing it on94 // scanoutFB returns the framebuffer of a client buffer, importing it on
89-// first use. A buffer KMS refuses is remembered and never retried.95+// first use. A buffer KMS refuses is remembered with its reason.
90-func (o *Output) scanoutFB(b *ports.DMABuf) (uint32, error) {96+func (o *Output) scanoutFB(b *ports.DMABuf, now time.Time) (uint32, string) {
91 if fb := o.clientFBs[b.ID]; fb != nil {97 if fb := o.clientFBs[b.ID]; fb != nil {
92- fb.last = o.flips98+ fb.last = now
93- if fb.failed {99+ return fb.fbID, fb.failed
94- return 0, errScanoutRefused
95- }
96- return fb.fbID, nil
97 }100 }
98- fb := &clientFB{last: o.flips}101+ fb := &clientFB{last: now}
99 o.clientFBs[b.ID] = fb102 o.clientFBs[b.ID] = fb
100- handle, err := o.importHandle(b.Planes[0].File)103+ id, err := o.card.addClientFB(b)
101 if err != nil {104 if err != nil {
102- fb.failed = true105+ o.log.Info().Err(err).Uint32("format", b.Format).Uint64("modifier", b.Modifier).Msg("scanout import failed")
103- return 0, errors.Join(errors.New("prime import"), err)106+ fb.failed = "import_failed"
104- }107+ return 0, fb.failed
105- cmd := fbCmd2{width: uint32(b.Width), height: uint32(b.Height), format: b.Format}
106- cmd.handles[0], cmd.pitches[0], cmd.offsets[0] = handle, b.Planes[0].Stride, b.Planes[0].Offset
107- if b.Modifier != modInvalid {
108- cmd.flags, cmd.modifiers[0] = fbModifiers, b.Modifier
109 }108 }
110- if err := ioctl(o.fd, ioctlAddFB2, unsafe.Pointer(&cmd)); err != nil {109+ fb.fbID = id
111- o.closeHandle(handle)110+ return id, ""
112- fb.failed = true111+}
113- return 0, errors.Join(errors.New("add fb2"), err)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
114 }121 }
115- fb.fbID, fb.handle = cmd.fbID, handle122+ return 0, false
116- return fb.fbID, nil
117 }123 }
118 124
119-var errScanoutRefused = errors.New("buffer refused for scanout")125+const (
126+ fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24
127+ fourccARGB = 'A' | 'R'<<8 | '2'<<16 | '4'<<24
128+)
120 129
121-// importHandle turns a dmabuf into a GEM handle on the card. The kernel130+// addClientFB imports a dmabuf as a framebuffer. The GEM handle belongs to
122-// returns the same handle for the same buffer object: handles are counted131+// the card fd and is shared by every import of the same buffer, so import,
123-// and closed with their last framebuffer.132+// ADDFB2 and close run under the card lock and the handle is closed right
124-func (o *Output) importHandle(f *os.File) (uint32, error) {133+// away: the framebuffer keeps its own reference to the buffer.
125- raw, err := f.SyscallConn()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()
126 if err != nil {140 if err != nil {
127 return 0, err141 return 0, err
128 }142 }
143+ c.gemMu.Lock()
144+ defer c.gemMu.Unlock()
129 var p primeHandle145 var p primeHandle
130 var ioErr error146 var ioErr error
131 if err := raw.Control(func(fd uintptr) {147 if err := raw.Control(func(fd uintptr) {
132 p.fd = int32(fd)148 p.fd = int32(fd)
133- ioErr = ioctl(o.fd, ioctlPrimeFDToHandle, unsafe.Pointer(&p))149+ ioErr = ioctl(c.fd, ioctlPrimeFDToHandle, unsafe.Pointer(&p))
134 }); err != nil {150 }); err != nil {
135 return 0, err151 return 0, err
136 }152 }
137 if ioErr != nil {153 if ioErr != nil {
138- return 0, ioErr154+ return 0, errors.Join(errors.New("prime import"), ioErr)
139 }155 }
140- o.handles[p.handle]++156+ defer func() {
141- return p.handle, nil157+ g := gemClose{handle: p.handle}
142-}158+ _ = ioctl(c.fd, ioctlGemClose, unsafe.Pointer(&g))
143-159+ }()
144-func (o *Output) closeHandle(handle uint32) {160+ cmd := fbCmd2{width: uint32(b.Width), height: uint32(b.Height), format: format}
145- if o.handles[handle]--; o.handles[handle] > 0 {161+ cmd.handles[0], cmd.pitches[0], cmd.offsets[0] = p.handle, b.Planes[0].Stride, b.Planes[0].Offset
146- return162+ 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)
147 }167 }
148- delete(o.handles, handle)168+ return cmd.fbID, nil
149- c := gemClose{handle: handle}
150- _ = ioctl(o.fd, ioctlGemClose, unsafe.Pointer(&c))
151 }169 }
152 170
153-// dropClientFBs frees framebuffers unused for scanoutIdleTTL flips, or all171+// dropClientFBs frees framebuffers not wanted for scanoutIdleTTL, or all
154 // of them, except those on screen or queued.172 // of them, except those on screen or queued.
155-func (o *Output) dropClientFBs(all bool) {173+func (o *Output) dropClientFBs(now time.Time, all bool) {
156 for id, fb := range o.clientFBs {174 for id, fb := range o.clientFBs {
157 if id == o.shown || id == o.queued {175 if id == o.shown || id == o.queued {
158 continue176 continue
159 }177 }
160- if !all && o.flips-fb.last < scanoutIdleTTL {178+ if !all && now.Sub(fb.last) < scanoutIdleTTL {
161 continue179 continue
162 }180 }
163- o.freeFB(fb)181+ if fb.fbID != 0 {
182+ v := fb.fbID
183+ _ = ioctl(o.fd, ioctlRmFB, unsafe.Pointer(&v))
184+ }
164 delete(o.clientFBs, id)185 delete(o.clientFBs, id)
165 }186 }
166 }187 }
167 188
168-func (o *Output) freeFB(fb *clientFB) {189+func scanoutable(f uint32) bool { _, ok := scanoutFormat(f); return ok }
169- if fb.fbID != 0 {
170- id := fb.fbID
171- _ = ioctl(o.fd, ioctlRmFB, unsafe.Pointer(&id))
172- }
173- if fb.handle != 0 {
174- o.closeHandle(fb.handle)
175- }
176-}
M internal/adapters/drm/scanout_test.go +16 -1
@@ -9,7 +9,7 @@ import (
99 func TestScanoutCandidate(t *testing.T) {
1010 full := ports.SceneWindow{ID: 1, Rect: ports.Rect{W: 100, H: 50}, Fullscreen: true}
1111 scene := ports.Scene{OutputWidth: 100, OutputHeight: 50, Windows: []ports.SceneWindow{full}}
12- buf := &ports.DMABuf{ID: 9, Width: 200, Height: 100, Planes: []ports.DMABufPlane{{}}}
12+ buf := &ports.DMABuf{ID: 9, Width: 200, Height: 100, Format: fourccARGB, Planes: []ports.DMABufPlane{{}}}
1313 game := ports.SurfaceContent{ID: 1, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, DMABuf: buf}
1414 for _, tc := range []struct {
1515 name string
@@ -37,6 +37,12 @@ func TestScanoutCandidate(t *testing.T) {
3737 {"subsurface", nil, func(c *ports.SurfaceContent) { c.Children = []ports.Subsurface{{}} }, "subsurfaces"},
3838 {"scaled", nil, func(c *ports.SurfaceContent) { c.Width = 100 }, "size_mismatch"},
3939 {"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"},
4046 {"whole geometry", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{W: 100, H: 50} }, ""},
4147 } {
4248 s := scene
@@ -53,3 +59,12 @@ func TestScanoutCandidate(t *testing.T) {
5359 }
5460 }
5561 }
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+}
@@ -9,7 +9,7 @@ import (
9 func TestScanoutCandidate(t *testing.T) {9 func TestScanoutCandidate(t *testing.T) {
10 full := ports.SceneWindow{ID: 1, Rect: ports.Rect{W: 100, H: 50}, Fullscreen: true}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}}11 scene := ports.Scene{OutputWidth: 100, OutputHeight: 50, Windows: []ports.SceneWindow{full}}
12- buf := &ports.DMABuf{ID: 9, Width: 200, Height: 100, Planes: []ports.DMABufPlane{{}}}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}13 game := ports.SurfaceContent{ID: 1, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, DMABuf: buf}
14 for _, tc := range []struct {14 for _, tc := range []struct {
15 name string15 name string
@@ -37,6 +37,12 @@ func TestScanoutCandidate(t *testing.T) {
37 {"subsurface", nil, func(c *ports.SurfaceContent) { c.Children = []ports.Subsurface{{}} }, "subsurfaces"},37 {"subsurface", nil, func(c *ports.SurfaceContent) { c.Children = []ports.Subsurface{{}} }, "subsurfaces"},
38 {"scaled", nil, func(c *ports.SurfaceContent) { c.Width = 100 }, "size_mismatch"},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"},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"},
40 {"whole geometry", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{W: 100, H: 50} }, ""},46 {"whole geometry", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{W: 100, H: 50} }, ""},
41 } {47 } {
42 s := scene48 s := scene
@@ -53,3 +59,12 @@ func TestScanoutCandidate(t *testing.T) {
53 }59 }
54 }60 }
55 }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 +23 -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,9 @@ 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{}
3945 update := func(c ports.SurfaceContent) {
46+ seen[c.ID] = max(seen[c.ID], c.Seq)
4047 dirty = dirty || scene.Shows(c.ID)
4148 if c.Empty() {
4249 delete(surfaces, c.ID)
@@ -88,6 +95,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
8895 }
8996 }
9097 if !haveScene || !dirty {
98+ // Contents not drawn are still read: report them.
99+ opts.report(false, seen)
91100 continue
92101 }
93102 dirty = false
@@ -104,6 +113,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
104113 return fmt.Errorf("render frame: %w", err)
105114 }
106115 frame++
116+ opts.report(false, seen)
107117 if opts.ScreenshotDir != "" {
108118 shot := r.Pixels()
109119 if opts.Cursor != nil {
@@ -135,3 +145,16 @@ func writePNG(path string, img *image.RGBA) error {
135145 }
136146 return os.Rename(f.Name(), path)
137147 }
148+
149+// report sends what the output read; a full channel drops it.
150+// Headless frames are not paced by a screen: Flip stays false and frame
151+// callbacks follow the refresh timer.
152+func (opts Options) report(flip bool, seen map[ports.WindowID]uint64) {
153+ if opts.Presented == nil {
154+ return
155+ }
156+ select {
157+ case opts.Presented <- ports.OutputPresented{Output: opts.Name, Flip: flip, Seen: maps.Clone(seen)}:
158+ default:
159+ }
160+}
@@ -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,9 @@ 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{}
39 update := func(c ports.SurfaceContent) {45 update := func(c ports.SurfaceContent) {
46+ seen[c.ID] = max(seen[c.ID], c.Seq)
40 dirty = dirty || scene.Shows(c.ID)47 dirty = dirty || scene.Shows(c.ID)
41 if c.Empty() {48 if c.Empty() {
42 delete(surfaces, c.ID)49 delete(surfaces, c.ID)
@@ -88,6 +95,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
88 }95 }
89 }96 }
90 if !haveScene || !dirty {97 if !haveScene || !dirty {
98+ // Contents not drawn are still read: report them.
99+ opts.report(false, seen)
91 continue100 continue
92 }101 }
93 dirty = false102 dirty = false
@@ -104,6 +113,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
104 return fmt.Errorf("render frame: %w", err)113 return fmt.Errorf("render frame: %w", err)
105 }114 }
106 frame++115 frame++
116+ opts.report(false, seen)
107 if opts.ScreenshotDir != "" {117 if opts.ScreenshotDir != "" {
108 shot := r.Pixels()118 shot := r.Pixels()
109 if opts.Cursor != nil {119 if opts.Cursor != nil {
@@ -135,3 +145,16 @@ func writePNG(path string, img *image.RGBA) error {
135 }145 }
136 return os.Rename(f.Name(), path)146 return os.Rename(f.Name(), path)
137 }147 }
148+
149+// report sends what the output read; a full channel drops it.
150+// Headless frames are not paced by a screen: Flip stays false and frame
151+// callbacks follow the refresh timer.
152+func (opts Options) report(flip bool, seen map[ports.WindowID]uint64) {
153+ if opts.Presented == nil {
154+ return
155+ }
156+ select {
157+ case opts.Presented <- ports.OutputPresented{Output: opts.Name, Flip: flip, Seen: maps.Clone(seen)}:
158+ default:
159+ }
160+}
M internal/adapters/wayland/frames.go +8 -4
@@ -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,14 +27,15 @@ func (s *Server) pace(ctx context.Context) {
2527 presented := s.channels.Presented
2628 for {
2729 flipped := map[string]bool{}
28- scanned := map[string]uint64{}
30+ var reports []ports.OutputPresented
2931 select {
3032 case <-ctx.Done():
3133 return
3234 case <-s.display.Stopped():
3335 return
3436 case p := <-presented:
35- flipped[p.Output], scanned[p.Output] = true, p.Scanout
37+ flipped[p.Output] = flipped[p.Output] || p.Flip
38+ reports = append(reports, p)
3639 case <-s.frameReady:
3740 case <-timer.C:
3841 }
@@ -42,7 +45,8 @@ func (s *Server) pace(ctx context.Context) {
4245 for {
4346 select {
4447 case p := <-presented:
45- flipped[p.Output], scanned[p.Output] = true, p.Scanout
48+ flipped[p.Output] = flipped[p.Output] || p.Flip
49+ reports = append(reports, p)
4650 default:
4751 break drain
4852 }
@@ -55,7 +59,7 @@ func (s *Server) pace(ctx context.Context) {
5559 for _, name := range due {
5660 s.sendFrames(name)
5761 }
58- if s.releaseHeld(time.Now(), scanned) && idle {
62+ if s.releaseHeld(time.Now(), reports) && idle {
5963 // Held buffers wait for a flip or heldTimeout.
6064 wait, idle = heldTimeout, false
6165 }
@@ -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,14 +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{}
28- scanned := map[string]uint64{}30+ var reports []ports.OutputPresented
29 select {31 select {
30 case <-ctx.Done():32 case <-ctx.Done():
31 return33 return
32 case <-s.display.Stopped():34 case <-s.display.Stopped():
33 return35 return
34 case p := <-presented:36 case p := <-presented:
35- flipped[p.Output], scanned[p.Output] = true, p.Scanout37+ flipped[p.Output] = flipped[p.Output] || p.Flip
38+ reports = append(reports, p)
36 case <-s.frameReady:39 case <-s.frameReady:
37 case <-timer.C:40 case <-timer.C:
38 }41 }
@@ -42,7 +45,8 @@ func (s *Server) pace(ctx context.Context) {
42 for {45 for {
43 select {46 select {
44 case p := <-presented:47 case p := <-presented:
45- flipped[p.Output], scanned[p.Output] = true, p.Scanout48+ flipped[p.Output] = flipped[p.Output] || p.Flip
49+ reports = append(reports, p)
46 default:50 default:
47 break drain51 break drain
48 }52 }
@@ -55,7 +59,7 @@ func (s *Server) pace(ctx context.Context) {
55 for _, name := range due {59 for _, name := range due {
56 s.sendFrames(name)60 s.sendFrames(name)
57 }61 }
58- if s.releaseHeld(time.Now(), scanned) && idle {62+ if s.releaseHeld(time.Now(), reports) && idle {
59 // Held buffers wait for a flip or heldTimeout.63 // Held buffers wait for a flip or heldTimeout.
60 wait, idle = heldTimeout, false64 wait, idle = heldTimeout, false
61 }65 }
M internal/adapters/wayland/release.go +57 -56
@@ -3,98 +3,99 @@ package wayland
33 import (
44 "time"
55
6+ "github.com/bnema/nefertty/internal/ports"
67 "github.com/bnema/purego-libwayland/protocol/wayland"
78 )
89
9-// Buffer release. A replaced wl_buffer goes back to the client (release)
10-// when nothing reads it any more. Composed outputs copy buffers into their
11-// own image, so buffers are released at once. While an output scans out a
12-// client buffer directly (ADR 006), a replaced GPU buffer of a surface on
13-// it may be on screen or queued for a page flip: it is held until the
14-// output presents a frame that scans out another buffer, or heldTimeout
15-// passes without a flip. The first buffer of a switch to scanout is not
16-// held (known limitation: it may be released while its flip is queued).
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.
1719
18-// heldTimeout releases held buffers of an output that stopped flipping; a
19-// queued flip completes well within it.
20+// heldTimeout releases held buffers of an output that stopped reporting.
2021 const heldTimeout = 100 * time.Millisecond
2122
2223 type heldBuffer struct {
23- res *wayland.Buffer
24- id uint64
25- at time.Time
24+ res *wayland.Buffer
25+ window ports.WindowID
26+ output string
27+ id uint64 // DMABuf ID, 0 for wl_shm
28+ after uint64 // the window's content Seq when the buffer was replaced
29+ at time.Time
2630 }
2731
28-// releaseBuffer releases a replaced buffer, or holds a GPU buffer until
29-// its output flipped away from it.
32+// releaseBuffer releases a replaced buffer, or holds it until the output
33+// showing its window no longer reads it.
3034 func (s *Server) releaseBuffer(surf *surface, b *wayland.Buffer) {
3135 if !b.Resource.Alive() {
3236 return
3337 }
34- d, ok := s.buffers[b.Resource].(*dmabufBuffer)
35- name := s.frameOutput(surf)
36- // Only an output scanning out a client buffer reads it after the
37- // frame: composed outputs copied it already.
38- if !ok || name == "" || s.scanned[name] == 0 {
38+ window, name := surf.root().windowID(), s.frameOutput(surf)
39+ if window == 0 || name == "" {
40+ // Not drawn (unmapped, hidden, a cursor): nothing reads it.
3941 b.SendRelease()
4042 return
4143 }
42- s.held[name] = append(s.held[name], heldBuffer{res: b, id: d.buf.ID, at: time.Now()})
43- // Wake the pacer: it releases the buffer after the next flip.
44+ h := heldBuffer{res: b, window: window, output: name, at: time.Now()}
45+ if d, ok := s.buffers[b.Resource].(*dmabufBuffer); ok {
46+ h.id = d.buf.ID
47+ }
48+ s.contentMu.Lock()
49+ h.after = s.contentSeq[window]
50+ s.contentMu.Unlock()
51+ s.held = append(s.held, h)
52+ // Wake the pacer: it releases the buffer on the next output report.
4453 select {
4554 case s.frameReady <- struct{}{}:
4655 default:
4756 }
4857 }
4958
50-// releaseHeld records the buffers scanned out by the outputs that flipped
51-// and releases held buffers no output shows. It reports whether buffers
52-// are still held.
53-func (s *Server) releaseHeld(now time.Time, scanned map[string]uint64) bool {
54- for name, id := range scanned {
55- if id == 0 {
56- delete(s.scanned, name)
57- } else {
58- s.scanned[name] = id
59- }
59+// releaseHeld records the output reports and releases the buffers no
60+// output reads. It reports whether buffers are still held.
61+func (s *Server) releaseHeld(now time.Time, reports []ports.OutputPresented) bool {
62+ for _, r := range reports {
63+ s.reports[r.Output] = r
6064 }
6165 live := map[string]bool{}
6266 for _, o := range s.outputs {
6367 live[o.name()] = true
6468 }
65- for name := range s.scanned {
69+ for name := range s.reports {
6670 if !live[name] {
67- delete(s.scanned, name)
71+ delete(s.reports, name)
6872 }
6973 }
70- for name, list := range s.held {
71- _, flipped := scanned[name]
72- kept := list[:0]
73- for _, h := range list {
74- if keepHeld(h, s.scanned[name], live[name], flipped, now) {
75- kept = append(kept, h)
76- continue
77- }
78- if h.res.Resource.Alive() {
79- h.res.SendRelease()
80- }
74+ kept := s.held[:0]
75+ for _, h := range s.held {
76+ r, reported := s.reports[h.output]
77+ if live[h.output] && keepHeld(h, r, reported, now) {
78+ kept = append(kept, h)
79+ continue
8180 }
82- if len(kept) == 0 {
83- delete(s.held, name)
84- } else {
85- s.held[name] = kept
81+ if h.res.Resource.Alive() {
82+ h.res.SendRelease()
8683 }
8784 }
85+ clear(s.held[len(kept):])
86+ s.held = kept
8887 return len(s.held) > 0
8988 }
9089
91-// keepHeld reports whether a held buffer must stay with the client's
92-// compositor: its output scans it out, or has not flipped since it was
93-// replaced (a queued flip may still show it) and heldTimeout has not
94-// passed. A buffer of an unplugged output is released.
95-func keepHeld(h heldBuffer, scanned uint64, live, flipped bool, now time.Time) bool {
96- if scanned == h.id {
90+// keepHeld reports whether the output (last report r) may still read a
91+// held buffer: it scans it out or queued it, it has not got a later
92+// content of the window yet, or it has not reported within heldTimeout.
93+func keepHeld(h heldBuffer, r ports.OutputPresented, reported bool, now time.Time) bool {
94+ if reported && h.id != 0 && (r.Shown == h.id || r.Queued == h.id) {
9795 return true
9896 }
99- return live && !flipped && now.Sub(h.at) < heldTimeout
97+ if reported && r.Seen[h.window] > h.after {
98+ return false
99+ }
100+ return now.Sub(h.at) < heldTimeout
100101 }
@@ -3,98 +3,99 @@ package wayland
3 import (3 import (
4 "time"4 "time"
5 5
6+ "github.com/bnema/nefertty/internal/ports"
6 "github.com/bnema/purego-libwayland/protocol/wayland"7 "github.com/bnema/purego-libwayland/protocol/wayland"
7 )8 )
8 9
9-// Buffer release. A replaced wl_buffer goes back to the client (release)10+// Buffer release. Outputs read client buffers in place: the renderer
10-// when nothing reads it any more. Composed outputs copy buffers into their11+// samples them when it draws, and an output scanning out a client buffer
11-// own image, so buffers are released at once. While an output scans out a12+// (ADR 006) shows it until the next flip. A replaced wl_buffer therefore
12-// client buffer directly (ADR 006), a replaced GPU buffer of a surface on13+// goes back to the client (release) only when the output showing its
13-// it may be on screen or queued for a page flip: it is held until the14+// window reports (ports.OutputPresented) that it has a later content of
14-// output presents a frame that scans out another buffer, or heldTimeout15+// that window and neither shows nor queues the buffer. Outputs keep only
15-// passes without a flip. The first buffer of a switch to scanout is not16+// the latest content of a window, so they never draw an older buffer
16-// held (known limitation: it may be released while its flip is queued).17+// again. heldTimeout guards against an output that stopped reporting
18+// (switched away, stalled): a buffer it does not scan out is released then.
17 19
18-// heldTimeout releases held buffers of an output that stopped flipping; a20+// heldTimeout releases held buffers of an output that stopped reporting.
19-// queued flip completes well within it.
20 const heldTimeout = 100 * time.Millisecond21 const heldTimeout = 100 * time.Millisecond
21 22
22 type heldBuffer struct {23 type heldBuffer struct {
23- res *wayland.Buffer24+ res *wayland.Buffer
24- id uint6425+ window ports.WindowID
25- at time.Time26+ output string
27+ id uint64 // DMABuf ID, 0 for wl_shm
28+ after uint64 // the window's content Seq when the buffer was replaced
29+ at time.Time
26 }30 }
27 31
28-// releaseBuffer releases a replaced buffer, or holds a GPU buffer until32+// releaseBuffer releases a replaced buffer, or holds it until the output
29-// its output flipped away from it.33+// showing its window no longer reads it.
30 func (s *Server) releaseBuffer(surf *surface, b *wayland.Buffer) {34 func (s *Server) releaseBuffer(surf *surface, b *wayland.Buffer) {
31 if !b.Resource.Alive() {35 if !b.Resource.Alive() {
32 return36 return
33 }37 }
34- d, ok := s.buffers[b.Resource].(*dmabufBuffer)38+ window, name := surf.root().windowID(), s.frameOutput(surf)
35- name := s.frameOutput(surf)39+ if window == 0 || name == "" {
36- // Only an output scanning out a client buffer reads it after the40+ // Not drawn (unmapped, hidden, a cursor): nothing reads it.
37- // frame: composed outputs copied it already.
38- if !ok || name == "" || s.scanned[name] == 0 {
39 b.SendRelease()41 b.SendRelease()
40 return42 return
41 }43 }
42- s.held[name] = append(s.held[name], heldBuffer{res: b, id: d.buf.ID, at: time.Now()})44+ h := heldBuffer{res: b, window: window, output: name, at: time.Now()}
43- // Wake the pacer: it releases the buffer after the next flip.45+ if d, ok := s.buffers[b.Resource].(*dmabufBuffer); ok {
46+ h.id = d.buf.ID
47+ }
48+ s.contentMu.Lock()
49+ h.after = s.contentSeq[window]
50+ s.contentMu.Unlock()
51+ s.held = append(s.held, h)
52+ // Wake the pacer: it releases the buffer on the next output report.
44 select {53 select {
45 case s.frameReady <- struct{}{}:54 case s.frameReady <- struct{}{}:
46 default:55 default:
47 }56 }
48 }57 }
49 58
50-// releaseHeld records the buffers scanned out by the outputs that flipped59+// releaseHeld records the output reports and releases the buffers no
51-// and releases held buffers no output shows. It reports whether buffers60+// output reads. It reports whether buffers are still held.
52-// are still held.61+func (s *Server) releaseHeld(now time.Time, reports []ports.OutputPresented) bool {
53-func (s *Server) releaseHeld(now time.Time, scanned map[string]uint64) bool {62+ for _, r := range reports {
54- for name, id := range scanned {63+ s.reports[r.Output] = r
55- if id == 0 {
56- delete(s.scanned, name)
57- } else {
58- s.scanned[name] = id
59- }
60 }64 }
61 live := map[string]bool{}65 live := map[string]bool{}
62 for _, o := range s.outputs {66 for _, o := range s.outputs {
63 live[o.name()] = true67 live[o.name()] = true
64 }68 }
65- for name := range s.scanned {69+ for name := range s.reports {
66 if !live[name] {70 if !live[name] {
67- delete(s.scanned, name)71+ delete(s.reports, name)
68 }72 }
69 }73 }
70- for name, list := range s.held {74+ kept := s.held[:0]
71- _, flipped := scanned[name]75+ for _, h := range s.held {
72- kept := list[:0]76+ r, reported := s.reports[h.output]
73- for _, h := range list {77+ if live[h.output] && keepHeld(h, r, reported, now) {
74- if keepHeld(h, s.scanned[name], live[name], flipped, now) {78+ kept = append(kept, h)
75- kept = append(kept, h)79+ continue
76- continue
77- }
78- if h.res.Resource.Alive() {
79- h.res.SendRelease()
80- }
81 }80 }
82- if len(kept) == 0 {81+ if h.res.Resource.Alive() {
83- delete(s.held, name)82+ h.res.SendRelease()
84- } else {
85- s.held[name] = kept
86 }83 }
87 }84 }
85+ clear(s.held[len(kept):])
86+ s.held = kept
88 return len(s.held) > 087 return len(s.held) > 0
89 }88 }
90 89
91-// keepHeld reports whether a held buffer must stay with the client's90+// keepHeld reports whether the output (last report r) may still read a
92-// compositor: its output scans it out, or has not flipped since it was91+// held buffer: it scans it out or queued it, it has not got a later
93-// replaced (a queued flip may still show it) and heldTimeout has not92+// content of the window yet, or it has not reported within heldTimeout.
94-// passed. A buffer of an unplugged output is released.93+func keepHeld(h heldBuffer, r ports.OutputPresented, reported bool, now time.Time) bool {
95-func keepHeld(h heldBuffer, scanned uint64, live, flipped bool, now time.Time) bool {94+ if reported && h.id != 0 && (r.Shown == h.id || r.Queued == h.id) {
96- if scanned == h.id {
97 return true95 return true
98 }96 }
99- return live && !flipped && now.Sub(h.at) < heldTimeout97+ if reported && r.Seen[h.window] > h.after {
98+ return false
99+ }
100+ return now.Sub(h.at) < heldTimeout
100 }101 }
M internal/adapters/wayland/release_test.go +20 -12
@@ -3,25 +3,33 @@ package wayland
33 import (
44 "testing"
55 "time"
6+
7+ "github.com/bnema/nefertty/internal/ports"
68 )
79
810 func TestKeepHeld(t *testing.T) {
911 now := time.Unix(100, 0)
10- h := heldBuffer{id: 7, at: now}
12+ dma := heldBuffer{window: 1, id: 7, after: 5, at: now}
13+ shm := heldBuffer{window: 1, after: 5, at: now}
14+ seen := func(seq uint64) map[ports.WindowID]uint64 { return map[ports.WindowID]uint64{1: seq} }
1115 for _, tc := range []struct {
12- name string
13- scanned uint64
14- live, flipped, want bool
15- after time.Duration
16+ name string
17+ h heldBuffer
18+ r ports.OutputPresented
19+ reported bool
20+ after time.Duration
21+ want bool
1622 }{
17- {"waits for a flip", 0, true, false, true, 0},
18- {"composed flip releases", 0, true, true, false, 0},
19- {"scanned out stays", 7, true, true, true, time.Second},
20- {"another buffer scanned out", 8, true, true, false, 0},
21- {"idle output times out", 0, true, false, false, heldTimeout},
22- {"unplugged output releases", 0, false, false, false, 0},
23+ {"no report yet", dma, ports.OutputPresented{}, false, 0, true},
24+ {"output has only this content", dma, ports.OutputPresented{Seen: seen(5)}, true, 0, true},
25+ {"later content seen", dma, ports.OutputPresented{Seen: seen(6)}, true, 0, false},
26+ {"scanned out", dma, ports.OutputPresented{Shown: 7, Seen: seen(6)}, true, time.Second, true},
27+ {"queued for scanout", dma, ports.OutputPresented{Queued: 7, Seen: seen(6)}, true, time.Second, true},
28+ {"another buffer scanned out", dma, ports.OutputPresented{Shown: 8, Seen: seen(6)}, true, 0, false},
29+ {"silent output times out", dma, ports.OutputPresented{Seen: seen(5)}, true, heldTimeout, false},
30+ {"shm follows content", shm, ports.OutputPresented{Shown: 0, Seen: seen(6)}, true, 0, false},
2331 } {
24- if got := keepHeld(h, tc.scanned, tc.live, tc.flipped, now.Add(tc.after)); got != tc.want {
32+ if got := keepHeld(tc.h, tc.r, tc.reported, now.Add(tc.after)); got != tc.want {
2533 t.Errorf("%s: %v", tc.name, got)
2634 }
2735 }
@@ -3,25 +3,33 @@ package wayland
3 import (3 import (
4 "testing"4 "testing"
5 "time"5 "time"
6+
7+ "github.com/bnema/nefertty/internal/ports"
6 )8 )
7 9
8 func TestKeepHeld(t *testing.T) {10 func TestKeepHeld(t *testing.T) {
9 now := time.Unix(100, 0)11 now := time.Unix(100, 0)
10- h := heldBuffer{id: 7, at: now}12+ dma := heldBuffer{window: 1, id: 7, after: 5, at: now}
13+ shm := heldBuffer{window: 1, after: 5, at: now}
14+ seen := func(seq uint64) map[ports.WindowID]uint64 { return map[ports.WindowID]uint64{1: seq} }
11 for _, tc := range []struct {15 for _, tc := range []struct {
12- name string16+ name string
13- scanned uint6417+ h heldBuffer
14- live, flipped, want bool18+ r ports.OutputPresented
15- after time.Duration19+ reported bool
20+ after time.Duration
21+ want bool
16 }{22 }{
17- {"waits for a flip", 0, true, false, true, 0},23+ {"no report yet", dma, ports.OutputPresented{}, false, 0, true},
18- {"composed flip releases", 0, true, true, false, 0},24+ {"output has only this content", dma, ports.OutputPresented{Seen: seen(5)}, true, 0, true},
19- {"scanned out stays", 7, true, true, true, time.Second},25+ {"later content seen", dma, ports.OutputPresented{Seen: seen(6)}, true, 0, false},
20- {"another buffer scanned out", 8, true, true, false, 0},26+ {"scanned out", dma, ports.OutputPresented{Shown: 7, Seen: seen(6)}, true, time.Second, true},
21- {"idle output times out", 0, true, false, false, heldTimeout},27+ {"queued for scanout", dma, ports.OutputPresented{Queued: 7, Seen: seen(6)}, true, time.Second, true},
22- {"unplugged output releases", 0, false, false, false, 0},28+ {"another buffer scanned out", dma, ports.OutputPresented{Shown: 8, Seen: seen(6)}, true, 0, false},
29+ {"silent output times out", dma, ports.OutputPresented{Seen: seen(5)}, true, heldTimeout, false},
30+ {"shm follows content", shm, ports.OutputPresented{Shown: 0, Seen: seen(6)}, true, 0, false},
23 } {31 } {
24- if got := keepHeld(h, tc.scanned, tc.live, tc.flipped, now.Add(tc.after)); got != tc.want {32+ if got := keepHeld(tc.h, tc.r, tc.reported, now.Add(tc.after)); got != tc.want {
25 t.Errorf("%s: %v", tc.name, got)33 t.Errorf("%s: %v", tc.name, got)
26 }34 }
27 }35 }
M internal/adapters/wayland/server.go +7 -6
@@ -52,10 +52,10 @@ type Server struct {
5252 awaiting map[string][]*wayland.Callback
5353 frameDue map[string]time.Time
5454 frameReady chan struct{}
55- // held buffers wait for a flip (release.go); scanned is the dmabuf ID
56- // each output scans out directly.
57- held map[string][]heldBuffer
58- scanned map[string]uint64
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
5959 frames uint64
6060 started time.Time
6161 surfaces map[*server.Resource]*surface
@@ -147,7 +147,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
147147 d.Close()
148148 return nil, err
149149 }
150- s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), held: map[string][]heldBuffer{}, scanned: map[string]uint64{}, 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}
151151 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
152152 if opts.Keymap != "" {
153153 fd, size, e := keymapFile(opts.Keymap)
@@ -266,8 +266,9 @@ func (s *Server) emitContent(c ports.SurfaceContent) {
266266 return
267267 }
268268 s.contentMu.Lock()
269- s.contents[c.ID] = c
270269 s.contentSeq[c.ID]++
270+ c.Seq = s.contentSeq[c.ID]
271+ s.contents[c.ID] = c
271272 close(s.contentNotify)
272273 s.contentNotify = make(chan struct{})
273274 s.contentMu.Unlock()
@@ -52,10 +52,10 @@ 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- // held buffers wait for a flip (release.go); scanned is the dmabuf ID55+ // held buffers wait until no output reads them (release.go); reports
56- // each output scans out directly.56+ // are the outputs' latest ports.OutputPresented.
57- held map[string][]heldBuffer57+ held []heldBuffer
58- scanned map[string]uint6458+ reports map[string]ports.OutputPresented
59 frames uint6459 frames uint64
60 started time.Time60 started time.Time
61 surfaces map[*server.Resource]*surface61 surfaces map[*server.Resource]*surface
@@ -147,7 +147,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
147 d.Close()147 d.Close()
148 return nil, err148 return nil, err
149 }149 }
150- s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), held: map[string][]heldBuffer{}, scanned: map[string]uint64{}, 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}
151 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}151 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
152 if opts.Keymap != "" {152 if opts.Keymap != "" {
153 fd, size, e := keymapFile(opts.Keymap)153 fd, size, e := keymapFile(opts.Keymap)
@@ -266,8 +266,9 @@ func (s *Server) emitContent(c ports.SurfaceContent) {
266 return266 return
267 }267 }
268 s.contentMu.Lock()268 s.contentMu.Lock()
269- s.contents[c.ID] = c
270 s.contentSeq[c.ID]++269 s.contentSeq[c.ID]++
270+ c.Seq = s.contentSeq[c.ID]
271+ s.contents[c.ID] = c
271 close(s.contentNotify)272 close(s.contentNotify)
272 s.contentNotify = make(chan struct{})273 s.contentNotify = make(chan struct{})
273 s.contentMu.Unlock()274 s.contentMu.Unlock()
M internal/app/app.go +2 -2
@@ -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 {
@@ -213,7 +213,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
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 {
@@ -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 {
@@ -213,7 +213,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
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/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 +14 -7
@@ -278,13 +278,18 @@ 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-// Scanout is the DMABuf ID now scanned out directly, 0 for a composed
284-// frame: until another flip, that client buffer must not be released.
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.
285288 type OutputPresented struct {
286- Output string
287- Scanout uint64
289+ Output string
290+ Flip bool
291+ Shown, Queued uint64
292+ Seen map[WindowID]uint64
288293 }
289294
290295 // OutputRemoved carries output → core an unplugged display.
@@ -525,7 +530,9 @@ type SceneLayer struct {
525530 // Geometry is the part of the surface that is the window (xdg window
526531 // geometry), the rest being client shadows.
527532 type SurfaceContent struct {
528- ID WindowID
533+ ID WindowID
534+ // Seq counts the window's contents (wayland sets it), for release.
535+ Seq uint64
529536 Width, Height int
530537 LogicalW, LogicalH int
531538 Opaque bool // x formats: ignore the alpha byte
@@ -278,13 +278,18 @@ 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-// Scanout is the DMABuf ID now scanned out directly, 0 for a composed283+// on it are due. Shown and Queued are the DMABuf IDs scanned out directly
284-// frame: until another flip, that client buffer must not be released.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.
285 type OutputPresented struct {288 type OutputPresented struct {
286- Output string289+ Output string
287- Scanout uint64290+ Flip bool
291+ Shown, Queued uint64
292+ Seen map[WindowID]uint64
288 }293 }
289 294
290 // OutputRemoved carries output → core an unplugged display.295 // OutputRemoved carries output → core an unplugged display.
@@ -525,7 +530,9 @@ type SceneLayer struct {
525 // 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
526 // geometry), the rest being client shadows.531 // geometry), the rest being client shadows.
527 type SurfaceContent struct {532 type SurfaceContent struct {
528- ID WindowID533+ ID WindowID
534+ // Seq counts the window's contents (wayland sets it), for release.
535+ Seq uint64
529 Width, Height int536 Width, Height int
530 LogicalW, LogicalH int537 LogicalW, LogicalH int
531 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 26

Tags 10

↑↓ moveenter openesc close