detached 4472471

feat(drm): scan out fullscreen client buffers directly

commit
44724718e7b6b36ee938ad9ededd981a68dedf0f
author
brice · · 2026-09-25 16:07 UTC
parent
1364461
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +464 -27
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 +66 -5
@@ -29,6 +29,13 @@ type Output struct {
2929 monitor Monitor
3030 cursor *Cursor
3131 log zerowrap.Logger
32+ // Direct scanout: client framebuffers by DMABuf ID, GEM handle counts,
33+ // and the buffer on screen and queued (0: the composed image).
34+ scanout bool
35+ clientFBs map[uint64]*clientFB
36+ handles map[uint32]int
37+ shown, queued uint64
38+ reason string // why the last frame was composed ("" = scanout)
3239 }
3340
3441 // CursorLoader returns the image of a cursor at an output scale, at most
@@ -38,7 +45,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.Cu
3845 // newOutput allocates buffers and the cursor for a connector on crtc.
3946 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
4047 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)}
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"}
4249 var err error
4350 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
4451 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
@@ -76,6 +83,7 @@ func (o *Output) Height() int { return int(o.mode.VDisplay) }
7683 // modeset shows the front buffer; needed at start and after every VT resume.
7784 func (o *Output) modeset() error {
7885 o.pending = false
86+ o.shown, o.queued = 0, 0
7987 front := o.bufs[1-o.back]
8088 if err := setCrtc(o.fd, o.crtc, o.conn.id, front.fbID, &o.mode); err != nil {
8189 return fmt.Errorf("set crtc: %w", err)
@@ -92,10 +100,55 @@ func (o *Output) present(r ports.Renderer) error {
92100 return err
93101 }
94102 o.pending, o.flipStart = true, time.Now()
103+ o.queued = 0
95104 o.back = 1 - o.back
96105 return nil
97106 }
98107
108+// tryScanout flips a fullscreen client buffer directly. It reports false,
109+// with the reason logged on change, when the frame must be composed.
110+func (o *Output) tryScanout(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (bool, error) {
111+ reason := "disabled"
112+ var c ports.SurfaceContent
113+ if o.scanout {
114+ c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())
115+ }
116+ var fb uint32
117+ 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+ }
125+ }
126+ if reason != o.reason {
127+ o.log.Info().Bool("direct_scanout", reason == "").Str("reason", reason).Str("connector", o.conn.name).Msg("scanout")
128+ o.reason = reason
129+ }
130+ if reason != "" {
131+ return false, nil
132+ }
133+ if o.shown == c.DMABuf.ID && o.queued == 0 {
134+ // Already on screen: nothing to flip.
135+ return true, nil
136+ }
137+ if err := flip(o.fd, o.crtc, fb); err != nil {
138+ if errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE) {
139+ // 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")
141+ o.clientFBs[c.DMABuf.ID].failed = true
142+ o.reason = "flip_refused"
143+ return false, nil
144+ }
145+ return true, err
146+ }
147+ o.pending, o.flipStart = true, time.Now()
148+ o.queued = c.DMABuf.ID
149+ return true, nil
150+}
151+
99152 // Close restores the CRTC state found at startup, when it had a mode, and
100153 // frees buffers.
101154 func (o *Output) Close() {
@@ -114,6 +167,8 @@ func (o *Output) Close() {
114167 b.destroy(o.fd)
115168 }
116169 }
170+ o.shown, o.queued = 0, 0
171+ o.dropClientFBs(true)
117172 }
118173
119174 // Run renders scenes and flips until ctx ends. active reports seat enable/disable.
@@ -170,8 +225,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
170225 }
171226 o.pending = false
172227 o.flips += n
228+ o.shown = o.queued
229+ o.dropClientFBs(false)
173230 select {
174- case presented <- ports.OutputPresented{Output: o.conn.name}:
231+ case presented <- ports.OutputPresented{Output: o.conn.name, Scanout: o.shown}:
175232 default:
176233 }
177234 if d := time.Since(o.flipStart); d > 20*time.Millisecond {
@@ -210,11 +267,15 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
210267 continue
211268 }
212269 start := time.Now()
213- if err := r.Render(scene, surfaces); err != nil {
214- return fmt.Errorf("render frame: %w", err)
270+ direct, err := o.tryScanout(scene, surfaces)
271+ if !direct {
272+ if err := r.Render(scene, surfaces); err != nil {
273+ return fmt.Errorf("render frame: %w", err)
274+ }
275+ err = o.present(r)
215276 }
216277 copyStart := time.Now()
217- if err := o.present(r); err != nil {
278+ if err != nil {
218279 if errors.Is(err, unix.EBUSY) {
219280 // A flip is still in flight; retry after its event.
220281 o.pending, o.flipStart = true, time.Now()
@@ -29,6 +29,13 @@ type Output struct {
29 monitor Monitor29 monitor Monitor
30 cursor *Cursor30 cursor *Cursor
31 log zerowrap.Logger31 log zerowrap.Logger
32+ // Direct scanout: client framebuffers by DMABuf ID, GEM handle counts,
33+ // and the buffer on screen and queued (0: the composed image).
34+ scanout bool
35+ clientFBs map[uint64]*clientFB
36+ handles map[uint32]int
37+ shown, queued uint64
38+ reason string // why the last frame was composed ("" = scanout)
32 }39 }
33 40
34 // CursorLoader returns the image of a cursor at an output scale, at most41 // CursorLoader returns the image of a cursor at an output scale, at most
@@ -38,7 +45,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.45 // newOutput allocates buffers and the cursor for a connector on crtc.
39 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {46 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
40 log := card.log47 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)}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"}
42 var err error49 var err error
43 if o.saved, err = getCrtc(card.fd, crtc); err != nil {50 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")51 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
@@ -76,6 +83,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.83 // modeset shows the front buffer; needed at start and after every VT resume.
77 func (o *Output) modeset() error {84 func (o *Output) modeset() error {
78 o.pending = false85 o.pending = false
86+ o.shown, o.queued = 0, 0
79 front := o.bufs[1-o.back]87 front := o.bufs[1-o.back]
80 if err := setCrtc(o.fd, o.crtc, o.conn.id, front.fbID, &o.mode); err != nil {88 if err := setCrtc(o.fd, o.crtc, o.conn.id, front.fbID, &o.mode); err != nil {
81 return fmt.Errorf("set crtc: %w", err)89 return fmt.Errorf("set crtc: %w", err)
@@ -92,10 +100,55 @@ func (o *Output) present(r ports.Renderer) error {
92 return err100 return err
93 }101 }
94 o.pending, o.flipStart = true, time.Now()102 o.pending, o.flipStart = true, time.Now()
103+ o.queued = 0
95 o.back = 1 - o.back104 o.back = 1 - o.back
96 return nil105 return nil
97 }106 }
98 107
108+// tryScanout flips a fullscreen client buffer directly. It reports false,
109+// with the reason logged on change, when the frame must be composed.
110+func (o *Output) tryScanout(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (bool, error) {
111+ reason := "disabled"
112+ var c ports.SurfaceContent
113+ if o.scanout {
114+ c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())
115+ }
116+ var fb uint32
117+ 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+ }
125+ }
126+ if reason != o.reason {
127+ o.log.Info().Bool("direct_scanout", reason == "").Str("reason", reason).Str("connector", o.conn.name).Msg("scanout")
128+ o.reason = reason
129+ }
130+ if reason != "" {
131+ return false, nil
132+ }
133+ if o.shown == c.DMABuf.ID && o.queued == 0 {
134+ // Already on screen: nothing to flip.
135+ return true, nil
136+ }
137+ if err := flip(o.fd, o.crtc, fb); err != nil {
138+ if errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE) {
139+ // 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")
141+ o.clientFBs[c.DMABuf.ID].failed = true
142+ o.reason = "flip_refused"
143+ return false, nil
144+ }
145+ return true, err
146+ }
147+ o.pending, o.flipStart = true, time.Now()
148+ o.queued = c.DMABuf.ID
149+ return true, nil
150+}
151+
99 // Close restores the CRTC state found at startup, when it had a mode, and152 // Close restores the CRTC state found at startup, when it had a mode, and
100 // frees buffers.153 // frees buffers.
101 func (o *Output) Close() {154 func (o *Output) Close() {
@@ -114,6 +167,8 @@ func (o *Output) Close() {
114 b.destroy(o.fd)167 b.destroy(o.fd)
115 }168 }
116 }169 }
170+ o.shown, o.queued = 0, 0
171+ o.dropClientFBs(true)
117 }172 }
118 173
119 // Run renders scenes and flips until ctx ends. active reports seat enable/disable.174 // Run renders scenes and flips until ctx ends. active reports seat enable/disable.
@@ -170,8 +225,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
170 }225 }
171 o.pending = false226 o.pending = false
172 o.flips += n227 o.flips += n
228+ o.shown = o.queued
229+ o.dropClientFBs(false)
173 select {230 select {
174- case presented <- ports.OutputPresented{Output: o.conn.name}:231+ case presented <- ports.OutputPresented{Output: o.conn.name, Scanout: o.shown}:
175 default:232 default:
176 }233 }
177 if d := time.Since(o.flipStart); d > 20*time.Millisecond {234 if d := time.Since(o.flipStart); d > 20*time.Millisecond {
@@ -210,11 +267,15 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
210 continue267 continue
211 }268 }
212 start := time.Now()269 start := time.Now()
213- if err := r.Render(scene, surfaces); err != nil {270+ direct, err := o.tryScanout(scene, surfaces)
214- return fmt.Errorf("render frame: %w", err)271+ if !direct {
272+ if err := r.Render(scene, surfaces); err != nil {
273+ return fmt.Errorf("render frame: %w", err)
274+ }
275+ err = o.present(r)
215 }276 }
216 copyStart := time.Now()277 copyStart := time.Now()
217- if err := o.present(r); err != nil {278+ if err != nil {
218 if errors.Is(err, unix.EBUSY) {279 if errors.Is(err, unix.EBUSY) {
219 // A flip is still in flight; retry after its event.280 // A flip is still in flight; retry after its event.
220 o.pending, o.flipStart = true, time.Now()281 o.pending, o.flipStart = true, time.Now()
A internal/adapters/drm/scanout.go +176 -0
@@ -0,0 +1,176 @@
1+package drm
2+
3+import (
4+ "errors"
5+ "os"
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+ modInvalid = 0x00ffffffffffffff
26+ scanoutIdleTTL = 240 // flips a cached framebuffer survives unused
27+)
28+
29+type primeHandle struct {
30+ handle, flags uint32
31+ fd int32
32+}
33+
34+type gemClose struct{ handle, pad uint32 }
35+
36+type fbCmd2 struct {
37+ fbID, width, height, format, flags uint32
38+ handles, pitches, offsets [4]uint32
39+ modifiers [4]uint64
40+}
41+
42+// scanoutCandidate returns the window content the output can scan out
43+// directly, or a reason why it cannot. w, h are the output's physical size.
44+func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int) (ports.SurfaceContent, string) {
45+ var full *ports.SceneWindow
46+ for i := range s.Windows {
47+ win := &s.Windows[i]
48+ if win.Hidden || win.Rect.W <= 0 || win.Rect.H <= 0 {
49+ continue
50+ }
51+ covers := win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight
52+ if !win.Fullscreen || !covers || full != nil {
53+ return ports.SurfaceContent{}, "other_windows"
54+ }
55+ full = win
56+ }
57+ if full == nil {
58+ return ports.SurfaceContent{}, "no_fullscreen"
59+ }
60+ for _, l := range s.Layers {
61+ if l.Layer == ports.LayerOverlay && l.Rect.W > 0 && l.Rect.H > 0 {
62+ return ports.SurfaceContent{}, "overlay_surface"
63+ }
64+ }
65+ c := surfaces[full.ID]
66+ switch {
67+ case c.DMABuf == nil:
68+ return c, "not_dmabuf"
69+ case len(c.Children) > 0:
70+ return c, "subsurfaces"
71+ case len(c.DMABuf.Planes) != 1:
72+ return c, "multi_plane"
73+ case c.Width != w || c.Height != h:
74+ return c, "size_mismatch"
75+ 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"
77+ }
78+ return c, ""
79+}
80+
81+// clientFB is a client buffer imported as a KMS framebuffer.
82+type clientFB struct {
83+ fbID, handle uint32
84+ last int // flip count when last shown
85+ failed bool
86+}
87+
88+// 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) {
91+ 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
97+ }
98+ fb := &clientFB{last: o.flips}
99+ o.clientFBs[b.ID] = fb
100+ handle, err := o.importHandle(b.Planes[0].File)
101+ 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
109+ }
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)
114+ }
115+ fb.fbID, fb.handle = cmd.fbID, handle
116+ return fb.fbID, nil
117+}
118+
119+var errScanoutRefused = errors.New("buffer refused for scanout")
120+
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()
126+ if err != nil {
127+ return 0, err
128+ }
129+ var p primeHandle
130+ var ioErr error
131+ if err := raw.Control(func(fd uintptr) {
132+ p.fd = int32(fd)
133+ ioErr = ioctl(o.fd, ioctlPrimeFDToHandle, unsafe.Pointer(&p))
134+ }); err != nil {
135+ return 0, err
136+ }
137+ if ioErr != nil {
138+ return 0, ioErr
139+ }
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
147+ }
148+ delete(o.handles, handle)
149+ c := gemClose{handle: handle}
150+ _ = ioctl(o.fd, ioctlGemClose, unsafe.Pointer(&c))
151+}
152+
153+// dropClientFBs frees framebuffers unused for scanoutIdleTTL flips, or all
154+// of them, except those on screen or queued.
155+func (o *Output) dropClientFBs(all bool) {
156+ for id, fb := range o.clientFBs {
157+ if id == o.shown || id == o.queued {
158+ continue
159+ }
160+ if !all && o.flips-fb.last < scanoutIdleTTL {
161+ continue
162+ }
163+ o.freeFB(fb)
164+ delete(o.clientFBs, id)
165+ }
166+}
167+
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+}
@@ -0,0 +1,176 @@
1+package drm
2+
3+import (
4+ "errors"
5+ "os"
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+ modInvalid = 0x00ffffffffffffff
26+ scanoutIdleTTL = 240 // flips a cached framebuffer survives unused
27+)
28+
29+type primeHandle struct {
30+ handle, flags uint32
31+ fd int32
32+}
33+
34+type gemClose struct{ handle, pad uint32 }
35+
36+type fbCmd2 struct {
37+ fbID, width, height, format, flags uint32
38+ handles, pitches, offsets [4]uint32
39+ modifiers [4]uint64
40+}
41+
42+// scanoutCandidate returns the window content the output can scan out
43+// directly, or a reason why it cannot. w, h are the output's physical size.
44+func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int) (ports.SurfaceContent, string) {
45+ var full *ports.SceneWindow
46+ for i := range s.Windows {
47+ win := &s.Windows[i]
48+ if win.Hidden || win.Rect.W <= 0 || win.Rect.H <= 0 {
49+ continue
50+ }
51+ covers := win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight
52+ if !win.Fullscreen || !covers || full != nil {
53+ return ports.SurfaceContent{}, "other_windows"
54+ }
55+ full = win
56+ }
57+ if full == nil {
58+ return ports.SurfaceContent{}, "no_fullscreen"
59+ }
60+ for _, l := range s.Layers {
61+ if l.Layer == ports.LayerOverlay && l.Rect.W > 0 && l.Rect.H > 0 {
62+ return ports.SurfaceContent{}, "overlay_surface"
63+ }
64+ }
65+ c := surfaces[full.ID]
66+ switch {
67+ case c.DMABuf == nil:
68+ return c, "not_dmabuf"
69+ case len(c.Children) > 0:
70+ return c, "subsurfaces"
71+ case len(c.DMABuf.Planes) != 1:
72+ return c, "multi_plane"
73+ case c.Width != w || c.Height != h:
74+ return c, "size_mismatch"
75+ 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"
77+ }
78+ return c, ""
79+}
80+
81+// clientFB is a client buffer imported as a KMS framebuffer.
82+type clientFB struct {
83+ fbID, handle uint32
84+ last int // flip count when last shown
85+ failed bool
86+}
87+
88+// 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) {
91+ 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
97+ }
98+ fb := &clientFB{last: o.flips}
99+ o.clientFBs[b.ID] = fb
100+ handle, err := o.importHandle(b.Planes[0].File)
101+ 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
109+ }
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)
114+ }
115+ fb.fbID, fb.handle = cmd.fbID, handle
116+ return fb.fbID, nil
117+}
118+
119+var errScanoutRefused = errors.New("buffer refused for scanout")
120+
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()
126+ if err != nil {
127+ return 0, err
128+ }
129+ var p primeHandle
130+ var ioErr error
131+ if err := raw.Control(func(fd uintptr) {
132+ p.fd = int32(fd)
133+ ioErr = ioctl(o.fd, ioctlPrimeFDToHandle, unsafe.Pointer(&p))
134+ }); err != nil {
135+ return 0, err
136+ }
137+ if ioErr != nil {
138+ return 0, ioErr
139+ }
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
147+ }
148+ delete(o.handles, handle)
149+ c := gemClose{handle: handle}
150+ _ = ioctl(o.fd, ioctlGemClose, unsafe.Pointer(&c))
151+}
152+
153+// dropClientFBs frees framebuffers unused for scanoutIdleTTL flips, or all
154+// of them, except those on screen or queued.
155+func (o *Output) dropClientFBs(all bool) {
156+ for id, fb := range o.clientFBs {
157+ if id == o.shown || id == o.queued {
158+ continue
159+ }
160+ if !all && o.flips-fb.last < scanoutIdleTTL {
161+ continue
162+ }
163+ o.freeFB(fb)
164+ delete(o.clientFBs, id)
165+ }
166+}
167+
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+}
A internal/adapters/drm/scanout_test.go +55 -0
@@ -0,0 +1,55 @@
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, 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+ {"whole geometry", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{W: 100, H: 50} }, ""},
41+ } {
42+ s := scene
43+ s.Windows = append([]ports.SceneWindow(nil), scene.Windows...)
44+ c := game
45+ if tc.scene != nil {
46+ tc.scene(&s)
47+ }
48+ if tc.cont != nil {
49+ tc.cont(&c)
50+ }
51+ if _, reason := scanoutCandidate(s, map[ports.WindowID]ports.SurfaceContent{1: c}, 200, 100); reason != tc.reason {
52+ t.Errorf("%s: reason %q, want %q", tc.name, reason, tc.reason)
53+ }
54+ }
55+}
@@ -0,0 +1,55 @@
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, 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+ {"whole geometry", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{W: 100, H: 50} }, ""},
41+ } {
42+ s := scene
43+ s.Windows = append([]ports.SceneWindow(nil), scene.Windows...)
44+ c := game
45+ if tc.scene != nil {
46+ tc.scene(&s)
47+ }
48+ if tc.cont != nil {
49+ tc.cont(&c)
50+ }
51+ if _, reason := scanoutCandidate(s, map[ports.WindowID]ports.SurfaceContent{1: c}, 200, 100); reason != tc.reason {
52+ t.Errorf("%s: reason %q, want %q", tc.name, reason, tc.reason)
53+ }
54+ }
55+}
M internal/adapters/wayland/frames.go +7 -2
@@ -25,13 +25,14 @@ func (s *Server) pace(ctx context.Context) {
2525 presented := s.channels.Presented
2626 for {
2727 flipped := map[string]bool{}
28+ scanned := map[string]uint64{}
2829 select {
2930 case <-ctx.Done():
3031 return
3132 case <-s.display.Stopped():
3233 return
3334 case p := <-presented:
34- flipped[p.Output] = true
35+ flipped[p.Output], scanned[p.Output] = true, p.Scanout
3536 case <-s.frameReady:
3637 case <-timer.C:
3738 }
@@ -41,7 +42,7 @@ func (s *Server) pace(ctx context.Context) {
4142 for {
4243 select {
4344 case p := <-presented:
44- flipped[p.Output] = true
45+ flipped[p.Output], scanned[p.Output] = true, p.Scanout
4546 default:
4647 break drain
4748 }
@@ -54,6 +55,10 @@ func (s *Server) pace(ctx context.Context) {
5455 for _, name := range due {
5556 s.sendFrames(name)
5657 }
58+ if s.releaseHeld(time.Now(), scanned) && idle {
59+ // Held buffers wait for a flip or heldTimeout.
60+ wait, idle = heldTimeout, false
61+ }
5762 }
5863 }) {
5964 return
@@ -25,13 +25,14 @@ func (s *Server) pace(ctx context.Context) {
25 presented := s.channels.Presented25 presented := s.channels.Presented
26 for {26 for {
27 flipped := map[string]bool{}27 flipped := map[string]bool{}
28+ scanned := map[string]uint64{}
28 select {29 select {
29 case <-ctx.Done():30 case <-ctx.Done():
30 return31 return
31 case <-s.display.Stopped():32 case <-s.display.Stopped():
32 return33 return
33 case p := <-presented:34 case p := <-presented:
34- flipped[p.Output] = true35+ flipped[p.Output], scanned[p.Output] = true, p.Scanout
35 case <-s.frameReady:36 case <-s.frameReady:
36 case <-timer.C:37 case <-timer.C:
37 }38 }
@@ -41,7 +42,7 @@ func (s *Server) pace(ctx context.Context) {
41 for {42 for {
42 select {43 select {
43 case p := <-presented:44 case p := <-presented:
44- flipped[p.Output] = true45+ flipped[p.Output], scanned[p.Output] = true, p.Scanout
45 default:46 default:
46 break drain47 break drain
47 }48 }
@@ -54,6 +55,10 @@ func (s *Server) pace(ctx context.Context) {
54 for _, name := range due {55 for _, name := range due {
55 s.sendFrames(name)56 s.sendFrames(name)
56 }57 }
58+ if s.releaseHeld(time.Now(), scanned) && idle {
59+ // Held buffers wait for a flip or heldTimeout.
60+ wait, idle = heldTimeout, false
61+ }
57 }62 }
58 }) {63 }) {
59 return64 return
A internal/adapters/wayland/release.go +100 -0
@@ -0,0 +1,100 @@
1+package wayland
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/purego-libwayland/protocol/wayland"
7+)
8+
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).
17+
18+// heldTimeout releases held buffers of an output that stopped flipping; a
19+// queued flip completes well within it.
20+const heldTimeout = 100 * time.Millisecond
21+
22+type heldBuffer struct {
23+ res *wayland.Buffer
24+ id uint64
25+ at time.Time
26+}
27+
28+// releaseBuffer releases a replaced buffer, or holds a GPU buffer until
29+// its output flipped away from it.
30+func (s *Server) releaseBuffer(surf *surface, b *wayland.Buffer) {
31+ if !b.Resource.Alive() {
32+ return
33+ }
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 {
39+ b.SendRelease()
40+ return
41+ }
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+ select {
45+ case s.frameReady <- struct{}{}:
46+ default:
47+ }
48+}
49+
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+ }
60+ }
61+ live := map[string]bool{}
62+ for _, o := range s.outputs {
63+ live[o.name()] = true
64+ }
65+ for name := range s.scanned {
66+ if !live[name] {
67+ delete(s.scanned, name)
68+ }
69+ }
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+ }
81+ }
82+ if len(kept) == 0 {
83+ delete(s.held, name)
84+ } else {
85+ s.held[name] = kept
86+ }
87+ }
88+ return len(s.held) > 0
89+}
90+
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 {
97+ return true
98+ }
99+ return live && !flipped && now.Sub(h.at) < heldTimeout
100+}
@@ -0,0 +1,100 @@
1+package wayland
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/purego-libwayland/protocol/wayland"
7+)
8+
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).
17+
18+// heldTimeout releases held buffers of an output that stopped flipping; a
19+// queued flip completes well within it.
20+const heldTimeout = 100 * time.Millisecond
21+
22+type heldBuffer struct {
23+ res *wayland.Buffer
24+ id uint64
25+ at time.Time
26+}
27+
28+// releaseBuffer releases a replaced buffer, or holds a GPU buffer until
29+// its output flipped away from it.
30+func (s *Server) releaseBuffer(surf *surface, b *wayland.Buffer) {
31+ if !b.Resource.Alive() {
32+ return
33+ }
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 {
39+ b.SendRelease()
40+ return
41+ }
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+ select {
45+ case s.frameReady <- struct{}{}:
46+ default:
47+ }
48+}
49+
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+ }
60+ }
61+ live := map[string]bool{}
62+ for _, o := range s.outputs {
63+ live[o.name()] = true
64+ }
65+ for name := range s.scanned {
66+ if !live[name] {
67+ delete(s.scanned, name)
68+ }
69+ }
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+ }
81+ }
82+ if len(kept) == 0 {
83+ delete(s.held, name)
84+ } else {
85+ s.held[name] = kept
86+ }
87+ }
88+ return len(s.held) > 0
89+}
90+
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 {
97+ return true
98+ }
99+ return live && !flipped && now.Sub(h.at) < heldTimeout
100+}
A internal/adapters/wayland/release_test.go +28 -0
@@ -0,0 +1,28 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestKeepHeld(t *testing.T) {
9+ now := time.Unix(100, 0)
10+ h := heldBuffer{id: 7, at: now}
11+ for _, tc := range []struct {
12+ name string
13+ scanned uint64
14+ live, flipped, want bool
15+ after time.Duration
16+ }{
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+ } {
24+ if got := keepHeld(h, tc.scanned, tc.live, tc.flipped, now.Add(tc.after)); got != tc.want {
25+ t.Errorf("%s: %v", tc.name, got)
26+ }
27+ }
28+}
@@ -0,0 +1,28 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestKeepHeld(t *testing.T) {
9+ now := time.Unix(100, 0)
10+ h := heldBuffer{id: 7, at: now}
11+ for _, tc := range []struct {
12+ name string
13+ scanned uint64
14+ live, flipped, want bool
15+ after time.Duration
16+ }{
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+ } {
24+ if got := keepHeld(h, tc.scanned, tc.live, tc.flipped, now.Add(tc.after)); got != tc.want {
25+ t.Errorf("%s: %v", tc.name, got)
26+ }
27+ }
28+}
M internal/adapters/wayland/server.go +11 -7
@@ -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 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
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), 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}
147151 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
148152 if opts.Keymap != "" {
149153 fd, size, e := keymapFile(opts.Keymap)
@@ -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 for a flip (release.go); scanned is the dmabuf ID
56- started time.Time56+ // each output scans out directly.
57- surfaces map[*server.Resource]*surface57+ held map[string][]heldBuffer
58- buffers map[*server.Resource]clientBuffer58+ scanned map[string]uint64
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), 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}
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)
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 +2 -2
@@ -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()
@@ -208,7 +208,7 @@ 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
@@ -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()
@@ -208,7 +208,7 @@ 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
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/ports/messages.go +6 -1
@@ -280,7 +280,12 @@ func (OutputAdded) outputEvent() {}
280280
281281 // OutputPresented carries output → wayland that a frame reached the screen
282282 // (a page flip completed): frame callbacks of the surfaces on it are due.
283-type OutputPresented struct{ Output string }
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.
285+type OutputPresented struct {
286+ Output string
287+ Scanout uint64
288+}
284289
285290 // OutputRemoved carries output → core an unplugged display.
286291 type OutputRemoved struct{ Name string }
@@ -280,7 +280,12 @@ func (OutputAdded) outputEvent() {}
280 280
281 // OutputPresented carries output → wayland that a frame reached the screen281 // OutputPresented carries output → wayland that a frame reached the screen
282 // (a page flip completed): frame callbacks of the surfaces on it are due.282 // (a page flip completed): frame callbacks of the surfaces on it are due.
283-type OutputPresented struct{ Output string }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.
285+type OutputPresented struct {
286+ Output string
287+ Scanout uint64
288+}
284 289
285 // OutputRemoved carries output → core an unplugged display.290 // OutputRemoved carries output → core an unplugged display.
286 type OutputRemoved struct{ Name string }291 type OutputRemoved struct{ Name string }

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