detached 2382937

test: make the master-loss resume and content forwarding tests deterministic

The master-loss stage engages the lock on the refused commit so the
retry is always a protected modeset. forwardContents is tested end to
end: a blocked stale frame is replaced by the newer one. The pacer timer
check no longer waits for the timer to fire. The DRM master retry
closure is renamed refuseMaster.
commit
2382937d094c9768a664c16b4bd51d21fe6d02a2
author
brice · · 2026-10-10 15:28 UTC
committer
brice · · 2026-10-10 15:39 UTC
parent
26d812f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +44 -20
M internal/adapters/drm/output.go +4 -4
@@ -1120,7 +1120,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11201120 defer reacquireTimer.Stop()
11211121 var reacquire masterRetry
11221122 // refuse records an enable refused for lack of DRM master.
1123- refuse := func(err error) {
1123+ refuseMaster := func(err error) {
11241124 if reacquire.refused(err, enabled, time.Now()) {
11251125 o.log.Warn().Err(err).Str("connector", o.conn.name).Int("attempts", masterRetryLimit).Msg("DRM master not back; output stays off until the next seat change")
11261126 }
@@ -1137,7 +1137,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11371137 if !o.commitFailed(err, &enabled) {
11381138 return true, err
11391139 }
1140- refuse(err)
1140+ refuseMaster(err)
11411141 return true, nil
11421142 }
11431143 }
@@ -1158,7 +1158,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11581158 if !o.commitFailed(err, &enabled) {
11591159 return true, err
11601160 }
1161- refuse(err)
1161+ refuseMaster(err)
11621162 return true, nil // epoch retry or wait disabled for master reacquisition
11631163 }
11641164 reacquire.done()
@@ -1195,7 +1195,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11951195 }
11961196 // A lock screen refused for lack of DRM master after a VT
11971197 // switch is retried like an ordinary enable.
1198- refuse(err)
1198+ refuseMaster(err)
11991199 } else {
12001200 reacquire.done()
12011201 }
@@ -1120,7 +1120,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1120 defer reacquireTimer.Stop()1120 defer reacquireTimer.Stop()
1121 var reacquire masterRetry1121 var reacquire masterRetry
1122 // refuse records an enable refused for lack of DRM master.1122 // refuse records an enable refused for lack of DRM master.
1123- refuse := func(err error) {1123+ refuseMaster := func(err error) {
1124 if reacquire.refused(err, enabled, time.Now()) {1124 if reacquire.refused(err, enabled, time.Now()) {
1125 o.log.Warn().Err(err).Str("connector", o.conn.name).Int("attempts", masterRetryLimit).Msg("DRM master not back; output stays off until the next seat change")1125 o.log.Warn().Err(err).Str("connector", o.conn.name).Int("attempts", masterRetryLimit).Msg("DRM master not back; output stays off until the next seat change")
1126 }1126 }
@@ -1137,7 +1137,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1137 if !o.commitFailed(err, &enabled) {1137 if !o.commitFailed(err, &enabled) {
1138 return true, err1138 return true, err
1139 }1139 }
1140- refuse(err)1140+ refuseMaster(err)
1141 return true, nil1141 return true, nil
1142 }1142 }
1143 }1143 }
@@ -1158,7 +1158,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1158 if !o.commitFailed(err, &enabled) {1158 if !o.commitFailed(err, &enabled) {
1159 return true, err1159 return true, err
1160 }1160 }
1161- refuse(err)1161+ refuseMaster(err)
1162 return true, nil // epoch retry or wait disabled for master reacquisition1162 return true, nil // epoch retry or wait disabled for master reacquisition
1163 }1163 }
1164 reacquire.done()1164 reacquire.done()
@@ -1195,7 +1195,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1195 }1195 }
1196 // A lock screen refused for lack of DRM master after a VT1196 // A lock screen refused for lack of DRM master after a VT
1197 // switch is retried like an ordinary enable.1197 // switch is retried like an ordinary enable.
1198- refuse(err)1198+ refuseMaster(err)
1199 } else {1199 } else {
1200 reacquire.done()1200 reacquire.done()
1201 }1201 }
M internal/adapters/drm/security_resume_test.go +15 -6
@@ -16,13 +16,23 @@ import (
1616 func TestSecurityUnlockedResumeUsesOwnerRetry(t *testing.T) {
1717 for _, stage := range []string{"engage-during-request", "master-loss"} {
1818 t.Run(stage, func(t *testing.T) {
19- var failures []error
19+ var state atomic.Uint64
20+ var rule func(*atomicReq, uint32) error
2021 if stage == "master-loss" {
21- failures = []error{unix.EACCES}
22+ // The lock engages as the kernel refuses the first enable for
23+ // lack of master: the retry must be a protected modeset.
24+ refused := false
25+ rule = func(*atomicReq, uint32) error {
26+ if refused {
27+ return nil
28+ }
29+ refused = true
30+ state.Store(3)
31+ return unix.EACCES
32+ }
2233 }
23- o, k, commits, mu := testOutputMu(t, failures...)
34+ o, k, commits, mu := testOutputRules(t, rule)
2435 o.cursor, o.tearing = nil, false
25- var state atomic.Uint64
2636 securityGate(t, o, &state)
2737 events := make(chan ports.SecurityBackendEvent, 4)
2838 o.SecurityEvents = events
@@ -55,8 +65,7 @@ func TestSecurityUnlockedResumeUsesOwnerRetry(t *testing.T) {
5565 }()
5666 active <- true
5767 if stage == "master-loss" {
58- waitFor(t, func() bool { mu.Lock(); defer mu.Unlock(); return len(*commits) == 1 })
59- state.Store(3)
68+ waitFor(t, func() bool { mu.Lock(); defer mu.Unlock(); return len(*commits) >= 1 })
6069 wake <- ports.SecurityState{}
6170 // The seat reported active before master came back: the owner
6271 // retries the protected modeset itself, with no second seat
@@ -16,13 +16,23 @@ import (
16 func TestSecurityUnlockedResumeUsesOwnerRetry(t *testing.T) {16 func TestSecurityUnlockedResumeUsesOwnerRetry(t *testing.T) {
17 for _, stage := range []string{"engage-during-request", "master-loss"} {17 for _, stage := range []string{"engage-during-request", "master-loss"} {
18 t.Run(stage, func(t *testing.T) {18 t.Run(stage, func(t *testing.T) {
19- var failures []error19+ var state atomic.Uint64
20+ var rule func(*atomicReq, uint32) error
20 if stage == "master-loss" {21 if stage == "master-loss" {
21- failures = []error{unix.EACCES}22+ // The lock engages as the kernel refuses the first enable for
23+ // lack of master: the retry must be a protected modeset.
24+ refused := false
25+ rule = func(*atomicReq, uint32) error {
26+ if refused {
27+ return nil
28+ }
29+ refused = true
30+ state.Store(3)
31+ return unix.EACCES
32+ }
22 }33 }
23- o, k, commits, mu := testOutputMu(t, failures...)34+ o, k, commits, mu := testOutputRules(t, rule)
24 o.cursor, o.tearing = nil, false35 o.cursor, o.tearing = nil, false
25- var state atomic.Uint64
26 securityGate(t, o, &state)36 securityGate(t, o, &state)
27 events := make(chan ports.SecurityBackendEvent, 4)37 events := make(chan ports.SecurityBackendEvent, 4)
28 o.SecurityEvents = events38 o.SecurityEvents = events
@@ -55,8 +65,7 @@ func TestSecurityUnlockedResumeUsesOwnerRetry(t *testing.T) {
55 }()65 }()
56 active <- true66 active <- true
57 if stage == "master-loss" {67 if stage == "master-loss" {
58- waitFor(t, func() bool { mu.Lock(); defer mu.Unlock(); return len(*commits) == 1 })68+ waitFor(t, func() bool { mu.Lock(); defer mu.Unlock(); return len(*commits) >= 1 })
59- state.Store(3)
60 wake <- ports.SecurityState{}69 wake <- ports.SecurityState{}
61 // The seat reported active before master came back: the owner70 // The seat reported active before master came back: the owner
62 // retries the protected modeset itself, with no second seat71 // retries the protected modeset itself, with no second seat
M internal/adapters/wayland/alloc_hotpath_test.go +19 -7
@@ -1,6 +1,7 @@
11 package wayland
22
33 import (
4+ "context"
45 "testing"
56 "time"
67
@@ -123,19 +124,30 @@ func TestEmitContentAllocations(t *testing.T) {
123124 t.Logf("emitContent: %.2f allocs", allocs)
124125 }
125126
126-// A reader waiting for newer content is woken by the next publication.
127-func TestEmitContentWakesWaiter(t *testing.T) {
127+// A forwarder blocked on a stale frame is woken by the next publication
128+// and sends the newer frame instead.
129+func TestForwardContentsSendsNewerFrame(t *testing.T) {
130+ contents := make(chan ports.SurfaceContent)
128131 s := &Server{
129- channels: Channels{Contents: make(chan ports.SurfaceContent)},
132+ channels: Channels{Contents: contents},
130133 contents: map[ports.WindowID]ports.SurfaceContent{},
131134 contentSeq: map[ports.WindowID]uint64{},
132135 damage: map[ports.WindowID][]ports.SeqDamage{},
133136 contentReady: make(chan struct{}, 1),
134137 }
135- s.emitContent(ports.SurfaceContent{ID: 1, Solid: &ports.SolidColor{}}, damage{full: true})
138+ ctx, cancel := context.WithCancel(context.Background())
139+ done := make(chan struct{})
140+ go func() { defer close(done); s.forwardContents(ctx) }()
141+ defer func() { cancel(); <-done }()
142+ s.emitContent(ports.SurfaceContent{ID: 1, Seq: 1, Solid: &ports.SolidColor{}}, damage{full: true})
143+ time.Sleep(10 * time.Millisecond) // let the forwarder block on seq 1
144+ s.emitContent(ports.SurfaceContent{ID: 1, Seq: 2, Solid: &ports.SolidColor{}}, damage{full: true})
136145 select {
137- case <-s.contentReady:
138- default:
139- t.Fatal("waiter not woken")
146+ case c := <-contents:
147+ if c.Seq != 2 {
148+ t.Fatalf("stale frame %d forwarded, want 2", c.Seq)
149+ }
150+ case <-time.After(2 * time.Second):
151+ t.Fatal("no frame forwarded")
140152 }
141153 }
@@ -1,6 +1,7 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 "time"6 "time"
6 7
@@ -123,19 +124,30 @@ func TestEmitContentAllocations(t *testing.T) {
123 t.Logf("emitContent: %.2f allocs", allocs)124 t.Logf("emitContent: %.2f allocs", allocs)
124 }125 }
125 126
126-// A reader waiting for newer content is woken by the next publication.127+// A forwarder blocked on a stale frame is woken by the next publication
127-func TestEmitContentWakesWaiter(t *testing.T) {128+// and sends the newer frame instead.
129+func TestForwardContentsSendsNewerFrame(t *testing.T) {
130+ contents := make(chan ports.SurfaceContent)
128 s := &Server{131 s := &Server{
129- channels: Channels{Contents: make(chan ports.SurfaceContent)},132+ channels: Channels{Contents: contents},
130 contents: map[ports.WindowID]ports.SurfaceContent{},133 contents: map[ports.WindowID]ports.SurfaceContent{},
131 contentSeq: map[ports.WindowID]uint64{},134 contentSeq: map[ports.WindowID]uint64{},
132 damage: map[ports.WindowID][]ports.SeqDamage{},135 damage: map[ports.WindowID][]ports.SeqDamage{},
133 contentReady: make(chan struct{}, 1),136 contentReady: make(chan struct{}, 1),
134 }137 }
135- s.emitContent(ports.SurfaceContent{ID: 1, Solid: &ports.SolidColor{}}, damage{full: true})138+ ctx, cancel := context.WithCancel(context.Background())
139+ done := make(chan struct{})
140+ go func() { defer close(done); s.forwardContents(ctx) }()
141+ defer func() { cancel(); <-done }()
142+ s.emitContent(ports.SurfaceContent{ID: 1, Seq: 1, Solid: &ports.SolidColor{}}, damage{full: true})
143+ time.Sleep(10 * time.Millisecond) // let the forwarder block on seq 1
144+ s.emitContent(ports.SurfaceContent{ID: 1, Seq: 2, Solid: &ports.SolidColor{}}, damage{full: true})
136 select {145 select {
137- case <-s.contentReady:146+ case c := <-contents:
138- default:147+ if c.Seq != 2 {
139- t.Fatal("waiter not woken")148+ t.Fatalf("stale frame %d forwarded, want 2", c.Seq)
149+ }
150+ case <-time.After(2 * time.Second):
151+ t.Fatal("no frame forwarded")
140 }152 }
141 }153 }
M internal/adapters/wayland/pace_inline_test.go +6 -3
@@ -88,13 +88,16 @@ func TestCommitWaitingOnBarrierArmsTimer(t *testing.T) {
8888 waitArmed(t, s, "timer not armed for the barrier deadline")
8989 }
9090
91-// waitArmed asserts the timer was armed: it fires within the test timeout.
92-// A receive works even when the deadline passed before the check.
91+// waitArmed asserts the timer was armed: it is pending, or it already
92+// fired and holds its value.
9393 func waitArmed(t *testing.T, s *Server, msg string) {
9494 t.Helper()
95+ if s.paceTimer.Stop() {
96+ return
97+ }
9598 select {
9699 case <-s.paceTimer.C:
97- case <-time.After(2 * time.Second):
100+ default:
98101 t.Fatal(msg)
99102 }
100103 }
@@ -88,13 +88,16 @@ func TestCommitWaitingOnBarrierArmsTimer(t *testing.T) {
88 waitArmed(t, s, "timer not armed for the barrier deadline")88 waitArmed(t, s, "timer not armed for the barrier deadline")
89 }89 }
90 90
91-// waitArmed asserts the timer was armed: it fires within the test timeout.91+// waitArmed asserts the timer was armed: it is pending, or it already
92-// A receive works even when the deadline passed before the check.92+// fired and holds its value.
93 func waitArmed(t *testing.T, s *Server, msg string) {93 func waitArmed(t *testing.T, s *Server, msg string) {
94 t.Helper()94 t.Helper()
95+ if s.paceTimer.Stop() {
96+ return
97+ }
95 select {98 select {
96 case <-s.paceTimer.C:99 case <-s.paceTimer.C:
97- case <-time.After(2 * time.Second):100+ default:
98 t.Fatal(msg)101 t.Fatal(msg)
99 }102 }
100 }103 }

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