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/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 if n, err := unix.Read(fd, make([]byte, 1)); n != 0 || err != nil {
84 t.Fatalf("read %d %v, want EOF", n, err)
85 }
86 cancel()
87 select {
88 case err := <-done:
89 if err != nil {
90 t.Fatal(err)
91 }
92 case <-time.After(3 * time.Second):
93 t.Fatal("Run did not stop")
94 }
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.
99func 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
110func 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.
124func TestRunStartsSatelliteOnConnect(t *testing.T) {
125 opts := testOptions(t, "")
126 var runs string
127 opts.Binary, 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
161func 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 if b, _ := os.ReadFile(d.lock); strings.TrimSpace(string(b)) != strconv.Itoa(os.Getpid()) {
180 t.Fatalf("lock holds %q", b)
181 }
182}
183
184func TestDirChecks(t *testing.T) {
185 for name, setup := range map[string]func(dir string) error{
186 "not sticky": func(dir string) error { return os.Mkdir(dir, 0o777) },
187 "symlink": func(dir string) error {
188 target := dir + "-target"
189 if err := os.Mkdir(target, 0o1777); err != nil {
190 return err
191 }
192 return os.Symlink(target, dir)
193 },
194 "file": func(dir string) error { return os.WriteFile(dir, nil, 0o777) },
195 } {
196 t.Run(name, func(t *testing.T) {
197 opts := testOptions(t, "true")
198 if err := setup(opts.Dir); err != nil {
199 t.Fatal(err)
200 }
201 _ = os.Chmod(opts.Dir, 0o777|os.ModeSticky)
202 if name == "not sticky" {
203 _ = os.Chmod(opts.Dir, 0o777)
204 }
205 if _, err := Open(opts); err == nil {
206 t.Fatal("opened an unsafe X11 directory")
207 }
208 })
209 }
210}
211
212func TestSupported(t *testing.T) {
213 if !Supported(context.Background(), "true") || Supported(context.Background(), "false") {
214 t.Fatal("exit status not honoured")
215 }
216 hang := filepath.Join(t.TempDir(), "hang")
217 if err := os.WriteFile(hang, []byte("#!/bin/sh\nexec sleep 10\n"), 0o755); err != nil {
218 t.Fatal(err)
219 }
220 start := time.Now()
221 if Supported(context.Background(), hang) || time.Since(start) > 3*time.Second {
222 t.Fatalf("hanging binary: %v", time.Since(start))
223 }
224}
225
226// Stopping kills the satellite's whole process group, even when a child
227// keeps its stderr open.
228func TestRunKillsSatelliteGroup(t *testing.T) {
229 opts := testOptions(t, "")
230 pidfile := filepath.Join(opts.TmpDir, "pid")
231 opts.Binary = filepath.Join(opts.TmpDir, "satellite")
232 script := "#!/bin/sh\ntrap '' TERM\nsleep 30 &\necho $! > " + pidfile + "\nwait\n"
233 if err := os.WriteFile(opts.Binary, []byte(script), 0o755); err != nil {
234 t.Fatal(err)
235 }
236 d, err := Open(opts)
237 if err != nil {
238 t.Fatal(err)
239 }
240 defer d.Close()
241 ctx, cancel := context.WithCancel(context.Background())
242 done := make(chan error, 1)
243 go func() { done <- d.Run(ctx) }()
244 unix.Close(dial(t, d.socket))
245 var pid int
246 for deadline := time.Now().Add(3 * time.Second); pid == 0 && time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
247 b, _ := os.ReadFile(pidfile)
248 pid, _ = strconv.Atoi(strings.TrimSpace(string(b)))
249 }
250 if pid == 0 {
251 t.Fatal("satellite did not start")
252 }
253 cancel()
254 select {
255 case <-done:
256 case <-time.After(5 * time.Second):
257 t.Fatal("Run did not stop")
258 }
259 for deadline := time.Now().Add(2 * time.Second); ; time.Sleep(20 * time.Millisecond) {
260 if errors.Is(unix.Kill(pid, 0), unix.ESRCH) {
261 break
262 }
263 if time.Now().After(deadline) {
264 t.Fatalf("child %d survived", pid)
265 }
266 }
267}