feat(wayland): add native wlr-screencopy source
- commit
- 99731da9ff189a0c4b427a09b95ba6efa74df83d
- author
- brice · · 2026-09-30 08:22 UTC
- committer
- brice · · 2026-09-30 14:48 UTC
- parent
- 3cdbdaa
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +1852 -0
A internal/adapters/wayland/compositor_test.go +535 -0
| @@ -0,0 +1,535 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/binary" | |
| 6 | + | "net" | |
| 7 | + | "path/filepath" | |
| 8 | + | "sync" | |
| 9 | + | "syscall" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "golang.org/x/sys/unix" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // This file implements the compositor side of the Wayland wire protocol over a | |
| 16 | + | // real Unix socket: it decodes the requests the Source sends (including | |
| 17 | + | // SCM_RIGHTS descriptors), maps the client's shm pools and answers with | |
| 18 | + | // encoded events. It is a protocol peer, not a substitute for a project type. | |
| 19 | + | ||
| 20 | + | const ( | |
| 21 | + | kindDisplay = iota + 1 | |
| 22 | + | kindRegistry | |
| 23 | + | kindOutput | |
| 24 | + | kindShm | |
| 25 | + | kindPool | |
| 26 | + | kindBuffer | |
| 27 | + | kindScreencopy | |
| 28 | + | kindFrame | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | const ( | |
| 32 | + | globalShm = 1000 | |
| 33 | + | globalScreencopy = 1001 | |
| 34 | + | ) | |
| 35 | + | ||
| 36 | + | type outputSpec struct { | |
| 37 | + | name string | |
| 38 | + | width, height uint32 | |
| 39 | + | scale int32 | |
| 40 | + | version uint32 | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // announce is one zwlr_screencopy_frame_v1.buffer event. | |
| 44 | + | type announce struct{ format, width, height, stride uint32 } | |
| 45 | + | ||
| 46 | + | type compositorConfig struct { | |
| 47 | + | outputs []outputSpec | |
| 48 | + | screencopyVersion uint32 // 0 selects 3 | |
| 49 | + | noScreencopy bool | |
| 50 | + | announce []announce // empty: derived from the output or region | |
| 51 | + | flags uint32 | |
| 52 | + | hold bool // do not answer copy until completePending | |
| 53 | + | failCapture bool | |
| 54 | + | removeOnCapture bool // global_remove of the output, then failed | |
| 55 | + | silent bool // accept the connection and never answer | |
| 56 | + | failCopies int // answer this many copy requests with failed | |
| 57 | + | readyEarly bool // send ready right after the buffer events, before any copy | |
| 58 | + | noShmOffer bool // send only buffer_done: no wl_shm buffer is offered | |
| 59 | + | duplicateGlobal bool // announce the first output global twice | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | type poolMap struct{ data []byte } | |
| 63 | + | ||
| 64 | + | type bufferInfo struct { | |
| 65 | + | pool *poolMap | |
| 66 | + | offset int | |
| 67 | + | width, height, stride, format uint32 | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | type compositor struct { | |
| 71 | + | t testing.TB | |
| 72 | + | path string | |
| 73 | + | ln net.Listener | |
| 74 | + | cfg compositorConfig | |
| 75 | + | ||
| 76 | + | mu sync.Mutex | |
| 77 | + | conn *net.UnixConn | |
| 78 | + | pools int | |
| 79 | + | buffers int | |
| 80 | + | destroyedBuffers int | |
| 81 | + | captures int | |
| 82 | + | copies int | |
| 83 | + | fdsReceived int | |
| 84 | + | lastRegion [4]int32 | |
| 85 | + | lastRegionSet bool | |
| 86 | + | seq uint32 | |
| 87 | + | pending struct { | |
| 88 | + | frame uint32 | |
| 89 | + | buffer *bufferInfo | |
| 90 | + | } | |
| 91 | + | hasPending bool | |
| 92 | + | copyEvent chan struct{} | |
| 93 | + | done chan struct{} | |
| 94 | + | ||
| 95 | + | wmu sync.Mutex | |
| 96 | + | out [4096]byte | |
| 97 | + | reg uint32 | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func startCompositor(t testing.TB, cfg compositorConfig) *compositor { | |
| 101 | + | t.Helper() | |
| 102 | + | if len(cfg.outputs) == 0 { | |
| 103 | + | cfg.outputs = []outputSpec{{name: "TEST-1", width: 8, height: 4, scale: 1, version: 4}} | |
| 104 | + | } | |
| 105 | + | if cfg.screencopyVersion == 0 { | |
| 106 | + | cfg.screencopyVersion = 3 | |
| 107 | + | } | |
| 108 | + | dir, err := filepath.EvalSymlinks(t.TempDir()) | |
| 109 | + | if err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | ln, err := net.Listen("unix", filepath.Join(dir, "wl-test")) | |
| 113 | + | if err != nil { | |
| 114 | + | t.Fatal(err) | |
| 115 | + | } | |
| 116 | + | c := &compositor{t: t, path: ln.Addr().String(), ln: ln, cfg: cfg, copyEvent: make(chan struct{}, 64), done: make(chan struct{})} | |
| 117 | + | go c.serve() | |
| 118 | + | t.Cleanup(func() { | |
| 119 | + | ln.Close() | |
| 120 | + | c.mu.Lock() | |
| 121 | + | if c.conn != nil { | |
| 122 | + | c.conn.Close() | |
| 123 | + | } | |
| 124 | + | c.mu.Unlock() | |
| 125 | + | <-c.done | |
| 126 | + | }) | |
| 127 | + | return c | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (c *compositor) stat(f func(*compositor) int) int { | |
| 131 | + | c.mu.Lock() | |
| 132 | + | defer c.mu.Unlock() | |
| 133 | + | return f(c) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (c *compositor) serve() { | |
| 137 | + | defer close(c.done) | |
| 138 | + | nc, err := c.ln.Accept() | |
| 139 | + | if err != nil { | |
| 140 | + | return | |
| 141 | + | } | |
| 142 | + | conn := nc.(*net.UnixConn) | |
| 143 | + | c.mu.Lock() | |
| 144 | + | c.conn = conn | |
| 145 | + | c.mu.Unlock() | |
| 146 | + | defer conn.Close() | |
| 147 | + | if c.cfg.silent { | |
| 148 | + | buf := make([]byte, 64) | |
| 149 | + | for { | |
| 150 | + | if _, err := conn.Read(buf); err != nil { | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | c.loop(conn) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | type objects struct { | |
| 159 | + | kinds map[uint32]int | |
| 160 | + | outputs map[uint32]int | |
| 161 | + | pools map[uint32]*poolMap | |
| 162 | + | buffers map[uint32]*bufferInfo | |
| 163 | + | frames map[uint32]*frameState | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | type frameState struct { | |
| 167 | + | output int | |
| 168 | + | w, h uint32 | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func (c *compositor) loop(conn *net.UnixConn) { | |
| 172 | + | objs := &objects{ | |
| 173 | + | kinds: map[uint32]int{1: kindDisplay}, outputs: map[uint32]int{}, pools: map[uint32]*poolMap{}, | |
| 174 | + | buffers: map[uint32]*bufferInfo{}, frames: map[uint32]*frameState{}, | |
| 175 | + | } | |
| 176 | + | buf := make([]byte, 0, 1<<16) | |
| 177 | + | chunk := make([]byte, 1<<15) | |
| 178 | + | oob := make([]byte, unix.CmsgSpace(4*16)) | |
| 179 | + | var fds []int | |
| 180 | + | defer func() { | |
| 181 | + | for _, fd := range fds { | |
| 182 | + | unix.Close(fd) | |
| 183 | + | } | |
| 184 | + | }() | |
| 185 | + | for { | |
| 186 | + | n, oobn, _, _, err := conn.ReadMsgUnix(chunk, oob) | |
| 187 | + | if oobn > 0 { | |
| 188 | + | msgs, perr := syscall.ParseSocketControlMessage(oob[:oobn]) | |
| 189 | + | if perr != nil { | |
| 190 | + | c.t.Errorf("control message: %v", perr) | |
| 191 | + | return | |
| 192 | + | } | |
| 193 | + | for i := range msgs { | |
| 194 | + | got, perr := syscall.ParseUnixRights(&msgs[i]) | |
| 195 | + | if perr != nil { | |
| 196 | + | c.t.Errorf("rights: %v", perr) | |
| 197 | + | return | |
| 198 | + | } | |
| 199 | + | fds = append(fds, got...) | |
| 200 | + | c.mu.Lock() | |
| 201 | + | c.fdsReceived += len(got) | |
| 202 | + | c.mu.Unlock() | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | buf = append(buf, chunk[:max(n, 0)]...) | |
| 206 | + | for len(buf) >= 8 { | |
| 207 | + | size := int(binary.LittleEndian.Uint32(buf[4:]) >> 16) | |
| 208 | + | if size < 8 || len(buf) < size { | |
| 209 | + | break | |
| 210 | + | } | |
| 211 | + | id := binary.LittleEndian.Uint32(buf) | |
| 212 | + | op := uint16(binary.LittleEndian.Uint32(buf[4:])) | |
| 213 | + | if !c.handle(objs, id, op, buf[8:size], &fds) { | |
| 214 | + | return | |
| 215 | + | } | |
| 216 | + | buf = buf[:copy(buf, buf[size:])] | |
| 217 | + | } | |
| 218 | + | if err != nil { | |
| 219 | + | return | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func word(b []byte, i int) uint32 { return binary.LittleEndian.Uint32(b[i*4:]) } | |
| 225 | + | ||
| 226 | + | func (c *compositor) handle(o *objects, id uint32, op uint16, body []byte, fds *[]int) bool { | |
| 227 | + | switch o.kinds[id] { | |
| 228 | + | case kindDisplay: | |
| 229 | + | switch op { | |
| 230 | + | case 0: // sync | |
| 231 | + | cb := word(body, 0) | |
| 232 | + | c.send(cb, 0, 0) | |
| 233 | + | c.send(1, 1, cb) | |
| 234 | + | case 1: // get_registry | |
| 235 | + | c.wmu.Lock() | |
| 236 | + | c.reg = word(body, 0) | |
| 237 | + | c.wmu.Unlock() | |
| 238 | + | o.kinds[word(body, 0)] = kindRegistry | |
| 239 | + | c.advertise() | |
| 240 | + | } | |
| 241 | + | case kindRegistry: | |
| 242 | + | name := word(body, 0) | |
| 243 | + | n := int(word(body, 1)) | |
| 244 | + | iface := string(body[8 : 8+n-1]) | |
| 245 | + | i := 8 + (n+3)&^3 | |
| 246 | + | version, newID := word(body, i/4), word(body, i/4+1) | |
| 247 | + | c.bind(o, name, iface, version, newID) | |
| 248 | + | case kindOutput: | |
| 249 | + | if op == 0 { | |
| 250 | + | c.send(1, 1, id) | |
| 251 | + | delete(o.kinds, id) | |
| 252 | + | } | |
| 253 | + | case kindShm: | |
| 254 | + | if op == 0 { | |
| 255 | + | if len(*fds) == 0 { | |
| 256 | + | c.t.Error("create_pool without descriptor") | |
| 257 | + | return false | |
| 258 | + | } | |
| 259 | + | fd := (*fds)[0] | |
| 260 | + | *fds = (*fds)[1:] | |
| 261 | + | size := int(word(body, 1)) | |
| 262 | + | data, err := unix.Mmap(fd, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) | |
| 263 | + | unix.Close(fd) | |
| 264 | + | if err != nil { | |
| 265 | + | c.t.Errorf("server mmap: %v", err) | |
| 266 | + | return false | |
| 267 | + | } | |
| 268 | + | pid := word(body, 0) | |
| 269 | + | o.kinds[pid] = kindPool | |
| 270 | + | o.pools[pid] = &poolMap{data: data} | |
| 271 | + | c.mu.Lock() | |
| 272 | + | c.pools++ | |
| 273 | + | c.mu.Unlock() | |
| 274 | + | } | |
| 275 | + | case kindPool: | |
| 276 | + | switch op { | |
| 277 | + | case 0: | |
| 278 | + | bid := word(body, 0) | |
| 279 | + | o.kinds[bid] = kindBuffer | |
| 280 | + | o.buffers[bid] = &bufferInfo{pool: o.pools[id], offset: int(word(body, 1)), width: word(body, 2), height: word(body, 3), stride: word(body, 4), format: word(body, 5)} | |
| 281 | + | c.mu.Lock() | |
| 282 | + | c.buffers++ | |
| 283 | + | c.mu.Unlock() | |
| 284 | + | case 1: | |
| 285 | + | c.send(1, 1, id) | |
| 286 | + | delete(o.kinds, id) | |
| 287 | + | } | |
| 288 | + | case kindBuffer: | |
| 289 | + | if op == 0 { | |
| 290 | + | c.mu.Lock() | |
| 291 | + | c.destroyedBuffers++ | |
| 292 | + | c.mu.Unlock() | |
| 293 | + | c.send(1, 1, id) | |
| 294 | + | delete(o.kinds, id) | |
| 295 | + | delete(o.buffers, id) | |
| 296 | + | } | |
| 297 | + | case kindScreencopy: | |
| 298 | + | if op <= 1 { | |
| 299 | + | c.capture(o, op, body) | |
| 300 | + | } | |
| 301 | + | case kindFrame: | |
| 302 | + | switch op { | |
| 303 | + | case 0: | |
| 304 | + | c.copy(o, id, o.buffers[word(body, 0)]) | |
| 305 | + | case 1: | |
| 306 | + | c.send(1, 1, id) | |
| 307 | + | delete(o.kinds, id) | |
| 308 | + | delete(o.frames, id) | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | return true | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func (c *compositor) advertise() { | |
| 315 | + | for i, spec := range c.cfg.outputs { | |
| 316 | + | c.sendGlobal(uint32(i+1), "wl_output", spec.version) | |
| 317 | + | } | |
| 318 | + | if c.cfg.duplicateGlobal { | |
| 319 | + | c.sendGlobal(1, "wl_output", c.cfg.outputs[0].version) | |
| 320 | + | } | |
| 321 | + | c.sendGlobal(globalShm, "wl_shm", 1) | |
| 322 | + | if !c.cfg.noScreencopy { | |
| 323 | + | c.sendGlobal(globalScreencopy, "zwlr_screencopy_manager_v1", c.cfg.screencopyVersion) | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | func (c *compositor) bind(o *objects, name uint32, iface string, version, id uint32) { | |
| 328 | + | switch iface { | |
| 329 | + | case "wl_output": | |
| 330 | + | o.kinds[id] = kindOutput | |
| 331 | + | idx := int(name) - 1 | |
| 332 | + | o.outputs[id] = idx | |
| 333 | + | spec := c.cfg.outputs[idx] | |
| 334 | + | c.wmu.Lock() | |
| 335 | + | m := c.begin(id, 0) | |
| 336 | + | m = c.words(m, 0, 0, uint32(spec.width), uint32(spec.height), 0) | |
| 337 | + | m = c.str(m, "Test") | |
| 338 | + | m = c.str(m, "Model") | |
| 339 | + | m = c.words(m, 0) | |
| 340 | + | c.flush(m) | |
| 341 | + | c.wmu.Unlock() | |
| 342 | + | c.send(id, 1, 1, spec.width, spec.height, 60000) | |
| 343 | + | if version >= 2 { | |
| 344 | + | c.send(id, 3, uint32(spec.scale)) | |
| 345 | + | } | |
| 346 | + | if version >= 4 { | |
| 347 | + | c.wmu.Lock() | |
| 348 | + | m := c.begin(id, 4) | |
| 349 | + | m = c.str(m, spec.name) | |
| 350 | + | c.flush(m) | |
| 351 | + | c.wmu.Unlock() | |
| 352 | + | } | |
| 353 | + | if version >= 2 { | |
| 354 | + | c.send(id, 2) | |
| 355 | + | } | |
| 356 | + | case "wl_shm": | |
| 357 | + | o.kinds[id] = kindShm | |
| 358 | + | c.send(id, 0, 0) | |
| 359 | + | c.send(id, 0, 1) | |
| 360 | + | case "zwlr_screencopy_manager_v1": | |
| 361 | + | o.kinds[id] = kindScreencopy | |
| 362 | + | default: | |
| 363 | + | c.t.Errorf("bind of unexpected interface %q", iface) | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func (c *compositor) capture(o *objects, op uint16, body []byte) { | |
| 368 | + | frame := word(body, 0) | |
| 369 | + | out := o.outputs[word(body, 2)] | |
| 370 | + | spec := c.cfg.outputs[out] | |
| 371 | + | w, h := spec.width, spec.height | |
| 372 | + | c.mu.Lock() | |
| 373 | + | c.captures++ | |
| 374 | + | if op == 1 { | |
| 375 | + | c.lastRegion = [4]int32{int32(word(body, 3)), int32(word(body, 4)), int32(word(body, 5)), int32(word(body, 6))} | |
| 376 | + | c.lastRegionSet = true | |
| 377 | + | w, h = word(body, 5), word(body, 6) | |
| 378 | + | } | |
| 379 | + | c.mu.Unlock() | |
| 380 | + | o.kinds[frame] = kindFrame | |
| 381 | + | o.frames[frame] = &frameState{output: out, w: w, h: h} | |
| 382 | + | if c.cfg.removeOnCapture { | |
| 383 | + | c.removeOutput(uint32(out + 1)) | |
| 384 | + | c.send(frame, 3) | |
| 385 | + | return | |
| 386 | + | } | |
| 387 | + | if c.cfg.failCapture { | |
| 388 | + | c.send(frame, 3) | |
| 389 | + | return | |
| 390 | + | } | |
| 391 | + | if c.cfg.readyEarly { | |
| 392 | + | c.send(frame, 2, 0, 1, 500) | |
| 393 | + | return | |
| 394 | + | } | |
| 395 | + | list := c.cfg.announce | |
| 396 | + | if c.cfg.noShmOffer { | |
| 397 | + | c.send(frame, 6) | |
| 398 | + | return | |
| 399 | + | } | |
| 400 | + | if len(list) == 0 { | |
| 401 | + | list = []announce{{format: 1, width: w, height: h, stride: w * 4}} | |
| 402 | + | } | |
| 403 | + | for _, a := range list { | |
| 404 | + | c.send(frame, 0, a.format, a.width, a.height, a.stride) | |
| 405 | + | } | |
| 406 | + | if c.cfg.screencopyVersion >= 3 { | |
| 407 | + | c.send(frame, 6) | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | func (c *compositor) copy(o *objects, frame uint32, b *bufferInfo) { | |
| 412 | + | if b == nil { | |
| 413 | + | c.t.Error("copy with unknown buffer") | |
| 414 | + | return | |
| 415 | + | } | |
| 416 | + | c.mu.Lock() | |
| 417 | + | c.copies++ | |
| 418 | + | if c.cfg.failCopies > 0 { | |
| 419 | + | c.cfg.failCopies-- | |
| 420 | + | c.mu.Unlock() | |
| 421 | + | c.send(frame, 3) | |
| 422 | + | return | |
| 423 | + | } | |
| 424 | + | hold := c.cfg.hold | |
| 425 | + | if hold { | |
| 426 | + | c.pending.frame, c.pending.buffer, c.hasPending = frame, b, true | |
| 427 | + | } | |
| 428 | + | c.mu.Unlock() | |
| 429 | + | select { | |
| 430 | + | case c.copyEvent <- struct{}{}: | |
| 431 | + | default: | |
| 432 | + | } | |
| 433 | + | if !hold { | |
| 434 | + | c.finish(frame, b) | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | // completePending answers a copy that was held. | |
| 439 | + | func (c *compositor) completePending() { | |
| 440 | + | c.mu.Lock() | |
| 441 | + | frame, b, ok := c.pending.frame, c.pending.buffer, c.hasPending | |
| 442 | + | c.hasPending = false | |
| 443 | + | c.mu.Unlock() | |
| 444 | + | if !ok { | |
| 445 | + | c.t.Error("no pending copy") | |
| 446 | + | return | |
| 447 | + | } | |
| 448 | + | c.finish(frame, b) | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | // pixel is the deterministic content written for (x, y) of frame number seq. | |
| 452 | + | func pixel(x, y int, seq uint32) [4]byte { | |
| 453 | + | return [4]byte{byte(x), byte(y), byte(seq), 0xff} | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | func (c *compositor) finish(frame uint32, b *bufferInfo) { | |
| 457 | + | c.mu.Lock() | |
| 458 | + | c.seq++ | |
| 459 | + | seq := c.seq | |
| 460 | + | c.mu.Unlock() | |
| 461 | + | data := b.pool.data[b.offset:] | |
| 462 | + | for y := 0; y < int(b.height); y++ { | |
| 463 | + | row := data[y*int(b.stride) : (y+1)*int(b.stride)] | |
| 464 | + | for x := 0; x < int(b.width); x++ { | |
| 465 | + | p := pixel(x, y, seq) | |
| 466 | + | copy(row[x*4:], p[:]) | |
| 467 | + | } | |
| 468 | + | for i := int(b.width) * 4; i < len(row); i++ { | |
| 469 | + | row[i] = 0xEE // stride padding | |
| 470 | + | } | |
| 471 | + | } | |
| 472 | + | c.send(frame, 1, c.cfg.flags) | |
| 473 | + | c.send(frame, 2, 0, 1, 500) | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | // removeOutput announces the removal of output global name (1-based). | |
| 477 | + | func (c *compositor) removeOutput(name uint32) { | |
| 478 | + | c.wmu.Lock() | |
| 479 | + | m := c.begin(c.reg, 1) | |
| 480 | + | m = c.words(m, name) | |
| 481 | + | c.flush(m) | |
| 482 | + | c.wmu.Unlock() | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | // Encoding helpers. Every message is built in c.out under wmu. | |
| 486 | + | ||
| 487 | + | func (c *compositor) send(obj uint32, op uint16, args ...uint32) { | |
| 488 | + | c.wmu.Lock() | |
| 489 | + | m := c.begin(obj, op) | |
| 490 | + | m = c.words(m, args...) | |
| 491 | + | c.flush(m) | |
| 492 | + | c.wmu.Unlock() | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | func (c *compositor) sendGlobal(name uint32, iface string, version uint32) { | |
| 496 | + | c.wmu.Lock() | |
| 497 | + | m := c.begin(c.reg, 0) | |
| 498 | + | m = c.words(m, name) | |
| 499 | + | m = c.str(m, iface) | |
| 500 | + | m = c.words(m, version) | |
| 501 | + | c.flush(m) | |
| 502 | + | c.wmu.Unlock() | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | func (c *compositor) begin(obj uint32, op uint16) []byte { | |
| 506 | + | m := c.out[:8] | |
| 507 | + | binary.LittleEndian.PutUint32(m, obj) | |
| 508 | + | binary.LittleEndian.PutUint32(m[4:], uint32(op)) | |
| 509 | + | return m | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | func (c *compositor) words(m []byte, args ...uint32) []byte { | |
| 513 | + | for _, a := range args { | |
| 514 | + | m = binary.LittleEndian.AppendUint32(m, a) | |
| 515 | + | } | |
| 516 | + | return m | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | func (c *compositor) str(m []byte, s string) []byte { | |
| 520 | + | m = binary.LittleEndian.AppendUint32(m, uint32(len(s)+1)) | |
| 521 | + | m = append(m, s...) | |
| 522 | + | m = append(m, 0) | |
| 523 | + | return append(m, bytes.Repeat([]byte{0}, (4-(len(s)+1)%4)%4)...) | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | func (c *compositor) flush(m []byte) { | |
| 527 | + | binary.LittleEndian.PutUint32(m[4:], uint32(len(m))<<16|binary.LittleEndian.Uint32(m[4:])&0xffff) | |
| 528 | + | c.mu.Lock() | |
| 529 | + | conn := c.conn | |
| 530 | + | c.mu.Unlock() | |
| 531 | + | if _, err := conn.Write(m); err != nil { | |
| 532 | + | // The client may have gone away during teardown. | |
| 533 | + | return | |
| 534 | + | } | |
| 535 | + | } | |
| @@ -0,0 +1,535 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/binary" | |||
| 6 | + | "net" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "sync" | |||
| 9 | + | "syscall" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | "golang.org/x/sys/unix" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // This file implements the compositor side of the Wayland wire protocol over a | |||
| 16 | + | // real Unix socket: it decodes the requests the Source sends (including | |||
| 17 | + | // SCM_RIGHTS descriptors), maps the client's shm pools and answers with | |||
| 18 | + | // encoded events. It is a protocol peer, not a substitute for a project type. | |||
| 19 | + | ||||
| 20 | + | const ( | |||
| 21 | + | kindDisplay = iota + 1 | |||
| 22 | + | kindRegistry | |||
| 23 | + | kindOutput | |||
| 24 | + | kindShm | |||
| 25 | + | kindPool | |||
| 26 | + | kindBuffer | |||
| 27 | + | kindScreencopy | |||
| 28 | + | kindFrame | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 31 | + | const ( | |||
| 32 | + | globalShm = 1000 | |||
| 33 | + | globalScreencopy = 1001 | |||
| 34 | + | ) | |||
| 35 | + | ||||
| 36 | + | type outputSpec struct { | |||
| 37 | + | name string | |||
| 38 | + | width, height uint32 | |||
| 39 | + | scale int32 | |||
| 40 | + | version uint32 | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // announce is one zwlr_screencopy_frame_v1.buffer event. | |||
| 44 | + | type announce struct{ format, width, height, stride uint32 } | |||
| 45 | + | ||||
| 46 | + | type compositorConfig struct { | |||
| 47 | + | outputs []outputSpec | |||
| 48 | + | screencopyVersion uint32 // 0 selects 3 | |||
| 49 | + | noScreencopy bool | |||
| 50 | + | announce []announce // empty: derived from the output or region | |||
| 51 | + | flags uint32 | |||
| 52 | + | hold bool // do not answer copy until completePending | |||
| 53 | + | failCapture bool | |||
| 54 | + | removeOnCapture bool // global_remove of the output, then failed | |||
| 55 | + | silent bool // accept the connection and never answer | |||
| 56 | + | failCopies int // answer this many copy requests with failed | |||
| 57 | + | readyEarly bool // send ready right after the buffer events, before any copy | |||
| 58 | + | noShmOffer bool // send only buffer_done: no wl_shm buffer is offered | |||
| 59 | + | duplicateGlobal bool // announce the first output global twice | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | type poolMap struct{ data []byte } | |||
| 63 | + | ||||
| 64 | + | type bufferInfo struct { | |||
| 65 | + | pool *poolMap | |||
| 66 | + | offset int | |||
| 67 | + | width, height, stride, format uint32 | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | type compositor struct { | |||
| 71 | + | t testing.TB | |||
| 72 | + | path string | |||
| 73 | + | ln net.Listener | |||
| 74 | + | cfg compositorConfig | |||
| 75 | + | ||||
| 76 | + | mu sync.Mutex | |||
| 77 | + | conn *net.UnixConn | |||
| 78 | + | pools int | |||
| 79 | + | buffers int | |||
| 80 | + | destroyedBuffers int | |||
| 81 | + | captures int | |||
| 82 | + | copies int | |||
| 83 | + | fdsReceived int | |||
| 84 | + | lastRegion [4]int32 | |||
| 85 | + | lastRegionSet bool | |||
| 86 | + | seq uint32 | |||
| 87 | + | pending struct { | |||
| 88 | + | frame uint32 | |||
| 89 | + | buffer *bufferInfo | |||
| 90 | + | } | |||
| 91 | + | hasPending bool | |||
| 92 | + | copyEvent chan struct{} | |||
| 93 | + | done chan struct{} | |||
| 94 | + | ||||
| 95 | + | wmu sync.Mutex | |||
| 96 | + | out [4096]byte | |||
| 97 | + | reg uint32 | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func startCompositor(t testing.TB, cfg compositorConfig) *compositor { | |||
| 101 | + | t.Helper() | |||
| 102 | + | if len(cfg.outputs) == 0 { | |||
| 103 | + | cfg.outputs = []outputSpec{{name: "TEST-1", width: 8, height: 4, scale: 1, version: 4}} | |||
| 104 | + | } | |||
| 105 | + | if cfg.screencopyVersion == 0 { | |||
| 106 | + | cfg.screencopyVersion = 3 | |||
| 107 | + | } | |||
| 108 | + | dir, err := filepath.EvalSymlinks(t.TempDir()) | |||
| 109 | + | if err != nil { | |||
| 110 | + | t.Fatal(err) | |||
| 111 | + | } | |||
| 112 | + | ln, err := net.Listen("unix", filepath.Join(dir, "wl-test")) | |||
| 113 | + | if err != nil { | |||
| 114 | + | t.Fatal(err) | |||
| 115 | + | } | |||
| 116 | + | c := &compositor{t: t, path: ln.Addr().String(), ln: ln, cfg: cfg, copyEvent: make(chan struct{}, 64), done: make(chan struct{})} | |||
| 117 | + | go c.serve() | |||
| 118 | + | t.Cleanup(func() { | |||
| 119 | + | ln.Close() | |||
| 120 | + | c.mu.Lock() | |||
| 121 | + | if c.conn != nil { | |||
| 122 | + | c.conn.Close() | |||
| 123 | + | } | |||
| 124 | + | c.mu.Unlock() | |||
| 125 | + | <-c.done | |||
| 126 | + | }) | |||
| 127 | + | return c | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func (c *compositor) stat(f func(*compositor) int) int { | |||
| 131 | + | c.mu.Lock() | |||
| 132 | + | defer c.mu.Unlock() | |||
| 133 | + | return f(c) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func (c *compositor) serve() { | |||
| 137 | + | defer close(c.done) | |||
| 138 | + | nc, err := c.ln.Accept() | |||
| 139 | + | if err != nil { | |||
| 140 | + | return | |||
| 141 | + | } | |||
| 142 | + | conn := nc.(*net.UnixConn) | |||
| 143 | + | c.mu.Lock() | |||
| 144 | + | c.conn = conn | |||
| 145 | + | c.mu.Unlock() | |||
| 146 | + | defer conn.Close() | |||
| 147 | + | if c.cfg.silent { | |||
| 148 | + | buf := make([]byte, 64) | |||
| 149 | + | for { | |||
| 150 | + | if _, err := conn.Read(buf); err != nil { | |||
| 151 | + | return | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | c.loop(conn) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | type objects struct { | |||
| 159 | + | kinds map[uint32]int | |||
| 160 | + | outputs map[uint32]int | |||
| 161 | + | pools map[uint32]*poolMap | |||
| 162 | + | buffers map[uint32]*bufferInfo | |||
| 163 | + | frames map[uint32]*frameState | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | type frameState struct { | |||
| 167 | + | output int | |||
| 168 | + | w, h uint32 | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func (c *compositor) loop(conn *net.UnixConn) { | |||
| 172 | + | objs := &objects{ | |||
| 173 | + | kinds: map[uint32]int{1: kindDisplay}, outputs: map[uint32]int{}, pools: map[uint32]*poolMap{}, | |||
| 174 | + | buffers: map[uint32]*bufferInfo{}, frames: map[uint32]*frameState{}, | |||
| 175 | + | } | |||
| 176 | + | buf := make([]byte, 0, 1<<16) | |||
| 177 | + | chunk := make([]byte, 1<<15) | |||
| 178 | + | oob := make([]byte, unix.CmsgSpace(4*16)) | |||
| 179 | + | var fds []int | |||
| 180 | + | defer func() { | |||
| 181 | + | for _, fd := range fds { | |||
| 182 | + | unix.Close(fd) | |||
| 183 | + | } | |||
| 184 | + | }() | |||
| 185 | + | for { | |||
| 186 | + | n, oobn, _, _, err := conn.ReadMsgUnix(chunk, oob) | |||
| 187 | + | if oobn > 0 { | |||
| 188 | + | msgs, perr := syscall.ParseSocketControlMessage(oob[:oobn]) | |||
| 189 | + | if perr != nil { | |||
| 190 | + | c.t.Errorf("control message: %v", perr) | |||
| 191 | + | return | |||
| 192 | + | } | |||
| 193 | + | for i := range msgs { | |||
| 194 | + | got, perr := syscall.ParseUnixRights(&msgs[i]) | |||
| 195 | + | if perr != nil { | |||
| 196 | + | c.t.Errorf("rights: %v", perr) | |||
| 197 | + | return | |||
| 198 | + | } | |||
| 199 | + | fds = append(fds, got...) | |||
| 200 | + | c.mu.Lock() | |||
| 201 | + | c.fdsReceived += len(got) | |||
| 202 | + | c.mu.Unlock() | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | buf = append(buf, chunk[:max(n, 0)]...) | |||
| 206 | + | for len(buf) >= 8 { | |||
| 207 | + | size := int(binary.LittleEndian.Uint32(buf[4:]) >> 16) | |||
| 208 | + | if size < 8 || len(buf) < size { | |||
| 209 | + | break | |||
| 210 | + | } | |||
| 211 | + | id := binary.LittleEndian.Uint32(buf) | |||
| 212 | + | op := uint16(binary.LittleEndian.Uint32(buf[4:])) | |||
| 213 | + | if !c.handle(objs, id, op, buf[8:size], &fds) { | |||
| 214 | + | return | |||
| 215 | + | } | |||
| 216 | + | buf = buf[:copy(buf, buf[size:])] | |||
| 217 | + | } | |||
| 218 | + | if err != nil { | |||
| 219 | + | return | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func word(b []byte, i int) uint32 { return binary.LittleEndian.Uint32(b[i*4:]) } | |||
| 225 | + | ||||
| 226 | + | func (c *compositor) handle(o *objects, id uint32, op uint16, body []byte, fds *[]int) bool { | |||
| 227 | + | switch o.kinds[id] { | |||
| 228 | + | case kindDisplay: | |||
| 229 | + | switch op { | |||
| 230 | + | case 0: // sync | |||
| 231 | + | cb := word(body, 0) | |||
| 232 | + | c.send(cb, 0, 0) | |||
| 233 | + | c.send(1, 1, cb) | |||
| 234 | + | case 1: // get_registry | |||
| 235 | + | c.wmu.Lock() | |||
| 236 | + | c.reg = word(body, 0) | |||
| 237 | + | c.wmu.Unlock() | |||
| 238 | + | o.kinds[word(body, 0)] = kindRegistry | |||
| 239 | + | c.advertise() | |||
| 240 | + | } | |||
| 241 | + | case kindRegistry: | |||
| 242 | + | name := word(body, 0) | |||
| 243 | + | n := int(word(body, 1)) | |||
| 244 | + | iface := string(body[8 : 8+n-1]) | |||
| 245 | + | i := 8 + (n+3)&^3 | |||
| 246 | + | version, newID := word(body, i/4), word(body, i/4+1) | |||
| 247 | + | c.bind(o, name, iface, version, newID) | |||
| 248 | + | case kindOutput: | |||
| 249 | + | if op == 0 { | |||
| 250 | + | c.send(1, 1, id) | |||
| 251 | + | delete(o.kinds, id) | |||
| 252 | + | } | |||
| 253 | + | case kindShm: | |||
| 254 | + | if op == 0 { | |||
| 255 | + | if len(*fds) == 0 { | |||
| 256 | + | c.t.Error("create_pool without descriptor") | |||
| 257 | + | return false | |||
| 258 | + | } | |||
| 259 | + | fd := (*fds)[0] | |||
| 260 | + | *fds = (*fds)[1:] | |||
| 261 | + | size := int(word(body, 1)) | |||
| 262 | + | data, err := unix.Mmap(fd, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) | |||
| 263 | + | unix.Close(fd) | |||
| 264 | + | if err != nil { | |||
| 265 | + | c.t.Errorf("server mmap: %v", err) | |||
| 266 | + | return false | |||
| 267 | + | } | |||
| 268 | + | pid := word(body, 0) | |||
| 269 | + | o.kinds[pid] = kindPool | |||
| 270 | + | o.pools[pid] = &poolMap{data: data} | |||
| 271 | + | c.mu.Lock() | |||
| 272 | + | c.pools++ | |||
| 273 | + | c.mu.Unlock() | |||
| 274 | + | } | |||
| 275 | + | case kindPool: | |||
| 276 | + | switch op { | |||
| 277 | + | case 0: | |||
| 278 | + | bid := word(body, 0) | |||
| 279 | + | o.kinds[bid] = kindBuffer | |||
| 280 | + | o.buffers[bid] = &bufferInfo{pool: o.pools[id], offset: int(word(body, 1)), width: word(body, 2), height: word(body, 3), stride: word(body, 4), format: word(body, 5)} | |||
| 281 | + | c.mu.Lock() | |||
| 282 | + | c.buffers++ | |||
| 283 | + | c.mu.Unlock() | |||
| 284 | + | case 1: | |||
| 285 | + | c.send(1, 1, id) | |||
| 286 | + | delete(o.kinds, id) | |||
| 287 | + | } | |||
| 288 | + | case kindBuffer: | |||
| 289 | + | if op == 0 { | |||
| 290 | + | c.mu.Lock() | |||
| 291 | + | c.destroyedBuffers++ | |||
| 292 | + | c.mu.Unlock() | |||
| 293 | + | c.send(1, 1, id) | |||
| 294 | + | delete(o.kinds, id) | |||
| 295 | + | delete(o.buffers, id) | |||
| 296 | + | } | |||
| 297 | + | case kindScreencopy: | |||
| 298 | + | if op <= 1 { | |||
| 299 | + | c.capture(o, op, body) | |||
| 300 | + | } | |||
| 301 | + | case kindFrame: | |||
| 302 | + | switch op { | |||
| 303 | + | case 0: | |||
| 304 | + | c.copy(o, id, o.buffers[word(body, 0)]) | |||
| 305 | + | case 1: | |||
| 306 | + | c.send(1, 1, id) | |||
| 307 | + | delete(o.kinds, id) | |||
| 308 | + | delete(o.frames, id) | |||
| 309 | + | } | |||
| 310 | + | } | |||
| 311 | + | return true | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | func (c *compositor) advertise() { | |||
| 315 | + | for i, spec := range c.cfg.outputs { | |||
| 316 | + | c.sendGlobal(uint32(i+1), "wl_output", spec.version) | |||
| 317 | + | } | |||
| 318 | + | if c.cfg.duplicateGlobal { | |||
| 319 | + | c.sendGlobal(1, "wl_output", c.cfg.outputs[0].version) | |||
| 320 | + | } | |||
| 321 | + | c.sendGlobal(globalShm, "wl_shm", 1) | |||
| 322 | + | if !c.cfg.noScreencopy { | |||
| 323 | + | c.sendGlobal(globalScreencopy, "zwlr_screencopy_manager_v1", c.cfg.screencopyVersion) | |||
| 324 | + | } | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | func (c *compositor) bind(o *objects, name uint32, iface string, version, id uint32) { | |||
| 328 | + | switch iface { | |||
| 329 | + | case "wl_output": | |||
| 330 | + | o.kinds[id] = kindOutput | |||
| 331 | + | idx := int(name) - 1 | |||
| 332 | + | o.outputs[id] = idx | |||
| 333 | + | spec := c.cfg.outputs[idx] | |||
| 334 | + | c.wmu.Lock() | |||
| 335 | + | m := c.begin(id, 0) | |||
| 336 | + | m = c.words(m, 0, 0, uint32(spec.width), uint32(spec.height), 0) | |||
| 337 | + | m = c.str(m, "Test") | |||
| 338 | + | m = c.str(m, "Model") | |||
| 339 | + | m = c.words(m, 0) | |||
| 340 | + | c.flush(m) | |||
| 341 | + | c.wmu.Unlock() | |||
| 342 | + | c.send(id, 1, 1, spec.width, spec.height, 60000) | |||
| 343 | + | if version >= 2 { | |||
| 344 | + | c.send(id, 3, uint32(spec.scale)) | |||
| 345 | + | } | |||
| 346 | + | if version >= 4 { | |||
| 347 | + | c.wmu.Lock() | |||
| 348 | + | m := c.begin(id, 4) | |||
| 349 | + | m = c.str(m, spec.name) | |||
| 350 | + | c.flush(m) | |||
| 351 | + | c.wmu.Unlock() | |||
| 352 | + | } | |||
| 353 | + | if version >= 2 { | |||
| 354 | + | c.send(id, 2) | |||
| 355 | + | } | |||
| 356 | + | case "wl_shm": | |||
| 357 | + | o.kinds[id] = kindShm | |||
| 358 | + | c.send(id, 0, 0) | |||
| 359 | + | c.send(id, 0, 1) | |||
| 360 | + | case "zwlr_screencopy_manager_v1": | |||
| 361 | + | o.kinds[id] = kindScreencopy | |||
| 362 | + | default: | |||
| 363 | + | c.t.Errorf("bind of unexpected interface %q", iface) | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func (c *compositor) capture(o *objects, op uint16, body []byte) { | |||
| 368 | + | frame := word(body, 0) | |||
| 369 | + | out := o.outputs[word(body, 2)] | |||
| 370 | + | spec := c.cfg.outputs[out] | |||
| 371 | + | w, h := spec.width, spec.height | |||
| 372 | + | c.mu.Lock() | |||
| 373 | + | c.captures++ | |||
| 374 | + | if op == 1 { | |||
| 375 | + | c.lastRegion = [4]int32{int32(word(body, 3)), int32(word(body, 4)), int32(word(body, 5)), int32(word(body, 6))} | |||
| 376 | + | c.lastRegionSet = true | |||
| 377 | + | w, h = word(body, 5), word(body, 6) | |||
| 378 | + | } | |||
| 379 | + | c.mu.Unlock() | |||
| 380 | + | o.kinds[frame] = kindFrame | |||
| 381 | + | o.frames[frame] = &frameState{output: out, w: w, h: h} | |||
| 382 | + | if c.cfg.removeOnCapture { | |||
| 383 | + | c.removeOutput(uint32(out + 1)) | |||
| 384 | + | c.send(frame, 3) | |||
| 385 | + | return | |||
| 386 | + | } | |||
| 387 | + | if c.cfg.failCapture { | |||
| 388 | + | c.send(frame, 3) | |||
| 389 | + | return | |||
| 390 | + | } | |||
| 391 | + | if c.cfg.readyEarly { | |||
| 392 | + | c.send(frame, 2, 0, 1, 500) | |||
| 393 | + | return | |||
| 394 | + | } | |||
| 395 | + | list := c.cfg.announce | |||
| 396 | + | if c.cfg.noShmOffer { | |||
| 397 | + | c.send(frame, 6) | |||
| 398 | + | return | |||
| 399 | + | } | |||
| 400 | + | if len(list) == 0 { | |||
| 401 | + | list = []announce{{format: 1, width: w, height: h, stride: w * 4}} | |||
| 402 | + | } | |||
| 403 | + | for _, a := range list { | |||
| 404 | + | c.send(frame, 0, a.format, a.width, a.height, a.stride) | |||
| 405 | + | } | |||
| 406 | + | if c.cfg.screencopyVersion >= 3 { | |||
| 407 | + | c.send(frame, 6) | |||
| 408 | + | } | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | func (c *compositor) copy(o *objects, frame uint32, b *bufferInfo) { | |||
| 412 | + | if b == nil { | |||
| 413 | + | c.t.Error("copy with unknown buffer") | |||
| 414 | + | return | |||
| 415 | + | } | |||
| 416 | + | c.mu.Lock() | |||
| 417 | + | c.copies++ | |||
| 418 | + | if c.cfg.failCopies > 0 { | |||
| 419 | + | c.cfg.failCopies-- | |||
| 420 | + | c.mu.Unlock() | |||
| 421 | + | c.send(frame, 3) | |||
| 422 | + | return | |||
| 423 | + | } | |||
| 424 | + | hold := c.cfg.hold | |||
| 425 | + | if hold { | |||
| 426 | + | c.pending.frame, c.pending.buffer, c.hasPending = frame, b, true | |||
| 427 | + | } | |||
| 428 | + | c.mu.Unlock() | |||
| 429 | + | select { | |||
| 430 | + | case c.copyEvent <- struct{}{}: | |||
| 431 | + | default: | |||
| 432 | + | } | |||
| 433 | + | if !hold { | |||
| 434 | + | c.finish(frame, b) | |||
| 435 | + | } | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | // completePending answers a copy that was held. | |||
| 439 | + | func (c *compositor) completePending() { | |||
| 440 | + | c.mu.Lock() | |||
| 441 | + | frame, b, ok := c.pending.frame, c.pending.buffer, c.hasPending | |||
| 442 | + | c.hasPending = false | |||
| 443 | + | c.mu.Unlock() | |||
| 444 | + | if !ok { | |||
| 445 | + | c.t.Error("no pending copy") | |||
| 446 | + | return | |||
| 447 | + | } | |||
| 448 | + | c.finish(frame, b) | |||
| 449 | + | } | |||
| 450 | + | ||||
| 451 | + | // pixel is the deterministic content written for (x, y) of frame number seq. | |||
| 452 | + | func pixel(x, y int, seq uint32) [4]byte { | |||
| 453 | + | return [4]byte{byte(x), byte(y), byte(seq), 0xff} | |||
| 454 | + | } | |||
| 455 | + | ||||
| 456 | + | func (c *compositor) finish(frame uint32, b *bufferInfo) { | |||
| 457 | + | c.mu.Lock() | |||
| 458 | + | c.seq++ | |||
| 459 | + | seq := c.seq | |||
| 460 | + | c.mu.Unlock() | |||
| 461 | + | data := b.pool.data[b.offset:] | |||
| 462 | + | for y := 0; y < int(b.height); y++ { | |||
| 463 | + | row := data[y*int(b.stride) : (y+1)*int(b.stride)] | |||
| 464 | + | for x := 0; x < int(b.width); x++ { | |||
| 465 | + | p := pixel(x, y, seq) | |||
| 466 | + | copy(row[x*4:], p[:]) | |||
| 467 | + | } | |||
| 468 | + | for i := int(b.width) * 4; i < len(row); i++ { | |||
| 469 | + | row[i] = 0xEE // stride padding | |||
| 470 | + | } | |||
| 471 | + | } | |||
| 472 | + | c.send(frame, 1, c.cfg.flags) | |||
| 473 | + | c.send(frame, 2, 0, 1, 500) | |||
| 474 | + | } | |||
| 475 | + | ||||
| 476 | + | // removeOutput announces the removal of output global name (1-based). | |||
| 477 | + | func (c *compositor) removeOutput(name uint32) { | |||
| 478 | + | c.wmu.Lock() | |||
| 479 | + | m := c.begin(c.reg, 1) | |||
| 480 | + | m = c.words(m, name) | |||
| 481 | + | c.flush(m) | |||
| 482 | + | c.wmu.Unlock() | |||
| 483 | + | } | |||
| 484 | + | ||||
| 485 | + | // Encoding helpers. Every message is built in c.out under wmu. | |||
| 486 | + | ||||
| 487 | + | func (c *compositor) send(obj uint32, op uint16, args ...uint32) { | |||
| 488 | + | c.wmu.Lock() | |||
| 489 | + | m := c.begin(obj, op) | |||
| 490 | + | m = c.words(m, args...) | |||
| 491 | + | c.flush(m) | |||
| 492 | + | c.wmu.Unlock() | |||
| 493 | + | } | |||
| 494 | + | ||||
| 495 | + | func (c *compositor) sendGlobal(name uint32, iface string, version uint32) { | |||
| 496 | + | c.wmu.Lock() | |||
| 497 | + | m := c.begin(c.reg, 0) | |||
| 498 | + | m = c.words(m, name) | |||
| 499 | + | m = c.str(m, iface) | |||
| 500 | + | m = c.words(m, version) | |||
| 501 | + | c.flush(m) | |||
| 502 | + | c.wmu.Unlock() | |||
| 503 | + | } | |||
| 504 | + | ||||
| 505 | + | func (c *compositor) begin(obj uint32, op uint16) []byte { | |||
| 506 | + | m := c.out[:8] | |||
| 507 | + | binary.LittleEndian.PutUint32(m, obj) | |||
| 508 | + | binary.LittleEndian.PutUint32(m[4:], uint32(op)) | |||
| 509 | + | return m | |||
| 510 | + | } | |||
| 511 | + | ||||
| 512 | + | func (c *compositor) words(m []byte, args ...uint32) []byte { | |||
| 513 | + | for _, a := range args { | |||
| 514 | + | m = binary.LittleEndian.AppendUint32(m, a) | |||
| 515 | + | } | |||
| 516 | + | return m | |||
| 517 | + | } | |||
| 518 | + | ||||
| 519 | + | func (c *compositor) str(m []byte, s string) []byte { | |||
| 520 | + | m = binary.LittleEndian.AppendUint32(m, uint32(len(s)+1)) | |||
| 521 | + | m = append(m, s...) | |||
| 522 | + | m = append(m, 0) | |||
| 523 | + | return append(m, bytes.Repeat([]byte{0}, (4-(len(s)+1)%4)%4)...) | |||
| 524 | + | } | |||
| 525 | + | ||||
| 526 | + | func (c *compositor) flush(m []byte) { | |||
| 527 | + | binary.LittleEndian.PutUint32(m[4:], uint32(len(m))<<16|binary.LittleEndian.Uint32(m[4:])&0xffff) | |||
| 528 | + | c.mu.Lock() | |||
| 529 | + | conn := c.conn | |||
| 530 | + | c.mu.Unlock() | |||
| 531 | + | if _, err := conn.Write(m); err != nil { | |||
| 532 | + | // The client may have gone away during teardown. | |||
| 533 | + | return | |||
| 534 | + | } | |||
| 535 | + | } | |||
A internal/adapters/wayland/norace_test.go +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | //go:build !race | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | const raceEnabled = false | |
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | //go:build !race | |||
| 2 | + | ||||
| 3 | + | package wayland | |||
| 4 | + | ||||
| 5 | + | const raceEnabled = false | |||
A internal/adapters/wayland/race_test.go +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | //go:build race | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | // The race detector adds allocations, so allocation budgets do not apply. | |
| 6 | + | const raceEnabled = true | |
| @@ -0,0 +1,6 @@ | |||||
| 1 | + | //go:build race | |||
| 2 | + | ||||
| 3 | + | package wayland | |||
| 4 | + | ||||
| 5 | + | // The race detector adds allocations, so allocation budgets do not apply. | |||
| 6 | + | const raceEnabled = true | |||
A internal/adapters/wayland/screencopy.go +199 -0
| @@ -0,0 +1,199 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/wlturbo" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/nefercap/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // wl_shm format values for the two supported native little-endian layouts. | |
| 13 | + | const ( | |
| 14 | + | shmARGB8888 = uint32(ports.ARGB8888) | |
| 15 | + | shmXRGB8888 = uint32(ports.XRGB8888) | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | const flagYInvert = 1 // zwlr_screencopy_frame_v1.flags.y_invert | |
| 19 | + | ||
| 20 | + | // The generated bindings of wlturbo v0.3 do not cover wlr-screencopy, so the | |
| 21 | + | // two objects used are declared here with their wire signatures. | |
| 22 | + | ||
| 23 | + | type screencopyManager struct{ wlturbo.BaseProxy } | |
| 24 | + | ||
| 25 | + | func (*screencopyManager) EventSignature(uint16) (string, bool) { return "", false } | |
| 26 | + | ||
| 27 | + | const ( | |
| 28 | + | reqCaptureOutput = 0 | |
| 29 | + | reqCaptureOutputRegion = 1 | |
| 30 | + | reqFrameCopy = 0 | |
| 31 | + | reqFrameDestroy = 1 | |
| 32 | + | ) | |
| 33 | + | ||
| 34 | + | // frameProxy is one zwlr_screencopy_frame_v1. Its events are dispatched on the | |
| 35 | + | // goroutine that owns the operation. | |
| 36 | + | type frameProxy struct { | |
| 37 | + | wlturbo.BaseProxy | |
| 38 | + | out *output | |
| 39 | + | ||
| 40 | + | haveBuffer bool // a buffer event announcing a supported shm format arrived | |
| 41 | + | sawBuffer bool | |
| 42 | + | unsupported uint32 // last rejected format | |
| 43 | + | bufDone bool | |
| 44 | + | geom bufGeom | |
| 45 | + | ||
| 46 | + | copied bool | |
| 47 | + | yInvert bool | |
| 48 | + | ready bool | |
| 49 | + | failed bool | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (*frameProxy) EventSignature(op uint16) (string, bool) { | |
| 53 | + | switch op { | |
| 54 | + | case 0, 4: | |
| 55 | + | return "uint,uint,uint,uint,", true // buffer, damage | |
| 56 | + | case 1: | |
| 57 | + | return "uint,", true // flags | |
| 58 | + | case 2, 5: | |
| 59 | + | return "uint,uint,uint,", true // ready, linux_dmabuf | |
| 60 | + | case 3, 6: | |
| 61 | + | return "", true // failed, buffer_done | |
| 62 | + | } | |
| 63 | + | return "", false | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func (f *frameProxy) Dispatch(e *wlturbo.Event) { | |
| 67 | + | switch e.Opcode { | |
| 68 | + | case 0: | |
| 69 | + | format, w, h, stride := e.Uint32(), e.Uint32(), e.Uint32(), e.Uint32() | |
| 70 | + | f.sawBuffer = true | |
| 71 | + | switch { | |
| 72 | + | case format != shmARGB8888 && format != shmXRGB8888: | |
| 73 | + | f.unsupported = format | |
| 74 | + | case !f.haveBuffer: | |
| 75 | + | f.haveBuffer = true | |
| 76 | + | f.geom = bufGeom{format: format, width: w, height: h, stride: stride} | |
| 77 | + | } | |
| 78 | + | case 1: | |
| 79 | + | f.yInvert = e.Uint32()&flagYInvert != 0 | |
| 80 | + | case 2: | |
| 81 | + | f.ready = true | |
| 82 | + | case 3: | |
| 83 | + | f.failed = true | |
| 84 | + | case 6: | |
| 85 | + | f.bufDone = true | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func validRegion(r ports.Region) error { | |
| 90 | + | if r == (ports.Region{}) { | |
| 91 | + | return nil | |
| 92 | + | } | |
| 93 | + | if r.X < 0 || r.Y < 0 || r.Width < 1 || r.Height < 1 || | |
| 94 | + | r.X > ports.MaxDimension || r.Y > ports.MaxDimension || | |
| 95 | + | r.Width > ports.MaxDimension || r.Height > ports.MaxDimension { | |
| 96 | + | return fmt.Errorf("%w: %+v", ports.ErrInvalidRegion, r) | |
| 97 | + | } | |
| 98 | + | return nil | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (s *Source) capture(t ports.Target) (ports.Frame, error) { | |
| 102 | + | out := s.outputs[t.OutputID] | |
| 103 | + | if out == nil { | |
| 104 | + | return ports.Frame{}, ports.ErrOutputNotFound | |
| 105 | + | } | |
| 106 | + | f := &frameProxy{out: out} | |
| 107 | + | f.SetContext(s.wl) | |
| 108 | + | var err error | |
| 109 | + | if t.Region == (ports.Region{}) { | |
| 110 | + | err = s.wl.Request(wlturbo.Request{Proxy: s.screencopy, Opcode: reqCaptureOutput, Name: "zwlr_screencopy_manager_v1.capture_output", Child: f}, | |
| 111 | + | f, int32(0), out.proxy) | |
| 112 | + | } else { | |
| 113 | + | r := t.Region | |
| 114 | + | err = s.wl.Request(wlturbo.Request{Proxy: s.screencopy, Opcode: reqCaptureOutputRegion, Name: "zwlr_screencopy_manager_v1.capture_output_region", Child: f}, | |
| 115 | + | f, int32(0), out.proxy, int32(r.X), int32(r.Y), int32(r.Width), int32(r.Height)) | |
| 116 | + | } | |
| 117 | + | if err != nil { | |
| 118 | + | s.terminate() | |
| 119 | + | return ports.Frame{}, fmt.Errorf("wayland: capture request: %w", err) | |
| 120 | + | } | |
| 121 | + | // Until copy is sent the compositor does not touch our buffer, so a | |
| 122 | + | // negotiation failure leaves the Source usable. After copy, any failure | |
| 123 | + | // other than the compositor's own "failed" event ends in terminal state. | |
| 124 | + | for { | |
| 125 | + | if err := s.dispatch(); err != nil { | |
| 126 | + | return ports.Frame{}, err | |
| 127 | + | } | |
| 128 | + | switch { | |
| 129 | + | case f.failed: | |
| 130 | + | s.destroyFrame(f) | |
| 131 | + | // The removal may trail the failure on the wire: read it. | |
| 132 | + | if err := s.roundtrip(); err != nil { | |
| 133 | + | return ports.Frame{}, err | |
| 134 | + | } | |
| 135 | + | if f.out.removed { | |
| 136 | + | return ports.Frame{}, ports.ErrOutputNotFound | |
| 137 | + | } | |
| 138 | + | return ports.Frame{}, errors.New("wayland: compositor failed the capture") | |
| 139 | + | case f.ready && !f.copied: | |
| 140 | + | s.terminate() | |
| 141 | + | return ports.Frame{}, errors.New("wayland: compositor completed a frame that was never copied") | |
| 142 | + | case f.ready: | |
| 143 | + | s.destroyFrame(f) | |
| 144 | + | return s.frameOf(f) | |
| 145 | + | case !f.copied && s.negotiated(f): | |
| 146 | + | if err := s.startCopy(f); err != nil { | |
| 147 | + | s.destroyFrame(f) | |
| 148 | + | return ports.Frame{}, err | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // negotiated reports whether the compositor announced every buffer type. | |
| 155 | + | // Before version 3 there is no buffer_done: the single buffer event is final. | |
| 156 | + | func (s *Source) negotiated(f *frameProxy) bool { | |
| 157 | + | if s.screencopy.Version() >= 3 { | |
| 158 | + | return f.bufDone | |
| 159 | + | } | |
| 160 | + | return f.sawBuffer | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (s *Source) startCopy(f *frameProxy) error { | |
| 164 | + | if !f.sawBuffer { | |
| 165 | + | return fmt.Errorf("%w: compositor offers no wl_shm buffer", ports.ErrUnsupportedFormat) | |
| 166 | + | } | |
| 167 | + | if !f.haveBuffer { | |
| 168 | + | return fmt.Errorf("%w: compositor offers shm format %#x", ports.ErrUnsupportedFormat, f.unsupported) | |
| 169 | + | } | |
| 170 | + | if err := f.geom.validate(); err != nil { | |
| 171 | + | return err | |
| 172 | + | } | |
| 173 | + | if err := s.ensureBuffer(f.geom); err != nil { | |
| 174 | + | return err | |
| 175 | + | } | |
| 176 | + | f.copied = true | |
| 177 | + | if err := s.wl.Request(wlturbo.Request{Proxy: f, Opcode: reqFrameCopy, Name: "zwlr_screencopy_frame_v1.copy"}, s.buf.buffer); err != nil { | |
| 178 | + | s.terminate() | |
| 179 | + | return fmt.Errorf("wayland: copy request: %w", err) | |
| 180 | + | } | |
| 181 | + | return nil | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func (s *Source) destroyFrame(f *frameProxy) { | |
| 185 | + | _ = s.wl.Request(wlturbo.Request{Proxy: f, Opcode: reqFrameDestroy, Name: "zwlr_screencopy_frame_v1.destroy", Destructor: true}) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func (s *Source) frameOf(f *frameProxy) (ports.Frame, error) { | |
| 189 | + | g := f.geom | |
| 190 | + | frame := ports.Frame{ | |
| 191 | + | Pixels: s.buf.data[:g.size:g.size], | |
| 192 | + | Width: int(g.width), Height: int(g.height), Stride: int(g.stride), | |
| 193 | + | Format: ports.PixelFormat(g.format), YInvert: f.yInvert, | |
| 194 | + | } | |
| 195 | + | if err := frame.Validate(); err != nil { | |
| 196 | + | return ports.Frame{}, err | |
| 197 | + | } | |
| 198 | + | return frame, nil | |
| 199 | + | } | |
| @@ -0,0 +1,199 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/wlturbo" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/nefercap/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // wl_shm format values for the two supported native little-endian layouts. | |||
| 13 | + | const ( | |||
| 14 | + | shmARGB8888 = uint32(ports.ARGB8888) | |||
| 15 | + | shmXRGB8888 = uint32(ports.XRGB8888) | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | const flagYInvert = 1 // zwlr_screencopy_frame_v1.flags.y_invert | |||
| 19 | + | ||||
| 20 | + | // The generated bindings of wlturbo v0.3 do not cover wlr-screencopy, so the | |||
| 21 | + | // two objects used are declared here with their wire signatures. | |||
| 22 | + | ||||
| 23 | + | type screencopyManager struct{ wlturbo.BaseProxy } | |||
| 24 | + | ||||
| 25 | + | func (*screencopyManager) EventSignature(uint16) (string, bool) { return "", false } | |||
| 26 | + | ||||
| 27 | + | const ( | |||
| 28 | + | reqCaptureOutput = 0 | |||
| 29 | + | reqCaptureOutputRegion = 1 | |||
| 30 | + | reqFrameCopy = 0 | |||
| 31 | + | reqFrameDestroy = 1 | |||
| 32 | + | ) | |||
| 33 | + | ||||
| 34 | + | // frameProxy is one zwlr_screencopy_frame_v1. Its events are dispatched on the | |||
| 35 | + | // goroutine that owns the operation. | |||
| 36 | + | type frameProxy struct { | |||
| 37 | + | wlturbo.BaseProxy | |||
| 38 | + | out *output | |||
| 39 | + | ||||
| 40 | + | haveBuffer bool // a buffer event announcing a supported shm format arrived | |||
| 41 | + | sawBuffer bool | |||
| 42 | + | unsupported uint32 // last rejected format | |||
| 43 | + | bufDone bool | |||
| 44 | + | geom bufGeom | |||
| 45 | + | ||||
| 46 | + | copied bool | |||
| 47 | + | yInvert bool | |||
| 48 | + | ready bool | |||
| 49 | + | failed bool | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (*frameProxy) EventSignature(op uint16) (string, bool) { | |||
| 53 | + | switch op { | |||
| 54 | + | case 0, 4: | |||
| 55 | + | return "uint,uint,uint,uint,", true // buffer, damage | |||
| 56 | + | case 1: | |||
| 57 | + | return "uint,", true // flags | |||
| 58 | + | case 2, 5: | |||
| 59 | + | return "uint,uint,uint,", true // ready, linux_dmabuf | |||
| 60 | + | case 3, 6: | |||
| 61 | + | return "", true // failed, buffer_done | |||
| 62 | + | } | |||
| 63 | + | return "", false | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func (f *frameProxy) Dispatch(e *wlturbo.Event) { | |||
| 67 | + | switch e.Opcode { | |||
| 68 | + | case 0: | |||
| 69 | + | format, w, h, stride := e.Uint32(), e.Uint32(), e.Uint32(), e.Uint32() | |||
| 70 | + | f.sawBuffer = true | |||
| 71 | + | switch { | |||
| 72 | + | case format != shmARGB8888 && format != shmXRGB8888: | |||
| 73 | + | f.unsupported = format | |||
| 74 | + | case !f.haveBuffer: | |||
| 75 | + | f.haveBuffer = true | |||
| 76 | + | f.geom = bufGeom{format: format, width: w, height: h, stride: stride} | |||
| 77 | + | } | |||
| 78 | + | case 1: | |||
| 79 | + | f.yInvert = e.Uint32()&flagYInvert != 0 | |||
| 80 | + | case 2: | |||
| 81 | + | f.ready = true | |||
| 82 | + | case 3: | |||
| 83 | + | f.failed = true | |||
| 84 | + | case 6: | |||
| 85 | + | f.bufDone = true | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func validRegion(r ports.Region) error { | |||
| 90 | + | if r == (ports.Region{}) { | |||
| 91 | + | return nil | |||
| 92 | + | } | |||
| 93 | + | if r.X < 0 || r.Y < 0 || r.Width < 1 || r.Height < 1 || | |||
| 94 | + | r.X > ports.MaxDimension || r.Y > ports.MaxDimension || | |||
| 95 | + | r.Width > ports.MaxDimension || r.Height > ports.MaxDimension { | |||
| 96 | + | return fmt.Errorf("%w: %+v", ports.ErrInvalidRegion, r) | |||
| 97 | + | } | |||
| 98 | + | return nil | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func (s *Source) capture(t ports.Target) (ports.Frame, error) { | |||
| 102 | + | out := s.outputs[t.OutputID] | |||
| 103 | + | if out == nil { | |||
| 104 | + | return ports.Frame{}, ports.ErrOutputNotFound | |||
| 105 | + | } | |||
| 106 | + | f := &frameProxy{out: out} | |||
| 107 | + | f.SetContext(s.wl) | |||
| 108 | + | var err error | |||
| 109 | + | if t.Region == (ports.Region{}) { | |||
| 110 | + | err = s.wl.Request(wlturbo.Request{Proxy: s.screencopy, Opcode: reqCaptureOutput, Name: "zwlr_screencopy_manager_v1.capture_output", Child: f}, | |||
| 111 | + | f, int32(0), out.proxy) | |||
| 112 | + | } else { | |||
| 113 | + | r := t.Region | |||
| 114 | + | err = s.wl.Request(wlturbo.Request{Proxy: s.screencopy, Opcode: reqCaptureOutputRegion, Name: "zwlr_screencopy_manager_v1.capture_output_region", Child: f}, | |||
| 115 | + | f, int32(0), out.proxy, int32(r.X), int32(r.Y), int32(r.Width), int32(r.Height)) | |||
| 116 | + | } | |||
| 117 | + | if err != nil { | |||
| 118 | + | s.terminate() | |||
| 119 | + | return ports.Frame{}, fmt.Errorf("wayland: capture request: %w", err) | |||
| 120 | + | } | |||
| 121 | + | // Until copy is sent the compositor does not touch our buffer, so a | |||
| 122 | + | // negotiation failure leaves the Source usable. After copy, any failure | |||
| 123 | + | // other than the compositor's own "failed" event ends in terminal state. | |||
| 124 | + | for { | |||
| 125 | + | if err := s.dispatch(); err != nil { | |||
| 126 | + | return ports.Frame{}, err | |||
| 127 | + | } | |||
| 128 | + | switch { | |||
| 129 | + | case f.failed: | |||
| 130 | + | s.destroyFrame(f) | |||
| 131 | + | // The removal may trail the failure on the wire: read it. | |||
| 132 | + | if err := s.roundtrip(); err != nil { | |||
| 133 | + | return ports.Frame{}, err | |||
| 134 | + | } | |||
| 135 | + | if f.out.removed { | |||
| 136 | + | return ports.Frame{}, ports.ErrOutputNotFound | |||
| 137 | + | } | |||
| 138 | + | return ports.Frame{}, errors.New("wayland: compositor failed the capture") | |||
| 139 | + | case f.ready && !f.copied: | |||
| 140 | + | s.terminate() | |||
| 141 | + | return ports.Frame{}, errors.New("wayland: compositor completed a frame that was never copied") | |||
| 142 | + | case f.ready: | |||
| 143 | + | s.destroyFrame(f) | |||
| 144 | + | return s.frameOf(f) | |||
| 145 | + | case !f.copied && s.negotiated(f): | |||
| 146 | + | if err := s.startCopy(f); err != nil { | |||
| 147 | + | s.destroyFrame(f) | |||
| 148 | + | return ports.Frame{}, err | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // negotiated reports whether the compositor announced every buffer type. | |||
| 155 | + | // Before version 3 there is no buffer_done: the single buffer event is final. | |||
| 156 | + | func (s *Source) negotiated(f *frameProxy) bool { | |||
| 157 | + | if s.screencopy.Version() >= 3 { | |||
| 158 | + | return f.bufDone | |||
| 159 | + | } | |||
| 160 | + | return f.sawBuffer | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func (s *Source) startCopy(f *frameProxy) error { | |||
| 164 | + | if !f.sawBuffer { | |||
| 165 | + | return fmt.Errorf("%w: compositor offers no wl_shm buffer", ports.ErrUnsupportedFormat) | |||
| 166 | + | } | |||
| 167 | + | if !f.haveBuffer { | |||
| 168 | + | return fmt.Errorf("%w: compositor offers shm format %#x", ports.ErrUnsupportedFormat, f.unsupported) | |||
| 169 | + | } | |||
| 170 | + | if err := f.geom.validate(); err != nil { | |||
| 171 | + | return err | |||
| 172 | + | } | |||
| 173 | + | if err := s.ensureBuffer(f.geom); err != nil { | |||
| 174 | + | return err | |||
| 175 | + | } | |||
| 176 | + | f.copied = true | |||
| 177 | + | if err := s.wl.Request(wlturbo.Request{Proxy: f, Opcode: reqFrameCopy, Name: "zwlr_screencopy_frame_v1.copy"}, s.buf.buffer); err != nil { | |||
| 178 | + | s.terminate() | |||
| 179 | + | return fmt.Errorf("wayland: copy request: %w", err) | |||
| 180 | + | } | |||
| 181 | + | return nil | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func (s *Source) destroyFrame(f *frameProxy) { | |||
| 185 | + | _ = s.wl.Request(wlturbo.Request{Proxy: f, Opcode: reqFrameDestroy, Name: "zwlr_screencopy_frame_v1.destroy", Destructor: true}) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func (s *Source) frameOf(f *frameProxy) (ports.Frame, error) { | |||
| 189 | + | g := f.geom | |||
| 190 | + | frame := ports.Frame{ | |||
| 191 | + | Pixels: s.buf.data[:g.size:g.size], | |||
| 192 | + | Width: int(g.width), Height: int(g.height), Stride: int(g.stride), | |||
| 193 | + | Format: ports.PixelFormat(g.format), YInvert: f.yInvert, | |||
| 194 | + | } | |||
| 195 | + | if err := frame.Validate(); err != nil { | |||
| 196 | + | return ports.Frame{}, err | |||
| 197 | + | } | |||
| 198 | + | return frame, nil | |||
| 199 | + | } | |||
A internal/adapters/wayland/shm.go +94 -0
| @@ -0,0 +1,94 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/wlturbo" | |
| 8 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 9 | + | "golang.org/x/sys/unix" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/nefercap/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // bufGeom is the buffer layout announced by the compositor. It is reused only | |
| 15 | + | // while the compositor keeps announcing the same one. | |
| 16 | + | type bufGeom struct { | |
| 17 | + | format, width, height, stride uint32 | |
| 18 | + | size int | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // validate bounds every dimension before any memory is created or mapped. | |
| 22 | + | func (g *bufGeom) validate() error { | |
| 23 | + | if g.width < 1 || g.height < 1 || g.width > ports.MaxDimension || g.height > ports.MaxDimension { | |
| 24 | + | return fmt.Errorf("wayland: invalid buffer dimensions %dx%d", g.width, g.height) | |
| 25 | + | } | |
| 26 | + | if g.stride < g.width*ports.BytesPerPixel || g.stride%ports.BytesPerPixel != 0 || g.stride > ports.MaxFrameBytes/g.height { | |
| 27 | + | return fmt.Errorf("wayland: invalid buffer stride %d for %dx%d", g.stride, g.width, g.height) | |
| 28 | + | } | |
| 29 | + | g.size = int(g.stride) * int(g.height) | |
| 30 | + | return nil | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // shmBuffer is the single wl_shm mapping and wl_buffer reused between | |
| 34 | + | // captures. The pool proxy is destroyed right after buffer creation, which the | |
| 35 | + | // protocol allows: the buffer keeps the compositor-side mapping alive. | |
| 36 | + | type shmBuffer struct { | |
| 37 | + | geom bufGeom | |
| 38 | + | data []byte | |
| 39 | + | buffer *core.Buffer | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // ensureBuffer makes s.buf match g, reusing it when unchanged. | |
| 43 | + | func (s *Source) ensureBuffer(g bufGeom) error { | |
| 44 | + | if s.buf.buffer != nil && s.buf.geom == g { | |
| 45 | + | return nil | |
| 46 | + | } | |
| 47 | + | if err := s.releaseBuffer(true); err != nil { | |
| 48 | + | s.terminate() | |
| 49 | + | return err | |
| 50 | + | } | |
| 51 | + | fd, err := wlturbo.CreateAnonymousFile(int64(g.size)) | |
| 52 | + | if err != nil { | |
| 53 | + | return fmt.Errorf("wayland: create shm file: %w", err) | |
| 54 | + | } | |
| 55 | + | data, err := wlturbo.MapMemory(fd, g.size) | |
| 56 | + | if err != nil { | |
| 57 | + | _ = unix.Close(fd) | |
| 58 | + | return fmt.Errorf("wayland: map shm file: %w", err) | |
| 59 | + | } | |
| 60 | + | // Context.Request closes fd once the message is sent, so on success the | |
| 61 | + | // descriptor belongs to the transport (the mapping outlives it). On error | |
| 62 | + | // the caller still owns it. | |
| 63 | + | pool, err := s.shm.CreatePool(fd, int32(g.size)) | |
| 64 | + | if err != nil { | |
| 65 | + | _ = unix.Close(fd) | |
| 66 | + | _ = wlturbo.UnmapMemory(data) | |
| 67 | + | s.terminate() | |
| 68 | + | return fmt.Errorf("wayland: create pool: %w", err) | |
| 69 | + | } | |
| 70 | + | buffer, err := pool.CreateBuffer(0, int32(g.width), int32(g.height), int32(g.stride), g.format) | |
| 71 | + | _ = pool.Destroy() | |
| 72 | + | if err != nil { | |
| 73 | + | _ = wlturbo.UnmapMemory(data) | |
| 74 | + | s.terminate() | |
| 75 | + | return fmt.Errorf("wayland: create buffer: %w", err) | |
| 76 | + | } | |
| 77 | + | s.buf = shmBuffer{geom: g, data: data, buffer: buffer} | |
| 78 | + | return nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // releaseBuffer unmaps the storage. With destroy set, and while connected, it | |
| 82 | + | // also destroys the wl_buffer. The caller holds opMu. | |
| 83 | + | func (s *Source) releaseBuffer(destroy bool) error { | |
| 84 | + | b := s.buf | |
| 85 | + | s.buf = shmBuffer{} | |
| 86 | + | var err error | |
| 87 | + | if b.buffer != nil && destroy && !s.dead.Load() { | |
| 88 | + | err = b.buffer.Destroy() | |
| 89 | + | } | |
| 90 | + | if b.data != nil { | |
| 91 | + | err = errors.Join(err, wlturbo.UnmapMemory(b.data)) | |
| 92 | + | } | |
| 93 | + | return err | |
| 94 | + | } | |
| @@ -0,0 +1,94 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/wlturbo" | |||
| 8 | + | "github.com/bnema/wlturbo/protocol/core" | |||
| 9 | + | "golang.org/x/sys/unix" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/nefercap/internal/ports" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // bufGeom is the buffer layout announced by the compositor. It is reused only | |||
| 15 | + | // while the compositor keeps announcing the same one. | |||
| 16 | + | type bufGeom struct { | |||
| 17 | + | format, width, height, stride uint32 | |||
| 18 | + | size int | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // validate bounds every dimension before any memory is created or mapped. | |||
| 22 | + | func (g *bufGeom) validate() error { | |||
| 23 | + | if g.width < 1 || g.height < 1 || g.width > ports.MaxDimension || g.height > ports.MaxDimension { | |||
| 24 | + | return fmt.Errorf("wayland: invalid buffer dimensions %dx%d", g.width, g.height) | |||
| 25 | + | } | |||
| 26 | + | if g.stride < g.width*ports.BytesPerPixel || g.stride%ports.BytesPerPixel != 0 || g.stride > ports.MaxFrameBytes/g.height { | |||
| 27 | + | return fmt.Errorf("wayland: invalid buffer stride %d for %dx%d", g.stride, g.width, g.height) | |||
| 28 | + | } | |||
| 29 | + | g.size = int(g.stride) * int(g.height) | |||
| 30 | + | return nil | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // shmBuffer is the single wl_shm mapping and wl_buffer reused between | |||
| 34 | + | // captures. The pool proxy is destroyed right after buffer creation, which the | |||
| 35 | + | // protocol allows: the buffer keeps the compositor-side mapping alive. | |||
| 36 | + | type shmBuffer struct { | |||
| 37 | + | geom bufGeom | |||
| 38 | + | data []byte | |||
| 39 | + | buffer *core.Buffer | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // ensureBuffer makes s.buf match g, reusing it when unchanged. | |||
| 43 | + | func (s *Source) ensureBuffer(g bufGeom) error { | |||
| 44 | + | if s.buf.buffer != nil && s.buf.geom == g { | |||
| 45 | + | return nil | |||
| 46 | + | } | |||
| 47 | + | if err := s.releaseBuffer(true); err != nil { | |||
| 48 | + | s.terminate() | |||
| 49 | + | return err | |||
| 50 | + | } | |||
| 51 | + | fd, err := wlturbo.CreateAnonymousFile(int64(g.size)) | |||
| 52 | + | if err != nil { | |||
| 53 | + | return fmt.Errorf("wayland: create shm file: %w", err) | |||
| 54 | + | } | |||
| 55 | + | data, err := wlturbo.MapMemory(fd, g.size) | |||
| 56 | + | if err != nil { | |||
| 57 | + | _ = unix.Close(fd) | |||
| 58 | + | return fmt.Errorf("wayland: map shm file: %w", err) | |||
| 59 | + | } | |||
| 60 | + | // Context.Request closes fd once the message is sent, so on success the | |||
| 61 | + | // descriptor belongs to the transport (the mapping outlives it). On error | |||
| 62 | + | // the caller still owns it. | |||
| 63 | + | pool, err := s.shm.CreatePool(fd, int32(g.size)) | |||
| 64 | + | if err != nil { | |||
| 65 | + | _ = unix.Close(fd) | |||
| 66 | + | _ = wlturbo.UnmapMemory(data) | |||
| 67 | + | s.terminate() | |||
| 68 | + | return fmt.Errorf("wayland: create pool: %w", err) | |||
| 69 | + | } | |||
| 70 | + | buffer, err := pool.CreateBuffer(0, int32(g.width), int32(g.height), int32(g.stride), g.format) | |||
| 71 | + | _ = pool.Destroy() | |||
| 72 | + | if err != nil { | |||
| 73 | + | _ = wlturbo.UnmapMemory(data) | |||
| 74 | + | s.terminate() | |||
| 75 | + | return fmt.Errorf("wayland: create buffer: %w", err) | |||
| 76 | + | } | |||
| 77 | + | s.buf = shmBuffer{geom: g, data: data, buffer: buffer} | |||
| 78 | + | return nil | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // releaseBuffer unmaps the storage. With destroy set, and while connected, it | |||
| 82 | + | // also destroys the wl_buffer. The caller holds opMu. | |||
| 83 | + | func (s *Source) releaseBuffer(destroy bool) error { | |||
| 84 | + | b := s.buf | |||
| 85 | + | s.buf = shmBuffer{} | |||
| 86 | + | var err error | |||
| 87 | + | if b.buffer != nil && destroy && !s.dead.Load() { | |||
| 88 | + | err = b.buffer.Destroy() | |||
| 89 | + | } | |||
| 90 | + | if b.data != nil { | |||
| 91 | + | err = errors.Join(err, wlturbo.UnmapMemory(b.data)) | |||
| 92 | + | } | |||
| 93 | + | return err | |||
| 94 | + | } | |||
A internal/adapters/wayland/source.go +351 -0
| @@ -0,0 +1,351 @@ | |||
| 1 | + | // Package wayland captures output frames over the wlr-screencopy protocol | |
| 2 | + | // using the wlturbo transport and wl_shm buffers. | |
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "cmp" | |
| 7 | + | "context" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "net" | |
| 11 | + | "os" | |
| 12 | + | "path/filepath" | |
| 13 | + | "slices" | |
| 14 | + | "sync" | |
| 15 | + | "sync/atomic" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/wlturbo" | |
| 18 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 19 | + | "github.com/bnema/zerowrap" | |
| 20 | + | ||
| 21 | + | "github.com/bnema/nefercap/internal/logging" | |
| 22 | + | "github.com/bnema/nefercap/internal/ports" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | const ( | |
| 26 | + | outputVersion = 4 // wl_output.name arrived in version 4 | |
| 27 | + | shmVersion = 1 | |
| 28 | + | screencopyVersion = 3 | |
| 29 | + | screencopyIface = "zwlr_screencopy_manager_v1" | |
| 30 | + | maxSettleRounds = 4 | |
| 31 | + | maxOutputScale = 16 // informational; larger announcements are clamped | |
| 32 | + | ) | |
| 33 | + | ||
| 34 | + | // output is the client-side state of one bound wl_output global. | |
| 35 | + | type output struct { | |
| 36 | + | proxy *core.Output | |
| 37 | + | id uint32 // wl_registry global name, stable for the connection | |
| 38 | + | version uint32 | |
| 39 | + | name string | |
| 40 | + | width, height int32 | |
| 41 | + | scale int32 | |
| 42 | + | removed bool | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Source is a ports.Source backed by one Wayland connection. | |
| 46 | + | // | |
| 47 | + | // All protocol events are dispatched synchronously by the goroutine that runs | |
| 48 | + | // Outputs or Capture, so event handlers need no locking. Calls are serialized | |
| 49 | + | // by opMu. Cancelling the context of a running call, a protocol failure or | |
| 50 | + | // Close closes the connection: the Source is then terminal and every later | |
| 51 | + | // call returns ports.ErrClosed. The call that observed a cancellation returns | |
| 52 | + | // its context's error. A context cancelled between calls affects only a later | |
| 53 | + | // call that is given that same context. | |
| 54 | + | type Source struct { | |
| 55 | + | log zerowrap.Logger | |
| 56 | + | display *wlturbo.Display | |
| 57 | + | wl *wlturbo.Context | |
| 58 | + | ||
| 59 | + | opMu sync.Mutex // serializes operations and guards everything below | |
| 60 | + | dead atomic.Bool | |
| 61 | + | ||
| 62 | + | // Cancellation watcher, reused while consecutive calls share a Done | |
| 63 | + | // channel. watchDone and watchStop are guarded by opMu; the callback only | |
| 64 | + | // reads the atomics, and acts only while a call is active and its | |
| 65 | + | // registration is still the current generation. | |
| 66 | + | watchDone <-chan struct{} | |
| 67 | + | watchStop func() bool | |
| 68 | + | watchGen atomic.Uint64 | |
| 69 | + | watchActive atomic.Bool | |
| 70 | + | ||
| 71 | + | shm *core.Shm | |
| 72 | + | screencopy *screencopyManager | |
| 73 | + | outputs map[uint32]*output | |
| 74 | + | bound int // wl_output globals bound so far | |
| 75 | + | buf shmBuffer | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | var _ ports.Source = (*Source)(nil) | |
| 79 | + | ||
| 80 | + | // New connects to socket (an absolute path, or a name relative to | |
| 81 | + | // XDG_RUNTIME_DIR; empty selects WAYLAND_DISPLAY) and discovers outputs. | |
| 82 | + | // | |
| 83 | + | // ctx bounds the constructor only: it interrupts discovery, and is detached | |
| 84 | + | // once New returns, so cancelling it afterwards does not close the Source. | |
| 85 | + | // Later calls are bound by their own contexts. The logger is derived from ctx. | |
| 86 | + | func New(ctx context.Context, socket string) (*Source, error) { | |
| 87 | + | path, err := socketPath(socket) | |
| 88 | + | if err != nil { | |
| 89 | + | return nil, err | |
| 90 | + | } | |
| 91 | + | conn, err := (&net.Dialer{}).DialContext(ctx, "unix", path) | |
| 92 | + | if err != nil { | |
| 93 | + | return nil, fmt.Errorf("wayland: connect: %w", err) | |
| 94 | + | } | |
| 95 | + | display, err := wlturbo.ConnectFromConn(conn) | |
| 96 | + | if err != nil { | |
| 97 | + | return nil, fmt.Errorf("wayland: %w", err) | |
| 98 | + | } | |
| 99 | + | s := &Source{ | |
| 100 | + | log: logging.For(ctx, "wayland"), | |
| 101 | + | display: display, | |
| 102 | + | wl: display.Context(), | |
| 103 | + | outputs: make(map[uint32]*output), | |
| 104 | + | } | |
| 105 | + | stop := context.AfterFunc(ctx, s.terminate) | |
| 106 | + | err = s.discover() | |
| 107 | + | cancelled := !stop() | |
| 108 | + | if cancelled || err != nil { | |
| 109 | + | s.terminate() | |
| 110 | + | if cancelled || ctx.Err() != nil { | |
| 111 | + | err = ctx.Err() | |
| 112 | + | } else { | |
| 113 | + | err = fmt.Errorf("wayland: discover: %w", err) | |
| 114 | + | } | |
| 115 | + | s.log.Error().Err(err).Msg("wayland source failed to start") | |
| 116 | + | return nil, err | |
| 117 | + | } | |
| 118 | + | s.log.Info().Int("outputs", len(s.outputs)).Uint32("screencopy_version", s.screencopy.Version()).Msg("wayland source started") | |
| 119 | + | return s, nil | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func socketPath(socket string) (string, error) { | |
| 123 | + | if socket == "" { | |
| 124 | + | socket = os.Getenv("WAYLAND_DISPLAY") | |
| 125 | + | if socket == "" { | |
| 126 | + | socket = "wayland-0" | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | if filepath.IsAbs(socket) { | |
| 130 | + | return socket, nil | |
| 131 | + | } | |
| 132 | + | dir := os.Getenv("XDG_RUNTIME_DIR") | |
| 133 | + | if dir == "" { | |
| 134 | + | return "", errors.New("wayland: XDG_RUNTIME_DIR is not set") | |
| 135 | + | } | |
| 136 | + | return filepath.Join(dir, socket), nil | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (s *Source) discover() error { | |
| 140 | + | reg := s.display.Registry() | |
| 141 | + | reg.AddHandler(core.OutputInterface, s.onOutputGlobal) | |
| 142 | + | reg.AddGlobalRemoveHandler(globalRemoved{s}) | |
| 143 | + | if err := s.roundtrip(); err != nil { | |
| 144 | + | return err | |
| 145 | + | } | |
| 146 | + | s.shm = core.NewShm(s.wl) | |
| 147 | + | if _, err := reg.BindNegotiated(core.ShmInterface, shmVersion, s.shm); err != nil { | |
| 148 | + | return fmt.Errorf("wayland: bind wl_shm: %w", err) | |
| 149 | + | } | |
| 150 | + | s.screencopy = &screencopyManager{} | |
| 151 | + | s.screencopy.SetContext(s.wl) | |
| 152 | + | if _, err := reg.BindNegotiated(screencopyIface, screencopyVersion, s.screencopy); err != nil { | |
| 153 | + | return fmt.Errorf("wayland: bind %s: %w", screencopyIface, err) | |
| 154 | + | } | |
| 155 | + | return s.roundtrip() | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func (s *Source) onOutputGlobal(reg *wlturbo.Registry, name, version uint32) { | |
| 159 | + | if version == 0 || s.outputs[name] != nil { | |
| 160 | + | return // unusable, or a duplicate announcement of a bound global | |
| 161 | + | } | |
| 162 | + | o := &output{id: name, scale: 1, version: min(version, outputVersion), proxy: core.NewOutput(s.wl)} | |
| 163 | + | o.proxy.OnMode(func(flags uint32, w, h, _ int32) { | |
| 164 | + | // The mode is informational: implausible values are ignored. | |
| 165 | + | if flags&1 != 0 && w >= 0 && h >= 0 && w <= ports.MaxDimension && h <= ports.MaxDimension { | |
| 166 | + | o.width, o.height = w, h | |
| 167 | + | } | |
| 168 | + | }) | |
| 169 | + | o.proxy.OnScale(func(f int32) { o.scale = min(max(f, 1), maxOutputScale) }) | |
| 170 | + | o.proxy.OnName(func(n string) { o.name = n }) | |
| 171 | + | if err := reg.Bind(name, core.OutputInterface, o.version, o.proxy); err != nil { | |
| 172 | + | s.log.Warn().Err(err).Uint32("id", name).Msg("bind wl_output failed") | |
| 173 | + | return | |
| 174 | + | } | |
| 175 | + | s.outputs[name] = o | |
| 176 | + | s.bound++ | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | type globalRemoved struct{ s *Source } | |
| 180 | + | ||
| 181 | + | func (g globalRemoved) HandleRegistryGlobalRemove(e wlturbo.RegistryGlobalRemoveEvent) { | |
| 182 | + | o := g.s.outputs[e.Name] | |
| 183 | + | if o == nil { | |
| 184 | + | return | |
| 185 | + | } | |
| 186 | + | delete(g.s.outputs, e.Name) | |
| 187 | + | o.removed = true | |
| 188 | + | if o.version >= 3 { | |
| 189 | + | _ = o.proxy.Release() | |
| 190 | + | } // older wl_output has no destructor: the proxy must stay to absorb events | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // terminate closes the connection, which unblocks any Dispatch. It is safe | |
| 194 | + | // from any goroutine and idempotent. | |
| 195 | + | func (s *Source) terminate() { | |
| 196 | + | s.dead.Store(true) | |
| 197 | + | _ = s.display.Close() | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func (s *Source) dispatch() error { | |
| 201 | + | err := s.display.Dispatch() | |
| 202 | + | if err != nil { | |
| 203 | + | s.terminate() | |
| 204 | + | return fmt.Errorf("wayland: dispatch: %w", err) | |
| 205 | + | } | |
| 206 | + | return nil | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // settle round-trips until no output was bound meanwhile, so that the events | |
| 210 | + | // of an output announced during the last round trip have been received too. | |
| 211 | + | func (s *Source) settle() error { | |
| 212 | + | for range maxSettleRounds { | |
| 213 | + | before := s.bound | |
| 214 | + | if err := s.roundtrip(); err != nil || s.bound == before { | |
| 215 | + | return err | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | return nil | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func (s *Source) roundtrip() error { | |
| 222 | + | err := s.display.Roundtrip() | |
| 223 | + | if err != nil { | |
| 224 | + | s.terminate() | |
| 225 | + | return fmt.Errorf("wayland: roundtrip: %w", err) | |
| 226 | + | } | |
| 227 | + | return nil | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // begin starts one serialized operation bound by ctx. The caller must hold | |
| 231 | + | // opMu and must call finish afterwards. Consecutive calls with the same Done | |
| 232 | + | // channel share one context.AfterFunc registration; contexts that can never be | |
| 233 | + | // cancelled register nothing. | |
| 234 | + | func (s *Source) begin(ctx context.Context) error { | |
| 235 | + | if s.dead.Load() { | |
| 236 | + | return ports.ErrClosed | |
| 237 | + | } | |
| 238 | + | if done := ctx.Done(); done != nil { | |
| 239 | + | if done != s.watchDone { | |
| 240 | + | s.detachWatcher() | |
| 241 | + | gen := s.watchGen.Add(1) | |
| 242 | + | s.watchDone = done | |
| 243 | + | s.watchStop = context.AfterFunc(ctx, func() { s.watchFired(gen) }) | |
| 244 | + | } | |
| 245 | + | // Activate before checking the context: a callback that fired while | |
| 246 | + | // idle did nothing, and this check then observes the cancellation. | |
| 247 | + | s.watchActive.Store(true) | |
| 248 | + | } | |
| 249 | + | if err := ctx.Err(); err != nil { | |
| 250 | + | s.watchActive.Store(false) | |
| 251 | + | s.terminate() | |
| 252 | + | s.detachWatcher() | |
| 253 | + | s.releaseBuffer(false) | |
| 254 | + | return err | |
| 255 | + | } | |
| 256 | + | return nil | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // watchFired runs on the context's goroutine. It ends the connection only | |
| 260 | + | // when it belongs to the running call's registration. | |
| 261 | + | func (s *Source) watchFired(gen uint64) { | |
| 262 | + | if s.watchActive.Load() && s.watchGen.Load() == gen { | |
| 263 | + | s.terminate() | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // detachWatcher drops the cached registration. The caller holds opMu. | |
| 268 | + | func (s *Source) detachWatcher() { | |
| 269 | + | if s.watchStop != nil { | |
| 270 | + | s.watchStop() | |
| 271 | + | } | |
| 272 | + | s.watchGen.Add(1) // a callback already running is now stale | |
| 273 | + | s.watchStop, s.watchDone = nil, nil | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // finish ends the call started by begin and classifies err. A cancellation | |
| 277 | + | // observed at any point makes the Source terminal. When it is terminal, the | |
| 278 | + | // mapped storage is released before returning. | |
| 279 | + | func (s *Source) finish(ctx context.Context, err error) error { | |
| 280 | + | s.watchActive.Store(false) | |
| 281 | + | if cerr := ctx.Err(); cerr != nil { | |
| 282 | + | s.terminate() | |
| 283 | + | } | |
| 284 | + | if !s.dead.Load() { | |
| 285 | + | return err | |
| 286 | + | } | |
| 287 | + | s.detachWatcher() | |
| 288 | + | s.releaseBuffer(false) | |
| 289 | + | if cerr := ctx.Err(); cerr != nil { | |
| 290 | + | return cerr | |
| 291 | + | } | |
| 292 | + | if err == nil || errors.Is(err, net.ErrClosed) { | |
| 293 | + | return ports.ErrClosed | |
| 294 | + | } | |
| 295 | + | s.log.Error().Err(err).Msg("wayland source terminated") | |
| 296 | + | return err | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | // Outputs lists the outputs currently announced by the compositor, by ID. | |
| 300 | + | func (s *Source) Outputs(ctx context.Context) ([]ports.Output, error) { | |
| 301 | + | s.opMu.Lock() | |
| 302 | + | defer s.opMu.Unlock() | |
| 303 | + | if err := s.begin(ctx); err != nil { | |
| 304 | + | return nil, err | |
| 305 | + | } | |
| 306 | + | err := s.settle() | |
| 307 | + | var list []ports.Output | |
| 308 | + | if err == nil { | |
| 309 | + | list = make([]ports.Output, 0, len(s.outputs)) | |
| 310 | + | for _, o := range s.outputs { | |
| 311 | + | name := o.name | |
| 312 | + | if name == "" { // wl_output older than version 4 has no name | |
| 313 | + | name = fmt.Sprintf("output-%d", o.id) | |
| 314 | + | } | |
| 315 | + | list = append(list, ports.Output{ID: o.id, Name: name, Width: int(o.width), Height: int(o.height), Scale: int(o.scale)}) | |
| 316 | + | } | |
| 317 | + | slices.SortFunc(list, func(a, b ports.Output) int { return cmp.Compare(a.ID, b.ID) }) | |
| 318 | + | } | |
| 319 | + | if err = s.finish(ctx, err); err != nil { | |
| 320 | + | return nil, err | |
| 321 | + | } | |
| 322 | + | return list, nil | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | // Capture copies one frame of the target into storage reused across calls. | |
| 326 | + | // The frame is valid until the next Capture or Close. | |
| 327 | + | func (s *Source) Capture(ctx context.Context, t ports.Target) (ports.Frame, error) { | |
| 328 | + | if err := validRegion(t.Region); err != nil { | |
| 329 | + | return ports.Frame{}, err | |
| 330 | + | } | |
| 331 | + | s.opMu.Lock() | |
| 332 | + | defer s.opMu.Unlock() | |
| 333 | + | if err := s.begin(ctx); err != nil { | |
| 334 | + | return ports.Frame{}, err | |
| 335 | + | } | |
| 336 | + | frame, err := s.capture(t) | |
| 337 | + | if err = s.finish(ctx, err); err != nil { | |
| 338 | + | return ports.Frame{}, err | |
| 339 | + | } | |
| 340 | + | return frame, nil | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // Close terminates the connection, waits for a blocked operation to observe | |
| 344 | + | // it, and releases the mapped storage. It is idempotent. | |
| 345 | + | func (s *Source) Close() error { | |
| 346 | + | s.terminate() | |
| 347 | + | s.opMu.Lock() | |
| 348 | + | defer s.opMu.Unlock() | |
| 349 | + | s.detachWatcher() | |
| 350 | + | return s.releaseBuffer(false) | |
| 351 | + | } | |
| @@ -0,0 +1,351 @@ | |||||
| 1 | + | // Package wayland captures output frames over the wlr-screencopy protocol | |||
| 2 | + | // using the wlturbo transport and wl_shm buffers. | |||
| 3 | + | package wayland | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "cmp" | |||
| 7 | + | "context" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "net" | |||
| 11 | + | "os" | |||
| 12 | + | "path/filepath" | |||
| 13 | + | "slices" | |||
| 14 | + | "sync" | |||
| 15 | + | "sync/atomic" | |||
| 16 | + | ||||
| 17 | + | "github.com/bnema/wlturbo" | |||
| 18 | + | "github.com/bnema/wlturbo/protocol/core" | |||
| 19 | + | "github.com/bnema/zerowrap" | |||
| 20 | + | ||||
| 21 | + | "github.com/bnema/nefercap/internal/logging" | |||
| 22 | + | "github.com/bnema/nefercap/internal/ports" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | const ( | |||
| 26 | + | outputVersion = 4 // wl_output.name arrived in version 4 | |||
| 27 | + | shmVersion = 1 | |||
| 28 | + | screencopyVersion = 3 | |||
| 29 | + | screencopyIface = "zwlr_screencopy_manager_v1" | |||
| 30 | + | maxSettleRounds = 4 | |||
| 31 | + | maxOutputScale = 16 // informational; larger announcements are clamped | |||
| 32 | + | ) | |||
| 33 | + | ||||
| 34 | + | // output is the client-side state of one bound wl_output global. | |||
| 35 | + | type output struct { | |||
| 36 | + | proxy *core.Output | |||
| 37 | + | id uint32 // wl_registry global name, stable for the connection | |||
| 38 | + | version uint32 | |||
| 39 | + | name string | |||
| 40 | + | width, height int32 | |||
| 41 | + | scale int32 | |||
| 42 | + | removed bool | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // Source is a ports.Source backed by one Wayland connection. | |||
| 46 | + | // | |||
| 47 | + | // All protocol events are dispatched synchronously by the goroutine that runs | |||
| 48 | + | // Outputs or Capture, so event handlers need no locking. Calls are serialized | |||
| 49 | + | // by opMu. Cancelling the context of a running call, a protocol failure or | |||
| 50 | + | // Close closes the connection: the Source is then terminal and every later | |||
| 51 | + | // call returns ports.ErrClosed. The call that observed a cancellation returns | |||
| 52 | + | // its context's error. A context cancelled between calls affects only a later | |||
| 53 | + | // call that is given that same context. | |||
| 54 | + | type Source struct { | |||
| 55 | + | log zerowrap.Logger | |||
| 56 | + | display *wlturbo.Display | |||
| 57 | + | wl *wlturbo.Context | |||
| 58 | + | ||||
| 59 | + | opMu sync.Mutex // serializes operations and guards everything below | |||
| 60 | + | dead atomic.Bool | |||
| 61 | + | ||||
| 62 | + | // Cancellation watcher, reused while consecutive calls share a Done | |||
| 63 | + | // channel. watchDone and watchStop are guarded by opMu; the callback only | |||
| 64 | + | // reads the atomics, and acts only while a call is active and its | |||
| 65 | + | // registration is still the current generation. | |||
| 66 | + | watchDone <-chan struct{} | |||
| 67 | + | watchStop func() bool | |||
| 68 | + | watchGen atomic.Uint64 | |||
| 69 | + | watchActive atomic.Bool | |||
| 70 | + | ||||
| 71 | + | shm *core.Shm | |||
| 72 | + | screencopy *screencopyManager | |||
| 73 | + | outputs map[uint32]*output | |||
| 74 | + | bound int // wl_output globals bound so far | |||
| 75 | + | buf shmBuffer | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | var _ ports.Source = (*Source)(nil) | |||
| 79 | + | ||||
| 80 | + | // New connects to socket (an absolute path, or a name relative to | |||
| 81 | + | // XDG_RUNTIME_DIR; empty selects WAYLAND_DISPLAY) and discovers outputs. | |||
| 82 | + | // | |||
| 83 | + | // ctx bounds the constructor only: it interrupts discovery, and is detached | |||
| 84 | + | // once New returns, so cancelling it afterwards does not close the Source. | |||
| 85 | + | // Later calls are bound by their own contexts. The logger is derived from ctx. | |||
| 86 | + | func New(ctx context.Context, socket string) (*Source, error) { | |||
| 87 | + | path, err := socketPath(socket) | |||
| 88 | + | if err != nil { | |||
| 89 | + | return nil, err | |||
| 90 | + | } | |||
| 91 | + | conn, err := (&net.Dialer{}).DialContext(ctx, "unix", path) | |||
| 92 | + | if err != nil { | |||
| 93 | + | return nil, fmt.Errorf("wayland: connect: %w", err) | |||
| 94 | + | } | |||
| 95 | + | display, err := wlturbo.ConnectFromConn(conn) | |||
| 96 | + | if err != nil { | |||
| 97 | + | return nil, fmt.Errorf("wayland: %w", err) | |||
| 98 | + | } | |||
| 99 | + | s := &Source{ | |||
| 100 | + | log: logging.For(ctx, "wayland"), | |||
| 101 | + | display: display, | |||
| 102 | + | wl: display.Context(), | |||
| 103 | + | outputs: make(map[uint32]*output), | |||
| 104 | + | } | |||
| 105 | + | stop := context.AfterFunc(ctx, s.terminate) | |||
| 106 | + | err = s.discover() | |||
| 107 | + | cancelled := !stop() | |||
| 108 | + | if cancelled || err != nil { | |||
| 109 | + | s.terminate() | |||
| 110 | + | if cancelled || ctx.Err() != nil { | |||
| 111 | + | err = ctx.Err() | |||
| 112 | + | } else { | |||
| 113 | + | err = fmt.Errorf("wayland: discover: %w", err) | |||
| 114 | + | } | |||
| 115 | + | s.log.Error().Err(err).Msg("wayland source failed to start") | |||
| 116 | + | return nil, err | |||
| 117 | + | } | |||
| 118 | + | s.log.Info().Int("outputs", len(s.outputs)).Uint32("screencopy_version", s.screencopy.Version()).Msg("wayland source started") | |||
| 119 | + | return s, nil | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func socketPath(socket string) (string, error) { | |||
| 123 | + | if socket == "" { | |||
| 124 | + | socket = os.Getenv("WAYLAND_DISPLAY") | |||
| 125 | + | if socket == "" { | |||
| 126 | + | socket = "wayland-0" | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | if filepath.IsAbs(socket) { | |||
| 130 | + | return socket, nil | |||
| 131 | + | } | |||
| 132 | + | dir := os.Getenv("XDG_RUNTIME_DIR") | |||
| 133 | + | if dir == "" { | |||
| 134 | + | return "", errors.New("wayland: XDG_RUNTIME_DIR is not set") | |||
| 135 | + | } | |||
| 136 | + | return filepath.Join(dir, socket), nil | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func (s *Source) discover() error { | |||
| 140 | + | reg := s.display.Registry() | |||
| 141 | + | reg.AddHandler(core.OutputInterface, s.onOutputGlobal) | |||
| 142 | + | reg.AddGlobalRemoveHandler(globalRemoved{s}) | |||
| 143 | + | if err := s.roundtrip(); err != nil { | |||
| 144 | + | return err | |||
| 145 | + | } | |||
| 146 | + | s.shm = core.NewShm(s.wl) | |||
| 147 | + | if _, err := reg.BindNegotiated(core.ShmInterface, shmVersion, s.shm); err != nil { | |||
| 148 | + | return fmt.Errorf("wayland: bind wl_shm: %w", err) | |||
| 149 | + | } | |||
| 150 | + | s.screencopy = &screencopyManager{} | |||
| 151 | + | s.screencopy.SetContext(s.wl) | |||
| 152 | + | if _, err := reg.BindNegotiated(screencopyIface, screencopyVersion, s.screencopy); err != nil { | |||
| 153 | + | return fmt.Errorf("wayland: bind %s: %w", screencopyIface, err) | |||
| 154 | + | } | |||
| 155 | + | return s.roundtrip() | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func (s *Source) onOutputGlobal(reg *wlturbo.Registry, name, version uint32) { | |||
| 159 | + | if version == 0 || s.outputs[name] != nil { | |||
| 160 | + | return // unusable, or a duplicate announcement of a bound global | |||
| 161 | + | } | |||
| 162 | + | o := &output{id: name, scale: 1, version: min(version, outputVersion), proxy: core.NewOutput(s.wl)} | |||
| 163 | + | o.proxy.OnMode(func(flags uint32, w, h, _ int32) { | |||
| 164 | + | // The mode is informational: implausible values are ignored. | |||
| 165 | + | if flags&1 != 0 && w >= 0 && h >= 0 && w <= ports.MaxDimension && h <= ports.MaxDimension { | |||
| 166 | + | o.width, o.height = w, h | |||
| 167 | + | } | |||
| 168 | + | }) | |||
| 169 | + | o.proxy.OnScale(func(f int32) { o.scale = min(max(f, 1), maxOutputScale) }) | |||
| 170 | + | o.proxy.OnName(func(n string) { o.name = n }) | |||
| 171 | + | if err := reg.Bind(name, core.OutputInterface, o.version, o.proxy); err != nil { | |||
| 172 | + | s.log.Warn().Err(err).Uint32("id", name).Msg("bind wl_output failed") | |||
| 173 | + | return | |||
| 174 | + | } | |||
| 175 | + | s.outputs[name] = o | |||
| 176 | + | s.bound++ | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | type globalRemoved struct{ s *Source } | |||
| 180 | + | ||||
| 181 | + | func (g globalRemoved) HandleRegistryGlobalRemove(e wlturbo.RegistryGlobalRemoveEvent) { | |||
| 182 | + | o := g.s.outputs[e.Name] | |||
| 183 | + | if o == nil { | |||
| 184 | + | return | |||
| 185 | + | } | |||
| 186 | + | delete(g.s.outputs, e.Name) | |||
| 187 | + | o.removed = true | |||
| 188 | + | if o.version >= 3 { | |||
| 189 | + | _ = o.proxy.Release() | |||
| 190 | + | } // older wl_output has no destructor: the proxy must stay to absorb events | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | // terminate closes the connection, which unblocks any Dispatch. It is safe | |||
| 194 | + | // from any goroutine and idempotent. | |||
| 195 | + | func (s *Source) terminate() { | |||
| 196 | + | s.dead.Store(true) | |||
| 197 | + | _ = s.display.Close() | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func (s *Source) dispatch() error { | |||
| 201 | + | err := s.display.Dispatch() | |||
| 202 | + | if err != nil { | |||
| 203 | + | s.terminate() | |||
| 204 | + | return fmt.Errorf("wayland: dispatch: %w", err) | |||
| 205 | + | } | |||
| 206 | + | return nil | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | // settle round-trips until no output was bound meanwhile, so that the events | |||
| 210 | + | // of an output announced during the last round trip have been received too. | |||
| 211 | + | func (s *Source) settle() error { | |||
| 212 | + | for range maxSettleRounds { | |||
| 213 | + | before := s.bound | |||
| 214 | + | if err := s.roundtrip(); err != nil || s.bound == before { | |||
| 215 | + | return err | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | return nil | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func (s *Source) roundtrip() error { | |||
| 222 | + | err := s.display.Roundtrip() | |||
| 223 | + | if err != nil { | |||
| 224 | + | s.terminate() | |||
| 225 | + | return fmt.Errorf("wayland: roundtrip: %w", err) | |||
| 226 | + | } | |||
| 227 | + | return nil | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | // begin starts one serialized operation bound by ctx. The caller must hold | |||
| 231 | + | // opMu and must call finish afterwards. Consecutive calls with the same Done | |||
| 232 | + | // channel share one context.AfterFunc registration; contexts that can never be | |||
| 233 | + | // cancelled register nothing. | |||
| 234 | + | func (s *Source) begin(ctx context.Context) error { | |||
| 235 | + | if s.dead.Load() { | |||
| 236 | + | return ports.ErrClosed | |||
| 237 | + | } | |||
| 238 | + | if done := ctx.Done(); done != nil { | |||
| 239 | + | if done != s.watchDone { | |||
| 240 | + | s.detachWatcher() | |||
| 241 | + | gen := s.watchGen.Add(1) | |||
| 242 | + | s.watchDone = done | |||
| 243 | + | s.watchStop = context.AfterFunc(ctx, func() { s.watchFired(gen) }) | |||
| 244 | + | } | |||
| 245 | + | // Activate before checking the context: a callback that fired while | |||
| 246 | + | // idle did nothing, and this check then observes the cancellation. | |||
| 247 | + | s.watchActive.Store(true) | |||
| 248 | + | } | |||
| 249 | + | if err := ctx.Err(); err != nil { | |||
| 250 | + | s.watchActive.Store(false) | |||
| 251 | + | s.terminate() | |||
| 252 | + | s.detachWatcher() | |||
| 253 | + | s.releaseBuffer(false) | |||
| 254 | + | return err | |||
| 255 | + | } | |||
| 256 | + | return nil | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // watchFired runs on the context's goroutine. It ends the connection only | |||
| 260 | + | // when it belongs to the running call's registration. | |||
| 261 | + | func (s *Source) watchFired(gen uint64) { | |||
| 262 | + | if s.watchActive.Load() && s.watchGen.Load() == gen { | |||
| 263 | + | s.terminate() | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | // detachWatcher drops the cached registration. The caller holds opMu. | |||
| 268 | + | func (s *Source) detachWatcher() { | |||
| 269 | + | if s.watchStop != nil { | |||
| 270 | + | s.watchStop() | |||
| 271 | + | } | |||
| 272 | + | s.watchGen.Add(1) // a callback already running is now stale | |||
| 273 | + | s.watchStop, s.watchDone = nil, nil | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | // finish ends the call started by begin and classifies err. A cancellation | |||
| 277 | + | // observed at any point makes the Source terminal. When it is terminal, the | |||
| 278 | + | // mapped storage is released before returning. | |||
| 279 | + | func (s *Source) finish(ctx context.Context, err error) error { | |||
| 280 | + | s.watchActive.Store(false) | |||
| 281 | + | if cerr := ctx.Err(); cerr != nil { | |||
| 282 | + | s.terminate() | |||
| 283 | + | } | |||
| 284 | + | if !s.dead.Load() { | |||
| 285 | + | return err | |||
| 286 | + | } | |||
| 287 | + | s.detachWatcher() | |||
| 288 | + | s.releaseBuffer(false) | |||
| 289 | + | if cerr := ctx.Err(); cerr != nil { | |||
| 290 | + | return cerr | |||
| 291 | + | } | |||
| 292 | + | if err == nil || errors.Is(err, net.ErrClosed) { | |||
| 293 | + | return ports.ErrClosed | |||
| 294 | + | } | |||
| 295 | + | s.log.Error().Err(err).Msg("wayland source terminated") | |||
| 296 | + | return err | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | // Outputs lists the outputs currently announced by the compositor, by ID. | |||
| 300 | + | func (s *Source) Outputs(ctx context.Context) ([]ports.Output, error) { | |||
| 301 | + | s.opMu.Lock() | |||
| 302 | + | defer s.opMu.Unlock() | |||
| 303 | + | if err := s.begin(ctx); err != nil { | |||
| 304 | + | return nil, err | |||
| 305 | + | } | |||
| 306 | + | err := s.settle() | |||
| 307 | + | var list []ports.Output | |||
| 308 | + | if err == nil { | |||
| 309 | + | list = make([]ports.Output, 0, len(s.outputs)) | |||
| 310 | + | for _, o := range s.outputs { | |||
| 311 | + | name := o.name | |||
| 312 | + | if name == "" { // wl_output older than version 4 has no name | |||
| 313 | + | name = fmt.Sprintf("output-%d", o.id) | |||
| 314 | + | } | |||
| 315 | + | list = append(list, ports.Output{ID: o.id, Name: name, Width: int(o.width), Height: int(o.height), Scale: int(o.scale)}) | |||
| 316 | + | } | |||
| 317 | + | slices.SortFunc(list, func(a, b ports.Output) int { return cmp.Compare(a.ID, b.ID) }) | |||
| 318 | + | } | |||
| 319 | + | if err = s.finish(ctx, err); err != nil { | |||
| 320 | + | return nil, err | |||
| 321 | + | } | |||
| 322 | + | return list, nil | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | // Capture copies one frame of the target into storage reused across calls. | |||
| 326 | + | // The frame is valid until the next Capture or Close. | |||
| 327 | + | func (s *Source) Capture(ctx context.Context, t ports.Target) (ports.Frame, error) { | |||
| 328 | + | if err := validRegion(t.Region); err != nil { | |||
| 329 | + | return ports.Frame{}, err | |||
| 330 | + | } | |||
| 331 | + | s.opMu.Lock() | |||
| 332 | + | defer s.opMu.Unlock() | |||
| 333 | + | if err := s.begin(ctx); err != nil { | |||
| 334 | + | return ports.Frame{}, err | |||
| 335 | + | } | |||
| 336 | + | frame, err := s.capture(t) | |||
| 337 | + | if err = s.finish(ctx, err); err != nil { | |||
| 338 | + | return ports.Frame{}, err | |||
| 339 | + | } | |||
| 340 | + | return frame, nil | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | // Close terminates the connection, waits for a blocked operation to observe | |||
| 344 | + | // it, and releases the mapped storage. It is idempotent. | |||
| 345 | + | func (s *Source) Close() error { | |||
| 346 | + | s.terminate() | |||
| 347 | + | s.opMu.Lock() | |||
| 348 | + | defer s.opMu.Unlock() | |||
| 349 | + | s.detachWatcher() | |||
| 350 | + | return s.releaseBuffer(false) | |||
| 351 | + | } | |||
A internal/adapters/wayland/source_test.go +662 -0
| @@ -0,0 +1,662 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "runtime" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/nefercap/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func newSource(t *testing.T, cfg compositorConfig) (*Source, *compositor) { | |
| 16 | + | t.Helper() | |
| 17 | + | comp := startCompositor(t, cfg) | |
| 18 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 19 | + | defer cancel() | |
| 20 | + | s, err := New(ctx, comp.path) | |
| 21 | + | require.NoError(t, err) | |
| 22 | + | t.Cleanup(func() { _ = s.Close() }) | |
| 23 | + | return s, comp | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func firstOutput(t *testing.T, s *Source) ports.Output { | |
| 27 | + | t.Helper() | |
| 28 | + | outs, err := s.Outputs(context.Background()) | |
| 29 | + | require.NoError(t, err) | |
| 30 | + | require.NotEmpty(t, outs) | |
| 31 | + | return outs[0] | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func fdCount(t *testing.T) int { | |
| 35 | + | t.Helper() | |
| 36 | + | entries, err := os.ReadDir("/proc/self/fd") | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | return len(entries) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestOutputs(t *testing.T) { | |
| 42 | + | s, _ := newSource(t, compositorConfig{outputs: []outputSpec{ | |
| 43 | + | {name: "DP-1", width: 8, height: 4, scale: 2, version: 4}, | |
| 44 | + | {name: "ignored", width: 6, height: 2, scale: 1, version: 3}, | |
| 45 | + | }}) | |
| 46 | + | outs, err := s.Outputs(context.Background()) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | require.Equal(t, []ports.Output{ | |
| 49 | + | {ID: 1, Name: "DP-1", Width: 8, Height: 4, Scale: 2}, | |
| 50 | + | {ID: 2, Name: "output-2", Width: 6, Height: 2, Scale: 1}, // no wl_output.name before version 4 | |
| 51 | + | }, outs) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestOutputRemovedBetweenCalls(t *testing.T) { | |
| 55 | + | s, comp := newSource(t, compositorConfig{outputs: []outputSpec{ | |
| 56 | + | {name: "A", width: 4, height: 4, scale: 1, version: 4}, | |
| 57 | + | {name: "B", width: 4, height: 4, scale: 1, version: 4}, | |
| 58 | + | }}) | |
| 59 | + | comp.removeOutput(1) | |
| 60 | + | outs, err := s.Outputs(context.Background()) | |
| 61 | + | require.NoError(t, err) | |
| 62 | + | require.Len(t, outs, 1) | |
| 63 | + | require.Equal(t, "B", outs[0].Name) | |
| 64 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: 1}) | |
| 65 | + | require.ErrorIs(t, err, ports.ErrOutputNotFound) | |
| 66 | + | // Not finding an output is not a connection failure. | |
| 67 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: 2}) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestOutputRemovedDuringCapture(t *testing.T) { | |
| 72 | + | s, _ := newSource(t, compositorConfig{removeOnCapture: true}) | |
| 73 | + | out := firstOutput(t, s) | |
| 74 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 75 | + | require.ErrorIs(t, err, ports.ErrOutputNotFound) | |
| 76 | + | outs, err := s.Outputs(context.Background()) | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | require.Empty(t, outs) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestCaptureFullOutput(t *testing.T) { | |
| 82 | + | s, comp := newSource(t, compositorConfig{}) | |
| 83 | + | out := firstOutput(t, s) | |
| 84 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 85 | + | require.NoError(t, err) | |
| 86 | + | require.NoError(t, f.Validate()) | |
| 87 | + | require.Equal(t, 8, f.Width) | |
| 88 | + | require.Equal(t, 4, f.Height) | |
| 89 | + | require.Equal(t, 32, f.Stride) | |
| 90 | + | require.Equal(t, ports.XRGB8888, f.Format) | |
| 91 | + | require.False(t, f.YInvert) | |
| 92 | + | for y := 0; y < f.Height; y++ { | |
| 93 | + | row := f.Row(y) | |
| 94 | + | for x := 0; x < f.Width; x++ { | |
| 95 | + | want := pixel(x, y, 1) | |
| 96 | + | require.Equal(t, want[:], row[x*4:x*4+4], "pixel %d,%d", x, y) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | require.Zero(t, comp.stat(func(c *compositor) int { return int(c.lastRegion[2]) }), "full output must not send a region") | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestCaptureStrideFormatAndYInvert(t *testing.T) { | |
| 103 | + | s, _ := newSource(t, compositorConfig{ | |
| 104 | + | announce: []announce{{format: 0, width: 5, height: 3, stride: 5*4 + 12}}, | |
| 105 | + | flags: 1, | |
| 106 | + | }) | |
| 107 | + | out := firstOutput(t, s) | |
| 108 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | require.NoError(t, f.Validate()) | |
| 111 | + | require.Equal(t, ports.ARGB8888, f.Format) | |
| 112 | + | require.Equal(t, 32, f.Stride) | |
| 113 | + | require.Equal(t, 5, f.Width) | |
| 114 | + | require.Equal(t, 3, f.Height) | |
| 115 | + | require.True(t, f.YInvert) | |
| 116 | + | require.Len(t, f.Pixels, 32*3) | |
| 117 | + | // Row(0) is the top row, stored last when y-inverted. | |
| 118 | + | want := pixel(0, 2, 1) | |
| 119 | + | require.Equal(t, want[:], f.Row(0)[:4]) | |
| 120 | + | require.Len(t, f.Row(0), 20) | |
| 121 | + | // Padding is not part of a row but is preserved in storage. | |
| 122 | + | require.Equal(t, byte(0xEE), f.Pixels[20]) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestCaptureRegion(t *testing.T) { | |
| 126 | + | s, comp := newSource(t, compositorConfig{}) | |
| 127 | + | out := firstOutput(t, s) | |
| 128 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{X: 1, Y: 2, Width: 3, Height: 1}}) | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | require.Equal(t, 3, f.Width) | |
| 131 | + | require.Equal(t, 1, f.Height) | |
| 132 | + | comp.mu.Lock() | |
| 133 | + | defer comp.mu.Unlock() | |
| 134 | + | require.True(t, comp.lastRegionSet) | |
| 135 | + | require.Equal(t, [4]int32{1, 2, 3, 1}, comp.lastRegion) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestCaptureInvalidRegion(t *testing.T) { | |
| 139 | + | s, comp := newSource(t, compositorConfig{}) | |
| 140 | + | out := firstOutput(t, s) | |
| 141 | + | for _, r := range []ports.Region{ | |
| 142 | + | {X: -1, Y: 0, Width: 1, Height: 1}, | |
| 143 | + | {X: 0, Y: -1, Width: 1, Height: 1}, | |
| 144 | + | {X: 0, Y: 0, Width: 0, Height: 1}, | |
| 145 | + | {X: 0, Y: 0, Width: 1, Height: -3}, | |
| 146 | + | {X: 0, Y: 0, Width: ports.MaxDimension + 1, Height: 1}, | |
| 147 | + | {X: 1 << 40, Y: 0, Width: 1, Height: 1}, | |
| 148 | + | } { | |
| 149 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: r}) | |
| 150 | + | require.ErrorIs(t, err, ports.ErrInvalidRegion, "%+v", r) | |
| 151 | + | } | |
| 152 | + | require.Zero(t, comp.stat(func(c *compositor) int { return c.captures })) | |
| 153 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 154 | + | require.NoError(t, err, "invalid regions must not end the session") | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestCaptureUnknownOutput(t *testing.T) { | |
| 158 | + | s, _ := newSource(t, compositorConfig{}) | |
| 159 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: 99}) | |
| 160 | + | require.ErrorIs(t, err, ports.ErrOutputNotFound) | |
| 161 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: 1}) | |
| 162 | + | require.NoError(t, err) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestCaptureRejectsBadBuffers(t *testing.T) { | |
| 166 | + | const xrgb = 1 | |
| 167 | + | cases := map[string]announce{ | |
| 168 | + | "zero width": {xrgb, 0, 4, 16}, | |
| 169 | + | "zero height": {xrgb, 4, 0, 16}, | |
| 170 | + | "wide": {xrgb, ports.MaxDimension + 1, 1, (ports.MaxDimension + 1) * 4}, | |
| 171 | + | "tall": {xrgb, 1, ports.MaxDimension + 1, 4}, | |
| 172 | + | "short stride": {xrgb, 4, 4, 12}, | |
| 173 | + | "unaligned stride": {xrgb, 4, 4, 18}, | |
| 174 | + | "over 256 MiB": {xrgb, 16384, 16384, 16384 * 4}, | |
| 175 | + | "huge stride": {xrgb, 4, 4, 1 << 31}, | |
| 176 | + | "stride overflow": {xrgb, 4, 2, 0xffffffff - 3}, | |
| 177 | + | } | |
| 178 | + | for name, a := range cases { | |
| 179 | + | t.Run(name, func(t *testing.T) { | |
| 180 | + | s, comp := newSource(t, compositorConfig{announce: []announce{a}}) | |
| 181 | + | out := firstOutput(t, s) | |
| 182 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 183 | + | require.Error(t, err) | |
| 184 | + | require.Zero(t, comp.stat(func(c *compositor) int { return c.pools }), "nothing may be allocated before validation") | |
| 185 | + | require.Zero(t, comp.stat(func(c *compositor) int { return c.copies })) | |
| 186 | + | _, err = s.Outputs(context.Background()) | |
| 187 | + | require.NoError(t, err, "a rejected announcement leaves the connection usable") | |
| 188 | + | }) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func TestCaptureUnsupportedFormat(t *testing.T) { | |
| 193 | + | for _, format := range []uint32{2, 0x34325258, 0x34324752, 0xffffffff} { // C8, XR24-like fourcc, RG24, garbage | |
| 194 | + | s, comp := newSource(t, compositorConfig{announce: []announce{{format, 4, 4, 16}}}) | |
| 195 | + | out := firstOutput(t, s) | |
| 196 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 197 | + | require.ErrorIs(t, err, ports.ErrUnsupportedFormat, "format %#x", format) | |
| 198 | + | require.Zero(t, comp.stat(func(c *compositor) int { return c.pools })) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestCapturePicksSupportedAnnouncement(t *testing.T) { | |
| 203 | + | s, _ := newSource(t, compositorConfig{announce: []announce{{0x34325258, 4, 4, 16}, {1, 3, 2, 12}}}) | |
| 204 | + | out := firstOutput(t, s) | |
| 205 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 206 | + | require.NoError(t, err) | |
| 207 | + | require.Equal(t, 3, f.Width) | |
| 208 | + | require.Equal(t, ports.XRGB8888, f.Format) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func TestCaptureWithoutBufferDone(t *testing.T) { | |
| 212 | + | s, _ := newSource(t, compositorConfig{screencopyVersion: 1}) | |
| 213 | + | out := firstOutput(t, s) | |
| 214 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 215 | + | require.NoError(t, err) | |
| 216 | + | require.NoError(t, f.Validate()) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func TestMissingScreencopy(t *testing.T) { | |
| 220 | + | comp := startCompositor(t, compositorConfig{noScreencopy: true}) | |
| 221 | + | _, err := New(context.Background(), comp.path) | |
| 222 | + | require.Error(t, err) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func TestReuseAndGeometryChange(t *testing.T) { | |
| 226 | + | s, comp := newSource(t, compositorConfig{}) | |
| 227 | + | out := firstOutput(t, s) | |
| 228 | + | target := ports.Target{OutputID: out.ID} | |
| 229 | + | f1, err := s.Capture(context.Background(), target) | |
| 230 | + | require.NoError(t, err) | |
| 231 | + | first := &f1.Pixels[0] | |
| 232 | + | for i := 2; i <= 5; i++ { | |
| 233 | + | f, err := s.Capture(context.Background(), target) | |
| 234 | + | require.NoError(t, err) | |
| 235 | + | require.Same(t, first, &f.Pixels[0], "storage must be reused") | |
| 236 | + | want := pixel(3, 1, uint32(i)) | |
| 237 | + | require.Equal(t, want[:], f.Row(1)[12:16], "capture %d must show fresh content", i) | |
| 238 | + | } | |
| 239 | + | require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.pools })) | |
| 240 | + | require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.buffers })) | |
| 241 | + | require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.fdsReceived })) | |
| 242 | + | ||
| 243 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{Width: 2, Height: 2}}) | |
| 244 | + | require.NoError(t, err) | |
| 245 | + | require.Equal(t, 2, f.Width) | |
| 246 | + | require.Equal(t, 2, comp.stat(func(c *compositor) int { return c.pools })) | |
| 247 | + | require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.destroyedBuffers })) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func TestNoDescriptorLeak(t *testing.T) { | |
| 251 | + | s, comp := newSource(t, compositorConfig{}) | |
| 252 | + | out := firstOutput(t, s) | |
| 253 | + | target := ports.Target{OutputID: out.ID} | |
| 254 | + | _, err := s.Capture(context.Background(), target) | |
| 255 | + | require.NoError(t, err) | |
| 256 | + | steady := fdCount(t) | |
| 257 | + | for i := 0; i < 30; i++ { | |
| 258 | + | // Alternate geometry: every switch creates a pool and must not keep its descriptor. | |
| 259 | + | region := ports.Region{Width: 2 + i%2, Height: 2} | |
| 260 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: region}) | |
| 261 | + | require.NoError(t, err) | |
| 262 | + | _, err = s.Capture(context.Background(), target) | |
| 263 | + | require.NoError(t, err) | |
| 264 | + | } | |
| 265 | + | require.Equal(t, 61, comp.stat(func(c *compositor) int { return c.fdsReceived })) | |
| 266 | + | require.Equal(t, steady, fdCount(t)) | |
| 267 | + | ||
| 268 | + | require.NoError(t, s.Close()) | |
| 269 | + | require.Eventually(t, func() bool { return fdCount(t) <= steady-2 }, 2*time.Second, 5*time.Millisecond, | |
| 270 | + | "connection descriptors must be closed on Close") | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func TestCancelInterruptsBlockedCapture(t *testing.T) { | |
| 274 | + | s, comp := newSource(t, compositorConfig{hold: true}) | |
| 275 | + | out := firstOutput(t, s) | |
| 276 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 277 | + | errc := make(chan error, 1) | |
| 278 | + | go func() { | |
| 279 | + | _, err := s.Capture(ctx, ports.Target{OutputID: out.ID}) | |
| 280 | + | errc <- err | |
| 281 | + | }() | |
| 282 | + | <-comp.copyEvent | |
| 283 | + | cancel() | |
| 284 | + | select { | |
| 285 | + | case err := <-errc: | |
| 286 | + | require.ErrorIs(t, err, context.Canceled) | |
| 287 | + | case <-time.After(3 * time.Second): | |
| 288 | + | t.Fatal("capture not interrupted") | |
| 289 | + | } | |
| 290 | + | _, err := s.Outputs(context.Background()) | |
| 291 | + | require.ErrorIs(t, err, ports.ErrClosed, "cancellation is not recoverable") | |
| 292 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 293 | + | require.ErrorIs(t, err, ports.ErrClosed) | |
| 294 | + | require.NoError(t, s.Close()) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func TestCloseInterruptsBlockedCapture(t *testing.T) { | |
| 298 | + | s, comp := newSource(t, compositorConfig{hold: true}) | |
| 299 | + | out := firstOutput(t, s) | |
| 300 | + | errc := make(chan error, 1) | |
| 301 | + | go func() { | |
| 302 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 303 | + | errc <- err | |
| 304 | + | }() | |
| 305 | + | <-comp.copyEvent | |
| 306 | + | require.NoError(t, s.Close()) | |
| 307 | + | select { | |
| 308 | + | case err := <-errc: | |
| 309 | + | require.ErrorIs(t, err, ports.ErrClosed) | |
| 310 | + | case <-time.After(3 * time.Second): | |
| 311 | + | t.Fatal("capture not interrupted") | |
| 312 | + | } | |
| 313 | + | require.NoError(t, s.Close()) | |
| 314 | + | _, err := s.Outputs(context.Background()) | |
| 315 | + | require.ErrorIs(t, err, ports.ErrClosed) | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func TestCancelledContextBeforeCall(t *testing.T) { | |
| 319 | + | s, _ := newSource(t, compositorConfig{}) | |
| 320 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 321 | + | cancel() | |
| 322 | + | _, err := s.Outputs(ctx) | |
| 323 | + | require.ErrorIs(t, err, context.Canceled) | |
| 324 | + | _, err = s.Outputs(context.Background()) | |
| 325 | + | require.ErrorIs(t, err, ports.ErrClosed) | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | func TestCompositorDisconnect(t *testing.T) { | |
| 329 | + | s, comp := newSource(t, compositorConfig{hold: true}) | |
| 330 | + | out := firstOutput(t, s) | |
| 331 | + | errc := make(chan error, 1) | |
| 332 | + | go func() { | |
| 333 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 334 | + | errc <- err | |
| 335 | + | }() | |
| 336 | + | <-comp.copyEvent | |
| 337 | + | comp.mu.Lock() | |
| 338 | + | comp.conn.Close() | |
| 339 | + | comp.mu.Unlock() | |
| 340 | + | select { | |
| 341 | + | case err := <-errc: | |
| 342 | + | require.Error(t, err) | |
| 343 | + | case <-time.After(3 * time.Second): | |
| 344 | + | t.Fatal("capture not interrupted") | |
| 345 | + | } | |
| 346 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 347 | + | require.ErrorIs(t, err, ports.ErrClosed) | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | func TestConstructorContextIsScopedToConstructor(t *testing.T) { | |
| 351 | + | comp := startCompositor(t, compositorConfig{}) | |
| 352 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 353 | + | s, err := New(ctx, comp.path) | |
| 354 | + | require.NoError(t, err) | |
| 355 | + | defer s.Close() | |
| 356 | + | cancel() | |
| 357 | + | time.Sleep(20 * time.Millisecond) | |
| 358 | + | out := firstOutput(t, s) | |
| 359 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 360 | + | require.NoError(t, err, "cancelling the constructor context must not close the source") | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | func TestConstructorTimeout(t *testing.T) { | |
| 364 | + | comp := startCompositor(t, compositorConfig{silent: true}) | |
| 365 | + | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) | |
| 366 | + | defer cancel() | |
| 367 | + | start := time.Now() | |
| 368 | + | _, err := New(ctx, comp.path) | |
| 369 | + | require.ErrorIs(t, err, context.DeadlineExceeded) | |
| 370 | + | require.Less(t, time.Since(start), 2*time.Second) | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | func TestConstructorConnectFailure(t *testing.T) { | |
| 374 | + | _, err := New(context.Background(), t.TempDir()+"/missing") | |
| 375 | + | require.Error(t, err) | |
| 376 | + | t.Setenv("XDG_RUNTIME_DIR", "") | |
| 377 | + | _, err = New(context.Background(), "relative") | |
| 378 | + | require.Error(t, err) | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | func TestConcurrentCallsAreSerialized(t *testing.T) { | |
| 382 | + | s, _ := newSource(t, compositorConfig{}) | |
| 383 | + | out := firstOutput(t, s) | |
| 384 | + | errc := make(chan error, 8) | |
| 385 | + | for range 8 { | |
| 386 | + | go func() { | |
| 387 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 388 | + | if err == nil { | |
| 389 | + | _, err = s.Outputs(context.Background()) | |
| 390 | + | } | |
| 391 | + | errc <- err | |
| 392 | + | }() | |
| 393 | + | } | |
| 394 | + | for range 8 { | |
| 395 | + | require.NoError(t, <-errc) | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | // Steady state cost of one capture over the real transport. The measurement | |
| 400 | + | // includes the in-process protocol peer, so it is an upper bound for the | |
| 401 | + | // Source alone; the budget is set from measured values (see the benchmark). | |
| 402 | + | func TestCaptureAllocations(t *testing.T) { | |
| 403 | + | if raceEnabled { | |
| 404 | + | t.Skip("race instrumentation changes allocation counts") | |
| 405 | + | } | |
| 406 | + | s, _ := newSource(t, compositorConfig{outputs: []outputSpec{{name: "X", width: 64, height: 64, scale: 1, version: 4}}}) | |
| 407 | + | out := firstOutput(t, s) | |
| 408 | + | ctx := context.Background() | |
| 409 | + | target := ports.Target{OutputID: out.ID} | |
| 410 | + | for range 3 { // warm pools and buffers | |
| 411 | + | _, err := s.Capture(ctx, target) | |
| 412 | + | require.NoError(t, err) | |
| 413 | + | } | |
| 414 | + | runtime.GC() | |
| 415 | + | avg := testing.AllocsPerRun(200, func() { | |
| 416 | + | if _, err := s.Capture(ctx, target); err != nil { | |
| 417 | + | panic(err) | |
| 418 | + | } | |
| 419 | + | }) | |
| 420 | + | t.Logf("allocations per capture (client and in-process peer): %g", avg) | |
| 421 | + | const budget = 14 // measured 12 (client and in-process peer), with headroom | |
| 422 | + | if avg > budget { | |
| 423 | + | t.Fatalf("allocations per capture %g exceed budget %d", avg, budget) | |
| 424 | + | } | |
| 425 | + | cancelable, cancel := context.WithCancel(ctx) | |
| 426 | + | defer cancel() | |
| 427 | + | avgCancelable := testing.AllocsPerRun(200, func() { | |
| 428 | + | if _, err := s.Capture(cancelable, target); err != nil { | |
| 429 | + | panic(err) | |
| 430 | + | } | |
| 431 | + | }) | |
| 432 | + | t.Logf("allocations per capture with cancellable context: %g", avgCancelable) | |
| 433 | + | if avgCancelable > budget { | |
| 434 | + | t.Fatalf("cancellable capture allocations %g exceed budget", avgCancelable) | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | func BenchmarkCapture(b *testing.B) { | |
| 439 | + | comp := startCompositor(b, compositorConfig{outputs: []outputSpec{{name: "BENCH", width: 1920, height: 1080, scale: 1, version: 4}}}) | |
| 440 | + | s, err := New(context.Background(), comp.path) | |
| 441 | + | if err != nil { | |
| 442 | + | b.Fatal(err) | |
| 443 | + | } | |
| 444 | + | defer s.Close() | |
| 445 | + | outs, err := s.Outputs(context.Background()) | |
| 446 | + | if err != nil || len(outs) == 0 { | |
| 447 | + | b.Fatal(err) | |
| 448 | + | } | |
| 449 | + | target := ports.Target{OutputID: outs[0].ID} | |
| 450 | + | ctx := context.Background() | |
| 451 | + | b.ReportAllocs() | |
| 452 | + | b.SetBytes(1920 * 1080 * 4) | |
| 453 | + | b.ResetTimer() | |
| 454 | + | for b.Loop() { | |
| 455 | + | if _, err := s.Capture(ctx, target); err != nil { | |
| 456 | + | b.Fatal(err) | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | func TestCaptureWaitsForReady(t *testing.T) { | |
| 462 | + | s, comp := newSource(t, compositorConfig{hold: true}) | |
| 463 | + | out := firstOutput(t, s) | |
| 464 | + | type result struct { | |
| 465 | + | f ports.Frame | |
| 466 | + | err error | |
| 467 | + | } | |
| 468 | + | res := make(chan result, 1) | |
| 469 | + | go func() { | |
| 470 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 471 | + | res <- result{f, err} | |
| 472 | + | }() | |
| 473 | + | <-comp.copyEvent | |
| 474 | + | select { | |
| 475 | + | case <-res: | |
| 476 | + | t.Fatal("capture returned before the compositor completed the copy") | |
| 477 | + | case <-time.After(50 * time.Millisecond): | |
| 478 | + | } | |
| 479 | + | comp.completePending() | |
| 480 | + | r := <-res | |
| 481 | + | require.NoError(t, r.err) | |
| 482 | + | want := pixel(1, 1, 1) | |
| 483 | + | require.Equal(t, want[:], r.f.Row(1)[4:8]) | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | // Opt-in: NEFERCAP_WAYLAND_SOCKET names the socket of a disposable headless | |
| 487 | + | // compositor that supports wlr-screencopy. Never point it at a real session. | |
| 488 | + | func TestHeadlessCompositor(t *testing.T) { | |
| 489 | + | socket := os.Getenv("NEFERCAP_WAYLAND_SOCKET") | |
| 490 | + | if socket == "" { | |
| 491 | + | t.Skip("NEFERCAP_WAYLAND_SOCKET not set") | |
| 492 | + | } | |
| 493 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 494 | + | defer cancel() | |
| 495 | + | s, err := New(ctx, socket) | |
| 496 | + | require.NoError(t, err) | |
| 497 | + | defer s.Close() | |
| 498 | + | out := firstOutput(t, s) | |
| 499 | + | require.NotEmpty(t, out.Name) | |
| 500 | + | for range 3 { | |
| 501 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 502 | + | require.NoError(t, err) | |
| 503 | + | require.NoError(t, f.Validate()) | |
| 504 | + | } | |
| 505 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{Width: 16, Height: 8}}) | |
| 506 | + | require.NoError(t, err) | |
| 507 | + | require.Equal(t, 16, f.Width) | |
| 508 | + | require.Equal(t, 8, f.Height) | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | func TestIdleCancellationDoesNotKillSource(t *testing.T) { | |
| 512 | + | s, _ := newSource(t, compositorConfig{}) | |
| 513 | + | out := firstOutput(t, s) | |
| 514 | + | target := ports.Target{OutputID: out.ID} | |
| 515 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 516 | + | _, err := s.Capture(ctx, target) | |
| 517 | + | require.NoError(t, err) | |
| 518 | + | _, err = s.Capture(ctx, target) // same context: the watcher stays registered | |
| 519 | + | require.NoError(t, err) | |
| 520 | + | cancel() | |
| 521 | + | _, err = s.Capture(context.Background(), target) | |
| 522 | + | require.NoError(t, err, "a context cancelled between calls must not end the source") | |
| 523 | + | _, err = s.Outputs(ctx) | |
| 524 | + | require.ErrorIs(t, err, context.Canceled, "the next call given the cancelled context observes it") | |
| 525 | + | _, err = s.Capture(context.Background(), target) | |
| 526 | + | require.ErrorIs(t, err, ports.ErrClosed) | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | func TestStaleContextDoesNotAffectLaterCall(t *testing.T) { | |
| 530 | + | s, comp := newSource(t, compositorConfig{hold: true}) | |
| 531 | + | out := firstOutput(t, s) | |
| 532 | + | target := ports.Target{OutputID: out.ID} | |
| 533 | + | ctxA, cancelA := context.WithCancel(context.Background()) | |
| 534 | + | ctxB, cancelB := context.WithCancel(context.Background()) | |
| 535 | + | defer cancelB() | |
| 536 | + | ||
| 537 | + | runCapture := func(ctx context.Context) chan error { | |
| 538 | + | errc := make(chan error, 1) | |
| 539 | + | go func() { | |
| 540 | + | _, err := s.Capture(ctx, target) | |
| 541 | + | errc <- err | |
| 542 | + | }() | |
| 543 | + | <-comp.copyEvent | |
| 544 | + | return errc | |
| 545 | + | } | |
| 546 | + | errc := runCapture(ctxA) | |
| 547 | + | comp.completePending() | |
| 548 | + | require.NoError(t, <-errc) | |
| 549 | + | ||
| 550 | + | errc = runCapture(ctxB) // blocked under B while A's old registration is replaced | |
| 551 | + | cancelA() | |
| 552 | + | select { | |
| 553 | + | case err := <-errc: | |
| 554 | + | t.Fatalf("cancelling a previous call's context ended a later call: %v", err) | |
| 555 | + | case <-time.After(50 * time.Millisecond): | |
| 556 | + | } | |
| 557 | + | comp.completePending() | |
| 558 | + | require.NoError(t, <-errc) | |
| 559 | + | ||
| 560 | + | errc = runCapture(ctxB) | |
| 561 | + | cancelB() | |
| 562 | + | select { | |
| 563 | + | case err := <-errc: | |
| 564 | + | require.ErrorIs(t, err, context.Canceled) | |
| 565 | + | case <-time.After(3 * time.Second): | |
| 566 | + | t.Fatal("blocked capture not interrupted") | |
| 567 | + | } | |
| 568 | + | s.opMu.Lock() | |
| 569 | + | released := s.buf.data == nil && s.watchStop == nil | |
| 570 | + | s.opMu.Unlock() | |
| 571 | + | require.True(t, released, "terminal source must release its mapping and watcher") | |
| 572 | + | _, err := s.Outputs(context.Background()) | |
| 573 | + | require.ErrorIs(t, err, ports.ErrClosed) | |
| 574 | + | } | |
| 575 | + | ||
| 576 | + | func TestContextSwitchRace(t *testing.T) { | |
| 577 | + | s, _ := newSource(t, compositorConfig{}) | |
| 578 | + | out := firstOutput(t, s) | |
| 579 | + | target := ports.Target{OutputID: out.ID} | |
| 580 | + | for i := 0; i < 200; i++ { | |
| 581 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 582 | + | _, err := s.Capture(ctx, target) | |
| 583 | + | require.NoError(t, err) | |
| 584 | + | go cancel() // races with the next call, which uses another context | |
| 585 | + | _, err = s.Capture(context.Background(), target) | |
| 586 | + | require.NoError(t, err) | |
| 587 | + | cancel() | |
| 588 | + | } | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | func TestNoShmOffer(t *testing.T) { | |
| 592 | + | s, comp := newSource(t, compositorConfig{noShmOffer: true}) | |
| 593 | + | out := firstOutput(t, s) | |
| 594 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 595 | + | require.ErrorIs(t, err, ports.ErrUnsupportedFormat) | |
| 596 | + | require.ErrorContains(t, err, "no wl_shm buffer") | |
| 597 | + | require.Zero(t, comp.stat(func(c *compositor) int { return c.pools })) | |
| 598 | + | _, err = s.Outputs(context.Background()) | |
| 599 | + | require.NoError(t, err) | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | func TestBufferDoneOnlyAfterShmOffer(t *testing.T) { | |
| 603 | + | // The default peer sends buffer then buffer_done: capture waits for the latter. | |
| 604 | + | s, _ := newSource(t, compositorConfig{}) | |
| 605 | + | out := firstOutput(t, s) | |
| 606 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 607 | + | require.NoError(t, err) | |
| 608 | + | } | |
| 609 | + | ||
| 610 | + | func TestMalformedOutputModeAndScale(t *testing.T) { | |
| 611 | + | s, _ := newSource(t, compositorConfig{outputs: []outputSpec{ | |
| 612 | + | {name: "BAD", width: 0xffffffff, height: 1 << 30, scale: -4, version: 4}, | |
| 613 | + | {name: "HUGE", width: 100, height: 50, scale: 1000, version: 4}, | |
| 614 | + | }}) | |
| 615 | + | outs, err := s.Outputs(context.Background()) | |
| 616 | + | require.NoError(t, err) | |
| 617 | + | require.Equal(t, []ports.Output{ | |
| 618 | + | {ID: 1, Name: "BAD", Scale: 1}, | |
| 619 | + | {ID: 2, Name: "HUGE", Width: 100, Height: 50, Scale: maxOutputScale}, | |
| 620 | + | }, outs) | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | func TestDuplicateGlobalIgnored(t *testing.T) { | |
| 624 | + | s, _ := newSource(t, compositorConfig{duplicateGlobal: true}) | |
| 625 | + | outs, err := s.Outputs(context.Background()) | |
| 626 | + | require.NoError(t, err) | |
| 627 | + | require.Len(t, outs, 1) | |
| 628 | + | require.Equal(t, 1, s.bound) | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | func TestReadyWithoutCopyIsTerminal(t *testing.T) { | |
| 632 | + | s, _ := newSource(t, compositorConfig{readyEarly: true}) | |
| 633 | + | out := firstOutput(t, s) | |
| 634 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 635 | + | require.Error(t, err) | |
| 636 | + | _, err = s.Outputs(context.Background()) | |
| 637 | + | require.ErrorIs(t, err, ports.ErrClosed) | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | func TestCompositorFailureIsRecoverable(t *testing.T) { | |
| 641 | + | t.Run("before copy", func(t *testing.T) { | |
| 642 | + | s, comp := newSource(t, compositorConfig{failCapture: true}) | |
| 643 | + | out := firstOutput(t, s) | |
| 644 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 645 | + | require.Error(t, err) | |
| 646 | + | comp.mu.Lock() | |
| 647 | + | comp.cfg.failCapture = false | |
| 648 | + | comp.mu.Unlock() | |
| 649 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 650 | + | require.NoError(t, err) | |
| 651 | + | }) | |
| 652 | + | t.Run("after copy", func(t *testing.T) { | |
| 653 | + | s, _ := newSource(t, compositorConfig{failCopies: 1}) | |
| 654 | + | out := firstOutput(t, s) | |
| 655 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 656 | + | require.Error(t, err) | |
| 657 | + | require.NotErrorIs(t, err, ports.ErrClosed) | |
| 658 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |
| 659 | + | require.NoError(t, err) | |
| 660 | + | require.NoError(t, f.Validate()) | |
| 661 | + | }) | |
| 662 | + | } | |
| @@ -0,0 +1,662 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "runtime" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/nefercap/internal/ports" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func newSource(t *testing.T, cfg compositorConfig) (*Source, *compositor) { | |||
| 16 | + | t.Helper() | |||
| 17 | + | comp := startCompositor(t, cfg) | |||
| 18 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 19 | + | defer cancel() | |||
| 20 | + | s, err := New(ctx, comp.path) | |||
| 21 | + | require.NoError(t, err) | |||
| 22 | + | t.Cleanup(func() { _ = s.Close() }) | |||
| 23 | + | return s, comp | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func firstOutput(t *testing.T, s *Source) ports.Output { | |||
| 27 | + | t.Helper() | |||
| 28 | + | outs, err := s.Outputs(context.Background()) | |||
| 29 | + | require.NoError(t, err) | |||
| 30 | + | require.NotEmpty(t, outs) | |||
| 31 | + | return outs[0] | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func fdCount(t *testing.T) int { | |||
| 35 | + | t.Helper() | |||
| 36 | + | entries, err := os.ReadDir("/proc/self/fd") | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | return len(entries) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestOutputs(t *testing.T) { | |||
| 42 | + | s, _ := newSource(t, compositorConfig{outputs: []outputSpec{ | |||
| 43 | + | {name: "DP-1", width: 8, height: 4, scale: 2, version: 4}, | |||
| 44 | + | {name: "ignored", width: 6, height: 2, scale: 1, version: 3}, | |||
| 45 | + | }}) | |||
| 46 | + | outs, err := s.Outputs(context.Background()) | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | require.Equal(t, []ports.Output{ | |||
| 49 | + | {ID: 1, Name: "DP-1", Width: 8, Height: 4, Scale: 2}, | |||
| 50 | + | {ID: 2, Name: "output-2", Width: 6, Height: 2, Scale: 1}, // no wl_output.name before version 4 | |||
| 51 | + | }, outs) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestOutputRemovedBetweenCalls(t *testing.T) { | |||
| 55 | + | s, comp := newSource(t, compositorConfig{outputs: []outputSpec{ | |||
| 56 | + | {name: "A", width: 4, height: 4, scale: 1, version: 4}, | |||
| 57 | + | {name: "B", width: 4, height: 4, scale: 1, version: 4}, | |||
| 58 | + | }}) | |||
| 59 | + | comp.removeOutput(1) | |||
| 60 | + | outs, err := s.Outputs(context.Background()) | |||
| 61 | + | require.NoError(t, err) | |||
| 62 | + | require.Len(t, outs, 1) | |||
| 63 | + | require.Equal(t, "B", outs[0].Name) | |||
| 64 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: 1}) | |||
| 65 | + | require.ErrorIs(t, err, ports.ErrOutputNotFound) | |||
| 66 | + | // Not finding an output is not a connection failure. | |||
| 67 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: 2}) | |||
| 68 | + | require.NoError(t, err) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestOutputRemovedDuringCapture(t *testing.T) { | |||
| 72 | + | s, _ := newSource(t, compositorConfig{removeOnCapture: true}) | |||
| 73 | + | out := firstOutput(t, s) | |||
| 74 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 75 | + | require.ErrorIs(t, err, ports.ErrOutputNotFound) | |||
| 76 | + | outs, err := s.Outputs(context.Background()) | |||
| 77 | + | require.NoError(t, err) | |||
| 78 | + | require.Empty(t, outs) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestCaptureFullOutput(t *testing.T) { | |||
| 82 | + | s, comp := newSource(t, compositorConfig{}) | |||
| 83 | + | out := firstOutput(t, s) | |||
| 84 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 85 | + | require.NoError(t, err) | |||
| 86 | + | require.NoError(t, f.Validate()) | |||
| 87 | + | require.Equal(t, 8, f.Width) | |||
| 88 | + | require.Equal(t, 4, f.Height) | |||
| 89 | + | require.Equal(t, 32, f.Stride) | |||
| 90 | + | require.Equal(t, ports.XRGB8888, f.Format) | |||
| 91 | + | require.False(t, f.YInvert) | |||
| 92 | + | for y := 0; y < f.Height; y++ { | |||
| 93 | + | row := f.Row(y) | |||
| 94 | + | for x := 0; x < f.Width; x++ { | |||
| 95 | + | want := pixel(x, y, 1) | |||
| 96 | + | require.Equal(t, want[:], row[x*4:x*4+4], "pixel %d,%d", x, y) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | require.Zero(t, comp.stat(func(c *compositor) int { return int(c.lastRegion[2]) }), "full output must not send a region") | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func TestCaptureStrideFormatAndYInvert(t *testing.T) { | |||
| 103 | + | s, _ := newSource(t, compositorConfig{ | |||
| 104 | + | announce: []announce{{format: 0, width: 5, height: 3, stride: 5*4 + 12}}, | |||
| 105 | + | flags: 1, | |||
| 106 | + | }) | |||
| 107 | + | out := firstOutput(t, s) | |||
| 108 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 109 | + | require.NoError(t, err) | |||
| 110 | + | require.NoError(t, f.Validate()) | |||
| 111 | + | require.Equal(t, ports.ARGB8888, f.Format) | |||
| 112 | + | require.Equal(t, 32, f.Stride) | |||
| 113 | + | require.Equal(t, 5, f.Width) | |||
| 114 | + | require.Equal(t, 3, f.Height) | |||
| 115 | + | require.True(t, f.YInvert) | |||
| 116 | + | require.Len(t, f.Pixels, 32*3) | |||
| 117 | + | // Row(0) is the top row, stored last when y-inverted. | |||
| 118 | + | want := pixel(0, 2, 1) | |||
| 119 | + | require.Equal(t, want[:], f.Row(0)[:4]) | |||
| 120 | + | require.Len(t, f.Row(0), 20) | |||
| 121 | + | // Padding is not part of a row but is preserved in storage. | |||
| 122 | + | require.Equal(t, byte(0xEE), f.Pixels[20]) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func TestCaptureRegion(t *testing.T) { | |||
| 126 | + | s, comp := newSource(t, compositorConfig{}) | |||
| 127 | + | out := firstOutput(t, s) | |||
| 128 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{X: 1, Y: 2, Width: 3, Height: 1}}) | |||
| 129 | + | require.NoError(t, err) | |||
| 130 | + | require.Equal(t, 3, f.Width) | |||
| 131 | + | require.Equal(t, 1, f.Height) | |||
| 132 | + | comp.mu.Lock() | |||
| 133 | + | defer comp.mu.Unlock() | |||
| 134 | + | require.True(t, comp.lastRegionSet) | |||
| 135 | + | require.Equal(t, [4]int32{1, 2, 3, 1}, comp.lastRegion) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestCaptureInvalidRegion(t *testing.T) { | |||
| 139 | + | s, comp := newSource(t, compositorConfig{}) | |||
| 140 | + | out := firstOutput(t, s) | |||
| 141 | + | for _, r := range []ports.Region{ | |||
| 142 | + | {X: -1, Y: 0, Width: 1, Height: 1}, | |||
| 143 | + | {X: 0, Y: -1, Width: 1, Height: 1}, | |||
| 144 | + | {X: 0, Y: 0, Width: 0, Height: 1}, | |||
| 145 | + | {X: 0, Y: 0, Width: 1, Height: -3}, | |||
| 146 | + | {X: 0, Y: 0, Width: ports.MaxDimension + 1, Height: 1}, | |||
| 147 | + | {X: 1 << 40, Y: 0, Width: 1, Height: 1}, | |||
| 148 | + | } { | |||
| 149 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: r}) | |||
| 150 | + | require.ErrorIs(t, err, ports.ErrInvalidRegion, "%+v", r) | |||
| 151 | + | } | |||
| 152 | + | require.Zero(t, comp.stat(func(c *compositor) int { return c.captures })) | |||
| 153 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 154 | + | require.NoError(t, err, "invalid regions must not end the session") | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestCaptureUnknownOutput(t *testing.T) { | |||
| 158 | + | s, _ := newSource(t, compositorConfig{}) | |||
| 159 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: 99}) | |||
| 160 | + | require.ErrorIs(t, err, ports.ErrOutputNotFound) | |||
| 161 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: 1}) | |||
| 162 | + | require.NoError(t, err) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestCaptureRejectsBadBuffers(t *testing.T) { | |||
| 166 | + | const xrgb = 1 | |||
| 167 | + | cases := map[string]announce{ | |||
| 168 | + | "zero width": {xrgb, 0, 4, 16}, | |||
| 169 | + | "zero height": {xrgb, 4, 0, 16}, | |||
| 170 | + | "wide": {xrgb, ports.MaxDimension + 1, 1, (ports.MaxDimension + 1) * 4}, | |||
| 171 | + | "tall": {xrgb, 1, ports.MaxDimension + 1, 4}, | |||
| 172 | + | "short stride": {xrgb, 4, 4, 12}, | |||
| 173 | + | "unaligned stride": {xrgb, 4, 4, 18}, | |||
| 174 | + | "over 256 MiB": {xrgb, 16384, 16384, 16384 * 4}, | |||
| 175 | + | "huge stride": {xrgb, 4, 4, 1 << 31}, | |||
| 176 | + | "stride overflow": {xrgb, 4, 2, 0xffffffff - 3}, | |||
| 177 | + | } | |||
| 178 | + | for name, a := range cases { | |||
| 179 | + | t.Run(name, func(t *testing.T) { | |||
| 180 | + | s, comp := newSource(t, compositorConfig{announce: []announce{a}}) | |||
| 181 | + | out := firstOutput(t, s) | |||
| 182 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 183 | + | require.Error(t, err) | |||
| 184 | + | require.Zero(t, comp.stat(func(c *compositor) int { return c.pools }), "nothing may be allocated before validation") | |||
| 185 | + | require.Zero(t, comp.stat(func(c *compositor) int { return c.copies })) | |||
| 186 | + | _, err = s.Outputs(context.Background()) | |||
| 187 | + | require.NoError(t, err, "a rejected announcement leaves the connection usable") | |||
| 188 | + | }) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func TestCaptureUnsupportedFormat(t *testing.T) { | |||
| 193 | + | for _, format := range []uint32{2, 0x34325258, 0x34324752, 0xffffffff} { // C8, XR24-like fourcc, RG24, garbage | |||
| 194 | + | s, comp := newSource(t, compositorConfig{announce: []announce{{format, 4, 4, 16}}}) | |||
| 195 | + | out := firstOutput(t, s) | |||
| 196 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 197 | + | require.ErrorIs(t, err, ports.ErrUnsupportedFormat, "format %#x", format) | |||
| 198 | + | require.Zero(t, comp.stat(func(c *compositor) int { return c.pools })) | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func TestCapturePicksSupportedAnnouncement(t *testing.T) { | |||
| 203 | + | s, _ := newSource(t, compositorConfig{announce: []announce{{0x34325258, 4, 4, 16}, {1, 3, 2, 12}}}) | |||
| 204 | + | out := firstOutput(t, s) | |||
| 205 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 206 | + | require.NoError(t, err) | |||
| 207 | + | require.Equal(t, 3, f.Width) | |||
| 208 | + | require.Equal(t, ports.XRGB8888, f.Format) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func TestCaptureWithoutBufferDone(t *testing.T) { | |||
| 212 | + | s, _ := newSource(t, compositorConfig{screencopyVersion: 1}) | |||
| 213 | + | out := firstOutput(t, s) | |||
| 214 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 215 | + | require.NoError(t, err) | |||
| 216 | + | require.NoError(t, f.Validate()) | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func TestMissingScreencopy(t *testing.T) { | |||
| 220 | + | comp := startCompositor(t, compositorConfig{noScreencopy: true}) | |||
| 221 | + | _, err := New(context.Background(), comp.path) | |||
| 222 | + | require.Error(t, err) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func TestReuseAndGeometryChange(t *testing.T) { | |||
| 226 | + | s, comp := newSource(t, compositorConfig{}) | |||
| 227 | + | out := firstOutput(t, s) | |||
| 228 | + | target := ports.Target{OutputID: out.ID} | |||
| 229 | + | f1, err := s.Capture(context.Background(), target) | |||
| 230 | + | require.NoError(t, err) | |||
| 231 | + | first := &f1.Pixels[0] | |||
| 232 | + | for i := 2; i <= 5; i++ { | |||
| 233 | + | f, err := s.Capture(context.Background(), target) | |||
| 234 | + | require.NoError(t, err) | |||
| 235 | + | require.Same(t, first, &f.Pixels[0], "storage must be reused") | |||
| 236 | + | want := pixel(3, 1, uint32(i)) | |||
| 237 | + | require.Equal(t, want[:], f.Row(1)[12:16], "capture %d must show fresh content", i) | |||
| 238 | + | } | |||
| 239 | + | require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.pools })) | |||
| 240 | + | require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.buffers })) | |||
| 241 | + | require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.fdsReceived })) | |||
| 242 | + | ||||
| 243 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{Width: 2, Height: 2}}) | |||
| 244 | + | require.NoError(t, err) | |||
| 245 | + | require.Equal(t, 2, f.Width) | |||
| 246 | + | require.Equal(t, 2, comp.stat(func(c *compositor) int { return c.pools })) | |||
| 247 | + | require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.destroyedBuffers })) | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | func TestNoDescriptorLeak(t *testing.T) { | |||
| 251 | + | s, comp := newSource(t, compositorConfig{}) | |||
| 252 | + | out := firstOutput(t, s) | |||
| 253 | + | target := ports.Target{OutputID: out.ID} | |||
| 254 | + | _, err := s.Capture(context.Background(), target) | |||
| 255 | + | require.NoError(t, err) | |||
| 256 | + | steady := fdCount(t) | |||
| 257 | + | for i := 0; i < 30; i++ { | |||
| 258 | + | // Alternate geometry: every switch creates a pool and must not keep its descriptor. | |||
| 259 | + | region := ports.Region{Width: 2 + i%2, Height: 2} | |||
| 260 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: region}) | |||
| 261 | + | require.NoError(t, err) | |||
| 262 | + | _, err = s.Capture(context.Background(), target) | |||
| 263 | + | require.NoError(t, err) | |||
| 264 | + | } | |||
| 265 | + | require.Equal(t, 61, comp.stat(func(c *compositor) int { return c.fdsReceived })) | |||
| 266 | + | require.Equal(t, steady, fdCount(t)) | |||
| 267 | + | ||||
| 268 | + | require.NoError(t, s.Close()) | |||
| 269 | + | require.Eventually(t, func() bool { return fdCount(t) <= steady-2 }, 2*time.Second, 5*time.Millisecond, | |||
| 270 | + | "connection descriptors must be closed on Close") | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | func TestCancelInterruptsBlockedCapture(t *testing.T) { | |||
| 274 | + | s, comp := newSource(t, compositorConfig{hold: true}) | |||
| 275 | + | out := firstOutput(t, s) | |||
| 276 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 277 | + | errc := make(chan error, 1) | |||
| 278 | + | go func() { | |||
| 279 | + | _, err := s.Capture(ctx, ports.Target{OutputID: out.ID}) | |||
| 280 | + | errc <- err | |||
| 281 | + | }() | |||
| 282 | + | <-comp.copyEvent | |||
| 283 | + | cancel() | |||
| 284 | + | select { | |||
| 285 | + | case err := <-errc: | |||
| 286 | + | require.ErrorIs(t, err, context.Canceled) | |||
| 287 | + | case <-time.After(3 * time.Second): | |||
| 288 | + | t.Fatal("capture not interrupted") | |||
| 289 | + | } | |||
| 290 | + | _, err := s.Outputs(context.Background()) | |||
| 291 | + | require.ErrorIs(t, err, ports.ErrClosed, "cancellation is not recoverable") | |||
| 292 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 293 | + | require.ErrorIs(t, err, ports.ErrClosed) | |||
| 294 | + | require.NoError(t, s.Close()) | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func TestCloseInterruptsBlockedCapture(t *testing.T) { | |||
| 298 | + | s, comp := newSource(t, compositorConfig{hold: true}) | |||
| 299 | + | out := firstOutput(t, s) | |||
| 300 | + | errc := make(chan error, 1) | |||
| 301 | + | go func() { | |||
| 302 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 303 | + | errc <- err | |||
| 304 | + | }() | |||
| 305 | + | <-comp.copyEvent | |||
| 306 | + | require.NoError(t, s.Close()) | |||
| 307 | + | select { | |||
| 308 | + | case err := <-errc: | |||
| 309 | + | require.ErrorIs(t, err, ports.ErrClosed) | |||
| 310 | + | case <-time.After(3 * time.Second): | |||
| 311 | + | t.Fatal("capture not interrupted") | |||
| 312 | + | } | |||
| 313 | + | require.NoError(t, s.Close()) | |||
| 314 | + | _, err := s.Outputs(context.Background()) | |||
| 315 | + | require.ErrorIs(t, err, ports.ErrClosed) | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func TestCancelledContextBeforeCall(t *testing.T) { | |||
| 319 | + | s, _ := newSource(t, compositorConfig{}) | |||
| 320 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 321 | + | cancel() | |||
| 322 | + | _, err := s.Outputs(ctx) | |||
| 323 | + | require.ErrorIs(t, err, context.Canceled) | |||
| 324 | + | _, err = s.Outputs(context.Background()) | |||
| 325 | + | require.ErrorIs(t, err, ports.ErrClosed) | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | func TestCompositorDisconnect(t *testing.T) { | |||
| 329 | + | s, comp := newSource(t, compositorConfig{hold: true}) | |||
| 330 | + | out := firstOutput(t, s) | |||
| 331 | + | errc := make(chan error, 1) | |||
| 332 | + | go func() { | |||
| 333 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 334 | + | errc <- err | |||
| 335 | + | }() | |||
| 336 | + | <-comp.copyEvent | |||
| 337 | + | comp.mu.Lock() | |||
| 338 | + | comp.conn.Close() | |||
| 339 | + | comp.mu.Unlock() | |||
| 340 | + | select { | |||
| 341 | + | case err := <-errc: | |||
| 342 | + | require.Error(t, err) | |||
| 343 | + | case <-time.After(3 * time.Second): | |||
| 344 | + | t.Fatal("capture not interrupted") | |||
| 345 | + | } | |||
| 346 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 347 | + | require.ErrorIs(t, err, ports.ErrClosed) | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | func TestConstructorContextIsScopedToConstructor(t *testing.T) { | |||
| 351 | + | comp := startCompositor(t, compositorConfig{}) | |||
| 352 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 353 | + | s, err := New(ctx, comp.path) | |||
| 354 | + | require.NoError(t, err) | |||
| 355 | + | defer s.Close() | |||
| 356 | + | cancel() | |||
| 357 | + | time.Sleep(20 * time.Millisecond) | |||
| 358 | + | out := firstOutput(t, s) | |||
| 359 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 360 | + | require.NoError(t, err, "cancelling the constructor context must not close the source") | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | func TestConstructorTimeout(t *testing.T) { | |||
| 364 | + | comp := startCompositor(t, compositorConfig{silent: true}) | |||
| 365 | + | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) | |||
| 366 | + | defer cancel() | |||
| 367 | + | start := time.Now() | |||
| 368 | + | _, err := New(ctx, comp.path) | |||
| 369 | + | require.ErrorIs(t, err, context.DeadlineExceeded) | |||
| 370 | + | require.Less(t, time.Since(start), 2*time.Second) | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | func TestConstructorConnectFailure(t *testing.T) { | |||
| 374 | + | _, err := New(context.Background(), t.TempDir()+"/missing") | |||
| 375 | + | require.Error(t, err) | |||
| 376 | + | t.Setenv("XDG_RUNTIME_DIR", "") | |||
| 377 | + | _, err = New(context.Background(), "relative") | |||
| 378 | + | require.Error(t, err) | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | func TestConcurrentCallsAreSerialized(t *testing.T) { | |||
| 382 | + | s, _ := newSource(t, compositorConfig{}) | |||
| 383 | + | out := firstOutput(t, s) | |||
| 384 | + | errc := make(chan error, 8) | |||
| 385 | + | for range 8 { | |||
| 386 | + | go func() { | |||
| 387 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 388 | + | if err == nil { | |||
| 389 | + | _, err = s.Outputs(context.Background()) | |||
| 390 | + | } | |||
| 391 | + | errc <- err | |||
| 392 | + | }() | |||
| 393 | + | } | |||
| 394 | + | for range 8 { | |||
| 395 | + | require.NoError(t, <-errc) | |||
| 396 | + | } | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | // Steady state cost of one capture over the real transport. The measurement | |||
| 400 | + | // includes the in-process protocol peer, so it is an upper bound for the | |||
| 401 | + | // Source alone; the budget is set from measured values (see the benchmark). | |||
| 402 | + | func TestCaptureAllocations(t *testing.T) { | |||
| 403 | + | if raceEnabled { | |||
| 404 | + | t.Skip("race instrumentation changes allocation counts") | |||
| 405 | + | } | |||
| 406 | + | s, _ := newSource(t, compositorConfig{outputs: []outputSpec{{name: "X", width: 64, height: 64, scale: 1, version: 4}}}) | |||
| 407 | + | out := firstOutput(t, s) | |||
| 408 | + | ctx := context.Background() | |||
| 409 | + | target := ports.Target{OutputID: out.ID} | |||
| 410 | + | for range 3 { // warm pools and buffers | |||
| 411 | + | _, err := s.Capture(ctx, target) | |||
| 412 | + | require.NoError(t, err) | |||
| 413 | + | } | |||
| 414 | + | runtime.GC() | |||
| 415 | + | avg := testing.AllocsPerRun(200, func() { | |||
| 416 | + | if _, err := s.Capture(ctx, target); err != nil { | |||
| 417 | + | panic(err) | |||
| 418 | + | } | |||
| 419 | + | }) | |||
| 420 | + | t.Logf("allocations per capture (client and in-process peer): %g", avg) | |||
| 421 | + | const budget = 14 // measured 12 (client and in-process peer), with headroom | |||
| 422 | + | if avg > budget { | |||
| 423 | + | t.Fatalf("allocations per capture %g exceed budget %d", avg, budget) | |||
| 424 | + | } | |||
| 425 | + | cancelable, cancel := context.WithCancel(ctx) | |||
| 426 | + | defer cancel() | |||
| 427 | + | avgCancelable := testing.AllocsPerRun(200, func() { | |||
| 428 | + | if _, err := s.Capture(cancelable, target); err != nil { | |||
| 429 | + | panic(err) | |||
| 430 | + | } | |||
| 431 | + | }) | |||
| 432 | + | t.Logf("allocations per capture with cancellable context: %g", avgCancelable) | |||
| 433 | + | if avgCancelable > budget { | |||
| 434 | + | t.Fatalf("cancellable capture allocations %g exceed budget", avgCancelable) | |||
| 435 | + | } | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | func BenchmarkCapture(b *testing.B) { | |||
| 439 | + | comp := startCompositor(b, compositorConfig{outputs: []outputSpec{{name: "BENCH", width: 1920, height: 1080, scale: 1, version: 4}}}) | |||
| 440 | + | s, err := New(context.Background(), comp.path) | |||
| 441 | + | if err != nil { | |||
| 442 | + | b.Fatal(err) | |||
| 443 | + | } | |||
| 444 | + | defer s.Close() | |||
| 445 | + | outs, err := s.Outputs(context.Background()) | |||
| 446 | + | if err != nil || len(outs) == 0 { | |||
| 447 | + | b.Fatal(err) | |||
| 448 | + | } | |||
| 449 | + | target := ports.Target{OutputID: outs[0].ID} | |||
| 450 | + | ctx := context.Background() | |||
| 451 | + | b.ReportAllocs() | |||
| 452 | + | b.SetBytes(1920 * 1080 * 4) | |||
| 453 | + | b.ResetTimer() | |||
| 454 | + | for b.Loop() { | |||
| 455 | + | if _, err := s.Capture(ctx, target); err != nil { | |||
| 456 | + | b.Fatal(err) | |||
| 457 | + | } | |||
| 458 | + | } | |||
| 459 | + | } | |||
| 460 | + | ||||
| 461 | + | func TestCaptureWaitsForReady(t *testing.T) { | |||
| 462 | + | s, comp := newSource(t, compositorConfig{hold: true}) | |||
| 463 | + | out := firstOutput(t, s) | |||
| 464 | + | type result struct { | |||
| 465 | + | f ports.Frame | |||
| 466 | + | err error | |||
| 467 | + | } | |||
| 468 | + | res := make(chan result, 1) | |||
| 469 | + | go func() { | |||
| 470 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 471 | + | res <- result{f, err} | |||
| 472 | + | }() | |||
| 473 | + | <-comp.copyEvent | |||
| 474 | + | select { | |||
| 475 | + | case <-res: | |||
| 476 | + | t.Fatal("capture returned before the compositor completed the copy") | |||
| 477 | + | case <-time.After(50 * time.Millisecond): | |||
| 478 | + | } | |||
| 479 | + | comp.completePending() | |||
| 480 | + | r := <-res | |||
| 481 | + | require.NoError(t, r.err) | |||
| 482 | + | want := pixel(1, 1, 1) | |||
| 483 | + | require.Equal(t, want[:], r.f.Row(1)[4:8]) | |||
| 484 | + | } | |||
| 485 | + | ||||
| 486 | + | // Opt-in: NEFERCAP_WAYLAND_SOCKET names the socket of a disposable headless | |||
| 487 | + | // compositor that supports wlr-screencopy. Never point it at a real session. | |||
| 488 | + | func TestHeadlessCompositor(t *testing.T) { | |||
| 489 | + | socket := os.Getenv("NEFERCAP_WAYLAND_SOCKET") | |||
| 490 | + | if socket == "" { | |||
| 491 | + | t.Skip("NEFERCAP_WAYLAND_SOCKET not set") | |||
| 492 | + | } | |||
| 493 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 494 | + | defer cancel() | |||
| 495 | + | s, err := New(ctx, socket) | |||
| 496 | + | require.NoError(t, err) | |||
| 497 | + | defer s.Close() | |||
| 498 | + | out := firstOutput(t, s) | |||
| 499 | + | require.NotEmpty(t, out.Name) | |||
| 500 | + | for range 3 { | |||
| 501 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 502 | + | require.NoError(t, err) | |||
| 503 | + | require.NoError(t, f.Validate()) | |||
| 504 | + | } | |||
| 505 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{Width: 16, Height: 8}}) | |||
| 506 | + | require.NoError(t, err) | |||
| 507 | + | require.Equal(t, 16, f.Width) | |||
| 508 | + | require.Equal(t, 8, f.Height) | |||
| 509 | + | } | |||
| 510 | + | ||||
| 511 | + | func TestIdleCancellationDoesNotKillSource(t *testing.T) { | |||
| 512 | + | s, _ := newSource(t, compositorConfig{}) | |||
| 513 | + | out := firstOutput(t, s) | |||
| 514 | + | target := ports.Target{OutputID: out.ID} | |||
| 515 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 516 | + | _, err := s.Capture(ctx, target) | |||
| 517 | + | require.NoError(t, err) | |||
| 518 | + | _, err = s.Capture(ctx, target) // same context: the watcher stays registered | |||
| 519 | + | require.NoError(t, err) | |||
| 520 | + | cancel() | |||
| 521 | + | _, err = s.Capture(context.Background(), target) | |||
| 522 | + | require.NoError(t, err, "a context cancelled between calls must not end the source") | |||
| 523 | + | _, err = s.Outputs(ctx) | |||
| 524 | + | require.ErrorIs(t, err, context.Canceled, "the next call given the cancelled context observes it") | |||
| 525 | + | _, err = s.Capture(context.Background(), target) | |||
| 526 | + | require.ErrorIs(t, err, ports.ErrClosed) | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | func TestStaleContextDoesNotAffectLaterCall(t *testing.T) { | |||
| 530 | + | s, comp := newSource(t, compositorConfig{hold: true}) | |||
| 531 | + | out := firstOutput(t, s) | |||
| 532 | + | target := ports.Target{OutputID: out.ID} | |||
| 533 | + | ctxA, cancelA := context.WithCancel(context.Background()) | |||
| 534 | + | ctxB, cancelB := context.WithCancel(context.Background()) | |||
| 535 | + | defer cancelB() | |||
| 536 | + | ||||
| 537 | + | runCapture := func(ctx context.Context) chan error { | |||
| 538 | + | errc := make(chan error, 1) | |||
| 539 | + | go func() { | |||
| 540 | + | _, err := s.Capture(ctx, target) | |||
| 541 | + | errc <- err | |||
| 542 | + | }() | |||
| 543 | + | <-comp.copyEvent | |||
| 544 | + | return errc | |||
| 545 | + | } | |||
| 546 | + | errc := runCapture(ctxA) | |||
| 547 | + | comp.completePending() | |||
| 548 | + | require.NoError(t, <-errc) | |||
| 549 | + | ||||
| 550 | + | errc = runCapture(ctxB) // blocked under B while A's old registration is replaced | |||
| 551 | + | cancelA() | |||
| 552 | + | select { | |||
| 553 | + | case err := <-errc: | |||
| 554 | + | t.Fatalf("cancelling a previous call's context ended a later call: %v", err) | |||
| 555 | + | case <-time.After(50 * time.Millisecond): | |||
| 556 | + | } | |||
| 557 | + | comp.completePending() | |||
| 558 | + | require.NoError(t, <-errc) | |||
| 559 | + | ||||
| 560 | + | errc = runCapture(ctxB) | |||
| 561 | + | cancelB() | |||
| 562 | + | select { | |||
| 563 | + | case err := <-errc: | |||
| 564 | + | require.ErrorIs(t, err, context.Canceled) | |||
| 565 | + | case <-time.After(3 * time.Second): | |||
| 566 | + | t.Fatal("blocked capture not interrupted") | |||
| 567 | + | } | |||
| 568 | + | s.opMu.Lock() | |||
| 569 | + | released := s.buf.data == nil && s.watchStop == nil | |||
| 570 | + | s.opMu.Unlock() | |||
| 571 | + | require.True(t, released, "terminal source must release its mapping and watcher") | |||
| 572 | + | _, err := s.Outputs(context.Background()) | |||
| 573 | + | require.ErrorIs(t, err, ports.ErrClosed) | |||
| 574 | + | } | |||
| 575 | + | ||||
| 576 | + | func TestContextSwitchRace(t *testing.T) { | |||
| 577 | + | s, _ := newSource(t, compositorConfig{}) | |||
| 578 | + | out := firstOutput(t, s) | |||
| 579 | + | target := ports.Target{OutputID: out.ID} | |||
| 580 | + | for i := 0; i < 200; i++ { | |||
| 581 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 582 | + | _, err := s.Capture(ctx, target) | |||
| 583 | + | require.NoError(t, err) | |||
| 584 | + | go cancel() // races with the next call, which uses another context | |||
| 585 | + | _, err = s.Capture(context.Background(), target) | |||
| 586 | + | require.NoError(t, err) | |||
| 587 | + | cancel() | |||
| 588 | + | } | |||
| 589 | + | } | |||
| 590 | + | ||||
| 591 | + | func TestNoShmOffer(t *testing.T) { | |||
| 592 | + | s, comp := newSource(t, compositorConfig{noShmOffer: true}) | |||
| 593 | + | out := firstOutput(t, s) | |||
| 594 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 595 | + | require.ErrorIs(t, err, ports.ErrUnsupportedFormat) | |||
| 596 | + | require.ErrorContains(t, err, "no wl_shm buffer") | |||
| 597 | + | require.Zero(t, comp.stat(func(c *compositor) int { return c.pools })) | |||
| 598 | + | _, err = s.Outputs(context.Background()) | |||
| 599 | + | require.NoError(t, err) | |||
| 600 | + | } | |||
| 601 | + | ||||
| 602 | + | func TestBufferDoneOnlyAfterShmOffer(t *testing.T) { | |||
| 603 | + | // The default peer sends buffer then buffer_done: capture waits for the latter. | |||
| 604 | + | s, _ := newSource(t, compositorConfig{}) | |||
| 605 | + | out := firstOutput(t, s) | |||
| 606 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 607 | + | require.NoError(t, err) | |||
| 608 | + | } | |||
| 609 | + | ||||
| 610 | + | func TestMalformedOutputModeAndScale(t *testing.T) { | |||
| 611 | + | s, _ := newSource(t, compositorConfig{outputs: []outputSpec{ | |||
| 612 | + | {name: "BAD", width: 0xffffffff, height: 1 << 30, scale: -4, version: 4}, | |||
| 613 | + | {name: "HUGE", width: 100, height: 50, scale: 1000, version: 4}, | |||
| 614 | + | }}) | |||
| 615 | + | outs, err := s.Outputs(context.Background()) | |||
| 616 | + | require.NoError(t, err) | |||
| 617 | + | require.Equal(t, []ports.Output{ | |||
| 618 | + | {ID: 1, Name: "BAD", Scale: 1}, | |||
| 619 | + | {ID: 2, Name: "HUGE", Width: 100, Height: 50, Scale: maxOutputScale}, | |||
| 620 | + | }, outs) | |||
| 621 | + | } | |||
| 622 | + | ||||
| 623 | + | func TestDuplicateGlobalIgnored(t *testing.T) { | |||
| 624 | + | s, _ := newSource(t, compositorConfig{duplicateGlobal: true}) | |||
| 625 | + | outs, err := s.Outputs(context.Background()) | |||
| 626 | + | require.NoError(t, err) | |||
| 627 | + | require.Len(t, outs, 1) | |||
| 628 | + | require.Equal(t, 1, s.bound) | |||
| 629 | + | } | |||
| 630 | + | ||||
| 631 | + | func TestReadyWithoutCopyIsTerminal(t *testing.T) { | |||
| 632 | + | s, _ := newSource(t, compositorConfig{readyEarly: true}) | |||
| 633 | + | out := firstOutput(t, s) | |||
| 634 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 635 | + | require.Error(t, err) | |||
| 636 | + | _, err = s.Outputs(context.Background()) | |||
| 637 | + | require.ErrorIs(t, err, ports.ErrClosed) | |||
| 638 | + | } | |||
| 639 | + | ||||
| 640 | + | func TestCompositorFailureIsRecoverable(t *testing.T) { | |||
| 641 | + | t.Run("before copy", func(t *testing.T) { | |||
| 642 | + | s, comp := newSource(t, compositorConfig{failCapture: true}) | |||
| 643 | + | out := firstOutput(t, s) | |||
| 644 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 645 | + | require.Error(t, err) | |||
| 646 | + | comp.mu.Lock() | |||
| 647 | + | comp.cfg.failCapture = false | |||
| 648 | + | comp.mu.Unlock() | |||
| 649 | + | _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 650 | + | require.NoError(t, err) | |||
| 651 | + | }) | |||
| 652 | + | t.Run("after copy", func(t *testing.T) { | |||
| 653 | + | s, _ := newSource(t, compositorConfig{failCopies: 1}) | |||
| 654 | + | out := firstOutput(t, s) | |||
| 655 | + | _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 656 | + | require.Error(t, err) | |||
| 657 | + | require.NotErrorIs(t, err, ports.ErrClosed) | |||
| 658 | + | f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}) | |||
| 659 | + | require.NoError(t, err) | |||
| 660 | + | require.NoError(t, f.Validate()) | |||
| 661 | + | }) | |||
| 662 | + | } | |||