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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||