fix(drm): carry cursor moves on game frames under VRR
A cursor-only commit under VRR refreshes the panel at its slowest rate (~21 ms), delaying the next fullscreen game frame. While a game drives VRR, cursor moves now wait for the next frame, or commit alone after 1/24 s. slow flip logs frame and vrr.
- commit
- 2b40f0aba5c1cd1bab0e1fe40249d14a376cb6e1
- author
- brice · · 2026-09-28 14:29 UTC
- committer
- brice · · 2026-09-28 14:55 UTC
- parent
- 7b60e95
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +92 -2
M docs/performance.md +3 -0
| @@ -6,6 +6,7 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen | |||
| 6 | 6 | ||
| 7 | 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 | 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 | 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. | |
| 10 | 11 | ||
| 11 | 12 | ## Guards | |
| @@ -31,6 +32,8 @@ GODEBUG=gctrace=1 neferwl --backend=drm | |||
| 31 | 32 | ||
| 32 | 33 | `make tty` enables it on `localhost:6060` (`make tty PPROF=` disables it). `--pprof` also serves `/debug/pprof/profile` (CPU) and `/debug/pprof/trace` (scheduler and GC timeline for `go tool trace`). Escape analysis (`go build -gcflags=-m ./internal/adapters/vulkan`) shows why a value reaches the heap. | |
| 33 | 34 | ||
| 35 | + | `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. | |
| 36 | + | ||
| 34 | 37 | ## Reference: 4K HDR tiled video playback | |
| 35 | 38 | ||
| 36 | 39 | 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). | |
| @@ -6,6 +6,7 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen | |||||
| 6 | 6 | ||||
| 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 | - **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. | 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. | ||
| 10 | 11 | ||||
| 11 | ## Guards | 12 | ## Guards | ||
| @@ -31,6 +32,8 @@ GODEBUG=gctrace=1 neferwl --backend=drm | |||||
| 31 | 32 | ||||
| 32 | `make tty` enables it on `localhost:6060` (`make tty PPROF=` disables it). `--pprof` also serves `/debug/pprof/profile` (CPU) and `/debug/pprof/trace` (scheduler and GC timeline for `go tool trace`). Escape analysis (`go build -gcflags=-m ./internal/adapters/vulkan`) shows why a value reaches the heap. | 33 | `make tty` enables it on `localhost:6060` (`make tty PPROF=` disables it). `--pprof` also serves `/debug/pprof/profile` (CPU) and `/debug/pprof/trace` (scheduler and GC timeline for `go tool trace`). Escape analysis (`go build -gcflags=-m ./internal/adapters/vulkan`) shows why a value reaches the heap. | ||
| 33 | 34 | ||||
| 35 | + | `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. | |||
| 36 | + | ||||
| 34 | ## Reference: 4K HDR tiled video playback | 37 | ## Reference: 4K HDR tiled video playback | ||
| 35 | 38 | ||||
| 36 | 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). | 39 | 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/drm/output.go +40 -2
| @@ -94,6 +94,10 @@ type Output struct { | |||
| 94 | 94 | vrrOn bool | |
| 95 | 95 | vrrGame bool | |
| 96 | 96 | composedSince time.Time | |
| 97 | + | // lastFrame is when the last frame was committed. cursorHeld: a cursor | |
| 98 | + | // move waits for the next frame (see cursorWaits). | |
| 99 | + | lastFrame time.Time | |
| 100 | + | cursorHeld bool | |
| 97 | 101 | // wantOff is the latest Scene.Off: a client turned the display off. | |
| 98 | 102 | // off: the CRTC is inactive for it. Every modeset (resume, recovery) | |
| 99 | 103 | // follows wantOff, so a display turned off never lights up. | |
| @@ -604,6 +608,7 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p | |||
| 604 | 608 | if !async { | |
| 605 | 609 | o.overlayOn = ov.buf | |
| 606 | 610 | } | |
| 611 | + | o.lastFrame, o.cursorHeld = time.Now(), false | |
| 607 | 612 | f.frame, f.async = true, async | |
| 608 | 613 | f.fences = dupFences(fence, ov.acquire) | |
| 609 | 614 | o.setAsync(async) | |
| @@ -626,7 +631,12 @@ func (o *Output) commitState(vrr bool) error { | |||
| 626 | 631 | if o.cursor != nil { | |
| 627 | 632 | cur = o.cursor.desired() | |
| 628 | 633 | if cur != o.cursor.applied { | |
| 629 | - | o.cursor.props(req, o.crtc, cur) | |
| 634 | + | if o.cursorWaits(time.Now()) { | |
| 635 | + | o.cursorHeld = true | |
| 636 | + | cur = o.cursor.applied | |
| 637 | + | } else { | |
| 638 | + | o.cursor.props(req, o.crtc, cur) | |
| 639 | + | } | |
| 630 | 640 | } | |
| 631 | 641 | } | |
| 632 | 642 | if vrr != o.vrrOn { | |
| @@ -647,6 +657,9 @@ func (o *Output) commitState(vrr bool) error { | |||
| 647 | 657 | } | |
| 648 | 658 | if o.cursor != nil { | |
| 649 | 659 | o.cursor.committed(cur) | |
| 660 | + | if cur == o.cursor.desired() { | |
| 661 | + | o.cursorHeld = false | |
| 662 | + | } | |
| 650 | 663 | } | |
| 651 | 664 | if vrr != o.vrrOn { | |
| 652 | 665 | o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr") | |
| @@ -656,6 +669,18 @@ func (o *Output) commitState(vrr bool) error { | |||
| 656 | 669 | return nil | |
| 657 | 670 | } | |
| 658 | 671 | ||
| 672 | + | // cursorMinInterval bounds how long a held cursor move waits for a frame. | |
| 673 | + | const cursorMinInterval = time.Second / 24 | |
| 674 | + | ||
| 675 | + | // cursorWaits reports whether a cursor move rides on the next frame | |
| 676 | + | // instead of its own commit. Under VRR a cursor-only commit refreshes the | |
| 677 | + | // panel at its slowest rate, which delays the game's next frame by up to | |
| 678 | + | // one such refresh. A game that stops drawing still gets the cursor at | |
| 679 | + | // cursorMinInterval. | |
| 680 | + | func (o *Output) cursorWaits(now time.Time) bool { | |
| 681 | + | return o.vrrOn && o.vrrGame && now.Sub(o.lastFrame) < cursorMinInterval | |
| 682 | + | } | |
| 683 | + | ||
| 659 | 684 | func boolValue(b bool) uint64 { | |
| 660 | 685 | if b { | |
| 661 | 686 | return 1 | |
| @@ -857,11 +882,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 857 | 882 | // guarantees a stopped/reset timer cannot deliver its old value. | |
| 858 | 883 | retryTimer := time.NewTimer(time.Hour) | |
| 859 | 884 | vrrTimer := time.NewTimer(time.Hour) | |
| 885 | + | cursorTimer := time.NewTimer(time.Hour) | |
| 860 | 886 | o.frame.startTimer() | |
| 861 | 887 | retryTimer.Stop() | |
| 862 | 888 | vrrTimer.Stop() | |
| 889 | + | cursorTimer.Stop() | |
| 863 | 890 | defer retryTimer.Stop() | |
| 864 | 891 | defer vrrTimer.Stop() | |
| 892 | + | defer cursorTimer.Stop() | |
| 865 | 893 | defer o.frame.stopTimer() | |
| 866 | 894 | // seen is the latest content Seq per window. It is reported only while | |
| 867 | 895 | // no frame is in flight: then the GPU has finished every frame that | |
| @@ -898,6 +926,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 898 | 926 | } else { | |
| 899 | 927 | vrrTimer.Stop() | |
| 900 | 928 | } | |
| 929 | + | // A held cursor move commits alone if no frame comes in time. | |
| 930 | + | var cursorDue <-chan time.Time | |
| 931 | + | if enabled && o.cursorHeld && !o.frame.pendingCommit() { | |
| 932 | + | cursorTimer.Reset(max(0, cursorMinInterval-time.Since(o.lastFrame))) | |
| 933 | + | cursorDue = cursorTimer.C | |
| 934 | + | } else { | |
| 935 | + | cursorTimer.Stop() | |
| 936 | + | } | |
| 901 | 937 | // A commit whose event never comes must not stop the output. | |
| 902 | 938 | stuck := o.frame.wait(enabled) | |
| 903 | 939 | stateDirty := false | |
| @@ -920,6 +956,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 920 | 956 | stateDirty = true | |
| 921 | 957 | case <-vrrOff: | |
| 922 | 958 | stateDirty = true | |
| 959 | + | case <-cursorDue: | |
| 960 | + | stateDirty = true | |
| 923 | 961 | case <-cursorWake: | |
| 924 | 962 | stateDirty = true | |
| 925 | 963 | case <-ctx.Done(): | |
| @@ -1149,7 +1187,7 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool { | |||
| 1149 | 1187 | return false | |
| 1150 | 1188 | } | |
| 1151 | 1189 | if age := time.Since(start); age > 20*time.Millisecond { | |
| 1152 | - | o.log.Info().Dur("flip_ms", age).Str("connector", o.conn.name).Msg("slow flip") | |
| 1190 | + | o.log.Info().Dur("flip_ms", age).Bool("frame", f.frame).Bool("vrr", o.vrrOn).Str("connector", o.conn.name).Msg("slow flip") | |
| 1153 | 1191 | } | |
| 1154 | 1192 | if o.cursor != nil { | |
| 1155 | 1193 | o.cursor.landed() | |
| @@ -94,6 +94,10 @@ type Output struct { | |||||
| 94 | vrrOn bool | 94 | vrrOn bool | ||
| 95 | vrrGame bool | 95 | vrrGame bool | ||
| 96 | composedSince time.Time | 96 | composedSince time.Time | ||
| 97 | + | // lastFrame is when the last frame was committed. cursorHeld: a cursor | |||
| 98 | + | // move waits for the next frame (see cursorWaits). | |||
| 99 | + | lastFrame time.Time | |||
| 100 | + | cursorHeld bool | |||
| 97 | // wantOff is the latest Scene.Off: a client turned the display off. | 101 | // wantOff is the latest Scene.Off: a client turned the display off. | ||
| 98 | // off: the CRTC is inactive for it. Every modeset (resume, recovery) | 102 | // off: the CRTC is inactive for it. Every modeset (resume, recovery) | ||
| 99 | // follows wantOff, so a display turned off never lights up. | 103 | // follows wantOff, so a display turned off never lights up. | ||
| @@ -604,6 +608,7 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p | |||||
| 604 | if !async { | 608 | if !async { | ||
| 605 | o.overlayOn = ov.buf | 609 | o.overlayOn = ov.buf | ||
| 606 | } | 610 | } | ||
| 611 | + | o.lastFrame, o.cursorHeld = time.Now(), false | |||
| 607 | f.frame, f.async = true, async | 612 | f.frame, f.async = true, async | ||
| 608 | f.fences = dupFences(fence, ov.acquire) | 613 | f.fences = dupFences(fence, ov.acquire) | ||
| 609 | o.setAsync(async) | 614 | o.setAsync(async) | ||
| @@ -626,7 +631,12 @@ func (o *Output) commitState(vrr bool) error { | |||||
| 626 | if o.cursor != nil { | 631 | if o.cursor != nil { | ||
| 627 | cur = o.cursor.desired() | 632 | cur = o.cursor.desired() | ||
| 628 | if cur != o.cursor.applied { | 633 | if cur != o.cursor.applied { | ||
| 629 | - | o.cursor.props(req, o.crtc, cur) | 634 | + | if o.cursorWaits(time.Now()) { |
| 635 | + | o.cursorHeld = true | |||
| 636 | + | cur = o.cursor.applied | |||
| 637 | + | } else { | |||
| 638 | + | o.cursor.props(req, o.crtc, cur) | |||
| 639 | + | } | |||
| 630 | } | 640 | } | ||
| 631 | } | 641 | } | ||
| 632 | if vrr != o.vrrOn { | 642 | if vrr != o.vrrOn { | ||
| @@ -647,6 +657,9 @@ func (o *Output) commitState(vrr bool) error { | |||||
| 647 | } | 657 | } | ||
| 648 | if o.cursor != nil { | 658 | if o.cursor != nil { | ||
| 649 | o.cursor.committed(cur) | 659 | o.cursor.committed(cur) | ||
| 660 | + | if cur == o.cursor.desired() { | |||
| 661 | + | o.cursorHeld = false | |||
| 662 | + | } | |||
| 650 | } | 663 | } | ||
| 651 | if vrr != o.vrrOn { | 664 | if vrr != o.vrrOn { | ||
| 652 | o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr") | 665 | o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr") | ||
| @@ -656,6 +669,18 @@ func (o *Output) commitState(vrr bool) error { | |||||
| 656 | return nil | 669 | return nil | ||
| 657 | } | 670 | } | ||
| 658 | 671 | ||||
| 672 | + | // cursorMinInterval bounds how long a held cursor move waits for a frame. | |||
| 673 | + | const cursorMinInterval = time.Second / 24 | |||
| 674 | + | ||||
| 675 | + | // cursorWaits reports whether a cursor move rides on the next frame | |||
| 676 | + | // instead of its own commit. Under VRR a cursor-only commit refreshes the | |||
| 677 | + | // panel at its slowest rate, which delays the game's next frame by up to | |||
| 678 | + | // one such refresh. A game that stops drawing still gets the cursor at | |||
| 679 | + | // cursorMinInterval. | |||
| 680 | + | func (o *Output) cursorWaits(now time.Time) bool { | |||
| 681 | + | return o.vrrOn && o.vrrGame && now.Sub(o.lastFrame) < cursorMinInterval | |||
| 682 | + | } | |||
| 683 | + | ||||
| 659 | func boolValue(b bool) uint64 { | 684 | func boolValue(b bool) uint64 { | ||
| 660 | if b { | 685 | if b { | ||
| 661 | return 1 | 686 | return 1 | ||
| @@ -857,11 +882,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 857 | // guarantees a stopped/reset timer cannot deliver its old value. | 882 | // guarantees a stopped/reset timer cannot deliver its old value. | ||
| 858 | retryTimer := time.NewTimer(time.Hour) | 883 | retryTimer := time.NewTimer(time.Hour) | ||
| 859 | vrrTimer := time.NewTimer(time.Hour) | 884 | vrrTimer := time.NewTimer(time.Hour) | ||
| 885 | + | cursorTimer := time.NewTimer(time.Hour) | |||
| 860 | o.frame.startTimer() | 886 | o.frame.startTimer() | ||
| 861 | retryTimer.Stop() | 887 | retryTimer.Stop() | ||
| 862 | vrrTimer.Stop() | 888 | vrrTimer.Stop() | ||
| 889 | + | cursorTimer.Stop() | |||
| 863 | defer retryTimer.Stop() | 890 | defer retryTimer.Stop() | ||
| 864 | defer vrrTimer.Stop() | 891 | defer vrrTimer.Stop() | ||
| 892 | + | defer cursorTimer.Stop() | |||
| 865 | defer o.frame.stopTimer() | 893 | defer o.frame.stopTimer() | ||
| 866 | // seen is the latest content Seq per window. It is reported only while | 894 | // seen is the latest content Seq per window. It is reported only while | ||
| 867 | // no frame is in flight: then the GPU has finished every frame that | 895 | // no frame is in flight: then the GPU has finished every frame that | ||
| @@ -898,6 +926,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 898 | } else { | 926 | } else { | ||
| 899 | vrrTimer.Stop() | 927 | vrrTimer.Stop() | ||
| 900 | } | 928 | } | ||
| 929 | + | // A held cursor move commits alone if no frame comes in time. | |||
| 930 | + | var cursorDue <-chan time.Time | |||
| 931 | + | if enabled && o.cursorHeld && !o.frame.pendingCommit() { | |||
| 932 | + | cursorTimer.Reset(max(0, cursorMinInterval-time.Since(o.lastFrame))) | |||
| 933 | + | cursorDue = cursorTimer.C | |||
| 934 | + | } else { | |||
| 935 | + | cursorTimer.Stop() | |||
| 936 | + | } | |||
| 901 | // A commit whose event never comes must not stop the output. | 937 | // A commit whose event never comes must not stop the output. | ||
| 902 | stuck := o.frame.wait(enabled) | 938 | stuck := o.frame.wait(enabled) | ||
| 903 | stateDirty := false | 939 | stateDirty := false | ||
| @@ -920,6 +956,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 920 | stateDirty = true | 956 | stateDirty = true | ||
| 921 | case <-vrrOff: | 957 | case <-vrrOff: | ||
| 922 | stateDirty = true | 958 | stateDirty = true | ||
| 959 | + | case <-cursorDue: | |||
| 960 | + | stateDirty = true | |||
| 923 | case <-cursorWake: | 961 | case <-cursorWake: | ||
| 924 | stateDirty = true | 962 | stateDirty = true | ||
| 925 | case <-ctx.Done(): | 963 | case <-ctx.Done(): | ||
| @@ -1149,7 +1187,7 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool { | |||||
| 1149 | return false | 1187 | return false | ||
| 1150 | } | 1188 | } | ||
| 1151 | if age := time.Since(start); age > 20*time.Millisecond { | 1189 | if age := time.Since(start); age > 20*time.Millisecond { | ||
| 1152 | - | o.log.Info().Dur("flip_ms", age).Str("connector", o.conn.name).Msg("slow flip") | 1190 | + | o.log.Info().Dur("flip_ms", age).Bool("frame", f.frame).Bool("vrr", o.vrrOn).Str("connector", o.conn.name).Msg("slow flip") |
| 1153 | } | 1191 | } | ||
| 1154 | if o.cursor != nil { | 1192 | if o.cursor != nil { | ||
| 1155 | o.cursor.landed() | 1193 | o.cursor.landed() | ||
M internal/adapters/drm/output_test.go +49 -0
| @@ -192,6 +192,55 @@ func TestCursorCommitWaitsForEvent(t *testing.T) { | |||
| 192 | 192 | } | |
| 193 | 193 | } | |
| 194 | 194 | ||
| 195 | + | // Under VRR with a game shown, a cursor move rides on the next frame: a | |
| 196 | + | // cursor-only commit would hold the panel for its slowest refresh. A game | |
| 197 | + | // that stops drawing still gets the cursor after cursorMinInterval. | |
| 198 | + | func TestVRRGameCursorWaitsForFrame(t *testing.T) { | |
| 199 | + | o, _, commits := testOutput(t) | |
| 200 | + | o.cursor.image = true | |
| 201 | + | o.vrrOn, o.vrrGame = true, true | |
| 202 | + | o.lastFrame = time.Now() | |
| 203 | + | o.cursor.Move(5, 5) | |
| 204 | + | if err := o.commitState(o.stateVRR()); err != nil { | |
| 205 | + | t.Fatal(err) | |
| 206 | + | } | |
| 207 | + | if len(*commits) != 0 || !o.cursorHeld || o.frame.pendingCommit() { | |
| 208 | + | t.Fatalf("cursor committed alone: %d commits held=%v", len(*commits), o.cursorHeld) | |
| 209 | + | } | |
| 210 | + | // The next frame carries the cursor. | |
| 211 | + | if err := o.commitFrame(70, nil, false, true, pendingFrame{}); err != nil { | |
| 212 | + | t.Fatal(err) | |
| 213 | + | } | |
| 214 | + | if v, _ := (*commits)[0].req.value(tCursor, 14); v != 5 || o.cursorHeld { | |
| 215 | + | t.Fatalf("frame cursor x %d held=%v", v, o.cursorHeld) | |
| 216 | + | } | |
| 217 | + | o.completed(eventOf((*commits)[0]), map[ports.WindowID]uint64{}) | |
| 218 | + | // The game stalls: the cursor commits alone after the interval. | |
| 219 | + | o.lastFrame = time.Now().Add(-cursorMinInterval) | |
| 220 | + | o.cursor.Move(8, 8) | |
| 221 | + | if err := o.commitState(o.stateVRR()); err != nil { | |
| 222 | + | t.Fatal(err) | |
| 223 | + | } | |
| 224 | + | if len(*commits) != 2 || o.cursorHeld { | |
| 225 | + | t.Fatalf("stalled game: %d commits held=%v", len(*commits), o.cursorHeld) | |
| 226 | + | } | |
| 227 | + | if v, _ := (*commits)[1].req.value(tCursor, 14); v != 8 { | |
| 228 | + | t.Fatalf("cursor x %d", v) | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // Without a game, the cursor keeps its own commits. | |
| 233 | + | func TestCursorCommitsAloneWithoutGame(t *testing.T) { | |
| 234 | + | o, _, commits := testOutput(t) | |
| 235 | + | o.cursor.image = true | |
| 236 | + | o.vrrOn, o.lastFrame = true, time.Now() | |
| 237 | + | o.composedSince = time.Now() | |
| 238 | + | o.cursor.Move(5, 5) | |
| 239 | + | if err := o.commitState(o.stateVRR()); err != nil || len(*commits) != 1 || o.cursorHeld { | |
| 240 | + | t.Fatalf("err %v, %d commits held=%v", err, len(*commits), o.cursorHeld) | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 195 | 244 | func TestBusyCommitStaysPending(t *testing.T) { | |
| 196 | 245 | o, _, _ := testOutput(t, unix.EBUSY) | |
| 197 | 246 | err := o.commitFrame(70, nil, false, false, pendingFrame{}) | |
| @@ -192,6 +192,55 @@ func TestCursorCommitWaitsForEvent(t *testing.T) { | |||||
| 192 | } | 192 | } | ||
| 193 | } | 193 | } | ||
| 194 | 194 | ||||
| 195 | + | // Under VRR with a game shown, a cursor move rides on the next frame: a | |||
| 196 | + | // cursor-only commit would hold the panel for its slowest refresh. A game | |||
| 197 | + | // that stops drawing still gets the cursor after cursorMinInterval. | |||
| 198 | + | func TestVRRGameCursorWaitsForFrame(t *testing.T) { | |||
| 199 | + | o, _, commits := testOutput(t) | |||
| 200 | + | o.cursor.image = true | |||
| 201 | + | o.vrrOn, o.vrrGame = true, true | |||
| 202 | + | o.lastFrame = time.Now() | |||
| 203 | + | o.cursor.Move(5, 5) | |||
| 204 | + | if err := o.commitState(o.stateVRR()); err != nil { | |||
| 205 | + | t.Fatal(err) | |||
| 206 | + | } | |||
| 207 | + | if len(*commits) != 0 || !o.cursorHeld || o.frame.pendingCommit() { | |||
| 208 | + | t.Fatalf("cursor committed alone: %d commits held=%v", len(*commits), o.cursorHeld) | |||
| 209 | + | } | |||
| 210 | + | // The next frame carries the cursor. | |||
| 211 | + | if err := o.commitFrame(70, nil, false, true, pendingFrame{}); err != nil { | |||
| 212 | + | t.Fatal(err) | |||
| 213 | + | } | |||
| 214 | + | if v, _ := (*commits)[0].req.value(tCursor, 14); v != 5 || o.cursorHeld { | |||
| 215 | + | t.Fatalf("frame cursor x %d held=%v", v, o.cursorHeld) | |||
| 216 | + | } | |||
| 217 | + | o.completed(eventOf((*commits)[0]), map[ports.WindowID]uint64{}) | |||
| 218 | + | // The game stalls: the cursor commits alone after the interval. | |||
| 219 | + | o.lastFrame = time.Now().Add(-cursorMinInterval) | |||
| 220 | + | o.cursor.Move(8, 8) | |||
| 221 | + | if err := o.commitState(o.stateVRR()); err != nil { | |||
| 222 | + | t.Fatal(err) | |||
| 223 | + | } | |||
| 224 | + | if len(*commits) != 2 || o.cursorHeld { | |||
| 225 | + | t.Fatalf("stalled game: %d commits held=%v", len(*commits), o.cursorHeld) | |||
| 226 | + | } | |||
| 227 | + | if v, _ := (*commits)[1].req.value(tCursor, 14); v != 8 { | |||
| 228 | + | t.Fatalf("cursor x %d", v) | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | // Without a game, the cursor keeps its own commits. | |||
| 233 | + | func TestCursorCommitsAloneWithoutGame(t *testing.T) { | |||
| 234 | + | o, _, commits := testOutput(t) | |||
| 235 | + | o.cursor.image = true | |||
| 236 | + | o.vrrOn, o.lastFrame = true, time.Now() | |||
| 237 | + | o.composedSince = time.Now() | |||
| 238 | + | o.cursor.Move(5, 5) | |||
| 239 | + | if err := o.commitState(o.stateVRR()); err != nil || len(*commits) != 1 || o.cursorHeld { | |||
| 240 | + | t.Fatalf("err %v, %d commits held=%v", err, len(*commits), o.cursorHeld) | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | ||||
| 195 | func TestBusyCommitStaysPending(t *testing.T) { | 244 | func TestBusyCommitStaysPending(t *testing.T) { | ||
| 196 | o, _, _ := testOutput(t, unix.EBUSY) | 245 | o, _, _ := testOutput(t, unix.EBUSY) | ||
| 197 | err := o.commitFrame(70, nil, false, false, pendingFrame{}) | 246 | err := o.commitFrame(70, nil, false, false, pendingFrame{}) | ||