docs: describe current transport guarantees and drop the stale failure baseline
- commit
- de4886b55c3ff56a71f97c57d9362bb6169d6a39
- author
- brice · · 2026-09-29 15:49 UTC
- parent
- 15bc8e7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +11 -161
M README.md +8 -36
| @@ -25,21 +25,11 @@ After `display.Roundtrip()` discovers globals, call `display.Registry().BindNego | |||
| 25 | 25 | ||
| 26 | 26 | ## Features | |
| 27 | 27 | ||
| 28 | - | ### Performance Optimizations | |
| 29 | - | ||
| 30 | - | - **Zero-allocation event handling**: For messages under 4KB | |
| 31 | - | - **Lock-free dispatcher**: For object IDs < 1024 using atomic operations | |
| 32 | - | - **Pre-allocated buffers**: Reusable buffers to minimize allocations | |
| 33 | - | - **Direct array indexing**: Fast opcode lookup for common cases | |
| 34 | - | - **Buffer pooling**: sync.Pool for temporary allocations | |
| 35 | - | - **File descriptor passing**: Efficient shared memory via SCM_RIGHTS | |
| 36 | - | ||
| 37 | - | ### Design Goals | |
| 38 | - | ||
| 39 | - | - Minimal allocations in hot paths | |
| 40 | - | - Efficient event dispatching | |
| 41 | - | - Clean API for protocol extensions | |
| 42 | - | - Foundation for high-performance Wayland applications | |
| 28 | + | - **Stream framing**: messages split across or coalesced within socket reads are framed exactly; event bodies are not copied. | |
| 29 | + | - **Descriptor ownership**: received FDs belong to the event that declares them and are closed if a handler does not take them. | |
| 30 | + | - **Object lifecycle**: destroyed objects stay as zombies until `delete_id`, so events already in flight are dropped instead of failing the connection. | |
| 31 | + | - **Version checks**: generated requests newer than the bound object version return `ErrVersionTooLow` before anything is sent. | |
| 32 | + | - **Allocation-free requests**: marshaling fixed-size requests does not allocate. | |
| 43 | 33 | ||
| 44 | 34 | ## Quick Start | |
| 45 | 35 | ||
| @@ -58,7 +48,8 @@ func main() { | |||
| 58 | 48 | } | |
| 59 | 49 | defer display.Close() | |
| 60 | 50 | ||
| 61 | - | // Basic event loop | |
| 51 | + | // Basic event loop. Handlers run inside Dispatch and must not call | |
| 52 | + | // Dispatch or Roundtrip on the same display. | |
| 62 | 53 | for { | |
| 63 | 54 | if err := display.Dispatch(); err != nil { | |
| 64 | 55 | break | |
| @@ -69,28 +60,9 @@ func main() { | |||
| 69 | 60 | ||
| 70 | 61 | For device control and input injection, use [libwldevices-go](https://github.com/bnema/libwldevices-go) which builds on top of WLTurbo. | |
| 71 | 62 | ||
| 72 | - | ## Implementation Status | |
| 73 | - | ||
| 74 | - | ### Core Protocol ✅ | |
| 75 | - | - Display, Registry, Compositor, Surface, Seat, Region | |
| 76 | - | - Event dispatching and handler registration | |
| 77 | - | - File descriptor passing for shared memory | |
| 78 | - | - Context-based proxy management | |
| 79 | - | ||
| 80 | - | ### Optimizations ✅ | |
| 81 | - | - Zero-allocation event handling (messages < 4KB) | |
| 82 | - | - Lock-free dispatcher for common cases | |
| 83 | - | - Pre-allocated and pooled buffers | |
| 84 | - | - Efficient event routing | |
| 85 | - | ||
| 86 | - | ### Future Improvements | |
| 87 | - | - Performance benchmarks and validation | |
| 88 | - | - Additional optimizations based on profiling | |
| 89 | - | - Extended protocol object support as needed | |
| 90 | - | ||
| 91 | 63 | ## Requirements | |
| 92 | 64 | ||
| 93 | - | - **Go 1.21+** | |
| 65 | + | - **Go 1.27+** | |
| 94 | 66 | - **Linux** with Wayland compositor | |
| 95 | 67 | - **Unix sockets** support | |
| 96 | 68 | ||
| @@ -25,21 +25,11 @@ After `display.Roundtrip()` discovers globals, call `display.Registry().BindNego | |||||
| 25 | 25 | ||||
| 26 | ## Features | 26 | ## Features | ||
| 27 | 27 | ||||
| 28 | - | ### Performance Optimizations | 28 | + | - **Stream framing**: messages split across or coalesced within socket reads are framed exactly; event bodies are not copied. |
| 29 | - | 29 | + | - **Descriptor ownership**: received FDs belong to the event that declares them and are closed if a handler does not take them. | |
| 30 | - | - **Zero-allocation event handling**: For messages under 4KB | 30 | + | - **Object lifecycle**: destroyed objects stay as zombies until `delete_id`, so events already in flight are dropped instead of failing the connection. |
| 31 | - | - **Lock-free dispatcher**: For object IDs < 1024 using atomic operations | 31 | + | - **Version checks**: generated requests newer than the bound object version return `ErrVersionTooLow` before anything is sent. |
| 32 | - | - **Pre-allocated buffers**: Reusable buffers to minimize allocations | 32 | + | - **Allocation-free requests**: marshaling fixed-size requests does not allocate. |
| 33 | - | - **Direct array indexing**: Fast opcode lookup for common cases | |||
| 34 | - | - **Buffer pooling**: sync.Pool for temporary allocations | |||
| 35 | - | - **File descriptor passing**: Efficient shared memory via SCM_RIGHTS | |||
| 36 | - | ||||
| 37 | - | ### Design Goals | |||
| 38 | - | ||||
| 39 | - | - Minimal allocations in hot paths | |||
| 40 | - | - Efficient event dispatching | |||
| 41 | - | - Clean API for protocol extensions | |||
| 42 | - | - Foundation for high-performance Wayland applications | |||
| 43 | 33 | ||||
| 44 | ## Quick Start | 34 | ## Quick Start | ||
| 45 | 35 | ||||
| @@ -58,7 +48,8 @@ func main() { | |||||
| 58 | } | 48 | } | ||
| 59 | defer display.Close() | 49 | defer display.Close() | ||
| 60 | 50 | ||||
| 61 | - | // Basic event loop | 51 | + | // Basic event loop. Handlers run inside Dispatch and must not call |
| 52 | + | // Dispatch or Roundtrip on the same display. | |||
| 62 | for { | 53 | for { | ||
| 63 | if err := display.Dispatch(); err != nil { | 54 | if err := display.Dispatch(); err != nil { | ||
| 64 | break | 55 | break | ||
| @@ -69,28 +60,9 @@ func main() { | |||||
| 69 | 60 | ||||
| 70 | For device control and input injection, use [libwldevices-go](https://github.com/bnema/libwldevices-go) which builds on top of WLTurbo. | 61 | For device control and input injection, use [libwldevices-go](https://github.com/bnema/libwldevices-go) which builds on top of WLTurbo. | ||
| 71 | 62 | ||||
| 72 | - | ## Implementation Status | |||
| 73 | - | ||||
| 74 | - | ### Core Protocol ✅ | |||
| 75 | - | - Display, Registry, Compositor, Surface, Seat, Region | |||
| 76 | - | - Event dispatching and handler registration | |||
| 77 | - | - File descriptor passing for shared memory | |||
| 78 | - | - Context-based proxy management | |||
| 79 | - | ||||
| 80 | - | ### Optimizations ✅ | |||
| 81 | - | - Zero-allocation event handling (messages < 4KB) | |||
| 82 | - | - Lock-free dispatcher for common cases | |||
| 83 | - | - Pre-allocated and pooled buffers | |||
| 84 | - | - Efficient event routing | |||
| 85 | - | ||||
| 86 | - | ### Future Improvements | |||
| 87 | - | - Performance benchmarks and validation | |||
| 88 | - | - Additional optimizations based on profiling | |||
| 89 | - | - Extended protocol object support as needed | |||
| 90 | - | ||||
| 91 | ## Requirements | 63 | ## Requirements | ||
| 92 | 64 | ||||
| 93 | - | - **Go 1.21+** | 65 | + | - **Go 1.27+** |
| 94 | - **Linux** with Wayland compositor | 66 | - **Linux** with Wayland compositor | ||
| 95 | - **Unix sockets** support | 67 | - **Unix sockets** support | ||
| 96 | 68 | ||||
M client.go +3 -4
| @@ -1,7 +1,6 @@ | |||
| 1 | - | // Package wlturbo provides a high-performance Wayland client implementation optimized for gaming and real-time applications. | |
| 2 | - | // | |
| 3 | - | // This package delivers sub-microsecond latency, zero-allocation hot paths, and support for 8000Hz gaming devices. | |
| 4 | - | // It's designed for video game engines, competitive gaming, and other performance-critical applications. | |
| 1 | + | // Package wlturbo is a Wayland client transport for Go: connection, framing, | |
| 2 | + | // descriptor passing, object lifecycle and the bootstrap registry. Protocol | |
| 3 | + | // bindings are generated into the protocol/ packages. | |
| 5 | 4 | package wlturbo | |
| 6 | 5 | ||
| 7 | 6 | import ( | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | - | // Package wlturbo provides a high-performance Wayland client implementation optimized for gaming and real-time applications. | 1 | + | // Package wlturbo is a Wayland client transport for Go: connection, framing, |
| 2 | - | // | 2 | + | // descriptor passing, object lifecycle and the bootstrap registry. Protocol |
| 3 | - | // This package delivers sub-microsecond latency, zero-allocation hot paths, and support for 8000Hz gaming devices. | 3 | + | // bindings are generated into the protocol/ packages. |
| 4 | - | // It's designed for video game engines, competitive gaming, and other performance-critical applications. | |||
| 5 | package wlturbo | 4 | package wlturbo | ||
| 6 | 5 | ||||
| 7 | import ( | 6 | import ( | ||
D docs/operational-baseline.md +0 -121
| @@ -1,121 +0,0 @@ | |||
| 1 | - | # WLTurbo failure baseline (Task 1 Step 1) | |
| 2 | - | ||
| 3 | - | Evidence ledger for `github.com/bnema/wlturbo` at the start of `phase1/operational-health`. It records what the current checkout *does*; it is not a claim that the package is operational. Nothing was fixed. | |
| 4 | - | ||
| 5 | - | ## 1. Run metadata | |
| 6 | - | ||
| 7 | - | - Repo: `/home/brice/dev/projects/wlturbo` — `git rev-parse HEAD` = `111649b1f3def6ff216191aa04d00eecbe14b435`, branch `phase1/operational-health`. | |
| 8 | - | - Date of run: 2026-09-13T08:10:00+02:00. `go version` = `go version go1.27.1-X:nodwarf5 linux/amd64`. Platform `Linux x86_64`. | |
| 9 | - | - Module `github.com/bnema/wlturbo`, `go 1.24`; only dependency `golang.org/x/sys v0.28.0`. `git status --porcelain` was empty at capture. | |
| 10 | - | - File hashes at capture: `client.go` 25622cd3…, `core.go` 9507202e…, `scm_linux.go` 0a2344a5…, `client_test.go` a60e484a…. | |
| 11 | - | - A concurrent rewrite of framing/FD handling was expected; the tree was clean at the commit above when these commands ran, so line numbers are pinned there and may shift. | |
| 12 | - | ||
| 13 | - | ## 2. Command evidence (verbatim, trimmed) | |
| 14 | - | ||
| 15 | - | ### 2.1 `go test ./...` → exit 1 | |
| 16 | - | ||
| 17 | - | ``` | |
| 18 | - | # github.com/bnema/wlturbo [github.com/bnema/wlturbo.test] | |
| 19 | - | ./client_test.go:50:8: event.ProxyId undefined (type *Event has no field or method ProxyId, but does have field ProxyID) | |
| 20 | - | ./client_test.go:63:49: event2.ProxyId undefined (type *Event has no field or method ProxyId, but does have field ProxyID) | |
| 21 | - | ./client_test.go:75:12: event.ProxyId undefined (type *Event has no field or method ProxyId, but does have field ProxyID) | |
| 22 | - | ./client_test.go:76:80: event.ProxyId undefined (type *Event has no field or method ProxyId, but does have field ProxyID) | |
| 23 | - | FAIL github.com/bnema/wlturbo [build failed] | |
| 24 | - | ? github.com/bnema/wlturbo/wl [no test files] | |
| 25 | - | FAIL | |
| 26 | - | ``` | |
| 27 | - | ||
| 28 | - | ### 2.2 `go test -race ./...` → exit 1 | |
| 29 | - | ||
| 30 | - | Output is byte-identical to 2.1; the compiler lines are: | |
| 31 | - | ||
| 32 | - | ``` | |
| 33 | - | ./client_test.go:50:8: event.ProxyId undefined (type *Event has no field or method ProxyId, but does have field ProxyID) | |
| 34 | - | ./client_test.go:63:49: event2.ProxyId undefined (type *Event has no field or method ProxyId, but does have field ProxyID) | |
| 35 | - | ./client_test.go:75:12: event.ProxyId undefined (type *Event has no field or method ProxyId, but does have field ProxyID) | |
| 36 | - | ./client_test.go:76:80: event.ProxyId undefined (type *Event has no field or method ProxyId, but does have field ProxyID) | |
| 37 | - | FAIL github.com/bnema/wlturbo [build failed] | |
| 38 | - | ``` | |
| 39 | - | ||
| 40 | - | ### 2.3 `go vet ./...` → exit 1 | |
| 41 | - | ||
| 42 | - | ``` | |
| 43 | - | # github.com/bnema/wlturbo | |
| 44 | - | # [github.com/bnema/wlturbo] | |
| 45 | - | vet: ./client_test.go:50:8: event.ProxyId undefined (type *Event has no field or method ProxyId, but does have field ProxyID) | |
| 46 | - | ``` | |
| 47 | - | ||
| 48 | - | Reproduction confirmed: this is a **build failure**, so no test body runs (with or without `-race`). The real field is `ProxyID` (`core.go:35`), and `client_test.go` still uses the old name `ProxyId` at four sites. | |
| 49 | - | ||
| 50 | - | ## 3. Transport inspection (`client.go`) | |
| 51 | - | ||
| 52 | - | ### 3.1 Message read path — `Display.Dispatch` (`client.go:345`) | |
| 53 | - | ||
| 54 | - | - `client.go:346-347`: holds `d.recvMu` for the whole read (mutual exclusion between concurrent dispatchers). | |
| 55 | - | - `client.go:350`: **single** `recvmsgWithFDs(d.headerBuf[:])` for the 8-byte header (`headerBuf [8]byte`, `client.go:71`). | |
| 56 | - | - `client.go:353-355`: `n < 8` → `incomplete header: got %d bytes`; a short read is an error, never retried. | |
| 57 | - | - `client.go:358-362`: `objectID` little-endian, `size = sizeOpcode >> 16`, `opcode = sizeOpcode & 0xffff`. | |
| 58 | - | - `client.go:367-369`: `if opcode > 0xFFFF` → error. **Unreachable**: `opcode` was just masked with `0xffff`. | |
| 59 | - | - `client.go:372-397`: if `size > 8`, a **second** `recvmsgWithFDs(body)` reads `size-8` bytes; if still short, `io.ReadFull(d.conn, remaining)` (`client.go:392`) is the only path that guarantees a full body. | |
| 60 | - | - FDs from both reads are appended to a local slice (`client.go:396`); that slice is then **never used** in `Dispatch`. | |
| 61 | - | ||
| 62 | - | ### 3.2 Header validation present / missing | |
| 63 | - | ||
| 64 | - | Present: the short-read check (`client.go:353`) and the unreachable opcode check (`client.go:367`). Missing, by inspection of `Dispatch`: | |
| 65 | - | ||
| 66 | - | - no **maximum message size** check; `size` is 16-bit so ≤ 65535, but nothing states or enforces that bound; | |
| 67 | - | - no **4-byte alignment** check on `size`; | |
| 68 | - | - no **unknown object ID** rejection — `client.go:431` only logs `WARNING: No object found for ID %d` and falls through to `d.dispatcher.Dispatch` (`client.go:439`); | |
| 69 | - | - no **unknown opcode** rejection per object. | |
| 70 | - | ||
| 71 | - | ### 3.3 Buffering | |
| 72 | - | ||
| 73 | - | `headerBuf [8]byte` (`client.go:71`) and `eventBodyBuf [4096]byte` (`client.go:74`) live on the `Display` and are reused for every event (`client.go:378-379`); larger bodies are allocated fresh (`client.go:381`). So the receive buffers are connection-local, but the small-body path returns a sub-slice of `d.eventBodyBuf` with no copy — a handler retaining the body sees it overwritten by the next `Dispatch`. `Event`s are likewise allocated fresh at `client.go:418` although a pool exists (`event_pool.go:12`). | |
| 74 | - | ||
| 75 | - | ### 3.4 Lifecycle | |
| 76 | - | ||
| 77 | - | - `Display.Close` (`client.go:213-215`) is a bare `return d.conn.Close()` with no closed flag, so it is **not idempotent**. External probe (socketpair + `net.FileConn`, run outside the repo): `first Close: <nil>`, `second Close: "close unix @->@: use of closed network connection"`. | |
| 78 | - | - `Dispatch` after close: `recvmsgWithFDs` → `ReadMsgUnix` returns the closed error (`scm_linux.go:97`), wrapped as `failed to read header: %w` (`client.go:351`). Same probe: `ReadMsgUnix after close: "read unix @->@: use of closed network connection"`. It errors rather than panics, but there is no guard or sentinel. | |
| 79 | - | - `Connect` (`client.go:145`) calls `conn.(*net.UnixConn).File()` (`client.go:170`), stores the fd (`client.go:175`) and closes the `*os.File` immediately (`client.go:177`); `d.fd` is **never read again** anywhere (verified by grep). | |
| 80 | - | ||
| 81 | - | ### 3.5 Hot-path `log.Printf` | |
| 82 | - | ||
| 83 | - | Unconditional per event / per allocation, using the default logger to stderr: `client.go:408` (every non-registry event), `:413` (server-created object), `:425` (proxied dispatch), `:431` (unknown object), `:230` (every `allocateID()`), `:795` and `:836` (`OutputHead.Dispatch`, `OutputMode.Dispatch`). | |
| 84 | - | ||
| 85 | - | ## 4. File-descriptor handling (`scm_linux.go`, `core.go`) | |
| 86 | - | ||
| 87 | - | ### 4.1 `globalFDQueue` | |
| 88 | - | ||
| 89 | - | - Package-global `globalFDQueue = &fdQueue{}` (`scm_linux.go:29`), shared by every `Display` in the process. | |
| 90 | - | - `items [256]atomic.Pointer[fdItem]` with monotonic `head`/`tail` counters; slot index is `counter & 255` (`scm_linux.go:52`, `:76`). The wraparound risk is capacity, not the counters: a producer 256 descriptors ahead of the consumer revisits an occupied slot. | |
| 91 | - | - `enqueueFD` (`scm_linux.go:47`) spins on `q.items[next].CompareAndSwap(nil, item)`; when the slot is occupied it **spins forever** — no bound, no lossy path. | |
| 92 | - | - `dequeueFD` (`scm_linux.go:65`) is single-consumer; when the slot loads `nil` despite `head < tail` it `continue`s (unbounded busy spin). On success it CASes `head`, reads `item.fd`, clears the slot and returns the item to `fdItemPool`. | |
| 93 | - | ||
| 94 | - | ### 4.2 Queueing — `recvmsgWithFDs` (`scm_linux.go:91`) | |
| 95 | - | ||
| 96 | - | - The pooled control buffer is `unix.CmsgSpace(4*4)` — **space for only 4 FDs** (`scm_linux.go:41`); `ReadMsgUnix`'s flags are discarded (`scm_linux.go:97`), so `MSG_CTRUNC` is never detected and excess FDs are lost. | |
| 97 | - | - On `SCM_RIGHTS` (`scm_linux.go:112-123`) every FD is **enqueued into the global queue** (`:119`) *and* also returned in the `fds` slice (`:122`); `Dispatch` discards the returned copy. Neither copy is closed or accounted for. | |
| 98 | - | - Parse errors (`scm_linux.go:104`, `:110`) return without closing any FD already parsed from the same control message. | |
| 99 | - | - `d` is otherwise unused for FDs: `sendmsgWithFDs` (`scm_linux.go:131`) writes via `d.conn`, and `d.fd` is never consulted. | |
| 100 | - | ||
| 101 | - | ### 4.3 Consumption & lifetime — `Event.Fd` (`core.go:224`) | |
| 102 | - | ||
| 103 | - | - `Event.Fd()` ignores `e.data` and pops the **global** queue via `GetNextFD()` (`core.go:227`, `scm_linux.go:150`); only on an empty queue does it read a placeholder `uint32` and return `0` (`core.go:232`). The descriptor is therefore not tied to this event, this `Display`, or even this connection — only to global FIFO order. | |
| 104 | - | - No path closes received FDs: not `Close` (`client.go:213`), not `Dispatch` error paths, not `recvmsgWithFDs` errors. The only `unix.Close` calls found are for SHM buffers (`shm.go:49`) and inside `CreateAnonymousFile` (`scm_linux.go`). | |
| 105 | - | - `d.fd` (from `conn.(*net.UnixConn).File()`, `client.go:170-177`) is **dead**: the `*os.File` is closed immediately, so the stored int is a stale, unowned descriptor number; the read/send paths use `d.conn`. | |
| 106 | - | ||
| 107 | - | ## 5. Test coverage inventory (`client_test.go`) | |
| 108 | - | ||
| 109 | - | Covered (10 functions: 8 tests + 2 benchmarks; none execute today): `TestFixed` (11), `TestEventPool` (41, uses the old name `ProxyId`), `TestEventDispatcher` (69), `TestEventDispatcherMultipleHandlers` (91), `TestMessageMarshalingBasic` (121), `TestMessageHeaderParsing` (178 — re-implements the header arithmetic locally and does **not** call `Display.Dispatch`), `TestAllocateID` (227), `TestRegistryGlobalStorage` (248), `BenchmarkEventDispatch` (292), `BenchmarkFixedConversion` (310). | |
| 110 | - | ||
| 111 | - | Untested (no test touches these): `Dispatch` framing (single header read, second body read, `io.ReadFull` fallback, short reads); `recvmsgWithFDs` / `sendmsgWithFDs` / `SCM_RIGHTS` / `globalFDQueue` / `Event.Fd` and FD lifetime; lifecycle (`Connect`, `Close`, double-close, use-after-close); concurrency (`recvMu`, `sendMu`, parallel `Dispatch`, `-race` paths); `handleDisplayEvent`, `handleServerObject`, `Roundtrip`. | |
| 112 | - | ||
| 113 | - | Live compositor: **no** test needs one — `client_test.go:8` states this, and a grep for `Connect`/`Dial`/`WAYLAND_DISPLAY`/`SCM_RIGHTS` in the file finds nothing. The blocker is purely the compile error. | |
| 114 | - | ||
| 115 | - | ## 6. Classification | |
| 116 | - | ||
| 117 | - | **Reproduced (executed):** (1) `go test ./...` build failure on `ProxyId`, lines 50/63/75/76 — §2.1, exit 1; (2) `go test -race ./...` identical — §2.2, exit 1; (3) `go vet ./...` same symbol — §2.3, exit 1; (4) `Close` is not idempotent — §3.4, external probe result `close unix @->@: use of closed network connection`. | |
| 118 | - | ||
| 119 | - | **Verified (read from code, with `file:line`):** `Event.ProxyID` exists (`core.go:35`); header read is one `ReadMsgUnix`, body is a second read plus `io.ReadFull` fallback (`client.go:350`, `:385`, `:392`); the `opcode > 0xFFFF` check is unreachable (`client.go:362` vs `:367`); unknown object IDs are only logged (`client.go:431`, `:439`); no max-size or alignment check exists in `Dispatch`; receive buffers are per-`Display` and reused (`client.go:71`, `:74`, `:379`); `d.fd` is set and never used (`client.go:170-177`); `globalFDQueue` is package-global (`scm_linux.go:29`); `enqueueFD`/`dequeueFD` can spin unbounded (`scm_linux.go:47-88`); control buffer holds 4 FDs and truncation flags are ignored (`scm_linux.go:41`, `:97`); hot-path logs (`client.go:230`, `:408`, `:413`, `:425`, `:431`, `:795`, `:836`); fresh `&Event{}` bypasses the pool (`client.go:418` vs `event_pool.go:12`); no test covers framing/SCM/lifecycle/concurrency and none needs a compositor (§5). | |
| 120 | - | ||
| 121 | - | **Suspected (derived from code, not executed because the package does not build):** the retained body sub-slice is clobbered by the next `Dispatch` (`client.go:379`); received FDs leak on parse error or shutdown (no `close` on the queue path — `scm_linux.go:104-122`, `core.go:227`); `Event.Fd` can return a descriptor belonging to another event or connection because the queue is global (`core.go:227`); FD loss on `MSG_CTRUNC` (`scm_linux.go:41`, `:97`); `Dispatch` after close returns a wrapped error with no sentinel (`client.go:351`). | |
| @@ -1,121 +0,0 @@ | |||||
| 1 | - | # WLTurbo failure baseline (Task 1 Step 1) | |||
| 2 | - | ||||
| 3 | - | Evidence ledger for `github.com/bnema/wlturbo` at the start of `phase1/operational-health`. It records what the current checkout *does*; it is not a claim that the package is operational. Nothing was fixed. | |||
| 4 | - | ||||
| 5 | - | ## 1. Run metadata | |||
| 6 | - | ||||
| 7 | - | - Repo: `/home/brice/dev/projects/wlturbo` — `git rev-parse HEAD` = `111649b1f3def6ff216191aa04d00eecbe14b435`, branch `phase1/operational-health`. | |||
| 8 | - | - Date of run: 2026-09-13T08:10:00+02:00. `go version` = `go version go1.27.1-X:nodwarf5 linux/amd64`. Platform `Linux x86_64`. | |||
| 9 | - | - Module `github.com/bnema/wlturbo`, `go 1.24`; only dependency `golang.org/x/sys v0.28.0`. `git status --porcelain` was empty at capture. | |||
| 10 | - | - File hashes at capture: `client.go` 25622cd3…, `core.go` 9507202e…, `scm_linux.go` 0a2344a5…, `client_test.go` a60e484a…. | |||
| 11 | - | - A concurrent rewrite of framing/FD handling was expected; the tree was clean at the commit above when these commands ran, so line numbers are pinned there and may shift. | |||
| 12 | - | ||||
| 13 | - | ## 2. Command evidence (verbatim, trimmed) | |||
| 14 | - | ||||
| 15 | - | ### 2.1 `go test ./...` → exit 1 | |||
| 16 | - | ||||
| 17 | - | ``` | |||
| 18 | - | # github.com/bnema/wlturbo [github.com/bnema/wlturbo.test] | |||
| 19 | - | ./client_test.go:50:8: event.ProxyId undefined (type *Event has no field or method ProxyId, but does have field ProxyID) | |||
| 20 | - | ./client_test.go:63:49: event2.ProxyId undefined (type *Event has no field or method ProxyId, but does have field ProxyID) | |||
| 21 | - | ./client_test.go:75:12: event.ProxyId undefined (type *Event has no field or method ProxyId, but does have field ProxyID) | |||
| 22 | - | ./client_test.go:76:80: event.ProxyId undefined (type *Event has no field or method ProxyId, but does have field ProxyID) | |||
| 23 | - | FAIL github.com/bnema/wlturbo [build failed] | |||
| 24 | - | ? github.com/bnema/wlturbo/wl [no test files] | |||
| 25 | - | FAIL | |||
| 26 | - | ``` | |||
| 27 | - | ||||
| 28 | - | ### 2.2 `go test -race ./...` → exit 1 | |||
| 29 | - | ||||
| 30 | - | Output is byte-identical to 2.1; the compiler lines are: | |||
| 31 | - | ||||
| 32 | - | ``` | |||
| 33 | - | ./client_test.go:50:8: event.ProxyId undefined (type *Event has no field or method ProxyId, but does have field ProxyID) | |||
| 34 | - | ./client_test.go:63:49: event2.ProxyId undefined (type *Event has no field or method ProxyId, but does have field ProxyID) | |||
| 35 | - | ./client_test.go:75:12: event.ProxyId undefined (type *Event has no field or method ProxyId, but does have field ProxyID) | |||
| 36 | - | ./client_test.go:76:80: event.ProxyId undefined (type *Event has no field or method ProxyId, but does have field ProxyID) | |||
| 37 | - | FAIL github.com/bnema/wlturbo [build failed] | |||
| 38 | - | ``` | |||
| 39 | - | ||||
| 40 | - | ### 2.3 `go vet ./...` → exit 1 | |||
| 41 | - | ||||
| 42 | - | ``` | |||
| 43 | - | # github.com/bnema/wlturbo | |||
| 44 | - | # [github.com/bnema/wlturbo] | |||
| 45 | - | vet: ./client_test.go:50:8: event.ProxyId undefined (type *Event has no field or method ProxyId, but does have field ProxyID) | |||
| 46 | - | ``` | |||
| 47 | - | ||||
| 48 | - | Reproduction confirmed: this is a **build failure**, so no test body runs (with or without `-race`). The real field is `ProxyID` (`core.go:35`), and `client_test.go` still uses the old name `ProxyId` at four sites. | |||
| 49 | - | ||||
| 50 | - | ## 3. Transport inspection (`client.go`) | |||
| 51 | - | ||||
| 52 | - | ### 3.1 Message read path — `Display.Dispatch` (`client.go:345`) | |||
| 53 | - | ||||
| 54 | - | - `client.go:346-347`: holds `d.recvMu` for the whole read (mutual exclusion between concurrent dispatchers). | |||
| 55 | - | - `client.go:350`: **single** `recvmsgWithFDs(d.headerBuf[:])` for the 8-byte header (`headerBuf [8]byte`, `client.go:71`). | |||
| 56 | - | - `client.go:353-355`: `n < 8` → `incomplete header: got %d bytes`; a short read is an error, never retried. | |||
| 57 | - | - `client.go:358-362`: `objectID` little-endian, `size = sizeOpcode >> 16`, `opcode = sizeOpcode & 0xffff`. | |||
| 58 | - | - `client.go:367-369`: `if opcode > 0xFFFF` → error. **Unreachable**: `opcode` was just masked with `0xffff`. | |||
| 59 | - | - `client.go:372-397`: if `size > 8`, a **second** `recvmsgWithFDs(body)` reads `size-8` bytes; if still short, `io.ReadFull(d.conn, remaining)` (`client.go:392`) is the only path that guarantees a full body. | |||
| 60 | - | - FDs from both reads are appended to a local slice (`client.go:396`); that slice is then **never used** in `Dispatch`. | |||
| 61 | - | ||||
| 62 | - | ### 3.2 Header validation present / missing | |||
| 63 | - | ||||
| 64 | - | Present: the short-read check (`client.go:353`) and the unreachable opcode check (`client.go:367`). Missing, by inspection of `Dispatch`: | |||
| 65 | - | ||||
| 66 | - | - no **maximum message size** check; `size` is 16-bit so ≤ 65535, but nothing states or enforces that bound; | |||
| 67 | - | - no **4-byte alignment** check on `size`; | |||
| 68 | - | - no **unknown object ID** rejection — `client.go:431` only logs `WARNING: No object found for ID %d` and falls through to `d.dispatcher.Dispatch` (`client.go:439`); | |||
| 69 | - | - no **unknown opcode** rejection per object. | |||
| 70 | - | ||||
| 71 | - | ### 3.3 Buffering | |||
| 72 | - | ||||
| 73 | - | `headerBuf [8]byte` (`client.go:71`) and `eventBodyBuf [4096]byte` (`client.go:74`) live on the `Display` and are reused for every event (`client.go:378-379`); larger bodies are allocated fresh (`client.go:381`). So the receive buffers are connection-local, but the small-body path returns a sub-slice of `d.eventBodyBuf` with no copy — a handler retaining the body sees it overwritten by the next `Dispatch`. `Event`s are likewise allocated fresh at `client.go:418` although a pool exists (`event_pool.go:12`). | |||
| 74 | - | ||||
| 75 | - | ### 3.4 Lifecycle | |||
| 76 | - | ||||
| 77 | - | - `Display.Close` (`client.go:213-215`) is a bare `return d.conn.Close()` with no closed flag, so it is **not idempotent**. External probe (socketpair + `net.FileConn`, run outside the repo): `first Close: <nil>`, `second Close: "close unix @->@: use of closed network connection"`. | |||
| 78 | - | - `Dispatch` after close: `recvmsgWithFDs` → `ReadMsgUnix` returns the closed error (`scm_linux.go:97`), wrapped as `failed to read header: %w` (`client.go:351`). Same probe: `ReadMsgUnix after close: "read unix @->@: use of closed network connection"`. It errors rather than panics, but there is no guard or sentinel. | |||
| 79 | - | - `Connect` (`client.go:145`) calls `conn.(*net.UnixConn).File()` (`client.go:170`), stores the fd (`client.go:175`) and closes the `*os.File` immediately (`client.go:177`); `d.fd` is **never read again** anywhere (verified by grep). | |||
| 80 | - | ||||
| 81 | - | ### 3.5 Hot-path `log.Printf` | |||
| 82 | - | ||||
| 83 | - | Unconditional per event / per allocation, using the default logger to stderr: `client.go:408` (every non-registry event), `:413` (server-created object), `:425` (proxied dispatch), `:431` (unknown object), `:230` (every `allocateID()`), `:795` and `:836` (`OutputHead.Dispatch`, `OutputMode.Dispatch`). | |||
| 84 | - | ||||
| 85 | - | ## 4. File-descriptor handling (`scm_linux.go`, `core.go`) | |||
| 86 | - | ||||
| 87 | - | ### 4.1 `globalFDQueue` | |||
| 88 | - | ||||
| 89 | - | - Package-global `globalFDQueue = &fdQueue{}` (`scm_linux.go:29`), shared by every `Display` in the process. | |||
| 90 | - | - `items [256]atomic.Pointer[fdItem]` with monotonic `head`/`tail` counters; slot index is `counter & 255` (`scm_linux.go:52`, `:76`). The wraparound risk is capacity, not the counters: a producer 256 descriptors ahead of the consumer revisits an occupied slot. | |||
| 91 | - | - `enqueueFD` (`scm_linux.go:47`) spins on `q.items[next].CompareAndSwap(nil, item)`; when the slot is occupied it **spins forever** — no bound, no lossy path. | |||
| 92 | - | - `dequeueFD` (`scm_linux.go:65`) is single-consumer; when the slot loads `nil` despite `head < tail` it `continue`s (unbounded busy spin). On success it CASes `head`, reads `item.fd`, clears the slot and returns the item to `fdItemPool`. | |||
| 93 | - | ||||
| 94 | - | ### 4.2 Queueing — `recvmsgWithFDs` (`scm_linux.go:91`) | |||
| 95 | - | ||||
| 96 | - | - The pooled control buffer is `unix.CmsgSpace(4*4)` — **space for only 4 FDs** (`scm_linux.go:41`); `ReadMsgUnix`'s flags are discarded (`scm_linux.go:97`), so `MSG_CTRUNC` is never detected and excess FDs are lost. | |||
| 97 | - | - On `SCM_RIGHTS` (`scm_linux.go:112-123`) every FD is **enqueued into the global queue** (`:119`) *and* also returned in the `fds` slice (`:122`); `Dispatch` discards the returned copy. Neither copy is closed or accounted for. | |||
| 98 | - | - Parse errors (`scm_linux.go:104`, `:110`) return without closing any FD already parsed from the same control message. | |||
| 99 | - | - `d` is otherwise unused for FDs: `sendmsgWithFDs` (`scm_linux.go:131`) writes via `d.conn`, and `d.fd` is never consulted. | |||
| 100 | - | ||||
| 101 | - | ### 4.3 Consumption & lifetime — `Event.Fd` (`core.go:224`) | |||
| 102 | - | ||||
| 103 | - | - `Event.Fd()` ignores `e.data` and pops the **global** queue via `GetNextFD()` (`core.go:227`, `scm_linux.go:150`); only on an empty queue does it read a placeholder `uint32` and return `0` (`core.go:232`). The descriptor is therefore not tied to this event, this `Display`, or even this connection — only to global FIFO order. | |||
| 104 | - | - No path closes received FDs: not `Close` (`client.go:213`), not `Dispatch` error paths, not `recvmsgWithFDs` errors. The only `unix.Close` calls found are for SHM buffers (`shm.go:49`) and inside `CreateAnonymousFile` (`scm_linux.go`). | |||
| 105 | - | - `d.fd` (from `conn.(*net.UnixConn).File()`, `client.go:170-177`) is **dead**: the `*os.File` is closed immediately, so the stored int is a stale, unowned descriptor number; the read/send paths use `d.conn`. | |||
| 106 | - | ||||
| 107 | - | ## 5. Test coverage inventory (`client_test.go`) | |||
| 108 | - | ||||
| 109 | - | Covered (10 functions: 8 tests + 2 benchmarks; none execute today): `TestFixed` (11), `TestEventPool` (41, uses the old name `ProxyId`), `TestEventDispatcher` (69), `TestEventDispatcherMultipleHandlers` (91), `TestMessageMarshalingBasic` (121), `TestMessageHeaderParsing` (178 — re-implements the header arithmetic locally and does **not** call `Display.Dispatch`), `TestAllocateID` (227), `TestRegistryGlobalStorage` (248), `BenchmarkEventDispatch` (292), `BenchmarkFixedConversion` (310). | |||
| 110 | - | ||||
| 111 | - | Untested (no test touches these): `Dispatch` framing (single header read, second body read, `io.ReadFull` fallback, short reads); `recvmsgWithFDs` / `sendmsgWithFDs` / `SCM_RIGHTS` / `globalFDQueue` / `Event.Fd` and FD lifetime; lifecycle (`Connect`, `Close`, double-close, use-after-close); concurrency (`recvMu`, `sendMu`, parallel `Dispatch`, `-race` paths); `handleDisplayEvent`, `handleServerObject`, `Roundtrip`. | |||
| 112 | - | ||||
| 113 | - | Live compositor: **no** test needs one — `client_test.go:8` states this, and a grep for `Connect`/`Dial`/`WAYLAND_DISPLAY`/`SCM_RIGHTS` in the file finds nothing. The blocker is purely the compile error. | |||
| 114 | - | ||||
| 115 | - | ## 6. Classification | |||
| 116 | - | ||||
| 117 | - | **Reproduced (executed):** (1) `go test ./...` build failure on `ProxyId`, lines 50/63/75/76 — §2.1, exit 1; (2) `go test -race ./...` identical — §2.2, exit 1; (3) `go vet ./...` same symbol — §2.3, exit 1; (4) `Close` is not idempotent — §3.4, external probe result `close unix @->@: use of closed network connection`. | |||
| 118 | - | ||||
| 119 | - | **Verified (read from code, with `file:line`):** `Event.ProxyID` exists (`core.go:35`); header read is one `ReadMsgUnix`, body is a second read plus `io.ReadFull` fallback (`client.go:350`, `:385`, `:392`); the `opcode > 0xFFFF` check is unreachable (`client.go:362` vs `:367`); unknown object IDs are only logged (`client.go:431`, `:439`); no max-size or alignment check exists in `Dispatch`; receive buffers are per-`Display` and reused (`client.go:71`, `:74`, `:379`); `d.fd` is set and never used (`client.go:170-177`); `globalFDQueue` is package-global (`scm_linux.go:29`); `enqueueFD`/`dequeueFD` can spin unbounded (`scm_linux.go:47-88`); control buffer holds 4 FDs and truncation flags are ignored (`scm_linux.go:41`, `:97`); hot-path logs (`client.go:230`, `:408`, `:413`, `:425`, `:431`, `:795`, `:836`); fresh `&Event{}` bypasses the pool (`client.go:418` vs `event_pool.go:12`); no test covers framing/SCM/lifecycle/concurrency and none needs a compositor (§5). | |||
| 120 | - | ||||
| 121 | - | **Suspected (derived from code, not executed because the package does not build):** the retained body sub-slice is clobbered by the next `Dispatch` (`client.go:379`); received FDs leak on parse error or shutdown (no `close` on the queue path — `scm_linux.go:104-122`, `core.go:227`); `Event.Fd` can return a descriptor belonging to another event or connection because the queue is global (`core.go:227`); FD loss on `MSG_CTRUNC` (`scm_linux.go:41`, `:97`); `Dispatch` after close returns a wrapped error with no sentinel (`client.go:351`). | |||