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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 90 | 90 | 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 | 95 | ## State for scripts | |
| 96 | 96 | ||
| @@ -90,7 +90,7 @@ xwayland = xwayland-satellite # the default; a path also works | |||||
| 90 | xwayland = off # no X11 display | 90 | 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 scripts | 95 | ## State for scripts | ||
| 96 | 96 | ||||
M internal/adapters/xwayland/xwayland.go +115 -18
| @@ -11,6 +11,8 @@ import ( | |||
| 11 | 11 | "os/exec" | |
| 12 | 12 | "path/filepath" | |
| 13 | 13 | "strconv" | |
| 14 | + | "strings" | |
| 15 | + | "sync" | |
| 14 | 16 | "syscall" | |
| 15 | 17 | "time" | |
| 16 | 18 | ||
| @@ -42,9 +44,11 @@ type Display struct { | |||
| 42 | 44 | } | |
| 43 | 45 | ||
| 44 | 46 | // 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") | |
| 48 | 52 | cmd.Env = []string{} | |
| 49 | 53 | return cmd.Run() == nil | |
| 50 | 54 | } | |
| @@ -54,10 +58,20 @@ func Open(opts Options) (*Display, error) { | |||
| 54 | 58 | if err := ensureDir(opts.Dir, opts.TmpDir); err != nil { | |
| 55 | 59 | return nil, err | |
| 56 | 60 | } | |
| 61 | + | var last error | |
| 57 | 62 | for n := 0; n < 50; n++ { | |
| 58 | 63 | lock := filepath.Join(opts.TmpDir, fmt.Sprintf(".X%d-lock", n)) | |
| 59 | 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 | 71 | if err != nil { | |
| 72 | + | if !errors.Is(err, unix.EEXIST) { | |
| 73 | + | last = fmt.Errorf("create %s: %w", lock, err) | |
| 74 | + | } | |
| 61 | 75 | continue | |
| 62 | 76 | } | |
| 63 | 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 | 83 | d := &Display{opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))} | |
| 70 | 84 | if err := d.listen(); err != nil { | |
| 85 | + | last = err | |
| 71 | 86 | d.Close() | |
| 72 | 87 | continue | |
| 73 | 88 | } | |
| 74 | 89 | return d, nil | |
| 75 | 90 | } | |
| 91 | + | if last != nil { | |
| 92 | + | return nil, fmt.Errorf("no free X11 display: %w", last) | |
| 93 | + | } | |
| 76 | 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 | 110 | // ensureDir creates the X11 socket directory, or checks an existing one is | |
| 80 | 111 | // shared and sticky like /tmp. | |
| 81 | 112 | func ensureDir(dir, tmp string) error { | |
| @@ -94,6 +125,9 @@ func ensureDir(dir, tmp string) error { | |||
| 94 | 125 | if err := unix.Lstat(tmp, &tst); err != nil { | |
| 95 | 126 | 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 | 131 | if st.Uid != tst.Uid && st.Uid != uint32(os.Getuid()) { | |
| 98 | 132 | return fmt.Errorf("%s has the wrong owner", dir) | |
| 99 | 133 | } | |
| @@ -152,16 +186,40 @@ func (d *Display) Close() { | |||
| 152 | 186 | // running, until ctx ends. The satellite takes over the sockets and accepts | |
| 153 | 187 | // the waiting clients itself. | |
| 154 | 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 | 205 | 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 | |
| 158 | 212 | } | |
| 159 | 213 | start := time.Now() | |
| 160 | 214 | err := d.serve(ctx) | |
| 161 | 215 | if ctx.Err() != nil { | |
| 162 | 216 | 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 | 223 | // Clients the satellite never accepted would wake us at once: a | |
| 166 | 224 | // satellite that cannot start must not respawn in a loop. | |
| 167 | 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. | |
| 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}) | |
| 183 | 241 | } | |
| 184 | 242 | 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 | |
| 188 | 246 | } | |
| 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 | + | } | |
| 191 | 257 | } | |
| 192 | 258 | } | |
| 193 | 259 | return ctx.Err() | |
| @@ -202,6 +268,7 @@ func (d *Display) serve(ctx context.Context) error { | |||
| 202 | 268 | cmd := exec.Command(d.opts.Binary, args...) | |
| 203 | 269 | cmd.Env = d.opts.Env | |
| 204 | 270 | cmd.ExtraFiles = d.sockets | |
| 271 | + | cmd.Stderr = &logWriter{log: d.opts.Log} | |
| 205 | 272 | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | |
| 206 | 273 | if err := cmd.Start(); err != nil { | |
| 207 | 274 | return err | |
| @@ -213,11 +280,13 @@ func (d *Display) serve(ctx context.Context) error { | |||
| 213 | 280 | case err := <-done: | |
| 214 | 281 | return err | |
| 215 | 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 | 286 | select { | |
| 218 | 287 | case <-done: | |
| 219 | 288 | case <-time.After(2 * time.Second): | |
| 220 | - | _ = cmd.Process.Kill() | |
| 289 | + | _ = unix.Kill(pgid, unix.SIGKILL) | |
| 221 | 290 | <-done | |
| 222 | 291 | } | |
| 223 | 292 | return ctx.Err() | |
| @@ -241,3 +310,31 @@ func (d *Display) dropPending() { | |||
| 241 | 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 | + | } | |
| @@ -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 sockets | 46 | // 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() == nil | 53 | 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, err | 59 | 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 | continue | 75 | 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 | continue | 87 | continue | ||
| 73 | } | 88 | } | ||
| 74 | return d, nil | 89 | 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 is | 110 | // 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 err | 126 | 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 accepts | 186 | // 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 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 | |||
| 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 nil | 216 | 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: a | 223 | // 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 err | 245 | + | continue |
| 188 | } | 246 | } | ||
| 189 | - | if n > 0 { | 247 | + | if err != nil { |
| 190 | - | return 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 | + | } | |||
| 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.Env | 269 | cmd.Env = d.opts.Env | ||
| 204 | cmd.ExtraFiles = d.sockets | 270 | 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 err | 274 | return err | ||
| @@ -213,11 +280,13 @@ func (d *Display) serve(ctx context.Context) error { | |||||
| 213 | case err := <-done: | 280 | case err := <-done: | ||
| 214 | return err | 281 | 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 | <-done | 290 | <-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) { | |||
| 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 | 66 | // A satellite that exits without accepting drops the waiting client and is | |
| 76 | 67 | // not respawned until another client connects. | |
| @@ -102,3 +93,125 @@ func TestRunDropsClientsOfFailedSatellite(t *testing.T) { | |||
| 102 | 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 | + | } | |
| @@ -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 is | 66 | // 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) | |||
| 110 | 110 | } else { | |
| 111 | 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 | 114 | if xdisplay != nil { | |
| 115 | 115 | defer xdisplay.Close() | |
| 116 | 116 | childEnv = append(childEnv, "DISPLAY="+xdisplay.Name()) | |
| @@ -181,6 +181,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 181 | 181 | go func() { defer workers.Done(); done <- c.Run(ctx) }() | |
| 182 | 182 | if xdisplay != nil { | |
| 183 | 183 | workers.Add(1) | |
| 184 | + | // X11 failing leaves the Wayland session running; Run logs why. | |
| 184 | 185 | go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }() | |
| 185 | 186 | } | |
| 186 | 187 | if statePath != "" { | |
| @@ -347,12 +348,12 @@ func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configError | |||
| 347 | 348 | ||
| 348 | 349 | // openXwayland reserves an X11 display for xwayland-satellite, or returns | |
| 349 | 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 | 352 | bin := opts.Config.Xwayland | |
| 352 | 353 | if bin == "" || opts.NoXwayland { | |
| 353 | 354 | return nil | |
| 354 | 355 | } | |
| 355 | - | if !xwayland.Supported(bin) { | |
| 356 | + | if !xwayland.Supported(ctx, bin) { | |
| 356 | 357 | log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled") | |
| 357 | 358 | return nil | |
| 358 | 359 | } | |
| @@ -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 returns | 349 | // 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.Xwayland | 352 | bin := opts.Config.Xwayland | ||
| 352 | if bin == "" || opts.NoXwayland { | 353 | if bin == "" || opts.NoXwayland { | ||
| 353 | return nil | 354 | 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 nil | 358 | return nil | ||
| 358 | } | 359 | } | ||