test(wayland): release stalled displays before server shutdown on failure
The command ring tests stall the display goroutine; a failing assertion left it blocked while the cleanup joined Server.Run, so a regression hung until the package timeout instead of failing.
- commit
- b5629dc35c9f58f992997f25da10a171aae676fa
- author
- brice · · 2026-10-18 05:08 UTC
- parent
- 90639c0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +27 -6
M internal/adapters/wayland/commands_test.go +27 -6
| @@ -3,6 +3,7 @@ package wayland | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "reflect" | |
| 6 | + | "sync" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -78,9 +79,9 @@ func TestCommandRingMergesWhenFull(t *testing.T) { | |||
| 78 | 79 | s, _, _, _, _, _ := presentServer(t) | |
| 79 | 80 | var got []ports.ClientCommand | |
| 80 | 81 | r := newCommandRing(s.display, func(q queuedCommand) { got = append(got, q.command()) }) | |
| 81 | - | release := make(chan struct{}) | |
| 82 | + | release := releaseOnce(t) | |
| 82 | 83 | stalled := make(chan struct{}) | |
| 83 | - | s.display.Post(func() { close(stalled); <-release }) | |
| 84 | + | s.display.Post(func() { close(stalled); <-release.ch }) | |
| 84 | 85 | <-stalled | |
| 85 | 86 | for i := range commandRingSize - 1 { | |
| 86 | 87 | r.Push(queue(ports.PointerFocus{ID: ports.WindowID(i)})) | |
| @@ -97,7 +98,7 @@ func TestCommandRingMergesWhenFull(t *testing.T) { | |||
| 97 | 98 | case <-time.After(2 * time.Second): | |
| 98 | 99 | t.Fatal("motion push blocked on a full ring") | |
| 99 | 100 | } | |
| 100 | - | close(release) | |
| 101 | + | release.do() | |
| 101 | 102 | var last ports.ClientCommand | |
| 102 | 103 | s.display.Do(func() { last = got[len(got)-1] }) | |
| 103 | 104 | if m, ok := last.(ports.PointerMotionTo); !ok || m.DX != 1000 { | |
| @@ -119,9 +120,12 @@ func TestForwarderDrainsWhileDisplayBusy(t *testing.T) { | |||
| 119 | 120 | go func() { done <- s.Run(ctx) }() | |
| 120 | 121 | defer func() { cancel(); <-done }() | |
| 121 | 122 | ||
| 122 | - | release := make(chan struct{}) | |
| 123 | + | // Deferred after the shutdown above, so it runs first: a failing | |
| 124 | + | // assertion never leaves the display stalled while Run is joined. | |
| 125 | + | release := releaseOnce(t) | |
| 126 | + | defer release.do() | |
| 123 | 127 | stalled := make(chan struct{}) | |
| 124 | - | s.display.Post(func() { close(stalled); <-release }) | |
| 128 | + | s.display.Post(func() { close(stalled); <-release.ch }) | |
| 125 | 129 | <-stalled | |
| 126 | 130 | ||
| 127 | 131 | // Not mergeable: every command takes a slot. | |
| @@ -155,7 +159,7 @@ func TestForwarderDrainsWhileDisplayBusy(t *testing.T) { | |||
| 155 | 159 | t.Fatal("sender finished although the ring is full") | |
| 156 | 160 | default: | |
| 157 | 161 | } | |
| 158 | - | close(release) | |
| 162 | + | release.do() | |
| 159 | 163 | select { | |
| 160 | 164 | case <-sent: | |
| 161 | 165 | case <-time.After(5 * time.Second): | |
| @@ -184,3 +188,20 @@ func TestCommandThroughput(t *testing.T) { | |||
| 184 | 188 | t.Fatalf("2000 motion commands took %v (%.0f/s); want well above 1000/s", d, 2000/d.Seconds()) | |
| 185 | 189 | } | |
| 186 | 190 | } | |
| 191 | + | ||
| 192 | + | // release unblocks a stalled display goroutine once, from the test or its | |
| 193 | + | // cleanup, so a failed assertion never hangs the server shutdown. | |
| 194 | + | type release struct { | |
| 195 | + | ch chan struct{} | |
| 196 | + | once sync.Once | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func (r *release) do() { r.once.Do(func() { close(r.ch) }) } | |
| 200 | + | ||
| 201 | + | // releaseOnce returns a release that the test cleanup also fires, before | |
| 202 | + | // the cleanups registered earlier (the server's). | |
| 203 | + | func releaseOnce(t *testing.T) *release { | |
| 204 | + | r := &release{ch: make(chan struct{})} | |
| 205 | + | t.Cleanup(r.do) | |
| 206 | + | return r | |
| 207 | + | } | |
| @@ -3,6 +3,7 @@ package wayland | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "reflect" | 5 | "reflect" | ||
| 6 | + | "sync" | |||
| 6 | "testing" | 7 | "testing" | ||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| @@ -78,9 +79,9 @@ func TestCommandRingMergesWhenFull(t *testing.T) { | |||||
| 78 | s, _, _, _, _, _ := presentServer(t) | 79 | s, _, _, _, _, _ := presentServer(t) | ||
| 79 | var got []ports.ClientCommand | 80 | var got []ports.ClientCommand | ||
| 80 | r := newCommandRing(s.display, func(q queuedCommand) { got = append(got, q.command()) }) | 81 | r := newCommandRing(s.display, func(q queuedCommand) { got = append(got, q.command()) }) | ||
| 81 | - | release := make(chan struct{}) | 82 | + | release := releaseOnce(t) |
| 82 | stalled := make(chan struct{}) | 83 | stalled := make(chan struct{}) | ||
| 83 | - | s.display.Post(func() { close(stalled); <-release }) | 84 | + | s.display.Post(func() { close(stalled); <-release.ch }) |
| 84 | <-stalled | 85 | <-stalled | ||
| 85 | for i := range commandRingSize - 1 { | 86 | for i := range commandRingSize - 1 { | ||
| 86 | r.Push(queue(ports.PointerFocus{ID: ports.WindowID(i)})) | 87 | r.Push(queue(ports.PointerFocus{ID: ports.WindowID(i)})) | ||
| @@ -97,7 +98,7 @@ func TestCommandRingMergesWhenFull(t *testing.T) { | |||||
| 97 | case <-time.After(2 * time.Second): | 98 | case <-time.After(2 * time.Second): | ||
| 98 | t.Fatal("motion push blocked on a full ring") | 99 | t.Fatal("motion push blocked on a full ring") | ||
| 99 | } | 100 | } | ||
| 100 | - | close(release) | 101 | + | release.do() |
| 101 | var last ports.ClientCommand | 102 | var last ports.ClientCommand | ||
| 102 | s.display.Do(func() { last = got[len(got)-1] }) | 103 | s.display.Do(func() { last = got[len(got)-1] }) | ||
| 103 | if m, ok := last.(ports.PointerMotionTo); !ok || m.DX != 1000 { | 104 | if m, ok := last.(ports.PointerMotionTo); !ok || m.DX != 1000 { | ||
| @@ -119,9 +120,12 @@ func TestForwarderDrainsWhileDisplayBusy(t *testing.T) { | |||||
| 119 | go func() { done <- s.Run(ctx) }() | 120 | go func() { done <- s.Run(ctx) }() | ||
| 120 | defer func() { cancel(); <-done }() | 121 | defer func() { cancel(); <-done }() | ||
| 121 | 122 | ||||
| 122 | - | release := make(chan struct{}) | 123 | + | // Deferred after the shutdown above, so it runs first: a failing |
| 124 | + | // assertion never leaves the display stalled while Run is joined. | |||
| 125 | + | release := releaseOnce(t) | |||
| 126 | + | defer release.do() | |||
| 123 | stalled := make(chan struct{}) | 127 | stalled := make(chan struct{}) | ||
| 124 | - | s.display.Post(func() { close(stalled); <-release }) | 128 | + | s.display.Post(func() { close(stalled); <-release.ch }) |
| 125 | <-stalled | 129 | <-stalled | ||
| 126 | 130 | ||||
| 127 | // Not mergeable: every command takes a slot. | 131 | // Not mergeable: every command takes a slot. | ||
| @@ -155,7 +159,7 @@ func TestForwarderDrainsWhileDisplayBusy(t *testing.T) { | |||||
| 155 | t.Fatal("sender finished although the ring is full") | 159 | t.Fatal("sender finished although the ring is full") | ||
| 156 | default: | 160 | default: | ||
| 157 | } | 161 | } | ||
| 158 | - | close(release) | 162 | + | release.do() |
| 159 | select { | 163 | select { | ||
| 160 | case <-sent: | 164 | case <-sent: | ||
| 161 | case <-time.After(5 * time.Second): | 165 | case <-time.After(5 * time.Second): | ||
| @@ -184,3 +188,20 @@ func TestCommandThroughput(t *testing.T) { | |||||
| 184 | t.Fatalf("2000 motion commands took %v (%.0f/s); want well above 1000/s", d, 2000/d.Seconds()) | 188 | t.Fatalf("2000 motion commands took %v (%.0f/s); want well above 1000/s", d, 2000/d.Seconds()) | ||
| 185 | } | 189 | } | ||
| 186 | } | 190 | } | ||
| 191 | + | ||||
| 192 | + | // release unblocks a stalled display goroutine once, from the test or its | |||
| 193 | + | // cleanup, so a failed assertion never hangs the server shutdown. | |||
| 194 | + | type release struct { | |||
| 195 | + | ch chan struct{} | |||
| 196 | + | once sync.Once | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func (r *release) do() { r.once.Do(func() { close(r.ch) }) } | |||
| 200 | + | ||||
| 201 | + | // releaseOnce returns a release that the test cleanup also fires, before | |||
| 202 | + | // the cleanups registered earlier (the server's). | |||
| 203 | + | func releaseOnce(t *testing.T) *release { | |||
| 204 | + | r := &release{ch: make(chan struct{})} | |||
| 205 | + | t.Cleanup(r.do) | |||
| 206 | + | return r | |||
| 207 | + | } | |||