1package xwayland
2
3import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "strconv"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/bnema/neferwl/internal/adapters/logging"
14 "golang.org/x/sys/unix"
15)
16
17func testOptions(t *testing.T, binary string) Options {
18 t.Helper()
19 tmp := t.TempDir()
20 if err := os.Chmod(tmp, 0o1777); err != nil {
21 t.Fatal(err)
22 }
23 return Options{Binary: binary, Dir: filepath.Join(tmp, ".X11-unix"), TmpDir: tmp, Log: logging.For(context.Background(), "xwayland")}
24}
25
26func dial(t *testing.T, path string) int {
27 t.Helper()
28 fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
29 if err != nil {
30 t.Fatal(err)
31 }
32 if err := unix.Connect(fd, &unix.SockaddrUnix{Name: path}); err != nil {
33 t.Fatal(err)
34 }
35 return fd
36}
37
38func TestOpenPicksFreeDisplay(t *testing.T) {
39 opts := testOptions(t, "true")
40 if err := os.WriteFile(filepath.Join(opts.TmpDir, ".X0-lock"), nil, 0o444); err != nil {
41 t.Fatal(err)
42 }
43 d, err := Open(opts)
44 if err != nil {
45 t.Fatal(err)
46 }
47 if d.Name() != ":1" {
48 t.Fatalf("display %s", d.Name())
49 }
50 lock, err := os.ReadFile(filepath.Join(opts.TmpDir, ".X1-lock"))
51 if err != nil || strings.TrimSpace(string(lock)) != strconv.Itoa(os.Getpid()) {
52 t.Fatalf("lock %q %v", lock, err)
53 }
54 st, err := os.Stat(opts.Dir)
55 if err != nil || st.Mode()&os.ModeSticky == 0 || st.Mode().Perm() != 0o777 {
56 t.Fatalf("dir %v %v", st.Mode(), err)
57 }
58 d.Close()
59 for _, p := range []string{filepath.Join(opts.Dir, "X1"), filepath.Join(opts.TmpDir, ".X1-lock")} {
60 if _, err := os.Stat(p); !os.IsNotExist(err) {
61 t.Fatalf("%s left behind: %v", p, err)
62 }
63 }
64}
65
66// A satellite that exits without accepting drops the waiting client and is
67// not respawned until another client connects.
68func TestRunDropsClientsOfFailedSatellite(t *testing.T) {
69 d, err := Open(testOptions(t, "false"))
70 if err != nil {
71 t.Fatal(err)
72 }
73 defer d.Close()
74 ctx, cancel := context.WithCancel(context.Background())
75 done := make(chan error, 1)
76 go func() { done <- d.Run(ctx) }()
77 fd := dial(t, d.socket)
78 defer unix.Close(fd)
79 // The dropped connection reads EOF.
80 if err := unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &unix.Timeval{Sec: 3}); err != nil {
81 t.Fatal(err)
82 }
83 // Go runtime signals (preemption) can interrupt the raw read: retry.
84 n, err := unix.Read(fd, make([]byte, 1))
85 for errors.Is(err, unix.EINTR) {
86 n, err = unix.Read(fd, make([]byte, 1))
87 }
88 if n != 0 || err != nil {
89 t.Fatalf("read %d %v, want EOF", n, err)
90 }
91 cancel()
92 select {
93 case err := <-done:
94 if err != nil {
95 t.Fatal(err)
96 }
97 case <-time.After(3 * time.Second):
98 t.Fatal("Run did not stop")
99 }
100}
101
102// satelliteScript writes a real script that records each run and whether it
103// received listening sockets on fds 3 and 4, then exits.
104func satelliteScript(t *testing.T, dir string) (string, string) {
105 t.Helper()
106 runs := filepath.Join(dir, "runs")
107 bin := filepath.Join(dir, "satellite")
108 script := "#!/bin/sh\n[ -S /proc/self/fd/3 ] && [ -S /proc/self/fd/4 ] && echo \"$*\" >> " + runs + "\n"
109 if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
110 t.Fatal(err)
111 }
112 return bin, runs
113}
114
115func readRuns(t *testing.T, path string, want int) []string {
116 t.Helper()
117 deadline := time.Now().Add(3 * time.Second)
118 for {
119 b, _ := os.ReadFile(path)
120 lines := strings.Fields(strings.ReplaceAll(string(b), " ", "_"))
121 if len(lines) >= want || time.Now().After(deadline) {
122 return lines
123 }
124 time.Sleep(20 * time.Millisecond)
125 }
126}
127
128// Each connection starts the satellite once, with both sockets handed over.
129func TestRunStartsSatelliteOnConnect(t *testing.T) {
130 opts := testOptions(t, "")
131 var runs string
132 opts.Binary, runs = satelliteScript(t, opts.TmpDir)
133 opts.Abstract = true
134 d, err := Open(opts)
135 if err != nil {
136 t.Fatal(err)
137 }
138 defer d.Close()
139 ctx, cancel := context.WithCancel(context.Background())
140 done := make(chan error, 1)
141 go func() { done <- d.Run(ctx) }()
142 time.Sleep(100 * time.Millisecond)
143 if got := readRuns(t, runs, 0); len(got) != 0 {
144 t.Fatalf("started without a client: %v", got)
145 }
146 unix.Close(dial(t, d.socket))
147 want := d.Name() + "_-listenfd_3_-listenfd_4"
148 if got := readRuns(t, runs, 1); len(got) != 1 || got[0] != want {
149 t.Fatalf("runs %v, want [%s]", got, want)
150 }
151 // The next client, after the backoff, starts it again.
152 time.Sleep(1200 * time.Millisecond)
153 if got := readRuns(t, runs, 1); len(got) != 1 {
154 t.Fatalf("respawned without a client: %v", got)
155 }
156 unix.Close(dial(t, "\x00"+d.socket))
157 if got := readRuns(t, runs, 2); len(got) != 2 {
158 t.Fatalf("runs %v after the second client", got)
159 }
160 cancel()
161 if err := <-done; err != nil {
162 t.Fatal(err)
163 }
164}
165
166func TestStaleLockReclaimed(t *testing.T) {
167 opts := testOptions(t, "true")
168 // PID 0x7ffffff0 does not exist.
169 if err := os.WriteFile(filepath.Join(opts.TmpDir, ".X0-lock"), []byte("2147483632\n"), 0o444); err != nil {
170 t.Fatal(err)
171 }
172 // A live process keeps its display.
173 if err := os.WriteFile(filepath.Join(opts.TmpDir, ".X1-lock"), []byte(strconv.Itoa(os.Getpid())), 0o444); err != nil {
174 t.Fatal(err)
175 }
176 d, err := Open(opts)
177 if err != nil {
178 t.Fatal(err)
179 }
180 defer d.Close()
181 if d.Name() != ":0" {
182 t.Fatalf("display %s", d.Name())
183 }
184 if b, _ := os.ReadFile(d.lock); strings.TrimSpace(string(b)) != strconv.Itoa(os.Getpid()) {
185 t.Fatalf("lock holds %q", b)
186 }
187}
188
189func TestDirChecks(t *testing.T) {
190 for name, setup := range map[string]func(dir string) error{
191 "not sticky": func(dir string) error { return os.Mkdir(dir, 0o777) },
192 "symlink": func(dir string) error {
193 target := dir + "-target"
194 if err := os.Mkdir(target, 0o1777); err != nil {
195 return err
196 }
197 return os.Symlink(target, dir)
198 },
199 "file": func(dir string) error { return os.WriteFile(dir, nil, 0o777) },
200 } {
201 t.Run(name, func(t *testing.T) {
202 opts := testOptions(t, "true")
203 if err := setup(opts.Dir); err != nil {
204 t.Fatal(err)
205 }
206 _ = os.Chmod(opts.Dir, 0o777|os.ModeSticky)
207 if name == "not sticky" {
208 _ = os.Chmod(opts.Dir, 0o777)
209 }
210 if _, err := Open(opts); err == nil {
211 t.Fatal("opened an unsafe X11 directory")
212 }
213 })
214 }
215}
216
217func TestSupported(t *testing.T) {
218 if !Supported(context.Background(), "true") || Supported(context.Background(), "false") {
219 t.Fatal("exit status not honoured")
220 }
221 hang := filepath.Join(t.TempDir(), "hang")
222 if err := os.WriteFile(hang, []byte("#!/bin/sh\nexec sleep 10\n"), 0o755); err != nil {
223 t.Fatal(err)
224 }
225 start := time.Now()
226 if Supported(context.Background(), hang) || time.Since(start) > 3*time.Second {
227 t.Fatalf("hanging binary: %v", time.Since(start))
228 }
229}
230
231// Stopping kills the satellite's whole process group, even when a child
232// keeps its stderr open.
233func TestRunKillsSatelliteGroup(t *testing.T) {
234 opts := testOptions(t, "")
235 pidfile := filepath.Join(opts.TmpDir, "pid")
236 opts.Binary = filepath.Join(opts.TmpDir, "satellite")
237 script := "#!/bin/sh\ntrap '' TERM\nsleep 30 &\necho $! > " + pidfile + "\nwait\n"
238 if err := os.WriteFile(opts.Binary, []byte(script), 0o755); err != nil {
239 t.Fatal(err)
240 }
241 d, err := Open(opts)
242 if err != nil {
243 t.Fatal(err)
244 }
245 defer d.Close()
246 ctx, cancel := context.WithCancel(context.Background())
247 done := make(chan error, 1)
248 go func() { done <- d.Run(ctx) }()
249 unix.Close(dial(t, d.socket))
250 var pid int
251 for deadline := time.Now().Add(3 * time.Second); pid == 0 && time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
252 b, _ := os.ReadFile(pidfile)
253 pid, _ = strconv.Atoi(strings.TrimSpace(string(b)))
254 }
255 if pid == 0 {
256 t.Fatal("satellite did not start")
257 }
258 cancel()
259 select {
260 case <-done:
261 case <-time.After(5 * time.Second):
262 t.Fatal("Run did not stop")
263 }
264 for deadline := time.Now().Add(2 * time.Second); ; time.Sleep(20 * time.Millisecond) {
265 if errors.Is(unix.Kill(pid, 0), unix.ESRCH) {
266 break
267 }
268 if time.Now().After(deadline) {
269 t.Fatalf("child %d survived", pid)
270 }
271 }
272}