1//go:build linux
2
3package client_test
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "net"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "strings"
14 "syscall"
15 "testing"
16 "time"
17
18 "github.com/bnema/go-wayland-bindings/client/fractionalscale"
19 "github.com/bnema/go-wayland-bindings/client/linuxdmabuf"
20 drmsyncobj "github.com/bnema/go-wayland-bindings/client/linuxdrmsyncobj"
21 "github.com/bnema/go-wayland-bindings/client/viewporter"
22 "github.com/bnema/go-wayland-bindings/client/wayland"
23 "github.com/bnema/go-wayland-bindings/client/xdgshell"
24 "github.com/bnema/wlturbo"
25 "golang.org/x/sys/unix"
26)
27
28func TestHeadlessNeferWL(t *testing.T) {
29 h := startHeadless(t)
30 d := h.connect(t)
31 bind := h.binder(t, d)
32 ctx := h.ctx
33 compositor := wayland.NewCompositor(d.Context())
34 bind(wayland.CompositorInterface, 6, compositor)
35 wm := xdgshell.NewXdgWmBase(d.Context())
36 bind(xdgshell.XdgWmBaseInterface, 7, wm)
37 dm := linuxdmabuf.NewLinuxDmabuf(d.Context())
38 if bind(linuxdmabuf.LinuxDmabufInterface, 4, dm) < 4 {
39 t.Fatal("dmabuf feedback requires v4")
40 }
41 syncManager := drmsyncobj.NewWpLinuxDrmSyncobjManager(d.Context())
42 bind(drmsyncobj.WpLinuxDrmSyncobjManagerInterface, 1, syncManager)
43 dataManager := wayland.NewDataDeviceManager(d.Context())
44 dataVersion := bind(wayland.DataDeviceManagerInterface, 4, dataManager)
45 vp := viewporter.NewWpViewporter(d.Context())
46 bind(viewporter.WpViewporterInterface, 1, vp)
47 if g, ok := d.Registry().FindGlobal(fractionalscale.WpFractionalScaleManagerInterface); ok {
48 fs := fractionalscale.NewWpFractionalScaleManager(d.Context())
49 v := bind(g.Interface, 1, fs)
50 if v != 1 {
51 t.Fatalf("fractional scale version %d", v)
52 }
53 noerr(t, fs.Destroy())
54 } else {
55 t.Log("fractional scale absent")
56 }
57 surface, e := compositor.CreateSurface()
58 noerr(t, e)
59 xs, e := wm.GetXdgSurface(surface)
60 noerr(t, e)
61 top, e := xs.GetToplevel()
62 noerr(t, e)
63 feedback, e := dm.GetSurfaceFeedback(surface)
64 noerr(t, e)
65 ss, e := syncManager.GetSurface(surface)
66 noerr(t, e)
67 configured, done, mainDevice, tableOK := false, false, false, false
68 top.OnConfigure(func(w, h int32, states []byte) { t.Logf("xdg_toplevel.configure %dx%d", w, h) })
69 xs.OnConfigure(func(serial uint32) {
70 noerr(t, xs.AckConfigure(serial))
71 configured = true
72 t.Logf("xdg_surface.configure ack=%d", serial)
73 })
74 feedback.OnMainDevice(func(b []byte) { mainDevice = len(b) == 8; t.Logf("main_device=%x", b) })
75 feedback.OnFormatTable(func(fd *wlturbo.OwnedFD, size uint32) {
76 entries, e := linuxdmabuf.ReadFormatTable(fd, size)
77 if e != nil {
78 t.Errorf("format table: %v", e)
79 } else {
80 tableOK = len(entries) > 0
81 t.Logf("format-table entries=%d", len(entries))
82 }
83 })
84 feedback.OnDone(func() { done = true })
85 // Initial empty commit requests xdg configure; no buffer or window is shown.
86 noerr(t, surface.Commit())
87 for (!configured || !done) && ctx.Err() == nil {
88 if e := d.Dispatch(); e != nil {
89 t.Fatalf("dispatch: %v\n%s", e, h.Log())
90 }
91 }
92 if !configured || !done || !mainDevice || !tableOK {
93 t.Fatalf("configure=%t feedback=%t main_device=%t table=%t: %v\n%s", configured, done, mainDevice, tableOK, ctx.Err(), h.Log())
94 }
95 noerr(t, surface.Commit())
96 // Drop all child objects while their parents are still alive.
97 noerr(t, ss.Destroy())
98 noerr(t, feedback.Destroy())
99 noerr(t, top.Destroy())
100 noerr(t, xs.Destroy())
101 noerr(t, surface.Destroy())
102 noerr(t, vp.Destroy())
103 if dataVersion >= 4 {
104 noerr(t, dataManager.Release())
105 }
106 noerr(t, syncManager.Destroy())
107 noerr(t, dm.Destroy())
108 noerr(t, wm.Destroy())
109 noerr(t, d.Close())
110}
111
112// headlessInstance is one isolated NeferWL process with its own runtime,
113// config, data and state directories. Cleanup kills the whole process group
114// and reaps it. ctx expires at the test deadline or when the process exits, so
115// a blocking Dispatch on a connection made by connect always returns.
116type headlessInstance struct {
117 ctx context.Context
118 socket string
119 logf *os.File
120 done chan struct{} // closed after the process is reaped
121 waitErr error // valid once done is closed
122}
123
124// startHeadless launches WLTURBO_HEADLESS or skips the test when it is unset.
125// It returns once the compositor socket exists, or fails with the process log
126// if the process exits early or the 18s test deadline expires.
127func startHeadless(t *testing.T) *headlessInstance {
128 t.Helper()
129 binary := os.Getenv("WLTURBO_HEADLESS")
130 if binary == "" {
131 t.Skip("set WLTURBO_HEADLESS to a NeferWL binary")
132 }
133 binary, e := filepath.Abs(binary)
134 noerr(t, e)
135 root := t.TempDir()
136 dirs := map[string]string{}
137 for _, name := range []string{"run", "config", "data", "state"} {
138 dirs[name] = filepath.Join(root, name)
139 noerr(t, os.Mkdir(dirs[name], 0700))
140 }
141 ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second)
142 t.Cleanup(cancel)
143 logf, e := os.Create(filepath.Join(root, "neferwl.log"))
144 noerr(t, e)
145 t.Cleanup(func() { _ = logf.Close() })
146 h := &headlessInstance{logf: logf, done: make(chan struct{})}
147 // Watch the runtime directory before starting so the socket creation cannot be missed.
148 // Non-blocking makes os.File use the poller, so Close unblocks the reader goroutine.
149 ifd, e := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC)
150 noerr(t, e)
151 watch := os.NewFile(uintptr(ifd), "inotify")
152 t.Cleanup(func() { _ = watch.Close() })
153 _, e = unix.InotifyAddWatch(ifd, dirs["run"], unix.IN_CREATE|unix.IN_ATTRIB)
154 noerr(t, e)
155 cmd := exec.Command(binary, "--backend=headless", "--no-terminal", "--no-xwayland", "--size", "640x480", "--timeout", "20s")
156 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
157 cmd.Env = append(cleanDisplayEnv(os.Environ()),
158 "XDG_RUNTIME_DIR="+dirs["run"], "XDG_CONFIG_HOME="+dirs["config"],
159 "XDG_DATA_HOME="+dirs["data"], "XDG_STATE_HOME="+dirs["state"])
160 cmd.Stdout = logf
161 cmd.Stderr = logf
162 noerr(t, cmd.Start())
163 // A failed startup must still terminate the process group and reap it.
164 t.Cleanup(func() {
165 _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
166 <-h.done
167 })
168 // Process exit cancels ctx, which also unblocks any Dispatch (see connect).
169 ctx, stopCtx := context.WithCancel(ctx)
170 t.Cleanup(stopCtx)
171 h.ctx = ctx
172 go func() {
173 h.waitErr = cmd.Wait()
174 close(h.done)
175 stopCtx()
176 }()
177 events := make(chan struct{}, 1)
178 go func() {
179 buf := make([]byte, 4096)
180 for {
181 if _, e := watch.Read(buf); e != nil {
182 return
183 }
184 select {
185 case events <- struct{}{}:
186 default:
187 }
188 }
189 }()
190 find := func() string {
191 paths, _ := filepath.Glob(filepath.Join(dirs["run"], "wayland-*"))
192 for _, path := range paths {
193 if info, e := os.Stat(path); e == nil && info.Mode()&os.ModeSocket != 0 {
194 return path
195 }
196 }
197 return ""
198 }
199 for h.socket = find(); h.socket == ""; h.socket = find() {
200 select {
201 case <-events:
202 case <-h.done:
203 t.Fatalf("neferwl exited before serving: %v\n%s", h.waitErr, h.Log())
204 case <-ctx.Done():
205 t.Fatalf("headless socket timeout: %v\n%s", ctx.Err(), h.Log())
206 }
207 }
208 return h
209}
210
211// Log returns the tail of the compositor output.
212func (h *headlessInstance) Log() string { return headlessLog(h.logf) }
213
214// connect dials the compositor and completes the initial registry roundtrip.
215// The instance context closes the connection when the test deadline expires or
216// the compositor exits, so a blocking Dispatch returns instead of hanging.
217func (h *headlessInstance) connect(t *testing.T) *wlturbo.Display {
218 t.Helper()
219 var conn net.Conn
220 for {
221 var e error
222 conn, e = (&net.Dialer{Timeout: time.Second}).DialContext(h.ctx, "unix", h.socket)
223 if e == nil {
224 break
225 }
226 // The socket file appears at bind(); listen() follows immediately, so
227 // a refused dial is transient unless the compositor is gone.
228 if !errors.Is(e, syscall.ECONNREFUSED) {
229 t.Fatalf("dial: %v\n%s", e, h.Log())
230 }
231 select {
232 case <-h.done:
233 t.Fatalf("neferwl exited before accepting: %v\n%s", h.waitErr, h.Log())
234 case <-h.ctx.Done():
235 t.Fatalf("dial timeout: %v\n%s", h.ctx.Err(), h.Log())
236 case <-time.After(5 * time.Millisecond):
237 }
238 }
239 d, e := wlturbo.ConnectFromConn(conn)
240 if e != nil {
241 _ = conn.Close()
242 t.Fatal(e)
243 }
244 t.Cleanup(func() { _ = d.Close() })
245 stop := context.AfterFunc(h.ctx, func() { _ = d.Close() })
246 t.Cleanup(func() { stop() })
247 h.sync(t, d, "registry roundtrip")
248 return d
249}
250
251// binder returns a bind function that fails the test with the compositor log.
252func (h *headlessInstance) binder(t *testing.T, d *wlturbo.Display) func(string, uint32, wlturbo.Proxy) uint32 {
253 return func(iface string, supported uint32, proxy wlturbo.Proxy) uint32 {
254 t.Helper()
255 v, err := d.Registry().BindNegotiated(iface, supported, proxy)
256 if err != nil {
257 t.Fatalf("bind %s: %v\n%s", iface, err, h.Log())
258 }
259 t.Logf("%s negotiated=%d", iface, v)
260 return v
261 }
262}
263
264// sync completes a roundtrip, reporting compositor protocol errors with its log.
265func (h *headlessInstance) sync(t *testing.T, d *wlturbo.Display, what string) {
266 t.Helper()
267 if e := d.Roundtrip(); e != nil {
268 t.Fatalf("%s: %v (ctx %v)\n%s", what, e, h.ctx.Err(), h.Log())
269 }
270}
271
272// headlessEnvDrop lists the environment prefixes the child must not inherit:
273// anything that could attach it to, or make it act on, the caller's session,
274// plus NeferWL's own configuration. The test client's own environment is not
275// changed; only the child process is isolated.
276var headlessEnvDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=",
277 "XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=",
278 "XDG_CURRENT_DESKTOP=", "NIRI_SOCKET=", "SWAYSOCK=", "HYPRLAND_INSTANCE_SIGNATURE=", "NEFERWL_"}
279
280func cleanDisplayEnv(in []string) []string {
281 out := make([]string, 0, len(in))
282next:
283 for _, e := range in {
284 for _, p := range headlessEnvDrop {
285 if strings.HasPrefix(e, p) {
286 continue next
287 }
288 }
289 out = append(out, e)
290 }
291 return out
292}
293
294func TestCleanDisplayEnv(t *testing.T) {
295 keep := []string{"PATH=/bin", "HOME=/h", "DISPLAYS=x", "XDG_DATA_DIRS=/d", "MY_NEFERWL_X=1"}
296 in := append([]string(nil), keep...)
297 for _, p := range headlessEnvDrop {
298 e := p + "placeholder"
299 if !strings.HasSuffix(p, "=") {
300 e += "=1" // a prefix such as XDG_SESSION_ names a family of variables
301 }
302 in = append(in, e)
303 }
304 got := cleanDisplayEnv(in)
305 if strings.Join(got, "\n") != strings.Join(keep, "\n") {
306 t.Fatalf("cleanDisplayEnv = %q, want %q", got, keep)
307 }
308}
309
310func headlessLog(f *os.File) string {
311 b, e := os.ReadFile(f.Name())
312 if e != nil {
313 return fmt.Sprint(e)
314 }
315 if len(b) > 8192 {
316 b = b[len(b)-8192:]
317 }
318 return string(b)
319}