detached 8f00b92

feat: add connection, event queue, outputs and fd watching

Port the reader/pause-gate design of nefergui's platform connection onto
go-wayland-bindings. The reader goroutine only decodes events into a fixed
256-slot ring; Dispatch applies them and calls the Handler on the owner
goroutine. wl_output state is tracked per global, WatchFD/UnwatchFD wrap a
one-shot epoll served by a second goroutine, and Bind is a generic method
that pauses the reader around the bind.
commit
8f00b92c2bbe1431cbefff32480154b0201de25e
author
brice · · 2026-10-01 15:30 UTC
parent
ccab19b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +1083 -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 conn.go +572 -0
@@ -0,0 +1,572 @@
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.
33+type Output struct {
34+ Global uint32 // registry name; identifies the output for its lifetime
35+ Name string // wl_output.name, empty before version 4
36+ Scale int32 // integer scale, 1 until announced
37+ Width, Height int32 // current mode in physical pixels
38+}
39+
40+// outputEntry holds the owner-side state of one bound wl_output. Properties
41+// accumulate in the pending fields and become visible at wl_output.done.
42+type outputEntry struct {
43+ proxy *wayland.Output
44+ done bool // first wl_output.done applied; the output is public
45+ pub Output
46+
47+ nameBuf [maxNameLen]byte
48+ nameLen int
49+ scale int32
50+ width, height int32
51+}
52+
53+// Conn is one Wayland connection. Two goroutines run beside the owner: the
54+// reader, which only decodes wire events into a fixed queue, and an epoll
55+// waiter for [Conn.WatchFD]. Everything else — event handling, requests,
56+// Bind, Roundtrip, Outputs, Close — belongs to the owner goroutine, the one
57+// that calls [Conn.Dispatch].
58+type Conn struct {
59+ sock net.Conn
60+ display *wl.Display
61+ wlctx *wl.Context
62+ q *queue
63+
64+ readerStarted bool
65+ readerDone chan struct{}
66+ pauseDepth int
67+
68+ epfd, efd int
69+ epollStart bool
70+ epollDone chan struct{}
71+ watched map[int32]uint64 // fd -> caller id
72+
73+ outputs []*outputEntry // sorted by Global
74+ view []Output // reused by Outputs
75+
76+ ready bool // Connect finished; announcements go to the Handler
77+ dispatching bool
78+ closed bool
79+}
80+
81+// Connect dials the compositor and completes the registry setup: every
82+// wl_output announced at that point is bound and its first description is
83+// available from [Conn.Outputs]. display names the socket relative to
84+// $XDG_RUNTIME_DIR, or is an absolute path; "" uses $WAYLAND_DISPLAY, then
85+// "wayland-0". Cancelling ctx abandons setup; afterwards ctx is not used.
86+func Connect(ctx context.Context, display string) (*Conn, error) {
87+ if ctx == nil {
88+ return nil, errors.New("neferclient: nil context")
89+ }
90+ if err := ctx.Err(); err != nil {
91+ return nil, err
92+ }
93+ path, err := socketPath(display)
94+ if err != nil {
95+ return nil, err
96+ }
97+ sock, err := (&net.Dialer{Timeout: dialTimeout}).DialContext(ctx, "unix", path)
98+ if err != nil {
99+ return nil, fmt.Errorf("failed to connect to Wayland: %w", err)
100+ }
101+ return connectConn(ctx, sock)
102+}
103+
104+func socketPath(name string) (string, error) {
105+ if name == "" {
106+ name = os.Getenv("WAYLAND_DISPLAY")
107+ if name == "" {
108+ name = "wayland-0"
109+ }
110+ }
111+ if filepath.IsAbs(name) {
112+ return name, nil
113+ }
114+ dir := os.Getenv("XDG_RUNTIME_DIR")
115+ if dir == "" {
116+ return "", errors.New("neferclient: XDG_RUNTIME_DIR not set")
117+ }
118+ return filepath.Join(dir, name), nil
119+}
120+
121+// connectConn runs the setup over an established connection and takes
122+// ownership of it.
123+func connectConn(ctx context.Context, sock net.Conn) (res *Conn, err error) {
124+ display, err := wlturbo.ConnectFromConn(sock)
125+ if err != nil {
126+ _ = sock.Close()
127+ return nil, err
128+ }
129+ c := &Conn{
130+ sock: sock,
131+ display: display,
132+ wlctx: display.Context(),
133+ q: newQueue(true), // no reader yet: owner-side events are parked
134+ efd: -1,
135+ epfd: -1,
136+ watched: make(map[int32]uint64),
137+ }
138+ watch := context.AfterFunc(ctx, func() { _ = display.Close() })
139+ defer func() {
140+ if !watch() && err == nil {
141+ err = ctx.Err()
142+ }
143+ if err != nil {
144+ _ = c.Close()
145+ res = nil
146+ if ctx.Err() != nil {
147+ err = errors.Join(ctx.Err(), err)
148+ }
149+ }
150+ }()
151+ if c.epfd, err = unix.EpollCreate1(unix.EPOLL_CLOEXEC); err != nil {
152+ return nil, fmt.Errorf("neferclient: epoll: %w", err)
153+ }
154+ if c.efd, err = unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK); err != nil {
155+ return nil, fmt.Errorf("neferclient: eventfd: %w", err)
156+ }
157+ ev := unix.EpollEvent{Events: unix.EPOLLIN, Fd: int32(c.efd)}
158+ if err = unix.EpollCtl(c.epfd, unix.EPOLL_CTL_ADD, c.efd, &ev); err != nil {
159+ return nil, fmt.Errorf("neferclient: epoll: %w", err)
160+ }
161+
162+ reg := display.Registry()
163+ reg.AddHandler(wayland.OutputInterface, func(_ *wl.Registry, name, version uint32) {
164+ e := event{kind: evGlobal, global: name, version: version}
165+ c.q.post(&e)
166+ })
167+ reg.AddGlobalRemoveHandler(removeHook{c.q})
168+
169+ // With no reader running, the owner's own roundtrips park every event in
170+ // the queue's held list, in order; applying it here is the startup drain.
171+ if err = display.Roundtrip(); err != nil {
172+ return nil, err
173+ }
174+ if err = c.applyHeld(); err != nil {
175+ return nil, err
176+ }
177+ if err = display.Roundtrip(); err != nil { // wl_output properties follow the binds
178+ return nil, err
179+ }
180+ if err = c.applyHeld(); err != nil {
181+ return nil, err
182+ }
183+
184+ c.ready = true
185+ c.readerDone = make(chan struct{})
186+ c.readerStarted = true
187+ c.epollDone = make(chan struct{})
188+ c.epollStart = true
189+ c.q.setPausing(false)
190+ go c.readLoop()
191+ go c.epollLoop()
192+ return c, nil
193+}
194+
195+// removeHook forwards registry global_remove to the queue.
196+type removeHook struct{ q *queue }
197+
198+func (h removeHook) HandleRegistryGlobalRemove(e wl.RegistryGlobalRemoveEvent) {
199+ ev := event{kind: evGlobalRemove, global: e.Name}
200+ h.q.post(&ev)
201+}
202+
203+// setupHandler applies setup events without notifying anyone and keeps the
204+// first failure, which makes Connect fail.
205+type setupHandler struct {
206+ nopHandler
207+ err *error
208+}
209+
210+func (h setupHandler) Error(err error) {
211+ if *h.err == nil {
212+ *h.err = err
213+ }
214+}
215+
216+// applyHeld applies the events parked during setup.
217+func (c *Conn) applyHeld() error {
218+ var err error
219+ h := setupHandler{err: &err}
220+ held := c.q.takeHeld()
221+ for i := range held {
222+ c.apply(&held[i], h)
223+ }
224+ return err
225+}
226+
227+// readLoop is the reader goroutine. It only calls Display.Dispatch; the
228+// generated handlers it triggers only push events into the queue.
229+func (c *Conn) readLoop() {
230+ defer close(c.readerDone)
231+ defer c.q.readerDone()
232+ for {
233+ if !c.q.gate() {
234+ return
235+ }
236+ err := c.display.Dispatch()
237+ if err == nil || errors.Is(err, os.ErrDeadlineExceeded) {
238+ continue // a pause request interrupted the read
239+ }
240+ if !c.q.isStopped() {
241+ c.q.setFatal(err)
242+ }
243+ return
244+ }
245+}
246+
247+// epollLoop is the second goroutine: it waits for watched descriptors and the
248+// shutdown eventfd.
249+func (c *Conn) epollLoop() {
250+ defer close(c.epollDone)
251+ var evs [8]unix.EpollEvent
252+ for {
253+ n, err := unix.EpollWait(c.epfd, evs[:], -1)
254+ if err != nil {
255+ if errors.Is(err, unix.EINTR) {
256+ continue
257+ }
258+ c.q.setFatal(fmt.Errorf("neferclient: epoll wait: %w", err))
259+ return
260+ }
261+ for i := range n {
262+ if evs[i].Fd == int32(c.efd) {
263+ return
264+ }
265+ e := event{kind: evFDReady, fd: evs[i].Fd}
266+ c.q.post(&e)
267+ }
268+ }
269+}
270+
271+// pause parks the reader between two reads and returns once it is parked.
272+// Nested calls on the owner goroutine share the first pause. Before the reader
273+// starts, the queue already parks everything and pause does nothing.
274+func (c *Conn) pause() {
275+ if !c.readerStarted {
276+ return
277+ }
278+ c.pauseDepth++
279+ if c.pauseDepth > 1 {
280+ return
281+ }
282+ c.q.setPausing(true)
283+ _ = c.sock.SetReadDeadline(time.Unix(1, 0)) // wakes a blocked read
284+ c.q.awaitParked()
285+ _ = c.sock.SetReadDeadline(time.Time{})
286+}
287+
288+func (c *Conn) resume() {
289+ if !c.readerStarted {
290+ return
291+ }
292+ c.pauseDepth--
293+ if c.pauseDepth == 0 {
294+ c.q.setPausing(false)
295+ }
296+}
297+
298+// Wake returns a channel with capacity 1 that is signaled whenever events are
299+// queued: call [Conn.Dispatch] when it fires. A signal can be stale (the
300+// events were already drained), so Dispatch may find nothing to do.
301+func (c *Conn) Wake() <-chan struct{} { return c.q.wake }
302+
303+// Context returns the wlturbo context that generated proxies are created on.
304+func (c *Conn) Context() *wl.Context { return c.wlctx }
305+
306+// Display returns the underlying wlturbo display. Only the owner goroutine
307+// may call Dispatch or Roundtrip on it; use [Conn.Dispatch] and
308+// [Conn.Roundtrip] instead.
309+func (c *Conn) Display() *wl.Display { return c.display }
310+
311+// Dispatch applies the events the reader queued and calls h for each one that
312+// matters to the application, on the calling goroutine. It handles at most one
313+// ring of events per call and signals [Conn.Wake] again if more are pending.
314+// A nil h discards the notifications but still applies the state changes. It
315+// returns the transport error once the queue is drained after the connection
316+// failed, and [ErrClosed] after Close.
317+func (c *Conn) Dispatch(h Handler) error {
318+ if c.closed {
319+ return ErrClosed
320+ }
321+ if c.dispatching {
322+ return errors.New("neferclient: Dispatch called from a Handler")
323+ }
324+ if h == nil {
325+ h = nopHandler{}
326+ }
327+ c.dispatching = true
328+ defer func() { c.dispatching = false }()
329+ var ev event
330+ for range ringSize {
331+ if !c.q.pop(&ev) {
332+ return c.q.fatalErr()
333+ }
334+ c.apply(&ev, h)
335+ if c.closed {
336+ return ErrClosed
337+ }
338+ }
339+ if c.q.pending() {
340+ c.q.signal()
341+ return nil
342+ }
343+ return c.q.fatalErr()
344+}
345+
346+// apply runs on the owner goroutine.
347+func (c *Conn) apply(ev *event, h Handler) {
348+ switch ev.kind {
349+ case evGlobal:
350+ if err := c.bindOutput(ev.global, ev.version); err != nil {
351+ h.Error(err)
352+ }
353+ case evGlobalRemove:
354+ c.removeOutput(ev.global, h)
355+ case evOutputName:
356+ if o := c.entry(ev.global); o != nil {
357+ o.nameLen = copy(o.nameBuf[:], ev.nameBytes())
358+ }
359+ case evOutputScale:
360+ if o := c.entry(ev.global); o != nil && ev.a > 0 {
361+ o.scale = ev.a
362+ }
363+ case evOutputMode:
364+ if o := c.entry(ev.global); o != nil && ev.flags&1 != 0 { // current mode
365+ o.width, o.height = ev.a, ev.b
366+ }
367+ case evOutputDone:
368+ if o := c.entry(ev.global); o != nil {
369+ c.commitOutput(o, h)
370+ }
371+ case evFDReady:
372+ id, ok := c.watched[ev.fd]
373+ if !ok {
374+ return // unwatched while the event was queued
375+ }
376+ h.FDReady(id)
377+ if _, ok := c.watched[ev.fd]; ok { // the handler may have unwatched it
378+ if err := c.armFD(int(ev.fd), unix.EPOLL_CTL_MOD); err != nil {
379+ h.Error(err)
380+ }
381+ }
382+ }
383+}
384+
385+func (c *Conn) entry(global uint32) *outputEntry {
386+ for _, o := range c.outputs {
387+ if o.pub.Global == global {
388+ return o
389+ }
390+ }
391+ return nil
392+}
393+
394+func (c *Conn) bindOutput(global, version uint32) error {
395+ if version == 0 || c.entry(global) != nil {
396+ return nil
397+ }
398+ o := &outputEntry{pub: Output{Global: global, Scale: 1}, scale: 1}
399+ out := wayland.NewOutput(c.wlctx)
400+ q := c.q
401+ // Handlers go on before the bind request is sent, so no early event of the
402+ // new object is lost. They run on the reader goroutine and only queue.
403+ out.OnName(func(name string) {
404+ e := event{kind: evOutputName, global: global}
405+ e.setName(name)
406+ q.post(&e)
407+ })
408+ out.OnScale(func(factor int32) {
409+ e := event{kind: evOutputScale, global: global, a: factor}
410+ q.post(&e)
411+ })
412+ out.OnMode(func(flags uint32, width, height, _ int32) {
413+ e := event{kind: evOutputMode, global: global, flags: flags, a: width, b: height}
414+ q.post(&e)
415+ })
416+ out.OnDone(func() {
417+ e := event{kind: evOutputDone, global: global}
418+ q.post(&e)
419+ })
420+ if err := c.display.Registry().Bind(global, wayland.OutputInterface, min(version, maxOutputVersion), out); err != nil {
421+ c.wlctx.Unregister(out)
422+ return fmt.Errorf("neferclient: bind wl_output %d: %w", global, err)
423+ }
424+ o.proxy = out
425+ i := len(c.outputs)
426+ c.outputs = append(c.outputs, o)
427+ for ; i > 0 && c.outputs[i-1].pub.Global > global; i-- {
428+ c.outputs[i], c.outputs[i-1] = c.outputs[i-1], c.outputs[i]
429+ }
430+ return nil
431+}
432+
433+func (c *Conn) commitOutput(o *outputEntry, h Handler) {
434+ if string(o.nameBuf[:o.nameLen]) != o.pub.Name {
435+ o.pub.Name = string(o.nameBuf[:o.nameLen])
436+ }
437+ o.pub.Scale, o.pub.Width, o.pub.Height = o.scale, o.width, o.height
438+ if !o.done {
439+ o.done = true
440+ if c.ready {
441+ h.OutputAdded(&o.pub)
442+ }
443+ }
444+}
445+
446+func (c *Conn) removeOutput(global uint32, h Handler) {
447+ for i, o := range c.outputs {
448+ if o.pub.Global != global {
449+ continue
450+ }
451+ if err := o.proxy.Release(); err != nil {
452+ c.wlctx.Unregister(o.proxy) // version 1 and 2 have no release
453+ }
454+ c.outputs = append(c.outputs[:i], c.outputs[i+1:]...)
455+ if o.done {
456+ h.OutputRemoved(global)
457+ }
458+ return
459+ }
460+}
461+
462+// Outputs returns the outputs that finished their first description, ordered
463+// by Global. The slice is reused by the next call; owner goroutine only.
464+func (c *Conn) Outputs() []Output {
465+ c.view = c.view[:0]
466+ for _, o := range c.outputs {
467+ if o.done {
468+ c.view = append(c.view, o.pub)
469+ }
470+ }
471+ return c.view
472+}
473+
474+// Roundtrip sends wl_display.sync and dispatches until it returns. The reader
475+// is paused meanwhile; the events it handled are queued for [Conn.Dispatch].
476+func (c *Conn) Roundtrip() error {
477+ if c.closed {
478+ return ErrClosed
479+ }
480+ c.pause()
481+ defer c.resume()
482+ return c.display.Roundtrip()
483+}
484+
485+// Bind binds the global named iface at min(announced, version) and returns the
486+// proxy and the negotiated version. newProxy creates the proxy on the given
487+// context; register its event handlers inside newProxy: the reader is paused
488+// until Bind returns, but the bound object's first events can arrive right
489+// after, so a handler added later may miss them. A missing global yields an
490+// error matching [ErrGlobalNotFound].
491+func (c *Conn) Bind[P wl.Proxy](iface string, version uint32, newProxy func(*wl.Context) P) (P, uint32, error) {
492+ var zero P
493+ if c.closed {
494+ return zero, 0, ErrClosed
495+ }
496+ c.pause()
497+ defer c.resume()
498+ p := newProxy(c.wlctx)
499+ v, err := c.display.Registry().BindNegotiated(iface, version, p)
500+ if err != nil {
501+ c.wlctx.Unregister(p)
502+ return zero, 0, err
503+ }
504+ return p, v, nil
505+}
506+
507+// WatchFD reports readability of fd through Handler.FDReady(id). The watch is
508+// one-shot: it is re-armed after each FDReady call returns. fd stays owned by
509+// the caller and must be unwatched before it is closed.
510+func (c *Conn) WatchFD(fd int, id uint64) error {
511+ if c.closed {
512+ return ErrClosed
513+ }
514+ if _, ok := c.watched[int32(fd)]; ok {
515+ return fmt.Errorf("neferclient: fd %d already watched", fd)
516+ }
517+ if err := c.armFD(fd, unix.EPOLL_CTL_ADD); err != nil {
518+ return err
519+ }
520+ c.watched[int32(fd)] = id
521+ return nil
522+}
523+
524+// UnwatchFD stops watching fd. A notification already queued is dropped.
525+func (c *Conn) UnwatchFD(fd int) error {
526+ if c.closed {
527+ return ErrClosed
528+ }
529+ if _, ok := c.watched[int32(fd)]; !ok {
530+ return fmt.Errorf("neferclient: fd %d not watched", fd)
531+ }
532+ delete(c.watched, int32(fd))
533+ if err := unix.EpollCtl(c.epfd, unix.EPOLL_CTL_DEL, fd, nil); err != nil {
534+ return fmt.Errorf("neferclient: unwatch fd %d: %w", fd, err)
535+ }
536+ return nil
537+}
538+
539+func (c *Conn) armFD(fd, op int) error {
540+ ev := unix.EpollEvent{Events: unix.EPOLLIN | unix.EPOLLONESHOT, Fd: int32(fd)}
541+ if err := unix.EpollCtl(c.epfd, op, fd, &ev); err != nil {
542+ return fmt.Errorf("neferclient: watch fd %d: %w", fd, err)
543+ }
544+ return nil
545+}
546+
547+// Close closes the connection and stops both goroutines. It never requests an
548+// unlock or any other protocol shutdown; those are explicit calls. It is safe
549+// to call more than once.
550+func (c *Conn) Close() error {
551+ if c == nil || c.closed {
552+ return nil
553+ }
554+ c.closed = true
555+ c.q.stop()
556+ err := c.display.Close()
557+ if c.readerStarted {
558+ <-c.readerDone
559+ }
560+ if c.epollStart {
561+ var one = [8]byte{1}
562+ _, _ = unix.Write(c.efd, one[:])
563+ <-c.epollDone
564+ }
565+ if c.epfd >= 0 {
566+ _ = unix.Close(c.epfd)
567+ }
568+ if c.efd >= 0 {
569+ _ = unix.Close(c.efd)
570+ }
571+ return err
572+}
@@ -0,0 +1,572 @@
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.
33+type Output struct {
34+ Global uint32 // registry name; identifies the output for its lifetime
35+ Name string // wl_output.name, empty before version 4
36+ Scale int32 // integer scale, 1 until announced
37+ Width, Height int32 // current mode in physical pixels
38+}
39+
40+// outputEntry holds the owner-side state of one bound wl_output. Properties
41+// accumulate in the pending fields and become visible at wl_output.done.
42+type outputEntry struct {
43+ proxy *wayland.Output
44+ done bool // first wl_output.done applied; the output is public
45+ pub Output
46+
47+ nameBuf [maxNameLen]byte
48+ nameLen int
49+ scale int32
50+ width, height int32
51+}
52+
53+// Conn is one Wayland connection. Two goroutines run beside the owner: the
54+// reader, which only decodes wire events into a fixed queue, and an epoll
55+// waiter for [Conn.WatchFD]. Everything else — event handling, requests,
56+// Bind, Roundtrip, Outputs, Close — belongs to the owner goroutine, the one
57+// that calls [Conn.Dispatch].
58+type Conn struct {
59+ sock net.Conn
60+ display *wl.Display
61+ wlctx *wl.Context
62+ q *queue
63+
64+ readerStarted bool
65+ readerDone chan struct{}
66+ pauseDepth int
67+
68+ epfd, efd int
69+ epollStart bool
70+ epollDone chan struct{}
71+ watched map[int32]uint64 // fd -> caller id
72+
73+ outputs []*outputEntry // sorted by Global
74+ view []Output // reused by Outputs
75+
76+ ready bool // Connect finished; announcements go to the Handler
77+ dispatching bool
78+ closed bool
79+}
80+
81+// Connect dials the compositor and completes the registry setup: every
82+// wl_output announced at that point is bound and its first description is
83+// available from [Conn.Outputs]. display names the socket relative to
84+// $XDG_RUNTIME_DIR, or is an absolute path; "" uses $WAYLAND_DISPLAY, then
85+// "wayland-0". Cancelling ctx abandons setup; afterwards ctx is not used.
86+func Connect(ctx context.Context, display string) (*Conn, error) {
87+ if ctx == nil {
88+ return nil, errors.New("neferclient: nil context")
89+ }
90+ if err := ctx.Err(); err != nil {
91+ return nil, err
92+ }
93+ path, err := socketPath(display)
94+ if err != nil {
95+ return nil, err
96+ }
97+ sock, err := (&net.Dialer{Timeout: dialTimeout}).DialContext(ctx, "unix", path)
98+ if err != nil {
99+ return nil, fmt.Errorf("failed to connect to Wayland: %w", err)
100+ }
101+ return connectConn(ctx, sock)
102+}
103+
104+func socketPath(name string) (string, error) {
105+ if name == "" {
106+ name = os.Getenv("WAYLAND_DISPLAY")
107+ if name == "" {
108+ name = "wayland-0"
109+ }
110+ }
111+ if filepath.IsAbs(name) {
112+ return name, nil
113+ }
114+ dir := os.Getenv("XDG_RUNTIME_DIR")
115+ if dir == "" {
116+ return "", errors.New("neferclient: XDG_RUNTIME_DIR not set")
117+ }
118+ return filepath.Join(dir, name), nil
119+}
120+
121+// connectConn runs the setup over an established connection and takes
122+// ownership of it.
123+func connectConn(ctx context.Context, sock net.Conn) (res *Conn, err error) {
124+ display, err := wlturbo.ConnectFromConn(sock)
125+ if err != nil {
126+ _ = sock.Close()
127+ return nil, err
128+ }
129+ c := &Conn{
130+ sock: sock,
131+ display: display,
132+ wlctx: display.Context(),
133+ q: newQueue(true), // no reader yet: owner-side events are parked
134+ efd: -1,
135+ epfd: -1,
136+ watched: make(map[int32]uint64),
137+ }
138+ watch := context.AfterFunc(ctx, func() { _ = display.Close() })
139+ defer func() {
140+ if !watch() && err == nil {
141+ err = ctx.Err()
142+ }
143+ if err != nil {
144+ _ = c.Close()
145+ res = nil
146+ if ctx.Err() != nil {
147+ err = errors.Join(ctx.Err(), err)
148+ }
149+ }
150+ }()
151+ if c.epfd, err = unix.EpollCreate1(unix.EPOLL_CLOEXEC); err != nil {
152+ return nil, fmt.Errorf("neferclient: epoll: %w", err)
153+ }
154+ if c.efd, err = unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK); err != nil {
155+ return nil, fmt.Errorf("neferclient: eventfd: %w", err)
156+ }
157+ ev := unix.EpollEvent{Events: unix.EPOLLIN, Fd: int32(c.efd)}
158+ if err = unix.EpollCtl(c.epfd, unix.EPOLL_CTL_ADD, c.efd, &ev); err != nil {
159+ return nil, fmt.Errorf("neferclient: epoll: %w", err)
160+ }
161+
162+ reg := display.Registry()
163+ reg.AddHandler(wayland.OutputInterface, func(_ *wl.Registry, name, version uint32) {
164+ e := event{kind: evGlobal, global: name, version: version}
165+ c.q.post(&e)
166+ })
167+ reg.AddGlobalRemoveHandler(removeHook{c.q})
168+
169+ // With no reader running, the owner's own roundtrips park every event in
170+ // the queue's held list, in order; applying it here is the startup drain.
171+ if err = display.Roundtrip(); err != nil {
172+ return nil, err
173+ }
174+ if err = c.applyHeld(); err != nil {
175+ return nil, err
176+ }
177+ if err = display.Roundtrip(); err != nil { // wl_output properties follow the binds
178+ return nil, err
179+ }
180+ if err = c.applyHeld(); err != nil {
181+ return nil, err
182+ }
183+
184+ c.ready = true
185+ c.readerDone = make(chan struct{})
186+ c.readerStarted = true
187+ c.epollDone = make(chan struct{})
188+ c.epollStart = true
189+ c.q.setPausing(false)
190+ go c.readLoop()
191+ go c.epollLoop()
192+ return c, nil
193+}
194+
195+// removeHook forwards registry global_remove to the queue.
196+type removeHook struct{ q *queue }
197+
198+func (h removeHook) HandleRegistryGlobalRemove(e wl.RegistryGlobalRemoveEvent) {
199+ ev := event{kind: evGlobalRemove, global: e.Name}
200+ h.q.post(&ev)
201+}
202+
203+// setupHandler applies setup events without notifying anyone and keeps the
204+// first failure, which makes Connect fail.
205+type setupHandler struct {
206+ nopHandler
207+ err *error
208+}
209+
210+func (h setupHandler) Error(err error) {
211+ if *h.err == nil {
212+ *h.err = err
213+ }
214+}
215+
216+// applyHeld applies the events parked during setup.
217+func (c *Conn) applyHeld() error {
218+ var err error
219+ h := setupHandler{err: &err}
220+ held := c.q.takeHeld()
221+ for i := range held {
222+ c.apply(&held[i], h)
223+ }
224+ return err
225+}
226+
227+// readLoop is the reader goroutine. It only calls Display.Dispatch; the
228+// generated handlers it triggers only push events into the queue.
229+func (c *Conn) readLoop() {
230+ defer close(c.readerDone)
231+ defer c.q.readerDone()
232+ for {
233+ if !c.q.gate() {
234+ return
235+ }
236+ err := c.display.Dispatch()
237+ if err == nil || errors.Is(err, os.ErrDeadlineExceeded) {
238+ continue // a pause request interrupted the read
239+ }
240+ if !c.q.isStopped() {
241+ c.q.setFatal(err)
242+ }
243+ return
244+ }
245+}
246+
247+// epollLoop is the second goroutine: it waits for watched descriptors and the
248+// shutdown eventfd.
249+func (c *Conn) epollLoop() {
250+ defer close(c.epollDone)
251+ var evs [8]unix.EpollEvent
252+ for {
253+ n, err := unix.EpollWait(c.epfd, evs[:], -1)
254+ if err != nil {
255+ if errors.Is(err, unix.EINTR) {
256+ continue
257+ }
258+ c.q.setFatal(fmt.Errorf("neferclient: epoll wait: %w", err))
259+ return
260+ }
261+ for i := range n {
262+ if evs[i].Fd == int32(c.efd) {
263+ return
264+ }
265+ e := event{kind: evFDReady, fd: evs[i].Fd}
266+ c.q.post(&e)
267+ }
268+ }
269+}
270+
271+// pause parks the reader between two reads and returns once it is parked.
272+// Nested calls on the owner goroutine share the first pause. Before the reader
273+// starts, the queue already parks everything and pause does nothing.
274+func (c *Conn) pause() {
275+ if !c.readerStarted {
276+ return
277+ }
278+ c.pauseDepth++
279+ if c.pauseDepth > 1 {
280+ return
281+ }
282+ c.q.setPausing(true)
283+ _ = c.sock.SetReadDeadline(time.Unix(1, 0)) // wakes a blocked read
284+ c.q.awaitParked()
285+ _ = c.sock.SetReadDeadline(time.Time{})
286+}
287+
288+func (c *Conn) resume() {
289+ if !c.readerStarted {
290+ return
291+ }
292+ c.pauseDepth--
293+ if c.pauseDepth == 0 {
294+ c.q.setPausing(false)
295+ }
296+}
297+
298+// Wake returns a channel with capacity 1 that is signaled whenever events are
299+// queued: call [Conn.Dispatch] when it fires. A signal can be stale (the
300+// events were already drained), so Dispatch may find nothing to do.
301+func (c *Conn) Wake() <-chan struct{} { return c.q.wake }
302+
303+// Context returns the wlturbo context that generated proxies are created on.
304+func (c *Conn) Context() *wl.Context { return c.wlctx }
305+
306+// Display returns the underlying wlturbo display. Only the owner goroutine
307+// may call Dispatch or Roundtrip on it; use [Conn.Dispatch] and
308+// [Conn.Roundtrip] instead.
309+func (c *Conn) Display() *wl.Display { return c.display }
310+
311+// Dispatch applies the events the reader queued and calls h for each one that
312+// matters to the application, on the calling goroutine. It handles at most one
313+// ring of events per call and signals [Conn.Wake] again if more are pending.
314+// A nil h discards the notifications but still applies the state changes. It
315+// returns the transport error once the queue is drained after the connection
316+// failed, and [ErrClosed] after Close.
317+func (c *Conn) Dispatch(h Handler) error {
318+ if c.closed {
319+ return ErrClosed
320+ }
321+ if c.dispatching {
322+ return errors.New("neferclient: Dispatch called from a Handler")
323+ }
324+ if h == nil {
325+ h = nopHandler{}
326+ }
327+ c.dispatching = true
328+ defer func() { c.dispatching = false }()
329+ var ev event
330+ for range ringSize {
331+ if !c.q.pop(&ev) {
332+ return c.q.fatalErr()
333+ }
334+ c.apply(&ev, h)
335+ if c.closed {
336+ return ErrClosed
337+ }
338+ }
339+ if c.q.pending() {
340+ c.q.signal()
341+ return nil
342+ }
343+ return c.q.fatalErr()
344+}
345+
346+// apply runs on the owner goroutine.
347+func (c *Conn) apply(ev *event, h Handler) {
348+ switch ev.kind {
349+ case evGlobal:
350+ if err := c.bindOutput(ev.global, ev.version); err != nil {
351+ h.Error(err)
352+ }
353+ case evGlobalRemove:
354+ c.removeOutput(ev.global, h)
355+ case evOutputName:
356+ if o := c.entry(ev.global); o != nil {
357+ o.nameLen = copy(o.nameBuf[:], ev.nameBytes())
358+ }
359+ case evOutputScale:
360+ if o := c.entry(ev.global); o != nil && ev.a > 0 {
361+ o.scale = ev.a
362+ }
363+ case evOutputMode:
364+ if o := c.entry(ev.global); o != nil && ev.flags&1 != 0 { // current mode
365+ o.width, o.height = ev.a, ev.b
366+ }
367+ case evOutputDone:
368+ if o := c.entry(ev.global); o != nil {
369+ c.commitOutput(o, h)
370+ }
371+ case evFDReady:
372+ id, ok := c.watched[ev.fd]
373+ if !ok {
374+ return // unwatched while the event was queued
375+ }
376+ h.FDReady(id)
377+ if _, ok := c.watched[ev.fd]; ok { // the handler may have unwatched it
378+ if err := c.armFD(int(ev.fd), unix.EPOLL_CTL_MOD); err != nil {
379+ h.Error(err)
380+ }
381+ }
382+ }
383+}
384+
385+func (c *Conn) entry(global uint32) *outputEntry {
386+ for _, o := range c.outputs {
387+ if o.pub.Global == global {
388+ return o
389+ }
390+ }
391+ return nil
392+}
393+
394+func (c *Conn) bindOutput(global, version uint32) error {
395+ if version == 0 || c.entry(global) != nil {
396+ return nil
397+ }
398+ o := &outputEntry{pub: Output{Global: global, Scale: 1}, scale: 1}
399+ out := wayland.NewOutput(c.wlctx)
400+ q := c.q
401+ // Handlers go on before the bind request is sent, so no early event of the
402+ // new object is lost. They run on the reader goroutine and only queue.
403+ out.OnName(func(name string) {
404+ e := event{kind: evOutputName, global: global}
405+ e.setName(name)
406+ q.post(&e)
407+ })
408+ out.OnScale(func(factor int32) {
409+ e := event{kind: evOutputScale, global: global, a: factor}
410+ q.post(&e)
411+ })
412+ out.OnMode(func(flags uint32, width, height, _ int32) {
413+ e := event{kind: evOutputMode, global: global, flags: flags, a: width, b: height}
414+ q.post(&e)
415+ })
416+ out.OnDone(func() {
417+ e := event{kind: evOutputDone, global: global}
418+ q.post(&e)
419+ })
420+ if err := c.display.Registry().Bind(global, wayland.OutputInterface, min(version, maxOutputVersion), out); err != nil {
421+ c.wlctx.Unregister(out)
422+ return fmt.Errorf("neferclient: bind wl_output %d: %w", global, err)
423+ }
424+ o.proxy = out
425+ i := len(c.outputs)
426+ c.outputs = append(c.outputs, o)
427+ for ; i > 0 && c.outputs[i-1].pub.Global > global; i-- {
428+ c.outputs[i], c.outputs[i-1] = c.outputs[i-1], c.outputs[i]
429+ }
430+ return nil
431+}
432+
433+func (c *Conn) commitOutput(o *outputEntry, h Handler) {
434+ if string(o.nameBuf[:o.nameLen]) != o.pub.Name {
435+ o.pub.Name = string(o.nameBuf[:o.nameLen])
436+ }
437+ o.pub.Scale, o.pub.Width, o.pub.Height = o.scale, o.width, o.height
438+ if !o.done {
439+ o.done = true
440+ if c.ready {
441+ h.OutputAdded(&o.pub)
442+ }
443+ }
444+}
445+
446+func (c *Conn) removeOutput(global uint32, h Handler) {
447+ for i, o := range c.outputs {
448+ if o.pub.Global != global {
449+ continue
450+ }
451+ if err := o.proxy.Release(); err != nil {
452+ c.wlctx.Unregister(o.proxy) // version 1 and 2 have no release
453+ }
454+ c.outputs = append(c.outputs[:i], c.outputs[i+1:]...)
455+ if o.done {
456+ h.OutputRemoved(global)
457+ }
458+ return
459+ }
460+}
461+
462+// Outputs returns the outputs that finished their first description, ordered
463+// by Global. The slice is reused by the next call; owner goroutine only.
464+func (c *Conn) Outputs() []Output {
465+ c.view = c.view[:0]
466+ for _, o := range c.outputs {
467+ if o.done {
468+ c.view = append(c.view, o.pub)
469+ }
470+ }
471+ return c.view
472+}
473+
474+// Roundtrip sends wl_display.sync and dispatches until it returns. The reader
475+// is paused meanwhile; the events it handled are queued for [Conn.Dispatch].
476+func (c *Conn) Roundtrip() error {
477+ if c.closed {
478+ return ErrClosed
479+ }
480+ c.pause()
481+ defer c.resume()
482+ return c.display.Roundtrip()
483+}
484+
485+// Bind binds the global named iface at min(announced, version) and returns the
486+// proxy and the negotiated version. newProxy creates the proxy on the given
487+// context; register its event handlers inside newProxy: the reader is paused
488+// until Bind returns, but the bound object's first events can arrive right
489+// after, so a handler added later may miss them. A missing global yields an
490+// error matching [ErrGlobalNotFound].
491+func (c *Conn) Bind[P wl.Proxy](iface string, version uint32, newProxy func(*wl.Context) P) (P, uint32, error) {
492+ var zero P
493+ if c.closed {
494+ return zero, 0, ErrClosed
495+ }
496+ c.pause()
497+ defer c.resume()
498+ p := newProxy(c.wlctx)
499+ v, err := c.display.Registry().BindNegotiated(iface, version, p)
500+ if err != nil {
501+ c.wlctx.Unregister(p)
502+ return zero, 0, err
503+ }
504+ return p, v, nil
505+}
506+
507+// WatchFD reports readability of fd through Handler.FDReady(id). The watch is
508+// one-shot: it is re-armed after each FDReady call returns. fd stays owned by
509+// the caller and must be unwatched before it is closed.
510+func (c *Conn) WatchFD(fd int, id uint64) error {
511+ if c.closed {
512+ return ErrClosed
513+ }
514+ if _, ok := c.watched[int32(fd)]; ok {
515+ return fmt.Errorf("neferclient: fd %d already watched", fd)
516+ }
517+ if err := c.armFD(fd, unix.EPOLL_CTL_ADD); err != nil {
518+ return err
519+ }
520+ c.watched[int32(fd)] = id
521+ return nil
522+}
523+
524+// UnwatchFD stops watching fd. A notification already queued is dropped.
525+func (c *Conn) UnwatchFD(fd int) error {
526+ if c.closed {
527+ return ErrClosed
528+ }
529+ if _, ok := c.watched[int32(fd)]; !ok {
530+ return fmt.Errorf("neferclient: fd %d not watched", fd)
531+ }
532+ delete(c.watched, int32(fd))
533+ if err := unix.EpollCtl(c.epfd, unix.EPOLL_CTL_DEL, fd, nil); err != nil {
534+ return fmt.Errorf("neferclient: unwatch fd %d: %w", fd, err)
535+ }
536+ return nil
537+}
538+
539+func (c *Conn) armFD(fd, op int) error {
540+ ev := unix.EpollEvent{Events: unix.EPOLLIN | unix.EPOLLONESHOT, Fd: int32(fd)}
541+ if err := unix.EpollCtl(c.epfd, op, fd, &ev); err != nil {
542+ return fmt.Errorf("neferclient: watch fd %d: %w", fd, err)
543+ }
544+ return nil
545+}
546+
547+// Close closes the connection and stops both goroutines. It never requests an
548+// unlock or any other protocol shutdown; those are explicit calls. It is safe
549+// to call more than once.
550+func (c *Conn) Close() error {
551+ if c == nil || c.closed {
552+ return nil
553+ }
554+ c.closed = true
555+ c.q.stop()
556+ err := c.display.Close()
557+ if c.readerStarted {
558+ <-c.readerDone
559+ }
560+ if c.epollStart {
561+ var one = [8]byte{1}
562+ _, _ = unix.Write(c.efd, one[:])
563+ <-c.epollDone
564+ }
565+ if c.epfd >= 0 {
566+ _ = unix.Close(c.epfd)
567+ }
568+ if c.efd >= 0 {
569+ _ = unix.Close(c.efd)
570+ }
571+ return err
572+}
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 +35 -0
@@ -0,0 +1,35 @@
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 discards every event. Connect uses it to apply the events queued
29+// during setup, and Dispatch uses it for a nil Handler.
30+type nopHandler struct{}
31+
32+func (nopHandler) OutputAdded(*Output) {}
33+func (nopHandler) OutputRemoved(uint32) {}
34+func (nopHandler) FDReady(uint64) {}
35+func (nopHandler) Error(error) {}
@@ -0,0 +1,35 @@
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 discards every event. Connect uses it to apply the events queued
29+// during setup, and Dispatch uses it for a nil Handler.
30+type nopHandler struct{}
31+
32+func (nopHandler) OutputAdded(*Output) {}
33+func (nopHandler) OutputRemoved(uint32) {}
34+func (nopHandler) FDReady(uint64) {}
35+func (nopHandler) Error(error) {}
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 +242 -0
@@ -0,0 +1,242 @@
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+// awaitParked blocks until the reader is parked or gone.
165+func (q *queue) awaitParked() {
166+ q.mu.Lock()
167+ for !q.readerParked && !q.readerExit {
168+ q.cond.Wait()
169+ }
170+ q.mu.Unlock()
171+}
172+
173+// gate runs on the reader goroutine before every read. It parks the reader
174+// while a pause is requested and re-posts what producers parked meanwhile.
175+// It reports false when the connection is closing.
176+func (q *queue) gate() bool {
177+ q.mu.Lock()
178+ for q.pausing && !q.stopped {
179+ if !q.readerParked {
180+ q.readerParked = true
181+ q.cond.Broadcast()
182+ }
183+ q.cond.Wait()
184+ }
185+ wasParked := q.readerParked
186+ q.readerParked = false
187+ stopped := q.stopped
188+ q.mu.Unlock()
189+ if stopped {
190+ return false
191+ }
192+ if wasParked {
193+ q.flushHeld()
194+ }
195+ return true
196+}
197+
198+func (q *queue) flushHeld() {
199+ q.mu.Lock()
200+ held := q.held
201+ q.held = q.spare[:0]
202+ q.mu.Unlock()
203+ for i := range held {
204+ q.post(&held[i])
205+ }
206+ q.mu.Lock()
207+ q.spare = held[:0]
208+ q.mu.Unlock()
209+}
210+
211+// takeHeld returns and clears the parked events. Only valid while no reader
212+// runs (during setup).
213+func (q *queue) takeHeld() []event {
214+ q.mu.Lock()
215+ held := q.held
216+ q.held = nil
217+ q.mu.Unlock()
218+ return held
219+}
220+
221+// length reports how many events are queued.
222+func (q *queue) length() int {
223+ q.mu.Lock()
224+ n := q.n
225+ q.mu.Unlock()
226+ return n
227+}
228+
229+// pending reports whether events are queued.
230+func (q *queue) pending() bool {
231+ q.mu.Lock()
232+ n := q.n
233+ q.mu.Unlock()
234+ return n > 0
235+}
236+
237+func (q *queue) readerDone() {
238+ q.mu.Lock()
239+ q.readerExit = true
240+ q.cond.Broadcast()
241+ q.mu.Unlock()
242+}
@@ -0,0 +1,242 @@
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+// awaitParked blocks until the reader is parked or gone.
165+func (q *queue) awaitParked() {
166+ q.mu.Lock()
167+ for !q.readerParked && !q.readerExit {
168+ q.cond.Wait()
169+ }
170+ q.mu.Unlock()
171+}
172+
173+// gate runs on the reader goroutine before every read. It parks the reader
174+// while a pause is requested and re-posts what producers parked meanwhile.
175+// It reports false when the connection is closing.
176+func (q *queue) gate() bool {
177+ q.mu.Lock()
178+ for q.pausing && !q.stopped {
179+ if !q.readerParked {
180+ q.readerParked = true
181+ q.cond.Broadcast()
182+ }
183+ q.cond.Wait()
184+ }
185+ wasParked := q.readerParked
186+ q.readerParked = false
187+ stopped := q.stopped
188+ q.mu.Unlock()
189+ if stopped {
190+ return false
191+ }
192+ if wasParked {
193+ q.flushHeld()
194+ }
195+ return true
196+}
197+
198+func (q *queue) flushHeld() {
199+ q.mu.Lock()
200+ held := q.held
201+ q.held = q.spare[:0]
202+ q.mu.Unlock()
203+ for i := range held {
204+ q.post(&held[i])
205+ }
206+ q.mu.Lock()
207+ q.spare = held[:0]
208+ q.mu.Unlock()
209+}
210+
211+// takeHeld returns and clears the parked events. Only valid while no reader
212+// runs (during setup).
213+func (q *queue) takeHeld() []event {
214+ q.mu.Lock()
215+ held := q.held
216+ q.held = nil
217+ q.mu.Unlock()
218+ return held
219+}
220+
221+// length reports how many events are queued.
222+func (q *queue) length() int {
223+ q.mu.Lock()
224+ n := q.n
225+ q.mu.Unlock()
226+ return n
227+}
228+
229+// pending reports whether events are queued.
230+func (q *queue) pending() bool {
231+ q.mu.Lock()
232+ n := q.n
233+ q.mu.Unlock()
234+ return n > 0
235+}
236+
237+func (q *queue) readerDone() {
238+ q.mu.Lock()
239+ q.readerExit = true
240+ q.cond.Broadcast()
241+ q.mu.Unlock()
242+}

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