detached d707400

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
2121 stuckAt time.Time
2222 fenceWarned bool
2323 busySince, lastBusy time.Time
24+ nomemSince, lastNomem time.Time
25+ nomemBackoff time.Duration
26+ nomemAttempts int
2427 stuckTimer *time.Timer
2528 }
2629
@@ -66,8 +69,11 @@ const (
6669 )
6770
6871 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
7177 )
7278
7379 // stuckLimit returns the configured recovery deadline or its default.
@@ -106,6 +112,32 @@ func (l *frameLifecycle) pendingCommit() bool { return l.pending }
106112 func (l *frameLifecycle) resetAfterModeset() {
107113 l.endPending()
108114 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
109141 }
110142
111143 // deadlineReached reports whether a pending commit is ready for recovery.
@@ -144,7 +176,7 @@ func (l *frameLifecycle) ours(user uint64) bool {
144176 // EBUSY there is no known serial, so any CRTC event ends that wait. The
145177 // returned start lets Output measure flip age after pending fences close.
146178 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 {
148180 return pendingFrame{}, time.Time{}, false
149181 }
150182 f, start := l.pendingFrame, l.flipStart
@@ -183,6 +215,9 @@ func (l *frameLifecycle) timeout(now time.Time, fencesReady bool) timeoutResult
183215 if l.pendingSerial != 0 {
184216 return timeoutMissing
185217 }
218+ if !l.nomemSince.IsZero() {
219+ return timeoutRetry
220+ }
186221 if l.busySince.IsZero() || now.Sub(l.busySince) < l.stuckLimit() {
187222 return timeoutRetry
188223 }
@@ -21,6 +21,9 @@ type frameLifecycle struct {
21 stuckAt time.Time21 stuckAt time.Time
22 fenceWarned bool22 fenceWarned bool
23 busySince, lastBusy time.Time23 busySince, lastBusy time.Time
24+ nomemSince, lastNomem time.Time
25+ nomemBackoff time.Duration
26+ nomemAttempts int
24 stuckTimer *time.Timer27 stuckTimer *time.Timer
25 }28 }
26 29
@@ -66,8 +69,11 @@ const (
66 )69 )
67 70
68 const (71 const (
69- stuckTimeout = time.Second72+ stuckTimeout = time.Second
70- busyRetry = 50 * time.Millisecond73+ 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. The176 // 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{}, false180 return pendingFrame{}, time.Time{}, false
149 }181 }
150 f, start := l.pendingFrame, l.flipStart182 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 timeoutMissing216 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 timeoutRetry222 return timeoutRetry
188 }223 }
M internal/adapters/drm/frame_lifecycle_test.go +29 -0
@@ -95,3 +95,32 @@ func TestFrameLifecycleTransitionsAllocations(t *testing.T) {
9595 t.Fatalf("frame lifecycle: %.1f allocs, want 0", allocs)
9696 }
9797 }
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 {
109109 readFences []*os.File
110110 // Direct scanout: client framebuffers by DMABuf ID and the buffer on
111111 // 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
118120 // Last immutable shows snapshot sent in a flip. Never mutate it: wayland
119121 // may still be reading a prior report on another goroutine.
120122 shows surfaces.ShowsSnapshot
@@ -415,6 +417,8 @@ func (o *Output) modesetImage(fb uint32, active bool) error {
415417 if err := o.disableProtected(); err != nil {
416418 return err
417419 }
420+ o.commitRecovered()
421+ o.nomemModeset = false
418422 o.frame.resetAfterModeset()
419423 o.forgetColor()
420424 o.planeRect = fullPlaneRect(o.Width(), o.Height())
@@ -450,6 +454,9 @@ func (o *Output) modesetImage(fb uint32, active bool) error {
450454 return errSecurityScene
451455 }
452456 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
457+ if !o.protected && errors.Is(err, unix.ENOMEM) {
458+ o.nomemModeset = true
459+ }
453460 _ = o.k.destroyBlob(blob)
454461 if o.protected {
455462 return protectedCommitError{fmt.Errorf("modeset: %w", err)}
@@ -458,6 +465,8 @@ func (o *Output) modesetImage(fb uint32, active bool) error {
458465 }
459466 // Only now is nothing of ours pending or on screen: on failure the
460467 // previous buffers may still show.
468+ o.commitRecovered()
469+ o.nomemModeset = false
461470 o.frame.resetAfterModeset()
462471 // Plane scaling and colour pipeline capabilities may change after a
463472 // modeset or VT resume; the planes' colour state is not assumed.
@@ -514,6 +523,8 @@ func (o *Output) powerOff() error {
514523 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
515524 return fmt.Errorf("power off: %w", err)
516525 }
526+ o.commitRecovered()
527+ o.nomemModeset = false
517528 o.colorBypassed()
518529 o.off, o.vrrGame = true, false
519530 // 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
735746 return nil
736747 }
737748 }
749+ if o.protected && errors.Is(err, unix.ENOMEM) {
750+ o.invalidateSecurity()
751+ return protectedCommitError{err}
752+ }
738753 return err
739754 }
740755 if !async {
@@ -767,6 +782,7 @@ func (o *Output) commitRect(fb uint32, fence *os.File, async bool, vrr bool, f p
767782 f.fenceReady = signalled(f.fences)
768783 }
769784 o.setAsync(async)
785+ o.commitRecovered()
770786 o.frame.begin(f, time.Now())
771787 return nil
772788 }
@@ -884,6 +900,7 @@ func (o *Output) commitState(vrr bool) error {
884900 o.setVRR(vrr)
885901 o.contentValue = o.contentWanted
886902 o.flipStats.stateCommits++
903+ o.commitRecovered()
887904 o.frame.begin(pendingFrame{}, time.Now())
888905 return nil
889906 }
@@ -937,6 +954,12 @@ func (o *Output) commitScanoutRect(fb uint32, c ports.SurfaceContent, f pendingF
937954 cfb.noAsync = true
938955 err = o.commitRect(fb, fence, false, vrr, f, overlayWin{}, rect, pc)
939956 }
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+ }
940963 if errors.Is(err, errColorRefused) {
941964 // KMS takes the buffer without the pipeline: compose it; the
942965 // 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
11021125 // While switched away they are made on the first enable.
11031126 if enabled {
11041127 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) {
11081131 return err
11091132 }
11101133 }
1111- if !o.protected {
1134+ if !o.protected && !o.frame.pendingCommit() {
11121135 o.probeAsync(r)
11131136 }
11141137 }
@@ -1557,7 +1580,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
15571580 }
15581581 // Output power: off waits for no commit in flight; on is a modeset
15591582 // and a full frame.
1560- if o.wantOff != o.off {
1583+ if o.wantOff != o.off || o.nomemModeset {
15611584 var err error
15621585 if o.wantOff {
15631586 err = o.powerOff()
@@ -1567,8 +1590,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
15671590 o.probeAsync(r) // skipped while it started off
15681591 }
15691592 }
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
15721598 }
15731599 }
15741600 if o.off {
@@ -1634,9 +1660,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
16341660 }
16351661 }
16361662
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).
16401666 func (o *Output) commitFailed(err error, enabled *bool) bool {
16411667 switch {
16421668 case errors.Is(err, errSecurityScene), errors.Is(err, errProtectionPending):
@@ -1657,6 +1683,20 @@ func (o *Output) commitFailed(err error, enabled *bool) bool {
16571683 o.deferProtection()
16581684 o.log.Warn().Err(err).Str("connector", o.conn.name).Dur("retry_in", o.protectBackoff).Msg("protected commit refused; output stays protected, retrying")
16591685 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
16601700 case lostMaster(err):
16611701 o.observeSecurity()
16621702 if o.protected {
@@ -1670,6 +1710,13 @@ func (o *Output) commitFailed(err error, enabled *bool) bool {
16701710 return false
16711711 }
16721712
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+
16731720 // flipDone times the completion ev of the commit f made at start: the
16741721 // flip stats, the flip trace and the VRR flip gap. It never allocates
16751722 // with tracing off.
@@ -109,12 +109,14 @@ type Output struct {
109 readFences []*os.File109 readFences []*os.File
110 // Direct scanout: client framebuffers by DMABuf ID and the buffer on110 // 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 bool112+ scanout bool
113- clientFBs map[uint64]*clientFB113+ clientFBs map[uint64]*clientFB
114- shown, queued uint64114+ shown, queued uint64
115- planeRect planeRect115+ 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.Queue117+ 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: wayland120 // 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.ShowsSnapshot122 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 err418 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 errSecurityScene454 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 the466 // 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 a471 // 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, false529 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 nil746 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 err753 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 nil787 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.contentWanted901 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 nil905 return nil
889 }906 }
@@ -937,6 +954,12 @@ func (o *Output) commitScanoutRect(fb uint32, c ports.SurfaceContent, f pendingF
937 cfb.noAsync = true954 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; the964 // 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 output1128+ // 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 err1131 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 modeset1581 // 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 error1584 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 off1590 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 err1594+ 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: a1663+// commitFailed handles refusals that keep the output: busy or memory
1638-// commit still in flight (EBUSY, retried after its event) or DRM master1664+// 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 true1685 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 false1710 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: the1720 // 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 allocates1721 // 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 {
335335 // composeFrame chooses overlay or composition after direct scanout cannot commit.
336336 // The caller owns the returned overlay fence.
337337 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+ }
338343 ov, rest := o.overlayFrame(s, surfaces)
339344 if ov.fb != 0 {
340345 return frameDecision{overlay: ov, composed: rest}
@@ -342,6 +347,8 @@ func (o *Output) composeFrame(s ports.Scene, surfaces map[ports.WindowID]ports.S
342347 return frameDecision{composed: s}
343348 }
344349
350+const nomemScanoutPause = 2 * time.Second
351+
345352 // decideFrame tries direct scanout, then overlay, then composition.
346353 // Captures require composition; the caller owns the returned overlay fence.
347354 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
350357 o.setOverlayReason("capture")
351358 return frameDecision{composed: s}
352359 }
360+ if time.Now().Before(o.scanoutPausedUntil) {
361+ return o.composeFrame(s, surfaces)
362+ }
353363 fb, c, mode := o.scanoutFrame(s, surfaces)
354364 if fb != 0 {
355365 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 {
3333 contents *contentFeed
3434 active chan bool
3535 presented chan ports.OutputPresented
36+ flipped chan flipEvent
37+ done <-chan error
38+ snapshot func() []commitRec
3639 sink ports.PresentationSink
3740 count func() int
3841 stop func() []commitRec
@@ -46,7 +49,8 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun {
4649 o, k, commits, commitMu := testOutputMu(t, append([]error{nil, nil}, errs...)...)
4750 o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{}
4851 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
5054 r := portsmocks.NewMockRenderer(t)
5155 buf := func() ports.DMABuf {
5256 f, w, _ := os.Pipe()
@@ -64,6 +68,8 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun {
6468 return render(), nil
6569 })
6670 r.EXPECT().Close().Return().Once()
71+ r.EXPECT().Trim(mock.Anything).Return(nil).Maybe()
72+ r.EXPECT().TakeRedrawn().Return(0).Maybe()
6773 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
6874 k.EXPECT().createBlob(mock.Anything).Return(99, nil)
6975 k.EXPECT().destroyBlob(mock.Anything).Return(nil)
@@ -72,6 +78,12 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun {
7278 h.sink, h.presented = presentedSink(t, 64)
7379 ctx, cancel := context.WithCancel(context.Background())
7480 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+ }
7587 go func() {
7688 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)
7789 }()
@@ -33,6 +33,9 @@ type stuckRun struct {
33 contents *contentFeed33 contents *contentFeed
34 active chan bool34 active chan bool
35 presented chan ports.OutputPresented35 presented chan ports.OutputPresented
36+ flipped chan flipEvent
37+ done <-chan error
38+ snapshot func() []commitRec
36 sink ports.PresentationSink39 sink ports.PresentationSink
37 count func() int40 count func() int
38 stop func() []commitRec41 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.Millisecond51 o.frame.stuckAfter = 200 * time.Millisecond
49- o.flipped = make(chan flipEvent) // no event ever52+ 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(), nil68 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 }()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close