detached 0bae9b7

test: cover dispatch, outputs, fd watching and headless connect

Wire-level tests drive Conn over a socketpair against a minimal compositor
peer; TestAllocDispatch pins zero allocations per drained event, and the
NEFERCLIENT_HEADLESS test connects to neferwl and lists outputs.
commit
0bae9b7c0afbb65977137ec68583f42a0e98a1c8
author
brice · · 2026-10-01 15:30 UTC
parent
8f00b92
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +762 -0
A alloc_test.go +65 -0
@@ -0,0 +1,65 @@
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{ fdReady, added, removed, errs int }
16+
17+func (h *countingHandler) OutputAdded(*neferclient.Output) { h.added++ }
18+func (h *countingHandler) OutputRemoved(uint32) { h.removed++ }
19+func (h *countingHandler) FDReady(uint64) { h.fdReady++ }
20+func (h *countingHandler) Error(error) { h.errs++ }
21+
22+// TestAllocDispatch measures the whole steady-state pipeline over a real
23+// socketpair: wire frames in, reader decode, queue, Dispatch (output state
24+// updates and a watched-fd notification with epoll re-arm). testing's
25+// AllocsPerRun counts every goroutine, so reader and epoll allocations are
26+// included.
27+func TestAllocDispatch(t *testing.T) {
28+ skipUnderRace(t)
29+ c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}})
30+ obj := srv.waitBound(7)
31+
32+ const scaleEvents = 200
33+ var batch []byte
34+ for i := range scaleEvents {
35+ batch = append(batch, frame(obj.id, 3, appendU32(nil, uint32(1+i%2)))...)
36+ }
37+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
38+ require.NoError(t, err)
39+ defer unix.Close(efd)
40+ require.NoError(t, c.WatchFD(efd, 1))
41+ one := [8]byte{1}
42+ var drain [8]byte
43+
44+ h := &countingHandler{}
45+ step := func() {
46+ if _, err := srv.conn.Write(batch); err != nil {
47+ t.Fatal(err)
48+ }
49+ _, _ = unix.Write(efd, one[:])
50+ for c.QueueLen() < scaleEvents+1 {
51+ runtime.Gosched()
52+ }
53+ _, _ = unix.Read(efd, drain[:])
54+ if err := c.Dispatch(h); err != nil {
55+ t.Fatal(err)
56+ }
57+ }
58+ step() // warm-up beyond AllocsPerRun's own: grow pools and buffers
59+ step()
60+ allocs := testing.AllocsPerRun(50, step)
61+ t.Logf("allocs per Dispatch of %d events: %v", scaleEvents+1, allocs)
62+ require.Zero(t, allocs)
63+ require.Positive(t, h.fdReady)
64+ require.Zero(t, h.errs)
65+}
@@ -0,0 +1,65 @@
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{ fdReady, added, removed, errs int }
16+
17+func (h *countingHandler) OutputAdded(*neferclient.Output) { h.added++ }
18+func (h *countingHandler) OutputRemoved(uint32) { h.removed++ }
19+func (h *countingHandler) FDReady(uint64) { h.fdReady++ }
20+func (h *countingHandler) Error(error) { h.errs++ }
21+
22+// TestAllocDispatch measures the whole steady-state pipeline over a real
23+// socketpair: wire frames in, reader decode, queue, Dispatch (output state
24+// updates and a watched-fd notification with epoll re-arm). testing's
25+// AllocsPerRun counts every goroutine, so reader and epoll allocations are
26+// included.
27+func TestAllocDispatch(t *testing.T) {
28+ skipUnderRace(t)
29+ c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}})
30+ obj := srv.waitBound(7)
31+
32+ const scaleEvents = 200
33+ var batch []byte
34+ for i := range scaleEvents {
35+ batch = append(batch, frame(obj.id, 3, appendU32(nil, uint32(1+i%2)))...)
36+ }
37+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
38+ require.NoError(t, err)
39+ defer unix.Close(efd)
40+ require.NoError(t, c.WatchFD(efd, 1))
41+ one := [8]byte{1}
42+ var drain [8]byte
43+
44+ h := &countingHandler{}
45+ step := func() {
46+ if _, err := srv.conn.Write(batch); err != nil {
47+ t.Fatal(err)
48+ }
49+ _, _ = unix.Write(efd, one[:])
50+ for c.QueueLen() < scaleEvents+1 {
51+ runtime.Gosched()
52+ }
53+ _, _ = unix.Read(efd, drain[:])
54+ if err := c.Dispatch(h); err != nil {
55+ t.Fatal(err)
56+ }
57+ }
58+ step() // warm-up beyond AllocsPerRun's own: grow pools and buffers
59+ step()
60+ allocs := testing.AllocsPerRun(50, step)
61+ t.Logf("allocs per Dispatch of %d events: %v", scaleEvents+1, allocs)
62+ require.Zero(t, allocs)
63+ require.Positive(t, h.fdReady)
64+ require.Zero(t, h.errs)
65+}
A conn_test.go +265 -0
@@ -0,0 +1,265 @@
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.
65+ h := neferclientmocks.NewMockHandler(t)
66+ require.NoError(t, c.Roundtrip())
67+ require.NoError(t, c.Dispatch(h))
68+}
69+
70+func TestConnectContextCancelled(t *testing.T) {
71+ client, _ := socketPair(t) // peer never answers
72+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
73+ defer cancel()
74+ c, err := neferclient.ConnectConn(ctx, client)
75+ require.Error(t, err)
76+ assert.Nil(t, c)
77+ assert.ErrorIs(t, err, context.DeadlineExceeded)
78+
79+ cancelled, stop := context.WithCancel(context.Background())
80+ stop()
81+ _, err = neferclient.Connect(cancelled, "")
82+ assert.ErrorIs(t, err, context.Canceled)
83+ //lint:ignore SA1012 a nil context is the case under test
84+ _, err = neferclient.Connect(nil, "")
85+ assert.Error(t, err)
86+}
87+
88+func TestHotplug(t *testing.T) {
89+ c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}},
90+ map[uint32]outputSpec{7: {"DP-1", 1, 1920, 1080}, 11: {"HDMI-A-1", 2, 3840, 2160}})
91+ h := neferclientmocks.NewMockHandler(t)
92+ var added []neferclient.Output
93+ h.EXPECT().OutputAdded(mock.Anything).Run(func(out *neferclient.Output) { added = append(added, *out) }).Once()
94+ srv.announce(wireGlobal{11, outputIface, 4})
95+ dispatchUntil(t, c, h, func() bool { return len(added) == 1 })
96+ assert.Equal(t, neferclient.Output{Global: 11, Name: "HDMI-A-1", Scale: 2, Width: 3840, Height: 2160}, added[0])
97+ require.Len(t, c.Outputs(), 2)
98+
99+ h.EXPECT().OutputRemoved(uint32(7)).Once()
100+ srv.remove(7)
101+ dispatchUntil(t, c, h, func() bool { return len(c.Outputs()) == 1 })
102+ assert.Equal(t, uint32(11), c.Outputs()[0].Global)
103+}
104+
105+func TestOutputPropertyChangeIsSilent(t *testing.T) {
106+ c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}})
107+ obj := srv.waitBound(7)
108+ srv.write(frame(obj.id, 3, appendU32(nil, 3))) // scale 3
109+ srv.write(frame(obj.id, 2, nil)) // done
110+ h := neferclientmocks.NewMockHandler(t) // no expectations: any call fails
111+ dispatchUntil(t, c, h, func() bool { return c.Outputs()[0].Scale == 3 })
112+}
113+
114+func TestRingBackpressureKeepsOrder(t *testing.T) {
115+ const total = 3 * neferclient.RingSize
116+ c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}})
117+ obj := srv.waitBound(7)
118+ var buf []byte
119+ for i := 1; i <= total; i++ {
120+ buf = append(buf, frame(obj.id, 3, appendU32(nil, uint32(i)))...)
121+ }
122+ buf = append(buf, frame(obj.id, 2, nil)...)
123+ go srv.write(buf)
124+ waitFor(t, func() bool { return c.QueueLen() == neferclient.RingSize }, "full ring")
125+
126+ // A pause (Roundtrip) must not deadlock against a producer blocked on the
127+ // full ring, and must not reorder what it held back.
128+ require.NoError(t, c.Roundtrip())
129+ dispatchUntil(t, c, nil, func() bool { return c.Outputs()[0].Scale == total })
130+}
131+
132+func waitFor(t *testing.T, cond func() bool, what string) {
133+ t.Helper()
134+ deadline := time.Now().Add(10 * time.Second)
135+ for !cond() {
136+ if time.Now().After(deadline) {
137+ t.Fatalf("timed out waiting for %s", what)
138+ }
139+ time.Sleep(time.Millisecond)
140+ }
141+}
142+
143+func TestBind(t *testing.T) {
144+ c, srv := connectWire(t, []wireGlobal{{3, wayland.CompositorInterface, 6}}, nil)
145+ comp, v, err := c.Bind[*wayland.Compositor](wayland.CompositorInterface, 4, wayland.NewCompositor)
146+ require.NoError(t, err)
147+ assert.NotNil(t, comp)
148+ assert.Equal(t, uint32(4), v)
149+ assert.Equal(t, uint32(4), srv.waitBound(3).version)
150+
151+ _, _, err = c.Bind[*wayland.Shm](wayland.ShmInterface, 1, wayland.NewShm)
152+ assert.ErrorIs(t, err, neferclient.ErrGlobalNotFound)
153+}
154+
155+func TestWatchFD(t *testing.T) {
156+ c, _ := connectWire(t, nil, nil)
157+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
158+ require.NoError(t, err)
159+ defer unix.Close(efd)
160+ require.NoError(t, c.WatchFD(efd, 42))
161+ assert.Error(t, c.WatchFD(efd, 43), "double watch")
162+
163+ h := neferclientmocks.NewMockHandler(t)
164+ got := 0
165+ h.EXPECT().FDReady(uint64(42)).Run(func(uint64) {
166+ got++
167+ var b [8]byte
168+ _, _ = unix.Read(efd, b[:])
169+ }).Times(2)
170+ signal := func() {
171+ one := [8]byte{1}
172+ _, err := unix.Write(efd, one[:])
173+ require.NoError(t, err)
174+ }
175+ signal()
176+ dispatchUntil(t, c, h, func() bool { return got == 1 })
177+ signal() // re-armed after the first FDReady returned
178+ dispatchUntil(t, c, h, func() bool { return got == 2 })
179+
180+ require.NoError(t, c.UnwatchFD(efd))
181+ assert.Error(t, c.UnwatchFD(efd))
182+ signal()
183+ require.NoError(t, c.Roundtrip())
184+ require.NoError(t, c.Dispatch(h)) // would fail the mock's Times(2)
185+}
186+
187+func TestUnwatchInsideHandler(t *testing.T) {
188+ c, _ := connectWire(t, nil, nil)
189+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
190+ require.NoError(t, err)
191+ defer unix.Close(efd)
192+ require.NoError(t, c.WatchFD(efd, 1))
193+ h := neferclientmocks.NewMockHandler(t)
194+ called := false
195+ h.EXPECT().FDReady(uint64(1)).Run(func(uint64) {
196+ called = true
197+ require.NoError(t, c.UnwatchFD(efd))
198+ }).Once()
199+ one := [8]byte{1}
200+ _, _ = unix.Write(efd, one[:])
201+ dispatchUntil(t, c, h, func() bool { return called })
202+}
203+
204+func TestTransportErrorSurfacesAfterQueuedEvents(t *testing.T) {
205+ c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}})
206+ obj := srv.waitBound(7)
207+ srv.write(frame(obj.id, 3, appendU32(nil, 5)))
208+ srv.write(frame(obj.id, 2, nil))
209+ srv.closeConn()
210+ timeout := time.After(10 * time.Second)
211+ var err error
212+ for err == nil {
213+ err = c.Dispatch(nil)
214+ if err != nil {
215+ break
216+ }
217+ select {
218+ case <-c.Wake():
219+ case <-timeout:
220+ t.Fatal("transport error never surfaced")
221+ }
222+ }
223+ assert.Equal(t, int32(5), c.Outputs()[0].Scale, "events queued before the failure are applied first")
224+}
225+
226+func TestHandlerCannotReenterDispatch(t *testing.T) {
227+ c, srv := connectWire(t, nil, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}})
228+ h := neferclientmocks.NewMockHandler(t)
229+ var inner error
230+ h.EXPECT().OutputAdded(mock.Anything).Run(func(*neferclient.Output) { inner = c.Dispatch(h) }).Once()
231+ srv.announce(wireGlobal{7, outputIface, 4})
232+ dispatchUntil(t, c, h, func() bool { return inner != nil })
233+ assert.Error(t, inner)
234+}
235+
236+func TestClose(t *testing.T) {
237+ c, _ := connectWire(t, nil, nil)
238+ require.NoError(t, c.Close())
239+ assert.NoError(t, c.Close())
240+ assert.ErrorIs(t, c.Dispatch(nil), neferclient.ErrClosed)
241+ assert.ErrorIs(t, c.Roundtrip(), neferclient.ErrClosed)
242+ assert.ErrorIs(t, c.WatchFD(0, 1), neferclient.ErrClosed)
243+ _, _, err := c.Bind[*wayland.Shm](wayland.ShmInterface, 1, wayland.NewShm)
244+ assert.ErrorIs(t, err, neferclient.ErrClosed)
245+ var nilConn *neferclient.Conn
246+ assert.NoError(t, nilConn.Close())
247+}
248+
249+func TestCloseWhileReaderBlockedOnFullRing(t *testing.T) {
250+ c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}})
251+ obj := srv.waitBound(7)
252+ var buf []byte
253+ for i := 0; i < 2*neferclient.RingSize; i++ {
254+ buf = append(buf, frame(obj.id, 3, appendU32(nil, 2))...)
255+ }
256+ go srv.write(buf)
257+ waitFor(t, func() bool { return c.QueueLen() == neferclient.RingSize }, "full ring")
258+ done := make(chan struct{})
259+ go func() { _ = c.Close(); close(done) }()
260+ select {
261+ case <-done:
262+ case <-time.After(5 * time.Second):
263+ t.Fatal("Close hung with a producer blocked on the full ring")
264+ }
265+}
@@ -0,0 +1,265 @@
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.
65+ h := neferclientmocks.NewMockHandler(t)
66+ require.NoError(t, c.Roundtrip())
67+ require.NoError(t, c.Dispatch(h))
68+}
69+
70+func TestConnectContextCancelled(t *testing.T) {
71+ client, _ := socketPair(t) // peer never answers
72+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
73+ defer cancel()
74+ c, err := neferclient.ConnectConn(ctx, client)
75+ require.Error(t, err)
76+ assert.Nil(t, c)
77+ assert.ErrorIs(t, err, context.DeadlineExceeded)
78+
79+ cancelled, stop := context.WithCancel(context.Background())
80+ stop()
81+ _, err = neferclient.Connect(cancelled, "")
82+ assert.ErrorIs(t, err, context.Canceled)
83+ //lint:ignore SA1012 a nil context is the case under test
84+ _, err = neferclient.Connect(nil, "")
85+ assert.Error(t, err)
86+}
87+
88+func TestHotplug(t *testing.T) {
89+ c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}},
90+ map[uint32]outputSpec{7: {"DP-1", 1, 1920, 1080}, 11: {"HDMI-A-1", 2, 3840, 2160}})
91+ h := neferclientmocks.NewMockHandler(t)
92+ var added []neferclient.Output
93+ h.EXPECT().OutputAdded(mock.Anything).Run(func(out *neferclient.Output) { added = append(added, *out) }).Once()
94+ srv.announce(wireGlobal{11, outputIface, 4})
95+ dispatchUntil(t, c, h, func() bool { return len(added) == 1 })
96+ assert.Equal(t, neferclient.Output{Global: 11, Name: "HDMI-A-1", Scale: 2, Width: 3840, Height: 2160}, added[0])
97+ require.Len(t, c.Outputs(), 2)
98+
99+ h.EXPECT().OutputRemoved(uint32(7)).Once()
100+ srv.remove(7)
101+ dispatchUntil(t, c, h, func() bool { return len(c.Outputs()) == 1 })
102+ assert.Equal(t, uint32(11), c.Outputs()[0].Global)
103+}
104+
105+func TestOutputPropertyChangeIsSilent(t *testing.T) {
106+ c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}})
107+ obj := srv.waitBound(7)
108+ srv.write(frame(obj.id, 3, appendU32(nil, 3))) // scale 3
109+ srv.write(frame(obj.id, 2, nil)) // done
110+ h := neferclientmocks.NewMockHandler(t) // no expectations: any call fails
111+ dispatchUntil(t, c, h, func() bool { return c.Outputs()[0].Scale == 3 })
112+}
113+
114+func TestRingBackpressureKeepsOrder(t *testing.T) {
115+ const total = 3 * neferclient.RingSize
116+ c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}})
117+ obj := srv.waitBound(7)
118+ var buf []byte
119+ for i := 1; i <= total; i++ {
120+ buf = append(buf, frame(obj.id, 3, appendU32(nil, uint32(i)))...)
121+ }
122+ buf = append(buf, frame(obj.id, 2, nil)...)
123+ go srv.write(buf)
124+ waitFor(t, func() bool { return c.QueueLen() == neferclient.RingSize }, "full ring")
125+
126+ // A pause (Roundtrip) must not deadlock against a producer blocked on the
127+ // full ring, and must not reorder what it held back.
128+ require.NoError(t, c.Roundtrip())
129+ dispatchUntil(t, c, nil, func() bool { return c.Outputs()[0].Scale == total })
130+}
131+
132+func waitFor(t *testing.T, cond func() bool, what string) {
133+ t.Helper()
134+ deadline := time.Now().Add(10 * time.Second)
135+ for !cond() {
136+ if time.Now().After(deadline) {
137+ t.Fatalf("timed out waiting for %s", what)
138+ }
139+ time.Sleep(time.Millisecond)
140+ }
141+}
142+
143+func TestBind(t *testing.T) {
144+ c, srv := connectWire(t, []wireGlobal{{3, wayland.CompositorInterface, 6}}, nil)
145+ comp, v, err := c.Bind[*wayland.Compositor](wayland.CompositorInterface, 4, wayland.NewCompositor)
146+ require.NoError(t, err)
147+ assert.NotNil(t, comp)
148+ assert.Equal(t, uint32(4), v)
149+ assert.Equal(t, uint32(4), srv.waitBound(3).version)
150+
151+ _, _, err = c.Bind[*wayland.Shm](wayland.ShmInterface, 1, wayland.NewShm)
152+ assert.ErrorIs(t, err, neferclient.ErrGlobalNotFound)
153+}
154+
155+func TestWatchFD(t *testing.T) {
156+ c, _ := connectWire(t, nil, nil)
157+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
158+ require.NoError(t, err)
159+ defer unix.Close(efd)
160+ require.NoError(t, c.WatchFD(efd, 42))
161+ assert.Error(t, c.WatchFD(efd, 43), "double watch")
162+
163+ h := neferclientmocks.NewMockHandler(t)
164+ got := 0
165+ h.EXPECT().FDReady(uint64(42)).Run(func(uint64) {
166+ got++
167+ var b [8]byte
168+ _, _ = unix.Read(efd, b[:])
169+ }).Times(2)
170+ signal := func() {
171+ one := [8]byte{1}
172+ _, err := unix.Write(efd, one[:])
173+ require.NoError(t, err)
174+ }
175+ signal()
176+ dispatchUntil(t, c, h, func() bool { return got == 1 })
177+ signal() // re-armed after the first FDReady returned
178+ dispatchUntil(t, c, h, func() bool { return got == 2 })
179+
180+ require.NoError(t, c.UnwatchFD(efd))
181+ assert.Error(t, c.UnwatchFD(efd))
182+ signal()
183+ require.NoError(t, c.Roundtrip())
184+ require.NoError(t, c.Dispatch(h)) // would fail the mock's Times(2)
185+}
186+
187+func TestUnwatchInsideHandler(t *testing.T) {
188+ c, _ := connectWire(t, nil, nil)
189+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
190+ require.NoError(t, err)
191+ defer unix.Close(efd)
192+ require.NoError(t, c.WatchFD(efd, 1))
193+ h := neferclientmocks.NewMockHandler(t)
194+ called := false
195+ h.EXPECT().FDReady(uint64(1)).Run(func(uint64) {
196+ called = true
197+ require.NoError(t, c.UnwatchFD(efd))
198+ }).Once()
199+ one := [8]byte{1}
200+ _, _ = unix.Write(efd, one[:])
201+ dispatchUntil(t, c, h, func() bool { return called })
202+}
203+
204+func TestTransportErrorSurfacesAfterQueuedEvents(t *testing.T) {
205+ c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}})
206+ obj := srv.waitBound(7)
207+ srv.write(frame(obj.id, 3, appendU32(nil, 5)))
208+ srv.write(frame(obj.id, 2, nil))
209+ srv.closeConn()
210+ timeout := time.After(10 * time.Second)
211+ var err error
212+ for err == nil {
213+ err = c.Dispatch(nil)
214+ if err != nil {
215+ break
216+ }
217+ select {
218+ case <-c.Wake():
219+ case <-timeout:
220+ t.Fatal("transport error never surfaced")
221+ }
222+ }
223+ assert.Equal(t, int32(5), c.Outputs()[0].Scale, "events queued before the failure are applied first")
224+}
225+
226+func TestHandlerCannotReenterDispatch(t *testing.T) {
227+ c, srv := connectWire(t, nil, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}})
228+ h := neferclientmocks.NewMockHandler(t)
229+ var inner error
230+ h.EXPECT().OutputAdded(mock.Anything).Run(func(*neferclient.Output) { inner = c.Dispatch(h) }).Once()
231+ srv.announce(wireGlobal{7, outputIface, 4})
232+ dispatchUntil(t, c, h, func() bool { return inner != nil })
233+ assert.Error(t, inner)
234+}
235+
236+func TestClose(t *testing.T) {
237+ c, _ := connectWire(t, nil, nil)
238+ require.NoError(t, c.Close())
239+ assert.NoError(t, c.Close())
240+ assert.ErrorIs(t, c.Dispatch(nil), neferclient.ErrClosed)
241+ assert.ErrorIs(t, c.Roundtrip(), neferclient.ErrClosed)
242+ assert.ErrorIs(t, c.WatchFD(0, 1), neferclient.ErrClosed)
243+ _, _, err := c.Bind[*wayland.Shm](wayland.ShmInterface, 1, wayland.NewShm)
244+ assert.ErrorIs(t, err, neferclient.ErrClosed)
245+ var nilConn *neferclient.Conn
246+ assert.NoError(t, nilConn.Close())
247+}
248+
249+func TestCloseWhileReaderBlockedOnFullRing(t *testing.T) {
250+ c, srv := connectWire(t, []wireGlobal{{7, outputIface, 4}}, map[uint32]outputSpec{7: {"DP-1", 1, 100, 100}})
251+ obj := srv.waitBound(7)
252+ var buf []byte
253+ for i := 0; i < 2*neferclient.RingSize; i++ {
254+ buf = append(buf, frame(obj.id, 3, appendU32(nil, 2))...)
255+ }
256+ go srv.write(buf)
257+ waitFor(t, func() bool { return c.QueueLen() == neferclient.RingSize }, "full ring")
258+ done := make(chan struct{})
259+ go func() { _ = c.Close(); close(done) }()
260+ select {
261+ case <-done:
262+ case <-time.After(5 * time.Second):
263+ t.Fatal("Close hung with a producer blocked on the full ring")
264+ }
265+}
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() }
A headless_test.go +138 -0
@@ -0,0 +1,138 @@
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+ h := &countingHandler{}
112+ for len(c.Outputs()) == 0 {
113+ select {
114+ case <-c.Wake():
115+ require.NoError(t, c.Dispatch(h))
116+ case <-ctx.Done():
117+ t.Fatal("no output announced")
118+ }
119+ }
120+ outs := c.Outputs()
121+ for _, o := range outs {
122+ t.Logf("output %d name=%q scale=%d %dx%d", o.Global, o.Name, o.Scale, o.Width, o.Height)
123+ require.Positive(t, o.Width)
124+ require.Positive(t, o.Height)
125+ require.NotZero(t, o.Scale)
126+ }
127+ require.NoError(t, c.Roundtrip())
128+
129+ comp, v, err := c.Bind[*wayland.Compositor](wayland.CompositorInterface, 4, wayland.NewCompositor)
130+ require.NoError(t, err)
131+ require.NotZero(t, v)
132+ surface, err := comp.CreateSurface()
133+ require.NoError(t, err)
134+ require.NoError(t, surface.Destroy())
135+ require.NoError(t, c.Roundtrip())
136+ require.NoError(t, c.Dispatch(nil))
137+ require.NoError(t, c.Close())
138+}
@@ -0,0 +1,138 @@
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+ h := &countingHandler{}
112+ for len(c.Outputs()) == 0 {
113+ select {
114+ case <-c.Wake():
115+ require.NoError(t, c.Dispatch(h))
116+ case <-ctx.Done():
117+ t.Fatal("no output announced")
118+ }
119+ }
120+ outs := c.Outputs()
121+ for _, o := range outs {
122+ t.Logf("output %d name=%q scale=%d %dx%d", o.Global, o.Name, o.Scale, o.Width, o.Height)
123+ require.Positive(t, o.Width)
124+ require.Positive(t, o.Height)
125+ require.NotZero(t, o.Scale)
126+ }
127+ require.NoError(t, c.Roundtrip())
128+
129+ comp, v, err := c.Bind[*wayland.Compositor](wayland.CompositorInterface, 4, wayland.NewCompositor)
130+ require.NoError(t, err)
131+ require.NotZero(t, v)
132+ surface, err := comp.CreateSurface()
133+ require.NoError(t, err)
134+ require.NoError(t, surface.Destroy())
135+ require.NoError(t, c.Roundtrip())
136+ require.NoError(t, c.Dispatch(nil))
137+ require.NoError(t, c.Close())
138+}
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 +255 -0
@@ -0,0 +1,255 @@
1+package neferclient_test
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "io"
7+ "net"
8+ "os"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/go-wayland-bindings/client/wayland"
14+ "github.com/bnema/neferclient"
15+ "golang.org/x/sys/unix"
16+)
17+
18+// wireServer speaks just enough of the compositor side of the Wayland wire
19+// protocol over a socketpair to drive Conn: registry globals, sync callbacks,
20+// wl_output binds and caller-scripted events. It is the protocol peer, not a
21+// double of one of our own interfaces.
22+type wireServer struct {
23+ t *testing.T
24+ conn *net.UnixConn
25+
26+ mu sync.Mutex
27+ cond *sync.Cond
28+ registry uint32
29+ globals []wireGlobal
30+ bound map[uint32]boundObject // global name -> bound object
31+ outputs map[uint32]outputSpec
32+}
33+
34+type wireGlobal struct {
35+ name uint32
36+ iface string
37+ version uint32
38+}
39+
40+type boundObject struct {
41+ id uint32
42+ version uint32
43+}
44+
45+// outputSpec is what the server sends right after an output bind.
46+type outputSpec struct {
47+ name string
48+ scale int32
49+ width, height int32
50+}
51+
52+func appendU32(b []byte, v uint32) []byte { return binary.LittleEndian.AppendUint32(b, v) }
53+
54+func appendStr(b []byte, s string) []byte {
55+ n := len(s) + 1
56+ b = appendU32(b, uint32(n))
57+ b = append(b, s...)
58+ b = append(b, 0)
59+ for len(b)%4 != 0 {
60+ b = append(b, 0)
61+ }
62+ return b
63+}
64+
65+func frame(obj uint32, op uint16, body []byte) []byte {
66+ b := appendU32(nil, obj)
67+ b = appendU32(b, uint32(8+len(body))<<16|uint32(op))
68+ return append(b, body...)
69+}
70+
71+// socketPair returns the client end as a net.Conn and the server end.
72+func socketPair(t *testing.T) (net.Conn, *net.UnixConn) {
73+ t.Helper()
74+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ mk := func(fd int) net.Conn {
79+ f := os.NewFile(uintptr(fd), "socketpair")
80+ c, err := net.FileConn(f)
81+ _ = f.Close()
82+ if err != nil {
83+ t.Fatal(err)
84+ }
85+ return c
86+ }
87+ client, server := mk(fds[0]), mk(fds[1])
88+ t.Cleanup(func() { _ = server.Close() })
89+ return client, server.(*net.UnixConn)
90+}
91+
92+// startWireServer serves a socketpair. outputs maps an announced wl_output
93+// global to the description sent when it is bound.
94+func startWireServer(t *testing.T, globals []wireGlobal, outputs map[uint32]outputSpec) (net.Conn, *wireServer) {
95+ t.Helper()
96+ client, srvConn := socketPair(t)
97+ s := &wireServer{t: t, conn: srvConn, globals: globals, outputs: outputs,
98+ bound: map[uint32]boundObject{}}
99+ s.cond = sync.NewCond(&s.mu)
100+ go s.serve()
101+ return client, s
102+}
103+
104+func (s *wireServer) write(b []byte) {
105+ if _, err := s.conn.Write(b); err != nil && !isClosed(err) {
106+ s.t.Errorf("wire server write: %v", err)
107+ }
108+}
109+
110+func isClosed(err error) bool {
111+ return err != nil && (err == io.EOF || err == net.ErrClosed || os.IsTimeout(err) || isConnReset(err))
112+}
113+
114+func isConnReset(err error) bool {
115+ for err != nil {
116+ if err == unix.EPIPE || err == unix.ECONNRESET {
117+ return true
118+ }
119+ u, ok := err.(interface{ Unwrap() error })
120+ if !ok {
121+ return false
122+ }
123+ err = u.Unwrap()
124+ }
125+ return false
126+}
127+
128+func (s *wireServer) serve() {
129+ var hdr [8]byte
130+ for {
131+ if _, err := io.ReadFull(s.conn, hdr[:]); err != nil {
132+ return
133+ }
134+ obj := binary.LittleEndian.Uint32(hdr[0:4])
135+ w := binary.LittleEndian.Uint32(hdr[4:8])
136+ size, op := int(w>>16), uint16(w)
137+ body := make([]byte, size-8)
138+ if _, err := io.ReadFull(s.conn, body); err != nil {
139+ return
140+ }
141+ s.handle(obj, op, body)
142+ }
143+}
144+
145+func (s *wireServer) handle(obj uint32, op uint16, body []byte) {
146+ s.mu.Lock()
147+ reg := s.registry
148+ s.mu.Unlock()
149+ switch {
150+ case obj == 1 && op == 1: // get_registry
151+ id := binary.LittleEndian.Uint32(body)
152+ s.mu.Lock()
153+ s.registry = id
154+ globals := s.globals
155+ s.mu.Unlock()
156+ for _, g := range globals {
157+ s.announce(g)
158+ }
159+ case obj == 1 && op == 0: // sync
160+ id := binary.LittleEndian.Uint32(body)
161+ s.write(frame(id, 0, appendU32(nil, 0)))
162+ s.write(frame(1, 1, appendU32(nil, id)))
163+ s.mu.Lock()
164+ s.cond.Broadcast()
165+ s.mu.Unlock()
166+ case obj == reg && op == 0: // bind
167+ name := binary.LittleEndian.Uint32(body)
168+ n := int(binary.LittleEndian.Uint32(body[4:]))
169+ off := 8 + (n+3)&^3
170+ version := binary.LittleEndian.Uint32(body[off:])
171+ id := binary.LittleEndian.Uint32(body[off+4:])
172+ s.mu.Lock()
173+ s.bound[name] = boundObject{id: id, version: version}
174+ spec, isOutput := s.outputs[name]
175+ s.cond.Broadcast()
176+ s.mu.Unlock()
177+ if isOutput {
178+ s.describe(id, version, spec)
179+ }
180+ case op == 0 && body == nil: // wl_output.release (destructor, no args)
181+ s.write(frame(1, 1, appendU32(nil, obj)))
182+ }
183+}
184+
185+func (s *wireServer) announce(g wireGlobal) {
186+ b := appendU32(nil, g.name)
187+ b = appendStr(b, g.iface)
188+ b = appendU32(b, g.version)
189+ s.mu.Lock()
190+ reg := s.registry
191+ s.mu.Unlock()
192+ s.write(frame(reg, 0, b))
193+}
194+
195+func (s *wireServer) remove(name uint32) {
196+ s.mu.Lock()
197+ reg := s.registry
198+ s.mu.Unlock()
199+ s.write(frame(reg, 1, appendU32(nil, name)))
200+}
201+
202+// describe sends mode, scale, name (version >= 4) and done for an output.
203+func (s *wireServer) describe(id, version uint32, o outputSpec) {
204+ mode := appendU32(nil, 3) // current|preferred
205+ mode = appendU32(mode, uint32(o.width))
206+ mode = appendU32(mode, uint32(o.height))
207+ mode = appendU32(mode, 60000)
208+ s.write(frame(id, 1, mode))
209+ s.write(frame(id, 3, appendU32(nil, uint32(o.scale))))
210+ if version >= 4 {
211+ s.write(frame(id, 4, appendStr(nil, o.name)))
212+ }
213+ s.write(frame(id, 2, nil))
214+}
215+
216+// waitBound waits until the client bound global and returns the object.
217+func (s *wireServer) waitBound(global uint32) boundObject {
218+ s.t.Helper()
219+ deadline := time.AfterFunc(5*time.Second, func() {
220+ s.mu.Lock()
221+ s.cond.Broadcast()
222+ s.mu.Unlock()
223+ })
224+ defer deadline.Stop()
225+ start := time.Now()
226+ s.mu.Lock()
227+ defer s.mu.Unlock()
228+ for {
229+ if b, ok := s.bound[global]; ok {
230+ return b
231+ }
232+ if time.Since(start) > 5*time.Second {
233+ s.t.Fatalf("global %d never bound", global)
234+ }
235+ s.cond.Wait()
236+ }
237+}
238+
239+func (s *wireServer) closeConn() { _ = s.conn.Close() }
240+
241+// connectWire connects a Conn to the wire server.
242+func connectWire(t *testing.T, globals []wireGlobal, outputs map[uint32]outputSpec) (*neferclient.Conn, *wireServer) {
243+ t.Helper()
244+ client, srv := startWireServer(t, globals, outputs)
245+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
246+ t.Cleanup(cancel)
247+ c, err := neferclient.ConnectConn(ctx, client)
248+ if err != nil {
249+ t.Fatalf("connect: %v", err)
250+ }
251+ t.Cleanup(func() { _ = c.Close() })
252+ return c, srv
253+}
254+
255+const outputIface = wayland.OutputInterface
@@ -0,0 +1,255 @@
1+package neferclient_test
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "io"
7+ "net"
8+ "os"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/go-wayland-bindings/client/wayland"
14+ "github.com/bnema/neferclient"
15+ "golang.org/x/sys/unix"
16+)
17+
18+// wireServer speaks just enough of the compositor side of the Wayland wire
19+// protocol over a socketpair to drive Conn: registry globals, sync callbacks,
20+// wl_output binds and caller-scripted events. It is the protocol peer, not a
21+// double of one of our own interfaces.
22+type wireServer struct {
23+ t *testing.T
24+ conn *net.UnixConn
25+
26+ mu sync.Mutex
27+ cond *sync.Cond
28+ registry uint32
29+ globals []wireGlobal
30+ bound map[uint32]boundObject // global name -> bound object
31+ outputs map[uint32]outputSpec
32+}
33+
34+type wireGlobal struct {
35+ name uint32
36+ iface string
37+ version uint32
38+}
39+
40+type boundObject struct {
41+ id uint32
42+ version uint32
43+}
44+
45+// outputSpec is what the server sends right after an output bind.
46+type outputSpec struct {
47+ name string
48+ scale int32
49+ width, height int32
50+}
51+
52+func appendU32(b []byte, v uint32) []byte { return binary.LittleEndian.AppendUint32(b, v) }
53+
54+func appendStr(b []byte, s string) []byte {
55+ n := len(s) + 1
56+ b = appendU32(b, uint32(n))
57+ b = append(b, s...)
58+ b = append(b, 0)
59+ for len(b)%4 != 0 {
60+ b = append(b, 0)
61+ }
62+ return b
63+}
64+
65+func frame(obj uint32, op uint16, body []byte) []byte {
66+ b := appendU32(nil, obj)
67+ b = appendU32(b, uint32(8+len(body))<<16|uint32(op))
68+ return append(b, body...)
69+}
70+
71+// socketPair returns the client end as a net.Conn and the server end.
72+func socketPair(t *testing.T) (net.Conn, *net.UnixConn) {
73+ t.Helper()
74+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ mk := func(fd int) net.Conn {
79+ f := os.NewFile(uintptr(fd), "socketpair")
80+ c, err := net.FileConn(f)
81+ _ = f.Close()
82+ if err != nil {
83+ t.Fatal(err)
84+ }
85+ return c
86+ }
87+ client, server := mk(fds[0]), mk(fds[1])
88+ t.Cleanup(func() { _ = server.Close() })
89+ return client, server.(*net.UnixConn)
90+}
91+
92+// startWireServer serves a socketpair. outputs maps an announced wl_output
93+// global to the description sent when it is bound.
94+func startWireServer(t *testing.T, globals []wireGlobal, outputs map[uint32]outputSpec) (net.Conn, *wireServer) {
95+ t.Helper()
96+ client, srvConn := socketPair(t)
97+ s := &wireServer{t: t, conn: srvConn, globals: globals, outputs: outputs,
98+ bound: map[uint32]boundObject{}}
99+ s.cond = sync.NewCond(&s.mu)
100+ go s.serve()
101+ return client, s
102+}
103+
104+func (s *wireServer) write(b []byte) {
105+ if _, err := s.conn.Write(b); err != nil && !isClosed(err) {
106+ s.t.Errorf("wire server write: %v", err)
107+ }
108+}
109+
110+func isClosed(err error) bool {
111+ return err != nil && (err == io.EOF || err == net.ErrClosed || os.IsTimeout(err) || isConnReset(err))
112+}
113+
114+func isConnReset(err error) bool {
115+ for err != nil {
116+ if err == unix.EPIPE || err == unix.ECONNRESET {
117+ return true
118+ }
119+ u, ok := err.(interface{ Unwrap() error })
120+ if !ok {
121+ return false
122+ }
123+ err = u.Unwrap()
124+ }
125+ return false
126+}
127+
128+func (s *wireServer) serve() {
129+ var hdr [8]byte
130+ for {
131+ if _, err := io.ReadFull(s.conn, hdr[:]); err != nil {
132+ return
133+ }
134+ obj := binary.LittleEndian.Uint32(hdr[0:4])
135+ w := binary.LittleEndian.Uint32(hdr[4:8])
136+ size, op := int(w>>16), uint16(w)
137+ body := make([]byte, size-8)
138+ if _, err := io.ReadFull(s.conn, body); err != nil {
139+ return
140+ }
141+ s.handle(obj, op, body)
142+ }
143+}
144+
145+func (s *wireServer) handle(obj uint32, op uint16, body []byte) {
146+ s.mu.Lock()
147+ reg := s.registry
148+ s.mu.Unlock()
149+ switch {
150+ case obj == 1 && op == 1: // get_registry
151+ id := binary.LittleEndian.Uint32(body)
152+ s.mu.Lock()
153+ s.registry = id
154+ globals := s.globals
155+ s.mu.Unlock()
156+ for _, g := range globals {
157+ s.announce(g)
158+ }
159+ case obj == 1 && op == 0: // sync
160+ id := binary.LittleEndian.Uint32(body)
161+ s.write(frame(id, 0, appendU32(nil, 0)))
162+ s.write(frame(1, 1, appendU32(nil, id)))
163+ s.mu.Lock()
164+ s.cond.Broadcast()
165+ s.mu.Unlock()
166+ case obj == reg && op == 0: // bind
167+ name := binary.LittleEndian.Uint32(body)
168+ n := int(binary.LittleEndian.Uint32(body[4:]))
169+ off := 8 + (n+3)&^3
170+ version := binary.LittleEndian.Uint32(body[off:])
171+ id := binary.LittleEndian.Uint32(body[off+4:])
172+ s.mu.Lock()
173+ s.bound[name] = boundObject{id: id, version: version}
174+ spec, isOutput := s.outputs[name]
175+ s.cond.Broadcast()
176+ s.mu.Unlock()
177+ if isOutput {
178+ s.describe(id, version, spec)
179+ }
180+ case op == 0 && body == nil: // wl_output.release (destructor, no args)
181+ s.write(frame(1, 1, appendU32(nil, obj)))
182+ }
183+}
184+
185+func (s *wireServer) announce(g wireGlobal) {
186+ b := appendU32(nil, g.name)
187+ b = appendStr(b, g.iface)
188+ b = appendU32(b, g.version)
189+ s.mu.Lock()
190+ reg := s.registry
191+ s.mu.Unlock()
192+ s.write(frame(reg, 0, b))
193+}
194+
195+func (s *wireServer) remove(name uint32) {
196+ s.mu.Lock()
197+ reg := s.registry
198+ s.mu.Unlock()
199+ s.write(frame(reg, 1, appendU32(nil, name)))
200+}
201+
202+// describe sends mode, scale, name (version >= 4) and done for an output.
203+func (s *wireServer) describe(id, version uint32, o outputSpec) {
204+ mode := appendU32(nil, 3) // current|preferred
205+ mode = appendU32(mode, uint32(o.width))
206+ mode = appendU32(mode, uint32(o.height))
207+ mode = appendU32(mode, 60000)
208+ s.write(frame(id, 1, mode))
209+ s.write(frame(id, 3, appendU32(nil, uint32(o.scale))))
210+ if version >= 4 {
211+ s.write(frame(id, 4, appendStr(nil, o.name)))
212+ }
213+ s.write(frame(id, 2, nil))
214+}
215+
216+// waitBound waits until the client bound global and returns the object.
217+func (s *wireServer) waitBound(global uint32) boundObject {
218+ s.t.Helper()
219+ deadline := time.AfterFunc(5*time.Second, func() {
220+ s.mu.Lock()
221+ s.cond.Broadcast()
222+ s.mu.Unlock()
223+ })
224+ defer deadline.Stop()
225+ start := time.Now()
226+ s.mu.Lock()
227+ defer s.mu.Unlock()
228+ for {
229+ if b, ok := s.bound[global]; ok {
230+ return b
231+ }
232+ if time.Since(start) > 5*time.Second {
233+ s.t.Fatalf("global %d never bound", global)
234+ }
235+ s.cond.Wait()
236+ }
237+}
238+
239+func (s *wireServer) closeConn() { _ = s.conn.Close() }
240+
241+// connectWire connects a Conn to the wire server.
242+func connectWire(t *testing.T, globals []wireGlobal, outputs map[uint32]outputSpec) (*neferclient.Conn, *wireServer) {
243+ t.Helper()
244+ client, srv := startWireServer(t, globals, outputs)
245+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
246+ t.Cleanup(cancel)
247+ c, err := neferclient.ConnectConn(ctx, client)
248+ if err != nil {
249+ t.Fatalf("connect: %v", err)
250+ }
251+ t.Cleanup(func() { _ = c.Close() })
252+ return c, srv
253+}
254+
255+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