detached 18cc6d4

fix(drm): retry an enable modeset refused before DRM master returns

On a VT switch back, the seat can report the session active before the
kernel gives DRM master back. The enable modeset then fails with EACCES,
the output is marked disabled and nothing retries it: the screen stays
frozen until the next VT switch.

Retry the enable on a bounded backoff (20 ms doubling to 1 s, at most
40 attempts) while the seat stays active. A new seat change or a
successful enable ends the retry. The seat-change handling moves into
one setActive helper shared by both paths.
commit
18cc6d48a74d1befeb077a352cf40f004d91c2cd
author
brice · · 2026-10-10 14:11 UTC
committer
brice · · 2026-10-10 15:39 UTC
parent
3305370
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +232 -35
M internal/adapters/drm/output.go +77 -35
@@ -1098,6 +1098,57 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10981098 cursorWake = o.cursor.wake
10991099 }
11001100 securityChanges := o.SecurityChanges
1101+ reacquireTimer := time.NewTimer(time.Hour)
1102+ reacquireTimer.Stop()
1103+ defer reacquireTimer.Stop()
1104+ var reacquire masterRetry
1105+ // setActive applies a seat change. skip reports that the loop must
1106+ // go on without touching the frame state (refused or protected).
1107+ setActive := func(on bool) (skip bool, err error) {
1108+ o.observeSecurity()
1109+ if o.protected {
1110+ o.invalidateSecurity()
1111+ enabled = on
1112+ if on && o.fbs[0] == 0 {
1113+ if err := o.showImages(r, imagesDriver, nil); err != nil {
1114+ if !o.commitFailed(err, &enabled) {
1115+ return true, err
1116+ }
1117+ reacquire.refused(err, &enabled)
1118+ return true, nil
1119+ }
1120+ }
1121+ reacquire.done()
1122+ return true, nil
1123+ }
1124+ // Modeset on every enable: a fast disable+enable can coalesce to one true.
1125+ if on {
1126+ enabled = true // this enable may supersede a previous master loss
1127+ var err error
1128+ if o.fbs[0] == 0 {
1129+ err = o.showImages(r, imagesDriver, nil)
1130+ } else {
1131+ err = o.modeset()
1132+ }
1133+ if err != nil {
1134+ if !o.commitFailed(err, &enabled) {
1135+ return true, err
1136+ }
1137+ reacquire.refused(err, &enabled)
1138+ return true, nil // epoch retry or wait disabled for master reacquisition
1139+ }
1140+ reacquire.done()
1141+ o.observeSecurity()
1142+ if o.protected {
1143+ return true, nil
1144+ }
1145+ o.probeAsync(r)
1146+ dirty = haveScene
1147+ }
1148+ enabled = on
1149+ o.log.Info().Bool("enabled", on).Msg("output")
1150+ return false, nil
1151+ }
11011152 for {
11021153 if o.observeSecurity() || haveScene && !o.sceneCurrent(scene) {
11031154 scene, haveScene, dirty = ports.Scene{}, false, false
@@ -1203,6 +1254,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12031254 } else {
12041255 protectTimer.Stop()
12051256 }
1257+ // An enable refused for lack of DRM master is retried on time.
1258+ var reacquireDue <-chan time.Time
1259+ if d, ok := reacquire.next(); ok {
1260+ reacquireTimer.Reset(d)
1261+ reacquireDue = reacquireTimer.C
1262+ } else {
1263+ reacquireTimer.Stop()
1264+ }
12061265 // A commit whose event never comes must not stop the output.
12071266 stuck := o.frame.wait(enabled)
12081267 stateDirty := false
@@ -1268,44 +1327,27 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12681327 ack <- struct{}{}
12691328 continue
12701329 case on := <-active:
1271- o.observeSecurity()
1272- if o.protected {
1273- o.invalidateSecurity()
1274- enabled = on
1275- if on && o.fbs[0] == 0 {
1276- if err := o.showImages(r, imagesDriver, nil); err != nil {
1277- if !o.commitFailed(err, &enabled) {
1278- return err
1279- }
1280- }
1281- }
1330+ reacquire.done()
1331+ skip, err := setActive(on)
1332+ if err != nil {
1333+ return err
1334+ }
1335+ if skip {
12821336 continue
12831337 }
1284- // Modeset on every enable: a fast disable+enable can coalesce to one true.
1285- if on {
1286- enabled = true // this enable may supersede a previous master loss
1287- var err error
1288- if o.fbs[0] == 0 {
1289- err = o.showImages(r, imagesDriver, nil)
1290- } else {
1291- err = o.modeset()
1292- }
1293- if err != nil {
1294- if !o.commitFailed(err, &enabled) {
1295- return err
1296- }
1297- continue // epoch retry or wait disabled for master reacquisition
1298- }
1299- o.observeSecurity()
1300- if o.protected {
1301- continue
1302- }
1303- o.probeAsync(r)
1304- dirty = haveScene
1305- stateDirty = true
1338+ stateDirty = on
1339+ case <-reacquireDue:
1340+ // The seat reported active before the kernel gave DRM master
1341+ // back: the enable modeset was refused. Try it again.
1342+ reacquire.attempts++
1343+ skip, err := setActive(true)
1344+ if err != nil {
1345+ return err
13061346 }
1307- enabled = on
1308- o.log.Info().Bool("enabled", on).Msg("output")
1347+ if skip {
1348+ continue
1349+ }
1350+ stateDirty = true
13091351 case ev := <-o.flipped:
13101352 o.observeSecurity()
13111353 if !o.completed(ev, seen) {
@@ -1098,6 +1098,57 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1098 cursorWake = o.cursor.wake1098 cursorWake = o.cursor.wake
1099 }1099 }
1100 securityChanges := o.SecurityChanges1100 securityChanges := o.SecurityChanges
1101+ reacquireTimer := time.NewTimer(time.Hour)
1102+ reacquireTimer.Stop()
1103+ defer reacquireTimer.Stop()
1104+ var reacquire masterRetry
1105+ // setActive applies a seat change. skip reports that the loop must
1106+ // go on without touching the frame state (refused or protected).
1107+ setActive := func(on bool) (skip bool, err error) {
1108+ o.observeSecurity()
1109+ if o.protected {
1110+ o.invalidateSecurity()
1111+ enabled = on
1112+ if on && o.fbs[0] == 0 {
1113+ if err := o.showImages(r, imagesDriver, nil); err != nil {
1114+ if !o.commitFailed(err, &enabled) {
1115+ return true, err
1116+ }
1117+ reacquire.refused(err, &enabled)
1118+ return true, nil
1119+ }
1120+ }
1121+ reacquire.done()
1122+ return true, nil
1123+ }
1124+ // Modeset on every enable: a fast disable+enable can coalesce to one true.
1125+ if on {
1126+ enabled = true // this enable may supersede a previous master loss
1127+ var err error
1128+ if o.fbs[0] == 0 {
1129+ err = o.showImages(r, imagesDriver, nil)
1130+ } else {
1131+ err = o.modeset()
1132+ }
1133+ if err != nil {
1134+ if !o.commitFailed(err, &enabled) {
1135+ return true, err
1136+ }
1137+ reacquire.refused(err, &enabled)
1138+ return true, nil // epoch retry or wait disabled for master reacquisition
1139+ }
1140+ reacquire.done()
1141+ o.observeSecurity()
1142+ if o.protected {
1143+ return true, nil
1144+ }
1145+ o.probeAsync(r)
1146+ dirty = haveScene
1147+ }
1148+ enabled = on
1149+ o.log.Info().Bool("enabled", on).Msg("output")
1150+ return false, nil
1151+ }
1101 for {1152 for {
1102 if o.observeSecurity() || haveScene && !o.sceneCurrent(scene) {1153 if o.observeSecurity() || haveScene && !o.sceneCurrent(scene) {
1103 scene, haveScene, dirty = ports.Scene{}, false, false1154 scene, haveScene, dirty = ports.Scene{}, false, false
@@ -1203,6 +1254,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1203 } else {1254 } else {
1204 protectTimer.Stop()1255 protectTimer.Stop()
1205 }1256 }
1257+ // An enable refused for lack of DRM master is retried on time.
1258+ var reacquireDue <-chan time.Time
1259+ if d, ok := reacquire.next(); ok {
1260+ reacquireTimer.Reset(d)
1261+ reacquireDue = reacquireTimer.C
1262+ } else {
1263+ reacquireTimer.Stop()
1264+ }
1206 // A commit whose event never comes must not stop the output.1265 // A commit whose event never comes must not stop the output.
1207 stuck := o.frame.wait(enabled)1266 stuck := o.frame.wait(enabled)
1208 stateDirty := false1267 stateDirty := false
@@ -1268,44 +1327,27 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1268 ack <- struct{}{}1327 ack <- struct{}{}
1269 continue1328 continue
1270 case on := <-active:1329 case on := <-active:
1271- o.observeSecurity()1330+ reacquire.done()
1272- if o.protected {1331+ skip, err := setActive(on)
1273- o.invalidateSecurity()1332+ if err != nil {
1274- enabled = on1333+ return err
1275- if on && o.fbs[0] == 0 {1334+ }
1276- if err := o.showImages(r, imagesDriver, nil); err != nil {1335+ if skip {
1277- if !o.commitFailed(err, &enabled) {
1278- return err
1279- }
1280- }
1281- }
1282 continue1336 continue
1283 }1337 }
1284- // Modeset on every enable: a fast disable+enable can coalesce to one true.1338+ stateDirty = on
1285- if on {1339+ case <-reacquireDue:
1286- enabled = true // this enable may supersede a previous master loss1340+ // The seat reported active before the kernel gave DRM master
1287- var err error1341+ // back: the enable modeset was refused. Try it again.
1288- if o.fbs[0] == 0 {1342+ reacquire.attempts++
1289- err = o.showImages(r, imagesDriver, nil)1343+ skip, err := setActive(true)
1290- } else {1344+ if err != nil {
1291- err = o.modeset()1345+ return err
1292- }
1293- if err != nil {
1294- if !o.commitFailed(err, &enabled) {
1295- return err
1296- }
1297- continue // epoch retry or wait disabled for master reacquisition
1298- }
1299- o.observeSecurity()
1300- if o.protected {
1301- continue
1302- }
1303- o.probeAsync(r)
1304- dirty = haveScene
1305- stateDirty = true
1306 }1346 }
1307- enabled = on1347+ if skip {
1308- o.log.Info().Bool("enabled", on).Msg("output")1348+ continue
1349+ }
1350+ stateDirty = true
1309 case ev := <-o.flipped:1351 case ev := <-o.flipped:
1310 o.observeSecurity()1352 o.observeSecurity()
1311 if !o.completed(ev, seen) {1353 if !o.completed(ev, seen) {
A internal/adapters/drm/reacquire.go +39 -0
@@ -0,0 +1,39 @@
1+package drm
2+
3+import "time"
4+
5+// masterRetry retries an enable modeset that the kernel refused because
6+// DRM master was not back yet. The seat can report the session active
7+// before the kernel hands master back; without a retry the output stays
8+// dark until the next VT switch.
9+type masterRetry struct {
10+ pending bool
11+ attempts int
12+}
13+
14+const (
15+ masterRetryFirst = 20 * time.Millisecond
16+ masterRetryMax = time.Second
17+ // masterRetryLimit bounds the retries of one enable (about 30 s).
18+ masterRetryLimit = 40
19+)
20+
21+// refused records an enable refused with err; only a lost master is
22+// retried, and enabled stays false until a retry succeeds.
23+func (m *masterRetry) refused(err error, enabled *bool) {
24+ if !lostMaster(err) || *enabled {
25+ return
26+ }
27+ m.pending = true
28+}
29+
30+// done ends the retry: the enable succeeded or the seat changed again.
31+func (m *masterRetry) done() { *m = masterRetry{} }
32+
33+// next is the delay before the next attempt, if one is due.
34+func (m *masterRetry) next() (time.Duration, bool) {
35+ if !m.pending || m.attempts >= masterRetryLimit {
36+ return 0, false
37+ }
38+ return min(masterRetryFirst<<min(m.attempts, 6), masterRetryMax), true
39+}
@@ -0,0 +1,39 @@
1+package drm
2+
3+import "time"
4+
5+// masterRetry retries an enable modeset that the kernel refused because
6+// DRM master was not back yet. The seat can report the session active
7+// before the kernel hands master back; without a retry the output stays
8+// dark until the next VT switch.
9+type masterRetry struct {
10+ pending bool
11+ attempts int
12+}
13+
14+const (
15+ masterRetryFirst = 20 * time.Millisecond
16+ masterRetryMax = time.Second
17+ // masterRetryLimit bounds the retries of one enable (about 30 s).
18+ masterRetryLimit = 40
19+)
20+
21+// refused records an enable refused with err; only a lost master is
22+// retried, and enabled stays false until a retry succeeds.
23+func (m *masterRetry) refused(err error, enabled *bool) {
24+ if !lostMaster(err) || *enabled {
25+ return
26+ }
27+ m.pending = true
28+}
29+
30+// done ends the retry: the enable succeeded or the seat changed again.
31+func (m *masterRetry) done() { *m = masterRetry{} }
32+
33+// next is the delay before the next attempt, if one is due.
34+func (m *masterRetry) next() (time.Duration, bool) {
35+ if !m.pending || m.attempts >= masterRetryLimit {
36+ return 0, false
37+ }
38+ return min(masterRetryFirst<<min(m.attempts, 6), masterRetryMax), true
39+}
A internal/adapters/drm/reacquire_test.go +116 -0
@@ -0,0 +1,116 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "testing/synctest"
8+ "time"
9+
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/stretchr/testify/mock"
13+ "golang.org/x/sys/unix"
14+)
15+
16+func TestMasterRetryBackoff(t *testing.T) {
17+ var m masterRetry
18+ enabled := false
19+ if _, ok := m.next(); ok {
20+ t.Fatal("retry due before a refusal")
21+ }
22+ m.refused(unix.EBUSY, &enabled)
23+ if _, ok := m.next(); ok {
24+ t.Fatal("a busy refusal is retried by the busy path, not here")
25+ }
26+ m.refused(unix.EACCES, &enabled)
27+ d, ok := m.next()
28+ if !ok || d != masterRetryFirst {
29+ t.Fatalf("first retry %v %v", d, ok)
30+ }
31+ m.attempts = 30
32+ if d, _ := m.next(); d != masterRetryMax {
33+ t.Fatalf("capped retry %v", d)
34+ }
35+ m.attempts = masterRetryLimit
36+ if _, ok := m.next(); ok {
37+ t.Fatal("retries past the limit")
38+ }
39+ m.done()
40+ if _, ok := m.next(); ok {
41+ t.Fatal("retry after done")
42+ }
43+}
44+
45+// The seat reports active before the kernel gives DRM master back: the
46+// enable modeset is refused, then retried until it succeeds, without a
47+// second seat change.
48+func TestEnableRefusedForMasterIsRetried(t *testing.T) {
49+ synctest.Test(t, enableRefusedForMasterIsRetried)
50+}
51+
52+func enableRefusedForMasterIsRetried(t *testing.T) {
53+ refuse := true
54+ o, k, commits, commitMu := testOutputRules(t, func(_ *atomicReq, flags uint32) error {
55+ if refuse && flags&atomicAllowModes != 0 && flags&atomicTestOnly == 0 {
56+ return unix.EACCES
57+ }
58+ return nil
59+ })
60+ o.cursor, o.tearing = nil, false
61+ r := portsmocks.NewMockRenderer(t)
62+ r.EXPECT().SetHDR(mock.Anything).Return().Maybe()
63+ r.EXPECT().UseTarget(mock.Anything).Return().Maybe()
64+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Maybe()
65+ r.EXPECT().Close().Return().Once()
66+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Maybe()
67+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
68+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
69+ active := make(chan bool, 1)
70+ active <- false
71+ ctx, cancel := context.WithCancel(context.Background())
72+ done := make(chan error, 1)
73+ go func() {
74+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, make(chan ports.OutputPresented, 1), nil, nil)
75+ }()
76+ modesets := func() int {
77+ commitMu.Lock()
78+ defer commitMu.Unlock()
79+ n := 0
80+ for _, c := range *commits {
81+ if c.flags&atomicAllowModes != 0 && c.flags&atomicTestOnly == 0 {
82+ n++
83+ }
84+ }
85+ return n
86+ }
87+ synctest.Wait()
88+ active <- true
89+ synctest.Wait()
90+ if n := modesets(); n != 1 {
91+ t.Fatalf("%d modesets after the refused enable", n)
92+ }
93+ time.Sleep(masterRetryFirst)
94+ synctest.Wait()
95+ if n := modesets(); n != 2 {
96+ t.Fatalf("%d modesets after the first retry", n)
97+ }
98+ commitMu.Lock()
99+ refuse = false
100+ commitMu.Unlock()
101+ time.Sleep(2 * masterRetryFirst)
102+ synctest.Wait()
103+ if n := modesets(); n != 3 {
104+ t.Fatalf("%d modesets after the retry that succeeds", n)
105+ }
106+ // Once enabled, nothing retries any more.
107+ time.Sleep(masterRetryMax * 2)
108+ synctest.Wait()
109+ if n := modesets(); n != 3 {
110+ t.Fatalf("%d modesets after success", n)
111+ }
112+ cancel()
113+ if err := <-done; err != nil && !errors.Is(err, context.Canceled) {
114+ t.Fatal(err)
115+ }
116+}
@@ -0,0 +1,116 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "testing/synctest"
8+ "time"
9+
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/stretchr/testify/mock"
13+ "golang.org/x/sys/unix"
14+)
15+
16+func TestMasterRetryBackoff(t *testing.T) {
17+ var m masterRetry
18+ enabled := false
19+ if _, ok := m.next(); ok {
20+ t.Fatal("retry due before a refusal")
21+ }
22+ m.refused(unix.EBUSY, &enabled)
23+ if _, ok := m.next(); ok {
24+ t.Fatal("a busy refusal is retried by the busy path, not here")
25+ }
26+ m.refused(unix.EACCES, &enabled)
27+ d, ok := m.next()
28+ if !ok || d != masterRetryFirst {
29+ t.Fatalf("first retry %v %v", d, ok)
30+ }
31+ m.attempts = 30
32+ if d, _ := m.next(); d != masterRetryMax {
33+ t.Fatalf("capped retry %v", d)
34+ }
35+ m.attempts = masterRetryLimit
36+ if _, ok := m.next(); ok {
37+ t.Fatal("retries past the limit")
38+ }
39+ m.done()
40+ if _, ok := m.next(); ok {
41+ t.Fatal("retry after done")
42+ }
43+}
44+
45+// The seat reports active before the kernel gives DRM master back: the
46+// enable modeset is refused, then retried until it succeeds, without a
47+// second seat change.
48+func TestEnableRefusedForMasterIsRetried(t *testing.T) {
49+ synctest.Test(t, enableRefusedForMasterIsRetried)
50+}
51+
52+func enableRefusedForMasterIsRetried(t *testing.T) {
53+ refuse := true
54+ o, k, commits, commitMu := testOutputRules(t, func(_ *atomicReq, flags uint32) error {
55+ if refuse && flags&atomicAllowModes != 0 && flags&atomicTestOnly == 0 {
56+ return unix.EACCES
57+ }
58+ return nil
59+ })
60+ o.cursor, o.tearing = nil, false
61+ r := portsmocks.NewMockRenderer(t)
62+ r.EXPECT().SetHDR(mock.Anything).Return().Maybe()
63+ r.EXPECT().UseTarget(mock.Anything).Return().Maybe()
64+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Maybe()
65+ r.EXPECT().Close().Return().Once()
66+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Maybe()
67+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
68+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
69+ active := make(chan bool, 1)
70+ active <- false
71+ ctx, cancel := context.WithCancel(context.Background())
72+ done := make(chan error, 1)
73+ go func() {
74+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, make(chan ports.OutputPresented, 1), nil, nil)
75+ }()
76+ modesets := func() int {
77+ commitMu.Lock()
78+ defer commitMu.Unlock()
79+ n := 0
80+ for _, c := range *commits {
81+ if c.flags&atomicAllowModes != 0 && c.flags&atomicTestOnly == 0 {
82+ n++
83+ }
84+ }
85+ return n
86+ }
87+ synctest.Wait()
88+ active <- true
89+ synctest.Wait()
90+ if n := modesets(); n != 1 {
91+ t.Fatalf("%d modesets after the refused enable", n)
92+ }
93+ time.Sleep(masterRetryFirst)
94+ synctest.Wait()
95+ if n := modesets(); n != 2 {
96+ t.Fatalf("%d modesets after the first retry", n)
97+ }
98+ commitMu.Lock()
99+ refuse = false
100+ commitMu.Unlock()
101+ time.Sleep(2 * masterRetryFirst)
102+ synctest.Wait()
103+ if n := modesets(); n != 3 {
104+ t.Fatalf("%d modesets after the retry that succeeds", n)
105+ }
106+ // Once enabled, nothing retries any more.
107+ time.Sleep(masterRetryMax * 2)
108+ synctest.Wait()
109+ if n := modesets(); n != 3 {
110+ t.Fatalf("%d modesets after success", n)
111+ }
112+ cancel()
113+ if err := <-done; err != nil && !errors.Is(err, context.Canceled) {
114+ t.Fatal(err)
115+ }
116+}

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 23

Tags 10

↑↓ moveenter openesc close