1package wayland
2
3import (
4 "context"
5 "slices"
6 "sync"
7 "testing"
8 "time"
9
10 ext "github.com/bnema/go-wayland-bindings/server/extworkspace"
11 "github.com/bnema/neferwl/internal/adapters/workspaceid"
12 "github.com/bnema/neferwl/internal/logging"
13 "github.com/bnema/neferwl/internal/ports"
14 "github.com/bnema/wlturbo"
15)
16
17// workspaceEventOrder records dispatch order across manager and child objects.
18type workspaceEventOrder struct {
19 mu sync.Mutex
20 events []uint32
21}
22
23func (o *workspaceEventOrder) add(v uint32) {
24 if o != nil {
25 o.mu.Lock()
26 o.events = append(o.events, v)
27 o.mu.Unlock()
28 }
29}
30func (o *workspaceEventOrder) take() []uint32 {
31 o.mu.Lock()
32 defer o.mu.Unlock()
33 v := slices.Clone(o.events)
34 o.events = nil
35 return v
36}
37
38type workspaceEvents struct {
39 wlturbo.BaseProxy
40 client *wlturbo.Display
41 events chan [2]uint32
42 idEvents chan string
43 order *workspaceEventOrder
44}
45
46func (p *workspaceEvents) Dispatch(e *wlturbo.Event) {
47 v := [2]uint32{uint32(e.Opcode), 0}
48 if e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup) || e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspace) {
49 v[1] = e.Uint32()
50 child := &workspaceChildEvents{events: p.events, idEvents: p.idEvents, order: p.order, group: e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup)}
51 child.SetID(v[1])
52 registerWireProxy(p.client, child)
53 }
54 p.order.add(v[0])
55 p.events <- v
56}
57
58type workspaceChildEvents struct {
59 wlturbo.BaseProxy
60 events chan [2]uint32
61 group bool
62 idEvents chan string
63 order *workspaceEventOrder
64}
65
66func (p *workspaceChildEvents) Dispatch(e *wlturbo.Event) {
67 // Encode the interface in the upper byte to distinguish manager, group and handle.
68 kind := uint32(2)
69 if p.group {
70 kind = 1
71 }
72 v := [2]uint32{kind<<8 | uint32(e.Opcode), 0}
73 switch {
74 case p.group && (e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventOutputEnter) || e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventOutputLeave)):
75 v[1] = e.Uint32()
76 case p.group && (e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter) || e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventWorkspaceLeave)):
77 v[1] = e.Uint32()
78 case !p.group && (e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventState) || e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventCapabilities)):
79 v[1] = e.Uint32()
80 case !p.group && e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventId):
81 p.idEvents <- e.String()
82 }
83 p.order.add(v[0])
84 p.events <- v
85}
86func TestWorkspaceProtocol(t *testing.T) {
87 dir := t.TempDir()
88 events := make(chan ports.ClientEvent, 16)
89 snapshots := make(chan ports.Workspaces, 1)
90 s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, WorkspaceIDs: workspaceid.WithPrefix("0a1b2c3d")}, Channels{Events: events, Workspaces: snapshots}, logging.For(context.Background(), "wayland"))
91 if err != nil {
92 t.Fatal(err)
93 }
94 ctx, cancel := context.WithCancel(context.Background())
95 done := make(chan error, 1)
96 go func() { done <- s.Run(ctx) }()
97 t.Cleanup(func() {
98 cancel()
99 if err := <-done; err != nil {
100 t.Error(err)
101 }
102 })
103 c := protocolClient(t, s, dir)
104 snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Active: true}, {ID: 43, Name: "2", Index: 1}}}}}
105 if !s.display.Do(func() {}) {
106 t.Fatal("display stopped")
107 }
108 managerID := bindProtocol(t, c, "ext_workspace_manager_v1")
109 p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16)}
110 p.SetID(managerID)
111 registerWireProxy(c, p)
112 var handle, secondHandle uint32
113 for i := 0; i < 8 && secondHandle == 0; i++ {
114 if err := c.Roundtrip(); err != nil {
115 t.Fatal(err)
116 }
117 for len(p.events) > 0 {
118 v := <-p.events
119 if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace {
120 if handle == 0 {
121 handle = v[1]
122 } else {
123 secondHandle = v[1]
124 }
125 }
126 }
127 }
128 if handle == 0 {
129 t.Fatal("workspace not announced")
130 }
131 // A dynamic workspace sends "<launch prefix>-<core ID>".
132 select {
133 case id := <-p.idEvents:
134 if id != "0a1b2c3d-42" {
135 t.Fatalf("workspace id %q, want 0a1b2c3d-42", id)
136 }
137 default:
138 t.Fatal("workspace sent no id")
139 }
140 out := bindProtocol(t, c, "wl_output")
141 registerProtocol(t, c, out)
142 entered := false
143 doneCount := 0
144 for i := 0; i < 8 && !entered; i++ {
145 if err := c.Roundtrip(); err != nil {
146 t.Fatal(err)
147 }
148 for len(p.events) > 0 {
149 v := <-p.events
150 if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, out} {
151 entered = true
152 }
153 if v[0] == ext.ExtWorkspaceManagerV1EventDone {
154 doneCount++
155 }
156 }
157 }
158 if err := c.Roundtrip(); err != nil {
159 t.Fatal(err)
160 }
161 for len(p.events) > 0 {
162 if (<-p.events)[0] == ext.ExtWorkspaceManagerV1EventDone {
163 doneCount++
164 }
165 }
166 if !entered || doneCount != 1 {
167 t.Fatalf("late output enter=%v, done=%d", entered, doneCount)
168 }
169 if len(p.idEvents) != 1 { // the second workspace's; the first was read above
170 t.Fatalf("id events left = %d, want 1", len(p.idEvents))
171 }
172 requestProtocol(t, c, handle, ext.ExtWorkspaceHandleV1RequestActivate)
173 requestProtocol(t, c, secondHandle, ext.ExtWorkspaceHandleV1RequestActivate)
174 if err := c.Roundtrip(); err != nil {
175 t.Fatal(err)
176 }
177 select {
178 case v := <-events:
179 t.Fatalf("activation before commit: %v", v)
180 default:
181 }
182 requestProtocol(t, c, managerID, ext.ExtWorkspaceManagerV1RequestCommit)
183 if err := c.Roundtrip(); err != nil {
184 t.Fatal(err)
185 }
186 select {
187 case v := <-events:
188 if got, ok := v.(ports.WorkspaceActivate); !ok || !slices.Equal(got.IDs, []uint64{42, 43}) {
189 t.Fatal(v)
190 }
191 case <-time.After(time.Second):
192 t.Fatal("no activation")
193 }
194 select {
195 case v := <-events:
196 t.Fatalf("extra activation message: %v", v)
197 default:
198 }
199 snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Hidden: true}, {ID: 43, Name: "2", Index: 1}}}}}
200 // The snapshot reaches the display through a forwarding goroutine:
201 // poll by round trips until a deadline, not a fixed count.
202 found := false
203 for deadline := time.Now().Add(2 * time.Second); !found && time.Now().Before(deadline); time.Sleep(time.Millisecond) {
204 if err := c.Roundtrip(); err != nil {
205 t.Fatal(err)
206 }
207 for len(p.events) > 0 {
208 v := <-p.events
209 if v == [2]uint32{2<<8 | ext.ExtWorkspaceHandleV1EventState, uint32(ext.ExtWorkspaceHandleV1StateHidden)} {
210 found = true
211 }
212 }
213 }
214 if !found {
215 t.Fatal("no state change")
216 }
217 snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}
218 found = false
219 for deadline := time.Now().Add(2 * time.Second); !found && time.Now().Before(deadline); time.Sleep(time.Millisecond) {
220 if err := c.Roundtrip(); err != nil {
221 t.Fatal(err)
222 }
223 for len(p.events) > 0 {
224 v := <-p.events
225 if v[0] == 1<<8|ext.ExtWorkspaceGroupHandleV1EventRemoved {
226 found = true
227 }
228 }
229 }
230 if !found {
231 t.Fatal("group not removed")
232 }
233}
234
235func TestWorkspaceConfiguredIDAndBatchDone(t *testing.T) {
236 dir := t.TempDir()
237 snapshots := make(chan ports.Workspaces, 1)
238 s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, WorkspaceIDs: workspaceid.WithPrefix("0a1b2c3d")}, Channels{Workspaces: snapshots}, logging.For(context.Background(), "wayland"))
239 if err != nil {
240 t.Fatal(err)
241 }
242 ctx, cancel := context.WithCancel(context.Background())
243 done := make(chan error, 1)
244 go func() { done <- s.Run(ctx) }()
245 t.Cleanup(func() {
246 cancel()
247 if err := <-done; err != nil {
248 t.Error(err)
249 }
250 })
251 c := protocolClient(t, s, dir)
252 // Bind manager first; the initial two-output batch has exactly one done.
253 managerID := bindProtocol(t, c, "ext_workspace_manager_v1")
254 p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16), order: &workspaceEventOrder{}}
255 p.SetID(managerID)
256 registerWireProxy(c, p)
257 if err := c.Roundtrip(); err != nil {
258 t.Fatal(err)
259 }
260 for len(p.events) > 0 {
261 <-p.events
262 }
263 p.order.take()
264 snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{
265 {Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Active: true}, {ID: 43, Name: "dev", Configured: "dev", Index: 1, Hidden: true}}},
266 {Name: "HEADLESS-2", Workspaces: []ports.WorkspaceInfo{{ID: 44, Name: "1", Active: true}}},
267 }}
268 // Synchronize with the display owner's processing of the channel snapshot.
269 for i := 0; i < 8; i++ {
270 if !s.display.Do(func() {}) {
271 t.Fatal("display stopped")
272 }
273 if err := c.Roundtrip(); err != nil {
274 t.Fatal(err)
275 }
276 if len(p.events) > 0 {
277 break
278 }
279 }
280 doneCount := 0
281 for len(p.events) > 0 {
282 if (<-p.events)[0] == ext.ExtWorkspaceManagerV1EventDone {
283 doneCount++
284 }
285 }
286 if doneCount != 1 {
287 t.Fatalf("snapshot done=%d, want 1", doneCount)
288 }
289 order := p.order.take()
290 // The second group begins after the first group's workspaces are fully
291 // initialized; workspace_enter follows all initial workspace details.
292 wantOrder := []uint32{
293 ext.ExtWorkspaceManagerV1EventWorkspaceGroup, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventCapabilities,
294 ext.ExtWorkspaceManagerV1EventWorkspace, 2<<8 | ext.ExtWorkspaceHandleV1EventId, 2<<8 | ext.ExtWorkspaceHandleV1EventName, 2<<8 | ext.ExtWorkspaceHandleV1EventCoordinates, 2<<8 | ext.ExtWorkspaceHandleV1EventState, 2<<8 | ext.ExtWorkspaceHandleV1EventCapabilities, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter,
295 ext.ExtWorkspaceManagerV1EventWorkspace, 2<<8 | ext.ExtWorkspaceHandleV1EventId, 2<<8 | ext.ExtWorkspaceHandleV1EventName, 2<<8 | ext.ExtWorkspaceHandleV1EventCoordinates, 2<<8 | ext.ExtWorkspaceHandleV1EventState, 2<<8 | ext.ExtWorkspaceHandleV1EventCapabilities, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter,
296 ext.ExtWorkspaceManagerV1EventWorkspaceGroup, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventCapabilities,
297 ext.ExtWorkspaceManagerV1EventWorkspace, 2<<8 | ext.ExtWorkspaceHandleV1EventId, 2<<8 | ext.ExtWorkspaceHandleV1EventName, 2<<8 | ext.ExtWorkspaceHandleV1EventCoordinates, 2<<8 | ext.ExtWorkspaceHandleV1EventState, 2<<8 | ext.ExtWorkspaceHandleV1EventCapabilities, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter,
298 ext.ExtWorkspaceManagerV1EventDone,
299 }
300 if !slices.Equal(order, wantOrder) {
301 t.Fatalf("creation order = %v, want %v", order, wantOrder)
302 }
303 // A configured workspace sends "name:<name>", the others the launch
304 // prefix and their core ID, in creation order.
305 for _, want := range []string{"0a1b2c3d-42", "name:dev", "0a1b2c3d-44"} {
306 select {
307 case id := <-p.idEvents:
308 if id != want {
309 t.Fatalf("id=%q, want %q", id, want)
310 }
311 default:
312 t.Fatalf("workspace %s has no id", want)
313 }
314 }
315 if len(p.idEvents) != 0 {
316 t.Fatal("extra id")
317 }
318 out := bindProtocol(t, c, "wl_output")
319 registerProtocol(t, c, out)
320 entered, outputDone := 0, 0
321 for i := 0; i < 8 && outputDone == 0; i++ {
322 if err := c.Roundtrip(); err != nil {
323 t.Fatal(err)
324 }
325 for len(p.events) > 0 {
326 v := <-p.events
327 if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, out} {
328 entered++
329 }
330 if v[0] == ext.ExtWorkspaceManagerV1EventDone {
331 outputDone++
332 }
333 }
334 }
335 if entered != 1 || outputDone != 1 {
336 t.Fatalf("late output enter=%d done=%d", entered, outputDone)
337 }
338 second := bindProtocol(t, c, "wl_output")
339 registerProtocol(t, c, second)
340 entered, outputDone = 0, 0
341 for i := 0; i < 8 && outputDone == 0; i++ {
342 if err := c.Roundtrip(); err != nil {
343 t.Fatal(err)
344 }
345 for len(p.events) > 0 {
346 v := <-p.events
347 if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, second} {
348 entered++
349 }
350 if v[0] == ext.ExtWorkspaceManagerV1EventDone {
351 outputDone++
352 }
353 }
354 }
355 if entered != 1 || outputDone != 1 {
356 t.Fatalf("second output enter=%d done=%d", entered, outputDone)
357 }
358 // A client may destroy a handle without removing the underlying workspace.
359 // A subsequent snapshot must not recreate the destroyed protocol object.
360 var group, workspace uint32
361 // Inspect server resources on the display owner's goroutine.
362 if !s.display.Do(func() {
363 m := s.workspaceManagers[0]
364 group = m.groups["HEADLESS-1"].res.ID()
365 workspace = m.handles[42].res.ID()
366 }) {
367 t.Fatal("display stopped")
368 }
369 requestProtocol(t, c, workspace, ext.ExtWorkspaceHandleV1RequestDestroy)
370 requestProtocol(t, c, group, ext.ExtWorkspaceGroupHandleV1RequestDestroy)
371 if err := c.Roundtrip(); err != nil {
372 t.Fatal(err)
373 }
374 snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "one", Active: true}, {ID: 43, Name: "dev", Configured: "dev", Index: 1, Hidden: true}}}, {Name: "HEADLESS-2", Workspaces: []ports.WorkspaceInfo{{ID: 44, Name: "1", Active: true}}}}}
375 for i := 0; i < 8; i++ {
376 if !s.display.Do(func() {}) {
377 t.Fatal("display stopped")
378 }
379 if err := c.Roundtrip(); err != nil {
380 t.Fatal(err)
381 }
382 }
383 for len(p.events) > 0 {
384 v := <-p.events
385 if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace || v[0] == ext.ExtWorkspaceManagerV1EventWorkspaceGroup {
386 t.Fatalf("recreated client-destroyed handle: %v", v)
387 }
388 }
389 // A configured rename is a removed handle and a new one (the id is the
390 // configured name), on the still-live second group. The destroyed first
391 // group remains a tombstone until it disappears.
392 snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "one", Active: true}, {ID: 43, Name: "dev", Configured: "dev", Index: 1, Hidden: true}}}, {Name: "HEADLESS-2", Workspaces: []ports.WorkspaceInfo{{ID: 44, Name: "code", Configured: "code", Active: true}}}}}
393 replaced, removed := 0, 0
394 for i := 0; i < 8 && replaced == 0; i++ {
395 if !s.display.Do(func() {}) {
396 t.Fatal("display stopped")
397 }
398 if err := c.Roundtrip(); err != nil {
399 t.Fatal(err)
400 }
401 for len(p.events) > 0 {
402 v := <-p.events
403 if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace {
404 replaced++
405 }
406 if v[0] == 2<<8|ext.ExtWorkspaceHandleV1EventRemoved {
407 removed++
408 }
409 }
410 }
411 if replaced != 1 || removed != 1 {
412 t.Fatalf("rename replaced=%d removed=%d", replaced, removed)
413 }
414 select {
415 case id := <-p.idEvents:
416 if id != "name:code" {
417 t.Fatalf("renamed id=%q", id)
418 }
419 default:
420 t.Fatal("missing renamed id")
421 }
422 if len(p.idEvents) != 0 {
423 t.Fatal("extra id")
424 }
425 // Removing the group clears its tombstone, allowing a new handle on return.
426 snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}
427 for i := 0; i < 8; i++ {
428 if !s.display.Do(func() {}) {
429 t.Fatal("display stopped")
430 }
431 if err := c.Roundtrip(); err != nil {
432 t.Fatal(err)
433 }
434 }
435 for len(p.events) > 0 {
436 <-p.events
437 }
438 snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Active: true}}}}}
439 recreated := 0
440 for i := 0; i < 8 && recreated < 2; i++ {
441 if !s.display.Do(func() {}) {
442 t.Fatal("display stopped")
443 }
444 if err := c.Roundtrip(); err != nil {
445 t.Fatal(err)
446 }
447 for len(p.events) > 0 {
448 v := <-p.events
449 if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace || v[0] == ext.ExtWorkspaceManagerV1EventWorkspaceGroup {
450 recreated++
451 }
452 }
453 }
454 if recreated != 2 {
455 t.Fatalf("new group and workspace after disappearance: %d", recreated)
456 }
457}