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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 157 | 157 | // move waits for the next frame (see cursorWaits). | |
| 158 | 158 | lastFrame time.Time | |
| 159 | 159 | 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 | 165 | // traceFlips logs every completion with its timing; lastFlipAt is | |
| 161 | 166 | // the previous frame flip's kernel timestamp (always kept). | |
| 162 | 167 | // flipStats are reported and reset with the periodic stats. wantedAt | |
| @@ -455,6 +460,7 @@ func (o *Output) modesetImage(fb uint32, active bool) error { | |||
| 455 | 460 | o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, !active | |
| 456 | 461 | // The gap and the flip interval belonged to the old state. | |
| 457 | 462 | o.flipGapUntil, o.flipGapAt, o.lastFlipAt = time.Time{}, 0, 0 | |
| 463 | + | o.stateAt, o.lastVblankAt = time.Time{}, 0 | |
| 458 | 464 | o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0] | |
| 459 | 465 | o.planeRect = fullPlaneRect(o.Width(), o.Height()) | |
| 460 | 466 | if o.cursor != nil { | |
| @@ -494,6 +500,8 @@ func (o *Output) powerOff() error { | |||
| 494 | 500 | } | |
| 495 | 501 | o.colorBypassed() | |
| 496 | 502 | 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 | 505 | o.setVRR(false) | |
| 498 | 506 | o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0] | |
| 499 | 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 | 729 | o.contentValue = o.contentWanted | |
| 722 | 730 | o.planeRect = rect | |
| 723 | 731 | o.lastFrame, o.cursorHeld = time.Now(), false | |
| 732 | + | // The frame carries every deferred state change. | |
| 733 | + | o.stateAt = time.Time{} | |
| 724 | 734 | f.frame, f.async = true, async | |
| 725 | 735 | f.wantedAt, o.wantedAt = o.wantedAt, 0 | |
| 726 | 736 | f.gapEnd, o.flipGapAt = o.flipGapAt, 0 | |
| @@ -774,6 +784,14 @@ func (o *Output) commitState(vrr bool) error { | |||
| 774 | 784 | if o.protected { | |
| 775 | 785 | 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 | 795 | req := &o.stateReq | |
| 778 | 796 | req.reset() | |
| 779 | 797 | cur := cursorState{} | |
| @@ -1097,6 +1115,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1097 | 1115 | retryTimer := time.NewTimer(time.Hour) | |
| 1098 | 1116 | vrrTimer := time.NewTimer(time.Hour) | |
| 1099 | 1117 | cursorTimer := time.NewTimer(time.Hour) | |
| 1118 | + | stateTimer := time.NewTimer(time.Hour) | |
| 1119 | + | stateTimer.Stop() | |
| 1120 | + | defer stateTimer.Stop() | |
| 1100 | 1121 | protectTimer := time.NewTimer(time.Hour) | |
| 1101 | 1122 | o.frame.startTimer() | |
| 1102 | 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 | 1287 | } else { | |
| 1267 | 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 | 1298 | // A game frame waiting out the VRR flip gap commits when it ends. | |
| 1270 | 1299 | var gapDue <-chan time.Time | |
| 1271 | 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 | 1367 | stateDirty = true | |
| 1339 | 1368 | case <-cursorDue: | |
| 1340 | 1369 | stateDirty = true | |
| 1370 | + | case <-stateDue: | |
| 1371 | + | stateDirty = true | |
| 1341 | 1372 | case <-cursorWake: | |
| 1342 | 1373 | stateDirty = true | |
| 1343 | 1374 | case <-gapDue: | |
| @@ -1638,6 +1669,9 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool { | |||
| 1638 | 1669 | return false | |
| 1639 | 1670 | } | |
| 1640 | 1671 | o.flipDone(ev, f, start, ours, trace, fenceAt) | |
| 1672 | + | if ours { | |
| 1673 | + | o.noteVblank(f, ev.when) | |
| 1674 | + | } | |
| 1641 | 1675 | if o.cursor != nil { | |
| 1642 | 1676 | o.cursor.landed() | |
| 1643 | 1677 | } | |
| @@ -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.Time | 158 | lastFrame time.Time | ||
| 159 | cursorHeld bool | 159 | 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 is | 165 | // 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. wantedAt | 167 | // 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, !active | 460 | 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, 0 | 462 | 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, false | 502 | 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.contentWanted | 729 | o.contentValue = o.contentWanted | ||
| 722 | o.planeRect = rect | 730 | o.planeRect = rect | ||
| 723 | o.lastFrame, o.cursorHeld = time.Now(), false | 731 | 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, async | 734 | f.frame, f.async = true, async | ||
| 725 | f.wantedAt, o.wantedAt = o.wantedAt, 0 | 735 | f.wantedAt, o.wantedAt = o.wantedAt, 0 | ||
| 726 | f.gapEnd, o.flipGapAt = o.flipGapAt, 0 | 736 | 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 errSecurityScene | 785 | 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.stateReq | 795 | 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.Time | 1299 | 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 = true | 1367 | stateDirty = true | ||
| 1339 | case <-cursorDue: | 1368 | case <-cursorDue: | ||
| 1340 | stateDirty = true | 1369 | stateDirty = true | ||
| 1370 | + | case <-stateDue: | |||
| 1371 | + | stateDirty = true | |||
| 1341 | case <-cursorWake: | 1372 | case <-cursorWake: | ||
| 1342 | stateDirty = true | 1373 | 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 false | 1669 | 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 | + | } | |||