detached 89b78d7

fix(drm): defer state-only commits to just before the next vblank

A cursor move committed alone at once took the CRTC's one commit per
vblank, so a frame arriving later in the same refresh period slipped a
vblank: video stuttered whenever the pointer moved. State changes now wait
until shortly before the predicted vblank; a frame committed earlier
carries them, otherwise they commit alone in time for the same vblank.
commit
89b78d78ef285143f8cd738a659bc907b67d1a77
author
brice · · 2026-10-18 04:33 UTC
parent
71aa990
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +236 -0
M internal/adapters/drm/output.go +34 -0
@@ -157,6 +157,11 @@ type Output struct {
157157 // move waits for the next frame (see cursorWaits).
158158 lastFrame time.Time
159159 cursorHeld bool
160+ // stateAt is when a deferred state commit is due (zero: none);
161+ // lastVblankAt is the kernel timestamp of the last vsynced flip, frame
162+ // or state (CLOCK_MONOTONIC, 0: unknown). See state_schedule.go.
163+ stateAt time.Time
164+ lastVblankAt time.Duration
160165 // traceFlips logs every completion with its timing; lastFlipAt is
161166 // the previous frame flip's kernel timestamp (always kept).
162167 // flipStats are reported and reset with the periodic stats. wantedAt
@@ -455,6 +460,7 @@ func (o *Output) modesetImage(fb uint32, active bool) error {
455460 o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, !active
456461 // The gap and the flip interval belonged to the old state.
457462 o.flipGapUntil, o.flipGapAt, o.lastFlipAt = time.Time{}, 0, 0
463+ o.stateAt, o.lastVblankAt = time.Time{}, 0
458464 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]
459465 o.planeRect = fullPlaneRect(o.Width(), o.Height())
460466 if o.cursor != nil {
@@ -494,6 +500,8 @@ func (o *Output) powerOff() error {
494500 }
495501 o.colorBypassed()
496502 o.off, o.vrrGame = true, false
503+ // No vblank to aim at: the modeset that turns it on commits the state.
504+ o.stateAt, o.lastVblankAt = time.Time{}, 0
497505 o.setVRR(false)
498506 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]
499507 o.log.Info().Str("connector", o.conn.name).Msg("power off")
@@ -721,6 +729,8 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
721729 o.contentValue = o.contentWanted
722730 o.planeRect = rect
723731 o.lastFrame, o.cursorHeld = time.Now(), false
732+ // The frame carries every deferred state change.
733+ o.stateAt = time.Time{}
724734 f.frame, f.async = true, async
725735 f.wantedAt, o.wantedAt = o.wantedAt, 0
726736 f.gapEnd, o.flipGapAt = o.flipGapAt, 0
@@ -774,6 +784,14 @@ func (o *Output) commitState(vrr bool) error {
774784 if o.protected {
775785 return errSecurityScene
776786 }
787+ if !o.stateChanged(vrr) {
788+ // A change undone before its commit holds nothing.
789+ o.stateAt, o.cursorHeld = time.Time{}, false
790+ return nil
791+ }
792+ if !o.stateDue(time.Now(), monotonic()) {
793+ return nil // a frame before then carries it; else the state timer
794+ }
777795 req := &o.stateReq
778796 req.reset()
779797 cur := cursorState{}
@@ -1097,6 +1115,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10971115 retryTimer := time.NewTimer(time.Hour)
10981116 vrrTimer := time.NewTimer(time.Hour)
10991117 cursorTimer := time.NewTimer(time.Hour)
1118+ stateTimer := time.NewTimer(time.Hour)
1119+ stateTimer.Stop()
1120+ defer stateTimer.Stop()
11001121 protectTimer := time.NewTimer(time.Hour)
11011122 o.frame.startTimer()
11021123 gapTimer := time.NewTimer(time.Hour)
@@ -1266,6 +1287,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12661287 } else {
12671288 cursorTimer.Stop()
12681289 }
1290+ // A deferred state change commits alone if no frame carried it.
1291+ var stateDue <-chan time.Time
1292+ if enabled && !o.protected && !o.off && !o.stateAt.IsZero() && !o.frame.pendingCommit() {
1293+ stateTimer.Reset(max(0, time.Until(o.stateAt)))
1294+ stateDue = stateTimer.C
1295+ } else {
1296+ stateTimer.Stop()
1297+ }
12691298 // A game frame waiting out the VRR flip gap commits when it ends.
12701299 var gapDue <-chan time.Time
12711300 if enabled && dirty && !o.frame.pendingCommit() && !o.flipGapUntil.IsZero() {
@@ -1338,6 +1367,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
13381367 stateDirty = true
13391368 case <-cursorDue:
13401369 stateDirty = true
1370+ case <-stateDue:
1371+ stateDirty = true
13411372 case <-cursorWake:
13421373 stateDirty = true
13431374 case <-gapDue:
@@ -1638,6 +1669,9 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
16381669 return false
16391670 }
16401671 o.flipDone(ev, f, start, ours, trace, fenceAt)
1672+ if ours {
1673+ o.noteVblank(f, ev.when)
1674+ }
16411675 if o.cursor != nil {
16421676 o.cursor.landed()
16431677 }
@@ -157,6 +157,11 @@ type Output struct {
157 // move waits for the next frame (see cursorWaits).157 // move waits for the next frame (see cursorWaits).
158 lastFrame time.Time158 lastFrame time.Time
159 cursorHeld bool159 cursorHeld bool
160+ // stateAt is when a deferred state commit is due (zero: none);
161+ // lastVblankAt is the kernel timestamp of the last vsynced flip, frame
162+ // or state (CLOCK_MONOTONIC, 0: unknown). See state_schedule.go.
163+ stateAt time.Time
164+ lastVblankAt time.Duration
160 // traceFlips logs every completion with its timing; lastFlipAt is165 // traceFlips logs every completion with its timing; lastFlipAt is
161 // the previous frame flip's kernel timestamp (always kept).166 // the previous frame flip's kernel timestamp (always kept).
162 // flipStats are reported and reset with the periodic stats. wantedAt167 // flipStats are reported and reset with the periodic stats. wantedAt
@@ -455,6 +460,7 @@ func (o *Output) modesetImage(fb uint32, active bool) error {
455 o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, !active460 o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, !active
456 // The gap and the flip interval belonged to the old state.461 // The gap and the flip interval belonged to the old state.
457 o.flipGapUntil, o.flipGapAt, o.lastFlipAt = time.Time{}, 0, 0462 o.flipGapUntil, o.flipGapAt, o.lastFlipAt = time.Time{}, 0, 0
463+ o.stateAt, o.lastVblankAt = time.Time{}, 0
458 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]464 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]
459 o.planeRect = fullPlaneRect(o.Width(), o.Height())465 o.planeRect = fullPlaneRect(o.Width(), o.Height())
460 if o.cursor != nil {466 if o.cursor != nil {
@@ -494,6 +500,8 @@ func (o *Output) powerOff() error {
494 }500 }
495 o.colorBypassed()501 o.colorBypassed()
496 o.off, o.vrrGame = true, false502 o.off, o.vrrGame = true, false
503+ // No vblank to aim at: the modeset that turns it on commits the state.
504+ o.stateAt, o.lastVblankAt = time.Time{}, 0
497 o.setVRR(false)505 o.setVRR(false)
498 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]506 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]
499 o.log.Info().Str("connector", o.conn.name).Msg("power off")507 o.log.Info().Str("connector", o.conn.name).Msg("power off")
@@ -721,6 +729,8 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
721 o.contentValue = o.contentWanted729 o.contentValue = o.contentWanted
722 o.planeRect = rect730 o.planeRect = rect
723 o.lastFrame, o.cursorHeld = time.Now(), false731 o.lastFrame, o.cursorHeld = time.Now(), false
732+ // The frame carries every deferred state change.
733+ o.stateAt = time.Time{}
724 f.frame, f.async = true, async734 f.frame, f.async = true, async
725 f.wantedAt, o.wantedAt = o.wantedAt, 0735 f.wantedAt, o.wantedAt = o.wantedAt, 0
726 f.gapEnd, o.flipGapAt = o.flipGapAt, 0736 f.gapEnd, o.flipGapAt = o.flipGapAt, 0
@@ -774,6 +784,14 @@ func (o *Output) commitState(vrr bool) error {
774 if o.protected {784 if o.protected {
775 return errSecurityScene785 return errSecurityScene
776 }786 }
787+ if !o.stateChanged(vrr) {
788+ // A change undone before its commit holds nothing.
789+ o.stateAt, o.cursorHeld = time.Time{}, false
790+ return nil
791+ }
792+ if !o.stateDue(time.Now(), monotonic()) {
793+ return nil // a frame before then carries it; else the state timer
794+ }
777 req := &o.stateReq795 req := &o.stateReq
778 req.reset()796 req.reset()
779 cur := cursorState{}797 cur := cursorState{}
@@ -1097,6 +1115,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1097 retryTimer := time.NewTimer(time.Hour)1115 retryTimer := time.NewTimer(time.Hour)
1098 vrrTimer := time.NewTimer(time.Hour)1116 vrrTimer := time.NewTimer(time.Hour)
1099 cursorTimer := time.NewTimer(time.Hour)1117 cursorTimer := time.NewTimer(time.Hour)
1118+ stateTimer := time.NewTimer(time.Hour)
1119+ stateTimer.Stop()
1120+ defer stateTimer.Stop()
1100 protectTimer := time.NewTimer(time.Hour)1121 protectTimer := time.NewTimer(time.Hour)
1101 o.frame.startTimer()1122 o.frame.startTimer()
1102 gapTimer := time.NewTimer(time.Hour)1123 gapTimer := time.NewTimer(time.Hour)
@@ -1266,6 +1287,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1266 } else {1287 } else {
1267 cursorTimer.Stop()1288 cursorTimer.Stop()
1268 }1289 }
1290+ // A deferred state change commits alone if no frame carried it.
1291+ var stateDue <-chan time.Time
1292+ if enabled && !o.protected && !o.off && !o.stateAt.IsZero() && !o.frame.pendingCommit() {
1293+ stateTimer.Reset(max(0, time.Until(o.stateAt)))
1294+ stateDue = stateTimer.C
1295+ } else {
1296+ stateTimer.Stop()
1297+ }
1269 // A game frame waiting out the VRR flip gap commits when it ends.1298 // A game frame waiting out the VRR flip gap commits when it ends.
1270 var gapDue <-chan time.Time1299 var gapDue <-chan time.Time
1271 if enabled && dirty && !o.frame.pendingCommit() && !o.flipGapUntil.IsZero() {1300 if enabled && dirty && !o.frame.pendingCommit() && !o.flipGapUntil.IsZero() {
@@ -1338,6 +1367,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1338 stateDirty = true1367 stateDirty = true
1339 case <-cursorDue:1368 case <-cursorDue:
1340 stateDirty = true1369 stateDirty = true
1370+ case <-stateDue:
1371+ stateDirty = true
1341 case <-cursorWake:1372 case <-cursorWake:
1342 stateDirty = true1373 stateDirty = true
1343 case <-gapDue:1374 case <-gapDue:
@@ -1638,6 +1669,9 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
1638 return false1669 return false
1639 }1670 }
1640 o.flipDone(ev, f, start, ours, trace, fenceAt)1671 o.flipDone(ev, f, start, ours, trace, fenceAt)
1672+ if ours {
1673+ o.noteVblank(f, ev.when)
1674+ }
1641 if o.cursor != nil {1675 if o.cursor != nil {
1642 o.cursor.landed()1676 o.cursor.landed()
1643 }1677 }
A internal/adapters/drm/state_schedule.go +78 -0
@@ -0,0 +1,78 @@
1+package drm
2+
3+import "time"
4+
5+// State commit scheduling: a CRTC takes one atomic commit per vblank, so a
6+// commit carrying only state (cursor move, VRR toggle, content type) holds
7+// the next frame until it lands. Committed as soon as the pointer moves, it
8+// pushes a video frame that arrives later in the same refresh period to the
9+// following vblank: a visible stutter on every cursor move over the output
10+// or across to another one.
11+//
12+// So a state change waits until stateLead before the next vblank. A frame
13+// committed before then carries it (one commit, no lost vblank); otherwise
14+// the state commits alone and still lands on that vblank, so the cursor is
15+// never later than it would have been.
16+//
17+// Under VRR there is no fixed vblank to aim at: state commits at once (a
18+// game's cursor rides on its frames, see cursorWaits).
19+
20+// stateLeadMax is how long before the vblank a deferred state commit is
21+// made: enough for the kernel to program the planes for that vblank.
22+const stateLeadMax = 1500 * time.Microsecond
23+
24+// vblankStale is how old the last vblank may be to predict the next one: a
25+// longer idle period lets the nominal period drift from the panel's.
26+const vblankStale = 250 * time.Millisecond
27+
28+// stateLead is stateLeadMax, at most a quarter of period.
29+func stateLead(period time.Duration) time.Duration { return min(stateLeadMax, period/4) }
30+
31+// stateDelay is how long a state change made at now (CLOCK_MONOTONIC)
32+// waits before its own commit: until stateLead before the next vblank, 0
33+// when that point has passed or no vblank can be predicted (VRR, no recent
34+// flip).
35+func (o *Output) stateDelay(now time.Duration) time.Duration {
36+ period := o.refreshPeriod()
37+ last := o.lastVblankAt
38+ if period <= 0 || last <= 0 || now < last || now-last > vblankStale {
39+ return 0
40+ }
41+ next := last + ((now-last)/period+1)*period
42+ return max(0, next-stateLead(period)-now)
43+}
44+
45+// stateChanged reports whether a state commit would change anything:
46+// cursor, VRR or content type differ from what KMS holds.
47+func (o *Output) stateChanged(vrr bool) bool {
48+ if o.cursor != nil && o.cursor.desired() != o.cursor.applied {
49+ return true
50+ }
51+ if vrr != o.vrrOn {
52+ return true
53+ }
54+ return o.contentProp != 0 && o.contentWanted != o.contentValue
55+}
56+
57+// stateDue reports whether the state commit may go now. The first call
58+// for a change fixes its time (stateAt) and later calls keep it, so a
59+// timer that fires late never pushes the commit by another period.
60+func (o *Output) stateDue(now time.Time, mono time.Duration) bool {
61+ if o.stateAt.IsZero() {
62+ o.stateAt = now.Add(o.stateDelay(mono))
63+ }
64+ if now.Before(o.stateAt) {
65+ return false
66+ }
67+ o.stateAt = time.Time{}
68+ return true
69+}
70+
71+// noteVblank records the vblank of a completed vsynced commit (frame or
72+// state). A tearing flip's timestamp is not a vblank.
73+func (o *Output) noteVblank(f pendingFrame, when time.Duration) {
74+ if f.async || when <= 0 {
75+ return
76+ }
77+ o.lastVblankAt = when
78+}
@@ -0,0 +1,78 @@
1+package drm
2+
3+import "time"
4+
5+// State commit scheduling: a CRTC takes one atomic commit per vblank, so a
6+// commit carrying only state (cursor move, VRR toggle, content type) holds
7+// the next frame until it lands. Committed as soon as the pointer moves, it
8+// pushes a video frame that arrives later in the same refresh period to the
9+// following vblank: a visible stutter on every cursor move over the output
10+// or across to another one.
11+//
12+// So a state change waits until stateLead before the next vblank. A frame
13+// committed before then carries it (one commit, no lost vblank); otherwise
14+// the state commits alone and still lands on that vblank, so the cursor is
15+// never later than it would have been.
16+//
17+// Under VRR there is no fixed vblank to aim at: state commits at once (a
18+// game's cursor rides on its frames, see cursorWaits).
19+
20+// stateLeadMax is how long before the vblank a deferred state commit is
21+// made: enough for the kernel to program the planes for that vblank.
22+const stateLeadMax = 1500 * time.Microsecond
23+
24+// vblankStale is how old the last vblank may be to predict the next one: a
25+// longer idle period lets the nominal period drift from the panel's.
26+const vblankStale = 250 * time.Millisecond
27+
28+// stateLead is stateLeadMax, at most a quarter of period.
29+func stateLead(period time.Duration) time.Duration { return min(stateLeadMax, period/4) }
30+
31+// stateDelay is how long a state change made at now (CLOCK_MONOTONIC)
32+// waits before its own commit: until stateLead before the next vblank, 0
33+// when that point has passed or no vblank can be predicted (VRR, no recent
34+// flip).
35+func (o *Output) stateDelay(now time.Duration) time.Duration {
36+ period := o.refreshPeriod()
37+ last := o.lastVblankAt
38+ if period <= 0 || last <= 0 || now < last || now-last > vblankStale {
39+ return 0
40+ }
41+ next := last + ((now-last)/period+1)*period
42+ return max(0, next-stateLead(period)-now)
43+}
44+
45+// stateChanged reports whether a state commit would change anything:
46+// cursor, VRR or content type differ from what KMS holds.
47+func (o *Output) stateChanged(vrr bool) bool {
48+ if o.cursor != nil && o.cursor.desired() != o.cursor.applied {
49+ return true
50+ }
51+ if vrr != o.vrrOn {
52+ return true
53+ }
54+ return o.contentProp != 0 && o.contentWanted != o.contentValue
55+}
56+
57+// stateDue reports whether the state commit may go now. The first call
58+// for a change fixes its time (stateAt) and later calls keep it, so a
59+// timer that fires late never pushes the commit by another period.
60+func (o *Output) stateDue(now time.Time, mono time.Duration) bool {
61+ if o.stateAt.IsZero() {
62+ o.stateAt = now.Add(o.stateDelay(mono))
63+ }
64+ if now.Before(o.stateAt) {
65+ return false
66+ }
67+ o.stateAt = time.Time{}
68+ return true
69+}
70+
71+// noteVblank records the vblank of a completed vsynced commit (frame or
72+// state). A tearing flip's timestamp is not a vblank.
73+func (o *Output) noteVblank(f pendingFrame, when time.Duration) {
74+ if f.async || when <= 0 {
75+ return
76+ }
77+ o.lastVblankAt = when
78+}
A internal/adapters/drm/state_schedule_test.go +124 -0
@@ -0,0 +1,124 @@
1+package drm
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestStateDelay(t *testing.T) {
11+ o, _, _ := testOutput(t) // 60 Hz
12+ p := o.nominalPeriod()
13+ lead := stateLead(p)
14+ last := 10 * time.Second
15+ for _, tc := range []struct {
16+ name string
17+ vrr bool
18+ last time.Duration
19+ now time.Duration
20+ want time.Duration
21+ }{
22+ {"no vblank yet", false, 0, last, 0},
23+ {"right after a vblank", false, last, last + time.Millisecond, p - lead - time.Millisecond},
24+ {"inside the lead", false, last, last + p - lead/2, 0},
25+ {"periods later", false, last, last + 3*p + time.Millisecond, p - lead - time.Millisecond},
26+ {"stale vblank", false, last, last + vblankStale + time.Millisecond, 0},
27+ {"VRR", true, last, last + time.Millisecond, 0},
28+ } {
29+ o.vrrOn, o.lastVblankAt = tc.vrr, tc.last
30+ if got := o.stateDelay(tc.now); got != tc.want {
31+ t.Errorf("%s: delay %v, want %v", tc.name, got, tc.want)
32+ }
33+ }
34+}
35+
36+// A cursor move right after a vblank waits: the frame that comes before
37+// the deadline carries it, in a single commit.
38+func TestDeferredCursorRidesOnFrame(t *testing.T) {
39+ o, _, commits := testOutput(t)
40+ o.cursor.image = true
41+ o.stateAt = time.Now().Add(time.Hour) // the vblank is far
42+ o.cursor.Move(7, 7)
43+ if err := o.commitState(false); err != nil {
44+ t.Fatal(err)
45+ }
46+ if len(*commits) != 0 || o.stateAt.IsZero() || o.frame.pendingCommit() {
47+ t.Fatalf("cursor committed at once: %d commits, due %v", len(*commits), o.stateAt)
48+ }
49+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
50+ t.Fatal(err)
51+ }
52+ if v, _ := (*commits)[0].req.value(tCursor, 14); v != 7 || !o.stateAt.IsZero() {
53+ t.Fatalf("frame cursor x %d, due %v", v, o.stateAt)
54+ }
55+}
56+
57+// With no frame, the deferred move commits alone once due, and a late
58+// timer does not push it by another period.
59+func TestDeferredCursorCommitsWhenDue(t *testing.T) {
60+ o, _, commits := testOutput(t)
61+ o.cursor.image = true
62+ o.stateAt = time.Now().Add(time.Hour)
63+ o.cursor.Move(4, 4)
64+ if err := o.commitState(false); err != nil || len(*commits) != 0 {
65+ t.Fatalf("err %v, %d commits", err, len(*commits))
66+ }
67+ o.stateAt = time.Now().Add(-time.Millisecond) // the timer fired late
68+ if err := o.commitState(false); err != nil || len(*commits) != 1 {
69+ t.Fatalf("due commit: %v, %d commits", err, len(*commits))
70+ }
71+ if v, _ := (*commits)[0].req.value(tCursor, 14); v != 4 || !o.stateAt.IsZero() {
72+ t.Fatalf("cursor x %d, due %v", v, o.stateAt)
73+ }
74+}
75+
76+// A move undone before its deadline commits nothing and clears the wait.
77+func TestDeferredCursorMovedBack(t *testing.T) {
78+ o, _, commits := testOutput(t)
79+ o.cursor.image = true
80+ o.cursor.Move(0, 0)
81+ o.cursor.applied = o.cursor.desired()
82+ o.stateAt = time.Now().Add(time.Hour)
83+ o.cursor.Move(9, 9)
84+ if err := o.commitState(false); err != nil || o.stateAt.IsZero() {
85+ t.Fatalf("err %v, due %v", err, o.stateAt)
86+ }
87+ o.cursor.Move(0, 0)
88+ if err := o.commitState(false); err != nil || !o.stateAt.IsZero() || len(*commits) != 0 {
89+ t.Fatalf("err %v, due %v, %d commits", err, o.stateAt, len(*commits))
90+ }
91+}
92+
93+// Powering off drops a deferred state change: an off output has no vblank,
94+// and an armed deadline would wake Run forever.
95+func TestPowerOffDropsDeferredState(t *testing.T) {
96+ o, _, _ := testOutput(t)
97+ o.stateAt, o.lastVblankAt = time.Now().Add(time.Hour), time.Second
98+ if err := o.powerOff(); err != nil {
99+ t.Fatal(err)
100+ }
101+ if !o.stateAt.IsZero() || o.lastVblankAt != 0 {
102+ t.Fatalf("due %v, vblank %v after power off", o.stateAt, o.lastVblankAt)
103+ }
104+}
105+
106+// Vsynced completions, state ones included, give the vblank; a tearing
107+// flip does not.
108+func TestCompletionRecordsVblank(t *testing.T) {
109+ o, _, commits := testOutput(t)
110+ o.cursor.image = true
111+ o.cursor.Move(2, 2)
112+ if err := o.commitState(false); err != nil || len(*commits) != 1 {
113+ t.Fatalf("err %v, %d commits", err, len(*commits))
114+ }
115+ ev := eventOf((*commits)[0])
116+ ev.when = 5 * time.Second
117+ if !o.completed(ev, map[ports.WindowID]uint64{}) || o.lastVblankAt != ev.when {
118+ t.Fatalf("vblank %v, want %v", o.lastVblankAt, ev.when)
119+ }
120+ o.noteVblank(pendingFrame{async: true}, 6*time.Second)
121+ if o.lastVblankAt != ev.when {
122+ t.Fatalf("tearing flip taken as a vblank: %v", o.lastVblankAt)
123+ }
124+}
@@ -0,0 +1,124 @@
1+package drm
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestStateDelay(t *testing.T) {
11+ o, _, _ := testOutput(t) // 60 Hz
12+ p := o.nominalPeriod()
13+ lead := stateLead(p)
14+ last := 10 * time.Second
15+ for _, tc := range []struct {
16+ name string
17+ vrr bool
18+ last time.Duration
19+ now time.Duration
20+ want time.Duration
21+ }{
22+ {"no vblank yet", false, 0, last, 0},
23+ {"right after a vblank", false, last, last + time.Millisecond, p - lead - time.Millisecond},
24+ {"inside the lead", false, last, last + p - lead/2, 0},
25+ {"periods later", false, last, last + 3*p + time.Millisecond, p - lead - time.Millisecond},
26+ {"stale vblank", false, last, last + vblankStale + time.Millisecond, 0},
27+ {"VRR", true, last, last + time.Millisecond, 0},
28+ } {
29+ o.vrrOn, o.lastVblankAt = tc.vrr, tc.last
30+ if got := o.stateDelay(tc.now); got != tc.want {
31+ t.Errorf("%s: delay %v, want %v", tc.name, got, tc.want)
32+ }
33+ }
34+}
35+
36+// A cursor move right after a vblank waits: the frame that comes before
37+// the deadline carries it, in a single commit.
38+func TestDeferredCursorRidesOnFrame(t *testing.T) {
39+ o, _, commits := testOutput(t)
40+ o.cursor.image = true
41+ o.stateAt = time.Now().Add(time.Hour) // the vblank is far
42+ o.cursor.Move(7, 7)
43+ if err := o.commitState(false); err != nil {
44+ t.Fatal(err)
45+ }
46+ if len(*commits) != 0 || o.stateAt.IsZero() || o.frame.pendingCommit() {
47+ t.Fatalf("cursor committed at once: %d commits, due %v", len(*commits), o.stateAt)
48+ }
49+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
50+ t.Fatal(err)
51+ }
52+ if v, _ := (*commits)[0].req.value(tCursor, 14); v != 7 || !o.stateAt.IsZero() {
53+ t.Fatalf("frame cursor x %d, due %v", v, o.stateAt)
54+ }
55+}
56+
57+// With no frame, the deferred move commits alone once due, and a late
58+// timer does not push it by another period.
59+func TestDeferredCursorCommitsWhenDue(t *testing.T) {
60+ o, _, commits := testOutput(t)
61+ o.cursor.image = true
62+ o.stateAt = time.Now().Add(time.Hour)
63+ o.cursor.Move(4, 4)
64+ if err := o.commitState(false); err != nil || len(*commits) != 0 {
65+ t.Fatalf("err %v, %d commits", err, len(*commits))
66+ }
67+ o.stateAt = time.Now().Add(-time.Millisecond) // the timer fired late
68+ if err := o.commitState(false); err != nil || len(*commits) != 1 {
69+ t.Fatalf("due commit: %v, %d commits", err, len(*commits))
70+ }
71+ if v, _ := (*commits)[0].req.value(tCursor, 14); v != 4 || !o.stateAt.IsZero() {
72+ t.Fatalf("cursor x %d, due %v", v, o.stateAt)
73+ }
74+}
75+
76+// A move undone before its deadline commits nothing and clears the wait.
77+func TestDeferredCursorMovedBack(t *testing.T) {
78+ o, _, commits := testOutput(t)
79+ o.cursor.image = true
80+ o.cursor.Move(0, 0)
81+ o.cursor.applied = o.cursor.desired()
82+ o.stateAt = time.Now().Add(time.Hour)
83+ o.cursor.Move(9, 9)
84+ if err := o.commitState(false); err != nil || o.stateAt.IsZero() {
85+ t.Fatalf("err %v, due %v", err, o.stateAt)
86+ }
87+ o.cursor.Move(0, 0)
88+ if err := o.commitState(false); err != nil || !o.stateAt.IsZero() || len(*commits) != 0 {
89+ t.Fatalf("err %v, due %v, %d commits", err, o.stateAt, len(*commits))
90+ }
91+}
92+
93+// Powering off drops a deferred state change: an off output has no vblank,
94+// and an armed deadline would wake Run forever.
95+func TestPowerOffDropsDeferredState(t *testing.T) {
96+ o, _, _ := testOutput(t)
97+ o.stateAt, o.lastVblankAt = time.Now().Add(time.Hour), time.Second
98+ if err := o.powerOff(); err != nil {
99+ t.Fatal(err)
100+ }
101+ if !o.stateAt.IsZero() || o.lastVblankAt != 0 {
102+ t.Fatalf("due %v, vblank %v after power off", o.stateAt, o.lastVblankAt)
103+ }
104+}
105+
106+// Vsynced completions, state ones included, give the vblank; a tearing
107+// flip does not.
108+func TestCompletionRecordsVblank(t *testing.T) {
109+ o, _, commits := testOutput(t)
110+ o.cursor.image = true
111+ o.cursor.Move(2, 2)
112+ if err := o.commitState(false); err != nil || len(*commits) != 1 {
113+ t.Fatalf("err %v, %d commits", err, len(*commits))
114+ }
115+ ev := eventOf((*commits)[0])
116+ ev.when = 5 * time.Second
117+ if !o.completed(ev, map[ports.WindowID]uint64{}) || o.lastVblankAt != ev.when {
118+ t.Fatalf("vblank %v, want %v", o.lastVblankAt, ev.when)
119+ }
120+ o.noteVblank(pendingFrame{async: true}, 6*time.Second)
121+ if o.lastVblankAt != ev.when {
122+ t.Fatalf("tearing flip taken as a vblank: %v", o.lastVblankAt)
123+ }
124+}

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