fix(drm): retry commits refused for memory instead of stopping the output
- commit
- d7074009750408abd2aaa848ff6b5fb5a5a01239
- author
- brice · · 2026-10-22 12:01 UTC
- parent
- 3c13b3d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +361 -20
M internal/adapters/drm/frame_lifecycle.go +38 -3
| @@ -21,6 +21,9 @@ type frameLifecycle struct { | |||
| 21 | 21 | stuckAt time.Time | |
| 22 | 22 | fenceWarned bool | |
| 23 | 23 | busySince, lastBusy time.Time | |
| 24 | + | nomemSince, lastNomem time.Time | |
| 25 | + | nomemBackoff time.Duration | |
| 26 | + | nomemAttempts int | |
| 24 | 27 | stuckTimer *time.Timer | |
| 25 | 28 | } | |
| 26 | 29 | ||
| @@ -66,8 +69,11 @@ const ( | |||
| 66 | 69 | ) | |
| 67 | 70 | ||
| 68 | 71 | const ( | |
| 69 | - | stuckTimeout = time.Second | |
| 70 | - | busyRetry = 50 * time.Millisecond | |
| 72 | + | stuckTimeout = time.Second | |
| 73 | + | busyRetry = 50 * time.Millisecond | |
| 74 | + | nomemRetryMin = 16 * time.Millisecond | |
| 75 | + | nomemRetryMax = time.Second | |
| 76 | + | nomemWarnAfter = 5 * time.Second | |
| 71 | 77 | ) | |
| 72 | 78 | ||
| 73 | 79 | // stuckLimit returns the configured recovery deadline or its default. | |
| @@ -106,6 +112,32 @@ func (l *frameLifecycle) pendingCommit() bool { return l.pending } | |||
| 106 | 112 | func (l *frameLifecycle) resetAfterModeset() { | |
| 107 | 113 | l.endPending() | |
| 108 | 114 | l.busySince = time.Time{} | |
| 115 | + | l.nomemRecovered(time.Time{}) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // nomem records a refused pin, not a missing flip. The old frame still shows. | |
| 119 | + | func (l *frameLifecycle) nomem(now time.Time) (attempt int, newRun bool) { | |
| 120 | + | l.endPending() | |
| 121 | + | newRun = l.nomemSince.IsZero() | |
| 122 | + | if newRun { | |
| 123 | + | l.nomemSince = now | |
| 124 | + | } | |
| 125 | + | l.nomemBackoff = min(nomemRetryMax, max(nomemRetryMin, 2*l.nomemBackoff)) | |
| 126 | + | l.lastNomem = now | |
| 127 | + | l.nomemAttempts++ | |
| 128 | + | l.pending, l.pendingSerial, l.flipStart = true, 0, now | |
| 129 | + | l.stuckAt = now.Add(l.nomemBackoff) | |
| 130 | + | return l.nomemAttempts, newRun | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // nomemRecovered consumes the refusal run after an accepted commit. | |
| 134 | + | func (l *frameLifecycle) nomemRecovered(now time.Time) (attempts int, outage time.Duration) { | |
| 135 | + | if !l.nomemSince.IsZero() { | |
| 136 | + | attempts, outage = l.nomemAttempts, now.Sub(l.nomemSince) | |
| 137 | + | } | |
| 138 | + | l.nomemSince, l.lastNomem = time.Time{}, time.Time{} | |
| 139 | + | l.nomemBackoff, l.nomemAttempts = 0, 0 | |
| 140 | + | return | |
| 109 | 141 | } | |
| 110 | 142 | ||
| 111 | 143 | // deadlineReached reports whether a pending commit is ready for recovery. | |
| @@ -144,7 +176,7 @@ func (l *frameLifecycle) ours(user uint64) bool { | |||
| 144 | 176 | // EBUSY there is no known serial, so any CRTC event ends that wait. The | |
| 145 | 177 | // returned start lets Output measure flip age after pending fences close. | |
| 146 | 178 | func (l *frameLifecycle) flip(user uint64) (pendingFrame, time.Time, bool) { | |
| 147 | - | if !l.pending || l.pendingSerial != 0 && user>>userKindBits != l.pendingSerial { | |
| 179 | + | if !l.pending || l.pendingSerial == 0 && !l.nomemSince.IsZero() || l.pendingSerial != 0 && user>>userKindBits != l.pendingSerial { | |
| 148 | 180 | return pendingFrame{}, time.Time{}, false | |
| 149 | 181 | } | |
| 150 | 182 | f, start := l.pendingFrame, l.flipStart | |
| @@ -183,6 +215,9 @@ func (l *frameLifecycle) timeout(now time.Time, fencesReady bool) timeoutResult | |||
| 183 | 215 | if l.pendingSerial != 0 { | |
| 184 | 216 | return timeoutMissing | |
| 185 | 217 | } | |
| 218 | + | if !l.nomemSince.IsZero() { | |
| 219 | + | return timeoutRetry | |
| 220 | + | } | |
| 186 | 221 | if l.busySince.IsZero() || now.Sub(l.busySince) < l.stuckLimit() { | |
| 187 | 222 | return timeoutRetry | |
| 188 | 223 | } | |
| @@ -21,6 +21,9 @@ type frameLifecycle struct { | |||||
| 21 | stuckAt time.Time | 21 | stuckAt time.Time | ||
| 22 | fenceWarned bool | 22 | fenceWarned bool | ||
| 23 | busySince, lastBusy time.Time | 23 | busySince, lastBusy time.Time | ||
| 24 | + | nomemSince, lastNomem time.Time | |||
| 25 | + | nomemBackoff time.Duration | |||
| 26 | + | nomemAttempts int | |||
| 24 | stuckTimer *time.Timer | 27 | stuckTimer *time.Timer | ||
| 25 | } | 28 | } | ||
| 26 | 29 | ||||
| @@ -66,8 +69,11 @@ const ( | |||||
| 66 | ) | 69 | ) | ||
| 67 | 70 | ||||
| 68 | const ( | 71 | const ( | ||
| 69 | - | stuckTimeout = time.Second | 72 | + | stuckTimeout = time.Second |
| 70 | - | busyRetry = 50 * time.Millisecond | 73 | + | busyRetry = 50 * time.Millisecond |
| 74 | + | nomemRetryMin = 16 * time.Millisecond | |||
| 75 | + | nomemRetryMax = time.Second | |||
| 76 | + | nomemWarnAfter = 5 * time.Second | |||
| 71 | ) | 77 | ) | ||
| 72 | 78 | ||||
| 73 | // stuckLimit returns the configured recovery deadline or its default. | 79 | // stuckLimit returns the configured recovery deadline or its default. | ||
| @@ -106,6 +112,32 @@ func (l *frameLifecycle) pendingCommit() bool { return l.pending } | |||||
| 106 | func (l *frameLifecycle) resetAfterModeset() { | 112 | func (l *frameLifecycle) resetAfterModeset() { | ||
| 107 | l.endPending() | 113 | l.endPending() | ||
| 108 | l.busySince = time.Time{} | 114 | l.busySince = time.Time{} | ||
| 115 | + | l.nomemRecovered(time.Time{}) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // nomem records a refused pin, not a missing flip. The old frame still shows. | |||
| 119 | + | func (l *frameLifecycle) nomem(now time.Time) (attempt int, newRun bool) { | |||
| 120 | + | l.endPending() | |||
| 121 | + | newRun = l.nomemSince.IsZero() | |||
| 122 | + | if newRun { | |||
| 123 | + | l.nomemSince = now | |||
| 124 | + | } | |||
| 125 | + | l.nomemBackoff = min(nomemRetryMax, max(nomemRetryMin, 2*l.nomemBackoff)) | |||
| 126 | + | l.lastNomem = now | |||
| 127 | + | l.nomemAttempts++ | |||
| 128 | + | l.pending, l.pendingSerial, l.flipStart = true, 0, now | |||
| 129 | + | l.stuckAt = now.Add(l.nomemBackoff) | |||
| 130 | + | return l.nomemAttempts, newRun | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // nomemRecovered consumes the refusal run after an accepted commit. | |||
| 134 | + | func (l *frameLifecycle) nomemRecovered(now time.Time) (attempts int, outage time.Duration) { | |||
| 135 | + | if !l.nomemSince.IsZero() { | |||
| 136 | + | attempts, outage = l.nomemAttempts, now.Sub(l.nomemSince) | |||
| 137 | + | } | |||
| 138 | + | l.nomemSince, l.lastNomem = time.Time{}, time.Time{} | |||
| 139 | + | l.nomemBackoff, l.nomemAttempts = 0, 0 | |||
| 140 | + | return | |||
| 109 | } | 141 | } | ||
| 110 | 142 | ||||
| 111 | // deadlineReached reports whether a pending commit is ready for recovery. | 143 | // deadlineReached reports whether a pending commit is ready for recovery. | ||
| @@ -144,7 +176,7 @@ func (l *frameLifecycle) ours(user uint64) bool { | |||||
| 144 | // EBUSY there is no known serial, so any CRTC event ends that wait. The | 176 | // EBUSY there is no known serial, so any CRTC event ends that wait. The | ||
| 145 | // returned start lets Output measure flip age after pending fences close. | 177 | // returned start lets Output measure flip age after pending fences close. | ||
| 146 | func (l *frameLifecycle) flip(user uint64) (pendingFrame, time.Time, bool) { | 178 | func (l *frameLifecycle) flip(user uint64) (pendingFrame, time.Time, bool) { | ||
| 147 | - | if !l.pending || l.pendingSerial != 0 && user>>userKindBits != l.pendingSerial { | 179 | + | if !l.pending || l.pendingSerial == 0 && !l.nomemSince.IsZero() || l.pendingSerial != 0 && user>>userKindBits != l.pendingSerial { |
| 148 | return pendingFrame{}, time.Time{}, false | 180 | return pendingFrame{}, time.Time{}, false | ||
| 149 | } | 181 | } | ||
| 150 | f, start := l.pendingFrame, l.flipStart | 182 | f, start := l.pendingFrame, l.flipStart | ||
| @@ -183,6 +215,9 @@ func (l *frameLifecycle) timeout(now time.Time, fencesReady bool) timeoutResult | |||||
| 183 | if l.pendingSerial != 0 { | 215 | if l.pendingSerial != 0 { | ||
| 184 | return timeoutMissing | 216 | return timeoutMissing | ||
| 185 | } | 217 | } | ||
| 218 | + | if !l.nomemSince.IsZero() { | |||
| 219 | + | return timeoutRetry | |||
| 220 | + | } | |||
| 186 | if l.busySince.IsZero() || now.Sub(l.busySince) < l.stuckLimit() { | 221 | if l.busySince.IsZero() || now.Sub(l.busySince) < l.stuckLimit() { | ||
| 187 | return timeoutRetry | 222 | return timeoutRetry | ||
| 188 | } | 223 | } | ||
M internal/adapters/drm/frame_lifecycle_test.go +29 -0
| @@ -95,3 +95,32 @@ func TestFrameLifecycleTransitionsAllocations(t *testing.T) { | |||
| 95 | 95 | t.Fatalf("frame lifecycle: %.1f allocs, want 0", allocs) | |
| 96 | 96 | } | |
| 97 | 97 | } | |
| 98 | + | ||
| 99 | + | func TestFrameLifecycleNoMemBackoffAndRecovery(t *testing.T) { | |
| 100 | + | l := frameLifecycle{stuckAfter: time.Millisecond} | |
| 101 | + | start := time.Unix(100, 0) | |
| 102 | + | now := start | |
| 103 | + | for i := range 20 { | |
| 104 | + | attempt, fresh := l.nomem(now) | |
| 105 | + | want := min(nomemRetryMax, nomemRetryMin*time.Duration(1<<min(i, 7))) | |
| 106 | + | if attempt != i+1 || fresh != (i == 0) || l.nomemBackoff != want || l.stuckAt != now.Add(want) { | |
| 107 | + | t.Fatalf("attempt %d fresh=%v backoff=%v deadline=%v", attempt, fresh, l.nomemBackoff, l.stuckAt) | |
| 108 | + | } | |
| 109 | + | if _, _, ok := l.flip(123 << userKindBits); ok { | |
| 110 | + | t.Fatal("stale event bypassed memory backoff") | |
| 111 | + | } | |
| 112 | + | if got := l.timeout(l.stuckAt.Add(10*l.stuckLimit()), true); got != timeoutRetry { | |
| 113 | + | t.Fatalf("ENOMEM escalated: %d", got) | |
| 114 | + | } | |
| 115 | + | now = l.stuckAt | |
| 116 | + | } | |
| 117 | + | attempts, outage := l.nomemRecovered(now) | |
| 118 | + | if attempts != 20 || outage != now.Sub(start) || !l.nomemSince.IsZero() || !l.lastNomem.IsZero() || l.nomemBackoff != 0 || l.nomemAttempts != 0 { | |
| 119 | + | t.Fatalf("recovery: %d %v %+v", attempts, outage, l) | |
| 120 | + | } | |
| 121 | + | l.nomem(now) | |
| 122 | + | l.resetAfterModeset() | |
| 123 | + | if !l.nomemSince.IsZero() || l.nomemAttempts != 0 || l.pending { | |
| 124 | + | t.Fatal("modeset did not clear run") | |
| 125 | + | } | |
| 126 | + | } | |
| @@ -95,3 +95,32 @@ func TestFrameLifecycleTransitionsAllocations(t *testing.T) { | |||||
| 95 | t.Fatalf("frame lifecycle: %.1f allocs, want 0", allocs) | 95 | t.Fatalf("frame lifecycle: %.1f allocs, want 0", allocs) | ||
| 96 | } | 96 | } | ||
| 97 | } | 97 | } | ||
| 98 | + | ||||
| 99 | + | func TestFrameLifecycleNoMemBackoffAndRecovery(t *testing.T) { | |||
| 100 | + | l := frameLifecycle{stuckAfter: time.Millisecond} | |||
| 101 | + | start := time.Unix(100, 0) | |||
| 102 | + | now := start | |||
| 103 | + | for i := range 20 { | |||
| 104 | + | attempt, fresh := l.nomem(now) | |||
| 105 | + | want := min(nomemRetryMax, nomemRetryMin*time.Duration(1<<min(i, 7))) | |||
| 106 | + | if attempt != i+1 || fresh != (i == 0) || l.nomemBackoff != want || l.stuckAt != now.Add(want) { | |||
| 107 | + | t.Fatalf("attempt %d fresh=%v backoff=%v deadline=%v", attempt, fresh, l.nomemBackoff, l.stuckAt) | |||
| 108 | + | } | |||
| 109 | + | if _, _, ok := l.flip(123 << userKindBits); ok { | |||
| 110 | + | t.Fatal("stale event bypassed memory backoff") | |||
| 111 | + | } | |||
| 112 | + | if got := l.timeout(l.stuckAt.Add(10*l.stuckLimit()), true); got != timeoutRetry { | |||
| 113 | + | t.Fatalf("ENOMEM escalated: %d", got) | |||
| 114 | + | } | |||
| 115 | + | now = l.stuckAt | |||
| 116 | + | } | |||
| 117 | + | attempts, outage := l.nomemRecovered(now) | |||
| 118 | + | if attempts != 20 || outage != now.Sub(start) || !l.nomemSince.IsZero() || !l.lastNomem.IsZero() || l.nomemBackoff != 0 || l.nomemAttempts != 0 { | |||
| 119 | + | t.Fatalf("recovery: %d %v %+v", attempts, outage, l) | |||
| 120 | + | } | |||
| 121 | + | l.nomem(now) | |||
| 122 | + | l.resetAfterModeset() | |||
| 123 | + | if !l.nomemSince.IsZero() || l.nomemAttempts != 0 || l.pending { | |||
| 124 | + | t.Fatal("modeset did not clear run") | |||
| 125 | + | } | |||
| 126 | + | } | |||
A internal/adapters/drm/nomem_test.go +208 -0
| @@ -0,0 +1,208 @@ | |||
| 1 | + | package drm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync/atomic" | |
| 5 | + | "testing" | |
| 6 | + | "testing/synctest" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | "golang.org/x/sys/unix" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestNoMemRetriesWithoutModeset(t *testing.T) { | |
| 15 | + | synctest.Test(t, func(t *testing.T) { | |
| 16 | + | h := startStuck(t, nil, unix.ENOMEM, unix.ENOMEM, nil) | |
| 17 | + | h.scenes <- ports.Scene{Seq: 1} | |
| 18 | + | synctest.Wait() | |
| 19 | + | for _, delay := range []time.Duration{nomemRetryMin, 2 * nomemRetryMin} { | |
| 20 | + | select { | |
| 21 | + | case p := <-h.presented: | |
| 22 | + | t.Fatalf("refused frame reported: %+v", p) | |
| 23 | + | default: | |
| 24 | + | } | |
| 25 | + | time.Sleep(delay) | |
| 26 | + | synctest.Wait() | |
| 27 | + | } | |
| 28 | + | c := h.snapshot() | |
| 29 | + | if len(c) != 5 { | |
| 30 | + | t.Fatalf("commits %d, want 5", len(c)) | |
| 31 | + | } | |
| 32 | + | for _, r := range c[2:] { | |
| 33 | + | if r.flags&atomicAllowModes != 0 || r.user&3 != userFrame { | |
| 34 | + | t.Fatalf("not a frame retry: %+v", r) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | select { | |
| 38 | + | case p := <-h.presented: | |
| 39 | + | t.Fatalf("reported before successful flip: %+v", p) | |
| 40 | + | default: | |
| 41 | + | } | |
| 42 | + | h.flipped <- eventOf(c[4]) | |
| 43 | + | synctest.Wait() | |
| 44 | + | select { | |
| 45 | + | case <-h.presented: | |
| 46 | + | default: | |
| 47 | + | t.Fatal("successful flip not reported") | |
| 48 | + | } | |
| 49 | + | select { | |
| 50 | + | case p := <-h.presented: | |
| 51 | + | t.Fatalf("extra report: %+v", p) | |
| 52 | + | default: | |
| 53 | + | } | |
| 54 | + | h.stop() | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestLastingNoMemKeepsOutput(t *testing.T) { | |
| 59 | + | synctest.Test(t, func(t *testing.T) { | |
| 60 | + | errs := make([]error, 21) | |
| 61 | + | for i := range errs { | |
| 62 | + | errs[i] = unix.ENOMEM | |
| 63 | + | } | |
| 64 | + | h := startStuck(t, nil, errs...) | |
| 65 | + | h.scenes <- ports.Scene{Seq: 1} | |
| 66 | + | synctest.Wait() | |
| 67 | + | for i := range 19 { | |
| 68 | + | time.Sleep(min(nomemRetryMax, nomemRetryMin*time.Duration(1<<min(i, 7)))) | |
| 69 | + | synctest.Wait() | |
| 70 | + | } | |
| 71 | + | select { | |
| 72 | + | case err := <-h.done: | |
| 73 | + | t.Fatalf("Run stopped: %v", err) | |
| 74 | + | default: | |
| 75 | + | } | |
| 76 | + | c := h.stop() | |
| 77 | + | if len(c) != 22 { | |
| 78 | + | t.Fatalf("commits %d want 22", len(c)) | |
| 79 | + | } | |
| 80 | + | for _, r := range c[2:] { | |
| 81 | + | if r.flags&atomicAllowModes != 0 { | |
| 82 | + | t.Fatal("ENOMEM triggered modeset") | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | }) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestNoMemModesetRetries(t *testing.T) { | |
| 89 | + | synctest.Test(t, func(t *testing.T) { | |
| 90 | + | h := startStuck(t, nil, nil, unix.ENOMEM, nil) | |
| 91 | + | h.scenes <- ports.Scene{Seq: 1, Off: true} | |
| 92 | + | synctest.Wait() | |
| 93 | + | if h.count() != 3 { | |
| 94 | + | t.Fatal("power off did not commit") | |
| 95 | + | } | |
| 96 | + | h.scenes <- ports.Scene{Seq: 2} | |
| 97 | + | synctest.Wait() | |
| 98 | + | time.Sleep(nomemRetryMin) | |
| 99 | + | synctest.Wait() | |
| 100 | + | select { | |
| 101 | + | case err := <-h.done: | |
| 102 | + | t.Fatalf("Run stopped: %v", err) | |
| 103 | + | default: | |
| 104 | + | } | |
| 105 | + | c := h.stop() | |
| 106 | + | if len(c) != 6 { | |
| 107 | + | t.Fatalf("commits %d want 6", len(c)) | |
| 108 | + | } | |
| 109 | + | for _, r := range c[3:5] { | |
| 110 | + | if r.flags != atomicAllowModes { | |
| 111 | + | t.Fatalf("not a retried modeset: %#x", r.flags) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | }) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestScanoutNoMemComposes(t *testing.T) { | |
| 118 | + | synctest.Test(t, func(t *testing.T) { | |
| 119 | + | o, _, _ := testOutput(t, unix.ENOMEM) | |
| 120 | + | s, surfaces := fullscreenScene() | |
| 121 | + | c := surfaces[1] | |
| 122 | + | o.clientFBs[9] = &clientFB{fbID: 80} | |
| 123 | + | o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}} | |
| 124 | + | direct, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorBypass) | |
| 125 | + | if direct || err != nil || o.reason != "nomem" || o.clientFBs[9].failed != "" { | |
| 126 | + | t.Fatalf("direct=%v err=%v reason=%q failed=%q", direct, err, o.reason, o.clientFBs[9].failed) | |
| 127 | + | } | |
| 128 | + | if d := o.composeFrame(s, surfaces); d.overlay.fb != 0 || d.composed.Seq != s.Seq { | |
| 129 | + | t.Fatal("immediate fallback did not compose") | |
| 130 | + | } | |
| 131 | + | for _, delay := range []time.Duration{0, nomemScanoutPause - time.Nanosecond} { | |
| 132 | + | time.Sleep(delay) | |
| 133 | + | d := o.decideFrame(s, surfaces, false) | |
| 134 | + | if d.fb != 0 || d.overlay.fb != 0 || d.composed.Seq != s.Seq || o.reason != "nomem" { | |
| 135 | + | t.Fatalf("not composed: %+v reason=%q", d, o.reason) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | time.Sleep(time.Nanosecond) | |
| 139 | + | if d := o.decideFrame(s, surfaces, false); d.fb != 80 { | |
| 140 | + | t.Fatalf("scanout not resumed: %+v reason=%q", d, o.reason) | |
| 141 | + | } | |
| 142 | + | }) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestProtectedNoMemKeepsProtectionRetry(t *testing.T) { | |
| 146 | + | for _, stage := range []string{"modeset", "commit"} { | |
| 147 | + | t.Run(stage, func(t *testing.T) { | |
| 148 | + | o, k, _ := testOutput(t, unix.ENOMEM) | |
| 149 | + | var state atomic.Uint64 | |
| 150 | + | state.Store(3) | |
| 151 | + | securityGate(t, o, &state) | |
| 152 | + | o.observeSecurity() | |
| 153 | + | events := make(chan ports.SecurityBackendEvent, 4) | |
| 154 | + | o.SecurityEvents = events | |
| 155 | + | var err error | |
| 156 | + | if stage == "modeset" { | |
| 157 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | |
| 158 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil) | |
| 159 | + | err = o.modesetImage(70, true) | |
| 160 | + | } else { | |
| 161 | + | o.securityPrepared = true // an admitted lock-screen frame | |
| 162 | + | err = o.commitFrame(70, nil, false, false, pendingFrame{security: o.securityState}) | |
| 163 | + | } | |
| 164 | + | enabled := true | |
| 165 | + | if !o.commitFailed(err, &enabled) || !enabled || o.securityPrepared || !o.protectBackoffActive() || !o.frame.nomemSince.IsZero() { | |
| 166 | + | t.Fatalf("protection retry bypassed: err=%v prepared=%v backoff=%v nomem=%v", err, o.securityPrepared, o.protectBackoff, o.frame.nomemSince) | |
| 167 | + | } | |
| 168 | + | select { | |
| 169 | + | case ev := <-events: | |
| 170 | + | t.Fatalf("proof on refusal: %T", ev) | |
| 171 | + | default: | |
| 172 | + | } | |
| 173 | + | }) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func TestNoMemSuccessClearsRun(t *testing.T) { | |
| 178 | + | o, _, _ := testOutput(t, unix.ENOMEM, nil) | |
| 179 | + | enabled := true | |
| 180 | + | if !o.commitFailed(o.commitFrame(70, nil, false, false, pendingFrame{}), &enabled) { | |
| 181 | + | t.Fatal("ENOMEM unhandled") | |
| 182 | + | } | |
| 183 | + | o.frame.timeout(o.frame.stuckAt, true) | |
| 184 | + | if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil { | |
| 185 | + | t.Fatal(err) | |
| 186 | + | } | |
| 187 | + | if !o.frame.nomemSince.IsZero() || o.frame.nomemAttempts != 0 { | |
| 188 | + | t.Fatal("successful commit did not clear run") | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func TestNoMemRetryRechecksSecurity(t *testing.T) { | |
| 193 | + | o, _, commits := testOutput(t, unix.ENOMEM) | |
| 194 | + | enabled := true | |
| 195 | + | if !o.commitFailed(o.commitFrame(70, nil, false, false, pendingFrame{}), &enabled) { | |
| 196 | + | t.Fatal("ENOMEM unhandled") | |
| 197 | + | } | |
| 198 | + | o.frame.timeout(o.frame.stuckAt, true) | |
| 199 | + | var state atomic.Uint64 | |
| 200 | + | state.Store(3) | |
| 201 | + | securityGate(t, o, &state) | |
| 202 | + | if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != errSecurityScene { | |
| 203 | + | t.Fatalf("stale retry: %v", err) | |
| 204 | + | } | |
| 205 | + | if len(*commits) != 1 { | |
| 206 | + | t.Fatal("stale retry reached KMS") | |
| 207 | + | } | |
| 208 | + | } | |
| @@ -0,0 +1,208 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync/atomic" | |||
| 5 | + | "testing" | |||
| 6 | + | "testing/synctest" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | "golang.org/x/sys/unix" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestNoMemRetriesWithoutModeset(t *testing.T) { | |||
| 15 | + | synctest.Test(t, func(t *testing.T) { | |||
| 16 | + | h := startStuck(t, nil, unix.ENOMEM, unix.ENOMEM, nil) | |||
| 17 | + | h.scenes <- ports.Scene{Seq: 1} | |||
| 18 | + | synctest.Wait() | |||
| 19 | + | for _, delay := range []time.Duration{nomemRetryMin, 2 * nomemRetryMin} { | |||
| 20 | + | select { | |||
| 21 | + | case p := <-h.presented: | |||
| 22 | + | t.Fatalf("refused frame reported: %+v", p) | |||
| 23 | + | default: | |||
| 24 | + | } | |||
| 25 | + | time.Sleep(delay) | |||
| 26 | + | synctest.Wait() | |||
| 27 | + | } | |||
| 28 | + | c := h.snapshot() | |||
| 29 | + | if len(c) != 5 { | |||
| 30 | + | t.Fatalf("commits %d, want 5", len(c)) | |||
| 31 | + | } | |||
| 32 | + | for _, r := range c[2:] { | |||
| 33 | + | if r.flags&atomicAllowModes != 0 || r.user&3 != userFrame { | |||
| 34 | + | t.Fatalf("not a frame retry: %+v", r) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | select { | |||
| 38 | + | case p := <-h.presented: | |||
| 39 | + | t.Fatalf("reported before successful flip: %+v", p) | |||
| 40 | + | default: | |||
| 41 | + | } | |||
| 42 | + | h.flipped <- eventOf(c[4]) | |||
| 43 | + | synctest.Wait() | |||
| 44 | + | select { | |||
| 45 | + | case <-h.presented: | |||
| 46 | + | default: | |||
| 47 | + | t.Fatal("successful flip not reported") | |||
| 48 | + | } | |||
| 49 | + | select { | |||
| 50 | + | case p := <-h.presented: | |||
| 51 | + | t.Fatalf("extra report: %+v", p) | |||
| 52 | + | default: | |||
| 53 | + | } | |||
| 54 | + | h.stop() | |||
| 55 | + | }) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestLastingNoMemKeepsOutput(t *testing.T) { | |||
| 59 | + | synctest.Test(t, func(t *testing.T) { | |||
| 60 | + | errs := make([]error, 21) | |||
| 61 | + | for i := range errs { | |||
| 62 | + | errs[i] = unix.ENOMEM | |||
| 63 | + | } | |||
| 64 | + | h := startStuck(t, nil, errs...) | |||
| 65 | + | h.scenes <- ports.Scene{Seq: 1} | |||
| 66 | + | synctest.Wait() | |||
| 67 | + | for i := range 19 { | |||
| 68 | + | time.Sleep(min(nomemRetryMax, nomemRetryMin*time.Duration(1<<min(i, 7)))) | |||
| 69 | + | synctest.Wait() | |||
| 70 | + | } | |||
| 71 | + | select { | |||
| 72 | + | case err := <-h.done: | |||
| 73 | + | t.Fatalf("Run stopped: %v", err) | |||
| 74 | + | default: | |||
| 75 | + | } | |||
| 76 | + | c := h.stop() | |||
| 77 | + | if len(c) != 22 { | |||
| 78 | + | t.Fatalf("commits %d want 22", len(c)) | |||
| 79 | + | } | |||
| 80 | + | for _, r := range c[2:] { | |||
| 81 | + | if r.flags&atomicAllowModes != 0 { | |||
| 82 | + | t.Fatal("ENOMEM triggered modeset") | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | }) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestNoMemModesetRetries(t *testing.T) { | |||
| 89 | + | synctest.Test(t, func(t *testing.T) { | |||
| 90 | + | h := startStuck(t, nil, nil, unix.ENOMEM, nil) | |||
| 91 | + | h.scenes <- ports.Scene{Seq: 1, Off: true} | |||
| 92 | + | synctest.Wait() | |||
| 93 | + | if h.count() != 3 { | |||
| 94 | + | t.Fatal("power off did not commit") | |||
| 95 | + | } | |||
| 96 | + | h.scenes <- ports.Scene{Seq: 2} | |||
| 97 | + | synctest.Wait() | |||
| 98 | + | time.Sleep(nomemRetryMin) | |||
| 99 | + | synctest.Wait() | |||
| 100 | + | select { | |||
| 101 | + | case err := <-h.done: | |||
| 102 | + | t.Fatalf("Run stopped: %v", err) | |||
| 103 | + | default: | |||
| 104 | + | } | |||
| 105 | + | c := h.stop() | |||
| 106 | + | if len(c) != 6 { | |||
| 107 | + | t.Fatalf("commits %d want 6", len(c)) | |||
| 108 | + | } | |||
| 109 | + | for _, r := range c[3:5] { | |||
| 110 | + | if r.flags != atomicAllowModes { | |||
| 111 | + | t.Fatalf("not a retried modeset: %#x", r.flags) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | }) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestScanoutNoMemComposes(t *testing.T) { | |||
| 118 | + | synctest.Test(t, func(t *testing.T) { | |||
| 119 | + | o, _, _ := testOutput(t, unix.ENOMEM) | |||
| 120 | + | s, surfaces := fullscreenScene() | |||
| 121 | + | c := surfaces[1] | |||
| 122 | + | o.clientFBs[9] = &clientFB{fbID: 80} | |||
| 123 | + | o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}} | |||
| 124 | + | direct, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorBypass) | |||
| 125 | + | if direct || err != nil || o.reason != "nomem" || o.clientFBs[9].failed != "" { | |||
| 126 | + | t.Fatalf("direct=%v err=%v reason=%q failed=%q", direct, err, o.reason, o.clientFBs[9].failed) | |||
| 127 | + | } | |||
| 128 | + | if d := o.composeFrame(s, surfaces); d.overlay.fb != 0 || d.composed.Seq != s.Seq { | |||
| 129 | + | t.Fatal("immediate fallback did not compose") | |||
| 130 | + | } | |||
| 131 | + | for _, delay := range []time.Duration{0, nomemScanoutPause - time.Nanosecond} { | |||
| 132 | + | time.Sleep(delay) | |||
| 133 | + | d := o.decideFrame(s, surfaces, false) | |||
| 134 | + | if d.fb != 0 || d.overlay.fb != 0 || d.composed.Seq != s.Seq || o.reason != "nomem" { | |||
| 135 | + | t.Fatalf("not composed: %+v reason=%q", d, o.reason) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | time.Sleep(time.Nanosecond) | |||
| 139 | + | if d := o.decideFrame(s, surfaces, false); d.fb != 80 { | |||
| 140 | + | t.Fatalf("scanout not resumed: %+v reason=%q", d, o.reason) | |||
| 141 | + | } | |||
| 142 | + | }) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func TestProtectedNoMemKeepsProtectionRetry(t *testing.T) { | |||
| 146 | + | for _, stage := range []string{"modeset", "commit"} { | |||
| 147 | + | t.Run(stage, func(t *testing.T) { | |||
| 148 | + | o, k, _ := testOutput(t, unix.ENOMEM) | |||
| 149 | + | var state atomic.Uint64 | |||
| 150 | + | state.Store(3) | |||
| 151 | + | securityGate(t, o, &state) | |||
| 152 | + | o.observeSecurity() | |||
| 153 | + | events := make(chan ports.SecurityBackendEvent, 4) | |||
| 154 | + | o.SecurityEvents = events | |||
| 155 | + | var err error | |||
| 156 | + | if stage == "modeset" { | |||
| 157 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | |||
| 158 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil) | |||
| 159 | + | err = o.modesetImage(70, true) | |||
| 160 | + | } else { | |||
| 161 | + | o.securityPrepared = true // an admitted lock-screen frame | |||
| 162 | + | err = o.commitFrame(70, nil, false, false, pendingFrame{security: o.securityState}) | |||
| 163 | + | } | |||
| 164 | + | enabled := true | |||
| 165 | + | if !o.commitFailed(err, &enabled) || !enabled || o.securityPrepared || !o.protectBackoffActive() || !o.frame.nomemSince.IsZero() { | |||
| 166 | + | t.Fatalf("protection retry bypassed: err=%v prepared=%v backoff=%v nomem=%v", err, o.securityPrepared, o.protectBackoff, o.frame.nomemSince) | |||
| 167 | + | } | |||
| 168 | + | select { | |||
| 169 | + | case ev := <-events: | |||
| 170 | + | t.Fatalf("proof on refusal: %T", ev) | |||
| 171 | + | default: | |||
| 172 | + | } | |||
| 173 | + | }) | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func TestNoMemSuccessClearsRun(t *testing.T) { | |||
| 178 | + | o, _, _ := testOutput(t, unix.ENOMEM, nil) | |||
| 179 | + | enabled := true | |||
| 180 | + | if !o.commitFailed(o.commitFrame(70, nil, false, false, pendingFrame{}), &enabled) { | |||
| 181 | + | t.Fatal("ENOMEM unhandled") | |||
| 182 | + | } | |||
| 183 | + | o.frame.timeout(o.frame.stuckAt, true) | |||
| 184 | + | if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil { | |||
| 185 | + | t.Fatal(err) | |||
| 186 | + | } | |||
| 187 | + | if !o.frame.nomemSince.IsZero() || o.frame.nomemAttempts != 0 { | |||
| 188 | + | t.Fatal("successful commit did not clear run") | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func TestNoMemRetryRechecksSecurity(t *testing.T) { | |||
| 193 | + | o, _, commits := testOutput(t, unix.ENOMEM) | |||
| 194 | + | enabled := true | |||
| 195 | + | if !o.commitFailed(o.commitFrame(70, nil, false, false, pendingFrame{}), &enabled) { | |||
| 196 | + | t.Fatal("ENOMEM unhandled") | |||
| 197 | + | } | |||
| 198 | + | o.frame.timeout(o.frame.stuckAt, true) | |||
| 199 | + | var state atomic.Uint64 | |||
| 200 | + | state.Store(3) | |||
| 201 | + | securityGate(t, o, &state) | |||
| 202 | + | if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != errSecurityScene { | |||
| 203 | + | t.Fatalf("stale retry: %v", err) | |||
| 204 | + | } | |||
| 205 | + | if len(*commits) != 1 { | |||
| 206 | + | t.Fatal("stale retry reached KMS") | |||
| 207 | + | } | |||
| 208 | + | } | |||
M internal/adapters/drm/output.go +63 -16
| @@ -109,12 +109,14 @@ type Output struct { | |||
| 109 | 109 | readFences []*os.File | |
| 110 | 110 | // Direct scanout: client framebuffers by DMABuf ID and the buffer on | |
| 111 | 111 | // screen and queued (0: the composed image). | |
| 112 | - | scanout bool | |
| 113 | - | clientFBs map[uint64]*clientFB | |
| 114 | - | shown, queued uint64 | |
| 115 | - | planeRect planeRect | |
| 116 | - | reason string // why the last frame was composed ("" = scanout) | |
| 117 | - | reports presented.Queue | |
| 112 | + | scanout bool | |
| 113 | + | clientFBs map[uint64]*clientFB | |
| 114 | + | shown, queued uint64 | |
| 115 | + | planeRect planeRect | |
| 116 | + | reason string // why the last frame was composed ("" = scanout) | |
| 117 | + | scanoutPausedUntil time.Time | |
| 118 | + | nomemModeset bool // retry the refused modeset, not an ordinary frame | |
| 119 | + | reports presented.Queue | |
| 118 | 120 | // Last immutable shows snapshot sent in a flip. Never mutate it: wayland | |
| 119 | 121 | // may still be reading a prior report on another goroutine. | |
| 120 | 122 | shows surfaces.ShowsSnapshot | |
| @@ -415,6 +417,8 @@ func (o *Output) modesetImage(fb uint32, active bool) error { | |||
| 415 | 417 | if err := o.disableProtected(); err != nil { | |
| 416 | 418 | return err | |
| 417 | 419 | } | |
| 420 | + | o.commitRecovered() | |
| 421 | + | o.nomemModeset = false | |
| 418 | 422 | o.frame.resetAfterModeset() | |
| 419 | 423 | o.forgetColor() | |
| 420 | 424 | o.planeRect = fullPlaneRect(o.Width(), o.Height()) | |
| @@ -450,6 +454,9 @@ func (o *Output) modesetImage(fb uint32, active bool) error { | |||
| 450 | 454 | return errSecurityScene | |
| 451 | 455 | } | |
| 452 | 456 | if err := o.k.commit(req, atomicAllowModes, 0); err != nil { | |
| 457 | + | if !o.protected && errors.Is(err, unix.ENOMEM) { | |
| 458 | + | o.nomemModeset = true | |
| 459 | + | } | |
| 453 | 460 | _ = o.k.destroyBlob(blob) | |
| 454 | 461 | if o.protected { | |
| 455 | 462 | return protectedCommitError{fmt.Errorf("modeset: %w", err)} | |
| @@ -458,6 +465,8 @@ func (o *Output) modesetImage(fb uint32, active bool) error { | |||
| 458 | 465 | } | |
| 459 | 466 | // Only now is nothing of ours pending or on screen: on failure the | |
| 460 | 467 | // previous buffers may still show. | |
| 468 | + | o.commitRecovered() | |
| 469 | + | o.nomemModeset = false | |
| 461 | 470 | o.frame.resetAfterModeset() | |
| 462 | 471 | // Plane scaling and colour pipeline capabilities may change after a | |
| 463 | 472 | // modeset or VT resume; the planes' colour state is not assumed. | |
| @@ -514,6 +523,8 @@ func (o *Output) powerOff() error { | |||
| 514 | 523 | if err := o.k.commit(req, atomicAllowModes, 0); err != nil { | |
| 515 | 524 | return fmt.Errorf("power off: %w", err) | |
| 516 | 525 | } | |
| 526 | + | o.commitRecovered() | |
| 527 | + | o.nomemModeset = false | |
| 517 | 528 | o.colorBypassed() | |
| 518 | 529 | o.off, o.vrrGame = true, false | |
| 519 | 530 | // No vblank to aim at: the modeset that turns it on commits the state. | |
| @@ -735,6 +746,10 @@ func (o *Output) commitRect(fb uint32, fence *os.File, async bool, vrr bool, f p | |||
| 735 | 746 | return nil | |
| 736 | 747 | } | |
| 737 | 748 | } | |
| 749 | + | if o.protected && errors.Is(err, unix.ENOMEM) { | |
| 750 | + | o.invalidateSecurity() | |
| 751 | + | return protectedCommitError{err} | |
| 752 | + | } | |
| 738 | 753 | return err | |
| 739 | 754 | } | |
| 740 | 755 | if !async { | |
| @@ -767,6 +782,7 @@ func (o *Output) commitRect(fb uint32, fence *os.File, async bool, vrr bool, f p | |||
| 767 | 782 | f.fenceReady = signalled(f.fences) | |
| 768 | 783 | } | |
| 769 | 784 | o.setAsync(async) | |
| 785 | + | o.commitRecovered() | |
| 770 | 786 | o.frame.begin(f, time.Now()) | |
| 771 | 787 | return nil | |
| 772 | 788 | } | |
| @@ -884,6 +900,7 @@ func (o *Output) commitState(vrr bool) error { | |||
| 884 | 900 | o.setVRR(vrr) | |
| 885 | 901 | o.contentValue = o.contentWanted | |
| 886 | 902 | o.flipStats.stateCommits++ | |
| 903 | + | o.commitRecovered() | |
| 887 | 904 | o.frame.begin(pendingFrame{}, time.Now()) | |
| 888 | 905 | return nil | |
| 889 | 906 | } | |
| @@ -937,6 +954,12 @@ func (o *Output) commitScanoutRect(fb uint32, c ports.SurfaceContent, f pendingF | |||
| 937 | 954 | cfb.noAsync = true | |
| 938 | 955 | err = o.commitRect(fb, fence, false, vrr, f, overlayWin{}, rect, pc) | |
| 939 | 956 | } | |
| 957 | + | if errors.Is(err, unix.ENOMEM) && !retryableProtected(err) { | |
| 958 | + | o.scanoutPausedUntil = time.Now().Add(nomemScanoutPause) | |
| 959 | + | o.setScanoutReason("nomem") | |
| 960 | + | o.renderLog.Info().Err(err).Str("connector", o.conn.name).Msg("scanout pin failed; composing") | |
| 961 | + | return false, nil | |
| 962 | + | } | |
| 940 | 963 | if errors.Is(err, errColorRefused) { | |
| 941 | 964 | // KMS takes the buffer without the pipeline: compose it; the | |
| 942 | 965 | // refusal is cached, the buffer is not marked failed. | |
| @@ -1102,13 +1125,13 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1102 | 1125 | // While switched away they are made on the first enable. | |
| 1103 | 1126 | if enabled { | |
| 1104 | 1127 | if err := o.showImages(r, imagesDriver, nil); err != nil { | |
| 1105 | - | // While protected, a recoverable KMS failure keeps the output | |
| 1106 | - | // registered and dark; the loop retries with bounded backoff. | |
| 1107 | - | if !o.protected || !o.commitFailed(err, &enabled) { | |
| 1128 | + | // Protected preparation and a memory-refused modeset keep the | |
| 1129 | + | // output registered; the loop retries with capped backoff. | |
| 1130 | + | if !o.protected && !o.nomemModeset || !o.commitFailed(err, &enabled) { | |
| 1108 | 1131 | return err | |
| 1109 | 1132 | } | |
| 1110 | 1133 | } | |
| 1111 | - | if !o.protected { | |
| 1134 | + | if !o.protected && !o.frame.pendingCommit() { | |
| 1112 | 1135 | o.probeAsync(r) | |
| 1113 | 1136 | } | |
| 1114 | 1137 | } | |
| @@ -1557,7 +1580,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1557 | 1580 | } | |
| 1558 | 1581 | // Output power: off waits for no commit in flight; on is a modeset | |
| 1559 | 1582 | // and a full frame. | |
| 1560 | - | if o.wantOff != o.off { | |
| 1583 | + | if o.wantOff != o.off || o.nomemModeset { | |
| 1561 | 1584 | var err error | |
| 1562 | 1585 | if o.wantOff { | |
| 1563 | 1586 | err = o.powerOff() | |
| @@ -1567,8 +1590,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1567 | 1590 | o.probeAsync(r) // skipped while it started off | |
| 1568 | 1591 | } | |
| 1569 | 1592 | } | |
| 1570 | - | if err != nil && !o.commitFailed(err, &enabled) { | |
| 1571 | - | return err | |
| 1593 | + | if err != nil { | |
| 1594 | + | if !o.commitFailed(err, &enabled) { | |
| 1595 | + | return err | |
| 1596 | + | } | |
| 1597 | + | continue | |
| 1572 | 1598 | } | |
| 1573 | 1599 | } | |
| 1574 | 1600 | if o.off { | |
| @@ -1634,9 +1660,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1634 | 1660 | } | |
| 1635 | 1661 | } | |
| 1636 | 1662 | ||
| 1637 | - | // commitFailed handles a commit error that does not stop the output: a | |
| 1638 | - | // commit still in flight (EBUSY, retried after its event) or DRM master | |
| 1639 | - | // lost mid-switch (the seat disable follows). | |
| 1663 | + | // commitFailed handles refusals that keep the output: busy or memory | |
| 1664 | + | // pressure (retried), protected preparation, or DRM master lost mid-switch | |
| 1665 | + | // (the seat disable follows). | |
| 1640 | 1666 | func (o *Output) commitFailed(err error, enabled *bool) bool { | |
| 1641 | 1667 | switch { | |
| 1642 | 1668 | case errors.Is(err, errSecurityScene), errors.Is(err, errProtectionPending): | |
| @@ -1657,6 +1683,20 @@ func (o *Output) commitFailed(err error, enabled *bool) bool { | |||
| 1657 | 1683 | o.deferProtection() | |
| 1658 | 1684 | o.log.Warn().Err(err).Str("connector", o.conn.name).Dur("retry_in", o.protectBackoff).Msg("protected commit refused; output stays protected, retrying") | |
| 1659 | 1685 | return true | |
| 1686 | + | case errors.Is(err, unix.ENOMEM): | |
| 1687 | + | now := time.Now() | |
| 1688 | + | previous := o.frame.lastNomem | |
| 1689 | + | attempt, newRun := o.frame.nomem(now) | |
| 1690 | + | age := now.Sub(o.frame.nomemSince) | |
| 1691 | + | switch { | |
| 1692 | + | case newRun: | |
| 1693 | + | o.log.Warn().Err(err).Str("connector", o.conn.name).Dur("retry_in", o.frame.nomemBackoff).Msg("commit refused for memory; keeping last frame, retrying") | |
| 1694 | + | case previous.Sub(o.frame.nomemSince) < nomemWarnAfter && age >= nomemWarnAfter: | |
| 1695 | + | o.log.Warn().Str("connector", o.conn.name).Int("attempts", attempt).Dur("age", age).Msg("commit still refused for memory; keeping last frame, retrying") | |
| 1696 | + | default: | |
| 1697 | + | o.log.Debug().Str("connector", o.conn.name).Int("attempts", attempt).Dur("age", age).Dur("retry_in", o.frame.nomemBackoff).Msg("commit refused for memory") | |
| 1698 | + | } | |
| 1699 | + | return true | |
| 1660 | 1700 | case lostMaster(err): | |
| 1661 | 1701 | o.observeSecurity() | |
| 1662 | 1702 | if o.protected { | |
| @@ -1670,6 +1710,13 @@ func (o *Output) commitFailed(err error, enabled *bool) bool { | |||
| 1670 | 1710 | return false | |
| 1671 | 1711 | } | |
| 1672 | 1712 | ||
| 1713 | + | // commitRecovered reports only an accepted commit, never a refused frame. | |
| 1714 | + | func (o *Output) commitRecovered() { | |
| 1715 | + | if attempts, outage := o.frame.nomemRecovered(time.Now()); attempts > 0 { | |
| 1716 | + | o.log.Info().Str("connector", o.conn.name).Int("attempts", attempts).Dur("outage", outage).Msg("commit recovered") | |
| 1717 | + | } | |
| 1718 | + | } | |
| 1719 | + | ||
| 1673 | 1720 | // flipDone times the completion ev of the commit f made at start: the | |
| 1674 | 1721 | // flip stats, the flip trace and the VRR flip gap. It never allocates | |
| 1675 | 1722 | // with tracing off. | |
| @@ -109,12 +109,14 @@ type Output struct { | |||||
| 109 | readFences []*os.File | 109 | readFences []*os.File | ||
| 110 | // Direct scanout: client framebuffers by DMABuf ID and the buffer on | 110 | // Direct scanout: client framebuffers by DMABuf ID and the buffer on | ||
| 111 | // screen and queued (0: the composed image). | 111 | // screen and queued (0: the composed image). | ||
| 112 | - | scanout bool | 112 | + | scanout bool |
| 113 | - | clientFBs map[uint64]*clientFB | 113 | + | clientFBs map[uint64]*clientFB |
| 114 | - | shown, queued uint64 | 114 | + | shown, queued uint64 |
| 115 | - | planeRect planeRect | 115 | + | planeRect planeRect |
| 116 | - | reason string // why the last frame was composed ("" = scanout) | 116 | + | reason string // why the last frame was composed ("" = scanout) |
| 117 | - | reports presented.Queue | 117 | + | scanoutPausedUntil time.Time |
| 118 | + | nomemModeset bool // retry the refused modeset, not an ordinary frame | |||
| 119 | + | reports presented.Queue | |||
| 118 | // Last immutable shows snapshot sent in a flip. Never mutate it: wayland | 120 | // Last immutable shows snapshot sent in a flip. Never mutate it: wayland | ||
| 119 | // may still be reading a prior report on another goroutine. | 121 | // may still be reading a prior report on another goroutine. | ||
| 120 | shows surfaces.ShowsSnapshot | 122 | shows surfaces.ShowsSnapshot | ||
| @@ -415,6 +417,8 @@ func (o *Output) modesetImage(fb uint32, active bool) error { | |||||
| 415 | if err := o.disableProtected(); err != nil { | 417 | if err := o.disableProtected(); err != nil { | ||
| 416 | return err | 418 | return err | ||
| 417 | } | 419 | } | ||
| 420 | + | o.commitRecovered() | |||
| 421 | + | o.nomemModeset = false | |||
| 418 | o.frame.resetAfterModeset() | 422 | o.frame.resetAfterModeset() | ||
| 419 | o.forgetColor() | 423 | o.forgetColor() | ||
| 420 | o.planeRect = fullPlaneRect(o.Width(), o.Height()) | 424 | o.planeRect = fullPlaneRect(o.Width(), o.Height()) | ||
| @@ -450,6 +454,9 @@ func (o *Output) modesetImage(fb uint32, active bool) error { | |||||
| 450 | return errSecurityScene | 454 | return errSecurityScene | ||
| 451 | } | 455 | } | ||
| 452 | if err := o.k.commit(req, atomicAllowModes, 0); err != nil { | 456 | if err := o.k.commit(req, atomicAllowModes, 0); err != nil { | ||
| 457 | + | if !o.protected && errors.Is(err, unix.ENOMEM) { | |||
| 458 | + | o.nomemModeset = true | |||
| 459 | + | } | |||
| 453 | _ = o.k.destroyBlob(blob) | 460 | _ = o.k.destroyBlob(blob) | ||
| 454 | if o.protected { | 461 | if o.protected { | ||
| 455 | return protectedCommitError{fmt.Errorf("modeset: %w", err)} | 462 | return protectedCommitError{fmt.Errorf("modeset: %w", err)} | ||
| @@ -458,6 +465,8 @@ func (o *Output) modesetImage(fb uint32, active bool) error { | |||||
| 458 | } | 465 | } | ||
| 459 | // Only now is nothing of ours pending or on screen: on failure the | 466 | // Only now is nothing of ours pending or on screen: on failure the | ||
| 460 | // previous buffers may still show. | 467 | // previous buffers may still show. | ||
| 468 | + | o.commitRecovered() | |||
| 469 | + | o.nomemModeset = false | |||
| 461 | o.frame.resetAfterModeset() | 470 | o.frame.resetAfterModeset() | ||
| 462 | // Plane scaling and colour pipeline capabilities may change after a | 471 | // Plane scaling and colour pipeline capabilities may change after a | ||
| 463 | // modeset or VT resume; the planes' colour state is not assumed. | 472 | // modeset or VT resume; the planes' colour state is not assumed. | ||
| @@ -514,6 +523,8 @@ func (o *Output) powerOff() error { | |||||
| 514 | if err := o.k.commit(req, atomicAllowModes, 0); err != nil { | 523 | if err := o.k.commit(req, atomicAllowModes, 0); err != nil { | ||
| 515 | return fmt.Errorf("power off: %w", err) | 524 | return fmt.Errorf("power off: %w", err) | ||
| 516 | } | 525 | } | ||
| 526 | + | o.commitRecovered() | |||
| 527 | + | o.nomemModeset = false | |||
| 517 | o.colorBypassed() | 528 | o.colorBypassed() | ||
| 518 | o.off, o.vrrGame = true, false | 529 | o.off, o.vrrGame = true, false | ||
| 519 | // No vblank to aim at: the modeset that turns it on commits the state. | 530 | // No vblank to aim at: the modeset that turns it on commits the state. | ||
| @@ -735,6 +746,10 @@ func (o *Output) commitRect(fb uint32, fence *os.File, async bool, vrr bool, f p | |||||
| 735 | return nil | 746 | return nil | ||
| 736 | } | 747 | } | ||
| 737 | } | 748 | } | ||
| 749 | + | if o.protected && errors.Is(err, unix.ENOMEM) { | |||
| 750 | + | o.invalidateSecurity() | |||
| 751 | + | return protectedCommitError{err} | |||
| 752 | + | } | |||
| 738 | return err | 753 | return err | ||
| 739 | } | 754 | } | ||
| 740 | if !async { | 755 | if !async { | ||
| @@ -767,6 +782,7 @@ func (o *Output) commitRect(fb uint32, fence *os.File, async bool, vrr bool, f p | |||||
| 767 | f.fenceReady = signalled(f.fences) | 782 | f.fenceReady = signalled(f.fences) | ||
| 768 | } | 783 | } | ||
| 769 | o.setAsync(async) | 784 | o.setAsync(async) | ||
| 785 | + | o.commitRecovered() | |||
| 770 | o.frame.begin(f, time.Now()) | 786 | o.frame.begin(f, time.Now()) | ||
| 771 | return nil | 787 | return nil | ||
| 772 | } | 788 | } | ||
| @@ -884,6 +900,7 @@ func (o *Output) commitState(vrr bool) error { | |||||
| 884 | o.setVRR(vrr) | 900 | o.setVRR(vrr) | ||
| 885 | o.contentValue = o.contentWanted | 901 | o.contentValue = o.contentWanted | ||
| 886 | o.flipStats.stateCommits++ | 902 | o.flipStats.stateCommits++ | ||
| 903 | + | o.commitRecovered() | |||
| 887 | o.frame.begin(pendingFrame{}, time.Now()) | 904 | o.frame.begin(pendingFrame{}, time.Now()) | ||
| 888 | return nil | 905 | return nil | ||
| 889 | } | 906 | } | ||
| @@ -937,6 +954,12 @@ func (o *Output) commitScanoutRect(fb uint32, c ports.SurfaceContent, f pendingF | |||||
| 937 | cfb.noAsync = true | 954 | cfb.noAsync = true | ||
| 938 | err = o.commitRect(fb, fence, false, vrr, f, overlayWin{}, rect, pc) | 955 | err = o.commitRect(fb, fence, false, vrr, f, overlayWin{}, rect, pc) | ||
| 939 | } | 956 | } | ||
| 957 | + | if errors.Is(err, unix.ENOMEM) && !retryableProtected(err) { | |||
| 958 | + | o.scanoutPausedUntil = time.Now().Add(nomemScanoutPause) | |||
| 959 | + | o.setScanoutReason("nomem") | |||
| 960 | + | o.renderLog.Info().Err(err).Str("connector", o.conn.name).Msg("scanout pin failed; composing") | |||
| 961 | + | return false, nil | |||
| 962 | + | } | |||
| 940 | if errors.Is(err, errColorRefused) { | 963 | if errors.Is(err, errColorRefused) { | ||
| 941 | // KMS takes the buffer without the pipeline: compose it; the | 964 | // KMS takes the buffer without the pipeline: compose it; the | ||
| 942 | // refusal is cached, the buffer is not marked failed. | 965 | // refusal is cached, the buffer is not marked failed. | ||
| @@ -1102,13 +1125,13 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1102 | // While switched away they are made on the first enable. | 1125 | // While switched away they are made on the first enable. | ||
| 1103 | if enabled { | 1126 | if enabled { | ||
| 1104 | if err := o.showImages(r, imagesDriver, nil); err != nil { | 1127 | if err := o.showImages(r, imagesDriver, nil); err != nil { | ||
| 1105 | - | // While protected, a recoverable KMS failure keeps the output | 1128 | + | // Protected preparation and a memory-refused modeset keep the |
| 1106 | - | // registered and dark; the loop retries with bounded backoff. | 1129 | + | // output registered; the loop retries with capped backoff. |
| 1107 | - | if !o.protected || !o.commitFailed(err, &enabled) { | 1130 | + | if !o.protected && !o.nomemModeset || !o.commitFailed(err, &enabled) { |
| 1108 | return err | 1131 | return err | ||
| 1109 | } | 1132 | } | ||
| 1110 | } | 1133 | } | ||
| 1111 | - | if !o.protected { | 1134 | + | if !o.protected && !o.frame.pendingCommit() { |
| 1112 | o.probeAsync(r) | 1135 | o.probeAsync(r) | ||
| 1113 | } | 1136 | } | ||
| 1114 | } | 1137 | } | ||
| @@ -1557,7 +1580,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1557 | } | 1580 | } | ||
| 1558 | // Output power: off waits for no commit in flight; on is a modeset | 1581 | // Output power: off waits for no commit in flight; on is a modeset | ||
| 1559 | // and a full frame. | 1582 | // and a full frame. | ||
| 1560 | - | if o.wantOff != o.off { | 1583 | + | if o.wantOff != o.off || o.nomemModeset { |
| 1561 | var err error | 1584 | var err error | ||
| 1562 | if o.wantOff { | 1585 | if o.wantOff { | ||
| 1563 | err = o.powerOff() | 1586 | err = o.powerOff() | ||
| @@ -1567,8 +1590,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1567 | o.probeAsync(r) // skipped while it started off | 1590 | o.probeAsync(r) // skipped while it started off | ||
| 1568 | } | 1591 | } | ||
| 1569 | } | 1592 | } | ||
| 1570 | - | if err != nil && !o.commitFailed(err, &enabled) { | 1593 | + | if err != nil { |
| 1571 | - | return err | 1594 | + | if !o.commitFailed(err, &enabled) { |
| 1595 | + | return err | |||
| 1596 | + | } | |||
| 1597 | + | continue | |||
| 1572 | } | 1598 | } | ||
| 1573 | } | 1599 | } | ||
| 1574 | if o.off { | 1600 | if o.off { | ||
| @@ -1634,9 +1660,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1634 | } | 1660 | } | ||
| 1635 | } | 1661 | } | ||
| 1636 | 1662 | ||||
| 1637 | - | // commitFailed handles a commit error that does not stop the output: a | 1663 | + | // commitFailed handles refusals that keep the output: busy or memory |
| 1638 | - | // commit still in flight (EBUSY, retried after its event) or DRM master | 1664 | + | // pressure (retried), protected preparation, or DRM master lost mid-switch |
| 1639 | - | // lost mid-switch (the seat disable follows). | 1665 | + | // (the seat disable follows). |
| 1640 | func (o *Output) commitFailed(err error, enabled *bool) bool { | 1666 | func (o *Output) commitFailed(err error, enabled *bool) bool { | ||
| 1641 | switch { | 1667 | switch { | ||
| 1642 | case errors.Is(err, errSecurityScene), errors.Is(err, errProtectionPending): | 1668 | case errors.Is(err, errSecurityScene), errors.Is(err, errProtectionPending): | ||
| @@ -1657,6 +1683,20 @@ func (o *Output) commitFailed(err error, enabled *bool) bool { | |||||
| 1657 | o.deferProtection() | 1683 | o.deferProtection() | ||
| 1658 | o.log.Warn().Err(err).Str("connector", o.conn.name).Dur("retry_in", o.protectBackoff).Msg("protected commit refused; output stays protected, retrying") | 1684 | o.log.Warn().Err(err).Str("connector", o.conn.name).Dur("retry_in", o.protectBackoff).Msg("protected commit refused; output stays protected, retrying") | ||
| 1659 | return true | 1685 | return true | ||
| 1686 | + | case errors.Is(err, unix.ENOMEM): | |||
| 1687 | + | now := time.Now() | |||
| 1688 | + | previous := o.frame.lastNomem | |||
| 1689 | + | attempt, newRun := o.frame.nomem(now) | |||
| 1690 | + | age := now.Sub(o.frame.nomemSince) | |||
| 1691 | + | switch { | |||
| 1692 | + | case newRun: | |||
| 1693 | + | o.log.Warn().Err(err).Str("connector", o.conn.name).Dur("retry_in", o.frame.nomemBackoff).Msg("commit refused for memory; keeping last frame, retrying") | |||
| 1694 | + | case previous.Sub(o.frame.nomemSince) < nomemWarnAfter && age >= nomemWarnAfter: | |||
| 1695 | + | o.log.Warn().Str("connector", o.conn.name).Int("attempts", attempt).Dur("age", age).Msg("commit still refused for memory; keeping last frame, retrying") | |||
| 1696 | + | default: | |||
| 1697 | + | o.log.Debug().Str("connector", o.conn.name).Int("attempts", attempt).Dur("age", age).Dur("retry_in", o.frame.nomemBackoff).Msg("commit refused for memory") | |||
| 1698 | + | } | |||
| 1699 | + | return true | |||
| 1660 | case lostMaster(err): | 1700 | case lostMaster(err): | ||
| 1661 | o.observeSecurity() | 1701 | o.observeSecurity() | ||
| 1662 | if o.protected { | 1702 | if o.protected { | ||
| @@ -1670,6 +1710,13 @@ func (o *Output) commitFailed(err error, enabled *bool) bool { | |||||
| 1670 | return false | 1710 | return false | ||
| 1671 | } | 1711 | } | ||
| 1672 | 1712 | ||||
| 1713 | + | // commitRecovered reports only an accepted commit, never a refused frame. | |||
| 1714 | + | func (o *Output) commitRecovered() { | |||
| 1715 | + | if attempts, outage := o.frame.nomemRecovered(time.Now()); attempts > 0 { | |||
| 1716 | + | o.log.Info().Str("connector", o.conn.name).Int("attempts", attempts).Dur("outage", outage).Msg("commit recovered") | |||
| 1717 | + | } | |||
| 1718 | + | } | |||
| 1719 | + | ||||
| 1673 | // flipDone times the completion ev of the commit f made at start: the | 1720 | // flipDone times the completion ev of the commit f made at start: the | ||
| 1674 | // flip stats, the flip trace and the VRR flip gap. It never allocates | 1721 | // flip stats, the flip trace and the VRR flip gap. It never allocates | ||
| 1675 | // with tracing off. | 1722 | // with tracing off. | ||
M internal/adapters/drm/scanout.go +10 -0
| @@ -335,6 +335,11 @@ type frameDecision struct { | |||
| 335 | 335 | // composeFrame chooses overlay or composition after direct scanout cannot commit. | |
| 336 | 336 | // The caller owns the returned overlay fence. | |
| 337 | 337 | func (o *Output) composeFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) frameDecision { | |
| 338 | + | if time.Now().Before(o.scanoutPausedUntil) { | |
| 339 | + | o.setScanoutReason("nomem") | |
| 340 | + | o.setOverlayReason("nomem") | |
| 341 | + | return frameDecision{composed: s} | |
| 342 | + | } | |
| 338 | 343 | ov, rest := o.overlayFrame(s, surfaces) | |
| 339 | 344 | if ov.fb != 0 { | |
| 340 | 345 | return frameDecision{overlay: ov, composed: rest} | |
| @@ -342,6 +347,8 @@ func (o *Output) composeFrame(s ports.Scene, surfaces map[ports.WindowID]ports.S | |||
| 342 | 347 | return frameDecision{composed: s} | |
| 343 | 348 | } | |
| 344 | 349 | ||
| 350 | + | const nomemScanoutPause = 2 * time.Second | |
| 351 | + | ||
| 345 | 352 | // decideFrame tries direct scanout, then overlay, then composition. | |
| 346 | 353 | // Captures require composition; the caller owns the returned overlay fence. | |
| 347 | 354 | func (o *Output) decideFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, capture bool) frameDecision { | |
| @@ -350,6 +357,9 @@ func (o *Output) decideFrame(s ports.Scene, surfaces map[ports.WindowID]ports.Su | |||
| 350 | 357 | o.setOverlayReason("capture") | |
| 351 | 358 | return frameDecision{composed: s} | |
| 352 | 359 | } | |
| 360 | + | if time.Now().Before(o.scanoutPausedUntil) { | |
| 361 | + | return o.composeFrame(s, surfaces) | |
| 362 | + | } | |
| 353 | 363 | fb, c, mode := o.scanoutFrame(s, surfaces) | |
| 354 | 364 | if fb != 0 { | |
| 355 | 365 | return frameDecision{fb: fb, content: c, rect: scanoutRect(c, o.Width(), o.Height()), color: mode} | |
| @@ -335,6 +335,11 @@ type frameDecision struct { | |||||
| 335 | // composeFrame chooses overlay or composition after direct scanout cannot commit. | 335 | // composeFrame chooses overlay or composition after direct scanout cannot commit. | ||
| 336 | // The caller owns the returned overlay fence. | 336 | // The caller owns the returned overlay fence. | ||
| 337 | func (o *Output) composeFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) frameDecision { | 337 | func (o *Output) composeFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) frameDecision { | ||
| 338 | + | if time.Now().Before(o.scanoutPausedUntil) { | |||
| 339 | + | o.setScanoutReason("nomem") | |||
| 340 | + | o.setOverlayReason("nomem") | |||
| 341 | + | return frameDecision{composed: s} | |||
| 342 | + | } | |||
| 338 | ov, rest := o.overlayFrame(s, surfaces) | 343 | ov, rest := o.overlayFrame(s, surfaces) | ||
| 339 | if ov.fb != 0 { | 344 | if ov.fb != 0 { | ||
| 340 | return frameDecision{overlay: ov, composed: rest} | 345 | return frameDecision{overlay: ov, composed: rest} | ||
| @@ -342,6 +347,8 @@ func (o *Output) composeFrame(s ports.Scene, surfaces map[ports.WindowID]ports.S | |||||
| 342 | return frameDecision{composed: s} | 347 | return frameDecision{composed: s} | ||
| 343 | } | 348 | } | ||
| 344 | 349 | ||||
| 350 | + | const nomemScanoutPause = 2 * time.Second | |||
| 351 | + | ||||
| 345 | // decideFrame tries direct scanout, then overlay, then composition. | 352 | // decideFrame tries direct scanout, then overlay, then composition. | ||
| 346 | // Captures require composition; the caller owns the returned overlay fence. | 353 | // Captures require composition; the caller owns the returned overlay fence. | ||
| 347 | func (o *Output) decideFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, capture bool) frameDecision { | 354 | func (o *Output) decideFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, capture bool) frameDecision { | ||
| @@ -350,6 +357,9 @@ func (o *Output) decideFrame(s ports.Scene, surfaces map[ports.WindowID]ports.Su | |||||
| 350 | o.setOverlayReason("capture") | 357 | o.setOverlayReason("capture") | ||
| 351 | return frameDecision{composed: s} | 358 | return frameDecision{composed: s} | ||
| 352 | } | 359 | } | ||
| 360 | + | if time.Now().Before(o.scanoutPausedUntil) { | |||
| 361 | + | return o.composeFrame(s, surfaces) | |||
| 362 | + | } | |||
| 353 | fb, c, mode := o.scanoutFrame(s, surfaces) | 363 | fb, c, mode := o.scanoutFrame(s, surfaces) | ||
| 354 | if fb != 0 { | 364 | if fb != 0 { | ||
| 355 | return frameDecision{fb: fb, content: c, rect: scanoutRect(c, o.Width(), o.Height()), color: mode} | 365 | return frameDecision{fb: fb, content: c, rect: scanoutRect(c, o.Width(), o.Height()), color: mode} | ||
M internal/adapters/drm/stuck_test.go +13 -1
| @@ -33,6 +33,9 @@ type stuckRun struct { | |||
| 33 | 33 | contents *contentFeed | |
| 34 | 34 | active chan bool | |
| 35 | 35 | presented chan ports.OutputPresented | |
| 36 | + | flipped chan flipEvent | |
| 37 | + | done <-chan error | |
| 38 | + | snapshot func() []commitRec | |
| 36 | 39 | sink ports.PresentationSink | |
| 37 | 40 | count func() int | |
| 38 | 41 | stop func() []commitRec | |
| @@ -46,7 +49,8 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun { | |||
| 46 | 49 | o, k, commits, commitMu := testOutputMu(t, append([]error{nil, nil}, errs...)...) | |
| 47 | 50 | o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{} | |
| 48 | 51 | o.frame.stuckAfter = 200 * time.Millisecond | |
| 49 | - | o.flipped = make(chan flipEvent) // no event ever | |
| 52 | + | flipped := make(chan flipEvent) | |
| 53 | + | o.flipped = flipped // tests send events explicitly | |
| 50 | 54 | r := portsmocks.NewMockRenderer(t) | |
| 51 | 55 | buf := func() ports.DMABuf { | |
| 52 | 56 | f, w, _ := os.Pipe() | |
| @@ -64,6 +68,8 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun { | |||
| 64 | 68 | return render(), nil | |
| 65 | 69 | }) | |
| 66 | 70 | r.EXPECT().Close().Return().Once() | |
| 71 | + | r.EXPECT().Trim(mock.Anything).Return(nil).Maybe() | |
| 72 | + | r.EXPECT().TakeRedrawn().Return(0).Maybe() | |
| 67 | 73 | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice() | |
| 68 | 74 | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | |
| 69 | 75 | k.EXPECT().destroyBlob(mock.Anything).Return(nil) | |
| @@ -72,6 +78,12 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun { | |||
| 72 | 78 | h.sink, h.presented = presentedSink(t, 64) | |
| 73 | 79 | ctx, cancel := context.WithCancel(context.Background()) | |
| 74 | 80 | done := make(chan error, 1) | |
| 81 | + | h.done, h.flipped = done, flipped | |
| 82 | + | h.snapshot = func() []commitRec { | |
| 83 | + | commitMu.Lock() | |
| 84 | + | defer commitMu.Unlock() | |
| 85 | + | return append([]commitRec(nil), (*commits)...) | |
| 86 | + | } | |
| 75 | 87 | go func() { | |
| 76 | 88 | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, h.active, h.scenes, h.contents.in(), h.contents.ret, nil, h.sink, nil, nil) | |
| 77 | 89 | }() | |
| @@ -33,6 +33,9 @@ type stuckRun struct { | |||||
| 33 | contents *contentFeed | 33 | contents *contentFeed | ||
| 34 | active chan bool | 34 | active chan bool | ||
| 35 | presented chan ports.OutputPresented | 35 | presented chan ports.OutputPresented | ||
| 36 | + | flipped chan flipEvent | |||
| 37 | + | done <-chan error | |||
| 38 | + | snapshot func() []commitRec | |||
| 36 | sink ports.PresentationSink | 39 | sink ports.PresentationSink | ||
| 37 | count func() int | 40 | count func() int | ||
| 38 | stop func() []commitRec | 41 | stop func() []commitRec | ||
| @@ -46,7 +49,8 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun { | |||||
| 46 | o, k, commits, commitMu := testOutputMu(t, append([]error{nil, nil}, errs...)...) | 49 | o, k, commits, commitMu := testOutputMu(t, append([]error{nil, nil}, errs...)...) | ||
| 47 | o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{} | 50 | o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{} | ||
| 48 | o.frame.stuckAfter = 200 * time.Millisecond | 51 | o.frame.stuckAfter = 200 * time.Millisecond | ||
| 49 | - | o.flipped = make(chan flipEvent) // no event ever | 52 | + | flipped := make(chan flipEvent) |
| 53 | + | o.flipped = flipped // tests send events explicitly | |||
| 50 | r := portsmocks.NewMockRenderer(t) | 54 | r := portsmocks.NewMockRenderer(t) | ||
| 51 | buf := func() ports.DMABuf { | 55 | buf := func() ports.DMABuf { | ||
| 52 | f, w, _ := os.Pipe() | 56 | f, w, _ := os.Pipe() | ||
| @@ -64,6 +68,8 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun { | |||||
| 64 | return render(), nil | 68 | return render(), nil | ||
| 65 | }) | 69 | }) | ||
| 66 | r.EXPECT().Close().Return().Once() | 70 | r.EXPECT().Close().Return().Once() | ||
| 71 | + | r.EXPECT().Trim(mock.Anything).Return(nil).Maybe() | |||
| 72 | + | r.EXPECT().TakeRedrawn().Return(0).Maybe() | |||
| 67 | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice() | 73 | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice() | ||
| 68 | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | 74 | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | ||
| 69 | k.EXPECT().destroyBlob(mock.Anything).Return(nil) | 75 | k.EXPECT().destroyBlob(mock.Anything).Return(nil) | ||
| @@ -72,6 +78,12 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun { | |||||
| 72 | h.sink, h.presented = presentedSink(t, 64) | 78 | h.sink, h.presented = presentedSink(t, 64) | ||
| 73 | ctx, cancel := context.WithCancel(context.Background()) | 79 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 74 | done := make(chan error, 1) | 80 | done := make(chan error, 1) | ||
| 81 | + | h.done, h.flipped = done, flipped | |||
| 82 | + | h.snapshot = func() []commitRec { | |||
| 83 | + | commitMu.Lock() | |||
| 84 | + | defer commitMu.Unlock() | |||
| 85 | + | return append([]commitRec(nil), (*commits)...) | |||
| 86 | + | } | |||
| 75 | go func() { | 87 | go func() { | ||
| 76 | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, h.active, h.scenes, h.contents.in(), h.contents.ret, nil, h.sink, nil, nil) | 88 | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, h.active, h.scenes, h.contents.in(), h.contents.ret, nil, h.sink, nil, nil) | ||
| 77 | }() | 89 | }() | ||