detached 996a519

fix(xwayland): reclaim stale locks, wake on cancel, log satellite, signal its group

commit
996a519403b5882a8fa2bf1d72c8a1110bd32774
author
brice · · 2026-09-25 12:59 UTC
parent
4818522
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +242 -31
M README.md +1 -1
@@ -90,7 +90,7 @@ xwayland = xwayland-satellite # the default; a path also works
9090 xwayland = off # no X11 display
9191 ```
9292
93-Do not start xwayland-satellite yourself with `startup`.
93+Do not start xwayland-satellite yourself with `startup`. Changing `xwayland` takes effect at the next start.
9494
9595 ## State for scripts
9696
@@ -90,7 +90,7 @@ xwayland = xwayland-satellite # the default; a path also works
90 xwayland = off # no X11 display90 xwayland = off # no X11 display
91 ```91 ```
92 92
93-Do not start xwayland-satellite yourself with `startup`.93+Do not start xwayland-satellite yourself with `startup`. Changing `xwayland` takes effect at the next start.
94 94
95 ## State for scripts95 ## State for scripts
96 96
M internal/adapters/xwayland/xwayland.go +115 -18
@@ -11,6 +11,8 @@ import (
1111 "os/exec"
1212 "path/filepath"
1313 "strconv"
14+ "strings"
15+ "sync"
1416 "syscall"
1517 "time"
1618
@@ -42,9 +44,11 @@ type Display struct {
4244 }
4345
4446 // Supported reports whether the binary can take over listening sockets
45-// (xwayland-satellite 0.7 and later).
46-func Supported(binary string) bool {
47- cmd := exec.Command(binary, ":0", "--test-listenfd-support")
47+// (xwayland-satellite 0.7 and later). A binary that hangs is unsupported.
48+func Supported(ctx context.Context, binary string) bool {
49+ ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
50+ defer cancel()
51+ cmd := exec.CommandContext(ctx, binary, ":0", "--test-listenfd-support")
4852 cmd.Env = []string{}
4953 return cmd.Run() == nil
5054 }
@@ -54,10 +58,20 @@ func Open(opts Options) (*Display, error) {
5458 if err := ensureDir(opts.Dir, opts.TmpDir); err != nil {
5559 return nil, err
5660 }
61+ var last error
5762 for n := 0; n < 50; n++ {
5863 lock := filepath.Join(opts.TmpDir, fmt.Sprintf(".X%d-lock", n))
5964 fd, err := unix.Open(lock, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC, 0o444)
65+ if errors.Is(err, unix.EEXIST) && staleLock(lock) {
66+ // A crashed session left it: take the display over.
67+ opts.Log.Info().Str("lock", lock).Msg("removing stale X11 lock")
68+ _ = os.Remove(lock)
69+ fd, err = unix.Open(lock, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC, 0o444)
70+ }
6071 if err != nil {
72+ if !errors.Is(err, unix.EEXIST) {
73+ last = fmt.Errorf("create %s: %w", lock, err)
74+ }
6175 continue
6276 }
6377 _, err = unix.Write(fd, fmt.Appendf(nil, "%10d\n", os.Getpid()))
@@ -68,14 +82,31 @@ func Open(opts Options) (*Display, error) {
6882 }
6983 d := &Display{opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}
7084 if err := d.listen(); err != nil {
85+ last = err
7186 d.Close()
7287 continue
7388 }
7489 return d, nil
7590 }
91+ if last != nil {
92+ return nil, fmt.Errorf("no free X11 display: %w", last)
93+ }
7694 return nil, errors.New("no free X11 display")
7795 }
7896
97+// staleLock reports whether a lock file names a process that is gone.
98+func staleLock(path string) bool {
99+ b, err := os.ReadFile(path)
100+ if err != nil {
101+ return false
102+ }
103+ pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
104+ if err != nil || pid <= 0 {
105+ return false
106+ }
107+ return errors.Is(unix.Kill(pid, 0), unix.ESRCH)
108+}
109+
79110 // ensureDir creates the X11 socket directory, or checks an existing one is
80111 // shared and sticky like /tmp.
81112 func ensureDir(dir, tmp string) error {
@@ -94,6 +125,9 @@ func ensureDir(dir, tmp string) error {
94125 if err := unix.Lstat(tmp, &tst); err != nil {
95126 return err
96127 }
128+ if st.Mode&unix.S_IFMT != unix.S_IFDIR {
129+ return fmt.Errorf("%s is not a directory", dir)
130+ }
97131 if st.Uid != tst.Uid && st.Uid != uint32(os.Getuid()) {
98132 return fmt.Errorf("%s has the wrong owner", dir)
99133 }
@@ -152,16 +186,40 @@ func (d *Display) Close() {
152186 // running, until ctx ends. The satellite takes over the sockets and accepts
153187 // the waiting clients itself.
154188 func (d *Display) Run(ctx context.Context) error {
189+ // The eventfd wakes waitClient when ctx ends; the waker is joined
190+ // before the fd closes.
191+ wake, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
192+ if err != nil {
193+ return err
194+ }
195+ stopWaker := make(chan struct{})
196+ var waker sync.WaitGroup
197+ waker.Go(func() {
198+ select {
199+ case <-ctx.Done():
200+ _, _ = unix.Write(wake, []byte{1, 0, 0, 0, 0, 0, 0, 0})
201+ case <-stopWaker:
202+ }
203+ })
204+ defer func() { close(stopWaker); waker.Wait(); unix.Close(wake) }()
155205 for {
156- if err := d.waitClient(ctx); err != nil {
157- return nil
206+ if err := d.waitClient(ctx, wake); err != nil {
207+ if ctx.Err() != nil {
208+ return nil
209+ }
210+ d.opts.Log.Warn().Err(err).Str("display", d.Name()).Msg("X11 display stopped")
211+ return err
158212 }
159213 start := time.Now()
160214 err := d.serve(ctx)
161215 if ctx.Err() != nil {
162216 return nil
163217 }
164- d.opts.Log.Info().Err(err).Str("display", d.Name()).Msg("xwayland-satellite exited")
218+ if err != nil {
219+ d.opts.Log.Warn().Err(err).Str("display", d.Name()).Msg("xwayland-satellite exited")
220+ } else {
221+ d.opts.Log.Info().Str("display", d.Name()).Msg("xwayland-satellite exited")
222+ }
165223 // Clients the satellite never accepted would wake us at once: a
166224 // satellite that cannot start must not respawn in a loop.
167225 d.dropPending()
@@ -175,19 +233,27 @@ func (d *Display) Run(ctx context.Context) error {
175233 }
176234 }
177235
178-// waitClient blocks until a socket has a pending connection.
179-func (d *Display) waitClient(ctx context.Context) error {
180- fds := make([]unix.PollFd, len(d.sockets))
181- for i, f := range d.sockets {
182- fds[i] = unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN}
236+// waitClient blocks until a socket has a pending connection or ctx ends.
237+func (d *Display) waitClient(ctx context.Context, wake int) error {
238+ fds := []unix.PollFd{{Fd: int32(wake), Events: unix.POLLIN}}
239+ for _, f := range d.sockets {
240+ fds = append(fds, unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN})
183241 }
184242 for ctx.Err() == nil {
185- n, err := unix.Poll(fds, 200)
186- if err != nil && !errors.Is(err, unix.EINTR) {
187- return err
243+ _, err := unix.Poll(fds, -1)
244+ if errors.Is(err, unix.EINTR) {
245+ continue
188246 }
189- if n > 0 {
190- return nil
247+ if err != nil {
248+ return fmt.Errorf("poll X11 sockets: %w", err)
249+ }
250+ for _, p := range fds[1:] {
251+ if p.Revents&(unix.POLLERR|unix.POLLNVAL|unix.POLLHUP) != 0 {
252+ return fmt.Errorf("X11 socket error (revents %#x)", p.Revents)
253+ }
254+ if p.Revents&unix.POLLIN != 0 {
255+ return nil
256+ }
191257 }
192258 }
193259 return ctx.Err()
@@ -202,6 +268,7 @@ func (d *Display) serve(ctx context.Context) error {
202268 cmd := exec.Command(d.opts.Binary, args...)
203269 cmd.Env = d.opts.Env
204270 cmd.ExtraFiles = d.sockets
271+ cmd.Stderr = &logWriter{log: d.opts.Log}
205272 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
206273 if err := cmd.Start(); err != nil {
207274 return err
@@ -213,11 +280,13 @@ func (d *Display) serve(ctx context.Context) error {
213280 case err := <-done:
214281 return err
215282 case <-ctx.Done():
216- _ = cmd.Process.Signal(syscall.SIGTERM)
283+ // The satellite leads its session's process group, Xwayland included.
284+ pgid := -cmd.Process.Pid
285+ _ = unix.Kill(pgid, unix.SIGTERM)
217286 select {
218287 case <-done:
219288 case <-time.After(2 * time.Second):
220- _ = cmd.Process.Kill()
289+ _ = unix.Kill(pgid, unix.SIGKILL)
221290 <-done
222291 }
223292 return ctx.Err()
@@ -241,3 +310,31 @@ func (d *Display) dropPending() {
241310 _ = unix.SetNonblock(fd, false)
242311 }
243312 }
313+
314+// logWriter logs the satellite's stderr line by line; exec copies it from
315+// a pipe on its own goroutine.
316+type logWriter struct {
317+ mu sync.Mutex
318+ log zerowrap.Logger
319+ line []byte
320+}
321+
322+func (w *logWriter) Write(p []byte) (int, error) {
323+ w.mu.Lock()
324+ defer w.mu.Unlock()
325+ w.line = append(w.line, p...)
326+ for {
327+ i := strings.IndexByte(string(w.line), '\n')
328+ if i < 0 {
329+ break
330+ }
331+ if i > 0 {
332+ w.log.Debug().Str("line", string(w.line[:i])).Msg("xwayland-satellite")
333+ }
334+ w.line = w.line[i+1:]
335+ }
336+ if len(w.line) > 4096 {
337+ w.line = w.line[:0]
338+ }
339+ return len(p), nil
340+}
@@ -11,6 +11,8 @@ import (
11 "os/exec"11 "os/exec"
12 "path/filepath"12 "path/filepath"
13 "strconv"13 "strconv"
14+ "strings"
15+ "sync"
14 "syscall"16 "syscall"
15 "time"17 "time"
16 18
@@ -42,9 +44,11 @@ type Display struct {
42 }44 }
43 45
44 // Supported reports whether the binary can take over listening sockets46 // Supported reports whether the binary can take over listening sockets
45-// (xwayland-satellite 0.7 and later).47+// (xwayland-satellite 0.7 and later). A binary that hangs is unsupported.
46-func Supported(binary string) bool {48+func Supported(ctx context.Context, binary string) bool {
47- cmd := exec.Command(binary, ":0", "--test-listenfd-support")49+ ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
50+ defer cancel()
51+ cmd := exec.CommandContext(ctx, binary, ":0", "--test-listenfd-support")
48 cmd.Env = []string{}52 cmd.Env = []string{}
49 return cmd.Run() == nil53 return cmd.Run() == nil
50 }54 }
@@ -54,10 +58,20 @@ func Open(opts Options) (*Display, error) {
54 if err := ensureDir(opts.Dir, opts.TmpDir); err != nil {58 if err := ensureDir(opts.Dir, opts.TmpDir); err != nil {
55 return nil, err59 return nil, err
56 }60 }
61+ var last error
57 for n := 0; n < 50; n++ {62 for n := 0; n < 50; n++ {
58 lock := filepath.Join(opts.TmpDir, fmt.Sprintf(".X%d-lock", n))63 lock := filepath.Join(opts.TmpDir, fmt.Sprintf(".X%d-lock", n))
59 fd, err := unix.Open(lock, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC, 0o444)64 fd, err := unix.Open(lock, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC, 0o444)
65+ if errors.Is(err, unix.EEXIST) && staleLock(lock) {
66+ // A crashed session left it: take the display over.
67+ opts.Log.Info().Str("lock", lock).Msg("removing stale X11 lock")
68+ _ = os.Remove(lock)
69+ fd, err = unix.Open(lock, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC, 0o444)
70+ }
60 if err != nil {71 if err != nil {
72+ if !errors.Is(err, unix.EEXIST) {
73+ last = fmt.Errorf("create %s: %w", lock, err)
74+ }
61 continue75 continue
62 }76 }
63 _, err = unix.Write(fd, fmt.Appendf(nil, "%10d\n", os.Getpid()))77 _, err = unix.Write(fd, fmt.Appendf(nil, "%10d\n", os.Getpid()))
@@ -68,14 +82,31 @@ func Open(opts Options) (*Display, error) {
68 }82 }
69 d := &Display{opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}83 d := &Display{opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}
70 if err := d.listen(); err != nil {84 if err := d.listen(); err != nil {
85+ last = err
71 d.Close()86 d.Close()
72 continue87 continue
73 }88 }
74 return d, nil89 return d, nil
75 }90 }
91+ if last != nil {
92+ return nil, fmt.Errorf("no free X11 display: %w", last)
93+ }
76 return nil, errors.New("no free X11 display")94 return nil, errors.New("no free X11 display")
77 }95 }
78 96
97+// staleLock reports whether a lock file names a process that is gone.
98+func staleLock(path string) bool {
99+ b, err := os.ReadFile(path)
100+ if err != nil {
101+ return false
102+ }
103+ pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
104+ if err != nil || pid <= 0 {
105+ return false
106+ }
107+ return errors.Is(unix.Kill(pid, 0), unix.ESRCH)
108+}
109+
79 // ensureDir creates the X11 socket directory, or checks an existing one is110 // ensureDir creates the X11 socket directory, or checks an existing one is
80 // shared and sticky like /tmp.111 // shared and sticky like /tmp.
81 func ensureDir(dir, tmp string) error {112 func ensureDir(dir, tmp string) error {
@@ -94,6 +125,9 @@ func ensureDir(dir, tmp string) error {
94 if err := unix.Lstat(tmp, &tst); err != nil {125 if err := unix.Lstat(tmp, &tst); err != nil {
95 return err126 return err
96 }127 }
128+ if st.Mode&unix.S_IFMT != unix.S_IFDIR {
129+ return fmt.Errorf("%s is not a directory", dir)
130+ }
97 if st.Uid != tst.Uid && st.Uid != uint32(os.Getuid()) {131 if st.Uid != tst.Uid && st.Uid != uint32(os.Getuid()) {
98 return fmt.Errorf("%s has the wrong owner", dir)132 return fmt.Errorf("%s has the wrong owner", dir)
99 }133 }
@@ -152,16 +186,40 @@ func (d *Display) Close() {
152 // running, until ctx ends. The satellite takes over the sockets and accepts186 // running, until ctx ends. The satellite takes over the sockets and accepts
153 // the waiting clients itself.187 // the waiting clients itself.
154 func (d *Display) Run(ctx context.Context) error {188 func (d *Display) Run(ctx context.Context) error {
189+ // The eventfd wakes waitClient when ctx ends; the waker is joined
190+ // before the fd closes.
191+ wake, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
192+ if err != nil {
193+ return err
194+ }
195+ stopWaker := make(chan struct{})
196+ var waker sync.WaitGroup
197+ waker.Go(func() {
198+ select {
199+ case <-ctx.Done():
200+ _, _ = unix.Write(wake, []byte{1, 0, 0, 0, 0, 0, 0, 0})
201+ case <-stopWaker:
202+ }
203+ })
204+ defer func() { close(stopWaker); waker.Wait(); unix.Close(wake) }()
155 for {205 for {
156- if err := d.waitClient(ctx); err != nil {206+ if err := d.waitClient(ctx, wake); err != nil {
157- return nil207+ if ctx.Err() != nil {
208+ return nil
209+ }
210+ d.opts.Log.Warn().Err(err).Str("display", d.Name()).Msg("X11 display stopped")
211+ return err
158 }212 }
159 start := time.Now()213 start := time.Now()
160 err := d.serve(ctx)214 err := d.serve(ctx)
161 if ctx.Err() != nil {215 if ctx.Err() != nil {
162 return nil216 return nil
163 }217 }
164- d.opts.Log.Info().Err(err).Str("display", d.Name()).Msg("xwayland-satellite exited")218+ if err != nil {
219+ d.opts.Log.Warn().Err(err).Str("display", d.Name()).Msg("xwayland-satellite exited")
220+ } else {
221+ d.opts.Log.Info().Str("display", d.Name()).Msg("xwayland-satellite exited")
222+ }
165 // Clients the satellite never accepted would wake us at once: a223 // Clients the satellite never accepted would wake us at once: a
166 // satellite that cannot start must not respawn in a loop.224 // satellite that cannot start must not respawn in a loop.
167 d.dropPending()225 d.dropPending()
@@ -175,19 +233,27 @@ func (d *Display) Run(ctx context.Context) error {
175 }233 }
176 }234 }
177 235
178-// waitClient blocks until a socket has a pending connection.236+// waitClient blocks until a socket has a pending connection or ctx ends.
179-func (d *Display) waitClient(ctx context.Context) error {237+func (d *Display) waitClient(ctx context.Context, wake int) error {
180- fds := make([]unix.PollFd, len(d.sockets))238+ fds := []unix.PollFd{{Fd: int32(wake), Events: unix.POLLIN}}
181- for i, f := range d.sockets {239+ for _, f := range d.sockets {
182- fds[i] = unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN}240+ fds = append(fds, unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN})
183 }241 }
184 for ctx.Err() == nil {242 for ctx.Err() == nil {
185- n, err := unix.Poll(fds, 200)243+ _, err := unix.Poll(fds, -1)
186- if err != nil && !errors.Is(err, unix.EINTR) {244+ if errors.Is(err, unix.EINTR) {
187- return err245+ continue
188 }246 }
189- if n > 0 {247+ if err != nil {
190- return nil248+ return fmt.Errorf("poll X11 sockets: %w", err)
249+ }
250+ for _, p := range fds[1:] {
251+ if p.Revents&(unix.POLLERR|unix.POLLNVAL|unix.POLLHUP) != 0 {
252+ return fmt.Errorf("X11 socket error (revents %#x)", p.Revents)
253+ }
254+ if p.Revents&unix.POLLIN != 0 {
255+ return nil
256+ }
191 }257 }
192 }258 }
193 return ctx.Err()259 return ctx.Err()
@@ -202,6 +268,7 @@ func (d *Display) serve(ctx context.Context) error {
202 cmd := exec.Command(d.opts.Binary, args...)268 cmd := exec.Command(d.opts.Binary, args...)
203 cmd.Env = d.opts.Env269 cmd.Env = d.opts.Env
204 cmd.ExtraFiles = d.sockets270 cmd.ExtraFiles = d.sockets
271+ cmd.Stderr = &logWriter{log: d.opts.Log}
205 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}272 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
206 if err := cmd.Start(); err != nil {273 if err := cmd.Start(); err != nil {
207 return err274 return err
@@ -213,11 +280,13 @@ func (d *Display) serve(ctx context.Context) error {
213 case err := <-done:280 case err := <-done:
214 return err281 return err
215 case <-ctx.Done():282 case <-ctx.Done():
216- _ = cmd.Process.Signal(syscall.SIGTERM)283+ // The satellite leads its session's process group, Xwayland included.
284+ pgid := -cmd.Process.Pid
285+ _ = unix.Kill(pgid, unix.SIGTERM)
217 select {286 select {
218 case <-done:287 case <-done:
219 case <-time.After(2 * time.Second):288 case <-time.After(2 * time.Second):
220- _ = cmd.Process.Kill()289+ _ = unix.Kill(pgid, unix.SIGKILL)
221 <-done290 <-done
222 }291 }
223 return ctx.Err()292 return ctx.Err()
@@ -241,3 +310,31 @@ func (d *Display) dropPending() {
241 _ = unix.SetNonblock(fd, false)310 _ = unix.SetNonblock(fd, false)
242 }311 }
243 }312 }
313+
314+// logWriter logs the satellite's stderr line by line; exec copies it from
315+// a pipe on its own goroutine.
316+type logWriter struct {
317+ mu sync.Mutex
318+ log zerowrap.Logger
319+ line []byte
320+}
321+
322+func (w *logWriter) Write(p []byte) (int, error) {
323+ w.mu.Lock()
324+ defer w.mu.Unlock()
325+ w.line = append(w.line, p...)
326+ for {
327+ i := strings.IndexByte(string(w.line), '\n')
328+ if i < 0 {
329+ break
330+ }
331+ if i > 0 {
332+ w.log.Debug().Str("line", string(w.line[:i])).Msg("xwayland-satellite")
333+ }
334+ w.line = w.line[i+1:]
335+ }
336+ if len(w.line) > 4096 {
337+ w.line = w.line[:0]
338+ }
339+ return len(p), nil
340+}
M internal/adapters/xwayland/xwayland_test.go +122 -9
@@ -62,15 +62,6 @@ func TestOpenPicksFreeDisplay(t *testing.T) {
6262 }
6363 }
6464
65-func TestInsecureDirRejected(t *testing.T) {
66- opts := testOptions(t, "true")
67- if err := os.Mkdir(opts.Dir, 0o755); err != nil {
68- t.Fatal(err)
69- }
70- if _, err := Open(opts); err == nil {
71- t.Fatal("opened a non-sticky X11 directory")
72- }
73-}
7465
7566 // A satellite that exits without accepting drops the waiting client and is
7667 // not respawned until another client connects.
@@ -102,3 +93,125 @@ func TestRunDropsClientsOfFailedSatellite(t *testing.T) {
10293 t.Fatal("Run did not stop")
10394 }
10495 }
96+
97+// satelliteScript writes a real script that records each run and whether it
98+// received listening sockets on fds 3 and 4, then exits.
99+func satelliteScript(t *testing.T, dir string) (string, string) {
100+ t.Helper()
101+ runs := filepath.Join(dir, "runs")
102+ bin := filepath.Join(dir, "satellite")
103+ script := "#!/bin/sh\n[ -S /proc/self/fd/3 ] && [ -S /proc/self/fd/4 ] && echo \"$*\" >> " + runs + "\n"
104+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
105+ t.Fatal(err)
106+ }
107+ return bin, runs
108+}
109+
110+func readRuns(t *testing.T, path string, want int) []string {
111+ t.Helper()
112+ deadline := time.Now().Add(3 * time.Second)
113+ for {
114+ b, _ := os.ReadFile(path)
115+ lines := strings.Fields(strings.ReplaceAll(string(b), " ", "_"))
116+ if len(lines) >= want || time.Now().After(deadline) {
117+ return lines
118+ }
119+ time.Sleep(20 * time.Millisecond)
120+ }
121+}
122+
123+// Each connection starts the satellite once, with both sockets handed over.
124+func TestRunStartsSatelliteOnConnect(t *testing.T) {
125+ opts := testOptions(t, "")
126+ opts.Binary, _ = satelliteScript(t, opts.TmpDir)
127+ _, runs := satelliteScript(t, opts.TmpDir)
128+ opts.Abstract = true
129+ d, err := Open(opts)
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ defer d.Close()
134+ ctx, cancel := context.WithCancel(context.Background())
135+ done := make(chan error, 1)
136+ go func() { done <- d.Run(ctx) }()
137+ time.Sleep(100 * time.Millisecond)
138+ if got := readRuns(t, runs, 0); len(got) != 0 {
139+ t.Fatalf("started without a client: %v", got)
140+ }
141+ unix.Close(dial(t, d.socket))
142+ want := d.Name() + "_-listenfd_3_-listenfd_4"
143+ if got := readRuns(t, runs, 1); len(got) != 1 || got[0] != want {
144+ t.Fatalf("runs %v, want [%s]", got, want)
145+ }
146+ // The next client, after the backoff, starts it again.
147+ time.Sleep(1200 * time.Millisecond)
148+ if got := readRuns(t, runs, 1); len(got) != 1 {
149+ t.Fatalf("respawned without a client: %v", got)
150+ }
151+ unix.Close(dial(t, "\x00"+d.socket))
152+ if got := readRuns(t, runs, 2); len(got) != 2 {
153+ t.Fatalf("runs %v after the second client", got)
154+ }
155+ cancel()
156+ if err := <-done; err != nil {
157+ t.Fatal(err)
158+ }
159+}
160+
161+func TestStaleLockReclaimed(t *testing.T) {
162+ opts := testOptions(t, "true")
163+ // PID 0x7ffffff0 does not exist.
164+ if err := os.WriteFile(filepath.Join(opts.TmpDir, ".X0-lock"), []byte("2147483632\n"), 0o444); err != nil {
165+ t.Fatal(err)
166+ }
167+ // A live process keeps its display.
168+ if err := os.WriteFile(filepath.Join(opts.TmpDir, ".X1-lock"), []byte(strconv.Itoa(os.Getpid())), 0o444); err != nil {
169+ t.Fatal(err)
170+ }
171+ d, err := Open(opts)
172+ if err != nil {
173+ t.Fatal(err)
174+ }
175+ defer d.Close()
176+ if d.Name() != ":0" {
177+ t.Fatalf("display %s", d.Name())
178+ }
179+}
180+
181+func TestDirChecks(t *testing.T) {
182+ for name, setup := range map[string]func(dir string) error{
183+ "not sticky": func(dir string) error { return os.Mkdir(dir, 0o777) },
184+ "symlink": func(dir string) error {
185+ target := dir + "-target"
186+ if err := os.Mkdir(target, 0o1777); err != nil {
187+ return err
188+ }
189+ return os.Symlink(target, dir)
190+ },
191+ "file": func(dir string) error { return os.WriteFile(dir, nil, 0o777) },
192+ } {
193+ t.Run(name, func(t *testing.T) {
194+ opts := testOptions(t, "true")
195+ if err := setup(opts.Dir); err != nil {
196+ t.Fatal(err)
197+ }
198+ _ = os.Chmod(opts.Dir, 0o777|os.ModeSticky)
199+ if name == "not sticky" {
200+ _ = os.Chmod(opts.Dir, 0o777)
201+ }
202+ if _, err := Open(opts); err == nil {
203+ t.Fatal("opened an unsafe X11 directory")
204+ }
205+ })
206+ }
207+}
208+
209+func TestSupported(t *testing.T) {
210+ if !Supported(context.Background(), "true") || Supported(context.Background(), "false") {
211+ t.Fatal("exit status not honoured")
212+ }
213+ start := time.Now()
214+ if Supported(context.Background(), "sleep") && time.Since(start) > 3*time.Second {
215+ t.Fatal("hang")
216+ }
217+}
@@ -62,15 +62,6 @@ func TestOpenPicksFreeDisplay(t *testing.T) {
62 }62 }
63 }63 }
64 64
65-func TestInsecureDirRejected(t *testing.T) {
66- opts := testOptions(t, "true")
67- if err := os.Mkdir(opts.Dir, 0o755); err != nil {
68- t.Fatal(err)
69- }
70- if _, err := Open(opts); err == nil {
71- t.Fatal("opened a non-sticky X11 directory")
72- }
73-}
74 65
75 // A satellite that exits without accepting drops the waiting client and is66 // A satellite that exits without accepting drops the waiting client and is
76 // not respawned until another client connects.67 // not respawned until another client connects.
@@ -102,3 +93,125 @@ func TestRunDropsClientsOfFailedSatellite(t *testing.T) {
102 t.Fatal("Run did not stop")93 t.Fatal("Run did not stop")
103 }94 }
104 }95 }
96+
97+// satelliteScript writes a real script that records each run and whether it
98+// received listening sockets on fds 3 and 4, then exits.
99+func satelliteScript(t *testing.T, dir string) (string, string) {
100+ t.Helper()
101+ runs := filepath.Join(dir, "runs")
102+ bin := filepath.Join(dir, "satellite")
103+ script := "#!/bin/sh\n[ -S /proc/self/fd/3 ] && [ -S /proc/self/fd/4 ] && echo \"$*\" >> " + runs + "\n"
104+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
105+ t.Fatal(err)
106+ }
107+ return bin, runs
108+}
109+
110+func readRuns(t *testing.T, path string, want int) []string {
111+ t.Helper()
112+ deadline := time.Now().Add(3 * time.Second)
113+ for {
114+ b, _ := os.ReadFile(path)
115+ lines := strings.Fields(strings.ReplaceAll(string(b), " ", "_"))
116+ if len(lines) >= want || time.Now().After(deadline) {
117+ return lines
118+ }
119+ time.Sleep(20 * time.Millisecond)
120+ }
121+}
122+
123+// Each connection starts the satellite once, with both sockets handed over.
124+func TestRunStartsSatelliteOnConnect(t *testing.T) {
125+ opts := testOptions(t, "")
126+ opts.Binary, _ = satelliteScript(t, opts.TmpDir)
127+ _, runs := satelliteScript(t, opts.TmpDir)
128+ opts.Abstract = true
129+ d, err := Open(opts)
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ defer d.Close()
134+ ctx, cancel := context.WithCancel(context.Background())
135+ done := make(chan error, 1)
136+ go func() { done <- d.Run(ctx) }()
137+ time.Sleep(100 * time.Millisecond)
138+ if got := readRuns(t, runs, 0); len(got) != 0 {
139+ t.Fatalf("started without a client: %v", got)
140+ }
141+ unix.Close(dial(t, d.socket))
142+ want := d.Name() + "_-listenfd_3_-listenfd_4"
143+ if got := readRuns(t, runs, 1); len(got) != 1 || got[0] != want {
144+ t.Fatalf("runs %v, want [%s]", got, want)
145+ }
146+ // The next client, after the backoff, starts it again.
147+ time.Sleep(1200 * time.Millisecond)
148+ if got := readRuns(t, runs, 1); len(got) != 1 {
149+ t.Fatalf("respawned without a client: %v", got)
150+ }
151+ unix.Close(dial(t, "\x00"+d.socket))
152+ if got := readRuns(t, runs, 2); len(got) != 2 {
153+ t.Fatalf("runs %v after the second client", got)
154+ }
155+ cancel()
156+ if err := <-done; err != nil {
157+ t.Fatal(err)
158+ }
159+}
160+
161+func TestStaleLockReclaimed(t *testing.T) {
162+ opts := testOptions(t, "true")
163+ // PID 0x7ffffff0 does not exist.
164+ if err := os.WriteFile(filepath.Join(opts.TmpDir, ".X0-lock"), []byte("2147483632\n"), 0o444); err != nil {
165+ t.Fatal(err)
166+ }
167+ // A live process keeps its display.
168+ if err := os.WriteFile(filepath.Join(opts.TmpDir, ".X1-lock"), []byte(strconv.Itoa(os.Getpid())), 0o444); err != nil {
169+ t.Fatal(err)
170+ }
171+ d, err := Open(opts)
172+ if err != nil {
173+ t.Fatal(err)
174+ }
175+ defer d.Close()
176+ if d.Name() != ":0" {
177+ t.Fatalf("display %s", d.Name())
178+ }
179+}
180+
181+func TestDirChecks(t *testing.T) {
182+ for name, setup := range map[string]func(dir string) error{
183+ "not sticky": func(dir string) error { return os.Mkdir(dir, 0o777) },
184+ "symlink": func(dir string) error {
185+ target := dir + "-target"
186+ if err := os.Mkdir(target, 0o1777); err != nil {
187+ return err
188+ }
189+ return os.Symlink(target, dir)
190+ },
191+ "file": func(dir string) error { return os.WriteFile(dir, nil, 0o777) },
192+ } {
193+ t.Run(name, func(t *testing.T) {
194+ opts := testOptions(t, "true")
195+ if err := setup(opts.Dir); err != nil {
196+ t.Fatal(err)
197+ }
198+ _ = os.Chmod(opts.Dir, 0o777|os.ModeSticky)
199+ if name == "not sticky" {
200+ _ = os.Chmod(opts.Dir, 0o777)
201+ }
202+ if _, err := Open(opts); err == nil {
203+ t.Fatal("opened an unsafe X11 directory")
204+ }
205+ })
206+ }
207+}
208+
209+func TestSupported(t *testing.T) {
210+ if !Supported(context.Background(), "true") || Supported(context.Background(), "false") {
211+ t.Fatal("exit status not honoured")
212+ }
213+ start := time.Now()
214+ if Supported(context.Background(), "sleep") && time.Since(start) > 3*time.Second {
215+ t.Fatal("hang")
216+ }
217+}
M internal/app/app.go +4 -3
@@ -110,7 +110,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
110110 } else {
111111 childEnv = append(childEnv, statefile.Env+"="+statePath)
112112 }
113- xdisplay := openXwayland(opts, childEnv, logging.For(ctx, "xwayland"))
113+ xdisplay := openXwayland(ctx, opts, childEnv, logging.For(ctx, "xwayland"))
114114 if xdisplay != nil {
115115 defer xdisplay.Close()
116116 childEnv = append(childEnv, "DISPLAY="+xdisplay.Name())
@@ -181,6 +181,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
181181 go func() { defer workers.Done(); done <- c.Run(ctx) }()
182182 if xdisplay != nil {
183183 workers.Add(1)
184+ // X11 failing leaves the Wayland session running; Run logs why.
184185 go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }()
185186 }
186187 if statePath != "" {
@@ -347,12 +348,12 @@ func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configError
347348
348349 // openXwayland reserves an X11 display for xwayland-satellite, or returns
349350 // nil when X11 is off or unavailable: the session runs without it.
350-func openXwayland(opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
351+func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
351352 bin := opts.Config.Xwayland
352353 if bin == "" || opts.NoXwayland {
353354 return nil
354355 }
355- if !xwayland.Supported(bin) {
356+ if !xwayland.Supported(ctx, bin) {
356357 log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
357358 return nil
358359 }
@@ -110,7 +110,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
110 } else {110 } else {
111 childEnv = append(childEnv, statefile.Env+"="+statePath)111 childEnv = append(childEnv, statefile.Env+"="+statePath)
112 }112 }
113- xdisplay := openXwayland(opts, childEnv, logging.For(ctx, "xwayland"))113+ xdisplay := openXwayland(ctx, opts, childEnv, logging.For(ctx, "xwayland"))
114 if xdisplay != nil {114 if xdisplay != nil {
115 defer xdisplay.Close()115 defer xdisplay.Close()
116 childEnv = append(childEnv, "DISPLAY="+xdisplay.Name())116 childEnv = append(childEnv, "DISPLAY="+xdisplay.Name())
@@ -181,6 +181,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
181 go func() { defer workers.Done(); done <- c.Run(ctx) }()181 go func() { defer workers.Done(); done <- c.Run(ctx) }()
182 if xdisplay != nil {182 if xdisplay != nil {
183 workers.Add(1)183 workers.Add(1)
184+ // X11 failing leaves the Wayland session running; Run logs why.
184 go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }()185 go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }()
185 }186 }
186 if statePath != "" {187 if statePath != "" {
@@ -347,12 +348,12 @@ func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configError
347 348
348 // openXwayland reserves an X11 display for xwayland-satellite, or returns349 // openXwayland reserves an X11 display for xwayland-satellite, or returns
349 // nil when X11 is off or unavailable: the session runs without it.350 // nil when X11 is off or unavailable: the session runs without it.
350-func openXwayland(opts Options, env []string, log zerowrap.Logger) *xwayland.Display {351+func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
351 bin := opts.Config.Xwayland352 bin := opts.Config.Xwayland
352 if bin == "" || opts.NoXwayland {353 if bin == "" || opts.NoXwayland {
353 return nil354 return nil
354 }355 }
355- if !xwayland.Supported(bin) {356+ if !xwayland.Supported(ctx, bin) {
356 log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")357 log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
357 return nil358 return nil
358 }359 }

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