1package wayland
2
3import (
4 "context"
5 "reflect"
6 "strings"
7 "sync"
8 "testing"
9 "time"
10
11 "github.com/bnema/neferwl/internal/adapters/logging"
12 "github.com/bnema/neferwl/internal/ports"
13 "github.com/bnema/zerowrap"
14)
15
16// Adjacent motions for one window fold into the last queued command, summing
17// the relative deltas; nothing is merged across another command, and the
18// order of arrival is kept.
19func TestCommandRingCollapsesMotionInOrder(t *testing.T) {
20 s, _, _, _, _, _ := presentServer(t)
21 locked := ports.SecurityState{Generation: 2, Protected: true}
22 unlocked := ports.SecurityState{Generation: 3}
23 var got []ports.ClientCommand // display goroutine only
24 r := newCommandRing(s.display, func(q queuedCommand) { got = append(got, q.command()) })
25 release := make(chan struct{})
26 stalled := make(chan struct{})
27 s.display.Post(func() { close(stalled); <-release })
28 <-stalled
29 for _, c := range []ports.ClientCommand{
30 ports.PointerMotionTo{ID: 1, X: 1, DX: 0.25, UnaccelDX: 0.5},
31 ports.PointerMotionTo{ID: 1, X: 2, DX: 0.25, UnaccelDX: 0.5},
32 ports.PointerMotionTo{ID: 1, X: 3, DX: 0.25, DY: -0.5, UnaccelDX: 0.5, UnaccelDY: -1},
33 ports.PointerButtonTo{ID: 1, Button: 272, Pressed: true},
34 ports.PointerMotionTo{ID: 1, X: 4},
35 ports.PointerFocus{ID: 2},
36 ports.PointerMotionTo{ID: 2, X: 5},
37 ports.PointerMotionTo{ID: 2, X: 6},
38 ports.PointerMotionTo{ID: 1, X: 7}, // other window: not adjacent-same
39 // Enveloped, as core sends them: same state merges, a different
40 // state, a bare motion, or another window does not.
41 ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 8, DX: 1, UnaccelDY: 2}},
42 ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 9, DX: 2, UnaccelDY: 3}},
43 ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 10, DY: 4}},
44 ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 11, DX: 1}},
45 ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 12, DX: 1}},
46 ports.PointerMotionTo{ID: 1, X: 13},
47 ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 14}},
48 ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 2, X: 15}},
49 ports.SecurityCommand{State: unlocked, Command: ports.PointerButtonTo{ID: 2}},
50 } {
51 if !r.Push(queue(c)) {
52 t.Fatal("push refused")
53 }
54 }
55 close(release)
56 want := []ports.ClientCommand{
57 // Slow motions add up: none of the sub-pixel deltas is lost.
58 ports.PointerMotionTo{ID: 1, X: 3, DX: 0.75, DY: -0.5, UnaccelDX: 1.5, UnaccelDY: -1},
59 ports.PointerButtonTo{ID: 1, Button: 272, Pressed: true},
60 ports.PointerMotionTo{ID: 1, X: 4},
61 ports.PointerFocus{ID: 2},
62 ports.PointerMotionTo{ID: 2, X: 6},
63 ports.PointerMotionTo{ID: 1, X: 7},
64 ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 10, DX: 3, DY: 4, UnaccelDY: 5}},
65 ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 12, DX: 2}},
66 ports.PointerMotionTo{ID: 1, X: 13},
67 ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 14}},
68 ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 2, X: 15}},
69 ports.SecurityCommand{State: unlocked, Command: ports.PointerButtonTo{ID: 2}},
70 }
71 var final []ports.ClientCommand
72 s.display.Do(func() { final = got }) // runs after the drain posted before it
73 if !reflect.DeepEqual(final, want) {
74 t.Fatalf("applied %#v, want %#v", final, want)
75 }
76}
77
78// A merge needs no room: motion keeps folding into the last queued command
79// while the ring is full of other commands, and the push does not block.
80func TestCommandRingMergesWhenFull(t *testing.T) {
81 s, _, _, _, _, _ := presentServer(t)
82 var got []ports.ClientCommand
83 r := newCommandRing(s.display, func(q queuedCommand) { got = append(got, q.command()) })
84 release := releaseOnce(t)
85 stalled := make(chan struct{})
86 s.display.Post(func() { close(stalled); <-release.ch })
87 <-stalled
88 for i := range commandRingSize - 1 {
89 r.Push(queue(ports.PointerFocus{ID: ports.WindowID(i)}))
90 }
91 done := make(chan struct{})
92 go func() {
93 defer close(done)
94 for range 1000 { // fills the ring with one motion, then folds the rest
95 r.Push(queue(ports.PointerMotionTo{ID: 1, DX: 1}))
96 }
97 }()
98 select {
99 case <-done:
100 case <-time.After(2 * time.Second):
101 t.Fatal("motion push blocked on a full ring")
102 }
103 release.do()
104 var last ports.ClientCommand
105 s.display.Do(func() { last = got[len(got)-1] })
106 if m, ok := last.(ports.PointerMotionTo); !ok || m.DX != 1000 {
107 t.Fatalf("last applied %#v, want one motion with DX 1000", last)
108 }
109}
110
111// A busy display goroutine must not stall the forwarder: it keeps draining
112// the Commands channel into the ring up to the ring capacity, and only then
113// does it wait (and core with it, through the full channel).
114func TestForwarderDrainsWhileDisplayBusy(t *testing.T) {
115 cmds := make(chan ports.ClientCommand, 32)
116 s, err := New(Options{RuntimeDir: t.TempDir(), Outputs: testOutputs}, Channels{Commands: cmds}, logging.For(context.Background(), "wayland"))
117 if err != nil {
118 t.Fatal(err)
119 }
120 ctx, cancel := context.WithCancel(context.Background())
121 done := make(chan error, 1)
122 go func() { done <- s.Run(ctx) }()
123 defer func() { cancel(); <-done }()
124
125 // Deferred after the shutdown above, so it runs first: a failing
126 // assertion never leaves the display stalled while Run is joined.
127 release := releaseOnce(t)
128 defer release.do()
129 stalled := make(chan struct{})
130 s.display.Post(func() { close(stalled); <-release.ch })
131 <-stalled
132
133 // Not mergeable: every command takes a slot.
134 const total = commandRingSize + 100
135 sent := make(chan struct{})
136 go func() {
137 defer close(sent)
138 for i := range total {
139 select {
140 case cmds <- ports.PointerFocus{ID: ports.WindowID(i + 1)}:
141 case <-ctx.Done():
142 return
143 }
144 }
145 }()
146 queued := func() int {
147 s.commands.mu.Lock()
148 defer s.commands.mu.Unlock()
149 return len(s.commands.ring)
150 }
151 // Ring full, one command in the blocked forwarder, a full channel.
152 deadline := time.Now().Add(5 * time.Second)
153 for queued() != commandRingSize || len(cmds) != cap(cmds) {
154 if time.Now().After(deadline) {
155 t.Fatalf("ring holds %d (want %d), channel %d (want %d)", queued(), commandRingSize, len(cmds), cap(cmds))
156 }
157 time.Sleep(time.Millisecond)
158 }
159 select {
160 case <-sent:
161 t.Fatal("sender finished although the ring is full")
162 default:
163 }
164 release.do()
165 select {
166 case <-sent:
167 case <-time.After(5 * time.Second):
168 t.Fatal("forwarder did not resume after the display was released")
169 }
170}
171
172// Each command used to cost one display.Do round trip (up to one 5 ms
173// event-loop dispatch), capping pointer motion at ~150/s. A 1 kHz mouse
174// must be absorbed without core blocking.
175func TestCommandThroughput(t *testing.T) {
176 cmds := make(chan ports.ClientCommand, 32)
177 s, err := New(Options{RuntimeDir: t.TempDir(), Outputs: testOutputs}, Channels{Commands: cmds}, logging.For(context.Background(), "wayland"))
178 if err != nil {
179 t.Fatal(err)
180 }
181 ctx, cancel := context.WithCancel(context.Background())
182 done := make(chan error, 1)
183 go func() { done <- s.Run(ctx) }()
184 defer func() { cancel(); <-done }()
185 start := time.Now()
186 for i := range 2000 {
187 cmds <- ports.PointerMotionTo{ID: 1, X: float64(i), Y: 1}
188 }
189 if d := time.Since(start); d > time.Second {
190 t.Fatalf("2000 motion commands took %v (%.0f/s); want well above 1000/s", d, 2000/d.Seconds())
191 }
192}
193
194// release unblocks a stalled display goroutine once, from the test or its
195// cleanup, so a failed assertion never hangs the server shutdown.
196type release struct {
197 ch chan struct{}
198 once sync.Once
199}
200
201func (r *release) do() { r.once.Do(func() { close(r.ch) }) }
202
203// releaseOnce returns a release that the test cleanup also fires, before
204// the cleanups registered earlier (the server's).
205func releaseOnce(t *testing.T) *release {
206 r := &release{ch: make(chan struct{})}
207 t.Cleanup(r.do)
208 return r
209}
210
211// A merged motion keeps the push time of the first motion folded into it, so
212// the reported latency is the longest wait any of its motions had.
213func TestMergeMotionKeepsOldestPushTime(t *testing.T) {
214 t0 := time.Unix(100, 0)
215 first := queue(ports.PointerMotionTo{ID: 1, DX: 1})
216 first.at = t0
217 second := queue(ports.PointerMotionTo{ID: 1, DX: 2})
218 second.at = t0.Add(5 * time.Millisecond)
219 if !mergeMotion(&first, second) {
220 t.Fatal("adjacent motions did not merge")
221 }
222 if !first.at.Equal(t0) {
223 t.Fatalf("merged at = %v, want the oldest %v", first.at, t0)
224 }
225}
226
227// commandStats keeps the largest push-to-apply wait and the applied count,
228// and take resets both.
229func TestCommandStatsObserveAndTake(t *testing.T) {
230 var c commandStats
231 t0 := time.Unix(100, 0)
232 c.observe(t0, t0.Add(3*time.Millisecond))
233 c.observe(t0, t0.Add(9*time.Millisecond))
234 c.observe(t0, t0.Add(time.Millisecond))
235 if n, d := c.take(); n != 3 || d != 9*time.Millisecond {
236 t.Fatalf("take = %d, %v; want 3, 9ms", n, d)
237 }
238 if n, d := c.take(); n != 0 || d != 0 {
239 t.Fatalf("second take = %d, %v; want zeros", n, d)
240 }
241}
242
243// The ring counts the largest queued length and the folded pushes, and
244// takeStats returns then resets them.
245func TestCommandRingStats(t *testing.T) {
246 s, _, _, _, _, _ := presentServer(t)
247 r := newCommandRing(s.display, func(queuedCommand) {})
248 release := releaseOnce(t)
249 stalled := make(chan struct{})
250 s.display.Post(func() { close(stalled); <-release.ch })
251 <-stalled
252 r.Push(queue(ports.PointerFocus{ID: 1}))
253 r.Push(queue(ports.PointerMotionTo{ID: 1, DX: 1}))
254 r.Push(queue(ports.PointerMotionTo{ID: 1, DX: 1})) // folded
255 r.Push(queue(ports.PointerMotionTo{ID: 1, DX: 1})) // folded
256 r.Push(queue(ports.PointerFocus{ID: 2}))
257 st := r.takeStats()
258 if st.maxLen != 3 || st.merged != 2 || st.maxPushBlock != 0 {
259 t.Fatalf("stats = %+v, want maxLen 3, merged 2, no block", st)
260 }
261 if st := r.takeStats(); st != (ringStats{maxLen: 3}) {
262 t.Fatalf("stats after take = %+v, want current occupancy only", st)
263 }
264 release.do()
265}
266
267// The report logs the period's figures and resets them; a period without
268// applied commands logs nothing.
269func TestReportCommandStats(t *testing.T) {
270 var out lockedBuffer
271 log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &out}).WithField("component", "wayland")
272 h := newAdmissionHarnessWith(t, 1, Options{}, log)
273 s := h.s
274 s.reportCommandStats()
275 if strings.Contains(out.String(), "command stats") {
276 t.Fatalf("idle period logged: %s", out.String())
277 }
278 t0 := time.Unix(100, 0)
279 s.commandStats.observe(t0, t0.Add(1500*time.Microsecond))
280 s.reportCommandStats()
281 got := out.String()
282 for _, want := range []string{`"message":"command stats"`, `"commands":1`, `"merged":0`, `"max_queued":0`, `"max_push_block_ms":0`, `"max_latency_ms":1.5`} {
283 if !strings.Contains(got, want) {
284 t.Fatalf("log lacks %s: %s", want, got)
285 }
286 }
287 n := strings.Count(got, "command stats")
288 s.reportCommandStats()
289 if strings.Count(out.String(), "command stats") != n {
290 t.Fatalf("stats not reset: %s", out.String())
291 }
292}
293
294// A stalled display applies nothing but fills its ring: the period still
295// logs, so a long stall is not hidden.
296func TestReportCommandStatsDuringStall(t *testing.T) {
297 var out lockedBuffer
298 log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &out}).WithField("component", "wayland")
299 h := newAdmissionHarnessWith(t, 1, Options{}, log)
300 s := h.s
301 s.commands.mu.Lock()
302 s.commands.stats.maxLen = 5
303 s.commands.mu.Unlock()
304 s.reportCommandStats()
305 got := out.String()
306 for _, want := range []string{`"message":"command stats"`, `"commands":0`, `"max_queued":5`} {
307 if !strings.Contains(got, want) {
308 t.Fatalf("log lacks %s: %s", want, got)
309 }
310 }
311}