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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 1120 | 1120 | defer reacquireTimer.Stop() | |
| 1121 | 1121 | var reacquire masterRetry | |
| 1122 | 1122 | // refuse records an enable refused for lack of DRM master. | |
| 1123 | - | refuse := func(err error) { | |
| 1123 | + | refuseMaster := func(err error) { | |
| 1124 | 1124 | if reacquire.refused(err, enabled, time.Now()) { | |
| 1125 | 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 | 1137 | if !o.commitFailed(err, &enabled) { | |
| 1138 | 1138 | return true, err | |
| 1139 | 1139 | } | |
| 1140 | - | refuse(err) | |
| 1140 | + | refuseMaster(err) | |
| 1141 | 1141 | 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 | 1158 | if !o.commitFailed(err, &enabled) { | |
| 1159 | 1159 | return true, err | |
| 1160 | 1160 | } | |
| 1161 | - | refuse(err) | |
| 1161 | + | refuseMaster(err) | |
| 1162 | 1162 | return true, nil // epoch retry or wait disabled for master reacquisition | |
| 1163 | 1163 | } | |
| 1164 | 1164 | reacquire.done() | |
| @@ -1195,7 +1195,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1195 | 1195 | } | |
| 1196 | 1196 | // A lock screen refused for lack of DRM master after a VT | |
| 1197 | 1197 | // switch is retried like an ordinary enable. | |
| 1198 | - | refuse(err) | |
| 1198 | + | refuseMaster(err) | |
| 1199 | 1199 | } else { | |
| 1200 | 1200 | reacquire.done() | |
| 1201 | 1201 | } | |
| @@ -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 masterRetry | 1121 | 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, err | 1138 | return true, err | ||
| 1139 | } | 1139 | } | ||
| 1140 | - | refuse(err) | 1140 | + | refuseMaster(err) |
| 1141 | return true, nil | 1141 | 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, err | 1159 | return true, err | ||
| 1160 | } | 1160 | } | ||
| 1161 | - | refuse(err) | 1161 | + | refuseMaster(err) |
| 1162 | return true, nil // epoch retry or wait disabled for master reacquisition | 1162 | 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 VT | 1196 | // 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 ( | |||
| 16 | 16 | func TestSecurityUnlockedResumeUsesOwnerRetry(t *testing.T) { | |
| 17 | 17 | for _, stage := range []string{"engage-during-request", "master-loss"} { | |
| 18 | 18 | t.Run(stage, func(t *testing.T) { | |
| 19 | - | var failures []error | |
| 19 | + | var state atomic.Uint64 | |
| 20 | + | var rule func(*atomicReq, uint32) error | |
| 20 | 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 | 35 | o.cursor, o.tearing = nil, false | |
| 25 | - | var state atomic.Uint64 | |
| 26 | 36 | securityGate(t, o, &state) | |
| 27 | 37 | events := make(chan ports.SecurityBackendEvent, 4) | |
| 28 | 38 | o.SecurityEvents = events | |
| @@ -55,8 +65,7 @@ func TestSecurityUnlockedResumeUsesOwnerRetry(t *testing.T) { | |||
| 55 | 65 | }() | |
| 56 | 66 | active <- true | |
| 57 | 67 | 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 }) | |
| 60 | 69 | wake <- ports.SecurityState{} | |
| 61 | 70 | // The seat reported active before master came back: the owner | |
| 62 | 71 | // 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 []error | 19 | + | 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, false | 35 | 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 = events | 38 | o.SecurityEvents = events | ||
| @@ -55,8 +65,7 @@ func TestSecurityUnlockedResumeUsesOwnerRetry(t *testing.T) { | |||||
| 55 | }() | 65 | }() | ||
| 56 | active <- true | 66 | 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 owner | 70 | // The seat reported active before master came back: the owner | ||
| 62 | // retries the protected modeset itself, with no second seat | 71 | // retries the protected modeset itself, with no second seat | ||
M internal/adapters/wayland/alloc_hotpath_test.go +19 -7
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package wayland | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "testing" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| @@ -123,19 +124,30 @@ func TestEmitContentAllocations(t *testing.T) { | |||
| 123 | 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 | - | 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) | |
| 128 | 131 | s := &Server{ | |
| 129 | - | channels: Channels{Contents: make(chan ports.SurfaceContent)}, | |
| 132 | + | channels: Channels{Contents: contents}, | |
| 130 | 133 | contents: map[ports.WindowID]ports.SurfaceContent{}, | |
| 131 | 134 | contentSeq: map[ports.WindowID]uint64{}, | |
| 132 | 135 | damage: map[ports.WindowID][]ports.SeqDamage{}, | |
| 133 | 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 | 145 | 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") | |
| 140 | 152 | } | |
| 141 | 153 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package wayland | 1 | 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) { | |||
| 88 | 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. | |
| 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. | |
| 93 | 93 | func waitArmed(t *testing.T, s *Server, msg string) { | |
| 94 | 94 | t.Helper() | |
| 95 | + | if s.paceTimer.Stop() { | |
| 96 | + | return | |
| 97 | + | } | |
| 95 | 98 | select { | |
| 96 | 99 | case <-s.paceTimer.C: | |
| 97 | - | case <-time.After(2 * time.Second): | |
| 100 | + | default: | |
| 98 | 101 | t.Fatal(msg) | |
| 99 | 102 | } | |
| 100 | 103 | } | |
| @@ -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 | } | ||