xwayland.go RawHistoryFolder

1// Package xwayland runs X11 clients through xwayland-satellite, started on
2// demand: neferwl owns the X11 display sockets and starts the satellite
3// when the first X11 client connects, and again after it exits.
4package xwayland
5
6import (
7	"context"
8	"errors"
9	"fmt"
10	"os"
11	"os/exec"
12	"path/filepath"
13	"slices"
14	"strconv"
15	"strings"
16	"sync"
17	"syscall"
18	"time"
19
20	"github.com/bnema/zerowrap"
21	"golang.org/x/sys/unix"
22)
23
24// Options configures the X11 display.
25type Options struct {
26	// Binary is the xwayland-satellite executable.
27	Binary string
28	// Env is the satellite's environment; it must hold WAYLAND_DISPLAY.
29	Env []string
30	// Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix
31	// and /tmp outside tests.
32	Dir, TmpDir string
33	// Abstract also listens on the abstract socket, as X servers do.
34	Abstract bool
35	Log      zerowrap.Logger
36}
37
38// Display is an X11 display served by xwayland-satellite.
39type Display struct {
40	opts    Options
41	n       int
42	sockets []*os.File // unix socket, then the abstract one
43	socket  string
44	lock    string
45}
46
47// Supported reports whether the binary can take over listening sockets
48// (xwayland-satellite 0.7 and later). A binary that hangs is unsupported.
49func Supported(ctx context.Context, binary string) bool {
50	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
51	defer cancel()
52	cmd := exec.CommandContext(ctx, binary, ":0", "--test-listenfd-support")
53	cmd.Env = []string{}
54	return cmd.Run() == nil
55}
56
57// Open reserves the first free display number and listens on its sockets.
58func Open(opts Options) (*Display, error) {
59	if err := ensureDir(opts.Dir, opts.TmpDir); err != nil {
60		return nil, err
61	}
62	var last error
63	for n := 0; n < 50; n++ {
64		lock := filepath.Join(opts.TmpDir, fmt.Sprintf(".X%d-lock", n))
65		fd, err := unix.Open(lock, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC, 0o444)
66		if errors.Is(err, unix.EEXIST) && staleLock(lock) {
67			// A crashed session left it: take the display over.
68			opts.Log.Info().Str("lock", lock).Msg("removing stale X11 lock")
69			_ = os.Remove(lock)
70			fd, err = unix.Open(lock, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC, 0o444)
71		}
72		if err != nil {
73			if !errors.Is(err, unix.EEXIST) {
74				last = fmt.Errorf("create %s: %w", lock, err)
75			}
76			continue
77		}
78		_, err = unix.Write(fd, fmt.Appendf(nil, "%10d\n", os.Getpid()))
79		unix.Close(fd)
80		if err != nil {
81			_ = os.Remove(lock)
82			return nil, fmt.Errorf("write X11 lock: %w", err)
83		}
84		d := &Display{opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}
85		if err := d.listen(); err != nil {
86			last = err
87			d.Close()
88			continue
89		}
90		return d, nil
91	}
92	if last != nil {
93		return nil, fmt.Errorf("no free X11 display: %w", last)
94	}
95	return nil, errors.New("no free X11 display")
96}
97
98// staleLock reports whether a lock file names a process that is gone.
99func staleLock(path string) bool {
100	b, err := os.ReadFile(path)
101	if err != nil {
102		return false
103	}
104	pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
105	if err != nil || pid <= 0 {
106		return false
107	}
108	return errors.Is(unix.Kill(pid, 0), unix.ESRCH)
109}
110
111// ensureDir creates the X11 socket directory, or checks an existing one is
112// shared and sticky like /tmp.
113func ensureDir(dir, tmp string) error {
114	err := unix.Mkdir(dir, 0o1777)
115	if err == nil {
116		// The umask may have dropped bits.
117		return unix.Chmod(dir, 0o1777)
118	}
119	if !errors.Is(err, unix.EEXIST) {
120		return fmt.Errorf("create %s: %w", dir, err)
121	}
122	var st, tst unix.Stat_t
123	if err := unix.Lstat(dir, &st); err != nil {
124		return err
125	}
126	if err := unix.Lstat(tmp, &tst); err != nil {
127		return err
128	}
129	if st.Mode&unix.S_IFMT != unix.S_IFDIR {
130		return fmt.Errorf("%s is not a directory", dir)
131	}
132	if st.Uid != tst.Uid && st.Uid != uint32(os.Getuid()) {
133		return fmt.Errorf("%s has the wrong owner", dir)
134	}
135	if st.Mode&0o022 != 0o022 || st.Mode&0o1000 == 0 {
136		return fmt.Errorf("%s must be world-writable and sticky", dir)
137	}
138	return nil
139}
140
141func (d *Display) listen() error {
142	names := []string{d.socket}
143	if d.opts.Abstract {
144		names = append(names, "@"+d.socket)
145	}
146	for _, name := range names {
147		if name == d.socket {
148			_ = os.Remove(name) // a stale socket without a lock
149		}
150		fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
151		if err != nil {
152			return err
153		}
154		f := os.NewFile(uintptr(fd), name)
155		addr := name
156		if addr[0] == '@' {
157			addr = "\x00" + addr[1:]
158		}
159		if err := unix.Bind(fd, &unix.SockaddrUnix{Name: addr}); err != nil {
160			f.Close()
161			return fmt.Errorf("bind %s: %w", name, err)
162		}
163		d.sockets = append(d.sockets, f)
164		if err := unix.Listen(fd, 128); err != nil {
165			return fmt.Errorf("listen %s: %w", name, err)
166		}
167	}
168	return nil
169}
170
171// Name is the DISPLAY value, such as ":1".
172func (d *Display) Name() string { return ":" + strconv.Itoa(d.n) }
173
174// Close stops listening and removes the socket and lock files.
175func (d *Display) Close() {
176	for _, f := range d.sockets {
177		f.Close()
178	}
179	if len(d.sockets) > 0 {
180		_ = os.Remove(d.socket)
181	}
182	d.sockets = nil
183	_ = os.Remove(d.lock)
184}
185
186// Run starts the satellite whenever an X11 client connects while it is not
187// running, until ctx ends. The satellite takes over the sockets and accepts
188// the waiting clients itself.
189func (d *Display) Run(ctx context.Context) error {
190	// The eventfd wakes waitClient when ctx ends; the waker is joined
191	// before the fd closes.
192	wake, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
193	if err != nil {
194		return err
195	}
196	stopWaker := make(chan struct{})
197	var waker sync.WaitGroup
198	waker.Go(func() {
199		select {
200		case <-ctx.Done():
201			_, _ = unix.Write(wake, []byte{1, 0, 0, 0, 0, 0, 0, 0})
202		case <-stopWaker:
203		}
204	})
205	defer func() { close(stopWaker); waker.Wait(); unix.Close(wake) }()
206	for {
207		if err := d.waitClient(ctx, wake); err != nil {
208			if ctx.Err() != nil {
209				return nil
210			}
211			d.opts.Log.Warn().Err(err).Str("display", d.Name()).Msg("X11 display stopped")
212			return err
213		}
214		start := time.Now()
215		stderr := &logWriter{log: d.opts.Log}
216		err := d.serve(ctx, stderr)
217		if ctx.Err() != nil {
218			return nil
219		}
220		if err != nil {
221			d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg("xwayland-satellite exited")
222		} else {
223			d.opts.Log.Info().Str("display", d.Name()).Msg("xwayland-satellite exited")
224		}
225		// Clients the satellite never accepted would wake us at once: a
226		// satellite that cannot start must not respawn in a loop.
227		d.dropPending()
228		if time.Since(start) < time.Second {
229			select {
230			case <-ctx.Done():
231				return nil
232			case <-time.After(time.Second):
233			}
234		}
235	}
236}
237
238// waitClient blocks until a socket has a pending connection or ctx ends.
239func (d *Display) waitClient(ctx context.Context, wake int) error {
240	fds := []unix.PollFd{{Fd: int32(wake), Events: unix.POLLIN}}
241	for _, f := range d.sockets {
242		fds = append(fds, unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN})
243	}
244	for ctx.Err() == nil {
245		_, err := unix.Poll(fds, -1)
246		if errors.Is(err, unix.EINTR) {
247			continue
248		}
249		if err != nil {
250			return fmt.Errorf("poll X11 sockets: %w", err)
251		}
252		for _, p := range fds[1:] {
253			if p.Revents&(unix.POLLERR|unix.POLLNVAL|unix.POLLHUP) != 0 {
254				return fmt.Errorf("X11 socket error (revents %#x)", p.Revents)
255			}
256			if p.Revents&unix.POLLIN != 0 {
257				return nil
258			}
259		}
260	}
261	return ctx.Err()
262}
263
264// serve runs the satellite on the sockets until it exits or ctx ends.
265func (d *Display) serve(ctx context.Context, stderr *logWriter) error {
266	args := []string{d.Name()}
267	for i := range d.sockets {
268		args = append(args, "-listenfd", strconv.Itoa(3+i))
269	}
270	cmd := exec.Command(d.opts.Binary, args...)
271	cmd.Env = d.opts.Env
272	cmd.ExtraFiles = d.sockets
273	cmd.Stderr = stderr
274	// Xwayland inherits the stderr pipe: Wait must not wait for it.
275	cmd.WaitDelay = 2 * time.Second
276	cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
277	if err := cmd.Start(); err != nil {
278		return err
279	}
280	d.opts.Log.Info().Str("display", d.Name()).Int("pid", cmd.Process.Pid).Msg("xwayland-satellite started")
281	done := make(chan error, 1)
282	go func() { done <- cmd.Wait() }()
283	select {
284	case err := <-done:
285		return err
286	case <-ctx.Done():
287		// The satellite leads its session's process group, Xwayland included.
288		pgid := -cmd.Process.Pid
289		_ = unix.Kill(pgid, unix.SIGTERM)
290		select {
291		case <-done:
292		case <-time.After(2 * time.Second):
293			_ = unix.Kill(pgid, unix.SIGKILL)
294			<-done
295		}
296		return ctx.Err()
297	}
298}
299
300// dropPending accepts and closes the connections waiting on the sockets.
301func (d *Display) dropPending() {
302	for _, f := range d.sockets {
303		fd := int(f.Fd())
304		if unix.SetNonblock(fd, true) != nil {
305			continue
306		}
307		for {
308			nfd, _, err := unix.Accept4(fd, unix.SOCK_CLOEXEC)
309			if err != nil {
310				break
311			}
312			unix.Close(nfd)
313		}
314		_ = unix.SetNonblock(fd, false)
315	}
316}
317
318// logWriter logs the satellite's stderr line by line and keeps the last
319// lines for the exit log; exec copies it from a pipe on its own goroutine.
320type logWriter struct {
321	mu   sync.Mutex
322	log  zerowrap.Logger
323	line []byte
324	last []string
325}
326
327// tail is the last lines written.
328func (w *logWriter) tail() []string {
329	w.mu.Lock()
330	defer w.mu.Unlock()
331	return slices.Clone(w.last)
332}
333
334func (w *logWriter) Write(p []byte) (int, error) {
335	w.mu.Lock()
336	defer w.mu.Unlock()
337	w.line = append(w.line, p...)
338	for {
339		i := strings.IndexByte(string(w.line), '\n')
340		if i < 0 {
341			break
342		}
343		if i > 0 {
344			line := string(w.line[:i])
345			w.log.Debug().Str("line", line).Msg("xwayland-satellite")
346			if w.last = append(w.last, line); len(w.last) > 5 {
347				w.last = w.last[1:]
348			}
349		}
350		w.line = w.line[i+1:]
351	}
352	if len(w.line) > 4096 {
353		w.line = w.line[:0]
354	}
355	return len(p), nil
356}

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 25

Tags 10

↑↓ moveenter openesc close