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}