detached 23ac271

Merge branch 'nc/core': connection, dispatch, outputs and fd watching

commit
23ac2716bd5265dfe2153094f8035858c4c50f4d
author
brice · · 2026-10-01 15:41 UTC
merge of
ccab19b + 5dd3383 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +1936 -1
M .mockery.yml +8 -1
@@ -5,4 +5,11 @@ structname: '{{.Mock}}{{.InterfaceName}}'
55 template: testify
66 template-schema: '{{.Template}}.schema.json'
77 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: testify5 template: testify
6 template-schema: '{{.Template}}.schema.json'6 template-schema: '{{.Template}}.schema.json'
7 require-template-schema-exists: true7 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 @@
11 module github.com/bnema/neferclient
22
33 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/neferclient1 module github.com/bnema/neferclient
2 2
3 go 1.273 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 = &mu; 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 = &mu; 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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 4

↑↓ moveenter openesc close