detached 5576f26

feat(drm): keep VRR on while a fullscreen window is composed (#34)

VRR ran only while a buffer was scanned out: a notification or overlay
layer above a fullscreen game, subsurfaces or a size mismatch forced
composition and turned VRR off after 500 ms.

The output owner now keeps VRR on while a visible fullscreen window
covers the output, scanned out or composed. The check reuses the
scanout covering rule, runs only on composed frames of VRR outputs, and
is remembered for cursor and timer state commits, which no longer
take any shown buffer for a game (a tiled window on the overlay plane
kept VRR on forever). The 500 ms hold still
applies when the fullscreen window goes away.

A VRR refusal on a composed frame now retries with its overlay plane:
the composed image left that window out, so dropping the plane showed a
hole.

fullscreenShown costs ~29 ns and 0 allocations on a 65-window scene; its
allocation guard joins perf-check.
commit
5576f265e39f746961acd1241ec404cc9820b6d7
author
bnema · · 2026-09-27 19:41 UTC
committer
GitHub · · 2026-09-27 19:41 UTC
parent
41f250a
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
7 files · +212 -17
M Makefile +1 -1
@@ -50,7 +50,7 @@ arch:
5050 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
5151 # Fast, mandatory guards for tiled content publications and SHM copies.
5252 perf-check:
53- CGO_ENABLED=0 go test ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestReportSeenAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
53+ CGO_ENABLED=0 go test ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestReportSeenAllocations|TestFullscreenShownAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
5454 check: vet test arch fakes-check perf-check
5555
5656 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules
@@ -50,7 +50,7 @@ arch:
50 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...50 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
51 # Fast, mandatory guards for tiled content publications and SHM copies.51 # Fast, mandatory guards for tiled content publications and SHM copies.
52 perf-check:52 perf-check:
53- CGO_ENABLED=0 go test ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestReportSeenAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=153+ CGO_ENABLED=0 go test ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestReportSeenAllocations|TestFullscreenShownAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
54 check: vet test arch fakes-check perf-check54 check: vet test arch fakes-check perf-check
55 55
56 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules56 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules
M README.md +1 -1
@@ -57,7 +57,7 @@ These protocols and kernel features cut copies, waits and latency between the ap
5757
5858 - **Zero copy.** GPU clients (`zwp_linux_dmabuf_v1` v4) are sampled in place. Vulkan composes straight into exported scanout images, and the frame fence goes to KMS as `IN_FENCE_FD`: no CPU copy, no CPU wait before a flip.
5959 - **Direct scanout.** A fullscreen window's buffer goes straight to the display plane; dmabuf feedback sends the game a scanout-ready format first. A lone opaque window can use an overlay plane.
60-- **Tearing and VRR.** `wp_tearing_control_v1` flips without waiting for vblank when the game requests it; variable refresh runs while a buffer is scanned out.
60+- **Tearing and VRR.** `wp_tearing_control_v1` flips without waiting for vblank when the game requests it; variable refresh runs while a fullscreen window covers the output, scanned out or composed (a notification or overlay above it keeps VRR on).
6161 - **Explicit sync.** `wp_linux_drm_syncobj_v1` passes GPU fences from the app to KMS. NVIDIA drivers need it.
6262 - **Frame pacing.** Apps get real flip times through `wp_presentation`, and can queue frames with `wp_fifo_v1` and `wp_commit_timing_v1`.
6363 - **Only what changed.** NeferWL redraws only damaged regions, skips surfaces hidden behind opaque ones, and leaves idle outputs alone. Frame, cursor, overlay and VRR go to the kernel in one atomic commit.
@@ -57,7 +57,7 @@ These protocols and kernel features cut copies, waits and latency between the ap
57 57
58 - **Zero copy.** GPU clients (`zwp_linux_dmabuf_v1` v4) are sampled in place. Vulkan composes straight into exported scanout images, and the frame fence goes to KMS as `IN_FENCE_FD`: no CPU copy, no CPU wait before a flip.58 - **Zero copy.** GPU clients (`zwp_linux_dmabuf_v1` v4) are sampled in place. Vulkan composes straight into exported scanout images, and the frame fence goes to KMS as `IN_FENCE_FD`: no CPU copy, no CPU wait before a flip.
59 - **Direct scanout.** A fullscreen window's buffer goes straight to the display plane; dmabuf feedback sends the game a scanout-ready format first. A lone opaque window can use an overlay plane.59 - **Direct scanout.** A fullscreen window's buffer goes straight to the display plane; dmabuf feedback sends the game a scanout-ready format first. A lone opaque window can use an overlay plane.
60-- **Tearing and VRR.** `wp_tearing_control_v1` flips without waiting for vblank when the game requests it; variable refresh runs while a buffer is scanned out.60+- **Tearing and VRR.** `wp_tearing_control_v1` flips without waiting for vblank when the game requests it; variable refresh runs while a fullscreen window covers the output, scanned out or composed (a notification or overlay above it keeps VRR on).
61 - **Explicit sync.** `wp_linux_drm_syncobj_v1` passes GPU fences from the app to KMS. NVIDIA drivers need it.61 - **Explicit sync.** `wp_linux_drm_syncobj_v1` passes GPU fences from the app to KMS. NVIDIA drivers need it.
62 - **Frame pacing.** Apps get real flip times through `wp_presentation`, and can queue frames with `wp_fifo_v1` and `wp_commit_timing_v1`.62 - **Frame pacing.** Apps get real flip times through `wp_presentation`, and can queue frames with `wp_fifo_v1` and `wp_commit_timing_v1`.
63 - **Only what changed.** NeferWL redraws only damaged regions, skips surfaces hidden behind opaque ones, and leaves idle outputs alone. Frame, cursor, overlay and VRR go to the kernel in one atomic commit.63 - **Only what changed.** NeferWL redraws only damaged regions, skips surfaces hidden behind opaque ones, and leaves idle outputs alone. Frame, cursor, overlay and VRR go to the kernel in one atomic commit.
M docs/config.md +1 -1
@@ -39,7 +39,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
3939 | `output.<name>.hdr` | `off` | Turns on HDR10 on capable outputs; otherwise falls back to SDR (reason in the log) |
4040 | `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) |
4141 | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display |
42-| `render.tearing` / `render.vrr` | `on` / `on` | For games in direct scanout |
42+| `render.tearing` / `render.vrr` | `on` / `on` | Tearing: games in direct scanout. VRR: any fullscreen window covering the output |
4343 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |
4444 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |
4545
@@ -39,7 +39,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
39 | `output.<name>.hdr` | `off` | Turns on HDR10 on capable outputs; otherwise falls back to SDR (reason in the log) |39 | `output.<name>.hdr` | `off` | Turns on HDR10 on capable outputs; otherwise falls back to SDR (reason in the log) |
40 | `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) |40 | `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) |
41 | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display |41 | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display |
42-| `render.tearing` / `render.vrr` | `on` / `on` | For games in direct scanout |42+| `render.tearing` / `render.vrr` | `on` / `on` | Tearing: games in direct scanout. VRR: any fullscreen window covering the output |
43 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |43 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |
44 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |44 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |
45 45
M internal/adapters/drm/output.go +25 -12
@@ -107,9 +107,11 @@ type Output struct {
107107 asyncProbed bool
108108 async bool
109109 // VRR: the CRTC's VRR_ENABLED property (0: none), on while a buffer is
110- // scanned out, set inside frame commits.
110+ // scanned out or a fullscreen window covers the output, set inside
111+ // frame commits. vrrGame is that state for the last frame.
111112 vrrProp uint32
112113 vrrOn bool
114+ vrrGame bool
113115 composedSince time.Time
114116 // wantOff is the latest Scene.Off: a client turned the display off.
115117 // off: the CRTC is inactive for it. Every modeset (resume, recovery)
@@ -456,7 +458,7 @@ func (o *Output) modeset() error {
456458 _ = o.k.destroyBlob(o.modeBlob)
457459 }
458460 o.modeBlob = blob
459- o.vrrOn, o.overlayOn, o.off = false, 0, o.wantOff
461+ o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, o.wantOff
460462 if o.cursor != nil {
461463 o.cursor.applied = cursorState{}
462464 o.cursor.screen, o.cursor.flying = 0, false
@@ -484,7 +486,7 @@ func (o *Output) powerOff() error {
484486 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
485487 return fmt.Errorf("power off: %w", err)
486488 }
487- o.off, o.vrrOn = true, false
489+ o.off, o.vrrOn, o.vrrGame = true, false, false
488490 o.log.Info().Str("connector", o.conn.name).Msg("power off")
489491 // An inactive output offers neither HDR nor direct scanout.
490492 o.sendFormats()
@@ -608,14 +610,16 @@ func (o *Output) probeAsync(r ports.Renderer) {
608610 o.log.Info().Err(err).Str("connector", o.conn.name).Bool("async_fence", o.asyncFence).Msg("tearing probe")
609611 }
610612
611-// wantVRR is the VRR state the next frame commit sets: on while a buffer
612-// is scanned out, off after composing for vrrHold (each toggle may
613-// flicker, so short compositions keep it).
614-func (o *Output) wantVRR(direct bool) bool {
613+// wantVRR is the VRR state the next frame commit sets: on while game (a
614+// buffer is scanned out or a fullscreen window covers the output), off
615+// after vrrHold without it (each toggle may flicker, so short breaks keep
616+// it).
617+func (o *Output) wantVRR(game bool) bool {
615618 if o.vrrProp == 0 {
616619 return false
617620 }
618- if direct {
621+ o.vrrGame = game
622+ if game {
619623 o.composedSince = time.Time{}
620624 return true
621625 }
@@ -625,6 +629,11 @@ func (o *Output) wantVRR(direct bool) bool {
625629 return o.vrrOn && time.Since(o.composedSince) <= vrrHold
626630 }
627631
632+// stateVRR is the VRR state of a commit without a new frame (cursor,
633+// vrrOff timer): the last frame's. A shown buffer is not enough: it may
634+// be a tiled window on the overlay plane.
635+func (o *Output) stateVRR() bool { return o.wantVRR(o.vrrGame) }
636+
628637 // commitFrame flips fb in one commit with the cursor and VRR. An async
629638 // commit carries only the primary plane (kernel rule): a frame that must
630639 // also move the cursor or change VRR flips at vblank. fence is the
@@ -667,8 +676,9 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
667676 }
668677 if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) {
669678 // Retry without turning VRR on: if that passes, the driver
670- // refuses VRR on this output.
671- if o.commitFrame(fb, fence, false, false, f) == nil {
679+ // refuses VRR on this output. The overlay stays: the composed
680+ // image left its window out.
681+ if o.commitWith(fb, fence, false, false, f, ov) == nil {
672682 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr refused; disabled")
673683 o.vrrProp = 0
674684 return nil
@@ -1156,7 +1166,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11561166 }
11571167 if !dirty || !haveScene {
11581168 if stateDirty {
1159- err := o.commitState(o.wantVRR(o.shown != 0))
1169+ err := o.commitState(o.stateVRR())
11601170 if errors.Is(err, errOverlayDropped) {
11611171 dirty = haveScene
11621172 continue
@@ -1215,7 +1225,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12151225 // The overlay buffer is on screen like a scanned-out one: it
12161226 // is reported shown, so it is not released under the plane.
12171227 f.queued, f.zeroCopy, f.composed = ov.buf, ov.id, true
1218- err = o.commitWith(o.fbs[o.back], done, false, o.wantVRR(false), f, ov)
1228+ // A covering fullscreen window keeps VRR while composed
1229+ // (overlay layer, subsurfaces, size mismatch).
1230+ game := o.vrrProp != 0 && fullscreenShown(&scene)
1231+ err = o.commitWith(o.fbs[o.back], done, false, o.wantVRR(game), f, ov)
12191232 if err != nil {
12201233 // The GPU may still read client buffers for this frame.
12211234 o.holdRead(done)
@@ -107,9 +107,11 @@ type Output struct {
107 asyncProbed bool107 asyncProbed bool
108 async bool108 async bool
109 // VRR: the CRTC's VRR_ENABLED property (0: none), on while a buffer is109 // VRR: the CRTC's VRR_ENABLED property (0: none), on while a buffer is
110- // scanned out, set inside frame commits.110+ // scanned out or a fullscreen window covers the output, set inside
111+ // frame commits. vrrGame is that state for the last frame.
111 vrrProp uint32112 vrrProp uint32
112 vrrOn bool113 vrrOn bool
114+ vrrGame bool
113 composedSince time.Time115 composedSince time.Time
114 // wantOff is the latest Scene.Off: a client turned the display off.116 // wantOff is the latest Scene.Off: a client turned the display off.
115 // off: the CRTC is inactive for it. Every modeset (resume, recovery)117 // off: the CRTC is inactive for it. Every modeset (resume, recovery)
@@ -456,7 +458,7 @@ func (o *Output) modeset() error {
456 _ = o.k.destroyBlob(o.modeBlob)458 _ = o.k.destroyBlob(o.modeBlob)
457 }459 }
458 o.modeBlob = blob460 o.modeBlob = blob
459- o.vrrOn, o.overlayOn, o.off = false, 0, o.wantOff461+ o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, o.wantOff
460 if o.cursor != nil {462 if o.cursor != nil {
461 o.cursor.applied = cursorState{}463 o.cursor.applied = cursorState{}
462 o.cursor.screen, o.cursor.flying = 0, false464 o.cursor.screen, o.cursor.flying = 0, false
@@ -484,7 +486,7 @@ func (o *Output) powerOff() error {
484 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {486 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
485 return fmt.Errorf("power off: %w", err)487 return fmt.Errorf("power off: %w", err)
486 }488 }
487- o.off, o.vrrOn = true, false489+ o.off, o.vrrOn, o.vrrGame = true, false, false
488 o.log.Info().Str("connector", o.conn.name).Msg("power off")490 o.log.Info().Str("connector", o.conn.name).Msg("power off")
489 // An inactive output offers neither HDR nor direct scanout.491 // An inactive output offers neither HDR nor direct scanout.
490 o.sendFormats()492 o.sendFormats()
@@ -608,14 +610,16 @@ func (o *Output) probeAsync(r ports.Renderer) {
608 o.log.Info().Err(err).Str("connector", o.conn.name).Bool("async_fence", o.asyncFence).Msg("tearing probe")610 o.log.Info().Err(err).Str("connector", o.conn.name).Bool("async_fence", o.asyncFence).Msg("tearing probe")
609 }611 }
610 612
611-// wantVRR is the VRR state the next frame commit sets: on while a buffer613+// wantVRR is the VRR state the next frame commit sets: on while game (a
612-// is scanned out, off after composing for vrrHold (each toggle may614+// buffer is scanned out or a fullscreen window covers the output), off
613-// flicker, so short compositions keep it).615+// after vrrHold without it (each toggle may flicker, so short breaks keep
614-func (o *Output) wantVRR(direct bool) bool {616+// it).
617+func (o *Output) wantVRR(game bool) bool {
615 if o.vrrProp == 0 {618 if o.vrrProp == 0 {
616 return false619 return false
617 }620 }
618- if direct {621+ o.vrrGame = game
622+ if game {
619 o.composedSince = time.Time{}623 o.composedSince = time.Time{}
620 return true624 return true
621 }625 }
@@ -625,6 +629,11 @@ func (o *Output) wantVRR(direct bool) bool {
625 return o.vrrOn && time.Since(o.composedSince) <= vrrHold629 return o.vrrOn && time.Since(o.composedSince) <= vrrHold
626 }630 }
627 631
632+// stateVRR is the VRR state of a commit without a new frame (cursor,
633+// vrrOff timer): the last frame's. A shown buffer is not enough: it may
634+// be a tiled window on the overlay plane.
635+func (o *Output) stateVRR() bool { return o.wantVRR(o.vrrGame) }
636+
628 // commitFrame flips fb in one commit with the cursor and VRR. An async637 // commitFrame flips fb in one commit with the cursor and VRR. An async
629 // commit carries only the primary plane (kernel rule): a frame that must638 // commit carries only the primary plane (kernel rule): a frame that must
630 // also move the cursor or change VRR flips at vblank. fence is the639 // also move the cursor or change VRR flips at vblank. fence is the
@@ -667,8 +676,9 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
667 }676 }
668 if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) {677 if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) {
669 // Retry without turning VRR on: if that passes, the driver678 // Retry without turning VRR on: if that passes, the driver
670- // refuses VRR on this output.679+ // refuses VRR on this output. The overlay stays: the composed
671- if o.commitFrame(fb, fence, false, false, f) == nil {680+ // image left its window out.
681+ if o.commitWith(fb, fence, false, false, f, ov) == nil {
672 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr refused; disabled")682 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr refused; disabled")
673 o.vrrProp = 0683 o.vrrProp = 0
674 return nil684 return nil
@@ -1156,7 +1166,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1156 }1166 }
1157 if !dirty || !haveScene {1167 if !dirty || !haveScene {
1158 if stateDirty {1168 if stateDirty {
1159- err := o.commitState(o.wantVRR(o.shown != 0))1169+ err := o.commitState(o.stateVRR())
1160 if errors.Is(err, errOverlayDropped) {1170 if errors.Is(err, errOverlayDropped) {
1161 dirty = haveScene1171 dirty = haveScene
1162 continue1172 continue
@@ -1215,7 +1225,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1215 // The overlay buffer is on screen like a scanned-out one: it1225 // The overlay buffer is on screen like a scanned-out one: it
1216 // is reported shown, so it is not released under the plane.1226 // is reported shown, so it is not released under the plane.
1217 f.queued, f.zeroCopy, f.composed = ov.buf, ov.id, true1227 f.queued, f.zeroCopy, f.composed = ov.buf, ov.id, true
1218- err = o.commitWith(o.fbs[o.back], done, false, o.wantVRR(false), f, ov)1228+ // A covering fullscreen window keeps VRR while composed
1229+ // (overlay layer, subsurfaces, size mismatch).
1230+ game := o.vrrProp != 0 && fullscreenShown(&scene)
1231+ err = o.commitWith(o.fbs[o.back], done, false, o.wantVRR(game), f, ov)
1219 if err != nil {1232 if err != nil {
1220 // The GPU may still read client buffers for this frame.1233 // The GPU may still read client buffers for this frame.
1221 o.holdRead(done)1234 o.holdRead(done)
M internal/adapters/drm/output_test.go +116 -0
@@ -359,6 +359,53 @@ func TestVRRToggleOnlyInsideFrames(t *testing.T) {
359359 }
360360 }
361361
362+// A composed fullscreen frame keeps VRR and restarts the hold; state
363+// commits (cursor, vrrOff timer) keep it too until the fullscreen ends.
364+func TestVRRKeptWhileFullscreenComposed(t *testing.T) {
365+ o, _, _ := testOutput(t)
366+ o.vrrOn = true
367+ o.composedSince = time.Now().Add(-time.Second)
368+ if !o.wantVRR(true) || !o.composedSince.IsZero() {
369+ t.Fatal("composed fullscreen dropped VRR")
370+ }
371+ // Cursor-only state commit between frames.
372+ if !o.stateVRR() {
373+ t.Fatal("state commit dropped VRR during fullscreen")
374+ }
375+ // Fullscreen ends, a tiled window stays on the overlay plane: the
376+ // hold starts, then VRR goes off.
377+ o.shown = 9
378+ if !o.wantVRR(false) || o.composedSince.IsZero() {
379+ t.Fatal("hold not started on exit")
380+ }
381+ o.composedSince = time.Now().Add(-time.Second)
382+ if o.stateVRR() {
383+ t.Fatal("VRR kept after the hold (overlay buffer taken for a game)")
384+ }
385+}
386+
387+// A VRR refusal on a composed frame with an overlay retries with the
388+// overlay: the composed image left its window out.
389+func TestVRRRefusedKeepsOverlay(t *testing.T) {
390+ o, k, commits := overlayOutput(t, unix.EINVAL)
391+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()
392+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
393+ s, c := overlayScene()
394+ ov, _ := o.overlayFrame(s, c)
395+ if ov.fb == 0 {
396+ t.Fatal("no overlay")
397+ }
398+ if err := o.commitWith(70, nil, false, true, pendingFrame{queued: ov.buf}, ov); err != nil {
399+ t.Fatal(err)
400+ }
401+ if len(*commits) != 2 || o.vrrProp != 0 {
402+ t.Fatalf("%d commits, vrrProp %d", len(*commits), o.vrrProp)
403+ }
404+ if v, _ := (*commits)[1].req.value(tOverlay, pFB); v != 88 || o.overlayOn != ov.buf {
405+ t.Fatalf("retry overlay fb %d, overlayOn %d", v, o.overlayOn)
406+ }
407+}
408+
362409 func TestReadPlanes(t *testing.T) {
363410 k := newMockkms(t)
364411 k.EXPECT().planes().Return([]planeRes{{id: 1, possible: 1}, {id: 2, possible: 2}, {id: 3, possible: 3}}, nil)
@@ -482,6 +529,75 @@ func TestRunReportsSeenAfterFlip(t *testing.T) {
482529 }
483530 }
484531
532+// Run: a fullscreen window composed under an overlay layer keeps VRR on in
533+// the frame commit; a tiled scene turns it off only after vrrHold.
534+func TestRunComposedFullscreenKeepsVRR(t *testing.T) {
535+ o, k, commits, commitMu := testOutputMu(t)
536+ o.cursor, o.tearing = nil, false
537+ flips := make(chan flipEvent, 1)
538+ o.flipped = flips
539+ r := portsmocks.NewMockRenderer(t)
540+ pipeBuf := func() ports.DMABuf {
541+ f, w, _ := os.Pipe()
542+ w.Close()
543+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
544+ }
545+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
546+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()
547+ r.EXPECT().UseTarget(mock.Anything).Return()
548+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
549+ r.EXPECT().Close().Return().Once()
550+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
551+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
552+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
553+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
554+ lastFrame := func() (commitRec, int) {
555+ commitMu.Lock()
556+ defer commitMu.Unlock()
557+ n, last := 0, commitRec{}
558+ for _, c := range *commits {
559+ if c.user&3 == userFrame {
560+ n, last = n+1, c
561+ }
562+ }
563+ return last, n
564+ }
565+ scenes := make(chan ports.Scene, 1)
566+ ctx, cancel := context.WithCancel(context.Background())
567+ done := make(chan error, 1)
568+ go func() {
569+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, make(chan ports.OutputPresented, 8), nil, nil)
570+ }()
571+ scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100,
572+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}},
573+ Layers: []ports.SceneLayer{{ID: 5, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 10, H: 10}}}}
574+ waitFor(t, func() bool { _, n := lastFrame(); return n == 1 })
575+ last, _ := lastFrame()
576+ if v, ok := last.req.value(tCrtc, pVRR); !ok || v != 1 {
577+ t.Fatalf("composed fullscreen frame VRR %d %v", v, ok)
578+ }
579+ flips <- flipEvent{crtc: tCrtc, user: last.user, when: time.Second, seq: 1}
580+ scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 100, H: 100}}}}
581+ waitFor(t, func() bool { _, n := lastFrame(); return n == 2 })
582+ last, _ = lastFrame()
583+ if v, _ := last.req.value(tCrtc, pVRR); v != 1 {
584+ t.Fatal("VRR dropped before vrrHold")
585+ }
586+ flips <- flipEvent{crtc: tCrtc, user: last.user, when: 2 * time.Second, seq: 2}
587+ // The vrrOff timer turns VRR off with a state commit after the hold.
588+ waitFor(t, func() bool {
589+ commitMu.Lock()
590+ defer commitMu.Unlock()
591+ c := (*commits)[len(*commits)-1]
592+ v, ok := c.req.value(tCrtc, pVRR)
593+ return c.user&3 == userState && ok && v == 0
594+ })
595+ cancel()
596+ if err := <-done; err != nil {
597+ t.Fatal(err)
598+ }
599+}
600+
485601 func waitFor(t *testing.T, cond func() bool) {
486602 t.Helper()
487603 deadline := time.Now().Add(2 * time.Second)
@@ -359,6 +359,53 @@ func TestVRRToggleOnlyInsideFrames(t *testing.T) {
359 }359 }
360 }360 }
361 361
362+// A composed fullscreen frame keeps VRR and restarts the hold; state
363+// commits (cursor, vrrOff timer) keep it too until the fullscreen ends.
364+func TestVRRKeptWhileFullscreenComposed(t *testing.T) {
365+ o, _, _ := testOutput(t)
366+ o.vrrOn = true
367+ o.composedSince = time.Now().Add(-time.Second)
368+ if !o.wantVRR(true) || !o.composedSince.IsZero() {
369+ t.Fatal("composed fullscreen dropped VRR")
370+ }
371+ // Cursor-only state commit between frames.
372+ if !o.stateVRR() {
373+ t.Fatal("state commit dropped VRR during fullscreen")
374+ }
375+ // Fullscreen ends, a tiled window stays on the overlay plane: the
376+ // hold starts, then VRR goes off.
377+ o.shown = 9
378+ if !o.wantVRR(false) || o.composedSince.IsZero() {
379+ t.Fatal("hold not started on exit")
380+ }
381+ o.composedSince = time.Now().Add(-time.Second)
382+ if o.stateVRR() {
383+ t.Fatal("VRR kept after the hold (overlay buffer taken for a game)")
384+ }
385+}
386+
387+// A VRR refusal on a composed frame with an overlay retries with the
388+// overlay: the composed image left its window out.
389+func TestVRRRefusedKeepsOverlay(t *testing.T) {
390+ o, k, commits := overlayOutput(t, unix.EINVAL)
391+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()
392+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
393+ s, c := overlayScene()
394+ ov, _ := o.overlayFrame(s, c)
395+ if ov.fb == 0 {
396+ t.Fatal("no overlay")
397+ }
398+ if err := o.commitWith(70, nil, false, true, pendingFrame{queued: ov.buf}, ov); err != nil {
399+ t.Fatal(err)
400+ }
401+ if len(*commits) != 2 || o.vrrProp != 0 {
402+ t.Fatalf("%d commits, vrrProp %d", len(*commits), o.vrrProp)
403+ }
404+ if v, _ := (*commits)[1].req.value(tOverlay, pFB); v != 88 || o.overlayOn != ov.buf {
405+ t.Fatalf("retry overlay fb %d, overlayOn %d", v, o.overlayOn)
406+ }
407+}
408+
362 func TestReadPlanes(t *testing.T) {409 func TestReadPlanes(t *testing.T) {
363 k := newMockkms(t)410 k := newMockkms(t)
364 k.EXPECT().planes().Return([]planeRes{{id: 1, possible: 1}, {id: 2, possible: 2}, {id: 3, possible: 3}}, nil)411 k.EXPECT().planes().Return([]planeRes{{id: 1, possible: 1}, {id: 2, possible: 2}, {id: 3, possible: 3}}, nil)
@@ -482,6 +529,75 @@ func TestRunReportsSeenAfterFlip(t *testing.T) {
482 }529 }
483 }530 }
484 531
532+// Run: a fullscreen window composed under an overlay layer keeps VRR on in
533+// the frame commit; a tiled scene turns it off only after vrrHold.
534+func TestRunComposedFullscreenKeepsVRR(t *testing.T) {
535+ o, k, commits, commitMu := testOutputMu(t)
536+ o.cursor, o.tearing = nil, false
537+ flips := make(chan flipEvent, 1)
538+ o.flipped = flips
539+ r := portsmocks.NewMockRenderer(t)
540+ pipeBuf := func() ports.DMABuf {
541+ f, w, _ := os.Pipe()
542+ w.Close()
543+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
544+ }
545+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
546+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()
547+ r.EXPECT().UseTarget(mock.Anything).Return()
548+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
549+ r.EXPECT().Close().Return().Once()
550+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
551+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
552+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
553+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
554+ lastFrame := func() (commitRec, int) {
555+ commitMu.Lock()
556+ defer commitMu.Unlock()
557+ n, last := 0, commitRec{}
558+ for _, c := range *commits {
559+ if c.user&3 == userFrame {
560+ n, last = n+1, c
561+ }
562+ }
563+ return last, n
564+ }
565+ scenes := make(chan ports.Scene, 1)
566+ ctx, cancel := context.WithCancel(context.Background())
567+ done := make(chan error, 1)
568+ go func() {
569+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, make(chan ports.OutputPresented, 8), nil, nil)
570+ }()
571+ scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100,
572+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}},
573+ Layers: []ports.SceneLayer{{ID: 5, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 10, H: 10}}}}
574+ waitFor(t, func() bool { _, n := lastFrame(); return n == 1 })
575+ last, _ := lastFrame()
576+ if v, ok := last.req.value(tCrtc, pVRR); !ok || v != 1 {
577+ t.Fatalf("composed fullscreen frame VRR %d %v", v, ok)
578+ }
579+ flips <- flipEvent{crtc: tCrtc, user: last.user, when: time.Second, seq: 1}
580+ scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 100, H: 100}}}}
581+ waitFor(t, func() bool { _, n := lastFrame(); return n == 2 })
582+ last, _ = lastFrame()
583+ if v, _ := last.req.value(tCrtc, pVRR); v != 1 {
584+ t.Fatal("VRR dropped before vrrHold")
585+ }
586+ flips <- flipEvent{crtc: tCrtc, user: last.user, when: 2 * time.Second, seq: 2}
587+ // The vrrOff timer turns VRR off with a state commit after the hold.
588+ waitFor(t, func() bool {
589+ commitMu.Lock()
590+ defer commitMu.Unlock()
591+ c := (*commits)[len(*commits)-1]
592+ v, ok := c.req.value(tCrtc, pVRR)
593+ return c.user&3 == userState && ok && v == 0
594+ })
595+ cancel()
596+ if err := <-done; err != nil {
597+ t.Fatal(err)
598+ }
599+}
600+
485 func waitFor(t *testing.T, cond func() bool) {601 func waitFor(t *testing.T, cond func() bool) {
486 t.Helper()602 t.Helper()
487 deadline := time.Now().Add(2 * time.Second)603 deadline := time.Now().Add(2 * time.Second)
M internal/adapters/drm/scanout.go +18 -2
@@ -43,6 +43,23 @@ type fbCmd2 struct {
4343 modifiers [4]uint64
4444 }
4545
46+// covers reports whether win fills the output of scene s.
47+func covers(s *ports.Scene, win *ports.SceneWindow) bool {
48+ return win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight
49+}
50+
51+// fullscreenShown reports whether a visible fullscreen window covers the
52+// output, whether its frame is scanned out or composed.
53+func fullscreenShown(s *ports.Scene) bool {
54+ for i := range s.Windows {
55+ win := &s.Windows[i]
56+ if win.Fullscreen && !win.Hidden && covers(s, win) {
57+ return true
58+ }
59+ }
60+ return false
61+}
62+
4663 // scanoutCandidate returns the window content the output can scan out
4764 // directly, or a reason why it cannot. w, h are the output's physical size.
4865 // The plane's formats are checked by scanoutFB.
@@ -53,8 +70,7 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
5370 if win.Hidden || win.Rect.W <= 0 || win.Rect.H <= 0 {
5471 continue
5572 }
56- covers := win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight
57- if !win.Fullscreen || !covers || full != nil {
73+ if !win.Fullscreen || !covers(&s, win) || full != nil {
5874 return ports.SurfaceContent{}, "other_windows"
5975 }
6076 full = win
@@ -43,6 +43,23 @@ type fbCmd2 struct {
43 modifiers [4]uint6443 modifiers [4]uint64
44 }44 }
45 45
46+// covers reports whether win fills the output of scene s.
47+func covers(s *ports.Scene, win *ports.SceneWindow) bool {
48+ return win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight
49+}
50+
51+// fullscreenShown reports whether a visible fullscreen window covers the
52+// output, whether its frame is scanned out or composed.
53+func fullscreenShown(s *ports.Scene) bool {
54+ for i := range s.Windows {
55+ win := &s.Windows[i]
56+ if win.Fullscreen && !win.Hidden && covers(s, win) {
57+ return true
58+ }
59+ }
60+ return false
61+}
62+
46 // scanoutCandidate returns the window content the output can scan out63 // scanoutCandidate returns the window content the output can scan out
47 // directly, or a reason why it cannot. w, h are the output's physical size.64 // directly, or a reason why it cannot. w, h are the output's physical size.
48 // The plane's formats are checked by scanoutFB.65 // The plane's formats are checked by scanoutFB.
@@ -53,8 +70,7 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
53 if win.Hidden || win.Rect.W <= 0 || win.Rect.H <= 0 {70 if win.Hidden || win.Rect.W <= 0 || win.Rect.H <= 0 {
54 continue71 continue
55 }72 }
56- covers := win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight73+ if !win.Fullscreen || !covers(&s, win) || full != nil {
57- if !win.Fullscreen || !covers || full != nil {
58 return ports.SurfaceContent{}, "other_windows"74 return ports.SurfaceContent{}, "other_windows"
59 }75 }
60 full = win76 full = win
M internal/adapters/drm/scanout_test.go +50 -0
@@ -57,6 +57,56 @@ func TestScanoutCandidate(t *testing.T) {
5757 }
5858 }
5959
60+// VRR follows a visible fullscreen window covering the output, even
61+// when other windows or layers force composition.
62+func TestFullscreenShown(t *testing.T) {
63+ full := ports.SceneWindow{ID: 1, Rect: ports.Rect{W: 100, H: 50}, Fullscreen: true}
64+ for _, tc := range []struct {
65+ name string
66+ wins []ports.SceneWindow
67+ want bool
68+ }{
69+ {"fullscreen", []ports.SceneWindow{full}, true},
70+ {"with dialog above", []ports.SceneWindow{full, {ID: 2, Rect: ports.Rect{W: 10, H: 10}, Floating: true}}, true},
71+ {"hidden", []ports.SceneWindow{{ID: 1, Rect: full.Rect, Fullscreen: true, Hidden: true}}, false},
72+ {"off screen", []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 100, W: 100, H: 50}, Fullscreen: true}}, false},
73+ {"tiled covering", []ports.SceneWindow{{ID: 1, Rect: full.Rect}}, false},
74+ {"none", nil, false},
75+ } {
76+ s := ports.Scene{OutputWidth: 100, OutputHeight: 50, Windows: tc.wins}
77+ if got := fullscreenShown(&s); got != tc.want {
78+ t.Errorf("%s: %v, want %v", tc.name, got, tc.want)
79+ }
80+ }
81+}
82+
83+// fullscreenShown runs on every composed frame of a VRR output.
84+func TestFullscreenShownAllocations(t *testing.T) {
85+ s := tiledFullscreenScene()
86+ if allocs := testing.AllocsPerRun(100, func() { fullscreenShown(&s) }); allocs != 0 {
87+ t.Errorf("fullscreenShown: %.1f allocs, want 0", allocs)
88+ }
89+}
90+
91+func BenchmarkFullscreenShown(b *testing.B) {
92+ s := tiledFullscreenScene()
93+ b.ReportAllocs()
94+ for b.Loop() {
95+ fullscreenShown(&s)
96+ }
97+}
98+
99+// tiledFullscreenScene is 64 tiled windows with the fullscreen one last
100+// (worst case for the walk).
101+func tiledFullscreenScene() ports.Scene {
102+ s := ports.Scene{OutputWidth: 3840, OutputHeight: 2160}
103+ for i := range 64 {
104+ s.Windows = append(s.Windows, ports.SceneWindow{ID: ports.WindowID(i + 1), Rect: ports.Rect{X: i * 60, W: 60, H: 2160}})
105+ }
106+ s.Windows = append(s.Windows, ports.SceneWindow{ID: 99, Rect: ports.Rect{W: 3840, H: 2160}, Fullscreen: true})
107+ return s
108+}
109+
60110 // A buffer scans out when the primary plane lists its format (or the
61111 // opaque variant) with its modifier; the tranche offers only formats the
62112 // renderer also composes.
@@ -57,6 +57,56 @@ func TestScanoutCandidate(t *testing.T) {
57 }57 }
58 }58 }
59 59
60+// VRR follows a visible fullscreen window covering the output, even
61+// when other windows or layers force composition.
62+func TestFullscreenShown(t *testing.T) {
63+ full := ports.SceneWindow{ID: 1, Rect: ports.Rect{W: 100, H: 50}, Fullscreen: true}
64+ for _, tc := range []struct {
65+ name string
66+ wins []ports.SceneWindow
67+ want bool
68+ }{
69+ {"fullscreen", []ports.SceneWindow{full}, true},
70+ {"with dialog above", []ports.SceneWindow{full, {ID: 2, Rect: ports.Rect{W: 10, H: 10}, Floating: true}}, true},
71+ {"hidden", []ports.SceneWindow{{ID: 1, Rect: full.Rect, Fullscreen: true, Hidden: true}}, false},
72+ {"off screen", []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 100, W: 100, H: 50}, Fullscreen: true}}, false},
73+ {"tiled covering", []ports.SceneWindow{{ID: 1, Rect: full.Rect}}, false},
74+ {"none", nil, false},
75+ } {
76+ s := ports.Scene{OutputWidth: 100, OutputHeight: 50, Windows: tc.wins}
77+ if got := fullscreenShown(&s); got != tc.want {
78+ t.Errorf("%s: %v, want %v", tc.name, got, tc.want)
79+ }
80+ }
81+}
82+
83+// fullscreenShown runs on every composed frame of a VRR output.
84+func TestFullscreenShownAllocations(t *testing.T) {
85+ s := tiledFullscreenScene()
86+ if allocs := testing.AllocsPerRun(100, func() { fullscreenShown(&s) }); allocs != 0 {
87+ t.Errorf("fullscreenShown: %.1f allocs, want 0", allocs)
88+ }
89+}
90+
91+func BenchmarkFullscreenShown(b *testing.B) {
92+ s := tiledFullscreenScene()
93+ b.ReportAllocs()
94+ for b.Loop() {
95+ fullscreenShown(&s)
96+ }
97+}
98+
99+// tiledFullscreenScene is 64 tiled windows with the fullscreen one last
100+// (worst case for the walk).
101+func tiledFullscreenScene() ports.Scene {
102+ s := ports.Scene{OutputWidth: 3840, OutputHeight: 2160}
103+ for i := range 64 {
104+ s.Windows = append(s.Windows, ports.SceneWindow{ID: ports.WindowID(i + 1), Rect: ports.Rect{X: i * 60, W: 60, H: 2160}})
105+ }
106+ s.Windows = append(s.Windows, ports.SceneWindow{ID: 99, Rect: ports.Rect{W: 3840, H: 2160}, Fullscreen: true})
107+ return s
108+}
109+
60 // A buffer scans out when the primary plane lists its format (or the110 // A buffer scans out when the primary plane lists its format (or the
61 // opaque variant) with its modifier; the tranche offers only formats the111 // opaque variant) with its modifier; the tranche offers only formats the
62 // renderer also composes.112 // renderer also composes.

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