detached 1d16b83

fix(drm): keep a gap after each VRR game flip (#63)

* fix(logging): keep log runs per backend

Headless runs (tests, harnesses) rotated the shared runs directory and
deleted the log of a running DRM session. Runs now go to
runs/<backend>/ with their own latest.log and 20-run rotation.

* feat(drm): add drm-flip flip timing trace and trim debug noise

--debug=drm-flip logs each commit completion with commit-to-flip time,
flip interval and the client fence signal time (SYNC_IOC_FILE_INFO),
to tell a late client frame from a late flip. The per-frame 'frame'
entry moves under it. --debug=all no longer logs typed keys (now
input-keys) nor every game cursor image, and repeats of the same
cursor shape are not logged. make tty takes TTY_DEBUG for these
categories.

* fix(drm): keep a gap after each VRR game flip

On amdgpu with a 48-165 Hz panel, game frames committed right after
the previous flip event sometimes missed the early refresh and flipped
only at the VRR timeout (~20.8 ms), and the next frame then landed at
the same point: runs of 5-30 frames at 48 Hz while the game rendered at
120-165 fps. drm-flip traces showed the client fence already signalled
at commit.

Game frames under VRR now commit at least render.vrr-flip-gap after the
previous flip (default 1ms, 0 turns it off). A 3-minute run with the
gap had no slow flip in gameplay. vrr_flip_gap.go records the
measurements.
commit
1d16b83bf90f9e8189ad21b27772382c96b9f9a8
author
bnema · · 2026-09-29 11:59 UTC
committer
GitHub · · 2026-09-29 11:59 UTC
parent
3adce45
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
22 files · +553 -36
M Makefile +4 -2
@@ -2,17 +2,19 @@
22
33 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.
44 TTY_TIMEOUT ?= 0
5+comma := ,
56 # Go runtime profiles for tty runs; set PPROF= to disable.
67 PPROF ?= localhost:6060
7-RUNS := $(or $(XDG_STATE_HOME),$(HOME)/.local/state)/neferwl/runs
8+RUNS := $(or $(XDG_STATE_HOME),$(HOME)/.local/state)/neferwl/runs/drm
89
910 # Build a fresh binary with the git revision embedded.
1011 bin:
1112 CGO_ENABLED=0 go build -o bin/neferwl ./cmd/neferwl
1213
1314 # Run on the current TTY. Quit: Ctrl+Alt+Backspace (or the quit bind).
15+# TTY_DEBUG adds narrow categories, e.g. TTY_DEBUG=drm-flip.
1416 tty: bin
15- ./bin/neferwl --backend=drm --debug=all --timeout=$(TTY_TIMEOUT) $(if $(PPROF),--pprof=$(PPROF)); \
17+ ./bin/neferwl --backend=drm --debug=all$(if $(TTY_DEBUG),$(comma)$(TTY_DEBUG)) --timeout=$(TTY_TIMEOUT) $(if $(PPROF),--pprof=$(PPROF)); \
1618 echo "exit $$? - log: $(RUNS)/latest.log"
1719
1820 # Summarise the last run: lifecycle, warnings and errors.
@@ -2,17 +2,19 @@
2 2
3 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.3 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.
4 TTY_TIMEOUT ?= 04 TTY_TIMEOUT ?= 0
5+comma := ,
5 # Go runtime profiles for tty runs; set PPROF= to disable.6 # Go runtime profiles for tty runs; set PPROF= to disable.
6 PPROF ?= localhost:60607 PPROF ?= localhost:6060
7-RUNS := $(or $(XDG_STATE_HOME),$(HOME)/.local/state)/neferwl/runs8+RUNS := $(or $(XDG_STATE_HOME),$(HOME)/.local/state)/neferwl/runs/drm
8 9
9 # Build a fresh binary with the git revision embedded.10 # Build a fresh binary with the git revision embedded.
10 bin:11 bin:
11 CGO_ENABLED=0 go build -o bin/neferwl ./cmd/neferwl12 CGO_ENABLED=0 go build -o bin/neferwl ./cmd/neferwl
12 13
13 # Run on the current TTY. Quit: Ctrl+Alt+Backspace (or the quit bind).14 # Run on the current TTY. Quit: Ctrl+Alt+Backspace (or the quit bind).
15+# TTY_DEBUG adds narrow categories, e.g. TTY_DEBUG=drm-flip.
14 tty: bin16 tty: bin
15- ./bin/neferwl --backend=drm --debug=all --timeout=$(TTY_TIMEOUT) $(if $(PPROF),--pprof=$(PPROF)); \17+ ./bin/neferwl --backend=drm --debug=all$(if $(TTY_DEBUG),$(comma)$(TTY_DEBUG)) --timeout=$(TTY_TIMEOUT) $(if $(PPROF),--pprof=$(PPROF)); \
16 echo "exit $$? - log: $(RUNS)/latest.log"18 echo "exit $$? - log: $(RUNS)/latest.log"
17 19
18 # Summarise the last run: lifecycle, warnings and errors.20 # Summarise the last run: lifecycle, warnings and errors.
M cmd/neferwl/main.go +2 -2
@@ -144,7 +144,7 @@ func run() error {
144144 noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps")
145145 headlessHDR := flags.Bool("headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable")
146146 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
147- debugFlag := flags.String("debug", "", "debug components (comma-separated or all; input-motion logs every pointer motion)")
147+ debugFlag := flags.String("debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion and input-keys, which log every flip, pointer motion or key)")
148148 configFlag := flags.String("config", "", "config path (empty uses XDG default)")
149149 session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")
150150 pprofAddr := flags.String("pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")
@@ -235,7 +235,7 @@ func run() error {
235235 }
236236 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
237237 defer stop()
238- ctx, closeLog, err := logging.Open(ctx, cfg.Log.Level, selected)
238+ ctx, closeLog, err := logging.Open(ctx, *backend, cfg.Log.Level, selected)
239239 if err != nil {
240240 fmt.Fprintln(os.Stderr, err)
241241 return err
@@ -144,7 +144,7 @@ func run() error {
144 noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps")144 noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps")
145 headlessHDR := flags.Bool("headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable")145 headlessHDR := flags.Bool("headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable")
146 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")146 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
147- debugFlag := flags.String("debug", "", "debug components (comma-separated or all; input-motion logs every pointer motion)")147+ debugFlag := flags.String("debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion and input-keys, which log every flip, pointer motion or key)")
148 configFlag := flags.String("config", "", "config path (empty uses XDG default)")148 configFlag := flags.String("config", "", "config path (empty uses XDG default)")
149 session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")149 session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")
150 pprofAddr := flags.String("pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")150 pprofAddr := flags.String("pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")
@@ -235,7 +235,7 @@ func run() error {
235 }235 }
236 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)236 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
237 defer stop()237 defer stop()
238- ctx, closeLog, err := logging.Open(ctx, cfg.Log.Level, selected)238+ ctx, closeLog, err := logging.Open(ctx, *backend, cfg.Log.Level, selected)
239 if err != nil {239 if err != nil {
240 fmt.Fprintln(os.Stderr, err)240 fmt.Fprintln(os.Stderr, err)
241 return err241 return err
M docs/config.md +1 -0
@@ -49,6 +49,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
4949 | `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) |
5050 | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display |
5151 | `render.tearing` / `render.vrr` | `on` / `on` | Tearing: games in direct scanout. VRR: any fullscreen window covering the output |
52+| `render.vrr-flip-gap` | `1ms` | Under VRR, a game frame commits at least this long after the previous flip, so it does not wait for the panel's slowest refresh (0 to 10ms; `0` turns it off) |
5253 | `performance.realtime` | `on` | Request real-time scheduling for output and input threads |
5354 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |
5455 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |
@@ -49,6 +49,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
49 | `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) |49 | `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) |
50 | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display |50 | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display |
51 | `render.tearing` / `render.vrr` | `on` / `on` | Tearing: games in direct scanout. VRR: any fullscreen window covering the output |51 | `render.tearing` / `render.vrr` | `on` / `on` | Tearing: games in direct scanout. VRR: any fullscreen window covering the output |
52+| `render.vrr-flip-gap` | `1ms` | Under VRR, a game frame commits at least this long after the previous flip, so it does not wait for the panel's slowest refresh (0 to 10ms; `0` turns it off) |
52 | `performance.realtime` | `on` | Request real-time scheduling for output and input threads |53 | `performance.realtime` | `on` | Request real-time scheduling for output and input threads |
53 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |54 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |
54 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |55 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |
M docs/performance.md +5 -0
@@ -7,6 +7,7 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen
77 - **No reflection on the FFI boundary.** libwayland and Vulkan are called through `purego.Syscall6/Syscall15` (fixed arity, zero allocation) and C calls back into Go through `purego.NewCallbackInts`. The Vulkan dispatch tables are generated methods; the libwayland wrapper caches resource id, version and client, and precomputes request argument counts.
88 - **No allocation per frame or per request on the steady-state path.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.
99 - **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz.
10+- **Game frames keep a short gap after each VRR flip.** On amdgpu, a flip committed right after the previous flip event can miss the early refresh and wait for the panel's slowest rate (~21 ms at 48 Hz); the next frame then lands at the same point, and the screen stays at 48 Hz while the game renders at 150 fps. Game frames under VRR therefore commit at least `render.vrr-flip-gap` (default 1 ms) after the previous flip. `internal/adapters/drm/vrr_flip_gap.go` has the measurements.
1011 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.
1112
1213 Output and input threads request real-time scheduling, and the Vulkan queue requests elevated priority. Both require CAP_SYS_NICE, which the Arch packages set on `/usr/bin/neferwl` at install. Apps launched by NeferWL inherit neither. Set `performance.realtime = false` to opt out. A binary built with `make bin` runs at normal priority unless you grant it: `sudo setcap cap_sys_nice+ep bin/neferwl`.
@@ -38,6 +39,10 @@ GODEBUG=gctrace=1 neferwl --backend=drm
3839
3940 `slow flip` log entries (flip over 20 ms) carry `frame` (false: cursor or VRR state commit) and `vrr`; `input stats` shows coalesced pointer motion and the longest wait for core.
4041
42+`--debug=drm-flip` (or `make tty TTY_DEBUG=drm-flip`) logs every commit completion as a `flip` entry: `commit_to_flip_ms`, `flip_interval_ms` (between frame flips), `fence_ready_at_commit`, and, when the client fence reports its signal time, `commit_to_fence_ms` and `fence_to_flip_ms`. A negative `commit_to_fence_ms` means the client finished before the commit.
43+
44+Logs go to `$XDG_STATE_HOME/neferwl/runs/<backend>/`, one file per run with `latest.log` pointing at the newest; each backend keeps its last 20 runs, so headless test runs never rotate a DRM session's log away. `--debug=all` leaves out the per-event categories `drm-flip`, `input-motion` and `input-keys` (typed text); name them to turn them on.
45+
4146 ## Reference: 4K HDR tiled video playback
4247
4348 Workload: a browser playing 4K HDR video with 65 tiled subsurfaces committing every frame, DRM backend, `perf record -g` for 8 s. Percentages are the share of the process's own samples (inclusive).
@@ -7,6 +7,7 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen
7 - **No reflection on the FFI boundary.** libwayland and Vulkan are called through `purego.Syscall6/Syscall15` (fixed arity, zero allocation) and C calls back into Go through `purego.NewCallbackInts`. The Vulkan dispatch tables are generated methods; the libwayland wrapper caches resource id, version and client, and precomputes request argument counts.7 - **No reflection on the FFI boundary.** libwayland and Vulkan are called through `purego.Syscall6/Syscall15` (fixed arity, zero allocation) and C calls back into Go through `purego.NewCallbackInts`. The Vulkan dispatch tables are generated methods; the libwayland wrapper caches resource id, version and client, and precomputes request argument counts.
8 - **No allocation per frame or per request on the steady-state path.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.8 - **No allocation per frame or per request on the steady-state path.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.
9 - **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz.9 - **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz.
10+- **Game frames keep a short gap after each VRR flip.** On amdgpu, a flip committed right after the previous flip event can miss the early refresh and wait for the panel's slowest rate (~21 ms at 48 Hz); the next frame then lands at the same point, and the screen stays at 48 Hz while the game renders at 150 fps. Game frames under VRR therefore commit at least `render.vrr-flip-gap` (default 1 ms) after the previous flip. `internal/adapters/drm/vrr_flip_gap.go` has the measurements.
10 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.11 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.
11 12
12 Output and input threads request real-time scheduling, and the Vulkan queue requests elevated priority. Both require CAP_SYS_NICE, which the Arch packages set on `/usr/bin/neferwl` at install. Apps launched by NeferWL inherit neither. Set `performance.realtime = false` to opt out. A binary built with `make bin` runs at normal priority unless you grant it: `sudo setcap cap_sys_nice+ep bin/neferwl`.13 Output and input threads request real-time scheduling, and the Vulkan queue requests elevated priority. Both require CAP_SYS_NICE, which the Arch packages set on `/usr/bin/neferwl` at install. Apps launched by NeferWL inherit neither. Set `performance.realtime = false` to opt out. A binary built with `make bin` runs at normal priority unless you grant it: `sudo setcap cap_sys_nice+ep bin/neferwl`.
@@ -38,6 +39,10 @@ GODEBUG=gctrace=1 neferwl --backend=drm
38 39
39 `slow flip` log entries (flip over 20 ms) carry `frame` (false: cursor or VRR state commit) and `vrr`; `input stats` shows coalesced pointer motion and the longest wait for core.40 `slow flip` log entries (flip over 20 ms) carry `frame` (false: cursor or VRR state commit) and `vrr`; `input stats` shows coalesced pointer motion and the longest wait for core.
40 41
42+`--debug=drm-flip` (or `make tty TTY_DEBUG=drm-flip`) logs every commit completion as a `flip` entry: `commit_to_flip_ms`, `flip_interval_ms` (between frame flips), `fence_ready_at_commit`, and, when the client fence reports its signal time, `commit_to_fence_ms` and `fence_to_flip_ms`. A negative `commit_to_fence_ms` means the client finished before the commit.
43+
44+Logs go to `$XDG_STATE_HOME/neferwl/runs/<backend>/`, one file per run with `latest.log` pointing at the newest; each backend keeps its last 20 runs, so headless test runs never rotate a DRM session's log away. `--debug=all` leaves out the per-event categories `drm-flip`, `input-motion` and `input-keys` (typed text); name them to turn them on.
45+
41 ## Reference: 4K HDR tiled video playback46 ## Reference: 4K HDR tiled video playback
42 47
43 Workload: a browser playing 4K HDR video with 65 tiled subsurfaces committing every frame, DRM backend, `perf record -g` for 8 s. Percentages are the share of the process's own samples (inclusive).48 Workload: a browser playing 4K HDR video with 65 tiled subsurfaces committing every frame, DRM backend, `perf record -g` for 8 s. Percentages are the share of the process's own samples (inclusive).
M internal/adapters/config/config.go +15 -0
@@ -13,11 +13,16 @@ import (
1313 "sort"
1414 "strconv"
1515 "strings"
16+ "time"
1617 "unicode"
1718
1819 "github.com/bnema/neferwl/internal/ports"
1920 )
2021
22+// maxVRRFlipGap bounds render.vrr-flip-gap below one refresh of a slow
23+// VRR panel (48 Hz: 20.8 ms).
24+const maxVRRFlipGap = 10 * time.Millisecond
25+
2126 // defaultBinds use the config syntax and go through the same parser as user binds.
2227 var defaultBinds = []struct{ combo, action string }{
2328 {"cmd+return", "spawn-terminal"},
@@ -135,6 +140,7 @@ func Defaults() ports.Config {
135140 c.Render.DirectScanout = true
136141 c.Render.Tearing = true
137142 c.Render.VRR = true
143+ c.Render.VRRFlipGap = time.Millisecond
138144 c.Performance.Realtime = true
139145 c.Log.Level = "info"
140146 c.Log.Debug = []string{}
@@ -577,6 +583,15 @@ func set(c *ports.Config, key, v string) error {
577583 return err
578584 }
579585 c.Render.VRR = b
586+ case "render.vrr-flip-gap":
587+ d, err := time.ParseDuration(v)
588+ if v == "0" {
589+ d, err = 0, nil
590+ }
591+ if err != nil || d < 0 || d > maxVRRFlipGap {
592+ return fmt.Errorf("must be a duration between 0 and %s, e.g. 1ms", maxVRRFlipGap)
593+ }
594+ c.Render.VRRFlipGap = d
580595 case "performance.realtime":
581596 b, err := onOff(v)
582597 if err != nil {
@@ -13,11 +13,16 @@ import (
13 "sort"13 "sort"
14 "strconv"14 "strconv"
15 "strings"15 "strings"
16+ "time"
16 "unicode"17 "unicode"
17 18
18 "github.com/bnema/neferwl/internal/ports"19 "github.com/bnema/neferwl/internal/ports"
19 )20 )
20 21
22+// maxVRRFlipGap bounds render.vrr-flip-gap below one refresh of a slow
23+// VRR panel (48 Hz: 20.8 ms).
24+const maxVRRFlipGap = 10 * time.Millisecond
25+
21 // defaultBinds use the config syntax and go through the same parser as user binds.26 // defaultBinds use the config syntax and go through the same parser as user binds.
22 var defaultBinds = []struct{ combo, action string }{27 var defaultBinds = []struct{ combo, action string }{
23 {"cmd+return", "spawn-terminal"},28 {"cmd+return", "spawn-terminal"},
@@ -135,6 +140,7 @@ func Defaults() ports.Config {
135 c.Render.DirectScanout = true140 c.Render.DirectScanout = true
136 c.Render.Tearing = true141 c.Render.Tearing = true
137 c.Render.VRR = true142 c.Render.VRR = true
143+ c.Render.VRRFlipGap = time.Millisecond
138 c.Performance.Realtime = true144 c.Performance.Realtime = true
139 c.Log.Level = "info"145 c.Log.Level = "info"
140 c.Log.Debug = []string{}146 c.Log.Debug = []string{}
@@ -577,6 +583,15 @@ func set(c *ports.Config, key, v string) error {
577 return err583 return err
578 }584 }
579 c.Render.VRR = b585 c.Render.VRR = b
586+ case "render.vrr-flip-gap":
587+ d, err := time.ParseDuration(v)
588+ if v == "0" {
589+ d, err = 0, nil
590+ }
591+ if err != nil || d < 0 || d > maxVRRFlipGap {
592+ return fmt.Errorf("must be a duration between 0 and %s, e.g. 1ms", maxVRRFlipGap)
593+ }
594+ c.Render.VRRFlipGap = d
580 case "performance.realtime":595 case "performance.realtime":
581 b, err := onOff(v)596 b, err := onOff(v)
582 if err != nil {597 if err != nil {
M internal/adapters/config/config_test.go +19 -0
@@ -6,6 +6,7 @@ import (
66 "reflect"
77 "strings"
88 "testing"
9+ "time"
910
1011 "github.com/bnema/neferwl/internal/ports"
1112 )
@@ -479,6 +480,24 @@ func TestStash(t *testing.T) {
479480 }
480481 }
481482
483+func TestVRRFlipGap(t *testing.T) {
484+ for _, tc := range []struct {
485+ value string
486+ want time.Duration
487+ warning bool
488+ }{
489+ {"0", 0, false}, {"0ms", 0, false}, {"500us", 500 * time.Microsecond, false}, {"2ms", 2 * time.Millisecond, false}, {"10ms", 10 * time.Millisecond, false},
490+ {"11ms", time.Millisecond, true}, {"-1ms", time.Millisecond, true}, {"1", time.Millisecond, true}, {"fast", time.Millisecond, true},
491+ } {
492+ t.Run(tc.value, func(t *testing.T) {
493+ c, warnings := parseString(t, "render.vrr-flip-gap = "+tc.value)
494+ if c.Render.VRRFlipGap != tc.want || (len(warnings) != 0) != tc.warning {
495+ t.Fatalf("gap %v, warnings %v", c.Render.VRRFlipGap, warnings)
496+ }
497+ })
498+ }
499+}
500+
482501 func TestFloatingDim(t *testing.T) {
483502 for _, tc := range []struct {
484503 value string
@@ -6,6 +6,7 @@ import (
6 "reflect"6 "reflect"
7 "strings"7 "strings"
8 "testing"8 "testing"
9+ "time"
9 10
10 "github.com/bnema/neferwl/internal/ports"11 "github.com/bnema/neferwl/internal/ports"
11 )12 )
@@ -479,6 +480,24 @@ func TestStash(t *testing.T) {
479 }480 }
480 }481 }
481 482
483+func TestVRRFlipGap(t *testing.T) {
484+ for _, tc := range []struct {
485+ value string
486+ want time.Duration
487+ warning bool
488+ }{
489+ {"0", 0, false}, {"0ms", 0, false}, {"500us", 500 * time.Microsecond, false}, {"2ms", 2 * time.Millisecond, false}, {"10ms", 10 * time.Millisecond, false},
490+ {"11ms", time.Millisecond, true}, {"-1ms", time.Millisecond, true}, {"1", time.Millisecond, true}, {"fast", time.Millisecond, true},
491+ } {
492+ t.Run(tc.value, func(t *testing.T) {
493+ c, warnings := parseString(t, "render.vrr-flip-gap = "+tc.value)
494+ if c.Render.VRRFlipGap != tc.want || (len(warnings) != 0) != tc.warning {
495+ t.Fatalf("gap %v, warnings %v", c.Render.VRRFlipGap, warnings)
496+ }
497+ })
498+ }
499+}
500+
482 func TestFloatingDim(t *testing.T) {501 func TestFloatingDim(t *testing.T) {
483 for _, tc := range []struct {502 for _, tc := range []struct {
484 value string503 value string
A internal/adapters/drm/flip_trace.go +102 -0
@@ -0,0 +1,102 @@
1+package drm
2+
3+import (
4+ "os"
5+ "runtime"
6+ "time"
7+ "unsafe"
8+
9+ "golang.org/x/sys/unix"
10+)
11+
12+// Flip tracing (--debug=drm-flip) logs every commit completion with the
13+// times needed to tell a late client fence from a late flip: all times
14+// are CLOCK_MONOTONIC, the clock of flip events and fence timestamps.
15+
16+// SYNC_IOC_FILE_INFO = _IOWR('>', 4, struct sync_file_info)
17+const ioctlSyncFileInfo = 0xc0383e04
18+
19+// syncFileInfo is struct sync_file_info.
20+type syncFileInfo struct {
21+ name [32]byte
22+ status int32
23+ flags uint32
24+ numFences uint32
25+ pad uint32
26+ fences uint64
27+}
28+
29+// syncFenceInfo is struct sync_fence_info.
30+type syncFenceInfo struct {
31+ objName, driverName [32]byte
32+ status int32
33+ flags uint32
34+ timestampNs uint64
35+}
36+
37+// fenceSignalledAt is when every fence of the sync file f signalled
38+// (0: not signalled yet or unknown).
39+func fenceSignalledAt(f *os.File) time.Duration {
40+ info := syncFileInfo{}
41+ if ioctl(int(f.Fd()), ioctlSyncFileInfo, unsafe.Pointer(&info)) != nil || info.numFences == 0 {
42+ return 0
43+ }
44+ fences := make([]syncFenceInfo, info.numFences)
45+ info.fences = uint64(uintptr(unsafe.Pointer(&fences[0])))
46+ err := ioctl(int(f.Fd()), ioctlSyncFileInfo, unsafe.Pointer(&info))
47+ runtime.KeepAlive(fences)
48+ if err != nil {
49+ return 0
50+ }
51+ var at uint64
52+ for _, fi := range fences {
53+ if fi.status != 1 {
54+ return 0
55+ }
56+ at = max(at, fi.timestampNs)
57+ }
58+ return time.Duration(at)
59+}
60+
61+// fencesSignalledAt is the latest signal time of fs (0: none, or one
62+// unsignalled).
63+func fencesSignalledAt(fs []*os.File) time.Duration {
64+ var at time.Duration
65+ for _, f := range fs {
66+ t := fenceSignalledAt(f)
67+ if t == 0 {
68+ return 0
69+ }
70+ at = max(at, t)
71+ }
72+ return at
73+}
74+
75+func monotonic() time.Duration {
76+ var ts unix.Timespec
77+ _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
78+ return time.Duration(ts.Nano())
79+}
80+
81+// traceFlip logs the completion ev of the commit f started at start.
82+// fenceAt is when its fences signalled (0: none or unknown).
83+func (o *Output) traceFlip(ev flipEvent, f pendingFrame, start time.Time, fenceAt time.Duration) {
84+ now := monotonic()
85+ commitAt := now - time.Since(start)
86+ e := o.log.Debug().Str("connector", o.conn.name).Bool("frame", f.frame).Bool("async", f.async).Bool("vrr", o.vrrOn).
87+ Bool("direct", f.zeroCopy != 0).Uint32("seq", ev.seq).
88+ Float64("commit_to_flip_ms", ms(ev.when-commitAt)).Float64("flip_to_read_ms", ms(now-ev.when)).
89+ Int("fences", len(f.fences)).Bool("fence_ready_at_commit", f.fenceReady)
90+ if fenceAt != 0 {
91+ e = e.Float64("commit_to_fence_ms", ms(fenceAt-commitAt)).Float64("fence_to_flip_ms", ms(ev.when-fenceAt))
92+ }
93+ if f.frame {
94+ if o.lastFlipAt != 0 {
95+ e = e.Float64("flip_interval_ms", ms(ev.when-o.lastFlipAt))
96+ }
97+ o.lastFlipAt = ev.when
98+ }
99+ e.Msg("flip")
100+}
101+
102+func ms(d time.Duration) float64 { return float64(d) / float64(time.Millisecond) }
@@ -0,0 +1,102 @@
1+package drm
2+
3+import (
4+ "os"
5+ "runtime"
6+ "time"
7+ "unsafe"
8+
9+ "golang.org/x/sys/unix"
10+)
11+
12+// Flip tracing (--debug=drm-flip) logs every commit completion with the
13+// times needed to tell a late client fence from a late flip: all times
14+// are CLOCK_MONOTONIC, the clock of flip events and fence timestamps.
15+
16+// SYNC_IOC_FILE_INFO = _IOWR('>', 4, struct sync_file_info)
17+const ioctlSyncFileInfo = 0xc0383e04
18+
19+// syncFileInfo is struct sync_file_info.
20+type syncFileInfo struct {
21+ name [32]byte
22+ status int32
23+ flags uint32
24+ numFences uint32
25+ pad uint32
26+ fences uint64
27+}
28+
29+// syncFenceInfo is struct sync_fence_info.
30+type syncFenceInfo struct {
31+ objName, driverName [32]byte
32+ status int32
33+ flags uint32
34+ timestampNs uint64
35+}
36+
37+// fenceSignalledAt is when every fence of the sync file f signalled
38+// (0: not signalled yet or unknown).
39+func fenceSignalledAt(f *os.File) time.Duration {
40+ info := syncFileInfo{}
41+ if ioctl(int(f.Fd()), ioctlSyncFileInfo, unsafe.Pointer(&info)) != nil || info.numFences == 0 {
42+ return 0
43+ }
44+ fences := make([]syncFenceInfo, info.numFences)
45+ info.fences = uint64(uintptr(unsafe.Pointer(&fences[0])))
46+ err := ioctl(int(f.Fd()), ioctlSyncFileInfo, unsafe.Pointer(&info))
47+ runtime.KeepAlive(fences)
48+ if err != nil {
49+ return 0
50+ }
51+ var at uint64
52+ for _, fi := range fences {
53+ if fi.status != 1 {
54+ return 0
55+ }
56+ at = max(at, fi.timestampNs)
57+ }
58+ return time.Duration(at)
59+}
60+
61+// fencesSignalledAt is the latest signal time of fs (0: none, or one
62+// unsignalled).
63+func fencesSignalledAt(fs []*os.File) time.Duration {
64+ var at time.Duration
65+ for _, f := range fs {
66+ t := fenceSignalledAt(f)
67+ if t == 0 {
68+ return 0
69+ }
70+ at = max(at, t)
71+ }
72+ return at
73+}
74+
75+func monotonic() time.Duration {
76+ var ts unix.Timespec
77+ _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
78+ return time.Duration(ts.Nano())
79+}
80+
81+// traceFlip logs the completion ev of the commit f started at start.
82+// fenceAt is when its fences signalled (0: none or unknown).
83+func (o *Output) traceFlip(ev flipEvent, f pendingFrame, start time.Time, fenceAt time.Duration) {
84+ now := monotonic()
85+ commitAt := now - time.Since(start)
86+ e := o.log.Debug().Str("connector", o.conn.name).Bool("frame", f.frame).Bool("async", f.async).Bool("vrr", o.vrrOn).
87+ Bool("direct", f.zeroCopy != 0).Uint32("seq", ev.seq).
88+ Float64("commit_to_flip_ms", ms(ev.when-commitAt)).Float64("flip_to_read_ms", ms(now-ev.when)).
89+ Int("fences", len(f.fences)).Bool("fence_ready_at_commit", f.fenceReady)
90+ if fenceAt != 0 {
91+ e = e.Float64("commit_to_fence_ms", ms(fenceAt-commitAt)).Float64("fence_to_flip_ms", ms(ev.when-fenceAt))
92+ }
93+ if f.frame {
94+ if o.lastFlipAt != 0 {
95+ e = e.Float64("flip_interval_ms", ms(ev.when-o.lastFlipAt))
96+ }
97+ o.lastFlipAt = ev.when
98+ }
99+ e.Msg("flip")
100+}
101+
102+func ms(d time.Duration) float64 { return float64(d) / float64(time.Millisecond) }
A internal/adapters/drm/flip_trace_test.go +58 -0
@@ -0,0 +1,58 @@
1+package drm
2+
3+import (
4+ "bytes"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/bnema/zerowrap"
11+ "github.com/stretchr/testify/mock"
12+)
13+
14+// Only the event of our pending commit is traced: a stale event and the
15+// event that ends an EBUSY wait are not.
16+func TestTraceFlipsOnlyOurCommit(t *testing.T) {
17+ o, _, commits := testOutput(t)
18+ o.cursor = nil
19+ o.traceFlips = true
20+ var log bytes.Buffer
21+ o.log = zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &log})
22+ seen := map[ports.WindowID]uint64{}
23+ flips := func() int { return strings.Count(log.String(), `"message":"flip"`) }
24+
25+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
26+ t.Fatal(err)
27+ }
28+ c := (*commits)[0]
29+ stale := flipEvent{crtc: tCrtc, user: c.user + 1<<userKindBits}
30+ if o.completed(stale, seen) || flips() != 0 {
31+ t.Fatalf("stale event traced: %s", log.String())
32+ }
33+ if !o.completed(flipEvent{crtc: tCrtc, user: c.user, when: time.Second}, seen) || flips() != 1 {
34+ t.Fatalf("own commit not traced: %s", log.String())
35+ }
36+ if o.lastFlipAt != time.Second {
37+ t.Fatalf("last flip %s", o.lastFlipAt)
38+ }
39+
40+ o.frame.busy(time.Now())
41+ if !o.completed(flipEvent{crtc: tCrtc, user: 12345}, seen) || flips() != 1 {
42+ t.Fatalf("EBUSY wait end traced: %s", log.String())
43+ }
44+}
45+
46+// A modeset starts a new flip interval: the time the output was inactive
47+// is not reported as a frame interval.
48+func TestModesetResetsFlipTrace(t *testing.T) {
49+ o, k, _ := testOutput(t)
50+ o.lastFlipAt = time.Second
51+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
52+ if err := o.modeset(); err != nil {
53+ t.Fatal(err)
54+ }
55+ if o.lastFlipAt != 0 {
56+ t.Fatalf("after modeset: last flip %s", o.lastFlipAt)
57+ }
58+}
@@ -0,0 +1,58 @@
1+package drm
2+
3+import (
4+ "bytes"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/bnema/zerowrap"
11+ "github.com/stretchr/testify/mock"
12+)
13+
14+// Only the event of our pending commit is traced: a stale event and the
15+// event that ends an EBUSY wait are not.
16+func TestTraceFlipsOnlyOurCommit(t *testing.T) {
17+ o, _, commits := testOutput(t)
18+ o.cursor = nil
19+ o.traceFlips = true
20+ var log bytes.Buffer
21+ o.log = zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &log})
22+ seen := map[ports.WindowID]uint64{}
23+ flips := func() int { return strings.Count(log.String(), `"message":"flip"`) }
24+
25+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
26+ t.Fatal(err)
27+ }
28+ c := (*commits)[0]
29+ stale := flipEvent{crtc: tCrtc, user: c.user + 1<<userKindBits}
30+ if o.completed(stale, seen) || flips() != 0 {
31+ t.Fatalf("stale event traced: %s", log.String())
32+ }
33+ if !o.completed(flipEvent{crtc: tCrtc, user: c.user, when: time.Second}, seen) || flips() != 1 {
34+ t.Fatalf("own commit not traced: %s", log.String())
35+ }
36+ if o.lastFlipAt != time.Second {
37+ t.Fatalf("last flip %s", o.lastFlipAt)
38+ }
39+
40+ o.frame.busy(time.Now())
41+ if !o.completed(flipEvent{crtc: tCrtc, user: 12345}, seen) || flips() != 1 {
42+ t.Fatalf("EBUSY wait end traced: %s", log.String())
43+ }
44+}
45+
46+// A modeset starts a new flip interval: the time the output was inactive
47+// is not reported as a frame interval.
48+func TestModesetResetsFlipTrace(t *testing.T) {
49+ o, k, _ := testOutput(t)
50+ o.lastFlipAt = time.Second
51+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
52+ if err := o.modeset(); err != nil {
53+ t.Fatal(err)
54+ }
55+ if o.lastFlipAt != 0 {
56+ t.Fatalf("after modeset: last flip %s", o.lastFlipAt)
57+ }
58+}
M internal/adapters/drm/frame_lifecycle.go +8 -0
@@ -35,6 +35,8 @@ type pendingFrame struct {
3535 // the commit ends. composed is set when the frame was rendered.
3636 fences []*os.File
3737 composed bool
38+ // fenceReady: every fence had signalled at commit (flip tracing only).
39+ fenceReady bool
3840 }
3941
4042 func (f *pendingFrame) closeFences() {
@@ -120,6 +122,12 @@ func (l *frameLifecycle) busy(now time.Time) bool {
120122 return newRun
121123 }
122124
125+// ours reports whether user is the event of the pending commit with a
126+// known serial (not the end of an EBUSY wait).
127+func (l *frameLifecycle) ours(user uint64) bool {
128+ return l.pending && l.pendingSerial != 0 && user>>userKindBits == l.pendingSerial
129+}
130+
123131 // flip consumes only the event for the pending commit; while waiting after
124132 // EBUSY there is no known serial, so any CRTC event ends that wait. The
125133 // returned start lets Output measure flip age after pending fences close.
@@ -35,6 +35,8 @@ type pendingFrame struct {
35 // the commit ends. composed is set when the frame was rendered.35 // the commit ends. composed is set when the frame was rendered.
36 fences []*os.File36 fences []*os.File
37 composed bool37 composed bool
38+ // fenceReady: every fence had signalled at commit (flip tracing only).
39+ fenceReady bool
38 }40 }
39 41
40 func (f *pendingFrame) closeFences() {42 func (f *pendingFrame) closeFences() {
@@ -120,6 +122,12 @@ func (l *frameLifecycle) busy(now time.Time) bool {
120 return newRun122 return newRun
121 }123 }
122 124
125+// ours reports whether user is the event of the pending commit with a
126+// known serial (not the end of an EBUSY wait).
127+func (l *frameLifecycle) ours(user uint64) bool {
128+ return l.pending && l.pendingSerial != 0 && user>>userKindBits == l.pendingSerial
129+}
130+
123 // flip consumes only the event for the pending commit; while waiting after131 // flip consumes only the event for the pending commit; while waiting after
124 // EBUSY there is no known serial, so any CRTC event ends that wait. The132 // EBUSY there is no known serial, so any CRTC event ends that wait. The
125 // returned start lets Output measure flip age after pending fences close.133 // returned start lets Output measure flip age after pending fences close.
M internal/adapters/drm/kms.go +7 -0
@@ -4,6 +4,7 @@ package drm
44 import (
55 "fmt"
66 "math"
7+ "time"
78 "unsafe"
89
910 "github.com/bnema/neferwl/internal/ports"
@@ -108,6 +109,12 @@ type Want struct {
108109 Sampled []ports.DMABufFormat
109110 // Device is the KMS device (dev_t) offered in scanout tranches.
110111 Device uint64
112+ // TraceFlips logs each frame and commit completion with its timing
113+ // (--debug=drm-flip).
114+ TraceFlips bool
115+ // VRRFlipGap delays a game frame after the previous flip under VRR
116+ // (render.vrr-flip-gap, 0: off; see vrr_flip_gap.go).
117+ VRRFlipGap time.Duration
111118 }
112119
113120 // HDRSettings is the requested HDR state and SDR reference white (nits).
@@ -4,6 +4,7 @@ package drm
4 import (4 import (
5 "fmt"5 "fmt"
6 "math"6 "math"
7+ "time"
7 "unsafe"8 "unsafe"
8 9
9 "github.com/bnema/neferwl/internal/ports"10 "github.com/bnema/neferwl/internal/ports"
@@ -108,6 +109,12 @@ type Want struct {
108 Sampled []ports.DMABufFormat109 Sampled []ports.DMABufFormat
109 // Device is the KMS device (dev_t) offered in scanout tranches.110 // Device is the KMS device (dev_t) offered in scanout tranches.
110 Device uint64111 Device uint64
112+ // TraceFlips logs each frame and commit completion with its timing
113+ // (--debug=drm-flip).
114+ TraceFlips bool
115+ // VRRFlipGap delays a game frame after the previous flip under VRR
116+ // (render.vrr-flip-gap, 0: off; see vrr_flip_gap.go).
117+ VRRFlipGap time.Duration
111 }118 }
112 119
113 // HDRSettings is the requested HDR state and SDR reference white (nits).120 // HDRSettings is the requested HDR state and SDR reference white (nits).
M internal/adapters/drm/output.go +52 -2
@@ -106,6 +106,15 @@ type Output struct {
106106 // move waits for the next frame (see cursorWaits).
107107 lastFrame time.Time
108108 cursorHeld bool
109+ // traceFlips logs every completion with its timing; lastFlipAt is
110+ // the previous frame flip's kernel timestamp.
111+ traceFlips bool
112+ lastFlipAt time.Duration
113+ // vrrFlipGap is the minimum time between a game frame's flip event
114+ // and the next frame commit under VRR (render.vrr-flip-gap, 0: off);
115+ // flipGapUntil is when the next frame may commit. See vrr_flip_gap.go.
116+ vrrFlipGap time.Duration
117+ flipGapUntil time.Time
109118 // wantOff is the latest Scene.Off: a client turned the display off.
110119 // off: the CRTC is inactive for it. Every modeset (resume, recovery)
111120 // follows wantOff, so a display turned off never lights up.
@@ -217,7 +226,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.Cu
217226 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
218227 log := card.log
219228 pipe := slices.Index(card.crtcs, crtc)
220- o := &Output{k: card.k, flipped: card.flips[crtc], frame: frameLifecycle{serials: &card.serials}, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start", ready: make(chan error, 1)}
229+ o := &Output{k: card.k, flipped: card.flips[crtc], frame: frameLifecycle{serials: &card.serials}, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start", ready: make(chan error, 1), traceFlips: card.want.TraceFlips, vrrFlipGap: card.want.VRRFlipGap}
221230 var err error
222231 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
223232 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
@@ -384,6 +393,8 @@ func (o *Output) modeset() error {
384393 }
385394 o.modeBlob = blob
386395 o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, o.wantOff
396+ // The gap and the traced flip interval belonged to the old state.
397+ o.flipGapUntil, o.lastFlipAt = time.Time{}, 0
387398 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]
388399 o.planeRect = fullPlaneRect(o.Width(), o.Height())
389400 if o.cursor != nil {
@@ -678,6 +689,9 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
678689 o.lastFrame, o.cursorHeld = time.Now(), false
679690 f.frame, f.async = true, async
680691 f.fences = dupFences(fence, ov.acquire)
692+ if o.traceFlips {
693+ f.fenceReady = signalled(f.fences)
694+ }
681695 o.setAsync(async)
682696 o.frame.begin(f, time.Now())
683697 return nil
@@ -991,6 +1005,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
9911005 vrrTimer := time.NewTimer(time.Hour)
9921006 cursorTimer := time.NewTimer(time.Hour)
9931007 o.frame.startTimer()
1008+ gapTimer := time.NewTimer(time.Hour)
1009+ gapTimer.Stop()
1010+ defer gapTimer.Stop()
9941011 retryTimer.Stop()
9951012 vrrTimer.Stop()
9961013 cursorTimer.Stop()
@@ -1041,6 +1058,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10411058 } else {
10421059 cursorTimer.Stop()
10431060 }
1061+ // A game frame waiting out the VRR flip gap commits when it ends.
1062+ var gapDue <-chan time.Time
1063+ if enabled && dirty && !o.frame.pendingCommit() && !o.flipGapUntil.IsZero() {
1064+ gapTimer.Reset(max(0, time.Until(o.flipGapUntil)))
1065+ gapDue = gapTimer.C
1066+ } else {
1067+ gapTimer.Stop()
1068+ }
10441069 // A commit whose event never comes must not stop the output.
10451070 stuck := o.frame.wait(enabled)
10461071 stateDirty := false
@@ -1067,6 +1092,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10671092 stateDirty = true
10681093 case <-cursorWake:
10691094 stateDirty = true
1095+ case <-gapDue:
10701096 case <-ctx.Done():
10711097 return nil
10721098 case q := <-captures:
@@ -1191,6 +1217,12 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11911217 }
11921218 continue
11931219 }
1220+ if !o.flipGapUntil.IsZero() {
1221+ if time.Now().Before(o.flipGapUntil) {
1222+ continue // gapDue commits it
1223+ }
1224+ o.flipGapUntil = time.Time{}
1225+ }
11941226 start := time.Now()
11951227 direct, err := o.submitFrame(ctx, r, scene, surfaces, seen, requests, captured)
11961228 requests = nil
@@ -1210,6 +1242,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12101242 }
12111243 dirty = false
12121244 frame++
1245+ if !o.traceFlips {
1246+ continue
1247+ }
12131248 o.log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Bool("direct", direct).Dur("took", time.Since(start)).Msg("frame")
12141249 }
12151250 }
@@ -1238,10 +1273,21 @@ func (o *Output) commitFailed(err error, enabled *bool) bool {
12381273 // reported with its kernel timestamp. It reports false for an event of
12391274 // another commit, which is dropped.
12401275 func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
1276+ // Only a commit of ours is traced: not a stale event, nor the end of
1277+ // an EBUSY wait. Its fences are read before flip closes them.
1278+ trace := o.traceFlips && o.frame.ours(ev.user)
1279+ var fenceAt time.Duration
1280+ if trace {
1281+ fenceAt = fencesSignalledAt(o.frame.pendingFrame.fences)
1282+ }
12411283 f, start, ok := o.frame.flip(ev.user)
12421284 if !ok {
12431285 return false
12441286 }
1287+ if trace {
1288+ o.traceFlip(ev, f, start, fenceAt)
1289+ }
1290+ o.startFlipGap(f)
12451291 if age := time.Since(start); age > 20*time.Millisecond {
12461292 o.log.Info().Dur("flip_ms", age).Bool("frame", f.frame).Bool("vrr", o.vrrOn).Str("connector", o.conn.name).Msg("slow flip")
12471293 }
@@ -1282,7 +1328,11 @@ func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorCh
12821328 o.log.Warn().Err(err).Msg("cursor")
12831329 return
12841330 }
1285- o.log.Debug().Float64("scale", scale).Str("shape", c.Shape).Bool("client", c.Image != nil).Int("w", img.W).Int("h", img.H).Msg("cursor image")
1331+ if c.Image != nil {
1332+ // Client cursors (games) reload on every change: too many to log.
1333+ return
1334+ }
1335+ o.log.Debug().Float64("scale", scale).Str("shape", c.Shape).Int("w", img.W).Int("h", img.H).Msg("cursor image")
12861336 }
12871337
12881338 // maxUnsent bounds reports waiting for wayland; past it the oldest flip is
@@ -106,6 +106,15 @@ type Output struct {
106 // move waits for the next frame (see cursorWaits).106 // move waits for the next frame (see cursorWaits).
107 lastFrame time.Time107 lastFrame time.Time
108 cursorHeld bool108 cursorHeld bool
109+ // traceFlips logs every completion with its timing; lastFlipAt is
110+ // the previous frame flip's kernel timestamp.
111+ traceFlips bool
112+ lastFlipAt time.Duration
113+ // vrrFlipGap is the minimum time between a game frame's flip event
114+ // and the next frame commit under VRR (render.vrr-flip-gap, 0: off);
115+ // flipGapUntil is when the next frame may commit. See vrr_flip_gap.go.
116+ vrrFlipGap time.Duration
117+ flipGapUntil time.Time
109 // wantOff is the latest Scene.Off: a client turned the display off.118 // wantOff is the latest Scene.Off: a client turned the display off.
110 // off: the CRTC is inactive for it. Every modeset (resume, recovery)119 // off: the CRTC is inactive for it. Every modeset (resume, recovery)
111 // follows wantOff, so a display turned off never lights up.120 // follows wantOff, so a display turned off never lights up.
@@ -217,7 +226,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.Cu
217 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {226 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
218 log := card.log227 log := card.log
219 pipe := slices.Index(card.crtcs, crtc)228 pipe := slices.Index(card.crtcs, crtc)
220- o := &Output{k: card.k, flipped: card.flips[crtc], frame: frameLifecycle{serials: &card.serials}, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start", ready: make(chan error, 1)}229+ o := &Output{k: card.k, flipped: card.flips[crtc], frame: frameLifecycle{serials: &card.serials}, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start", ready: make(chan error, 1), traceFlips: card.want.TraceFlips, vrrFlipGap: card.want.VRRFlipGap}
221 var err error230 var err error
222 if o.saved, err = getCrtc(card.fd, crtc); err != nil {231 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
223 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")232 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
@@ -384,6 +393,8 @@ func (o *Output) modeset() error {
384 }393 }
385 o.modeBlob = blob394 o.modeBlob = blob
386 o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, o.wantOff395 o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, o.wantOff
396+ // The gap and the traced flip interval belonged to the old state.
397+ o.flipGapUntil, o.lastFlipAt = time.Time{}, 0
387 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]398 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]
388 o.planeRect = fullPlaneRect(o.Width(), o.Height())399 o.planeRect = fullPlaneRect(o.Width(), o.Height())
389 if o.cursor != nil {400 if o.cursor != nil {
@@ -678,6 +689,9 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
678 o.lastFrame, o.cursorHeld = time.Now(), false689 o.lastFrame, o.cursorHeld = time.Now(), false
679 f.frame, f.async = true, async690 f.frame, f.async = true, async
680 f.fences = dupFences(fence, ov.acquire)691 f.fences = dupFences(fence, ov.acquire)
692+ if o.traceFlips {
693+ f.fenceReady = signalled(f.fences)
694+ }
681 o.setAsync(async)695 o.setAsync(async)
682 o.frame.begin(f, time.Now())696 o.frame.begin(f, time.Now())
683 return nil697 return nil
@@ -991,6 +1005,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
991 vrrTimer := time.NewTimer(time.Hour)1005 vrrTimer := time.NewTimer(time.Hour)
992 cursorTimer := time.NewTimer(time.Hour)1006 cursorTimer := time.NewTimer(time.Hour)
993 o.frame.startTimer()1007 o.frame.startTimer()
1008+ gapTimer := time.NewTimer(time.Hour)
1009+ gapTimer.Stop()
1010+ defer gapTimer.Stop()
994 retryTimer.Stop()1011 retryTimer.Stop()
995 vrrTimer.Stop()1012 vrrTimer.Stop()
996 cursorTimer.Stop()1013 cursorTimer.Stop()
@@ -1041,6 +1058,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1041 } else {1058 } else {
1042 cursorTimer.Stop()1059 cursorTimer.Stop()
1043 }1060 }
1061+ // A game frame waiting out the VRR flip gap commits when it ends.
1062+ var gapDue <-chan time.Time
1063+ if enabled && dirty && !o.frame.pendingCommit() && !o.flipGapUntil.IsZero() {
1064+ gapTimer.Reset(max(0, time.Until(o.flipGapUntil)))
1065+ gapDue = gapTimer.C
1066+ } else {
1067+ gapTimer.Stop()
1068+ }
1044 // A commit whose event never comes must not stop the output.1069 // A commit whose event never comes must not stop the output.
1045 stuck := o.frame.wait(enabled)1070 stuck := o.frame.wait(enabled)
1046 stateDirty := false1071 stateDirty := false
@@ -1067,6 +1092,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1067 stateDirty = true1092 stateDirty = true
1068 case <-cursorWake:1093 case <-cursorWake:
1069 stateDirty = true1094 stateDirty = true
1095+ case <-gapDue:
1070 case <-ctx.Done():1096 case <-ctx.Done():
1071 return nil1097 return nil
1072 case q := <-captures:1098 case q := <-captures:
@@ -1191,6 +1217,12 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1191 }1217 }
1192 continue1218 continue
1193 }1219 }
1220+ if !o.flipGapUntil.IsZero() {
1221+ if time.Now().Before(o.flipGapUntil) {
1222+ continue // gapDue commits it
1223+ }
1224+ o.flipGapUntil = time.Time{}
1225+ }
1194 start := time.Now()1226 start := time.Now()
1195 direct, err := o.submitFrame(ctx, r, scene, surfaces, seen, requests, captured)1227 direct, err := o.submitFrame(ctx, r, scene, surfaces, seen, requests, captured)
1196 requests = nil1228 requests = nil
@@ -1210,6 +1242,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1210 }1242 }
1211 dirty = false1243 dirty = false
1212 frame++1244 frame++
1245+ if !o.traceFlips {
1246+ continue
1247+ }
1213 o.log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Bool("direct", direct).Dur("took", time.Since(start)).Msg("frame")1248 o.log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Bool("direct", direct).Dur("took", time.Since(start)).Msg("frame")
1214 }1249 }
1215 }1250 }
@@ -1238,10 +1273,21 @@ func (o *Output) commitFailed(err error, enabled *bool) bool {
1238 // reported with its kernel timestamp. It reports false for an event of1273 // reported with its kernel timestamp. It reports false for an event of
1239 // another commit, which is dropped.1274 // another commit, which is dropped.
1240 func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {1275 func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
1276+ // Only a commit of ours is traced: not a stale event, nor the end of
1277+ // an EBUSY wait. Its fences are read before flip closes them.
1278+ trace := o.traceFlips && o.frame.ours(ev.user)
1279+ var fenceAt time.Duration
1280+ if trace {
1281+ fenceAt = fencesSignalledAt(o.frame.pendingFrame.fences)
1282+ }
1241 f, start, ok := o.frame.flip(ev.user)1283 f, start, ok := o.frame.flip(ev.user)
1242 if !ok {1284 if !ok {
1243 return false1285 return false
1244 }1286 }
1287+ if trace {
1288+ o.traceFlip(ev, f, start, fenceAt)
1289+ }
1290+ o.startFlipGap(f)
1245 if age := time.Since(start); age > 20*time.Millisecond {1291 if age := time.Since(start); age > 20*time.Millisecond {
1246 o.log.Info().Dur("flip_ms", age).Bool("frame", f.frame).Bool("vrr", o.vrrOn).Str("connector", o.conn.name).Msg("slow flip")1292 o.log.Info().Dur("flip_ms", age).Bool("frame", f.frame).Bool("vrr", o.vrrOn).Str("connector", o.conn.name).Msg("slow flip")
1247 }1293 }
@@ -1282,7 +1328,11 @@ func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorCh
1282 o.log.Warn().Err(err).Msg("cursor")1328 o.log.Warn().Err(err).Msg("cursor")
1283 return1329 return
1284 }1330 }
1285- o.log.Debug().Float64("scale", scale).Str("shape", c.Shape).Bool("client", c.Image != nil).Int("w", img.W).Int("h", img.H).Msg("cursor image")1331+ if c.Image != nil {
1332+ // Client cursors (games) reload on every change: too many to log.
1333+ return
1334+ }
1335+ o.log.Debug().Float64("scale", scale).Str("shape", c.Shape).Int("w", img.W).Int("h", img.H).Msg("cursor image")
1286 }1336 }
1287 1337
1288 // maxUnsent bounds reports waiting for wayland; past it the oldest flip is1338 // maxUnsent bounds reports waiting for wayland; past it the oldest flip is
M internal/adapters/drm/output_test.go +2 -1
@@ -31,6 +31,7 @@ type commitRec struct {
3131 req atomicReq
3232 flags uint32
3333 user uint64
34+ at time.Time // when the commit was made
3435 }
3536
3637 // testOutput returns an output on a mocked kms that records commits;
@@ -49,7 +50,7 @@ func testOutputMu(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec,
4950 k.EXPECT().commit(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(r *atomicReq, flags uint32, user uint64) error {
5051 commitMu.Lock()
5152 defer commitMu.Unlock()
52- c := commitRec{flags: flags, user: user}
53+ c := commitRec{flags: flags, user: user, at: time.Now()}
5354 c.req.objs = append([]uint32(nil), r.objs...)
5455 for _, ps := range r.props {
5556 c.req.props = append(c.req.props, append([]propValue(nil), ps...))
@@ -31,6 +31,7 @@ type commitRec struct {
31 req atomicReq31 req atomicReq
32 flags uint3232 flags uint32
33 user uint6433 user uint64
34+ at time.Time // when the commit was made
34 }35 }
35 36
36 // testOutput returns an output on a mocked kms that records commits;37 // testOutput returns an output on a mocked kms that records commits;
@@ -49,7 +50,7 @@ func testOutputMu(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec,
49 k.EXPECT().commit(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(r *atomicReq, flags uint32, user uint64) error {50 k.EXPECT().commit(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(r *atomicReq, flags uint32, user uint64) error {
50 commitMu.Lock()51 commitMu.Lock()
51 defer commitMu.Unlock()52 defer commitMu.Unlock()
52- c := commitRec{flags: flags, user: user}53+ c := commitRec{flags: flags, user: user, at: time.Now()}
53 c.req.objs = append([]uint32(nil), r.objs...)54 c.req.objs = append([]uint32(nil), r.objs...)
54 for _, ps := range r.props {55 for _, ps := range r.props {
55 c.req.props = append(c.req.props, append([]propValue(nil), ps...))56 c.req.props = append(c.req.props, append([]propValue(nil), ps...))
A internal/adapters/drm/vrr_flip_gap.go +52 -0
@@ -0,0 +1,52 @@
1+package drm
2+
3+import "time"
4+
5+// VRR flip gap: a workaround for game frames that hit the panel's slowest
6+// refresh while the game renders fast.
7+//
8+// Symptom: fullscreen game, direct scanout, VRR on, 120-165 fps. Now and
9+// then, runs of 5 to 30 consecutive flips each take ~20.8 ms: the panel's
10+// maximum VRR frame time (1/48 Hz on a 48-165 Hz panel). The game's FPS
11+// counter stays steady, but the screen visibly drops to 48 Hz for a
12+// fraction of a second.
13+//
14+// Root cause, measured with --debug=drm-flip on amdgpu (DP, 48-165 Hz):
15+// - the client's acquire fence had already signalled at commit, and
16+// the atomic ioctl returned at once: neither the game nor our commit
17+// path is late;
18+// - every slow flip was committed within ~0.3 ms after the previous
19+// flip event, i.e. right at the start of the next refresh cycle;
20+// - the flip event then comes ~20.8 ms after the commit: the flip
21+// misses the early-refresh window and the display only refreshes when
22+// the VRR timeout forces it (vtotal at the minimum rate);
23+// - since Output.Run commits the next frame as soon as that late event
24+// arrives, the next commit lands at the same point of the cycle, and
25+// the output stays locked at the minimum rate until the game's timing
26+// drifts.
27+//
28+// Why the kernel misses these flips is not established (amdgpu DC VRR
29+// flip programming around vblank is the likely place). What is measured:
30+// committing the next game frame a short time after the flip event keeps
31+// it out of that window. With a 1 ms gap a 3-minute run had 0 slow flips
32+// in gameplay, against bursts of up to 30 without it and ~10 isolated
33+// slow flips per minute with a 2 ms delay applied only after a slow flip.
34+// The cost is at most the gap of added latency per frame, well under one
35+// 165 Hz refresh (6 ms), and it applies only while VRR runs for a game.
36+//
37+// render.vrr-flip-gap sets the gap (default 1 ms, 0 turns the workaround
38+// off); drop it once the driver no longer needs it.
39+
40+// startFlipGap is called on each completed commit f: after a game frame's
41+// flip under VRR, the next frame waits vrrFlipGap before it commits. A
42+// state commit (cursor, VRR toggle) completing inside the gap keeps it:
43+// the next frame still follows the last frame flip.
44+func (o *Output) startFlipGap(f pendingFrame) {
45+ if !f.frame {
46+ return
47+ }
48+ o.flipGapUntil = time.Time{}
49+ if o.vrrFlipGap > 0 && o.vrrOn && o.vrrGame {
50+ o.flipGapUntil = time.Now().Add(o.vrrFlipGap)
51+ }
52+}
@@ -0,0 +1,52 @@
1+package drm
2+
3+import "time"
4+
5+// VRR flip gap: a workaround for game frames that hit the panel's slowest
6+// refresh while the game renders fast.
7+//
8+// Symptom: fullscreen game, direct scanout, VRR on, 120-165 fps. Now and
9+// then, runs of 5 to 30 consecutive flips each take ~20.8 ms: the panel's
10+// maximum VRR frame time (1/48 Hz on a 48-165 Hz panel). The game's FPS
11+// counter stays steady, but the screen visibly drops to 48 Hz for a
12+// fraction of a second.
13+//
14+// Root cause, measured with --debug=drm-flip on amdgpu (DP, 48-165 Hz):
15+// - the client's acquire fence had already signalled at commit, and
16+// the atomic ioctl returned at once: neither the game nor our commit
17+// path is late;
18+// - every slow flip was committed within ~0.3 ms after the previous
19+// flip event, i.e. right at the start of the next refresh cycle;
20+// - the flip event then comes ~20.8 ms after the commit: the flip
21+// misses the early-refresh window and the display only refreshes when
22+// the VRR timeout forces it (vtotal at the minimum rate);
23+// - since Output.Run commits the next frame as soon as that late event
24+// arrives, the next commit lands at the same point of the cycle, and
25+// the output stays locked at the minimum rate until the game's timing
26+// drifts.
27+//
28+// Why the kernel misses these flips is not established (amdgpu DC VRR
29+// flip programming around vblank is the likely place). What is measured:
30+// committing the next game frame a short time after the flip event keeps
31+// it out of that window. With a 1 ms gap a 3-minute run had 0 slow flips
32+// in gameplay, against bursts of up to 30 without it and ~10 isolated
33+// slow flips per minute with a 2 ms delay applied only after a slow flip.
34+// The cost is at most the gap of added latency per frame, well under one
35+// 165 Hz refresh (6 ms), and it applies only while VRR runs for a game.
36+//
37+// render.vrr-flip-gap sets the gap (default 1 ms, 0 turns the workaround
38+// off); drop it once the driver no longer needs it.
39+
40+// startFlipGap is called on each completed commit f: after a game frame's
41+// flip under VRR, the next frame waits vrrFlipGap before it commits. A
42+// state commit (cursor, VRR toggle) completing inside the gap keeps it:
43+// the next frame still follows the last frame flip.
44+func (o *Output) startFlipGap(f pendingFrame) {
45+ if !f.frame {
46+ return
47+ }
48+ o.flipGapUntil = time.Time{}
49+ if o.vrrFlipGap > 0 && o.vrrOn && o.vrrGame {
50+ o.flipGapUntil = time.Now().Add(o.vrrFlipGap)
51+ }
52+}
A internal/adapters/drm/vrr_flip_gap_test.go +145 -0
@@ -0,0 +1,145 @@
1+package drm
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+ "time"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+)
13+
14+// The gap starts only after a game frame's flip under VRR.
15+func TestStartFlipGap(t *testing.T) {
16+ for _, tc := range []struct {
17+ name string
18+ gap time.Duration
19+ frame, vrr, game bool
20+ want bool
21+ }{
22+ {"game frame under VRR", time.Millisecond, true, true, true, true},
23+ {"gap off", 0, true, true, true, false},
24+ {"state commit keeps the gap", time.Millisecond, false, true, true, true},
25+ {"state commit keeps no gap", time.Millisecond, false, false, false, false},
26+ {"VRR off", time.Millisecond, true, false, true, false},
27+ {"desktop under VRR hold", time.Millisecond, true, true, false, false},
28+ } {
29+ // A gap is pending from the last game frame when it applies.
30+ pending := time.Time{}
31+ if tc.vrr && tc.game {
32+ pending = time.Now().Add(time.Hour)
33+ }
34+ o := &Output{vrrFlipGap: tc.gap, vrrOn: tc.vrr, vrrGame: tc.game, flipGapUntil: pending}
35+ o.startFlipGap(pendingFrame{frame: tc.frame})
36+ if got := !o.flipGapUntil.IsZero(); got != tc.want {
37+ t.Errorf("%s: gap started %v, want %v", tc.name, got, tc.want)
38+ }
39+ }
40+}
41+
42+// A cursor commit that completes inside the gap does not end it.
43+func TestStateCommitKeepsFlipGap(t *testing.T) {
44+ o, _, commits := testOutput(t)
45+ o.vrrFlipGap, o.vrrOn, o.vrrGame = time.Hour, true, true
46+ seen := map[ports.WindowID]uint64{}
47+ if err := o.commitFrame(70, nil, false, true, pendingFrame{}); err != nil {
48+ t.Fatal(err)
49+ }
50+ o.completed(eventOf((*commits)[0]), seen)
51+ gap := o.flipGapUntil
52+ if gap.IsZero() {
53+ t.Fatal("no gap after a game frame")
54+ }
55+ o.cursor.image = true
56+ o.lastFrame = time.Time{} // the cursor commits alone
57+ o.cursor.Move(3, 3)
58+ if err := o.commitState(o.stateVRR()); err != nil || len(*commits) != 2 {
59+ t.Fatalf("cursor commit: %v, %d commits", err, len(*commits))
60+ }
61+ o.completed(eventOf((*commits)[1]), seen)
62+ if o.flipGapUntil != gap {
63+ t.Fatalf("gap %v after a cursor commit, want %v", o.flipGapUntil, gap)
64+ }
65+}
66+
67+// A modeset drops a pending gap: it belonged to the old display state.
68+func TestModesetClearsFlipGap(t *testing.T) {
69+ o, k, _ := testOutput(t)
70+ o.flipGapUntil = time.Now().Add(time.Hour)
71+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
72+ if err := o.modeset(); err != nil {
73+ t.Fatal(err)
74+ }
75+ if !o.flipGapUntil.IsZero() {
76+ t.Fatalf("gap until %v after modeset", o.flipGapUntil)
77+ }
78+}
79+
80+// Run commits the next game frame no sooner than the gap after the flip
81+// event, then commits it on its own when the gap ends.
82+func TestRunWaitsVRRFlipGap(t *testing.T) {
83+ o, k, commits, commitMu := testOutputMu(t)
84+ o.cursor, o.tearing = nil, false
85+ o.vrrFlipGap = 30 * time.Millisecond
86+ flips := make(chan flipEvent, 1)
87+ o.flipped = flips
88+ r := portsmocks.NewMockRenderer(t)
89+ pipeBuf := func() ports.DMABuf {
90+ f, w, _ := os.Pipe()
91+ w.Close()
92+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
93+ }
94+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
95+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()
96+ r.EXPECT().UseTarget(mock.Anything).Return()
97+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
98+ r.EXPECT().Close().Return().Once()
99+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
100+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
101+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
102+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
103+ frames := func() (commitRec, int) {
104+ commitMu.Lock()
105+ defer commitMu.Unlock()
106+ n, last := 0, commitRec{}
107+ for _, c := range *commits {
108+ if c.user&3 == userFrame {
109+ n, last = n+1, c
110+ }
111+ }
112+ return last, n
113+ }
114+ scenes := make(chan ports.Scene, 1)
115+ ctx, cancel := context.WithCancel(context.Background())
116+ done := make(chan error, 1)
117+ go func() {
118+ 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)
119+ }()
120+ // A composed fullscreen window: a game frame under VRR.
121+ game := ports.Scene{OutputWidth: 200, OutputHeight: 100,
122+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}},
123+ Layers: []ports.SceneLayer{{ID: 5, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 10, H: 10}}}}
124+ scenes <- game
125+ waitFor(t, func() bool { _, n := frames(); return n == 1 })
126+ last, _ := frames()
127+ // The next frame is already waiting when the flip arrives.
128+ game.Seq = 1
129+ scenes <- game
130+ flips <- flipEvent{crtc: tCrtc, user: last.user, when: time.Second, seq: 1}
131+ // Run handles the flip after it is sent: its commit is at least the
132+ // gap after this point, whatever the scheduling delay.
133+ sent := time.Now()
134+ waitFor(t, func() bool { _, n := frames(); return n == 2 })
135+ commitMu.Lock()
136+ second := (*commits)[len(*commits)-1].at
137+ commitMu.Unlock()
138+ if waited := second.Sub(sent); waited < o.vrrFlipGap-time.Millisecond {
139+ t.Fatalf("next frame committed %s after the flip, gap %s", waited, o.vrrFlipGap)
140+ }
141+ cancel()
142+ if err := <-done; err != nil {
143+ t.Fatal(err)
144+ }
145+}
@@ -0,0 +1,145 @@
1+package drm
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+ "time"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+)
13+
14+// The gap starts only after a game frame's flip under VRR.
15+func TestStartFlipGap(t *testing.T) {
16+ for _, tc := range []struct {
17+ name string
18+ gap time.Duration
19+ frame, vrr, game bool
20+ want bool
21+ }{
22+ {"game frame under VRR", time.Millisecond, true, true, true, true},
23+ {"gap off", 0, true, true, true, false},
24+ {"state commit keeps the gap", time.Millisecond, false, true, true, true},
25+ {"state commit keeps no gap", time.Millisecond, false, false, false, false},
26+ {"VRR off", time.Millisecond, true, false, true, false},
27+ {"desktop under VRR hold", time.Millisecond, true, true, false, false},
28+ } {
29+ // A gap is pending from the last game frame when it applies.
30+ pending := time.Time{}
31+ if tc.vrr && tc.game {
32+ pending = time.Now().Add(time.Hour)
33+ }
34+ o := &Output{vrrFlipGap: tc.gap, vrrOn: tc.vrr, vrrGame: tc.game, flipGapUntil: pending}
35+ o.startFlipGap(pendingFrame{frame: tc.frame})
36+ if got := !o.flipGapUntil.IsZero(); got != tc.want {
37+ t.Errorf("%s: gap started %v, want %v", tc.name, got, tc.want)
38+ }
39+ }
40+}
41+
42+// A cursor commit that completes inside the gap does not end it.
43+func TestStateCommitKeepsFlipGap(t *testing.T) {
44+ o, _, commits := testOutput(t)
45+ o.vrrFlipGap, o.vrrOn, o.vrrGame = time.Hour, true, true
46+ seen := map[ports.WindowID]uint64{}
47+ if err := o.commitFrame(70, nil, false, true, pendingFrame{}); err != nil {
48+ t.Fatal(err)
49+ }
50+ o.completed(eventOf((*commits)[0]), seen)
51+ gap := o.flipGapUntil
52+ if gap.IsZero() {
53+ t.Fatal("no gap after a game frame")
54+ }
55+ o.cursor.image = true
56+ o.lastFrame = time.Time{} // the cursor commits alone
57+ o.cursor.Move(3, 3)
58+ if err := o.commitState(o.stateVRR()); err != nil || len(*commits) != 2 {
59+ t.Fatalf("cursor commit: %v, %d commits", err, len(*commits))
60+ }
61+ o.completed(eventOf((*commits)[1]), seen)
62+ if o.flipGapUntil != gap {
63+ t.Fatalf("gap %v after a cursor commit, want %v", o.flipGapUntil, gap)
64+ }
65+}
66+
67+// A modeset drops a pending gap: it belonged to the old display state.
68+func TestModesetClearsFlipGap(t *testing.T) {
69+ o, k, _ := testOutput(t)
70+ o.flipGapUntil = time.Now().Add(time.Hour)
71+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
72+ if err := o.modeset(); err != nil {
73+ t.Fatal(err)
74+ }
75+ if !o.flipGapUntil.IsZero() {
76+ t.Fatalf("gap until %v after modeset", o.flipGapUntil)
77+ }
78+}
79+
80+// Run commits the next game frame no sooner than the gap after the flip
81+// event, then commits it on its own when the gap ends.
82+func TestRunWaitsVRRFlipGap(t *testing.T) {
83+ o, k, commits, commitMu := testOutputMu(t)
84+ o.cursor, o.tearing = nil, false
85+ o.vrrFlipGap = 30 * time.Millisecond
86+ flips := make(chan flipEvent, 1)
87+ o.flipped = flips
88+ r := portsmocks.NewMockRenderer(t)
89+ pipeBuf := func() ports.DMABuf {
90+ f, w, _ := os.Pipe()
91+ w.Close()
92+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
93+ }
94+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
95+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()
96+ r.EXPECT().UseTarget(mock.Anything).Return()
97+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
98+ r.EXPECT().Close().Return().Once()
99+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
100+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
101+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
102+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
103+ frames := func() (commitRec, int) {
104+ commitMu.Lock()
105+ defer commitMu.Unlock()
106+ n, last := 0, commitRec{}
107+ for _, c := range *commits {
108+ if c.user&3 == userFrame {
109+ n, last = n+1, c
110+ }
111+ }
112+ return last, n
113+ }
114+ scenes := make(chan ports.Scene, 1)
115+ ctx, cancel := context.WithCancel(context.Background())
116+ done := make(chan error, 1)
117+ go func() {
118+ 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)
119+ }()
120+ // A composed fullscreen window: a game frame under VRR.
121+ game := ports.Scene{OutputWidth: 200, OutputHeight: 100,
122+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}},
123+ Layers: []ports.SceneLayer{{ID: 5, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 10, H: 10}}}}
124+ scenes <- game
125+ waitFor(t, func() bool { _, n := frames(); return n == 1 })
126+ last, _ := frames()
127+ // The next frame is already waiting when the flip arrives.
128+ game.Seq = 1
129+ scenes <- game
130+ flips <- flipEvent{crtc: tCrtc, user: last.user, when: time.Second, seq: 1}
131+ // Run handles the flip after it is sent: its commit is at least the
132+ // gap after this point, whatever the scheduling delay.
133+ sent := time.Now()
134+ waitFor(t, func() bool { _, n := frames(); return n == 2 })
135+ commitMu.Lock()
136+ second := (*commits)[len(*commits)-1].at
137+ commitMu.Unlock()
138+ if waited := second.Sub(sent); waited < o.vrrFlipGap-time.Millisecond {
139+ t.Fatalf("next frame committed %s after the flip, gap %s", waited, o.vrrFlipGap)
140+ }
141+ cancel()
142+ if err := <-done; err != nil {
143+ t.Fatal(err)
144+ }
145+}
M internal/adapters/libinput/libinput.go +6 -1
@@ -207,6 +207,9 @@ type Options struct {
207207 // LogMotion logs every pointer motion (--debug=input-motion); motions
208208 // are otherwise only counted in the input stats.
209209 LogMotion bool
210+ // LogKeys logs every key (--debug=input-keys): typed text, off by
211+ // default.
212+ LogKeys bool
210213 }
211214
212215 // statsEvery is how often Run logs input throughput.
@@ -447,7 +450,9 @@ func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.Inpu
447450 k := keyboardEvent(ev)
448451 code, pressed := keyboardKey(k), keyboardState(k) == 1
449452 ke := opts.Keymap.Key(code, pressed, msec(keyboardUsec(k)))
450- log.Debug().Uint32("code", code).Str("keysym", ke.Keysym).Bool("pressed", pressed).Uint8("mods", uint8(ke.Mods)).Msg("key")
453+ if opts.LogKeys {
454+ log.Debug().Uint32("code", code).Str("keysym", ke.Keysym).Bool("pressed", pressed).Uint8("mods", uint8(ke.Mods)).Msg("key")
455+ }
451456 switch action, vt := hotkey(ke); action {
452457 case hotkeyQuit:
453458 log.Warn().Str("reason", "emergency-key").Msg("quit")
@@ -207,6 +207,9 @@ type Options struct {
207 // LogMotion logs every pointer motion (--debug=input-motion); motions207 // LogMotion logs every pointer motion (--debug=input-motion); motions
208 // are otherwise only counted in the input stats.208 // are otherwise only counted in the input stats.
209 LogMotion bool209 LogMotion bool
210+ // LogKeys logs every key (--debug=input-keys): typed text, off by
211+ // default.
212+ LogKeys bool
210 }213 }
211 214
212 // statsEvery is how often Run logs input throughput.215 // statsEvery is how often Run logs input throughput.
@@ -447,7 +450,9 @@ func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.Inpu
447 k := keyboardEvent(ev)450 k := keyboardEvent(ev)
448 code, pressed := keyboardKey(k), keyboardState(k) == 1451 code, pressed := keyboardKey(k), keyboardState(k) == 1
449 ke := opts.Keymap.Key(code, pressed, msec(keyboardUsec(k)))452 ke := opts.Keymap.Key(code, pressed, msec(keyboardUsec(k)))
450- log.Debug().Uint32("code", code).Str("keysym", ke.Keysym).Bool("pressed", pressed).Uint8("mods", uint8(ke.Mods)).Msg("key")453+ if opts.LogKeys {
454+ log.Debug().Uint32("code", code).Str("keysym", ke.Keysym).Bool("pressed", pressed).Uint8("mods", uint8(ke.Mods)).Msg("key")
455+ }
451 switch action, vt := hotkey(ke); action {456 switch action, vt := hotkey(ke); action {
452 case hotkeyQuit:457 case hotkeyQuit:
453 log.Warn().Str("reason", "emergency-key").Msg("quit")458 log.Warn().Str("reason", "emergency-key").Msg("quit")
M internal/adapters/wayland/cursor.go +3 -1
@@ -140,8 +140,10 @@ func (s *Server) setCursor(c ports.CursorChange) {
140140 if s.channels.Cursors == nil {
141141 return
142142 }
143- s.log.Debug().Str("shape", c.Shape).Bool("client", c.Image != nil).Bool("hidden", c.Hidden).Msg("cursor")
144143 s.cursorMu.Lock()
144+ if c.Image == nil && (c.Shape != s.cursorLatest.Shape || c.Hidden != s.cursorLatest.Hidden || s.cursorLatest.Image != nil) {
145+ s.log.Debug().Str("shape", c.Shape).Bool("hidden", c.Hidden).Msg("cursor")
146+ }
145147 s.cursorLatest, s.cursorQueued = c, true
146148 s.cursorMu.Unlock()
147149 select {
@@ -140,8 +140,10 @@ func (s *Server) setCursor(c ports.CursorChange) {
140 if s.channels.Cursors == nil {140 if s.channels.Cursors == nil {
141 return141 return
142 }142 }
143- s.log.Debug().Str("shape", c.Shape).Bool("client", c.Image != nil).Bool("hidden", c.Hidden).Msg("cursor")
144 s.cursorMu.Lock()143 s.cursorMu.Lock()
144+ if c.Image == nil && (c.Shape != s.cursorLatest.Shape || c.Hidden != s.cursorLatest.Hidden || s.cursorLatest.Image != nil) {
145+ s.log.Debug().Str("shape", c.Shape).Bool("hidden", c.Hidden).Msg("cursor")
146+ }
145 s.cursorLatest, s.cursorQueued = c, true147 s.cursorLatest, s.cursorQueued = c, true
146 s.cursorMu.Unlock()148 s.cursorMu.Unlock()
147 select {149 select {
M internal/app/app.go +2 -1
@@ -200,7 +200,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
200200 log.Warn().Str("component", "sched").Err(err).Msg("input scheduling")
201201 }
202202 }
203- return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Touchpad: opts.Config.Touchpad, Touchpads: touchpads, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion")}, input)
203+ return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Touchpad: opts.Config.Touchpad, Touchpads: touchpads, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion"), LogKeys: logging.Enabled(ctx, "input-keys")}, input)
204204 })
205205 return
206206 }
@@ -265,6 +265,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
265265 want := func(cfg ports.Config) drm.Want {
266266 w := wantFromConfig(cfg)
267267 w.Sampled = dmabuf.Formats
268+ w.TraceFlips = logging.Enabled(ctx, "drm-flip")
268269 return w
269270 }
270271 return hw.runOutputs(ctx, want, opts.Config, apply, outputIO, curs, newRenderer, logging.For(ctx, "drm"))
@@ -200,7 +200,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
200 log.Warn().Str("component", "sched").Err(err).Msg("input scheduling")200 log.Warn().Str("component", "sched").Err(err).Msg("input scheduling")
201 }201 }
202 }202 }
203- return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Touchpad: opts.Config.Touchpad, Touchpads: touchpads, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion")}, input)203+ return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Touchpad: opts.Config.Touchpad, Touchpads: touchpads, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion"), LogKeys: logging.Enabled(ctx, "input-keys")}, input)
204 })204 })
205 return205 return
206 }206 }
@@ -265,6 +265,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
265 want := func(cfg ports.Config) drm.Want {265 want := func(cfg ports.Config) drm.Want {
266 w := wantFromConfig(cfg)266 w := wantFromConfig(cfg)
267 w.Sampled = dmabuf.Formats267 w.Sampled = dmabuf.Formats
268+ w.TraceFlips = logging.Enabled(ctx, "drm-flip")
268 return w269 return w
269 }270 }
270 return hw.runOutputs(ctx, want, opts.Config, apply, outputIO, curs, newRenderer, logging.For(ctx, "drm"))271 return hw.runOutputs(ctx, want, opts.Config, apply, outputIO, curs, newRenderer, logging.For(ctx, "drm"))
M internal/app/drm.go +2 -1
@@ -81,6 +81,7 @@ func openDRM(ctx context.Context, cfg ports.Config) (*drmBackend, error) {
8181 return nil, fmt.Errorf("open seat: %w", err)
8282 }
8383 want := wantFromConfig(cfg)
84+ want.TraceFlips = logging.Enabled(ctx, "drm-flip")
8485 paths, _ := filepath.Glob("/dev/dri/card[0-9]*")
8586 sort.Strings(paths)
8687 b := &drmBackend{seat: s, clock: clock.System{}}
@@ -112,7 +113,7 @@ func openDRM(ctx context.Context, cfg ports.Config) (*drmBackend, error) {
112113 // wantFromConfig turns output.<name> and render.* entries into connector choices.
113114 func wantFromConfig(cfg ports.Config) drm.Want {
114115 r := cfg.Render
115- want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}, HDR: map[string]drm.HDRSettings{}, NoScanout: !r.DirectScanout, NoTearing: !r.Tearing, NoVRR: !r.VRR}
116+ want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}, HDR: map[string]drm.HDRSettings{}, NoScanout: !r.DirectScanout, NoTearing: !r.Tearing, NoVRR: !r.VRR, VRRFlipGap: r.VRRFlipGap}
116117 for _, o := range cfg.Outputs {
117118 nits := o.SDRBrightness
118119 if nits == 0 {
@@ -81,6 +81,7 @@ func openDRM(ctx context.Context, cfg ports.Config) (*drmBackend, error) {
81 return nil, fmt.Errorf("open seat: %w", err)81 return nil, fmt.Errorf("open seat: %w", err)
82 }82 }
83 want := wantFromConfig(cfg)83 want := wantFromConfig(cfg)
84+ want.TraceFlips = logging.Enabled(ctx, "drm-flip")
84 paths, _ := filepath.Glob("/dev/dri/card[0-9]*")85 paths, _ := filepath.Glob("/dev/dri/card[0-9]*")
85 sort.Strings(paths)86 sort.Strings(paths)
86 b := &drmBackend{seat: s, clock: clock.System{}}87 b := &drmBackend{seat: s, clock: clock.System{}}
@@ -112,7 +113,7 @@ func openDRM(ctx context.Context, cfg ports.Config) (*drmBackend, error) {
112 // wantFromConfig turns output.<name> and render.* entries into connector choices.113 // wantFromConfig turns output.<name> and render.* entries into connector choices.
113 func wantFromConfig(cfg ports.Config) drm.Want {114 func wantFromConfig(cfg ports.Config) drm.Want {
114 r := cfg.Render115 r := cfg.Render
115- want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}, HDR: map[string]drm.HDRSettings{}, NoScanout: !r.DirectScanout, NoTearing: !r.Tearing, NoVRR: !r.VRR}116+ want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}, HDR: map[string]drm.HDRSettings{}, NoScanout: !r.DirectScanout, NoTearing: !r.Tearing, NoVRR: !r.VRR, VRRFlipGap: r.VRRFlipGap}
116 for _, o := range cfg.Outputs {117 for _, o := range cfg.Outputs {
117 nits := o.SDRBrightness118 nits := o.SDRBrightness
118 if nits == 0 {119 if nits == 0 {
M internal/app/drm_test.go +3 -2
@@ -2,6 +2,7 @@ package app
22
33 import (
44 "testing"
5+ "time"
56
67 "github.com/bnema/neferwl/internal/adapters/drm"
78 "github.com/bnema/neferwl/internal/ports"
@@ -9,9 +10,9 @@ import (
910
1011 func TestWantFromConfig(t *testing.T) {
1112 cfg := ports.Config{Outputs: []ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true, HDR: true, SDRBrightness: 350}, {Name: "HDMI-A-1", Mode: "1920x1080@60"}, {Name: "DP-3", Off: true}}}
12- cfg.Render.DirectScanout, cfg.Render.VRR = true, true
13+ cfg.Render.DirectScanout, cfg.Render.VRR, cfg.Render.VRRFlipGap = true, true, 2*time.Millisecond
1314 want := wantFromConfig(cfg)
14- if m := want.Modes["HDMI-A-1"]; m != [3]float64{1920, 1080, 60} || !want.Disabled["DP-3"] || len(want.Modes) != 1 || want.NoScanout || !want.NoTearing || want.NoVRR || want.HDR["eDP-1"] != (drm.HDRSettings{Enabled: true, SDRBrightness: 350}) || want.HDR["HDMI-A-1"].SDRBrightness != 203 {
15+ if m := want.Modes["HDMI-A-1"]; m != [3]float64{1920, 1080, 60} || want.VRRFlipGap != 2*time.Millisecond || !want.Disabled["DP-3"] || len(want.Modes) != 1 || want.NoScanout || !want.NoTearing || want.NoVRR || want.HDR["eDP-1"] != (drm.HDRSettings{Enabled: true, SDRBrightness: 350}) || want.HDR["HDMI-A-1"].SDRBrightness != 203 {
1516 t.Fatalf("%+v", want)
1617 }
1718 }
@@ -2,6 +2,7 @@ package app
2 2
3 import (3 import (
4 "testing"4 "testing"
5+ "time"
5 6
6 "github.com/bnema/neferwl/internal/adapters/drm"7 "github.com/bnema/neferwl/internal/adapters/drm"
7 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
@@ -9,9 +10,9 @@ import (
9 10
10 func TestWantFromConfig(t *testing.T) {11 func TestWantFromConfig(t *testing.T) {
11 cfg := ports.Config{Outputs: []ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true, HDR: true, SDRBrightness: 350}, {Name: "HDMI-A-1", Mode: "1920x1080@60"}, {Name: "DP-3", Off: true}}}12 cfg := ports.Config{Outputs: []ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true, HDR: true, SDRBrightness: 350}, {Name: "HDMI-A-1", Mode: "1920x1080@60"}, {Name: "DP-3", Off: true}}}
12- cfg.Render.DirectScanout, cfg.Render.VRR = true, true13+ cfg.Render.DirectScanout, cfg.Render.VRR, cfg.Render.VRRFlipGap = true, true, 2*time.Millisecond
13 want := wantFromConfig(cfg)14 want := wantFromConfig(cfg)
14- if m := want.Modes["HDMI-A-1"]; m != [3]float64{1920, 1080, 60} || !want.Disabled["DP-3"] || len(want.Modes) != 1 || want.NoScanout || !want.NoTearing || want.NoVRR || want.HDR["eDP-1"] != (drm.HDRSettings{Enabled: true, SDRBrightness: 350}) || want.HDR["HDMI-A-1"].SDRBrightness != 203 {15+ if m := want.Modes["HDMI-A-1"]; m != [3]float64{1920, 1080, 60} || want.VRRFlipGap != 2*time.Millisecond || !want.Disabled["DP-3"] || len(want.Modes) != 1 || want.NoScanout || !want.NoTearing || want.NoVRR || want.HDR["eDP-1"] != (drm.HDRSettings{Enabled: true, SDRBrightness: 350}) || want.HDR["HDMI-A-1"].SDRBrightness != 203 {
15 t.Fatalf("%+v", want)16 t.Fatalf("%+v", want)
16 }17 }
17 }18 }
M internal/logging/logging.go +10 -7
@@ -21,9 +21,10 @@ var components = map[string]bool{
2121 }
2222
2323 // categories are debug switches narrower than a component, only on when
24-// named: "all" leaves them off (input-motion logs every pointer motion).
25-// Naming one turns on debug for its component.
26-var categories = map[string]string{"input-motion": "input"}
24+// named: "all" leaves them off. input-motion logs every pointer motion,
25+// input-keys every key (typed text), drm-flip every frame and commit
26+// completion with its timing. Naming one turns on debug for its component.
27+var categories = map[string]string{"input-motion": "input", "input-keys": "input", "drm-flip": "drm"}
2728
2829 type debugKey struct{}
2930 type levelKey struct{}
@@ -50,10 +51,12 @@ func ParseDebug(value string) (map[string]bool, error) {
5051 // keepRuns is how many per-run log files Open retains.
5152 const keepRuns = 20
5253
53-// Open creates runs/<timestamp>.log, points runs/latest.log at it, prunes old runs
54-// and attaches the logger to ctx.
54+// Open creates runs/<backend>/<timestamp>.log, points latest.log in that
55+// directory at it, prunes old runs of that backend and attaches the logger
56+// to ctx. Each backend keeps its own runs: headless test runs never rotate
57+// a DRM session's log away.
5558 // The caller must invoke close after all logging is complete.
56-func Open(ctx context.Context, level, debug string) (context.Context, func() error, error) {
59+func Open(ctx context.Context, backend, level, debug string) (context.Context, func() error, error) {
5760 selected, err := ParseDebug(debug)
5861 if err != nil {
5962 return nil, nil, err
@@ -66,7 +69,7 @@ func Open(ctx context.Context, level, debug string) (context.Context, func() err
6669 }
6770 state = filepath.Join(home, ".local", "state")
6871 }
69- dir := filepath.Join(state, "neferwl", "runs")
72+ dir := filepath.Join(state, "neferwl", "runs", backend)
7073 if err := os.MkdirAll(dir, 0o700); err != nil {
7174 return nil, nil, err
7275 }
@@ -21,9 +21,10 @@ var components = map[string]bool{
21 }21 }
22 22
23 // categories are debug switches narrower than a component, only on when23 // categories are debug switches narrower than a component, only on when
24-// named: "all" leaves them off (input-motion logs every pointer motion).24+// named: "all" leaves them off. input-motion logs every pointer motion,
25-// Naming one turns on debug for its component.25+// input-keys every key (typed text), drm-flip every frame and commit
26-var categories = map[string]string{"input-motion": "input"}26+// completion with its timing. Naming one turns on debug for its component.
27+var categories = map[string]string{"input-motion": "input", "input-keys": "input", "drm-flip": "drm"}
27 28
28 type debugKey struct{}29 type debugKey struct{}
29 type levelKey struct{}30 type levelKey struct{}
@@ -50,10 +51,12 @@ func ParseDebug(value string) (map[string]bool, error) {
50 // keepRuns is how many per-run log files Open retains.51 // keepRuns is how many per-run log files Open retains.
51 const keepRuns = 2052 const keepRuns = 20
52 53
53-// Open creates runs/<timestamp>.log, points runs/latest.log at it, prunes old runs54+// Open creates runs/<backend>/<timestamp>.log, points latest.log in that
54-// and attaches the logger to ctx.55+// directory at it, prunes old runs of that backend and attaches the logger
56+// to ctx. Each backend keeps its own runs: headless test runs never rotate
57+// a DRM session's log away.
55 // The caller must invoke close after all logging is complete.58 // The caller must invoke close after all logging is complete.
56-func Open(ctx context.Context, level, debug string) (context.Context, func() error, error) {59+func Open(ctx context.Context, backend, level, debug string) (context.Context, func() error, error) {
57 selected, err := ParseDebug(debug)60 selected, err := ParseDebug(debug)
58 if err != nil {61 if err != nil {
59 return nil, nil, err62 return nil, nil, err
@@ -66,7 +69,7 @@ func Open(ctx context.Context, level, debug string) (context.Context, func() err
66 }69 }
67 state = filepath.Join(home, ".local", "state")70 state = filepath.Join(home, ".local", "state")
68 }71 }
69- dir := filepath.Join(state, "neferwl", "runs")72+ dir := filepath.Join(state, "neferwl", "runs", backend)
70 if err := os.MkdirAll(dir, 0o700); err != nil {73 if err := os.MkdirAll(dir, 0o700); err != nil {
71 return nil, nil, err74 return nil, nil, err
72 }75 }
M internal/logging/logging_test.go +47 -15
@@ -34,11 +34,11 @@ func TestParseDebug(t *testing.T) {
3434
3535 func TestComponentLevelsAndRotation(t *testing.T) {
3636 t.Setenv("XDG_STATE_HOME", t.TempDir())
37- dir := filepath.Join(os.Getenv("XDG_STATE_HOME"), "neferwl", "runs")
37+ dir := filepath.Join(os.Getenv("XDG_STATE_HOME"), "neferwl", "runs", "drm")
3838 path := filepath.Join(dir, "latest.log")
3939 for run := 0; run < keepRuns+2; run++ {
4040 time.Sleep(2 * time.Millisecond) // distinct millisecond timestamps
41- ctx, closeLog, err := Open(context.Background(), "info", "core")
41+ ctx, closeLog, err := Open(context.Background(), "drm", "info", "core")
4242 if err != nil {
4343 t.Fatal(err)
4444 }
@@ -84,10 +84,39 @@ func splitLines(data []byte) [][]byte {
8484 return lines
8585 }
8686
87+// Headless runs rotate in their own directory: they never prune a DRM run.
88+func TestBackendsRotateSeparately(t *testing.T) {
89+ t.Setenv("XDG_STATE_HOME", t.TempDir())
90+ runs := filepath.Join(os.Getenv("XDG_STATE_HOME"), "neferwl", "runs")
91+ open := func(backend string) {
92+ time.Sleep(2 * time.Millisecond) // distinct millisecond timestamps
93+ _, closeLog, err := Open(context.Background(), backend, "info", "")
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ if err := closeLog(); err != nil {
98+ t.Fatal(err)
99+ }
100+ }
101+ open("drm")
102+ for range keepRuns + 2 {
103+ open("headless")
104+ }
105+ if drm, _ := filepath.Glob(filepath.Join(runs, "drm", "2*.log")); len(drm) != 1 {
106+ t.Fatalf("drm runs %v", drm)
107+ }
108+ if _, err := os.Stat(filepath.Join(runs, "drm", "latest.log")); err != nil {
109+ t.Fatal(err)
110+ }
111+ if headless, _ := filepath.Glob(filepath.Join(runs, "headless", "2*.log")); len(headless) != keepRuns {
112+ t.Fatalf("kept %d headless runs", len(headless))
113+ }
114+}
115+
87116 // A category alone raises its component to debug, so its lines are written.
88117 func TestCategoryEnablesComponentDebug(t *testing.T) {
89118 t.Setenv("XDG_STATE_HOME", t.TempDir())
90- ctx, closeLog, err := Open(context.Background(), "info", "input-motion")
119+ ctx, closeLog, err := Open(context.Background(), "drm", "info", "input-motion")
91120 if err != nil {
92121 t.Fatal(err)
93122 }
@@ -97,7 +126,7 @@ func TestCategoryEnablesComponentDebug(t *testing.T) {
97126 if err := closeLog(); err != nil {
98127 t.Fatal(err)
99128 }
100- data, err := os.ReadFile(filepath.Join(os.Getenv("XDG_STATE_HOME"), "neferwl", "runs", "latest.log"))
129+ data, err := os.ReadFile(filepath.Join(os.Getenv("XDG_STATE_HOME"), "neferwl", "runs", "drm", "latest.log"))
101130 if err != nil {
102131 t.Fatal(err)
103132 }
@@ -108,17 +137,20 @@ func TestCategoryEnablesComponentDebug(t *testing.T) {
108137
109138 // Narrow categories are on only when named, never through "all".
110139 func TestEnabledCategories(t *testing.T) {
111- for _, tc := range []struct {
112- debug string
113- want bool
114- }{{"all", false}, {"input", false}, {"input-motion", true}, {"all,input-motion", true}} {
115- sel, err := ParseDebug(tc.debug)
116- if err != nil {
117- t.Fatal(err)
118- }
119- ctx := context.WithValue(context.Background(), debugKey{}, debugSet(sel))
120- if got := Enabled(ctx, "input-motion"); got != tc.want {
121- t.Errorf("%q: %v", tc.debug, got)
140+ for _, category := range []string{"input-motion", "input-keys", "drm-flip"} {
141+ component := categories[category]
142+ for _, tc := range []struct {
143+ debug string
144+ want bool
145+ }{{"all", false}, {component, false}, {category, true}, {"all," + category, true}} {
146+ sel, err := ParseDebug(tc.debug)
147+ if err != nil {
148+ t.Fatal(err)
149+ }
150+ ctx := context.WithValue(context.Background(), debugKey{}, debugSet(sel))
151+ if got := Enabled(ctx, category); got != tc.want {
152+ t.Errorf("%s with %q: %v", category, tc.debug, got)
153+ }
122154 }
123155 }
124156 }
@@ -34,11 +34,11 @@ func TestParseDebug(t *testing.T) {
34 34
35 func TestComponentLevelsAndRotation(t *testing.T) {35 func TestComponentLevelsAndRotation(t *testing.T) {
36 t.Setenv("XDG_STATE_HOME", t.TempDir())36 t.Setenv("XDG_STATE_HOME", t.TempDir())
37- dir := filepath.Join(os.Getenv("XDG_STATE_HOME"), "neferwl", "runs")37+ dir := filepath.Join(os.Getenv("XDG_STATE_HOME"), "neferwl", "runs", "drm")
38 path := filepath.Join(dir, "latest.log")38 path := filepath.Join(dir, "latest.log")
39 for run := 0; run < keepRuns+2; run++ {39 for run := 0; run < keepRuns+2; run++ {
40 time.Sleep(2 * time.Millisecond) // distinct millisecond timestamps40 time.Sleep(2 * time.Millisecond) // distinct millisecond timestamps
41- ctx, closeLog, err := Open(context.Background(), "info", "core")41+ ctx, closeLog, err := Open(context.Background(), "drm", "info", "core")
42 if err != nil {42 if err != nil {
43 t.Fatal(err)43 t.Fatal(err)
44 }44 }
@@ -84,10 +84,39 @@ func splitLines(data []byte) [][]byte {
84 return lines84 return lines
85 }85 }
86 86
87+// Headless runs rotate in their own directory: they never prune a DRM run.
88+func TestBackendsRotateSeparately(t *testing.T) {
89+ t.Setenv("XDG_STATE_HOME", t.TempDir())
90+ runs := filepath.Join(os.Getenv("XDG_STATE_HOME"), "neferwl", "runs")
91+ open := func(backend string) {
92+ time.Sleep(2 * time.Millisecond) // distinct millisecond timestamps
93+ _, closeLog, err := Open(context.Background(), backend, "info", "")
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ if err := closeLog(); err != nil {
98+ t.Fatal(err)
99+ }
100+ }
101+ open("drm")
102+ for range keepRuns + 2 {
103+ open("headless")
104+ }
105+ if drm, _ := filepath.Glob(filepath.Join(runs, "drm", "2*.log")); len(drm) != 1 {
106+ t.Fatalf("drm runs %v", drm)
107+ }
108+ if _, err := os.Stat(filepath.Join(runs, "drm", "latest.log")); err != nil {
109+ t.Fatal(err)
110+ }
111+ if headless, _ := filepath.Glob(filepath.Join(runs, "headless", "2*.log")); len(headless) != keepRuns {
112+ t.Fatalf("kept %d headless runs", len(headless))
113+ }
114+}
115+
87 // A category alone raises its component to debug, so its lines are written.116 // A category alone raises its component to debug, so its lines are written.
88 func TestCategoryEnablesComponentDebug(t *testing.T) {117 func TestCategoryEnablesComponentDebug(t *testing.T) {
89 t.Setenv("XDG_STATE_HOME", t.TempDir())118 t.Setenv("XDG_STATE_HOME", t.TempDir())
90- ctx, closeLog, err := Open(context.Background(), "info", "input-motion")119+ ctx, closeLog, err := Open(context.Background(), "drm", "info", "input-motion")
91 if err != nil {120 if err != nil {
92 t.Fatal(err)121 t.Fatal(err)
93 }122 }
@@ -97,7 +126,7 @@ func TestCategoryEnablesComponentDebug(t *testing.T) {
97 if err := closeLog(); err != nil {126 if err := closeLog(); err != nil {
98 t.Fatal(err)127 t.Fatal(err)
99 }128 }
100- data, err := os.ReadFile(filepath.Join(os.Getenv("XDG_STATE_HOME"), "neferwl", "runs", "latest.log"))129+ data, err := os.ReadFile(filepath.Join(os.Getenv("XDG_STATE_HOME"), "neferwl", "runs", "drm", "latest.log"))
101 if err != nil {130 if err != nil {
102 t.Fatal(err)131 t.Fatal(err)
103 }132 }
@@ -108,17 +137,20 @@ func TestCategoryEnablesComponentDebug(t *testing.T) {
108 137
109 // Narrow categories are on only when named, never through "all".138 // Narrow categories are on only when named, never through "all".
110 func TestEnabledCategories(t *testing.T) {139 func TestEnabledCategories(t *testing.T) {
111- for _, tc := range []struct {140+ for _, category := range []string{"input-motion", "input-keys", "drm-flip"} {
112- debug string141+ component := categories[category]
113- want bool142+ for _, tc := range []struct {
114- }{{"all", false}, {"input", false}, {"input-motion", true}, {"all,input-motion", true}} {143+ debug string
115- sel, err := ParseDebug(tc.debug)144+ want bool
116- if err != nil {145+ }{{"all", false}, {component, false}, {category, true}, {"all," + category, true}} {
117- t.Fatal(err)146+ sel, err := ParseDebug(tc.debug)
118- }147+ if err != nil {
119- ctx := context.WithValue(context.Background(), debugKey{}, debugSet(sel))148+ t.Fatal(err)
120- if got := Enabled(ctx, "input-motion"); got != tc.want {149+ }
121- t.Errorf("%q: %v", tc.debug, got)150+ ctx := context.WithValue(context.Background(), debugKey{}, debugSet(sel))
151+ if got := Enabled(ctx, category); got != tc.want {
152+ t.Errorf("%s with %q: %v", category, tc.debug, got)
153+ }
122 }154 }
123 }155 }
124 }156 }
M internal/ports/config.go +8 -1
@@ -1,6 +1,9 @@
11 package ports
22
3-import "image"
3+import (
4+ "image"
5+ "time"
6+)
47
58 // DefaultSDRBrightness is the BT.2408 SDR reference white in HDR, in nits.
69 const DefaultSDRBrightness = 203
@@ -113,6 +116,10 @@ type Config struct {
113116 // Tearing honours wp_tearing_control_v1 in direct scanout; VRR
114117 // turns variable refresh on while a buffer is scanned out.
115118 Tearing, VRR bool
119+ // VRRFlipGap is the minimum time between a game frame's flip and
120+ // the next frame commit under VRR (0: off). It works around
121+ // flips that land at the panel's slowest refresh.
122+ VRRFlipGap time.Duration
116123 }
117124 Performance struct{ Realtime bool }
118125 Log struct {
@@ -1,6 +1,9 @@
1 package ports1 package ports
2 2
3-import "image"3+import (
4+ "image"
5+ "time"
6+)
4 7
5 // DefaultSDRBrightness is the BT.2408 SDR reference white in HDR, in nits.8 // DefaultSDRBrightness is the BT.2408 SDR reference white in HDR, in nits.
6 const DefaultSDRBrightness = 2039 const DefaultSDRBrightness = 203
@@ -113,6 +116,10 @@ type Config struct {
113 // Tearing honours wp_tearing_control_v1 in direct scanout; VRR116 // Tearing honours wp_tearing_control_v1 in direct scanout; VRR
114 // turns variable refresh on while a buffer is scanned out.117 // turns variable refresh on while a buffer is scanned out.
115 Tearing, VRR bool118 Tearing, VRR bool
119+ // VRRFlipGap is the minimum time between a game frame's flip and
120+ // the next frame commit under VRR (0: off). It works around
121+ // flips that land at the panel's slowest refresh.
122+ VRRFlipGap time.Duration
116 }123 }
117 Performance struct{ Realtime bool }124 Performance struct{ Realtime bool }
118 Log struct {125 Log struct {

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 25

Tags 10

↑↓ moveenter openesc close