Merge branch 'nc/core': connection, dispatch, outputs and fd watching
15 files · +1936 -1
M .mockery.yml +8 -1
| @@ -5,4 +5,11 @@ structname: '{{.Mock}}{{.InterfaceName}}' | |||
| 5 | 5 | template: testify | |
| 6 | 6 | template-schema: '{{.Template}}.schema.json' | |
| 7 | 7 | require-template-schema-exists: true | |
| 8 | - | packages: {} | |
| 8 | + | packages: | |
| 9 | + | github.com/bnema/neferclient: | |
| 10 | + | config: | |
| 11 | + | dir: mocks | |
| 12 | + | pkgname: neferclientmocks | |
| 13 | + | filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go' | |
| 14 | + | interfaces: | |
| 15 | + | Handler: | |
| @@ -5,4 +5,11 @@ structname: '{{.Mock}}{{.InterfaceName}}' | |||||
| 5 | template: testify | 5 | template: testify | ||
| 6 | template-schema: '{{.Template}}.schema.json' | 6 | template-schema: '{{.Template}}.schema.json' | ||
| 7 | require-template-schema-exists: true | 7 | require-template-schema-exists: true | ||
| 8 | - | packages: {} | 8 | + | packages: |
| 9 | + | github.com/bnema/neferclient: | |||
| 10 | + | config: | |||
| 11 | + | dir: mocks | |||
| 12 | + | pkgname: neferclientmocks | |||
| 13 | + | filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go' | |||
| 14 | + | interfaces: | |||
| 15 | + | Handler: | |||
A alloc_test.go +67 -0
| @@ -0,0 +1,67 @@ | |||
| 1 | + | package neferclient_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "runtime" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | "golang.org/x/sys/unix" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/neferclient" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // countingHandler is the measured system's input, not a double of a port: it | |
| 14 | + | // only counts, so the test sees the cost of the library alone. | |
| 15 | + | type countingHandler struct { | |
| 16 | + | neferclient.NopHandler | |
| 17 | + | fdReady, errs int | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (h *countingHandler) FDReady(uint64) { h.fdReady++ } | |
| 21 | + | func (h *countingHandler) Error(error) { h.errs++ } | |
| 22 | + | ||
| 23 | + | // TestAllocDispatch measures the whole steady-state pipeline over a real | |
| 24 | + | // socketpair: wire frames in, reader decode, queue, Dispatch (output state | |
| 25 | + | // updates, an output commit and a watched-fd notification with epoll re-arm). testing's | |
| 26 | + | // AllocsPerRun counts every goroutine, so reader and epoll allocations are | |
| 27 | + | // included. | |
| 28 | + | func TestAllocDispatch(t *testing.T) { | |
| 29 | + | skipUnderRace(t) | |
| 30 | + | c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}}) | |
| 31 | + | obj := srv.waitBound(7) | |
| 32 | + | ||
| 33 | + | const scaleEvents = 200 | |
| 34 | + | var batch []byte | |
| 35 | + | for i := range scaleEvents { | |
| 36 | + | batch = append(batch, frame(obj.id, 3, appendU32(nil, uint32(1+i%2)))...) | |
| 37 | + | } | |
| 38 | + | batch = append(batch, frame(obj.id, 2, nil)...) // wl_output.done: commitOutput runs | |
| 39 | + | efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | defer unix.Close(efd) | |
| 42 | + | require.NoError(t, c.WatchFD(efd, 1)) | |
| 43 | + | one := [8]byte{1} | |
| 44 | + | var drain [8]byte | |
| 45 | + | ||
| 46 | + | h := &countingHandler{} | |
| 47 | + | step := func() { | |
| 48 | + | if _, err := srv.conn.Write(batch); err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | _, _ = unix.Write(efd, one[:]) | |
| 52 | + | for c.QueueLen() < scaleEvents+2 { | |
| 53 | + | runtime.Gosched() | |
| 54 | + | } | |
| 55 | + | _, _ = unix.Read(efd, drain[:]) | |
| 56 | + | if err := c.Dispatch(h); err != nil { | |
| 57 | + | t.Fatal(err) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | step() // warm-up beyond AllocsPerRun's own: grow pools and buffers | |
| 61 | + | step() | |
| 62 | + | allocs := testing.AllocsPerRun(50, step) | |
| 63 | + | t.Logf("allocs per Dispatch of %d events: %v", scaleEvents+2, allocs) | |
| 64 | + | require.Zero(t, allocs) | |
| 65 | + | require.Positive(t, h.fdReady) | |
| 66 | + | require.Zero(t, h.errs) | |
| 67 | + | } | |
| @@ -0,0 +1,67 @@ | |||||
| 1 | + | package neferclient_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "runtime" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | "golang.org/x/sys/unix" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/neferclient" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // countingHandler is the measured system's input, not a double of a port: it | |||
| 14 | + | // only counts, so the test sees the cost of the library alone. | |||
| 15 | + | type countingHandler struct { | |||
| 16 | + | neferclient.NopHandler | |||
| 17 | + | fdReady, errs int | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func (h *countingHandler) FDReady(uint64) { h.fdReady++ } | |||
| 21 | + | func (h *countingHandler) Error(error) { h.errs++ } | |||
| 22 | + | ||||
| 23 | + | // TestAllocDispatch measures the whole steady-state pipeline over a real | |||
| 24 | + | // socketpair: wire frames in, reader decode, queue, Dispatch (output state | |||
| 25 | + | // updates, an output commit and a watched-fd notification with epoll re-arm). testing's | |||
| 26 | + | // AllocsPerRun counts every goroutine, so reader and epoll allocations are | |||
| 27 | + | // included. | |||
| 28 | + | func TestAllocDispatch(t *testing.T) { | |||
| 29 | + | skipUnderRace(t) | |||
| 30 | + | c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}}) | |||
| 31 | + | obj := srv.waitBound(7) | |||
| 32 | + | ||||
| 33 | + | const scaleEvents = 200 | |||
| 34 | + | var batch []byte | |||
| 35 | + | for i := range scaleEvents { | |||
| 36 | + | batch = append(batch, frame(obj.id, 3, appendU32(nil, uint32(1+i%2)))...) | |||
| 37 | + | } | |||
| 38 | + | batch = append(batch, frame(obj.id, 2, nil)...) // wl_output.done: commitOutput runs | |||
| 39 | + | efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK) | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | defer unix.Close(efd) | |||
| 42 | + | require.NoError(t, c.WatchFD(efd, 1)) | |||
| 43 | + | one := [8]byte{1} | |||
| 44 | + | var drain [8]byte | |||
| 45 | + | ||||
| 46 | + | h := &countingHandler{} | |||
| 47 | + | step := func() { | |||
| 48 | + | if _, err := srv.conn.Write(batch); err != nil { | |||
| 49 | + | t.Fatal(err) | |||
| 50 | + | } | |||
| 51 | + | _, _ = unix.Write(efd, one[:]) | |||
| 52 | + | for c.QueueLen() < scaleEvents+2 { | |||
| 53 | + | runtime.Gosched() | |||
| 54 | + | } | |||
| 55 | + | _, _ = unix.Read(efd, drain[:]) | |||
| 56 | + | if err := c.Dispatch(h); err != nil { | |||
| 57 | + | t.Fatal(err) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | step() // warm-up beyond AllocsPerRun's own: grow pools and buffers | |||
| 61 | + | step() | |||
| 62 | + | allocs := testing.AllocsPerRun(50, step) | |||
| 63 | + | t.Logf("allocs per Dispatch of %d events: %v", scaleEvents+2, allocs) | |||
| 64 | + | require.Zero(t, allocs) | |||
| 65 | + | require.Positive(t, h.fdReady) | |||
| 66 | + | require.Zero(t, h.errs) | |||
| 67 | + | } | |||
A conn.go +583 -0
| @@ -0,0 +1,583 @@ | |||
| 1 | + | package neferclient | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "net" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/go-wayland-bindings/client/wayland" | |
| 13 | + | "github.com/bnema/wlturbo" | |
| 14 | + | "github.com/bnema/wlturbo/wl" | |
| 15 | + | "golang.org/x/sys/unix" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // dialTimeout bounds the unix-socket dial; a local dial is normally instant. | |
| 19 | + | const dialTimeout = 5 * time.Second | |
| 20 | + | ||
| 21 | + | // maxOutputVersion is the wl_output version bound: 4 adds names. | |
| 22 | + | const maxOutputVersion = 4 | |
| 23 | + | ||
| 24 | + | var ( | |
| 25 | + | // ErrClosed is returned by calls on a closed Conn. | |
| 26 | + | ErrClosed = errors.New("neferclient: connection closed") | |
| 27 | + | // ErrGlobalNotFound is returned by [Conn.Bind] when the compositor does | |
| 28 | + | // not announce the interface. Test it with errors.Is. | |
| 29 | + | ErrGlobalNotFound = wlturbo.ErrGlobalNotFound | |
| 30 | + | ) | |
| 31 | + | ||
| 32 | + | // Output describes one wl_output after its latest wl_output.done. Only | |
| 33 | + | // wl_output globals of version 2 or later are bound (version 1 has no done | |
| 34 | + | // event, so its description is never known to be complete); older ones are | |
| 35 | + | // ignored. | |
| 36 | + | type Output struct { | |
| 37 | + | Global uint32 // registry name; identifies the output for its lifetime | |
| 38 | + | Name string // wl_output.name, empty before version 4 | |
| 39 | + | Scale int32 // integer scale, 1 until announced | |
| 40 | + | Width, Height int32 // current mode in physical pixels | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // outputEntry holds the owner-side state of one bound wl_output. Properties | |
| 44 | + | // accumulate in the pending fields and become visible at wl_output.done. | |
| 45 | + | type outputEntry struct { | |
| 46 | + | proxy *wayland.Output | |
| 47 | + | done bool // first wl_output.done applied; the output is public | |
| 48 | + | pub Output | |
| 49 | + | ||
| 50 | + | nameBuf [maxNameLen]byte | |
| 51 | + | nameLen int | |
| 52 | + | scale int32 | |
| 53 | + | width, height int32 | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // Conn is one Wayland connection. Two goroutines run beside the owner: the | |
| 57 | + | // reader, which only decodes wire events into a fixed queue, and an epoll | |
| 58 | + | // waiter for [Conn.WatchFD]. Everything else — event handling, requests, | |
| 59 | + | // Bind, Roundtrip, Outputs, Close — belongs to the owner goroutine, the one | |
| 60 | + | // that calls [Conn.Dispatch]. | |
| 61 | + | type Conn struct { | |
| 62 | + | sock net.Conn | |
| 63 | + | display *wl.Display | |
| 64 | + | wlctx *wl.Context | |
| 65 | + | q *queue | |
| 66 | + | ||
| 67 | + | readerStarted bool | |
| 68 | + | readerDone chan struct{} | |
| 69 | + | pauseDepth int | |
| 70 | + | ||
| 71 | + | epfd, efd int | |
| 72 | + | epollStart bool | |
| 73 | + | epollDone chan struct{} | |
| 74 | + | watched map[int32]uint64 // fd -> caller id | |
| 75 | + | ||
| 76 | + | outputs []*outputEntry // sorted by Global | |
| 77 | + | view []Output // reused by Outputs | |
| 78 | + | ||
| 79 | + | ready bool // Connect finished; announcements go to the Handler | |
| 80 | + | dispatching bool | |
| 81 | + | closed bool | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // Connect dials the compositor and completes the registry setup: every | |
| 85 | + | // wl_output announced at that point is bound and its first description is | |
| 86 | + | // available from [Conn.Outputs]. display names the socket relative to | |
| 87 | + | // $XDG_RUNTIME_DIR, or is an absolute path; "" uses $WAYLAND_DISPLAY, then | |
| 88 | + | // "wayland-0". Cancelling ctx abandons setup; afterwards ctx is not used. | |
| 89 | + | func Connect(ctx context.Context, display string) (*Conn, error) { | |
| 90 | + | if ctx == nil { | |
| 91 | + | return nil, errors.New("neferclient: nil context") | |
| 92 | + | } | |
| 93 | + | if err := ctx.Err(); err != nil { | |
| 94 | + | return nil, err | |
| 95 | + | } | |
| 96 | + | path, err := socketPath(display) | |
| 97 | + | if err != nil { | |
| 98 | + | return nil, err | |
| 99 | + | } | |
| 100 | + | sock, err := (&net.Dialer{Timeout: dialTimeout}).DialContext(ctx, "unix", path) | |
| 101 | + | if err != nil { | |
| 102 | + | return nil, fmt.Errorf("neferclient: connect %s: %w", path, err) | |
| 103 | + | } | |
| 104 | + | return connectConn(ctx, sock) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func socketPath(name string) (string, error) { | |
| 108 | + | if name == "" { | |
| 109 | + | name = os.Getenv("WAYLAND_DISPLAY") | |
| 110 | + | if name == "" { | |
| 111 | + | name = "wayland-0" | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | if filepath.IsAbs(name) { | |
| 115 | + | return name, nil | |
| 116 | + | } | |
| 117 | + | dir := os.Getenv("XDG_RUNTIME_DIR") | |
| 118 | + | if dir == "" { | |
| 119 | + | return "", errors.New("neferclient: XDG_RUNTIME_DIR not set") | |
| 120 | + | } | |
| 121 | + | return filepath.Join(dir, name), nil | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // connectConn runs the setup over an established connection and takes | |
| 125 | + | // ownership of it. | |
| 126 | + | func connectConn(ctx context.Context, sock net.Conn) (res *Conn, err error) { | |
| 127 | + | display, err := wlturbo.ConnectFromConn(sock) | |
| 128 | + | if err != nil { | |
| 129 | + | _ = sock.Close() | |
| 130 | + | return nil, fmt.Errorf("neferclient: %w", err) | |
| 131 | + | } | |
| 132 | + | c := &Conn{ | |
| 133 | + | sock: sock, | |
| 134 | + | display: display, | |
| 135 | + | wlctx: display.Context(), | |
| 136 | + | q: newQueue(true), // no reader yet: owner-side events are parked | |
| 137 | + | efd: -1, | |
| 138 | + | epfd: -1, | |
| 139 | + | watched: make(map[int32]uint64), | |
| 140 | + | } | |
| 141 | + | watch := context.AfterFunc(ctx, func() { _ = display.Close() }) | |
| 142 | + | defer func() { | |
| 143 | + | if !watch() && err == nil { | |
| 144 | + | err = ctx.Err() | |
| 145 | + | } | |
| 146 | + | if err != nil { | |
| 147 | + | _ = c.Close() | |
| 148 | + | res = nil | |
| 149 | + | if ctx.Err() != nil { | |
| 150 | + | err = errors.Join(ctx.Err(), err) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | }() | |
| 154 | + | if c.epfd, err = unix.EpollCreate1(unix.EPOLL_CLOEXEC); err != nil { | |
| 155 | + | return nil, fmt.Errorf("neferclient: epoll: %w", err) | |
| 156 | + | } | |
| 157 | + | if c.efd, err = unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK); err != nil { | |
| 158 | + | return nil, fmt.Errorf("neferclient: eventfd: %w", err) | |
| 159 | + | } | |
| 160 | + | ev := unix.EpollEvent{Events: unix.EPOLLIN, Fd: int32(c.efd)} | |
| 161 | + | if err = unix.EpollCtl(c.epfd, unix.EPOLL_CTL_ADD, c.efd, &ev); err != nil { | |
| 162 | + | return nil, fmt.Errorf("neferclient: epoll: %w", err) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | reg := display.Registry() | |
| 166 | + | reg.AddHandler(wayland.OutputInterface, func(_ *wl.Registry, name, version uint32) { | |
| 167 | + | e := event{kind: evGlobal, global: name, version: version} | |
| 168 | + | c.q.post(&e) | |
| 169 | + | }) | |
| 170 | + | reg.AddGlobalRemoveHandler(removeHook{c.q}) | |
| 171 | + | ||
| 172 | + | // With no reader running, the owner's own roundtrips park every event in | |
| 173 | + | // the queue's held list, in order; applying it here is the startup drain. | |
| 174 | + | if err = display.Roundtrip(); err != nil { | |
| 175 | + | return nil, fmt.Errorf("neferclient: registry roundtrip: %w", err) | |
| 176 | + | } | |
| 177 | + | if err = c.applyHeld(); err != nil { | |
| 178 | + | return nil, err | |
| 179 | + | } | |
| 180 | + | if err = display.Roundtrip(); err != nil { // wl_output properties follow the binds | |
| 181 | + | return nil, fmt.Errorf("neferclient: output roundtrip: %w", err) | |
| 182 | + | } | |
| 183 | + | if err = c.applyHeld(); err != nil { | |
| 184 | + | return nil, err | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | c.ready = true | |
| 188 | + | c.readerDone = make(chan struct{}) | |
| 189 | + | c.readerStarted = true | |
| 190 | + | c.epollDone = make(chan struct{}) | |
| 191 | + | c.epollStart = true | |
| 192 | + | c.q.setPausing(false) | |
| 193 | + | go c.readLoop() | |
| 194 | + | go c.epollLoop() | |
| 195 | + | return c, nil | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // removeHook forwards registry global_remove to the queue. | |
| 199 | + | type removeHook struct{ q *queue } | |
| 200 | + | ||
| 201 | + | func (h removeHook) HandleRegistryGlobalRemove(e wl.RegistryGlobalRemoveEvent) { | |
| 202 | + | ev := event{kind: evGlobalRemove, global: e.Name} | |
| 203 | + | h.q.post(&ev) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // setupHandler applies setup events without notifying anyone and keeps the | |
| 207 | + | // first failure, which makes Connect fail. | |
| 208 | + | type setupHandler struct { | |
| 209 | + | NopHandler | |
| 210 | + | err *error | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func (h setupHandler) Error(err error) { | |
| 214 | + | if *h.err == nil { | |
| 215 | + | *h.err = err | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // applyHeld applies the events parked during setup. | |
| 220 | + | func (c *Conn) applyHeld() error { | |
| 221 | + | var err error | |
| 222 | + | h := setupHandler{err: &err} | |
| 223 | + | held := c.q.takeHeld() | |
| 224 | + | for i := range held { | |
| 225 | + | c.apply(&held[i], h) | |
| 226 | + | } | |
| 227 | + | return err | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // readLoop is the reader goroutine. It only calls Display.Dispatch; the | |
| 231 | + | // generated handlers it triggers only push events into the queue. | |
| 232 | + | func (c *Conn) readLoop() { | |
| 233 | + | defer close(c.readerDone) | |
| 234 | + | defer c.q.readerDone() | |
| 235 | + | for { | |
| 236 | + | if !c.q.gate() { | |
| 237 | + | return | |
| 238 | + | } | |
| 239 | + | err := c.display.Dispatch() | |
| 240 | + | if err == nil || errors.Is(err, os.ErrDeadlineExceeded) { | |
| 241 | + | continue // a pause request interrupted the read | |
| 242 | + | } | |
| 243 | + | if !c.q.isStopped() { | |
| 244 | + | c.q.setFatal(fmt.Errorf("neferclient: read: %w", err)) | |
| 245 | + | } | |
| 246 | + | return | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // epollLoop is the second goroutine: it waits for watched descriptors and the | |
| 251 | + | // shutdown eventfd. | |
| 252 | + | func (c *Conn) epollLoop() { | |
| 253 | + | defer close(c.epollDone) | |
| 254 | + | var evs [8]unix.EpollEvent | |
| 255 | + | for { | |
| 256 | + | n, err := unix.EpollWait(c.epfd, evs[:], -1) | |
| 257 | + | if err != nil { | |
| 258 | + | if errors.Is(err, unix.EINTR) { | |
| 259 | + | continue | |
| 260 | + | } | |
| 261 | + | c.q.setFatal(fmt.Errorf("neferclient: epoll wait: %w", err)) | |
| 262 | + | return | |
| 263 | + | } | |
| 264 | + | for i := range n { | |
| 265 | + | if evs[i].Fd == int32(c.efd) { | |
| 266 | + | return | |
| 267 | + | } | |
| 268 | + | e := event{kind: evFDReady, fd: evs[i].Fd} | |
| 269 | + | c.q.post(&e) | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | // pause parks the reader between two reads and returns once it is parked. | |
| 275 | + | // Nested calls on the owner goroutine share the first pause. Before the reader | |
| 276 | + | // starts, the queue already parks everything and pause does nothing. | |
| 277 | + | func (c *Conn) pause() { | |
| 278 | + | if !c.readerStarted { | |
| 279 | + | return | |
| 280 | + | } | |
| 281 | + | c.pauseDepth++ | |
| 282 | + | if c.pauseDepth > 1 { | |
| 283 | + | return | |
| 284 | + | } | |
| 285 | + | c.q.setPausing(true) | |
| 286 | + | _ = c.sock.SetReadDeadline(time.Unix(1, 0)) // wakes a blocked read | |
| 287 | + | c.q.awaitParked() | |
| 288 | + | _ = c.sock.SetReadDeadline(time.Time{}) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | func (c *Conn) resume() { | |
| 292 | + | if !c.readerStarted { | |
| 293 | + | return | |
| 294 | + | } | |
| 295 | + | c.pauseDepth-- | |
| 296 | + | if c.pauseDepth == 0 { | |
| 297 | + | c.q.endPause() | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // Wake returns a channel with capacity 1 that is signaled whenever events are | |
| 302 | + | // queued: call [Conn.Dispatch] when it fires. A signal can be stale (the | |
| 303 | + | // events were already drained), so Dispatch may find nothing to do. | |
| 304 | + | func (c *Conn) Wake() <-chan struct{} { return c.q.wake } | |
| 305 | + | ||
| 306 | + | // Context returns the wlturbo context that generated proxies are created on. | |
| 307 | + | func (c *Conn) Context() *wl.Context { return c.wlctx } | |
| 308 | + | ||
| 309 | + | // Display returns the underlying wlturbo display. Only the owner goroutine | |
| 310 | + | // may call Dispatch or Roundtrip on it; use [Conn.Dispatch] and | |
| 311 | + | // [Conn.Roundtrip] instead. | |
| 312 | + | func (c *Conn) Display() *wl.Display { return c.display } | |
| 313 | + | ||
| 314 | + | // Dispatch applies the events the reader queued and calls h for each one that | |
| 315 | + | // matters to the application, on the calling goroutine. It handles at most one | |
| 316 | + | // ring of events per call and signals [Conn.Wake] again if more are pending. | |
| 317 | + | // A nil h discards the notifications but still applies the state changes. It | |
| 318 | + | // returns the transport error once the queue is drained after the connection | |
| 319 | + | // failed, and [ErrClosed] after Close. | |
| 320 | + | func (c *Conn) Dispatch(h Handler) error { | |
| 321 | + | if c.closed { | |
| 322 | + | return ErrClosed | |
| 323 | + | } | |
| 324 | + | if c.dispatching { | |
| 325 | + | return errors.New("neferclient: Dispatch called from a Handler") | |
| 326 | + | } | |
| 327 | + | if h == nil { | |
| 328 | + | h = NopHandler{} | |
| 329 | + | } | |
| 330 | + | c.dispatching = true | |
| 331 | + | defer func() { c.dispatching = false }() | |
| 332 | + | var ev event | |
| 333 | + | for range ringSize { | |
| 334 | + | if !c.q.pop(&ev) { | |
| 335 | + | return c.q.fatalErr() | |
| 336 | + | } | |
| 337 | + | c.apply(&ev, h) | |
| 338 | + | if c.closed { | |
| 339 | + | return ErrClosed | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | if c.q.pending() { | |
| 343 | + | c.q.signal() | |
| 344 | + | return nil | |
| 345 | + | } | |
| 346 | + | return c.q.fatalErr() | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | // apply runs on the owner goroutine. | |
| 350 | + | func (c *Conn) apply(ev *event, h Handler) { | |
| 351 | + | switch ev.kind { | |
| 352 | + | case evGlobal: | |
| 353 | + | if err := c.bindOutput(ev.global, ev.version); err != nil { | |
| 354 | + | h.Error(err) | |
| 355 | + | } | |
| 356 | + | case evGlobalRemove: | |
| 357 | + | c.removeOutput(ev.global, h) | |
| 358 | + | case evOutputName: | |
| 359 | + | if o := c.entry(ev.global); o != nil { | |
| 360 | + | o.nameLen = copy(o.nameBuf[:], ev.nameBytes()) | |
| 361 | + | } | |
| 362 | + | case evOutputScale: | |
| 363 | + | if o := c.entry(ev.global); o != nil && ev.a > 0 { | |
| 364 | + | o.scale = ev.a | |
| 365 | + | } | |
| 366 | + | case evOutputMode: | |
| 367 | + | if o := c.entry(ev.global); o != nil && ev.flags&1 != 0 { // current mode | |
| 368 | + | o.width, o.height = ev.a, ev.b | |
| 369 | + | } | |
| 370 | + | case evOutputDone: | |
| 371 | + | if o := c.entry(ev.global); o != nil { | |
| 372 | + | c.commitOutput(o, h) | |
| 373 | + | } | |
| 374 | + | case evFDReady: | |
| 375 | + | id, ok := c.watched[ev.fd] | |
| 376 | + | if !ok { | |
| 377 | + | return // unwatched while the event was queued | |
| 378 | + | } | |
| 379 | + | h.FDReady(id) | |
| 380 | + | if c.closed { | |
| 381 | + | return // the handler closed the connection | |
| 382 | + | } | |
| 383 | + | if _, ok := c.watched[ev.fd]; ok { // the handler may have unwatched it | |
| 384 | + | if err := c.armFD(int(ev.fd), unix.EPOLL_CTL_MOD); err != nil { | |
| 385 | + | h.Error(err) | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | func (c *Conn) entry(global uint32) *outputEntry { | |
| 392 | + | for _, o := range c.outputs { | |
| 393 | + | if o.pub.Global == global { | |
| 394 | + | return o | |
| 395 | + | } | |
| 396 | + | } | |
| 397 | + | return nil | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | func (c *Conn) bindOutput(global, version uint32) error { | |
| 401 | + | if version < 2 || c.entry(global) != nil { // v1 has no wl_output.done | |
| 402 | + | return nil | |
| 403 | + | } | |
| 404 | + | o := &outputEntry{pub: Output{Global: global, Scale: 1}, scale: 1} | |
| 405 | + | out := wayland.NewOutput(c.wlctx) | |
| 406 | + | q := c.q | |
| 407 | + | // Handlers go on before the bind request is sent, so no early event of the | |
| 408 | + | // new object is lost. They run on the reader goroutine and only queue. | |
| 409 | + | out.OnName(func(name string) { | |
| 410 | + | e := event{kind: evOutputName, global: global} | |
| 411 | + | e.setName(name) | |
| 412 | + | q.post(&e) | |
| 413 | + | }) | |
| 414 | + | out.OnScale(func(factor int32) { | |
| 415 | + | e := event{kind: evOutputScale, global: global, a: factor} | |
| 416 | + | q.post(&e) | |
| 417 | + | }) | |
| 418 | + | out.OnMode(func(flags uint32, width, height, _ int32) { | |
| 419 | + | e := event{kind: evOutputMode, global: global, flags: flags, a: width, b: height} | |
| 420 | + | q.post(&e) | |
| 421 | + | }) | |
| 422 | + | out.OnDone(func() { | |
| 423 | + | e := event{kind: evOutputDone, global: global} | |
| 424 | + | q.post(&e) | |
| 425 | + | }) | |
| 426 | + | if err := c.display.Registry().Bind(global, wayland.OutputInterface, min(version, maxOutputVersion), out); err != nil { | |
| 427 | + | c.wlctx.Unregister(out) | |
| 428 | + | return fmt.Errorf("neferclient: bind wl_output %d: %w", global, err) | |
| 429 | + | } | |
| 430 | + | o.proxy = out | |
| 431 | + | i := len(c.outputs) | |
| 432 | + | c.outputs = append(c.outputs, o) | |
| 433 | + | for ; i > 0 && c.outputs[i-1].pub.Global > global; i-- { | |
| 434 | + | c.outputs[i], c.outputs[i-1] = c.outputs[i-1], c.outputs[i] | |
| 435 | + | } | |
| 436 | + | return nil | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | func (c *Conn) commitOutput(o *outputEntry, h Handler) { | |
| 440 | + | if string(o.nameBuf[:o.nameLen]) != o.pub.Name { | |
| 441 | + | o.pub.Name = string(o.nameBuf[:o.nameLen]) | |
| 442 | + | } | |
| 443 | + | o.pub.Scale, o.pub.Width, o.pub.Height = o.scale, o.width, o.height | |
| 444 | + | if !o.done { | |
| 445 | + | o.done = true | |
| 446 | + | if c.ready { | |
| 447 | + | h.OutputAdded(&o.pub) | |
| 448 | + | } | |
| 449 | + | } | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | func (c *Conn) removeOutput(global uint32, h Handler) { | |
| 453 | + | for i, o := range c.outputs { | |
| 454 | + | if o.pub.Global != global { | |
| 455 | + | continue | |
| 456 | + | } | |
| 457 | + | if err := o.proxy.Release(); err != nil { | |
| 458 | + | c.wlctx.Unregister(o.proxy) // version 1 and 2 have no release | |
| 459 | + | } | |
| 460 | + | c.outputs = append(c.outputs[:i], c.outputs[i+1:]...) | |
| 461 | + | if o.done { | |
| 462 | + | h.OutputRemoved(global) | |
| 463 | + | } | |
| 464 | + | return | |
| 465 | + | } | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | // Outputs returns the outputs that finished their first description, ordered | |
| 469 | + | // by Global. The slice is reused by the next call; owner goroutine only. | |
| 470 | + | func (c *Conn) Outputs() []Output { | |
| 471 | + | c.view = c.view[:0] | |
| 472 | + | for _, o := range c.outputs { | |
| 473 | + | if o.done { | |
| 474 | + | c.view = append(c.view, o.pub) | |
| 475 | + | } | |
| 476 | + | } | |
| 477 | + | return c.view | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | // Roundtrip sends wl_display.sync and dispatches until it returns. The reader | |
| 481 | + | // is paused meanwhile; when Roundtrip returns, every event handled before the | |
| 482 | + | // sync reply is queued for [Conn.Dispatch] (up to the ring capacity; the rest | |
| 483 | + | // follows as the reader resumes). | |
| 484 | + | func (c *Conn) Roundtrip() error { | |
| 485 | + | if c.closed { | |
| 486 | + | return ErrClosed | |
| 487 | + | } | |
| 488 | + | c.pause() | |
| 489 | + | defer c.resume() | |
| 490 | + | if err := c.display.Roundtrip(); err != nil { | |
| 491 | + | return fmt.Errorf("neferclient: roundtrip: %w", err) | |
| 492 | + | } | |
| 493 | + | return nil | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | // Bind binds the global named iface at min(announced, version) and returns the | |
| 497 | + | // proxy and the negotiated version. newProxy creates the proxy on the given | |
| 498 | + | // context; register its event handlers inside newProxy: the reader is paused | |
| 499 | + | // until Bind returns, but the bound object's first events can arrive right | |
| 500 | + | // after, so a handler added later may miss them. A missing global yields an | |
| 501 | + | // error matching [ErrGlobalNotFound]. | |
| 502 | + | func (c *Conn) Bind[P wl.Proxy](iface string, version uint32, newProxy func(*wl.Context) P) (P, uint32, error) { | |
| 503 | + | var zero P | |
| 504 | + | if c.closed { | |
| 505 | + | return zero, 0, ErrClosed | |
| 506 | + | } | |
| 507 | + | c.pause() | |
| 508 | + | defer c.resume() | |
| 509 | + | p := newProxy(c.wlctx) | |
| 510 | + | v, err := c.display.Registry().BindNegotiated(iface, version, p) | |
| 511 | + | if err != nil { | |
| 512 | + | c.wlctx.Unregister(p) | |
| 513 | + | return zero, 0, err | |
| 514 | + | } | |
| 515 | + | return p, v, nil | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | // WatchFD reports readability of fd through Handler.FDReady(id). The watch is | |
| 519 | + | // one-shot: it is re-armed after each FDReady call returns. fd stays owned by | |
| 520 | + | // the caller and must be unwatched before it is closed. | |
| 521 | + | func (c *Conn) WatchFD(fd int, id uint64) error { | |
| 522 | + | if c.closed { | |
| 523 | + | return ErrClosed | |
| 524 | + | } | |
| 525 | + | if _, ok := c.watched[int32(fd)]; ok { | |
| 526 | + | return fmt.Errorf("neferclient: fd %d already watched", fd) | |
| 527 | + | } | |
| 528 | + | if err := c.armFD(fd, unix.EPOLL_CTL_ADD); err != nil { | |
| 529 | + | return err | |
| 530 | + | } | |
| 531 | + | c.watched[int32(fd)] = id | |
| 532 | + | return nil | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | // UnwatchFD stops watching fd. A notification already queued is dropped. | |
| 536 | + | func (c *Conn) UnwatchFD(fd int) error { | |
| 537 | + | if c.closed { | |
| 538 | + | return ErrClosed | |
| 539 | + | } | |
| 540 | + | if _, ok := c.watched[int32(fd)]; !ok { | |
| 541 | + | return fmt.Errorf("neferclient: fd %d not watched", fd) | |
| 542 | + | } | |
| 543 | + | delete(c.watched, int32(fd)) | |
| 544 | + | if err := unix.EpollCtl(c.epfd, unix.EPOLL_CTL_DEL, fd, nil); err != nil { | |
| 545 | + | return fmt.Errorf("neferclient: unwatch fd %d: %w", fd, err) | |
| 546 | + | } | |
| 547 | + | return nil | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | func (c *Conn) armFD(fd, op int) error { | |
| 551 | + | ev := unix.EpollEvent{Events: unix.EPOLLIN | unix.EPOLLONESHOT, Fd: int32(fd)} | |
| 552 | + | if err := unix.EpollCtl(c.epfd, op, fd, &ev); err != nil { | |
| 553 | + | return fmt.Errorf("neferclient: watch fd %d: %w", fd, err) | |
| 554 | + | } | |
| 555 | + | return nil | |
| 556 | + | } | |
| 557 | + | ||
| 558 | + | // Close closes the connection and stops both goroutines. It never requests an | |
| 559 | + | // unlock or any other protocol shutdown; those are explicit calls. It is safe | |
| 560 | + | // to call more than once. | |
| 561 | + | func (c *Conn) Close() error { | |
| 562 | + | if c == nil || c.closed { | |
| 563 | + | return nil | |
| 564 | + | } | |
| 565 | + | c.closed = true | |
| 566 | + | c.q.stop() | |
| 567 | + | err := c.display.Close() | |
| 568 | + | if c.readerStarted { | |
| 569 | + | <-c.readerDone | |
| 570 | + | } | |
| 571 | + | if c.epollStart { | |
| 572 | + | var one = [8]byte{1} | |
| 573 | + | _, _ = unix.Write(c.efd, one[:]) | |
| 574 | + | <-c.epollDone | |
| 575 | + | } | |
| 576 | + | if c.epfd >= 0 { | |
| 577 | + | _ = unix.Close(c.epfd) | |
| 578 | + | } | |
| 579 | + | if c.efd >= 0 { | |
| 580 | + | _ = unix.Close(c.efd) | |
| 581 | + | } | |
| 582 | + | return err | |
| 583 | + | } | |
| @@ -0,0 +1,583 @@ | |||||
| 1 | + | package neferclient | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "net" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/go-wayland-bindings/client/wayland" | |||
| 13 | + | "github.com/bnema/wlturbo" | |||
| 14 | + | "github.com/bnema/wlturbo/wl" | |||
| 15 | + | "golang.org/x/sys/unix" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // dialTimeout bounds the unix-socket dial; a local dial is normally instant. | |||
| 19 | + | const dialTimeout = 5 * time.Second | |||
| 20 | + | ||||
| 21 | + | // maxOutputVersion is the wl_output version bound: 4 adds names. | |||
| 22 | + | const maxOutputVersion = 4 | |||
| 23 | + | ||||
| 24 | + | var ( | |||
| 25 | + | // ErrClosed is returned by calls on a closed Conn. | |||
| 26 | + | ErrClosed = errors.New("neferclient: connection closed") | |||
| 27 | + | // ErrGlobalNotFound is returned by [Conn.Bind] when the compositor does | |||
| 28 | + | // not announce the interface. Test it with errors.Is. | |||
| 29 | + | ErrGlobalNotFound = wlturbo.ErrGlobalNotFound | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 32 | + | // Output describes one wl_output after its latest wl_output.done. Only | |||
| 33 | + | // wl_output globals of version 2 or later are bound (version 1 has no done | |||
| 34 | + | // event, so its description is never known to be complete); older ones are | |||
| 35 | + | // ignored. | |||
| 36 | + | type Output struct { | |||
| 37 | + | Global uint32 // registry name; identifies the output for its lifetime | |||
| 38 | + | Name string // wl_output.name, empty before version 4 | |||
| 39 | + | Scale int32 // integer scale, 1 until announced | |||
| 40 | + | Width, Height int32 // current mode in physical pixels | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // outputEntry holds the owner-side state of one bound wl_output. Properties | |||
| 44 | + | // accumulate in the pending fields and become visible at wl_output.done. | |||
| 45 | + | type outputEntry struct { | |||
| 46 | + | proxy *wayland.Output | |||
| 47 | + | done bool // first wl_output.done applied; the output is public | |||
| 48 | + | pub Output | |||
| 49 | + | ||||
| 50 | + | nameBuf [maxNameLen]byte | |||
| 51 | + | nameLen int | |||
| 52 | + | scale int32 | |||
| 53 | + | width, height int32 | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // Conn is one Wayland connection. Two goroutines run beside the owner: the | |||
| 57 | + | // reader, which only decodes wire events into a fixed queue, and an epoll | |||
| 58 | + | // waiter for [Conn.WatchFD]. Everything else — event handling, requests, | |||
| 59 | + | // Bind, Roundtrip, Outputs, Close — belongs to the owner goroutine, the one | |||
| 60 | + | // that calls [Conn.Dispatch]. | |||
| 61 | + | type Conn struct { | |||
| 62 | + | sock net.Conn | |||
| 63 | + | display *wl.Display | |||
| 64 | + | wlctx *wl.Context | |||
| 65 | + | q *queue | |||
| 66 | + | ||||
| 67 | + | readerStarted bool | |||
| 68 | + | readerDone chan struct{} | |||
| 69 | + | pauseDepth int | |||
| 70 | + | ||||
| 71 | + | epfd, efd int | |||
| 72 | + | epollStart bool | |||
| 73 | + | epollDone chan struct{} | |||
| 74 | + | watched map[int32]uint64 // fd -> caller id | |||
| 75 | + | ||||
| 76 | + | outputs []*outputEntry // sorted by Global | |||
| 77 | + | view []Output // reused by Outputs | |||
| 78 | + | ||||
| 79 | + | ready bool // Connect finished; announcements go to the Handler | |||
| 80 | + | dispatching bool | |||
| 81 | + | closed bool | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // Connect dials the compositor and completes the registry setup: every | |||
| 85 | + | // wl_output announced at that point is bound and its first description is | |||
| 86 | + | // available from [Conn.Outputs]. display names the socket relative to | |||
| 87 | + | // $XDG_RUNTIME_DIR, or is an absolute path; "" uses $WAYLAND_DISPLAY, then | |||
| 88 | + | // "wayland-0". Cancelling ctx abandons setup; afterwards ctx is not used. | |||
| 89 | + | func Connect(ctx context.Context, display string) (*Conn, error) { | |||
| 90 | + | if ctx == nil { | |||
| 91 | + | return nil, errors.New("neferclient: nil context") | |||
| 92 | + | } | |||
| 93 | + | if err := ctx.Err(); err != nil { | |||
| 94 | + | return nil, err | |||
| 95 | + | } | |||
| 96 | + | path, err := socketPath(display) | |||
| 97 | + | if err != nil { | |||
| 98 | + | return nil, err | |||
| 99 | + | } | |||
| 100 | + | sock, err := (&net.Dialer{Timeout: dialTimeout}).DialContext(ctx, "unix", path) | |||
| 101 | + | if err != nil { | |||
| 102 | + | return nil, fmt.Errorf("neferclient: connect %s: %w", path, err) | |||
| 103 | + | } | |||
| 104 | + | return connectConn(ctx, sock) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func socketPath(name string) (string, error) { | |||
| 108 | + | if name == "" { | |||
| 109 | + | name = os.Getenv("WAYLAND_DISPLAY") | |||
| 110 | + | if name == "" { | |||
| 111 | + | name = "wayland-0" | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | if filepath.IsAbs(name) { | |||
| 115 | + | return name, nil | |||
| 116 | + | } | |||
| 117 | + | dir := os.Getenv("XDG_RUNTIME_DIR") | |||
| 118 | + | if dir == "" { | |||
| 119 | + | return "", errors.New("neferclient: XDG_RUNTIME_DIR not set") | |||
| 120 | + | } | |||
| 121 | + | return filepath.Join(dir, name), nil | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // connectConn runs the setup over an established connection and takes | |||
| 125 | + | // ownership of it. | |||
| 126 | + | func connectConn(ctx context.Context, sock net.Conn) (res *Conn, err error) { | |||
| 127 | + | display, err := wlturbo.ConnectFromConn(sock) | |||
| 128 | + | if err != nil { | |||
| 129 | + | _ = sock.Close() | |||
| 130 | + | return nil, fmt.Errorf("neferclient: %w", err) | |||
| 131 | + | } | |||
| 132 | + | c := &Conn{ | |||
| 133 | + | sock: sock, | |||
| 134 | + | display: display, | |||
| 135 | + | wlctx: display.Context(), | |||
| 136 | + | q: newQueue(true), // no reader yet: owner-side events are parked | |||
| 137 | + | efd: -1, | |||
| 138 | + | epfd: -1, | |||
| 139 | + | watched: make(map[int32]uint64), | |||
| 140 | + | } | |||
| 141 | + | watch := context.AfterFunc(ctx, func() { _ = display.Close() }) | |||
| 142 | + | defer func() { | |||
| 143 | + | if !watch() && err == nil { | |||
| 144 | + | err = ctx.Err() | |||
| 145 | + | } | |||
| 146 | + | if err != nil { | |||
| 147 | + | _ = c.Close() | |||
| 148 | + | res = nil | |||
| 149 | + | if ctx.Err() != nil { | |||
| 150 | + | err = errors.Join(ctx.Err(), err) | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | }() | |||
| 154 | + | if c.epfd, err = unix.EpollCreate1(unix.EPOLL_CLOEXEC); err != nil { | |||
| 155 | + | return nil, fmt.Errorf("neferclient: epoll: %w", err) | |||
| 156 | + | } | |||
| 157 | + | if c.efd, err = unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK); err != nil { | |||
| 158 | + | return nil, fmt.Errorf("neferclient: eventfd: %w", err) | |||
| 159 | + | } | |||
| 160 | + | ev := unix.EpollEvent{Events: unix.EPOLLIN, Fd: int32(c.efd)} | |||
| 161 | + | if err = unix.EpollCtl(c.epfd, unix.EPOLL_CTL_ADD, c.efd, &ev); err != nil { | |||
| 162 | + | return nil, fmt.Errorf("neferclient: epoll: %w", err) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | reg := display.Registry() | |||
| 166 | + | reg.AddHandler(wayland.OutputInterface, func(_ *wl.Registry, name, version uint32) { | |||
| 167 | + | e := event{kind: evGlobal, global: name, version: version} | |||
| 168 | + | c.q.post(&e) | |||
| 169 | + | }) | |||
| 170 | + | reg.AddGlobalRemoveHandler(removeHook{c.q}) | |||
| 171 | + | ||||
| 172 | + | // With no reader running, the owner's own roundtrips park every event in | |||
| 173 | + | // the queue's held list, in order; applying it here is the startup drain. | |||
| 174 | + | if err = display.Roundtrip(); err != nil { | |||
| 175 | + | return nil, fmt.Errorf("neferclient: registry roundtrip: %w", err) | |||
| 176 | + | } | |||
| 177 | + | if err = c.applyHeld(); err != nil { | |||
| 178 | + | return nil, err | |||
| 179 | + | } | |||
| 180 | + | if err = display.Roundtrip(); err != nil { // wl_output properties follow the binds | |||
| 181 | + | return nil, fmt.Errorf("neferclient: output roundtrip: %w", err) | |||
| 182 | + | } | |||
| 183 | + | if err = c.applyHeld(); err != nil { | |||
| 184 | + | return nil, err | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | c.ready = true | |||
| 188 | + | c.readerDone = make(chan struct{}) | |||
| 189 | + | c.readerStarted = true | |||
| 190 | + | c.epollDone = make(chan struct{}) | |||
| 191 | + | c.epollStart = true | |||
| 192 | + | c.q.setPausing(false) | |||
| 193 | + | go c.readLoop() | |||
| 194 | + | go c.epollLoop() | |||
| 195 | + | return c, nil | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // removeHook forwards registry global_remove to the queue. | |||
| 199 | + | type removeHook struct{ q *queue } | |||
| 200 | + | ||||
| 201 | + | func (h removeHook) HandleRegistryGlobalRemove(e wl.RegistryGlobalRemoveEvent) { | |||
| 202 | + | ev := event{kind: evGlobalRemove, global: e.Name} | |||
| 203 | + | h.q.post(&ev) | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // setupHandler applies setup events without notifying anyone and keeps the | |||
| 207 | + | // first failure, which makes Connect fail. | |||
| 208 | + | type setupHandler struct { | |||
| 209 | + | NopHandler | |||
| 210 | + | err *error | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | func (h setupHandler) Error(err error) { | |||
| 214 | + | if *h.err == nil { | |||
| 215 | + | *h.err = err | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // applyHeld applies the events parked during setup. | |||
| 220 | + | func (c *Conn) applyHeld() error { | |||
| 221 | + | var err error | |||
| 222 | + | h := setupHandler{err: &err} | |||
| 223 | + | held := c.q.takeHeld() | |||
| 224 | + | for i := range held { | |||
| 225 | + | c.apply(&held[i], h) | |||
| 226 | + | } | |||
| 227 | + | return err | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | // readLoop is the reader goroutine. It only calls Display.Dispatch; the | |||
| 231 | + | // generated handlers it triggers only push events into the queue. | |||
| 232 | + | func (c *Conn) readLoop() { | |||
| 233 | + | defer close(c.readerDone) | |||
| 234 | + | defer c.q.readerDone() | |||
| 235 | + | for { | |||
| 236 | + | if !c.q.gate() { | |||
| 237 | + | return | |||
| 238 | + | } | |||
| 239 | + | err := c.display.Dispatch() | |||
| 240 | + | if err == nil || errors.Is(err, os.ErrDeadlineExceeded) { | |||
| 241 | + | continue // a pause request interrupted the read | |||
| 242 | + | } | |||
| 243 | + | if !c.q.isStopped() { | |||
| 244 | + | c.q.setFatal(fmt.Errorf("neferclient: read: %w", err)) | |||
| 245 | + | } | |||
| 246 | + | return | |||
| 247 | + | } | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | // epollLoop is the second goroutine: it waits for watched descriptors and the | |||
| 251 | + | // shutdown eventfd. | |||
| 252 | + | func (c *Conn) epollLoop() { | |||
| 253 | + | defer close(c.epollDone) | |||
| 254 | + | var evs [8]unix.EpollEvent | |||
| 255 | + | for { | |||
| 256 | + | n, err := unix.EpollWait(c.epfd, evs[:], -1) | |||
| 257 | + | if err != nil { | |||
| 258 | + | if errors.Is(err, unix.EINTR) { | |||
| 259 | + | continue | |||
| 260 | + | } | |||
| 261 | + | c.q.setFatal(fmt.Errorf("neferclient: epoll wait: %w", err)) | |||
| 262 | + | return | |||
| 263 | + | } | |||
| 264 | + | for i := range n { | |||
| 265 | + | if evs[i].Fd == int32(c.efd) { | |||
| 266 | + | return | |||
| 267 | + | } | |||
| 268 | + | e := event{kind: evFDReady, fd: evs[i].Fd} | |||
| 269 | + | c.q.post(&e) | |||
| 270 | + | } | |||
| 271 | + | } | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | // pause parks the reader between two reads and returns once it is parked. | |||
| 275 | + | // Nested calls on the owner goroutine share the first pause. Before the reader | |||
| 276 | + | // starts, the queue already parks everything and pause does nothing. | |||
| 277 | + | func (c *Conn) pause() { | |||
| 278 | + | if !c.readerStarted { | |||
| 279 | + | return | |||
| 280 | + | } | |||
| 281 | + | c.pauseDepth++ | |||
| 282 | + | if c.pauseDepth > 1 { | |||
| 283 | + | return | |||
| 284 | + | } | |||
| 285 | + | c.q.setPausing(true) | |||
| 286 | + | _ = c.sock.SetReadDeadline(time.Unix(1, 0)) // wakes a blocked read | |||
| 287 | + | c.q.awaitParked() | |||
| 288 | + | _ = c.sock.SetReadDeadline(time.Time{}) | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | func (c *Conn) resume() { | |||
| 292 | + | if !c.readerStarted { | |||
| 293 | + | return | |||
| 294 | + | } | |||
| 295 | + | c.pauseDepth-- | |||
| 296 | + | if c.pauseDepth == 0 { | |||
| 297 | + | c.q.endPause() | |||
| 298 | + | } | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | // Wake returns a channel with capacity 1 that is signaled whenever events are | |||
| 302 | + | // queued: call [Conn.Dispatch] when it fires. A signal can be stale (the | |||
| 303 | + | // events were already drained), so Dispatch may find nothing to do. | |||
| 304 | + | func (c *Conn) Wake() <-chan struct{} { return c.q.wake } | |||
| 305 | + | ||||
| 306 | + | // Context returns the wlturbo context that generated proxies are created on. | |||
| 307 | + | func (c *Conn) Context() *wl.Context { return c.wlctx } | |||
| 308 | + | ||||
| 309 | + | // Display returns the underlying wlturbo display. Only the owner goroutine | |||
| 310 | + | // may call Dispatch or Roundtrip on it; use [Conn.Dispatch] and | |||
| 311 | + | // [Conn.Roundtrip] instead. | |||
| 312 | + | func (c *Conn) Display() *wl.Display { return c.display } | |||
| 313 | + | ||||
| 314 | + | // Dispatch applies the events the reader queued and calls h for each one that | |||
| 315 | + | // matters to the application, on the calling goroutine. It handles at most one | |||
| 316 | + | // ring of events per call and signals [Conn.Wake] again if more are pending. | |||
| 317 | + | // A nil h discards the notifications but still applies the state changes. It | |||
| 318 | + | // returns the transport error once the queue is drained after the connection | |||
| 319 | + | // failed, and [ErrClosed] after Close. | |||
| 320 | + | func (c *Conn) Dispatch(h Handler) error { | |||
| 321 | + | if c.closed { | |||
| 322 | + | return ErrClosed | |||
| 323 | + | } | |||
| 324 | + | if c.dispatching { | |||
| 325 | + | return errors.New("neferclient: Dispatch called from a Handler") | |||
| 326 | + | } | |||
| 327 | + | if h == nil { | |||
| 328 | + | h = NopHandler{} | |||
| 329 | + | } | |||
| 330 | + | c.dispatching = true | |||
| 331 | + | defer func() { c.dispatching = false }() | |||
| 332 | + | var ev event | |||
| 333 | + | for range ringSize { | |||
| 334 | + | if !c.q.pop(&ev) { | |||
| 335 | + | return c.q.fatalErr() | |||
| 336 | + | } | |||
| 337 | + | c.apply(&ev, h) | |||
| 338 | + | if c.closed { | |||
| 339 | + | return ErrClosed | |||
| 340 | + | } | |||
| 341 | + | } | |||
| 342 | + | if c.q.pending() { | |||
| 343 | + | c.q.signal() | |||
| 344 | + | return nil | |||
| 345 | + | } | |||
| 346 | + | return c.q.fatalErr() | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | // apply runs on the owner goroutine. | |||
| 350 | + | func (c *Conn) apply(ev *event, h Handler) { | |||
| 351 | + | switch ev.kind { | |||
| 352 | + | case evGlobal: | |||
| 353 | + | if err := c.bindOutput(ev.global, ev.version); err != nil { | |||
| 354 | + | h.Error(err) | |||
| 355 | + | } | |||
| 356 | + | case evGlobalRemove: | |||
| 357 | + | c.removeOutput(ev.global, h) | |||
| 358 | + | case evOutputName: | |||
| 359 | + | if o := c.entry(ev.global); o != nil { | |||
| 360 | + | o.nameLen = copy(o.nameBuf[:], ev.nameBytes()) | |||
| 361 | + | } | |||
| 362 | + | case evOutputScale: | |||
| 363 | + | if o := c.entry(ev.global); o != nil && ev.a > 0 { | |||
| 364 | + | o.scale = ev.a | |||
| 365 | + | } | |||
| 366 | + | case evOutputMode: | |||
| 367 | + | if o := c.entry(ev.global); o != nil && ev.flags&1 != 0 { // current mode | |||
| 368 | + | o.width, o.height = ev.a, ev.b | |||
| 369 | + | } | |||
| 370 | + | case evOutputDone: | |||
| 371 | + | if o := c.entry(ev.global); o != nil { | |||
| 372 | + | c.commitOutput(o, h) | |||
| 373 | + | } | |||
| 374 | + | case evFDReady: | |||
| 375 | + | id, ok := c.watched[ev.fd] | |||
| 376 | + | if !ok { | |||
| 377 | + | return // unwatched while the event was queued | |||
| 378 | + | } | |||
| 379 | + | h.FDReady(id) | |||
| 380 | + | if c.closed { | |||
| 381 | + | return // the handler closed the connection | |||
| 382 | + | } | |||
| 383 | + | if _, ok := c.watched[ev.fd]; ok { // the handler may have unwatched it | |||
| 384 | + | if err := c.armFD(int(ev.fd), unix.EPOLL_CTL_MOD); err != nil { | |||
| 385 | + | h.Error(err) | |||
| 386 | + | } | |||
| 387 | + | } | |||
| 388 | + | } | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | func (c *Conn) entry(global uint32) *outputEntry { | |||
| 392 | + | for _, o := range c.outputs { | |||
| 393 | + | if o.pub.Global == global { | |||
| 394 | + | return o | |||
| 395 | + | } | |||
| 396 | + | } | |||
| 397 | + | return nil | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | func (c *Conn) bindOutput(global, version uint32) error { | |||
| 401 | + | if version < 2 || c.entry(global) != nil { // v1 has no wl_output.done | |||
| 402 | + | return nil | |||
| 403 | + | } | |||
| 404 | + | o := &outputEntry{pub: Output{Global: global, Scale: 1}, scale: 1} | |||
| 405 | + | out := wayland.NewOutput(c.wlctx) | |||
| 406 | + | q := c.q | |||
| 407 | + | // Handlers go on before the bind request is sent, so no early event of the | |||
| 408 | + | // new object is lost. They run on the reader goroutine and only queue. | |||
| 409 | + | out.OnName(func(name string) { | |||
| 410 | + | e := event{kind: evOutputName, global: global} | |||
| 411 | + | e.setName(name) | |||
| 412 | + | q.post(&e) | |||
| 413 | + | }) | |||
| 414 | + | out.OnScale(func(factor int32) { | |||
| 415 | + | e := event{kind: evOutputScale, global: global, a: factor} | |||
| 416 | + | q.post(&e) | |||
| 417 | + | }) | |||
| 418 | + | out.OnMode(func(flags uint32, width, height, _ int32) { | |||
| 419 | + | e := event{kind: evOutputMode, global: global, flags: flags, a: width, b: height} | |||
| 420 | + | q.post(&e) | |||
| 421 | + | }) | |||
| 422 | + | out.OnDone(func() { | |||
| 423 | + | e := event{kind: evOutputDone, global: global} | |||
| 424 | + | q.post(&e) | |||
| 425 | + | }) | |||
| 426 | + | if err := c.display.Registry().Bind(global, wayland.OutputInterface, min(version, maxOutputVersion), out); err != nil { | |||
| 427 | + | c.wlctx.Unregister(out) | |||
| 428 | + | return fmt.Errorf("neferclient: bind wl_output %d: %w", global, err) | |||
| 429 | + | } | |||
| 430 | + | o.proxy = out | |||
| 431 | + | i := len(c.outputs) | |||
| 432 | + | c.outputs = append(c.outputs, o) | |||
| 433 | + | for ; i > 0 && c.outputs[i-1].pub.Global > global; i-- { | |||
| 434 | + | c.outputs[i], c.outputs[i-1] = c.outputs[i-1], c.outputs[i] | |||
| 435 | + | } | |||
| 436 | + | return nil | |||
| 437 | + | } | |||
| 438 | + | ||||
| 439 | + | func (c *Conn) commitOutput(o *outputEntry, h Handler) { | |||
| 440 | + | if string(o.nameBuf[:o.nameLen]) != o.pub.Name { | |||
| 441 | + | o.pub.Name = string(o.nameBuf[:o.nameLen]) | |||
| 442 | + | } | |||
| 443 | + | o.pub.Scale, o.pub.Width, o.pub.Height = o.scale, o.width, o.height | |||
| 444 | + | if !o.done { | |||
| 445 | + | o.done = true | |||
| 446 | + | if c.ready { | |||
| 447 | + | h.OutputAdded(&o.pub) | |||
| 448 | + | } | |||
| 449 | + | } | |||
| 450 | + | } | |||
| 451 | + | ||||
| 452 | + | func (c *Conn) removeOutput(global uint32, h Handler) { | |||
| 453 | + | for i, o := range c.outputs { | |||
| 454 | + | if o.pub.Global != global { | |||
| 455 | + | continue | |||
| 456 | + | } | |||
| 457 | + | if err := o.proxy.Release(); err != nil { | |||
| 458 | + | c.wlctx.Unregister(o.proxy) // version 1 and 2 have no release | |||
| 459 | + | } | |||
| 460 | + | c.outputs = append(c.outputs[:i], c.outputs[i+1:]...) | |||
| 461 | + | if o.done { | |||
| 462 | + | h.OutputRemoved(global) | |||
| 463 | + | } | |||
| 464 | + | return | |||
| 465 | + | } | |||
| 466 | + | } | |||
| 467 | + | ||||
| 468 | + | // Outputs returns the outputs that finished their first description, ordered | |||
| 469 | + | // by Global. The slice is reused by the next call; owner goroutine only. | |||
| 470 | + | func (c *Conn) Outputs() []Output { | |||
| 471 | + | c.view = c.view[:0] | |||
| 472 | + | for _, o := range c.outputs { | |||
| 473 | + | if o.done { | |||
| 474 | + | c.view = append(c.view, o.pub) | |||
| 475 | + | } | |||
| 476 | + | } | |||
| 477 | + | return c.view | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | // Roundtrip sends wl_display.sync and dispatches until it returns. The reader | |||
| 481 | + | // is paused meanwhile; when Roundtrip returns, every event handled before the | |||
| 482 | + | // sync reply is queued for [Conn.Dispatch] (up to the ring capacity; the rest | |||
| 483 | + | // follows as the reader resumes). | |||
| 484 | + | func (c *Conn) Roundtrip() error { | |||
| 485 | + | if c.closed { | |||
| 486 | + | return ErrClosed | |||
| 487 | + | } | |||
| 488 | + | c.pause() | |||
| 489 | + | defer c.resume() | |||
| 490 | + | if err := c.display.Roundtrip(); err != nil { | |||
| 491 | + | return fmt.Errorf("neferclient: roundtrip: %w", err) | |||
| 492 | + | } | |||
| 493 | + | return nil | |||
| 494 | + | } | |||
| 495 | + | ||||
| 496 | + | // Bind binds the global named iface at min(announced, version) and returns the | |||
| 497 | + | // proxy and the negotiated version. newProxy creates the proxy on the given | |||
| 498 | + | // context; register its event handlers inside newProxy: the reader is paused | |||
| 499 | + | // until Bind returns, but the bound object's first events can arrive right | |||
| 500 | + | // after, so a handler added later may miss them. A missing global yields an | |||
| 501 | + | // error matching [ErrGlobalNotFound]. | |||
| 502 | + | func (c *Conn) Bind[P wl.Proxy](iface string, version uint32, newProxy func(*wl.Context) P) (P, uint32, error) { | |||
| 503 | + | var zero P | |||
| 504 | + | if c.closed { | |||
| 505 | + | return zero, 0, ErrClosed | |||
| 506 | + | } | |||
| 507 | + | c.pause() | |||
| 508 | + | defer c.resume() | |||
| 509 | + | p := newProxy(c.wlctx) | |||
| 510 | + | v, err := c.display.Registry().BindNegotiated(iface, version, p) | |||
| 511 | + | if err != nil { | |||
| 512 | + | c.wlctx.Unregister(p) | |||
| 513 | + | return zero, 0, err | |||
| 514 | + | } | |||
| 515 | + | return p, v, nil | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | // WatchFD reports readability of fd through Handler.FDReady(id). The watch is | |||
| 519 | + | // one-shot: it is re-armed after each FDReady call returns. fd stays owned by | |||
| 520 | + | // the caller and must be unwatched before it is closed. | |||
| 521 | + | func (c *Conn) WatchFD(fd int, id uint64) error { | |||
| 522 | + | if c.closed { | |||
| 523 | + | return ErrClosed | |||
| 524 | + | } | |||
| 525 | + | if _, ok := c.watched[int32(fd)]; ok { | |||
| 526 | + | return fmt.Errorf("neferclient: fd %d already watched", fd) | |||
| 527 | + | } | |||
| 528 | + | if err := c.armFD(fd, unix.EPOLL_CTL_ADD); err != nil { | |||
| 529 | + | return err | |||
| 530 | + | } | |||
| 531 | + | c.watched[int32(fd)] = id | |||
| 532 | + | return nil | |||
| 533 | + | } | |||
| 534 | + | ||||
| 535 | + | // UnwatchFD stops watching fd. A notification already queued is dropped. | |||
| 536 | + | func (c *Conn) UnwatchFD(fd int) error { | |||
| 537 | + | if c.closed { | |||
| 538 | + | return ErrClosed | |||
| 539 | + | } | |||
| 540 | + | if _, ok := c.watched[int32(fd)]; !ok { | |||
| 541 | + | return fmt.Errorf("neferclient: fd %d not watched", fd) | |||
| 542 | + | } | |||
| 543 | + | delete(c.watched, int32(fd)) | |||
| 544 | + | if err := unix.EpollCtl(c.epfd, unix.EPOLL_CTL_DEL, fd, nil); err != nil { | |||
| 545 | + | return fmt.Errorf("neferclient: unwatch fd %d: %w", fd, err) | |||
| 546 | + | } | |||
| 547 | + | return nil | |||
| 548 | + | } | |||
| 549 | + | ||||
| 550 | + | func (c *Conn) armFD(fd, op int) error { | |||
| 551 | + | ev := unix.EpollEvent{Events: unix.EPOLLIN | unix.EPOLLONESHOT, Fd: int32(fd)} | |||
| 552 | + | if err := unix.EpollCtl(c.epfd, op, fd, &ev); err != nil { | |||
| 553 | + | return fmt.Errorf("neferclient: watch fd %d: %w", fd, err) | |||
| 554 | + | } | |||
| 555 | + | return nil | |||
| 556 | + | } | |||
| 557 | + | ||||
| 558 | + | // Close closes the connection and stops both goroutines. It never requests an | |||
| 559 | + | // unlock or any other protocol shutdown; those are explicit calls. It is safe | |||
| 560 | + | // to call more than once. | |||
| 561 | + | func (c *Conn) Close() error { | |||
| 562 | + | if c == nil || c.closed { | |||
| 563 | + | return nil | |||
| 564 | + | } | |||
| 565 | + | c.closed = true | |||
| 566 | + | c.q.stop() | |||
| 567 | + | err := c.display.Close() | |||
| 568 | + | if c.readerStarted { | |||
| 569 | + | <-c.readerDone | |||
| 570 | + | } | |||
| 571 | + | if c.epollStart { | |||
| 572 | + | var one = [8]byte{1} | |||
| 573 | + | _, _ = unix.Write(c.efd, one[:]) | |||
| 574 | + | <-c.epollDone | |||
| 575 | + | } | |||
| 576 | + | if c.epfd >= 0 { | |||
| 577 | + | _ = unix.Close(c.epfd) | |||
| 578 | + | } | |||
| 579 | + | if c.efd >= 0 { | |||
| 580 | + | _ = unix.Close(c.efd) | |||
| 581 | + | } | |||
| 582 | + | return err | |||
| 583 | + | } | |||
A conn_test.go +333 -0
| @@ -0,0 +1,333 @@ | |||
| 1 | + | package neferclient_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/go-wayland-bindings/client/wayland" | |
| 9 | + | "github.com/bnema/neferclient" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | "golang.org/x/sys/unix" | |
| 14 | + | ||
| 15 | + | neferclientmocks "github.com/bnema/neferclient/mocks" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // dispatchUntil dispatches whenever Wake fires until done reports true. | |
| 19 | + | func dispatchUntil(t *testing.T, c *neferclient.Conn, h neferclient.Handler, done func() bool) { | |
| 20 | + | t.Helper() | |
| 21 | + | timeout := time.After(10 * time.Second) | |
| 22 | + | for { | |
| 23 | + | require.NoError(t, c.Dispatch(h)) | |
| 24 | + | if done() { | |
| 25 | + | return | |
| 26 | + | } | |
| 27 | + | select { | |
| 28 | + | case <-c.Wake(): | |
| 29 | + | case <-timeout: | |
| 30 | + | t.Fatal("timed out waiting for events") | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestSocketPath(t *testing.T) { | |
| 36 | + | t.Setenv("XDG_RUNTIME_DIR", "/run/user/1") | |
| 37 | + | t.Setenv("WAYLAND_DISPLAY", "") | |
| 38 | + | p, err := neferclient.SocketPath("") | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | assert.Equal(t, "/run/user/1/wayland-0", p) | |
| 41 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-7") | |
| 42 | + | p, _ = neferclient.SocketPath("") | |
| 43 | + | assert.Equal(t, "/run/user/1/wayland-7", p) | |
| 44 | + | p, _ = neferclient.SocketPath("x") | |
| 45 | + | assert.Equal(t, "/run/user/1/x", p) | |
| 46 | + | p, _ = neferclient.SocketPath("/tmp/sock") | |
| 47 | + | assert.Equal(t, "/tmp/sock", p) | |
| 48 | + | t.Setenv("XDG_RUNTIME_DIR", "") | |
| 49 | + | _, err = neferclient.SocketPath("rel") | |
| 50 | + | assert.Error(t, err) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestConnectInitialOutputs(t *testing.T) { | |
| 54 | + | c, srv := connectWire(t, | |
| 55 | + | []wireGlobal{{1, "wl_seat", 9}, {7, outputIface, 4}, {9, outputIface, 2}}, | |
| 56 | + | map[uint32]outputSpec{7: {"DP-1", 2, 1920, 1080}, 9: {"ignored-before-v4", 1, 800, 600}}) | |
| 57 | + | assert.Equal(t, []neferclient.Output{ | |
| 58 | + | {Global: 7, Name: "DP-1", Scale: 2, Width: 1920, Height: 1080}, | |
| 59 | + | {Global: 9, Name: "", Scale: 1, Width: 800, Height: 600}, | |
| 60 | + | }, c.Outputs()) | |
| 61 | + | assert.Equal(t, uint32(4), srv.waitBound(7).version, "bound at min(announced, 4)") | |
| 62 | + | assert.Equal(t, uint32(2), srv.waitBound(9).version) | |
| 63 | + | ||
| 64 | + | // Setup events are applied silently: nothing is left for the Handler. The | |
| 65 | + | // roundtrip moves everything handled so far into the ring, so a leaked | |
| 66 | + | // setup event would reach the strict mock here. | |
| 67 | + | h := neferclientmocks.NewMockHandler(t) | |
| 68 | + | require.NoError(t, c.Roundtrip()) | |
| 69 | + | require.NoError(t, c.Dispatch(h)) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestConnectContextCancelled(t *testing.T) { | |
| 73 | + | client, _ := socketPair(t) // peer never answers | |
| 74 | + | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | |
| 75 | + | defer cancel() | |
| 76 | + | c, err := neferclient.ConnectConn(ctx, client) | |
| 77 | + | require.Error(t, err) | |
| 78 | + | assert.Nil(t, c) | |
| 79 | + | assert.ErrorIs(t, err, context.DeadlineExceeded) | |
| 80 | + | ||
| 81 | + | cancelled, stop := context.WithCancel(context.Background()) | |
| 82 | + | stop() | |
| 83 | + | _, err = neferclient.Connect(cancelled, "") | |
| 84 | + | assert.ErrorIs(t, err, context.Canceled) | |
| 85 | + | //lint:ignore SA1012 a nil context is the case under test | |
| 86 | + | _, err = neferclient.Connect(nil, "") | |
| 87 | + | assert.Error(t, err) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestHotplug(t *testing.T) { | |
| 91 | + | c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, | |
| 92 | + | map[uint32]outputSpec{7: {"DP-1", 1, 1920, 1080}, 11: {"HDMI-A-1", 2, 3840, 2160}}) | |
| 93 | + | h := neferclientmocks.NewMockHandler(t) | |
| 94 | + | var added []neferclient.Output | |
| 95 | + | h.EXPECT().OutputAdded(mock.Anything).Run(func(out *neferclient.Output) { added = append(added, *out) }).Once() | |
| 96 | + | srv.announce(wireGlobal{11, outputIface, 4}) | |
| 97 | + | dispatchUntil(t, c, h, func() bool { return len(added) == 1 }) | |
| 98 | + | assert.Equal(t, neferclient.Output{Global: 11, Name: "HDMI-A-1", Scale: 2, Width: 3840, Height: 2160}, added[0]) | |
| 99 | + | require.Len(t, c.Outputs(), 2) | |
| 100 | + | ||
| 101 | + | h.EXPECT().OutputRemoved(uint32(7)).Once() | |
| 102 | + | srv.remove(7) | |
| 103 | + | dispatchUntil(t, c, h, func() bool { return len(c.Outputs()) == 1 }) | |
| 104 | + | assert.Equal(t, uint32(11), c.Outputs()[0].Global) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestOutputPropertyChangeIsSilent(t *testing.T) { | |
| 108 | + | c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}}) | |
| 109 | + | obj := srv.waitBound(7) | |
| 110 | + | srv.write(frame(obj.id, 3, appendU32(nil, 3))) // scale 3 | |
| 111 | + | srv.write(frame(obj.id, 2, nil)) // done | |
| 112 | + | h := neferclientmocks.NewMockHandler(t) // no expectations: any call fails | |
| 113 | + | dispatchUntil(t, c, h, func() bool { return c.Outputs()[0].Scale == 3 }) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestRingBackpressureKeepsOrder(t *testing.T) { | |
| 117 | + | const total = 3 * neferclient.RingSize | |
| 118 | + | c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}}) | |
| 119 | + | obj := srv.waitBound(7) | |
| 120 | + | var buf []byte | |
| 121 | + | for i := 1; i <= total; i++ { | |
| 122 | + | buf = append(buf, frame(obj.id, 3, appendU32(nil, uint32(i)))...) | |
| 123 | + | buf = append(buf, frame(obj.id, 2, nil)...) | |
| 124 | + | } | |
| 125 | + | go srv.write(buf) | |
| 126 | + | waitFor(t, func() bool { return c.QueueLen() == neferclient.RingSize }, "full ring") | |
| 127 | + | ||
| 128 | + | // A pause (Roundtrip) must not deadlock against a producer blocked on the | |
| 129 | + | // full ring, and must not reorder what it held back: the visible scale | |
| 130 | + | // only ever grows, ending at total. | |
| 131 | + | require.NoError(t, c.Roundtrip()) | |
| 132 | + | prev := int32(0) | |
| 133 | + | dispatchUntil(t, c, nil, func() bool { | |
| 134 | + | got := c.Outputs()[0].Scale | |
| 135 | + | require.GreaterOrEqual(t, got, prev, "scale went backwards: events were reordered") | |
| 136 | + | prev = got | |
| 137 | + | return got == total | |
| 138 | + | }) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func TestRoundtripQueuesEventsForNextDispatch(t *testing.T) { | |
| 142 | + | c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}}) | |
| 143 | + | obj := srv.waitBound(7) | |
| 144 | + | srv.write(frame(obj.id, 3, appendU32(nil, 4))) // scale 4 | |
| 145 | + | srv.write(frame(obj.id, 2, nil)) // done | |
| 146 | + | require.NoError(t, c.Roundtrip()) | |
| 147 | + | // No Wake wait: the events handled during the roundtrip are already queued. | |
| 148 | + | require.NoError(t, c.Dispatch(nil)) | |
| 149 | + | assert.Equal(t, int32(4), c.Outputs()[0].Scale) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestBindQueuesEventsForNextDispatch(t *testing.T) { | |
| 153 | + | c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}, {3, wayland.CompositorInterface, 6}}, | |
| 154 | + | map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}}) | |
| 155 | + | obj := srv.waitBound(7) | |
| 156 | + | srv.write(frame(obj.id, 3, appendU32(nil, 2))) | |
| 157 | + | srv.write(frame(obj.id, 2, nil)) | |
| 158 | + | // The bind pauses the reader; wait until the reader has the events so they | |
| 159 | + | // are handled during the pause or before it, never after Bind returns. | |
| 160 | + | waitFor(t, func() bool { return c.QueueLen() == 2 }, "events queued") | |
| 161 | + | _, _, err := c.Bind[*wayland.Compositor](wayland.CompositorInterface, 4, wayland.NewCompositor) | |
| 162 | + | require.NoError(t, err) | |
| 163 | + | require.NoError(t, c.Dispatch(nil)) | |
| 164 | + | assert.Equal(t, int32(2), c.Outputs()[0].Scale) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func waitFor(t *testing.T, cond func() bool, what string) { | |
| 168 | + | t.Helper() | |
| 169 | + | deadline := time.Now().Add(10 * time.Second) | |
| 170 | + | for !cond() { | |
| 171 | + | if time.Now().After(deadline) { | |
| 172 | + | t.Fatalf("timed out waiting for %s", what) | |
| 173 | + | } | |
| 174 | + | time.Sleep(time.Millisecond) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestBind(t *testing.T) { | |
| 179 | + | c, srv := connectWire(t, []wireGlobal{{3, wayland.CompositorInterface, 6}}, nil) | |
| 180 | + | comp, v, err := c.Bind[*wayland.Compositor](wayland.CompositorInterface, 4, wayland.NewCompositor) | |
| 181 | + | require.NoError(t, err) | |
| 182 | + | assert.NotNil(t, comp) | |
| 183 | + | assert.Equal(t, uint32(4), v) | |
| 184 | + | assert.Equal(t, uint32(4), srv.waitBound(3).version) | |
| 185 | + | ||
| 186 | + | _, _, err = c.Bind[*wayland.Shm](wayland.ShmInterface, 1, wayland.NewShm) | |
| 187 | + | assert.ErrorIs(t, err, neferclient.ErrGlobalNotFound) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func TestWatchFD(t *testing.T) { | |
| 191 | + | c, _ := connectWire(t, nil, nil) | |
| 192 | + | efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK) | |
| 193 | + | require.NoError(t, err) | |
| 194 | + | defer unix.Close(efd) | |
| 195 | + | require.NoError(t, c.WatchFD(efd, 42)) | |
| 196 | + | assert.Error(t, c.WatchFD(efd, 43), "double watch") | |
| 197 | + | ||
| 198 | + | h := neferclientmocks.NewMockHandler(t) | |
| 199 | + | got := 0 | |
| 200 | + | h.EXPECT().FDReady(uint64(42)).Run(func(uint64) { | |
| 201 | + | got++ | |
| 202 | + | var b [8]byte | |
| 203 | + | _, _ = unix.Read(efd, b[:]) | |
| 204 | + | }).Times(2) | |
| 205 | + | signal := func() { | |
| 206 | + | one := [8]byte{1} | |
| 207 | + | _, err := unix.Write(efd, one[:]) | |
| 208 | + | require.NoError(t, err) | |
| 209 | + | } | |
| 210 | + | signal() | |
| 211 | + | dispatchUntil(t, c, h, func() bool { return got == 1 }) | |
| 212 | + | signal() // re-armed after the first FDReady returned | |
| 213 | + | dispatchUntil(t, c, h, func() bool { return got == 2 }) | |
| 214 | + | ||
| 215 | + | require.NoError(t, c.UnwatchFD(efd)) | |
| 216 | + | assert.Error(t, c.UnwatchFD(efd)) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func TestUnwatchDropsQueuedNotification(t *testing.T) { | |
| 220 | + | c, _ := connectWire(t, nil, nil) | |
| 221 | + | efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK) | |
| 222 | + | require.NoError(t, err) | |
| 223 | + | defer unix.Close(efd) | |
| 224 | + | require.NoError(t, c.WatchFD(efd, 9)) | |
| 225 | + | one := [8]byte{1} | |
| 226 | + | _, err = unix.Write(efd, one[:]) | |
| 227 | + | require.NoError(t, err) | |
| 228 | + | waitFor(t, func() bool { return c.QueueLen() > 0 }, "queued fd notification") | |
| 229 | + | require.NoError(t, c.UnwatchFD(efd)) | |
| 230 | + | h := neferclientmocks.NewMockHandler(t) // no FDReady expectation: a call fails | |
| 231 | + | require.NoError(t, c.Dispatch(h)) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func TestCloseInsideFDReady(t *testing.T) { | |
| 235 | + | c, _ := connectWire(t, nil, nil) | |
| 236 | + | efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK) | |
| 237 | + | require.NoError(t, err) | |
| 238 | + | defer unix.Close(efd) | |
| 239 | + | require.NoError(t, c.WatchFD(efd, 1)) | |
| 240 | + | h := neferclientmocks.NewMockHandler(t) // Error must not be called after Close | |
| 241 | + | h.EXPECT().FDReady(uint64(1)).Run(func(uint64) { require.NoError(t, c.Close()) }).Once() | |
| 242 | + | one := [8]byte{1} | |
| 243 | + | _, _ = unix.Write(efd, one[:]) | |
| 244 | + | waitFor(t, func() bool { return c.QueueLen() > 0 }, "queued fd notification") | |
| 245 | + | assert.ErrorIs(t, c.Dispatch(h), neferclient.ErrClosed) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func TestOutputVersionOneIgnored(t *testing.T) { | |
| 249 | + | c, _ := connectWire(t, []wireGlobal{{5, outputIface, 1}, {7, outputIface, 2}}, | |
| 250 | + | map[uint32]outputSpec{5: {"old", 1, 10, 10}, 7: {"DP-1", 1, 100, 100}}) | |
| 251 | + | require.Len(t, c.Outputs(), 1) | |
| 252 | + | assert.Equal(t, uint32(7), c.Outputs()[0].Global) | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func TestUnwatchInsideHandler(t *testing.T) { | |
| 256 | + | c, _ := connectWire(t, nil, nil) | |
| 257 | + | efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK) | |
| 258 | + | require.NoError(t, err) | |
| 259 | + | defer unix.Close(efd) | |
| 260 | + | require.NoError(t, c.WatchFD(efd, 1)) | |
| 261 | + | h := neferclientmocks.NewMockHandler(t) | |
| 262 | + | called := false | |
| 263 | + | h.EXPECT().FDReady(uint64(1)).Run(func(uint64) { | |
| 264 | + | called = true | |
| 265 | + | require.NoError(t, c.UnwatchFD(efd)) | |
| 266 | + | }).Once() | |
| 267 | + | one := [8]byte{1} | |
| 268 | + | _, _ = unix.Write(efd, one[:]) | |
| 269 | + | dispatchUntil(t, c, h, func() bool { return called }) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func TestTransportErrorSurfacesAfterQueuedEvents(t *testing.T) { | |
| 273 | + | c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}}) | |
| 274 | + | obj := srv.waitBound(7) | |
| 275 | + | srv.write(frame(obj.id, 3, appendU32(nil, 5))) | |
| 276 | + | srv.write(frame(obj.id, 2, nil)) | |
| 277 | + | srv.closeConn() | |
| 278 | + | timeout := time.After(10 * time.Second) | |
| 279 | + | var err error | |
| 280 | + | for err == nil { | |
| 281 | + | err = c.Dispatch(nil) | |
| 282 | + | if err != nil { | |
| 283 | + | break | |
| 284 | + | } | |
| 285 | + | select { | |
| 286 | + | case <-c.Wake(): | |
| 287 | + | case <-timeout: | |
| 288 | + | t.Fatal("transport error never surfaced") | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | assert.Equal(t, int32(5), c.Outputs()[0].Scale, "events queued before the failure are applied first") | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func TestHandlerCannotReenterDispatch(t *testing.T) { | |
| 295 | + | c, srv := connectWire(t, nil, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}}) | |
| 296 | + | h := neferclientmocks.NewMockHandler(t) | |
| 297 | + | var inner error | |
| 298 | + | h.EXPECT().OutputAdded(mock.Anything).Run(func(*neferclient.Output) { inner = c.Dispatch(h) }).Once() | |
| 299 | + | srv.announce(wireGlobal{7, outputIface, 4}) | |
| 300 | + | dispatchUntil(t, c, h, func() bool { return inner != nil }) | |
| 301 | + | assert.Error(t, inner) | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func TestClose(t *testing.T) { | |
| 305 | + | c, _ := connectWire(t, nil, nil) | |
| 306 | + | require.NoError(t, c.Close()) | |
| 307 | + | assert.NoError(t, c.Close()) | |
| 308 | + | assert.ErrorIs(t, c.Dispatch(nil), neferclient.ErrClosed) | |
| 309 | + | assert.ErrorIs(t, c.Roundtrip(), neferclient.ErrClosed) | |
| 310 | + | assert.ErrorIs(t, c.WatchFD(0, 1), neferclient.ErrClosed) | |
| 311 | + | _, _, err := c.Bind[*wayland.Shm](wayland.ShmInterface, 1, wayland.NewShm) | |
| 312 | + | assert.ErrorIs(t, err, neferclient.ErrClosed) | |
| 313 | + | var nilConn *neferclient.Conn | |
| 314 | + | assert.NoError(t, nilConn.Close()) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func TestCloseWhileReaderBlockedOnFullRing(t *testing.T) { | |
| 318 | + | c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}}) | |
| 319 | + | obj := srv.waitBound(7) | |
| 320 | + | var buf []byte | |
| 321 | + | for i := 0; i < 2*neferclient.RingSize; i++ { | |
| 322 | + | buf = append(buf, frame(obj.id, 3, appendU32(nil, 2))...) | |
| 323 | + | } | |
| 324 | + | go srv.write(buf) | |
| 325 | + | waitFor(t, func() bool { return c.QueueLen() == neferclient.RingSize }, "full ring") | |
| 326 | + | done := make(chan struct{}) | |
| 327 | + | go func() { _ = c.Close(); close(done) }() | |
| 328 | + | select { | |
| 329 | + | case <-done: | |
| 330 | + | case <-time.After(5 * time.Second): | |
| 331 | + | t.Fatal("Close hung with a producer blocked on the full ring") | |
| 332 | + | } | |
| 333 | + | } | |
| @@ -0,0 +1,333 @@ | |||||
| 1 | + | package neferclient_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/go-wayland-bindings/client/wayland" | |||
| 9 | + | "github.com/bnema/neferclient" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | "golang.org/x/sys/unix" | |||
| 14 | + | ||||
| 15 | + | neferclientmocks "github.com/bnema/neferclient/mocks" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // dispatchUntil dispatches whenever Wake fires until done reports true. | |||
| 19 | + | func dispatchUntil(t *testing.T, c *neferclient.Conn, h neferclient.Handler, done func() bool) { | |||
| 20 | + | t.Helper() | |||
| 21 | + | timeout := time.After(10 * time.Second) | |||
| 22 | + | for { | |||
| 23 | + | require.NoError(t, c.Dispatch(h)) | |||
| 24 | + | if done() { | |||
| 25 | + | return | |||
| 26 | + | } | |||
| 27 | + | select { | |||
| 28 | + | case <-c.Wake(): | |||
| 29 | + | case <-timeout: | |||
| 30 | + | t.Fatal("timed out waiting for events") | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestSocketPath(t *testing.T) { | |||
| 36 | + | t.Setenv("XDG_RUNTIME_DIR", "/run/user/1") | |||
| 37 | + | t.Setenv("WAYLAND_DISPLAY", "") | |||
| 38 | + | p, err := neferclient.SocketPath("") | |||
| 39 | + | require.NoError(t, err) | |||
| 40 | + | assert.Equal(t, "/run/user/1/wayland-0", p) | |||
| 41 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-7") | |||
| 42 | + | p, _ = neferclient.SocketPath("") | |||
| 43 | + | assert.Equal(t, "/run/user/1/wayland-7", p) | |||
| 44 | + | p, _ = neferclient.SocketPath("x") | |||
| 45 | + | assert.Equal(t, "/run/user/1/x", p) | |||
| 46 | + | p, _ = neferclient.SocketPath("/tmp/sock") | |||
| 47 | + | assert.Equal(t, "/tmp/sock", p) | |||
| 48 | + | t.Setenv("XDG_RUNTIME_DIR", "") | |||
| 49 | + | _, err = neferclient.SocketPath("rel") | |||
| 50 | + | assert.Error(t, err) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestConnectInitialOutputs(t *testing.T) { | |||
| 54 | + | c, srv := connectWire(t, | |||
| 55 | + | []wireGlobal{{1, "wl_seat", 9}, {7, outputIface, 4}, {9, outputIface, 2}}, | |||
| 56 | + | map[uint32]outputSpec{7: {"DP-1", 2, 1920, 1080}, 9: {"ignored-before-v4", 1, 800, 600}}) | |||
| 57 | + | assert.Equal(t, []neferclient.Output{ | |||
| 58 | + | {Global: 7, Name: "DP-1", Scale: 2, Width: 1920, Height: 1080}, | |||
| 59 | + | {Global: 9, Name: "", Scale: 1, Width: 800, Height: 600}, | |||
| 60 | + | }, c.Outputs()) | |||
| 61 | + | assert.Equal(t, uint32(4), srv.waitBound(7).version, "bound at min(announced, 4)") | |||
| 62 | + | assert.Equal(t, uint32(2), srv.waitBound(9).version) | |||
| 63 | + | ||||
| 64 | + | // Setup events are applied silently: nothing is left for the Handler. The | |||
| 65 | + | // roundtrip moves everything handled so far into the ring, so a leaked | |||
| 66 | + | // setup event would reach the strict mock here. | |||
| 67 | + | h := neferclientmocks.NewMockHandler(t) | |||
| 68 | + | require.NoError(t, c.Roundtrip()) | |||
| 69 | + | require.NoError(t, c.Dispatch(h)) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestConnectContextCancelled(t *testing.T) { | |||
| 73 | + | client, _ := socketPair(t) // peer never answers | |||
| 74 | + | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | |||
| 75 | + | defer cancel() | |||
| 76 | + | c, err := neferclient.ConnectConn(ctx, client) | |||
| 77 | + | require.Error(t, err) | |||
| 78 | + | assert.Nil(t, c) | |||
| 79 | + | assert.ErrorIs(t, err, context.DeadlineExceeded) | |||
| 80 | + | ||||
| 81 | + | cancelled, stop := context.WithCancel(context.Background()) | |||
| 82 | + | stop() | |||
| 83 | + | _, err = neferclient.Connect(cancelled, "") | |||
| 84 | + | assert.ErrorIs(t, err, context.Canceled) | |||
| 85 | + | //lint:ignore SA1012 a nil context is the case under test | |||
| 86 | + | _, err = neferclient.Connect(nil, "") | |||
| 87 | + | assert.Error(t, err) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestHotplug(t *testing.T) { | |||
| 91 | + | c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, | |||
| 92 | + | map[uint32]outputSpec{7: {"DP-1", 1, 1920, 1080}, 11: {"HDMI-A-1", 2, 3840, 2160}}) | |||
| 93 | + | h := neferclientmocks.NewMockHandler(t) | |||
| 94 | + | var added []neferclient.Output | |||
| 95 | + | h.EXPECT().OutputAdded(mock.Anything).Run(func(out *neferclient.Output) { added = append(added, *out) }).Once() | |||
| 96 | + | srv.announce(wireGlobal{11, outputIface, 4}) | |||
| 97 | + | dispatchUntil(t, c, h, func() bool { return len(added) == 1 }) | |||
| 98 | + | assert.Equal(t, neferclient.Output{Global: 11, Name: "HDMI-A-1", Scale: 2, Width: 3840, Height: 2160}, added[0]) | |||
| 99 | + | require.Len(t, c.Outputs(), 2) | |||
| 100 | + | ||||
| 101 | + | h.EXPECT().OutputRemoved(uint32(7)).Once() | |||
| 102 | + | srv.remove(7) | |||
| 103 | + | dispatchUntil(t, c, h, func() bool { return len(c.Outputs()) == 1 }) | |||
| 104 | + | assert.Equal(t, uint32(11), c.Outputs()[0].Global) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestOutputPropertyChangeIsSilent(t *testing.T) { | |||
| 108 | + | c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}}) | |||
| 109 | + | obj := srv.waitBound(7) | |||
| 110 | + | srv.write(frame(obj.id, 3, appendU32(nil, 3))) // scale 3 | |||
| 111 | + | srv.write(frame(obj.id, 2, nil)) // done | |||
| 112 | + | h := neferclientmocks.NewMockHandler(t) // no expectations: any call fails | |||
| 113 | + | dispatchUntil(t, c, h, func() bool { return c.Outputs()[0].Scale == 3 }) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestRingBackpressureKeepsOrder(t *testing.T) { | |||
| 117 | + | const total = 3 * neferclient.RingSize | |||
| 118 | + | c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}}) | |||
| 119 | + | obj := srv.waitBound(7) | |||
| 120 | + | var buf []byte | |||
| 121 | + | for i := 1; i <= total; i++ { | |||
| 122 | + | buf = append(buf, frame(obj.id, 3, appendU32(nil, uint32(i)))...) | |||
| 123 | + | buf = append(buf, frame(obj.id, 2, nil)...) | |||
| 124 | + | } | |||
| 125 | + | go srv.write(buf) | |||
| 126 | + | waitFor(t, func() bool { return c.QueueLen() == neferclient.RingSize }, "full ring") | |||
| 127 | + | ||||
| 128 | + | // A pause (Roundtrip) must not deadlock against a producer blocked on the | |||
| 129 | + | // full ring, and must not reorder what it held back: the visible scale | |||
| 130 | + | // only ever grows, ending at total. | |||
| 131 | + | require.NoError(t, c.Roundtrip()) | |||
| 132 | + | prev := int32(0) | |||
| 133 | + | dispatchUntil(t, c, nil, func() bool { | |||
| 134 | + | got := c.Outputs()[0].Scale | |||
| 135 | + | require.GreaterOrEqual(t, got, prev, "scale went backwards: events were reordered") | |||
| 136 | + | prev = got | |||
| 137 | + | return got == total | |||
| 138 | + | }) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func TestRoundtripQueuesEventsForNextDispatch(t *testing.T) { | |||
| 142 | + | c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}}) | |||
| 143 | + | obj := srv.waitBound(7) | |||
| 144 | + | srv.write(frame(obj.id, 3, appendU32(nil, 4))) // scale 4 | |||
| 145 | + | srv.write(frame(obj.id, 2, nil)) // done | |||
| 146 | + | require.NoError(t, c.Roundtrip()) | |||
| 147 | + | // No Wake wait: the events handled during the roundtrip are already queued. | |||
| 148 | + | require.NoError(t, c.Dispatch(nil)) | |||
| 149 | + | assert.Equal(t, int32(4), c.Outputs()[0].Scale) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func TestBindQueuesEventsForNextDispatch(t *testing.T) { | |||
| 153 | + | c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}, {3, wayland.CompositorInterface, 6}}, | |||
| 154 | + | map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}}) | |||
| 155 | + | obj := srv.waitBound(7) | |||
| 156 | + | srv.write(frame(obj.id, 3, appendU32(nil, 2))) | |||
| 157 | + | srv.write(frame(obj.id, 2, nil)) | |||
| 158 | + | // The bind pauses the reader; wait until the reader has the events so they | |||
| 159 | + | // are handled during the pause or before it, never after Bind returns. | |||
| 160 | + | waitFor(t, func() bool { return c.QueueLen() == 2 }, "events queued") | |||
| 161 | + | _, _, err := c.Bind[*wayland.Compositor](wayland.CompositorInterface, 4, wayland.NewCompositor) | |||
| 162 | + | require.NoError(t, err) | |||
| 163 | + | require.NoError(t, c.Dispatch(nil)) | |||
| 164 | + | assert.Equal(t, int32(2), c.Outputs()[0].Scale) | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func waitFor(t *testing.T, cond func() bool, what string) { | |||
| 168 | + | t.Helper() | |||
| 169 | + | deadline := time.Now().Add(10 * time.Second) | |||
| 170 | + | for !cond() { | |||
| 171 | + | if time.Now().After(deadline) { | |||
| 172 | + | t.Fatalf("timed out waiting for %s", what) | |||
| 173 | + | } | |||
| 174 | + | time.Sleep(time.Millisecond) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func TestBind(t *testing.T) { | |||
| 179 | + | c, srv := connectWire(t, []wireGlobal{{3, wayland.CompositorInterface, 6}}, nil) | |||
| 180 | + | comp, v, err := c.Bind[*wayland.Compositor](wayland.CompositorInterface, 4, wayland.NewCompositor) | |||
| 181 | + | require.NoError(t, err) | |||
| 182 | + | assert.NotNil(t, comp) | |||
| 183 | + | assert.Equal(t, uint32(4), v) | |||
| 184 | + | assert.Equal(t, uint32(4), srv.waitBound(3).version) | |||
| 185 | + | ||||
| 186 | + | _, _, err = c.Bind[*wayland.Shm](wayland.ShmInterface, 1, wayland.NewShm) | |||
| 187 | + | assert.ErrorIs(t, err, neferclient.ErrGlobalNotFound) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func TestWatchFD(t *testing.T) { | |||
| 191 | + | c, _ := connectWire(t, nil, nil) | |||
| 192 | + | efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK) | |||
| 193 | + | require.NoError(t, err) | |||
| 194 | + | defer unix.Close(efd) | |||
| 195 | + | require.NoError(t, c.WatchFD(efd, 42)) | |||
| 196 | + | assert.Error(t, c.WatchFD(efd, 43), "double watch") | |||
| 197 | + | ||||
| 198 | + | h := neferclientmocks.NewMockHandler(t) | |||
| 199 | + | got := 0 | |||
| 200 | + | h.EXPECT().FDReady(uint64(42)).Run(func(uint64) { | |||
| 201 | + | got++ | |||
| 202 | + | var b [8]byte | |||
| 203 | + | _, _ = unix.Read(efd, b[:]) | |||
| 204 | + | }).Times(2) | |||
| 205 | + | signal := func() { | |||
| 206 | + | one := [8]byte{1} | |||
| 207 | + | _, err := unix.Write(efd, one[:]) | |||
| 208 | + | require.NoError(t, err) | |||
| 209 | + | } | |||
| 210 | + | signal() | |||
| 211 | + | dispatchUntil(t, c, h, func() bool { return got == 1 }) | |||
| 212 | + | signal() // re-armed after the first FDReady returned | |||
| 213 | + | dispatchUntil(t, c, h, func() bool { return got == 2 }) | |||
| 214 | + | ||||
| 215 | + | require.NoError(t, c.UnwatchFD(efd)) | |||
| 216 | + | assert.Error(t, c.UnwatchFD(efd)) | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func TestUnwatchDropsQueuedNotification(t *testing.T) { | |||
| 220 | + | c, _ := connectWire(t, nil, nil) | |||
| 221 | + | efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK) | |||
| 222 | + | require.NoError(t, err) | |||
| 223 | + | defer unix.Close(efd) | |||
| 224 | + | require.NoError(t, c.WatchFD(efd, 9)) | |||
| 225 | + | one := [8]byte{1} | |||
| 226 | + | _, err = unix.Write(efd, one[:]) | |||
| 227 | + | require.NoError(t, err) | |||
| 228 | + | waitFor(t, func() bool { return c.QueueLen() > 0 }, "queued fd notification") | |||
| 229 | + | require.NoError(t, c.UnwatchFD(efd)) | |||
| 230 | + | h := neferclientmocks.NewMockHandler(t) // no FDReady expectation: a call fails | |||
| 231 | + | require.NoError(t, c.Dispatch(h)) | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | func TestCloseInsideFDReady(t *testing.T) { | |||
| 235 | + | c, _ := connectWire(t, nil, nil) | |||
| 236 | + | efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK) | |||
| 237 | + | require.NoError(t, err) | |||
| 238 | + | defer unix.Close(efd) | |||
| 239 | + | require.NoError(t, c.WatchFD(efd, 1)) | |||
| 240 | + | h := neferclientmocks.NewMockHandler(t) // Error must not be called after Close | |||
| 241 | + | h.EXPECT().FDReady(uint64(1)).Run(func(uint64) { require.NoError(t, c.Close()) }).Once() | |||
| 242 | + | one := [8]byte{1} | |||
| 243 | + | _, _ = unix.Write(efd, one[:]) | |||
| 244 | + | waitFor(t, func() bool { return c.QueueLen() > 0 }, "queued fd notification") | |||
| 245 | + | assert.ErrorIs(t, c.Dispatch(h), neferclient.ErrClosed) | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func TestOutputVersionOneIgnored(t *testing.T) { | |||
| 249 | + | c, _ := connectWire(t, []wireGlobal{{5, outputIface, 1}, {7, outputIface, 2}}, | |||
| 250 | + | map[uint32]outputSpec{5: {"old", 1, 10, 10}, 7: {"DP-1", 1, 100, 100}}) | |||
| 251 | + | require.Len(t, c.Outputs(), 1) | |||
| 252 | + | assert.Equal(t, uint32(7), c.Outputs()[0].Global) | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | func TestUnwatchInsideHandler(t *testing.T) { | |||
| 256 | + | c, _ := connectWire(t, nil, nil) | |||
| 257 | + | efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK) | |||
| 258 | + | require.NoError(t, err) | |||
| 259 | + | defer unix.Close(efd) | |||
| 260 | + | require.NoError(t, c.WatchFD(efd, 1)) | |||
| 261 | + | h := neferclientmocks.NewMockHandler(t) | |||
| 262 | + | called := false | |||
| 263 | + | h.EXPECT().FDReady(uint64(1)).Run(func(uint64) { | |||
| 264 | + | called = true | |||
| 265 | + | require.NoError(t, c.UnwatchFD(efd)) | |||
| 266 | + | }).Once() | |||
| 267 | + | one := [8]byte{1} | |||
| 268 | + | _, _ = unix.Write(efd, one[:]) | |||
| 269 | + | dispatchUntil(t, c, h, func() bool { return called }) | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func TestTransportErrorSurfacesAfterQueuedEvents(t *testing.T) { | |||
| 273 | + | c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}}) | |||
| 274 | + | obj := srv.waitBound(7) | |||
| 275 | + | srv.write(frame(obj.id, 3, appendU32(nil, 5))) | |||
| 276 | + | srv.write(frame(obj.id, 2, nil)) | |||
| 277 | + | srv.closeConn() | |||
| 278 | + | timeout := time.After(10 * time.Second) | |||
| 279 | + | var err error | |||
| 280 | + | for err == nil { | |||
| 281 | + | err = c.Dispatch(nil) | |||
| 282 | + | if err != nil { | |||
| 283 | + | break | |||
| 284 | + | } | |||
| 285 | + | select { | |||
| 286 | + | case <-c.Wake(): | |||
| 287 | + | case <-timeout: | |||
| 288 | + | t.Fatal("transport error never surfaced") | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | assert.Equal(t, int32(5), c.Outputs()[0].Scale, "events queued before the failure are applied first") | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func TestHandlerCannotReenterDispatch(t *testing.T) { | |||
| 295 | + | c, srv := connectWire(t, nil, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}}) | |||
| 296 | + | h := neferclientmocks.NewMockHandler(t) | |||
| 297 | + | var inner error | |||
| 298 | + | h.EXPECT().OutputAdded(mock.Anything).Run(func(*neferclient.Output) { inner = c.Dispatch(h) }).Once() | |||
| 299 | + | srv.announce(wireGlobal{7, outputIface, 4}) | |||
| 300 | + | dispatchUntil(t, c, h, func() bool { return inner != nil }) | |||
| 301 | + | assert.Error(t, inner) | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | func TestClose(t *testing.T) { | |||
| 305 | + | c, _ := connectWire(t, nil, nil) | |||
| 306 | + | require.NoError(t, c.Close()) | |||
| 307 | + | assert.NoError(t, c.Close()) | |||
| 308 | + | assert.ErrorIs(t, c.Dispatch(nil), neferclient.ErrClosed) | |||
| 309 | + | assert.ErrorIs(t, c.Roundtrip(), neferclient.ErrClosed) | |||
| 310 | + | assert.ErrorIs(t, c.WatchFD(0, 1), neferclient.ErrClosed) | |||
| 311 | + | _, _, err := c.Bind[*wayland.Shm](wayland.ShmInterface, 1, wayland.NewShm) | |||
| 312 | + | assert.ErrorIs(t, err, neferclient.ErrClosed) | |||
| 313 | + | var nilConn *neferclient.Conn | |||
| 314 | + | assert.NoError(t, nilConn.Close()) | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | func TestCloseWhileReaderBlockedOnFullRing(t *testing.T) { | |||
| 318 | + | c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}}) | |||
| 319 | + | obj := srv.waitBound(7) | |||
| 320 | + | var buf []byte | |||
| 321 | + | for i := 0; i < 2*neferclient.RingSize; i++ { | |||
| 322 | + | buf = append(buf, frame(obj.id, 3, appendU32(nil, 2))...) | |||
| 323 | + | } | |||
| 324 | + | go srv.write(buf) | |||
| 325 | + | waitFor(t, func() bool { return c.QueueLen() == neferclient.RingSize }, "full ring") | |||
| 326 | + | done := make(chan struct{}) | |||
| 327 | + | go func() { _ = c.Close(); close(done) }() | |||
| 328 | + | select { | |||
| 329 | + | case <-done: | |||
| 330 | + | case <-time.After(5 * time.Second): | |||
| 331 | + | t.Fatal("Close hung with a producer blocked on the full ring") | |||
| 332 | + | } | |||
| 333 | + | } | |||
A export_test.go +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | package neferclient | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "net" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // Test access for the external neferclient_test package, which imports the | |
| 9 | + | // generated mocks (they import this package, so in-package tests cannot). | |
| 10 | + | ||
| 11 | + | const RingSize = ringSize | |
| 12 | + | ||
| 13 | + | func ConnectConn(ctx context.Context, sock net.Conn) (*Conn, error) { return connectConn(ctx, sock) } | |
| 14 | + | ||
| 15 | + | func SocketPath(name string) (string, error) { return socketPath(name) } | |
| 16 | + | ||
| 17 | + | func (c *Conn) QueueLen() int { return c.q.length() } | |
| @@ -0,0 +1,17 @@ | |||||
| 1 | + | package neferclient | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "net" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // Test access for the external neferclient_test package, which imports the | |||
| 9 | + | // generated mocks (they import this package, so in-package tests cannot). | |||
| 10 | + | ||||
| 11 | + | const RingSize = ringSize | |||
| 12 | + | ||||
| 13 | + | func ConnectConn(ctx context.Context, sock net.Conn) (*Conn, error) { return connectConn(ctx, sock) } | |||
| 14 | + | ||||
| 15 | + | func SocketPath(name string) (string, error) { return socketPath(name) } | |||
| 16 | + | ||||
| 17 | + | func (c *Conn) QueueLen() int { return c.q.length() } | |||
M go.mod +12 -0
| @@ -1,3 +1,15 @@ | |||
| 1 | 1 | module github.com/bnema/neferclient | |
| 2 | 2 | ||
| 3 | 3 | go 1.27 | |
| 4 | + | ||
| 5 | + | require ( | |
| 6 | + | github.com/bnema/go-wayland-bindings v0.1.0 | |
| 7 | + | github.com/bnema/wlturbo v0.6.0 | |
| 8 | + | github.com/stretchr/testify v1.12.1 | |
| 9 | + | golang.org/x/sys v0.48.0 | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | require ( | |
| 13 | + | github.com/stretchr/objx v0.5.3 // indirect | |
| 14 | + | go.yaml.in/yaml/v3 v3.0.5 // indirect | |
| 15 | + | ) | |
| @@ -1,3 +1,15 @@ | |||||
| 1 | module github.com/bnema/neferclient | 1 | module github.com/bnema/neferclient | ||
| 2 | 2 | ||||
| 3 | go 1.27 | 3 | go 1.27 | ||
| 4 | + | ||||
| 5 | + | require ( | |||
| 6 | + | github.com/bnema/go-wayland-bindings v0.1.0 | |||
| 7 | + | github.com/bnema/wlturbo v0.6.0 | |||
| 8 | + | github.com/stretchr/testify v1.12.1 | |||
| 9 | + | golang.org/x/sys v0.48.0 | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | require ( | |||
| 13 | + | github.com/stretchr/objx v0.5.3 // indirect | |||
| 14 | + | go.yaml.in/yaml/v3 v3.0.5 // indirect | |||
| 15 | + | ) | |||
A go.sum +8 -0 generated file: collapsed
A handler.go +36 -0
| @@ -0,0 +1,36 @@ | |||
| 1 | + | package neferclient | |
| 2 | + | ||
| 3 | + | // Handler receives the events [Conn.Dispatch] drains. Every method is called | |
| 4 | + | // on the goroutine that called Dispatch, never on the reader goroutine, so an | |
| 5 | + | // implementation may send requests and call [Conn.Bind], [Conn.Roundtrip], | |
| 6 | + | // [Conn.WatchFD] and [Conn.UnwatchFD] from inside any method. It must not call | |
| 7 | + | // Dispatch. | |
| 8 | + | type Handler interface { | |
| 9 | + | // OutputAdded reports an output that became complete after [Connect] | |
| 10 | + | // returned: the compositor sent its first wl_output.done. Outputs that | |
| 11 | + | // existed during Connect are not reported; read them with [Conn.Outputs]. | |
| 12 | + | // Later property changes are applied to [Conn.Outputs] silently. out is | |
| 13 | + | // valid only during the call. | |
| 14 | + | OutputAdded(out *Output) | |
| 15 | + | // OutputRemoved reports that the wl_output global went away. The output is | |
| 16 | + | // already gone from [Conn.Outputs]. | |
| 17 | + | OutputRemoved(global uint32) | |
| 18 | + | // FDReady reports that a descriptor registered with [Conn.WatchFD] is | |
| 19 | + | // readable. The watch is re-armed when the call returns. It can be | |
| 20 | + | // spurious: drain the descriptor without blocking. | |
| 21 | + | FDReady(id uint64) | |
| 22 | + | // Error reports a non-fatal failure while handling an event, for example | |
| 23 | + | // a wl_output that could not be bound. Fatal transport errors are returned | |
| 24 | + | // by Dispatch instead. | |
| 25 | + | Error(err error) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // NopHandler implements every Handler method as a no-op. Embed it in a handler | |
| 29 | + | // and override only the methods you need; methods added to Handler in later | |
| 30 | + | // versions get their no-op here, so embedding keeps the type compiling. | |
| 31 | + | type NopHandler struct{} | |
| 32 | + | ||
| 33 | + | func (NopHandler) OutputAdded(*Output) {} | |
| 34 | + | func (NopHandler) OutputRemoved(uint32) {} | |
| 35 | + | func (NopHandler) FDReady(uint64) {} | |
| 36 | + | func (NopHandler) Error(error) {} | |
| @@ -0,0 +1,36 @@ | |||||
| 1 | + | package neferclient | |||
| 2 | + | ||||
| 3 | + | // Handler receives the events [Conn.Dispatch] drains. Every method is called | |||
| 4 | + | // on the goroutine that called Dispatch, never on the reader goroutine, so an | |||
| 5 | + | // implementation may send requests and call [Conn.Bind], [Conn.Roundtrip], | |||
| 6 | + | // [Conn.WatchFD] and [Conn.UnwatchFD] from inside any method. It must not call | |||
| 7 | + | // Dispatch. | |||
| 8 | + | type Handler interface { | |||
| 9 | + | // OutputAdded reports an output that became complete after [Connect] | |||
| 10 | + | // returned: the compositor sent its first wl_output.done. Outputs that | |||
| 11 | + | // existed during Connect are not reported; read them with [Conn.Outputs]. | |||
| 12 | + | // Later property changes are applied to [Conn.Outputs] silently. out is | |||
| 13 | + | // valid only during the call. | |||
| 14 | + | OutputAdded(out *Output) | |||
| 15 | + | // OutputRemoved reports that the wl_output global went away. The output is | |||
| 16 | + | // already gone from [Conn.Outputs]. | |||
| 17 | + | OutputRemoved(global uint32) | |||
| 18 | + | // FDReady reports that a descriptor registered with [Conn.WatchFD] is | |||
| 19 | + | // readable. The watch is re-armed when the call returns. It can be | |||
| 20 | + | // spurious: drain the descriptor without blocking. | |||
| 21 | + | FDReady(id uint64) | |||
| 22 | + | // Error reports a non-fatal failure while handling an event, for example | |||
| 23 | + | // a wl_output that could not be bound. Fatal transport errors are returned | |||
| 24 | + | // by Dispatch instead. | |||
| 25 | + | Error(err error) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // NopHandler implements every Handler method as a no-op. Embed it in a handler | |||
| 29 | + | // and override only the methods you need; methods added to Handler in later | |||
| 30 | + | // versions get their no-op here, so embedding keeps the type compiling. | |||
| 31 | + | type NopHandler struct{} | |||
| 32 | + | ||||
| 33 | + | func (NopHandler) OutputAdded(*Output) {} | |||
| 34 | + | func (NopHandler) OutputRemoved(uint32) {} | |||
| 35 | + | func (NopHandler) FDReady(uint64) {} | |||
| 36 | + | func (NopHandler) Error(error) {} | |||
A headless_test.go +137 -0
| @@ -0,0 +1,137 @@ | |||
| 1 | + | package neferclient_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "os/exec" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "syscall" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/go-wayland-bindings/client/wayland" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/neferclient" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // headlessEnvDrop lists the environment prefixes the child compositor must not | |
| 20 | + | // inherit: anything that could attach it to, or make it act on, the caller's | |
| 21 | + | // session. | |
| 22 | + | var headlessEnvDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=", | |
| 23 | + | "XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=", | |
| 24 | + | "XDG_CURRENT_DESKTOP=", "NIRI_SOCKET=", "SWAYSOCK=", "HYPRLAND_INSTANCE_SIGNATURE=", "NEFERWL_"} | |
| 25 | + | ||
| 26 | + | func cleanEnv(in []string) []string { | |
| 27 | + | out := make([]string, 0, len(in)) | |
| 28 | + | next: | |
| 29 | + | for _, e := range in { | |
| 30 | + | for _, p := range headlessEnvDrop { | |
| 31 | + | if strings.HasPrefix(e, p) { | |
| 32 | + | continue next | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | out = append(out, e) | |
| 36 | + | } | |
| 37 | + | return out | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // startHeadless runs NeferWL (NEFERCLIENT_HEADLESS) in an isolated runtime | |
| 41 | + | // directory and returns its socket path. The test is skipped when the variable | |
| 42 | + | // is unset. | |
| 43 | + | func startHeadless(t *testing.T) string { | |
| 44 | + | t.Helper() | |
| 45 | + | bin := os.Getenv("NEFERCLIENT_HEADLESS") | |
| 46 | + | if bin == "" { | |
| 47 | + | t.Skip("set NEFERCLIENT_HEADLESS to a NeferWL binary") | |
| 48 | + | } | |
| 49 | + | bin, err := filepath.Abs(bin) | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | root := t.TempDir() | |
| 52 | + | dirs := map[string]string{} | |
| 53 | + | for _, name := range []string{"run", "config", "data", "state"} { | |
| 54 | + | dirs[name] = filepath.Join(root, name) | |
| 55 | + | require.NoError(t, os.Mkdir(dirs[name], 0o700)) | |
| 56 | + | } | |
| 57 | + | logf, err := os.Create(filepath.Join(root, "neferwl.log")) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | t.Cleanup(func() { _ = logf.Close() }) | |
| 60 | + | cmd := exec.Command(bin, "--backend=headless", "--no-terminal", "--no-xwayland", "--size", "640x480", "--timeout", "30s") | |
| 61 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| 62 | + | cmd.Env = append(cleanEnv(os.Environ()), | |
| 63 | + | "XDG_RUNTIME_DIR="+dirs["run"], "XDG_CONFIG_HOME="+dirs["config"], | |
| 64 | + | "XDG_DATA_HOME="+dirs["data"], "XDG_STATE_HOME="+dirs["state"]) | |
| 65 | + | cmd.Stdout, cmd.Stderr = logf, logf | |
| 66 | + | require.NoError(t, cmd.Start()) | |
| 67 | + | exited := make(chan struct{}) | |
| 68 | + | go func() { _ = cmd.Wait(); close(exited) }() | |
| 69 | + | t.Cleanup(func() { | |
| 70 | + | _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |
| 71 | + | <-exited | |
| 72 | + | }) | |
| 73 | + | deadline := time.Now().Add(15 * time.Second) | |
| 74 | + | for { | |
| 75 | + | paths, _ := filepath.Glob(filepath.Join(dirs["run"], "wayland-*")) | |
| 76 | + | for _, p := range paths { | |
| 77 | + | if info, err := os.Stat(p); err == nil && info.Mode()&os.ModeSocket != 0 { | |
| 78 | + | return p | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | select { | |
| 82 | + | case <-exited: | |
| 83 | + | b, _ := os.ReadFile(logf.Name()) | |
| 84 | + | t.Fatalf("neferwl exited before serving:\n%s", b) | |
| 85 | + | case <-time.After(5 * time.Millisecond): | |
| 86 | + | } | |
| 87 | + | if time.Now().After(deadline) { | |
| 88 | + | b, _ := os.ReadFile(logf.Name()) | |
| 89 | + | t.Fatalf("headless socket timeout:\n%s", b) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestHeadlessConnect(t *testing.T) { | |
| 95 | + | socket := startHeadless(t) | |
| 96 | + | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) | |
| 97 | + | defer cancel() | |
| 98 | + | var c *neferclient.Conn | |
| 99 | + | var err error | |
| 100 | + | for { // the socket file exists before listen(); a refused dial is transient | |
| 101 | + | if c, err = neferclient.Connect(ctx, socket); err == nil || ctx.Err() != nil { | |
| 102 | + | break | |
| 103 | + | } | |
| 104 | + | time.Sleep(5 * time.Millisecond) | |
| 105 | + | } | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | defer c.Close() | |
| 108 | + | ||
| 109 | + | // The compositor creates its virtual output just after the socket appears, | |
| 110 | + | // so it may arrive through OutputAdded instead of being there at Connect. | |
| 111 | + | for len(c.Outputs()) == 0 { | |
| 112 | + | select { | |
| 113 | + | case <-c.Wake(): | |
| 114 | + | require.NoError(t, c.Dispatch(nil)) | |
| 115 | + | case <-ctx.Done(): | |
| 116 | + | t.Fatal("no output announced") | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | outs := c.Outputs() | |
| 120 | + | for _, o := range outs { | |
| 121 | + | t.Logf("output %d name=%q scale=%d %dx%d", o.Global, o.Name, o.Scale, o.Width, o.Height) | |
| 122 | + | require.Positive(t, o.Width) | |
| 123 | + | require.Positive(t, o.Height) | |
| 124 | + | require.NotZero(t, o.Scale) | |
| 125 | + | } | |
| 126 | + | require.NoError(t, c.Roundtrip()) | |
| 127 | + | ||
| 128 | + | comp, v, err := c.Bind[*wayland.Compositor](wayland.CompositorInterface, 4, wayland.NewCompositor) | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | require.NotZero(t, v) | |
| 131 | + | surface, err := comp.CreateSurface() | |
| 132 | + | require.NoError(t, err) | |
| 133 | + | require.NoError(t, surface.Destroy()) | |
| 134 | + | require.NoError(t, c.Roundtrip()) | |
| 135 | + | require.NoError(t, c.Dispatch(nil)) | |
| 136 | + | require.NoError(t, c.Close()) | |
| 137 | + | } | |
| @@ -0,0 +1,137 @@ | |||||
| 1 | + | package neferclient_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "os/exec" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | "syscall" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/go-wayland-bindings/client/wayland" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/neferclient" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // headlessEnvDrop lists the environment prefixes the child compositor must not | |||
| 20 | + | // inherit: anything that could attach it to, or make it act on, the caller's | |||
| 21 | + | // session. | |||
| 22 | + | var headlessEnvDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=", | |||
| 23 | + | "XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=", | |||
| 24 | + | "XDG_CURRENT_DESKTOP=", "NIRI_SOCKET=", "SWAYSOCK=", "HYPRLAND_INSTANCE_SIGNATURE=", "NEFERWL_"} | |||
| 25 | + | ||||
| 26 | + | func cleanEnv(in []string) []string { | |||
| 27 | + | out := make([]string, 0, len(in)) | |||
| 28 | + | next: | |||
| 29 | + | for _, e := range in { | |||
| 30 | + | for _, p := range headlessEnvDrop { | |||
| 31 | + | if strings.HasPrefix(e, p) { | |||
| 32 | + | continue next | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | out = append(out, e) | |||
| 36 | + | } | |||
| 37 | + | return out | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // startHeadless runs NeferWL (NEFERCLIENT_HEADLESS) in an isolated runtime | |||
| 41 | + | // directory and returns its socket path. The test is skipped when the variable | |||
| 42 | + | // is unset. | |||
| 43 | + | func startHeadless(t *testing.T) string { | |||
| 44 | + | t.Helper() | |||
| 45 | + | bin := os.Getenv("NEFERCLIENT_HEADLESS") | |||
| 46 | + | if bin == "" { | |||
| 47 | + | t.Skip("set NEFERCLIENT_HEADLESS to a NeferWL binary") | |||
| 48 | + | } | |||
| 49 | + | bin, err := filepath.Abs(bin) | |||
| 50 | + | require.NoError(t, err) | |||
| 51 | + | root := t.TempDir() | |||
| 52 | + | dirs := map[string]string{} | |||
| 53 | + | for _, name := range []string{"run", "config", "data", "state"} { | |||
| 54 | + | dirs[name] = filepath.Join(root, name) | |||
| 55 | + | require.NoError(t, os.Mkdir(dirs[name], 0o700)) | |||
| 56 | + | } | |||
| 57 | + | logf, err := os.Create(filepath.Join(root, "neferwl.log")) | |||
| 58 | + | require.NoError(t, err) | |||
| 59 | + | t.Cleanup(func() { _ = logf.Close() }) | |||
| 60 | + | cmd := exec.Command(bin, "--backend=headless", "--no-terminal", "--no-xwayland", "--size", "640x480", "--timeout", "30s") | |||
| 61 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |||
| 62 | + | cmd.Env = append(cleanEnv(os.Environ()), | |||
| 63 | + | "XDG_RUNTIME_DIR="+dirs["run"], "XDG_CONFIG_HOME="+dirs["config"], | |||
| 64 | + | "XDG_DATA_HOME="+dirs["data"], "XDG_STATE_HOME="+dirs["state"]) | |||
| 65 | + | cmd.Stdout, cmd.Stderr = logf, logf | |||
| 66 | + | require.NoError(t, cmd.Start()) | |||
| 67 | + | exited := make(chan struct{}) | |||
| 68 | + | go func() { _ = cmd.Wait(); close(exited) }() | |||
| 69 | + | t.Cleanup(func() { | |||
| 70 | + | _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |||
| 71 | + | <-exited | |||
| 72 | + | }) | |||
| 73 | + | deadline := time.Now().Add(15 * time.Second) | |||
| 74 | + | for { | |||
| 75 | + | paths, _ := filepath.Glob(filepath.Join(dirs["run"], "wayland-*")) | |||
| 76 | + | for _, p := range paths { | |||
| 77 | + | if info, err := os.Stat(p); err == nil && info.Mode()&os.ModeSocket != 0 { | |||
| 78 | + | return p | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | select { | |||
| 82 | + | case <-exited: | |||
| 83 | + | b, _ := os.ReadFile(logf.Name()) | |||
| 84 | + | t.Fatalf("neferwl exited before serving:\n%s", b) | |||
| 85 | + | case <-time.After(5 * time.Millisecond): | |||
| 86 | + | } | |||
| 87 | + | if time.Now().After(deadline) { | |||
| 88 | + | b, _ := os.ReadFile(logf.Name()) | |||
| 89 | + | t.Fatalf("headless socket timeout:\n%s", b) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestHeadlessConnect(t *testing.T) { | |||
| 95 | + | socket := startHeadless(t) | |||
| 96 | + | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) | |||
| 97 | + | defer cancel() | |||
| 98 | + | var c *neferclient.Conn | |||
| 99 | + | var err error | |||
| 100 | + | for { // the socket file exists before listen(); a refused dial is transient | |||
| 101 | + | if c, err = neferclient.Connect(ctx, socket); err == nil || ctx.Err() != nil { | |||
| 102 | + | break | |||
| 103 | + | } | |||
| 104 | + | time.Sleep(5 * time.Millisecond) | |||
| 105 | + | } | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | defer c.Close() | |||
| 108 | + | ||||
| 109 | + | // The compositor creates its virtual output just after the socket appears, | |||
| 110 | + | // so it may arrive through OutputAdded instead of being there at Connect. | |||
| 111 | + | for len(c.Outputs()) == 0 { | |||
| 112 | + | select { | |||
| 113 | + | case <-c.Wake(): | |||
| 114 | + | require.NoError(t, c.Dispatch(nil)) | |||
| 115 | + | case <-ctx.Done(): | |||
| 116 | + | t.Fatal("no output announced") | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | outs := c.Outputs() | |||
| 120 | + | for _, o := range outs { | |||
| 121 | + | t.Logf("output %d name=%q scale=%d %dx%d", o.Global, o.Name, o.Scale, o.Width, o.Height) | |||
| 122 | + | require.Positive(t, o.Width) | |||
| 123 | + | require.Positive(t, o.Height) | |||
| 124 | + | require.NotZero(t, o.Scale) | |||
| 125 | + | } | |||
| 126 | + | require.NoError(t, c.Roundtrip()) | |||
| 127 | + | ||||
| 128 | + | comp, v, err := c.Bind[*wayland.Compositor](wayland.CompositorInterface, 4, wayland.NewCompositor) | |||
| 129 | + | require.NoError(t, err) | |||
| 130 | + | require.NotZero(t, v) | |||
| 131 | + | surface, err := comp.CreateSurface() | |||
| 132 | + | require.NoError(t, err) | |||
| 133 | + | require.NoError(t, surface.Destroy()) | |||
| 134 | + | require.NoError(t, c.Roundtrip()) | |||
| 135 | + | require.NoError(t, c.Dispatch(nil)) | |||
| 136 | + | require.NoError(t, c.Close()) | |||
| 137 | + | } | |||
A mocks/handler_mock.go +206 -0
| @@ -0,0 +1,206 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package neferclientmocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "github.com/bnema/neferclient" | |
| 9 | + | mock "github.com/stretchr/testify/mock" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // NewMockHandler creates a new instance of MockHandler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 13 | + | // The first argument is typically a *testing.T value. | |
| 14 | + | func NewMockHandler(t interface { | |
| 15 | + | mock.TestingT | |
| 16 | + | Cleanup(func()) | |
| 17 | + | }) *MockHandler { | |
| 18 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 19 | + | helper.Helper() | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | mock := &MockHandler{} | |
| 23 | + | mock.Mock.Test(t) | |
| 24 | + | ||
| 25 | + | t.Cleanup(func() { | |
| 26 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 27 | + | helper.Helper() | |
| 28 | + | } | |
| 29 | + | mock.AssertExpectations(t) | |
| 30 | + | }) | |
| 31 | + | ||
| 32 | + | return mock | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // MockHandler is an autogenerated mock type for the Handler type | |
| 36 | + | type MockHandler struct { | |
| 37 | + | mock.Mock | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | type MockHandler_Expecter struct { | |
| 41 | + | mock *mock.Mock | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (_m *MockHandler) EXPECT() *MockHandler_Expecter { | |
| 45 | + | return &MockHandler_Expecter{mock: &_m.Mock} | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Error provides a mock function for the type MockHandler | |
| 49 | + | func (_mock *MockHandler) Error(err error) { | |
| 50 | + | _mock.Called(err) | |
| 51 | + | return | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // MockHandler_Error_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Error' | |
| 55 | + | type MockHandler_Error_Call struct { | |
| 56 | + | *mock.Call | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // Error is a helper method to define mock.On call | |
| 60 | + | // - err error | |
| 61 | + | func (_e *MockHandler_Expecter) Error(err any) *MockHandler_Error_Call { | |
| 62 | + | return &MockHandler_Error_Call{Call: _e.mock.On("Error", err)} | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (_c *MockHandler_Error_Call) Run(run func(err error)) *MockHandler_Error_Call { | |
| 66 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 67 | + | var arg0 error | |
| 68 | + | if args[0] != nil { | |
| 69 | + | arg0 = args[0].(error) | |
| 70 | + | } | |
| 71 | + | run( | |
| 72 | + | arg0, | |
| 73 | + | ) | |
| 74 | + | }) | |
| 75 | + | return _c | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (_c *MockHandler_Error_Call) Return() *MockHandler_Error_Call { | |
| 79 | + | _c.Call.Return() | |
| 80 | + | return _c | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (_c *MockHandler_Error_Call) RunAndReturn(run func(err error)) *MockHandler_Error_Call { | |
| 84 | + | _c.Run(run) | |
| 85 | + | return _c | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // FDReady provides a mock function for the type MockHandler | |
| 89 | + | func (_mock *MockHandler) FDReady(id uint64) { | |
| 90 | + | _mock.Called(id) | |
| 91 | + | return | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // MockHandler_FDReady_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FDReady' | |
| 95 | + | type MockHandler_FDReady_Call struct { | |
| 96 | + | *mock.Call | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // FDReady is a helper method to define mock.On call | |
| 100 | + | // - id uint64 | |
| 101 | + | func (_e *MockHandler_Expecter) FDReady(id any) *MockHandler_FDReady_Call { | |
| 102 | + | return &MockHandler_FDReady_Call{Call: _e.mock.On("FDReady", id)} | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func (_c *MockHandler_FDReady_Call) Run(run func(id uint64)) *MockHandler_FDReady_Call { | |
| 106 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 107 | + | var arg0 uint64 | |
| 108 | + | if args[0] != nil { | |
| 109 | + | arg0 = args[0].(uint64) | |
| 110 | + | } | |
| 111 | + | run( | |
| 112 | + | arg0, | |
| 113 | + | ) | |
| 114 | + | }) | |
| 115 | + | return _c | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (_c *MockHandler_FDReady_Call) Return() *MockHandler_FDReady_Call { | |
| 119 | + | _c.Call.Return() | |
| 120 | + | return _c | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (_c *MockHandler_FDReady_Call) RunAndReturn(run func(id uint64)) *MockHandler_FDReady_Call { | |
| 124 | + | _c.Run(run) | |
| 125 | + | return _c | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // OutputAdded provides a mock function for the type MockHandler | |
| 129 | + | func (_mock *MockHandler) OutputAdded(out *neferclient.Output) { | |
| 130 | + | _mock.Called(out) | |
| 131 | + | return | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // MockHandler_OutputAdded_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OutputAdded' | |
| 135 | + | type MockHandler_OutputAdded_Call struct { | |
| 136 | + | *mock.Call | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // OutputAdded is a helper method to define mock.On call | |
| 140 | + | // - out *neferclient.Output | |
| 141 | + | func (_e *MockHandler_Expecter) OutputAdded(out any) *MockHandler_OutputAdded_Call { | |
| 142 | + | return &MockHandler_OutputAdded_Call{Call: _e.mock.On("OutputAdded", out)} | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func (_c *MockHandler_OutputAdded_Call) Run(run func(out *neferclient.Output)) *MockHandler_OutputAdded_Call { | |
| 146 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 147 | + | var arg0 *neferclient.Output | |
| 148 | + | if args[0] != nil { | |
| 149 | + | arg0 = args[0].(*neferclient.Output) | |
| 150 | + | } | |
| 151 | + | run( | |
| 152 | + | arg0, | |
| 153 | + | ) | |
| 154 | + | }) | |
| 155 | + | return _c | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func (_c *MockHandler_OutputAdded_Call) Return() *MockHandler_OutputAdded_Call { | |
| 159 | + | _c.Call.Return() | |
| 160 | + | return _c | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (_c *MockHandler_OutputAdded_Call) RunAndReturn(run func(out *neferclient.Output)) *MockHandler_OutputAdded_Call { | |
| 164 | + | _c.Run(run) | |
| 165 | + | return _c | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // OutputRemoved provides a mock function for the type MockHandler | |
| 169 | + | func (_mock *MockHandler) OutputRemoved(global uint32) { | |
| 170 | + | _mock.Called(global) | |
| 171 | + | return | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // MockHandler_OutputRemoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OutputRemoved' | |
| 175 | + | type MockHandler_OutputRemoved_Call struct { | |
| 176 | + | *mock.Call | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // OutputRemoved is a helper method to define mock.On call | |
| 180 | + | // - global uint32 | |
| 181 | + | func (_e *MockHandler_Expecter) OutputRemoved(global any) *MockHandler_OutputRemoved_Call { | |
| 182 | + | return &MockHandler_OutputRemoved_Call{Call: _e.mock.On("OutputRemoved", global)} | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func (_c *MockHandler_OutputRemoved_Call) Run(run func(global uint32)) *MockHandler_OutputRemoved_Call { | |
| 186 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 187 | + | var arg0 uint32 | |
| 188 | + | if args[0] != nil { | |
| 189 | + | arg0 = args[0].(uint32) | |
| 190 | + | } | |
| 191 | + | run( | |
| 192 | + | arg0, | |
| 193 | + | ) | |
| 194 | + | }) | |
| 195 | + | return _c | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func (_c *MockHandler_OutputRemoved_Call) Return() *MockHandler_OutputRemoved_Call { | |
| 199 | + | _c.Call.Return() | |
| 200 | + | return _c | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (_c *MockHandler_OutputRemoved_Call) RunAndReturn(run func(global uint32)) *MockHandler_OutputRemoved_Call { | |
| 204 | + | _c.Run(run) | |
| 205 | + | return _c | |
| 206 | + | } | |
| @@ -0,0 +1,206 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package neferclientmocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "github.com/bnema/neferclient" | |||
| 9 | + | mock "github.com/stretchr/testify/mock" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // NewMockHandler creates a new instance of MockHandler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 13 | + | // The first argument is typically a *testing.T value. | |||
| 14 | + | func NewMockHandler(t interface { | |||
| 15 | + | mock.TestingT | |||
| 16 | + | Cleanup(func()) | |||
| 17 | + | }) *MockHandler { | |||
| 18 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 19 | + | helper.Helper() | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | mock := &MockHandler{} | |||
| 23 | + | mock.Mock.Test(t) | |||
| 24 | + | ||||
| 25 | + | t.Cleanup(func() { | |||
| 26 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 27 | + | helper.Helper() | |||
| 28 | + | } | |||
| 29 | + | mock.AssertExpectations(t) | |||
| 30 | + | }) | |||
| 31 | + | ||||
| 32 | + | return mock | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // MockHandler is an autogenerated mock type for the Handler type | |||
| 36 | + | type MockHandler struct { | |||
| 37 | + | mock.Mock | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | type MockHandler_Expecter struct { | |||
| 41 | + | mock *mock.Mock | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (_m *MockHandler) EXPECT() *MockHandler_Expecter { | |||
| 45 | + | return &MockHandler_Expecter{mock: &_m.Mock} | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // Error provides a mock function for the type MockHandler | |||
| 49 | + | func (_mock *MockHandler) Error(err error) { | |||
| 50 | + | _mock.Called(err) | |||
| 51 | + | return | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // MockHandler_Error_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Error' | |||
| 55 | + | type MockHandler_Error_Call struct { | |||
| 56 | + | *mock.Call | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // Error is a helper method to define mock.On call | |||
| 60 | + | // - err error | |||
| 61 | + | func (_e *MockHandler_Expecter) Error(err any) *MockHandler_Error_Call { | |||
| 62 | + | return &MockHandler_Error_Call{Call: _e.mock.On("Error", err)} | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func (_c *MockHandler_Error_Call) Run(run func(err error)) *MockHandler_Error_Call { | |||
| 66 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 67 | + | var arg0 error | |||
| 68 | + | if args[0] != nil { | |||
| 69 | + | arg0 = args[0].(error) | |||
| 70 | + | } | |||
| 71 | + | run( | |||
| 72 | + | arg0, | |||
| 73 | + | ) | |||
| 74 | + | }) | |||
| 75 | + | return _c | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (_c *MockHandler_Error_Call) Return() *MockHandler_Error_Call { | |||
| 79 | + | _c.Call.Return() | |||
| 80 | + | return _c | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func (_c *MockHandler_Error_Call) RunAndReturn(run func(err error)) *MockHandler_Error_Call { | |||
| 84 | + | _c.Run(run) | |||
| 85 | + | return _c | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // FDReady provides a mock function for the type MockHandler | |||
| 89 | + | func (_mock *MockHandler) FDReady(id uint64) { | |||
| 90 | + | _mock.Called(id) | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // MockHandler_FDReady_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FDReady' | |||
| 95 | + | type MockHandler_FDReady_Call struct { | |||
| 96 | + | *mock.Call | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // FDReady is a helper method to define mock.On call | |||
| 100 | + | // - id uint64 | |||
| 101 | + | func (_e *MockHandler_Expecter) FDReady(id any) *MockHandler_FDReady_Call { | |||
| 102 | + | return &MockHandler_FDReady_Call{Call: _e.mock.On("FDReady", id)} | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func (_c *MockHandler_FDReady_Call) Run(run func(id uint64)) *MockHandler_FDReady_Call { | |||
| 106 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 107 | + | var arg0 uint64 | |||
| 108 | + | if args[0] != nil { | |||
| 109 | + | arg0 = args[0].(uint64) | |||
| 110 | + | } | |||
| 111 | + | run( | |||
| 112 | + | arg0, | |||
| 113 | + | ) | |||
| 114 | + | }) | |||
| 115 | + | return _c | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func (_c *MockHandler_FDReady_Call) Return() *MockHandler_FDReady_Call { | |||
| 119 | + | _c.Call.Return() | |||
| 120 | + | return _c | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func (_c *MockHandler_FDReady_Call) RunAndReturn(run func(id uint64)) *MockHandler_FDReady_Call { | |||
| 124 | + | _c.Run(run) | |||
| 125 | + | return _c | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // OutputAdded provides a mock function for the type MockHandler | |||
| 129 | + | func (_mock *MockHandler) OutputAdded(out *neferclient.Output) { | |||
| 130 | + | _mock.Called(out) | |||
| 131 | + | return | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // MockHandler_OutputAdded_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OutputAdded' | |||
| 135 | + | type MockHandler_OutputAdded_Call struct { | |||
| 136 | + | *mock.Call | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // OutputAdded is a helper method to define mock.On call | |||
| 140 | + | // - out *neferclient.Output | |||
| 141 | + | func (_e *MockHandler_Expecter) OutputAdded(out any) *MockHandler_OutputAdded_Call { | |||
| 142 | + | return &MockHandler_OutputAdded_Call{Call: _e.mock.On("OutputAdded", out)} | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func (_c *MockHandler_OutputAdded_Call) Run(run func(out *neferclient.Output)) *MockHandler_OutputAdded_Call { | |||
| 146 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 147 | + | var arg0 *neferclient.Output | |||
| 148 | + | if args[0] != nil { | |||
| 149 | + | arg0 = args[0].(*neferclient.Output) | |||
| 150 | + | } | |||
| 151 | + | run( | |||
| 152 | + | arg0, | |||
| 153 | + | ) | |||
| 154 | + | }) | |||
| 155 | + | return _c | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func (_c *MockHandler_OutputAdded_Call) Return() *MockHandler_OutputAdded_Call { | |||
| 159 | + | _c.Call.Return() | |||
| 160 | + | return _c | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func (_c *MockHandler_OutputAdded_Call) RunAndReturn(run func(out *neferclient.Output)) *MockHandler_OutputAdded_Call { | |||
| 164 | + | _c.Run(run) | |||
| 165 | + | return _c | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // OutputRemoved provides a mock function for the type MockHandler | |||
| 169 | + | func (_mock *MockHandler) OutputRemoved(global uint32) { | |||
| 170 | + | _mock.Called(global) | |||
| 171 | + | return | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // MockHandler_OutputRemoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OutputRemoved' | |||
| 175 | + | type MockHandler_OutputRemoved_Call struct { | |||
| 176 | + | *mock.Call | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // OutputRemoved is a helper method to define mock.On call | |||
| 180 | + | // - global uint32 | |||
| 181 | + | func (_e *MockHandler_Expecter) OutputRemoved(global any) *MockHandler_OutputRemoved_Call { | |||
| 182 | + | return &MockHandler_OutputRemoved_Call{Call: _e.mock.On("OutputRemoved", global)} | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func (_c *MockHandler_OutputRemoved_Call) Run(run func(global uint32)) *MockHandler_OutputRemoved_Call { | |||
| 186 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 187 | + | var arg0 uint32 | |||
| 188 | + | if args[0] != nil { | |||
| 189 | + | arg0 = args[0].(uint32) | |||
| 190 | + | } | |||
| 191 | + | run( | |||
| 192 | + | arg0, | |||
| 193 | + | ) | |||
| 194 | + | }) | |||
| 195 | + | return _c | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func (_c *MockHandler_OutputRemoved_Call) Return() *MockHandler_OutputRemoved_Call { | |||
| 199 | + | _c.Call.Return() | |||
| 200 | + | return _c | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func (_c *MockHandler_OutputRemoved_Call) RunAndReturn(run func(global uint32)) *MockHandler_OutputRemoved_Call { | |||
| 204 | + | _c.Run(run) | |||
| 205 | + | return _c | |||
| 206 | + | } | |||
A queue.go +264 -0
| @@ -0,0 +1,264 @@ | |||
| 1 | + | package neferclient | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | ) | |
| 6 | + | ||
| 7 | + | const ( | |
| 8 | + | ringSize = 256 // events queued between the reader and Dispatch | |
| 9 | + | maxNameLen = 64 // longest output name kept; longer names are truncated | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type eventKind uint8 | |
| 13 | + | ||
| 14 | + | const ( | |
| 15 | + | evGlobal eventKind = iota + 1 // registry global (wl_output only): global, version | |
| 16 | + | evGlobalRemove // registry global_remove: global | |
| 17 | + | evOutputName // name, global | |
| 18 | + | evOutputScale // a = factor, global | |
| 19 | + | evOutputMode // flags, a = width, b = height, global | |
| 20 | + | evOutputDone // global | |
| 21 | + | evFDReady // fd (epoll data) | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // event is the only thing the reader and the epoll goroutine hand to the | |
| 25 | + | // owner. It holds no pointers except through fixed storage, so queuing one | |
| 26 | + | // never allocates. | |
| 27 | + | type event struct { | |
| 28 | + | kind eventKind | |
| 29 | + | nameLen uint8 | |
| 30 | + | global uint32 | |
| 31 | + | version uint32 | |
| 32 | + | flags uint32 | |
| 33 | + | fd int32 | |
| 34 | + | a, b int32 | |
| 35 | + | name [maxNameLen]byte | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func (e *event) setName(s string) { | |
| 39 | + | e.nameLen = uint8(copy(e.name[:], s)) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (e *event) nameBytes() []byte { return e.name[:e.nameLen] } | |
| 43 | + | ||
| 44 | + | // queue is the bounded hand-off between producers (the reader goroutine and | |
| 45 | + | // the epoll goroutine) and the owner goroutine, plus the reader pause gate. | |
| 46 | + | // | |
| 47 | + | // A producer facing a full ring waits on cond; Dispatch releases it as it | |
| 48 | + | // drains. A pause request also releases it: while pausing, producers park | |
| 49 | + | // events in held instead of waiting, so neither side can block on the other. | |
| 50 | + | // Parked events are re-posted, in order, by the reader once it resumes. | |
| 51 | + | type queue struct { | |
| 52 | + | mu sync.Mutex | |
| 53 | + | cond sync.Cond // L = μ one cond for every state change, all are rare | |
| 54 | + | ||
| 55 | + | ring [ringSize]event | |
| 56 | + | head int | |
| 57 | + | n int | |
| 58 | + | ||
| 59 | + | pausing bool | |
| 60 | + | readerParked bool | |
| 61 | + | readerExit bool | |
| 62 | + | stopped bool | |
| 63 | + | held []event | |
| 64 | + | spare []event // capacity reused by the next held generation | |
| 65 | + | fatal error | |
| 66 | + | ||
| 67 | + | wake chan struct{} | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func newQueue(pausing bool) *queue { | |
| 71 | + | q := &queue{pausing: pausing, wake: make(chan struct{}, 1)} | |
| 72 | + | q.cond.L = &q.mu | |
| 73 | + | return q | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (q *queue) signal() { | |
| 77 | + | select { | |
| 78 | + | case q.wake <- struct{}{}: | |
| 79 | + | default: | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // post queues ev. It blocks while the ring is full and not pausing. | |
| 84 | + | func (q *queue) post(ev *event) { | |
| 85 | + | q.mu.Lock() | |
| 86 | + | for { | |
| 87 | + | switch { | |
| 88 | + | case q.stopped: | |
| 89 | + | q.mu.Unlock() | |
| 90 | + | return | |
| 91 | + | case q.pausing: | |
| 92 | + | q.held = append(q.held, *ev) | |
| 93 | + | q.mu.Unlock() | |
| 94 | + | return | |
| 95 | + | case q.n < ringSize: | |
| 96 | + | q.ring[(q.head+q.n)%ringSize] = *ev | |
| 97 | + | q.n++ | |
| 98 | + | q.mu.Unlock() | |
| 99 | + | q.signal() | |
| 100 | + | return | |
| 101 | + | } | |
| 102 | + | q.cond.Wait() | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // pop moves the oldest queued event into ev. | |
| 107 | + | func (q *queue) pop(ev *event) bool { | |
| 108 | + | q.mu.Lock() | |
| 109 | + | if q.n == 0 { | |
| 110 | + | q.mu.Unlock() | |
| 111 | + | return false | |
| 112 | + | } | |
| 113 | + | *ev = q.ring[q.head] | |
| 114 | + | q.head = (q.head + 1) % ringSize | |
| 115 | + | full := q.n == ringSize | |
| 116 | + | q.n-- | |
| 117 | + | if full { | |
| 118 | + | q.cond.Broadcast() | |
| 119 | + | } | |
| 120 | + | q.mu.Unlock() | |
| 121 | + | return true | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // setFatal records the first transport failure; Dispatch returns it once the | |
| 125 | + | // events queued before it are drained. | |
| 126 | + | func (q *queue) setFatal(err error) { | |
| 127 | + | q.mu.Lock() | |
| 128 | + | if q.fatal == nil && !q.stopped { | |
| 129 | + | q.fatal = err | |
| 130 | + | } | |
| 131 | + | q.mu.Unlock() | |
| 132 | + | q.signal() | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func (q *queue) fatalErr() error { | |
| 136 | + | q.mu.Lock() | |
| 137 | + | err := q.fatal | |
| 138 | + | q.mu.Unlock() | |
| 139 | + | return err | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func (q *queue) isStopped() bool { | |
| 143 | + | q.mu.Lock() | |
| 144 | + | s := q.stopped | |
| 145 | + | q.mu.Unlock() | |
| 146 | + | return s | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (q *queue) stop() { | |
| 150 | + | q.mu.Lock() | |
| 151 | + | q.stopped = true | |
| 152 | + | q.cond.Broadcast() | |
| 153 | + | q.mu.Unlock() | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // setPausing starts or ends a pause. | |
| 157 | + | func (q *queue) setPausing(v bool) { | |
| 158 | + | q.mu.Lock() | |
| 159 | + | q.pausing = v | |
| 160 | + | q.cond.Broadcast() | |
| 161 | + | q.mu.Unlock() | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // endPause ends a pause started by setPausing(true). Events producers parked | |
| 165 | + | // meanwhile are moved into the ring, in order, as far as it has room, so that | |
| 166 | + | // everything handled during the pause is visible to the next Dispatch. The | |
| 167 | + | // remainder stays at the front of held; the reader re-posts it when it resumes. | |
| 168 | + | func (q *queue) endPause() { | |
| 169 | + | q.mu.Lock() | |
| 170 | + | m := min(len(q.held), ringSize-q.n) | |
| 171 | + | for i := range m { | |
| 172 | + | q.ring[(q.head+q.n)%ringSize] = q.held[i] | |
| 173 | + | q.n++ | |
| 174 | + | } | |
| 175 | + | if m > 0 { | |
| 176 | + | q.held = q.held[:copy(q.held, q.held[m:])] | |
| 177 | + | } | |
| 178 | + | q.pausing = false | |
| 179 | + | q.cond.Broadcast() | |
| 180 | + | q.mu.Unlock() | |
| 181 | + | if m > 0 { | |
| 182 | + | q.signal() | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // awaitParked blocks until the reader is parked or gone. | |
| 187 | + | func (q *queue) awaitParked() { | |
| 188 | + | q.mu.Lock() | |
| 189 | + | for !q.readerParked && !q.readerExit { | |
| 190 | + | q.cond.Wait() | |
| 191 | + | } | |
| 192 | + | q.mu.Unlock() | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // gate runs on the reader goroutine before every read. It parks the reader | |
| 196 | + | // while a pause is requested and re-posts what producers parked meanwhile. | |
| 197 | + | // It reports false when the connection is closing. | |
| 198 | + | func (q *queue) gate() bool { | |
| 199 | + | q.mu.Lock() | |
| 200 | + | for q.pausing && !q.stopped { | |
| 201 | + | if !q.readerParked { | |
| 202 | + | q.readerParked = true | |
| 203 | + | q.cond.Broadcast() | |
| 204 | + | } | |
| 205 | + | q.cond.Wait() | |
| 206 | + | } | |
| 207 | + | wasParked := q.readerParked | |
| 208 | + | q.readerParked = false | |
| 209 | + | stopped := q.stopped | |
| 210 | + | q.mu.Unlock() | |
| 211 | + | if stopped { | |
| 212 | + | return false | |
| 213 | + | } | |
| 214 | + | if wasParked { | |
| 215 | + | q.flushHeld() | |
| 216 | + | } | |
| 217 | + | return true | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func (q *queue) flushHeld() { | |
| 221 | + | q.mu.Lock() | |
| 222 | + | held := q.held | |
| 223 | + | q.held = q.spare[:0] | |
| 224 | + | q.mu.Unlock() | |
| 225 | + | for i := range held { | |
| 226 | + | q.post(&held[i]) | |
| 227 | + | } | |
| 228 | + | q.mu.Lock() | |
| 229 | + | q.spare = held[:0] | |
| 230 | + | q.mu.Unlock() | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // takeHeld returns and clears the parked events. Only valid while no reader | |
| 234 | + | // runs (during setup). | |
| 235 | + | func (q *queue) takeHeld() []event { | |
| 236 | + | q.mu.Lock() | |
| 237 | + | held := q.held | |
| 238 | + | q.held = nil | |
| 239 | + | q.mu.Unlock() | |
| 240 | + | return held | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // length reports how many events are queued. | |
| 244 | + | func (q *queue) length() int { | |
| 245 | + | q.mu.Lock() | |
| 246 | + | n := q.n | |
| 247 | + | q.mu.Unlock() | |
| 248 | + | return n | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // pending reports whether events are queued. | |
| 252 | + | func (q *queue) pending() bool { | |
| 253 | + | q.mu.Lock() | |
| 254 | + | n := q.n | |
| 255 | + | q.mu.Unlock() | |
| 256 | + | return n > 0 | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func (q *queue) readerDone() { | |
| 260 | + | q.mu.Lock() | |
| 261 | + | q.readerExit = true | |
| 262 | + | q.cond.Broadcast() | |
| 263 | + | q.mu.Unlock() | |
| 264 | + | } | |
| @@ -0,0 +1,264 @@ | |||||
| 1 | + | package neferclient | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | ) | |||
| 6 | + | ||||
| 7 | + | const ( | |||
| 8 | + | ringSize = 256 // events queued between the reader and Dispatch | |||
| 9 | + | maxNameLen = 64 // longest output name kept; longer names are truncated | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type eventKind uint8 | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | evGlobal eventKind = iota + 1 // registry global (wl_output only): global, version | |||
| 16 | + | evGlobalRemove // registry global_remove: global | |||
| 17 | + | evOutputName // name, global | |||
| 18 | + | evOutputScale // a = factor, global | |||
| 19 | + | evOutputMode // flags, a = width, b = height, global | |||
| 20 | + | evOutputDone // global | |||
| 21 | + | evFDReady // fd (epoll data) | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // event is the only thing the reader and the epoll goroutine hand to the | |||
| 25 | + | // owner. It holds no pointers except through fixed storage, so queuing one | |||
| 26 | + | // never allocates. | |||
| 27 | + | type event struct { | |||
| 28 | + | kind eventKind | |||
| 29 | + | nameLen uint8 | |||
| 30 | + | global uint32 | |||
| 31 | + | version uint32 | |||
| 32 | + | flags uint32 | |||
| 33 | + | fd int32 | |||
| 34 | + | a, b int32 | |||
| 35 | + | name [maxNameLen]byte | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (e *event) setName(s string) { | |||
| 39 | + | e.nameLen = uint8(copy(e.name[:], s)) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (e *event) nameBytes() []byte { return e.name[:e.nameLen] } | |||
| 43 | + | ||||
| 44 | + | // queue is the bounded hand-off between producers (the reader goroutine and | |||
| 45 | + | // the epoll goroutine) and the owner goroutine, plus the reader pause gate. | |||
| 46 | + | // | |||
| 47 | + | // A producer facing a full ring waits on cond; Dispatch releases it as it | |||
| 48 | + | // drains. A pause request also releases it: while pausing, producers park | |||
| 49 | + | // events in held instead of waiting, so neither side can block on the other. | |||
| 50 | + | // Parked events are re-posted, in order, by the reader once it resumes. | |||
| 51 | + | type queue struct { | |||
| 52 | + | mu sync.Mutex | |||
| 53 | + | cond sync.Cond // L = μ one cond for every state change, all are rare | |||
| 54 | + | ||||
| 55 | + | ring [ringSize]event | |||
| 56 | + | head int | |||
| 57 | + | n int | |||
| 58 | + | ||||
| 59 | + | pausing bool | |||
| 60 | + | readerParked bool | |||
| 61 | + | readerExit bool | |||
| 62 | + | stopped bool | |||
| 63 | + | held []event | |||
| 64 | + | spare []event // capacity reused by the next held generation | |||
| 65 | + | fatal error | |||
| 66 | + | ||||
| 67 | + | wake chan struct{} | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func newQueue(pausing bool) *queue { | |||
| 71 | + | q := &queue{pausing: pausing, wake: make(chan struct{}, 1)} | |||
| 72 | + | q.cond.L = &q.mu | |||
| 73 | + | return q | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func (q *queue) signal() { | |||
| 77 | + | select { | |||
| 78 | + | case q.wake <- struct{}{}: | |||
| 79 | + | default: | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // post queues ev. It blocks while the ring is full and not pausing. | |||
| 84 | + | func (q *queue) post(ev *event) { | |||
| 85 | + | q.mu.Lock() | |||
| 86 | + | for { | |||
| 87 | + | switch { | |||
| 88 | + | case q.stopped: | |||
| 89 | + | q.mu.Unlock() | |||
| 90 | + | return | |||
| 91 | + | case q.pausing: | |||
| 92 | + | q.held = append(q.held, *ev) | |||
| 93 | + | q.mu.Unlock() | |||
| 94 | + | return | |||
| 95 | + | case q.n < ringSize: | |||
| 96 | + | q.ring[(q.head+q.n)%ringSize] = *ev | |||
| 97 | + | q.n++ | |||
| 98 | + | q.mu.Unlock() | |||
| 99 | + | q.signal() | |||
| 100 | + | return | |||
| 101 | + | } | |||
| 102 | + | q.cond.Wait() | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // pop moves the oldest queued event into ev. | |||
| 107 | + | func (q *queue) pop(ev *event) bool { | |||
| 108 | + | q.mu.Lock() | |||
| 109 | + | if q.n == 0 { | |||
| 110 | + | q.mu.Unlock() | |||
| 111 | + | return false | |||
| 112 | + | } | |||
| 113 | + | *ev = q.ring[q.head] | |||
| 114 | + | q.head = (q.head + 1) % ringSize | |||
| 115 | + | full := q.n == ringSize | |||
| 116 | + | q.n-- | |||
| 117 | + | if full { | |||
| 118 | + | q.cond.Broadcast() | |||
| 119 | + | } | |||
| 120 | + | q.mu.Unlock() | |||
| 121 | + | return true | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // setFatal records the first transport failure; Dispatch returns it once the | |||
| 125 | + | // events queued before it are drained. | |||
| 126 | + | func (q *queue) setFatal(err error) { | |||
| 127 | + | q.mu.Lock() | |||
| 128 | + | if q.fatal == nil && !q.stopped { | |||
| 129 | + | q.fatal = err | |||
| 130 | + | } | |||
| 131 | + | q.mu.Unlock() | |||
| 132 | + | q.signal() | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func (q *queue) fatalErr() error { | |||
| 136 | + | q.mu.Lock() | |||
| 137 | + | err := q.fatal | |||
| 138 | + | q.mu.Unlock() | |||
| 139 | + | return err | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func (q *queue) isStopped() bool { | |||
| 143 | + | q.mu.Lock() | |||
| 144 | + | s := q.stopped | |||
| 145 | + | q.mu.Unlock() | |||
| 146 | + | return s | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func (q *queue) stop() { | |||
| 150 | + | q.mu.Lock() | |||
| 151 | + | q.stopped = true | |||
| 152 | + | q.cond.Broadcast() | |||
| 153 | + | q.mu.Unlock() | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // setPausing starts or ends a pause. | |||
| 157 | + | func (q *queue) setPausing(v bool) { | |||
| 158 | + | q.mu.Lock() | |||
| 159 | + | q.pausing = v | |||
| 160 | + | q.cond.Broadcast() | |||
| 161 | + | q.mu.Unlock() | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // endPause ends a pause started by setPausing(true). Events producers parked | |||
| 165 | + | // meanwhile are moved into the ring, in order, as far as it has room, so that | |||
| 166 | + | // everything handled during the pause is visible to the next Dispatch. The | |||
| 167 | + | // remainder stays at the front of held; the reader re-posts it when it resumes. | |||
| 168 | + | func (q *queue) endPause() { | |||
| 169 | + | q.mu.Lock() | |||
| 170 | + | m := min(len(q.held), ringSize-q.n) | |||
| 171 | + | for i := range m { | |||
| 172 | + | q.ring[(q.head+q.n)%ringSize] = q.held[i] | |||
| 173 | + | q.n++ | |||
| 174 | + | } | |||
| 175 | + | if m > 0 { | |||
| 176 | + | q.held = q.held[:copy(q.held, q.held[m:])] | |||
| 177 | + | } | |||
| 178 | + | q.pausing = false | |||
| 179 | + | q.cond.Broadcast() | |||
| 180 | + | q.mu.Unlock() | |||
| 181 | + | if m > 0 { | |||
| 182 | + | q.signal() | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // awaitParked blocks until the reader is parked or gone. | |||
| 187 | + | func (q *queue) awaitParked() { | |||
| 188 | + | q.mu.Lock() | |||
| 189 | + | for !q.readerParked && !q.readerExit { | |||
| 190 | + | q.cond.Wait() | |||
| 191 | + | } | |||
| 192 | + | q.mu.Unlock() | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // gate runs on the reader goroutine before every read. It parks the reader | |||
| 196 | + | // while a pause is requested and re-posts what producers parked meanwhile. | |||
| 197 | + | // It reports false when the connection is closing. | |||
| 198 | + | func (q *queue) gate() bool { | |||
| 199 | + | q.mu.Lock() | |||
| 200 | + | for q.pausing && !q.stopped { | |||
| 201 | + | if !q.readerParked { | |||
| 202 | + | q.readerParked = true | |||
| 203 | + | q.cond.Broadcast() | |||
| 204 | + | } | |||
| 205 | + | q.cond.Wait() | |||
| 206 | + | } | |||
| 207 | + | wasParked := q.readerParked | |||
| 208 | + | q.readerParked = false | |||
| 209 | + | stopped := q.stopped | |||
| 210 | + | q.mu.Unlock() | |||
| 211 | + | if stopped { | |||
| 212 | + | return false | |||
| 213 | + | } | |||
| 214 | + | if wasParked { | |||
| 215 | + | q.flushHeld() | |||
| 216 | + | } | |||
| 217 | + | return true | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func (q *queue) flushHeld() { | |||
| 221 | + | q.mu.Lock() | |||
| 222 | + | held := q.held | |||
| 223 | + | q.held = q.spare[:0] | |||
| 224 | + | q.mu.Unlock() | |||
| 225 | + | for i := range held { | |||
| 226 | + | q.post(&held[i]) | |||
| 227 | + | } | |||
| 228 | + | q.mu.Lock() | |||
| 229 | + | q.spare = held[:0] | |||
| 230 | + | q.mu.Unlock() | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // takeHeld returns and clears the parked events. Only valid while no reader | |||
| 234 | + | // runs (during setup). | |||
| 235 | + | func (q *queue) takeHeld() []event { | |||
| 236 | + | q.mu.Lock() | |||
| 237 | + | held := q.held | |||
| 238 | + | q.held = nil | |||
| 239 | + | q.mu.Unlock() | |||
| 240 | + | return held | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // length reports how many events are queued. | |||
| 244 | + | func (q *queue) length() int { | |||
| 245 | + | q.mu.Lock() | |||
| 246 | + | n := q.n | |||
| 247 | + | q.mu.Unlock() | |||
| 248 | + | return n | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | // pending reports whether events are queued. | |||
| 252 | + | func (q *queue) pending() bool { | |||
| 253 | + | q.mu.Lock() | |||
| 254 | + | n := q.n | |||
| 255 | + | q.mu.Unlock() | |||
| 256 | + | return n > 0 | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | func (q *queue) readerDone() { | |||
| 260 | + | q.mu.Lock() | |||
| 261 | + | q.readerExit = true | |||
| 262 | + | q.cond.Broadcast() | |||
| 263 | + | q.mu.Unlock() | |||
| 264 | + | } | |||
A race_off_test.go +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | //go:build !race | |
| 2 | + | ||
| 3 | + | package neferclient_test | |
| 4 | + | ||
| 5 | + | const raceEnabled = false | |
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | //go:build !race | |||
| 2 | + | ||||
| 3 | + | package neferclient_test | |||
| 4 | + | ||||
| 5 | + | const raceEnabled = false | |||
A race_on_test.go +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | //go:build race | |
| 2 | + | ||
| 3 | + | package neferclient_test | |
| 4 | + | ||
| 5 | + | const raceEnabled = true | |
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | //go:build race | |||
| 2 | + | ||||
| 3 | + | package neferclient_test | |||
| 4 | + | ||||
| 5 | + | const raceEnabled = true | |||
A race_test.go +12 -0
| @@ -0,0 +1,12 @@ | |||
| 1 | + | package neferclient_test | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | // skipUnderRace skips allocation guards: the race detector randomly drops | |
| 6 | + | // sync.Pool entries and instruments allocations, so counts are meaningless. | |
| 7 | + | func skipUnderRace(t *testing.T) { | |
| 8 | + | t.Helper() | |
| 9 | + | if raceEnabled { | |
| 10 | + | t.Skip("allocation counts are not meaningful under -race") | |
| 11 | + | } | |
| 12 | + | } | |
| @@ -0,0 +1,12 @@ | |||||
| 1 | + | package neferclient_test | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | // skipUnderRace skips allocation guards: the race detector randomly drops | |||
| 6 | + | // sync.Pool entries and instruments allocations, so counts are meaningless. | |||
| 7 | + | func skipUnderRace(t *testing.T) { | |||
| 8 | + | t.Helper() | |||
| 9 | + | if raceEnabled { | |||
| 10 | + | t.Skip("allocation counts are not meaningful under -race") | |||
| 11 | + | } | |||
| 12 | + | } | |||
A wire_test.go +243 -0
| @@ -0,0 +1,243 @@ | |||
| 1 | + | package neferclient_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/binary" | |
| 6 | + | "errors" | |
| 7 | + | "io" | |
| 8 | + | "net" | |
| 9 | + | "os" | |
| 10 | + | "sync" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/go-wayland-bindings/client/wayland" | |
| 15 | + | "github.com/bnema/neferclient" | |
| 16 | + | "golang.org/x/sys/unix" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // wireServer speaks just enough of the compositor side of the Wayland wire | |
| 20 | + | // protocol over a socketpair to drive Conn: registry globals, sync callbacks, | |
| 21 | + | // wl_output binds and caller-scripted events. It is the protocol peer, not a | |
| 22 | + | // double of one of our own interfaces. | |
| 23 | + | type wireServer struct { | |
| 24 | + | t *testing.T | |
| 25 | + | conn *net.UnixConn | |
| 26 | + | ||
| 27 | + | mu sync.Mutex | |
| 28 | + | cond *sync.Cond | |
| 29 | + | registry uint32 | |
| 30 | + | globals []wireGlobal | |
| 31 | + | bound map[uint32]boundObject // global name -> bound object | |
| 32 | + | outputs map[uint32]outputSpec | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | type wireGlobal struct { | |
| 36 | + | name uint32 | |
| 37 | + | iface string | |
| 38 | + | version uint32 | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type boundObject struct { | |
| 42 | + | id uint32 | |
| 43 | + | version uint32 | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // outputSpec is what the server sends right after an output bind. | |
| 47 | + | type outputSpec struct { | |
| 48 | + | name string | |
| 49 | + | scale int32 | |
| 50 | + | width, height int32 | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func appendU32(b []byte, v uint32) []byte { return binary.LittleEndian.AppendUint32(b, v) } | |
| 54 | + | ||
| 55 | + | func appendStr(b []byte, s string) []byte { | |
| 56 | + | n := len(s) + 1 | |
| 57 | + | b = appendU32(b, uint32(n)) | |
| 58 | + | b = append(b, s...) | |
| 59 | + | b = append(b, 0) | |
| 60 | + | for len(b)%4 != 0 { | |
| 61 | + | b = append(b, 0) | |
| 62 | + | } | |
| 63 | + | return b | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func frame(obj uint32, op uint16, body []byte) []byte { | |
| 67 | + | b := appendU32(nil, obj) | |
| 68 | + | b = appendU32(b, uint32(8+len(body))<<16|uint32(op)) | |
| 69 | + | return append(b, body...) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // socketPair returns the client end as a net.Conn and the server end. | |
| 73 | + | func socketPair(t *testing.T) (net.Conn, *net.UnixConn) { | |
| 74 | + | t.Helper() | |
| 75 | + | fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) | |
| 76 | + | if err != nil { | |
| 77 | + | t.Fatal(err) | |
| 78 | + | } | |
| 79 | + | mk := func(fd int) net.Conn { | |
| 80 | + | f := os.NewFile(uintptr(fd), "socketpair") | |
| 81 | + | c, err := net.FileConn(f) | |
| 82 | + | _ = f.Close() | |
| 83 | + | if err != nil { | |
| 84 | + | t.Fatal(err) | |
| 85 | + | } | |
| 86 | + | return c | |
| 87 | + | } | |
| 88 | + | client, server := mk(fds[0]), mk(fds[1]) | |
| 89 | + | t.Cleanup(func() { _ = server.Close() }) | |
| 90 | + | return client, server.(*net.UnixConn) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // startWireServer serves a socketpair. outputs maps an announced wl_output | |
| 94 | + | // global to the description sent when it is bound. | |
| 95 | + | func startWireServer(t *testing.T, globals []wireGlobal, outputs map[uint32]outputSpec) (net.Conn, *wireServer) { | |
| 96 | + | t.Helper() | |
| 97 | + | client, srvConn := socketPair(t) | |
| 98 | + | s := &wireServer{t: t, conn: srvConn, globals: globals, outputs: outputs, | |
| 99 | + | bound: map[uint32]boundObject{}} | |
| 100 | + | s.cond = sync.NewCond(&s.mu) | |
| 101 | + | go s.serve() | |
| 102 | + | return client, s | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func (s *wireServer) write(b []byte) { | |
| 106 | + | if _, err := s.conn.Write(b); err != nil && !isClosed(err) { | |
| 107 | + | s.t.Errorf("wire server write: %v", err) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func isClosed(err error) bool { | |
| 112 | + | return errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || errors.Is(err, os.ErrDeadlineExceeded) || | |
| 113 | + | errors.Is(err, unix.EPIPE) || errors.Is(err, unix.ECONNRESET) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func (s *wireServer) serve() { | |
| 117 | + | var hdr [8]byte | |
| 118 | + | for { | |
| 119 | + | if _, err := io.ReadFull(s.conn, hdr[:]); err != nil { | |
| 120 | + | return | |
| 121 | + | } | |
| 122 | + | obj := binary.LittleEndian.Uint32(hdr[0:4]) | |
| 123 | + | w := binary.LittleEndian.Uint32(hdr[4:8]) | |
| 124 | + | size, op := int(w>>16), uint16(w) | |
| 125 | + | body := make([]byte, size-8) | |
| 126 | + | if _, err := io.ReadFull(s.conn, body); err != nil { | |
| 127 | + | return | |
| 128 | + | } | |
| 129 | + | s.handle(obj, op, body) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func (s *wireServer) handle(obj uint32, op uint16, body []byte) { | |
| 134 | + | s.mu.Lock() | |
| 135 | + | reg := s.registry | |
| 136 | + | s.mu.Unlock() | |
| 137 | + | switch { | |
| 138 | + | case obj == 1 && op == 1: // get_registry | |
| 139 | + | id := binary.LittleEndian.Uint32(body) | |
| 140 | + | s.mu.Lock() | |
| 141 | + | s.registry = id | |
| 142 | + | globals := s.globals | |
| 143 | + | s.mu.Unlock() | |
| 144 | + | for _, g := range globals { | |
| 145 | + | s.announce(g) | |
| 146 | + | } | |
| 147 | + | case obj == 1 && op == 0: // sync | |
| 148 | + | id := binary.LittleEndian.Uint32(body) | |
| 149 | + | s.write(frame(id, 0, appendU32(nil, 0))) | |
| 150 | + | s.write(frame(1, 1, appendU32(nil, id))) | |
| 151 | + | s.mu.Lock() | |
| 152 | + | s.cond.Broadcast() | |
| 153 | + | s.mu.Unlock() | |
| 154 | + | case obj == reg && op == 0: // bind | |
| 155 | + | name := binary.LittleEndian.Uint32(body) | |
| 156 | + | n := int(binary.LittleEndian.Uint32(body[4:])) | |
| 157 | + | off := 8 + (n+3)&^3 | |
| 158 | + | version := binary.LittleEndian.Uint32(body[off:]) | |
| 159 | + | id := binary.LittleEndian.Uint32(body[off+4:]) | |
| 160 | + | s.mu.Lock() | |
| 161 | + | s.bound[name] = boundObject{id: id, version: version} | |
| 162 | + | spec, isOutput := s.outputs[name] | |
| 163 | + | s.cond.Broadcast() | |
| 164 | + | s.mu.Unlock() | |
| 165 | + | if isOutput { | |
| 166 | + | s.describe(id, version, spec) | |
| 167 | + | } | |
| 168 | + | case op == 0 && body == nil: // wl_output.release (destructor, no args) | |
| 169 | + | s.write(frame(1, 1, appendU32(nil, obj))) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func (s *wireServer) announce(g wireGlobal) { | |
| 174 | + | b := appendU32(nil, g.name) | |
| 175 | + | b = appendStr(b, g.iface) | |
| 176 | + | b = appendU32(b, g.version) | |
| 177 | + | s.mu.Lock() | |
| 178 | + | reg := s.registry | |
| 179 | + | s.mu.Unlock() | |
| 180 | + | s.write(frame(reg, 0, b)) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func (s *wireServer) remove(name uint32) { | |
| 184 | + | s.mu.Lock() | |
| 185 | + | reg := s.registry | |
| 186 | + | s.mu.Unlock() | |
| 187 | + | s.write(frame(reg, 1, appendU32(nil, name))) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // describe sends mode, scale, name (version >= 4) and done for an output. | |
| 191 | + | func (s *wireServer) describe(id, version uint32, o outputSpec) { | |
| 192 | + | mode := appendU32(nil, 3) // current|preferred | |
| 193 | + | mode = appendU32(mode, uint32(o.width)) | |
| 194 | + | mode = appendU32(mode, uint32(o.height)) | |
| 195 | + | mode = appendU32(mode, 60000) | |
| 196 | + | s.write(frame(id, 1, mode)) | |
| 197 | + | s.write(frame(id, 3, appendU32(nil, uint32(o.scale)))) | |
| 198 | + | if version >= 4 { | |
| 199 | + | s.write(frame(id, 4, appendStr(nil, o.name))) | |
| 200 | + | } | |
| 201 | + | s.write(frame(id, 2, nil)) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // waitBound waits until the client bound global and returns the object. | |
| 205 | + | func (s *wireServer) waitBound(global uint32) boundObject { | |
| 206 | + | s.t.Helper() | |
| 207 | + | deadline := time.AfterFunc(5*time.Second, func() { | |
| 208 | + | s.mu.Lock() | |
| 209 | + | s.cond.Broadcast() | |
| 210 | + | s.mu.Unlock() | |
| 211 | + | }) | |
| 212 | + | defer deadline.Stop() | |
| 213 | + | start := time.Now() | |
| 214 | + | s.mu.Lock() | |
| 215 | + | defer s.mu.Unlock() | |
| 216 | + | for { | |
| 217 | + | if b, ok := s.bound[global]; ok { | |
| 218 | + | return b | |
| 219 | + | } | |
| 220 | + | if time.Since(start) > 5*time.Second { | |
| 221 | + | s.t.Fatalf("global %d never bound", global) | |
| 222 | + | } | |
| 223 | + | s.cond.Wait() | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func (s *wireServer) closeConn() { _ = s.conn.Close() } | |
| 228 | + | ||
| 229 | + | // connectWire connects a Conn to the wire server. | |
| 230 | + | func connectWire(t *testing.T, globals []wireGlobal, outputs map[uint32]outputSpec) (*neferclient.Conn, *wireServer) { | |
| 231 | + | t.Helper() | |
| 232 | + | client, srv := startWireServer(t, globals, outputs) | |
| 233 | + | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 234 | + | t.Cleanup(cancel) | |
| 235 | + | c, err := neferclient.ConnectConn(ctx, client) | |
| 236 | + | if err != nil { | |
| 237 | + | t.Fatalf("connect: %v", err) | |
| 238 | + | } | |
| 239 | + | t.Cleanup(func() { _ = c.Close() }) | |
| 240 | + | return c, srv | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | const outputIface = wayland.OutputInterface | |
| @@ -0,0 +1,243 @@ | |||||
| 1 | + | package neferclient_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/binary" | |||
| 6 | + | "errors" | |||
| 7 | + | "io" | |||
| 8 | + | "net" | |||
| 9 | + | "os" | |||
| 10 | + | "sync" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/go-wayland-bindings/client/wayland" | |||
| 15 | + | "github.com/bnema/neferclient" | |||
| 16 | + | "golang.org/x/sys/unix" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // wireServer speaks just enough of the compositor side of the Wayland wire | |||
| 20 | + | // protocol over a socketpair to drive Conn: registry globals, sync callbacks, | |||
| 21 | + | // wl_output binds and caller-scripted events. It is the protocol peer, not a | |||
| 22 | + | // double of one of our own interfaces. | |||
| 23 | + | type wireServer struct { | |||
| 24 | + | t *testing.T | |||
| 25 | + | conn *net.UnixConn | |||
| 26 | + | ||||
| 27 | + | mu sync.Mutex | |||
| 28 | + | cond *sync.Cond | |||
| 29 | + | registry uint32 | |||
| 30 | + | globals []wireGlobal | |||
| 31 | + | bound map[uint32]boundObject // global name -> bound object | |||
| 32 | + | outputs map[uint32]outputSpec | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | type wireGlobal struct { | |||
| 36 | + | name uint32 | |||
| 37 | + | iface string | |||
| 38 | + | version uint32 | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | type boundObject struct { | |||
| 42 | + | id uint32 | |||
| 43 | + | version uint32 | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // outputSpec is what the server sends right after an output bind. | |||
| 47 | + | type outputSpec struct { | |||
| 48 | + | name string | |||
| 49 | + | scale int32 | |||
| 50 | + | width, height int32 | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func appendU32(b []byte, v uint32) []byte { return binary.LittleEndian.AppendUint32(b, v) } | |||
| 54 | + | ||||
| 55 | + | func appendStr(b []byte, s string) []byte { | |||
| 56 | + | n := len(s) + 1 | |||
| 57 | + | b = appendU32(b, uint32(n)) | |||
| 58 | + | b = append(b, s...) | |||
| 59 | + | b = append(b, 0) | |||
| 60 | + | for len(b)%4 != 0 { | |||
| 61 | + | b = append(b, 0) | |||
| 62 | + | } | |||
| 63 | + | return b | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func frame(obj uint32, op uint16, body []byte) []byte { | |||
| 67 | + | b := appendU32(nil, obj) | |||
| 68 | + | b = appendU32(b, uint32(8+len(body))<<16|uint32(op)) | |||
| 69 | + | return append(b, body...) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // socketPair returns the client end as a net.Conn and the server end. | |||
| 73 | + | func socketPair(t *testing.T) (net.Conn, *net.UnixConn) { | |||
| 74 | + | t.Helper() | |||
| 75 | + | fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) | |||
| 76 | + | if err != nil { | |||
| 77 | + | t.Fatal(err) | |||
| 78 | + | } | |||
| 79 | + | mk := func(fd int) net.Conn { | |||
| 80 | + | f := os.NewFile(uintptr(fd), "socketpair") | |||
| 81 | + | c, err := net.FileConn(f) | |||
| 82 | + | _ = f.Close() | |||
| 83 | + | if err != nil { | |||
| 84 | + | t.Fatal(err) | |||
| 85 | + | } | |||
| 86 | + | return c | |||
| 87 | + | } | |||
| 88 | + | client, server := mk(fds[0]), mk(fds[1]) | |||
| 89 | + | t.Cleanup(func() { _ = server.Close() }) | |||
| 90 | + | return client, server.(*net.UnixConn) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // startWireServer serves a socketpair. outputs maps an announced wl_output | |||
| 94 | + | // global to the description sent when it is bound. | |||
| 95 | + | func startWireServer(t *testing.T, globals []wireGlobal, outputs map[uint32]outputSpec) (net.Conn, *wireServer) { | |||
| 96 | + | t.Helper() | |||
| 97 | + | client, srvConn := socketPair(t) | |||
| 98 | + | s := &wireServer{t: t, conn: srvConn, globals: globals, outputs: outputs, | |||
| 99 | + | bound: map[uint32]boundObject{}} | |||
| 100 | + | s.cond = sync.NewCond(&s.mu) | |||
| 101 | + | go s.serve() | |||
| 102 | + | return client, s | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func (s *wireServer) write(b []byte) { | |||
| 106 | + | if _, err := s.conn.Write(b); err != nil && !isClosed(err) { | |||
| 107 | + | s.t.Errorf("wire server write: %v", err) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func isClosed(err error) bool { | |||
| 112 | + | return errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || errors.Is(err, os.ErrDeadlineExceeded) || | |||
| 113 | + | errors.Is(err, unix.EPIPE) || errors.Is(err, unix.ECONNRESET) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func (s *wireServer) serve() { | |||
| 117 | + | var hdr [8]byte | |||
| 118 | + | for { | |||
| 119 | + | if _, err := io.ReadFull(s.conn, hdr[:]); err != nil { | |||
| 120 | + | return | |||
| 121 | + | } | |||
| 122 | + | obj := binary.LittleEndian.Uint32(hdr[0:4]) | |||
| 123 | + | w := binary.LittleEndian.Uint32(hdr[4:8]) | |||
| 124 | + | size, op := int(w>>16), uint16(w) | |||
| 125 | + | body := make([]byte, size-8) | |||
| 126 | + | if _, err := io.ReadFull(s.conn, body); err != nil { | |||
| 127 | + | return | |||
| 128 | + | } | |||
| 129 | + | s.handle(obj, op, body) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func (s *wireServer) handle(obj uint32, op uint16, body []byte) { | |||
| 134 | + | s.mu.Lock() | |||
| 135 | + | reg := s.registry | |||
| 136 | + | s.mu.Unlock() | |||
| 137 | + | switch { | |||
| 138 | + | case obj == 1 && op == 1: // get_registry | |||
| 139 | + | id := binary.LittleEndian.Uint32(body) | |||
| 140 | + | s.mu.Lock() | |||
| 141 | + | s.registry = id | |||
| 142 | + | globals := s.globals | |||
| 143 | + | s.mu.Unlock() | |||
| 144 | + | for _, g := range globals { | |||
| 145 | + | s.announce(g) | |||
| 146 | + | } | |||
| 147 | + | case obj == 1 && op == 0: // sync | |||
| 148 | + | id := binary.LittleEndian.Uint32(body) | |||
| 149 | + | s.write(frame(id, 0, appendU32(nil, 0))) | |||
| 150 | + | s.write(frame(1, 1, appendU32(nil, id))) | |||
| 151 | + | s.mu.Lock() | |||
| 152 | + | s.cond.Broadcast() | |||
| 153 | + | s.mu.Unlock() | |||
| 154 | + | case obj == reg && op == 0: // bind | |||
| 155 | + | name := binary.LittleEndian.Uint32(body) | |||
| 156 | + | n := int(binary.LittleEndian.Uint32(body[4:])) | |||
| 157 | + | off := 8 + (n+3)&^3 | |||
| 158 | + | version := binary.LittleEndian.Uint32(body[off:]) | |||
| 159 | + | id := binary.LittleEndian.Uint32(body[off+4:]) | |||
| 160 | + | s.mu.Lock() | |||
| 161 | + | s.bound[name] = boundObject{id: id, version: version} | |||
| 162 | + | spec, isOutput := s.outputs[name] | |||
| 163 | + | s.cond.Broadcast() | |||
| 164 | + | s.mu.Unlock() | |||
| 165 | + | if isOutput { | |||
| 166 | + | s.describe(id, version, spec) | |||
| 167 | + | } | |||
| 168 | + | case op == 0 && body == nil: // wl_output.release (destructor, no args) | |||
| 169 | + | s.write(frame(1, 1, appendU32(nil, obj))) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func (s *wireServer) announce(g wireGlobal) { | |||
| 174 | + | b := appendU32(nil, g.name) | |||
| 175 | + | b = appendStr(b, g.iface) | |||
| 176 | + | b = appendU32(b, g.version) | |||
| 177 | + | s.mu.Lock() | |||
| 178 | + | reg := s.registry | |||
| 179 | + | s.mu.Unlock() | |||
| 180 | + | s.write(frame(reg, 0, b)) | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func (s *wireServer) remove(name uint32) { | |||
| 184 | + | s.mu.Lock() | |||
| 185 | + | reg := s.registry | |||
| 186 | + | s.mu.Unlock() | |||
| 187 | + | s.write(frame(reg, 1, appendU32(nil, name))) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // describe sends mode, scale, name (version >= 4) and done for an output. | |||
| 191 | + | func (s *wireServer) describe(id, version uint32, o outputSpec) { | |||
| 192 | + | mode := appendU32(nil, 3) // current|preferred | |||
| 193 | + | mode = appendU32(mode, uint32(o.width)) | |||
| 194 | + | mode = appendU32(mode, uint32(o.height)) | |||
| 195 | + | mode = appendU32(mode, 60000) | |||
| 196 | + | s.write(frame(id, 1, mode)) | |||
| 197 | + | s.write(frame(id, 3, appendU32(nil, uint32(o.scale)))) | |||
| 198 | + | if version >= 4 { | |||
| 199 | + | s.write(frame(id, 4, appendStr(nil, o.name))) | |||
| 200 | + | } | |||
| 201 | + | s.write(frame(id, 2, nil)) | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // waitBound waits until the client bound global and returns the object. | |||
| 205 | + | func (s *wireServer) waitBound(global uint32) boundObject { | |||
| 206 | + | s.t.Helper() | |||
| 207 | + | deadline := time.AfterFunc(5*time.Second, func() { | |||
| 208 | + | s.mu.Lock() | |||
| 209 | + | s.cond.Broadcast() | |||
| 210 | + | s.mu.Unlock() | |||
| 211 | + | }) | |||
| 212 | + | defer deadline.Stop() | |||
| 213 | + | start := time.Now() | |||
| 214 | + | s.mu.Lock() | |||
| 215 | + | defer s.mu.Unlock() | |||
| 216 | + | for { | |||
| 217 | + | if b, ok := s.bound[global]; ok { | |||
| 218 | + | return b | |||
| 219 | + | } | |||
| 220 | + | if time.Since(start) > 5*time.Second { | |||
| 221 | + | s.t.Fatalf("global %d never bound", global) | |||
| 222 | + | } | |||
| 223 | + | s.cond.Wait() | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func (s *wireServer) closeConn() { _ = s.conn.Close() } | |||
| 228 | + | ||||
| 229 | + | // connectWire connects a Conn to the wire server. | |||
| 230 | + | func connectWire(t *testing.T, globals []wireGlobal, outputs map[uint32]outputSpec) (*neferclient.Conn, *wireServer) { | |||
| 231 | + | t.Helper() | |||
| 232 | + | client, srv := startWireServer(t, globals, outputs) | |||
| 233 | + | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |||
| 234 | + | t.Cleanup(cancel) | |||
| 235 | + | c, err := neferclient.ConnectConn(ctx, client) | |||
| 236 | + | if err != nil { | |||
| 237 | + | t.Fatalf("connect: %v", err) | |||
| 238 | + | } | |||
| 239 | + | t.Cleanup(func() { _ = c.Close() }) | |||
| 240 | + | return c, srv | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | const outputIface = wayland.OutputInterface | |||