detached 5004296

headless_test.go RawHistoryFolder

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}

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 1

↑↓ moveenter openesc close