refactor(xwayland): split attachment into Wayland fd at start and WM fd after readiness
- commit
- 57e2ff1252277284aa800bd7dec844d7b9cb193c
- author
- brice · · 2026-10-22 10:42 UTC
- committer
- brice · · 2026-10-22 17:54 UTC
- parent
- e519f1f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +122 -40
M internal/adapters/wayland/xwayland.go +14 -10
| @@ -5,26 +5,23 @@ import ( | |||
| 5 | 5 | "golang.org/x/sys/unix" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | - | // xwaylandClient belongs to the display goroutine. Phase 6 takes wmFD here | |
| 9 | - | // to run the XWM; until then it is held open without dispatching X11 traffic. | |
| 8 | + | // xwaylandClient belongs to the display goroutine. | |
| 10 | 9 | type xwaylandClient struct { | |
| 11 | 10 | client server.Client | |
| 12 | - | wmFD int | |
| 13 | 11 | } | |
| 14 | 12 | ||
| 15 | - | // AttachXwayland transfers ownership of both descriptors to the display. | |
| 16 | - | // It may be called from any goroutine; Phase 6 takes the XWM fd from here. | |
| 17 | - | func (s *Server) AttachXwayland(wlFD, wmFD int) { | |
| 13 | + | // AttachXwayland transfers ownership of the Wayland descriptor to the display. | |
| 14 | + | // It may be called from any goroutine. | |
| 15 | + | func (s *Server) AttachXwayland(wlFD int) { | |
| 18 | 16 | ran := s.display.Do(func() { | |
| 19 | 17 | c, err := s.display.CreateClient(wlFD) | |
| 20 | 18 | if err != nil { | |
| 21 | 19 | s.log.Warn().Err(err).Msg("Xwayland connection not adopted") | |
| 22 | 20 | _ = unix.Close(wlFD) // CreateClient only adopts on success. | |
| 23 | - | _ = unix.Close(wmFD) | |
| 24 | 21 | return | |
| 25 | 22 | } | |
| 26 | 23 | s.forgetXwayland() | |
| 27 | - | s.xwayland = xwaylandClient{client: c, wmFD: wmFD} | |
| 24 | + | s.xwayland = xwaylandClient{client: c} | |
| 28 | 25 | c.OnDestroy(func() { | |
| 29 | 26 | if s.xwayland.client == c { | |
| 30 | 27 | s.forgetXwayland() | |
| @@ -34,14 +31,21 @@ func (s *Server) AttachXwayland(wlFD, wmFD int) { | |||
| 34 | 31 | }) | |
| 35 | 32 | if !ran { | |
| 36 | 33 | _ = unix.Close(wlFD) | |
| 37 | - | _ = unix.Close(wmFD) | |
| 38 | 34 | } | |
| 39 | 35 | } | |
| 40 | 36 | ||
| 41 | 37 | // forgetXwayland runs on the display owner, or after Run has joined it. | |
| 42 | 38 | func (s *Server) forgetXwayland() { | |
| 43 | 39 | if s.xwayland.client != (server.Client{}) { | |
| 44 | - | _ = unix.Close(s.xwayland.wmFD) | |
| 45 | 40 | s.xwayland = xwaylandClient{} | |
| 46 | 41 | } | |
| 47 | 42 | } | |
| 43 | + | ||
| 44 | + | // AttachXWM takes ownership of the window manager descriptor. It runs on the | |
| 45 | + | // starter goroutine once Xwayland is ready; the XWM is not implemented yet, | |
| 46 | + | // so the descriptor is closed and stop does nothing. | |
| 47 | + | func (s *Server) AttachXWM(wmFD int) (stop func()) { | |
| 48 | + | s.log.Info().Msg("X11 window management not implemented: closing XWM descriptor") | |
| 49 | + | _ = unix.Close(wmFD) | |
| 50 | + | return func() {} | |
| 51 | + | } | |
| @@ -5,26 +5,23 @@ import ( | |||||
| 5 | "golang.org/x/sys/unix" | 5 | "golang.org/x/sys/unix" | ||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | - | // xwaylandClient belongs to the display goroutine. Phase 6 takes wmFD here | 8 | + | // xwaylandClient belongs to the display goroutine. |
| 9 | - | // to run the XWM; until then it is held open without dispatching X11 traffic. | |||
| 10 | type xwaylandClient struct { | 9 | type xwaylandClient struct { | ||
| 11 | client server.Client | 10 | client server.Client | ||
| 12 | - | wmFD int | |||
| 13 | } | 11 | } | ||
| 14 | 12 | ||||
| 15 | - | // AttachXwayland transfers ownership of both descriptors to the display. | 13 | + | // AttachXwayland transfers ownership of the Wayland descriptor to the display. |
| 16 | - | // It may be called from any goroutine; Phase 6 takes the XWM fd from here. | 14 | + | // It may be called from any goroutine. |
| 17 | - | func (s *Server) AttachXwayland(wlFD, wmFD int) { | 15 | + | func (s *Server) AttachXwayland(wlFD int) { |
| 18 | ran := s.display.Do(func() { | 16 | ran := s.display.Do(func() { | ||
| 19 | c, err := s.display.CreateClient(wlFD) | 17 | c, err := s.display.CreateClient(wlFD) | ||
| 20 | if err != nil { | 18 | if err != nil { | ||
| 21 | s.log.Warn().Err(err).Msg("Xwayland connection not adopted") | 19 | s.log.Warn().Err(err).Msg("Xwayland connection not adopted") | ||
| 22 | _ = unix.Close(wlFD) // CreateClient only adopts on success. | 20 | _ = unix.Close(wlFD) // CreateClient only adopts on success. | ||
| 23 | - | _ = unix.Close(wmFD) | |||
| 24 | return | 21 | return | ||
| 25 | } | 22 | } | ||
| 26 | s.forgetXwayland() | 23 | s.forgetXwayland() | ||
| 27 | - | s.xwayland = xwaylandClient{client: c, wmFD: wmFD} | 24 | + | s.xwayland = xwaylandClient{client: c} |
| 28 | c.OnDestroy(func() { | 25 | c.OnDestroy(func() { | ||
| 29 | if s.xwayland.client == c { | 26 | if s.xwayland.client == c { | ||
| 30 | s.forgetXwayland() | 27 | s.forgetXwayland() | ||
| @@ -34,14 +31,21 @@ func (s *Server) AttachXwayland(wlFD, wmFD int) { | |||||
| 34 | }) | 31 | }) | ||
| 35 | if !ran { | 32 | if !ran { | ||
| 36 | _ = unix.Close(wlFD) | 33 | _ = unix.Close(wlFD) | ||
| 37 | - | _ = unix.Close(wmFD) | |||
| 38 | } | 34 | } | ||
| 39 | } | 35 | } | ||
| 40 | 36 | ||||
| 41 | // forgetXwayland runs on the display owner, or after Run has joined it. | 37 | // forgetXwayland runs on the display owner, or after Run has joined it. | ||
| 42 | func (s *Server) forgetXwayland() { | 38 | func (s *Server) forgetXwayland() { | ||
| 43 | if s.xwayland.client != (server.Client{}) { | 39 | if s.xwayland.client != (server.Client{}) { | ||
| 44 | - | _ = unix.Close(s.xwayland.wmFD) | |||
| 45 | s.xwayland = xwaylandClient{} | 40 | s.xwayland = xwaylandClient{} | ||
| 46 | } | 41 | } | ||
| 47 | } | 42 | } | ||
| 43 | + | ||||
| 44 | + | // AttachXWM takes ownership of the window manager descriptor. It runs on the | |||
| 45 | + | // starter goroutine once Xwayland is ready; the XWM is not implemented yet, | |||
| 46 | + | // so the descriptor is closed and stop does nothing. | |||
| 47 | + | func (s *Server) AttachXWM(wmFD int) (stop func()) { | |||
| 48 | + | s.log.Info().Msg("X11 window management not implemented: closing XWM descriptor") | |||
| 49 | + | _ = unix.Close(wmFD) | |||
| 50 | + | return func() {} | |||
| 51 | + | } | |||
M internal/adapters/wayland/xwayland_test.go +7 -12
| @@ -31,16 +31,15 @@ func assertFDClosed(t *testing.T, fd int) { | |||
| 31 | 31 | ||
| 32 | 32 | func TestAttachXwaylandLifecycle(t *testing.T) { | |
| 33 | 33 | h := newAdmissionHarness(t, 1) | |
| 34 | - | wl, wm := xwaylandPair(t), xwaylandPair(t) | |
| 35 | - | h.s.AttachXwayland(wl[0], wm[0]) | |
| 34 | + | wl := xwaylandPair(t) | |
| 35 | + | h.s.AttachXwayland(wl[0]) | |
| 36 | 36 | var first server.Client | |
| 37 | 37 | h.s.display.Do(func() { first = h.s.xwayland.client }) | |
| 38 | 38 | if first == (server.Client{}) { | |
| 39 | 39 | t.Fatal("client not recorded") | |
| 40 | 40 | } | |
| 41 | - | wl2, wm2 := xwaylandPair(t), xwaylandPair(t) | |
| 42 | - | h.s.AttachXwayland(wl2[0], wm2[0]) | |
| 43 | - | assertFDClosed(t, wm[0]) | |
| 41 | + | wl2 := xwaylandPair(t) | |
| 42 | + | h.s.AttachXwayland(wl2[0]) | |
| 44 | 43 | unix.Close(wl[1]) | |
| 45 | 44 | wl[1] = -1 // Old client's destroy must not clear the new record. | |
| 46 | 45 | waitTrue(t, "old client destroyed", func() bool { | |
| @@ -59,7 +58,6 @@ func TestAttachXwaylandLifecycle(t *testing.T) { | |||
| 59 | 58 | h.s.display.Do(func() { clear = h.s.xwayland.client == (server.Client{}) }) | |
| 60 | 59 | return clear | |
| 61 | 60 | }) | |
| 62 | - | assertFDClosed(t, wm2[0]) | |
| 63 | 61 | } | |
| 64 | 62 | ||
| 65 | 63 | func TestAttachXwaylandFailureClosesFDs(t *testing.T) { | |
| @@ -69,10 +67,8 @@ func TestAttachXwaylandFailureClosesFDs(t *testing.T) { | |||
| 69 | 67 | t.Fatal(err) | |
| 70 | 68 | } | |
| 71 | 69 | defer unix.Close(p[1]) | |
| 72 | - | wm := xwaylandPair(t) | |
| 73 | - | h.s.AttachXwayland(p[0], wm[0]) | |
| 70 | + | h.s.AttachXwayland(p[0]) | |
| 74 | 71 | assertFDClosed(t, p[0]) | |
| 75 | - | assertFDClosed(t, wm[0]) | |
| 76 | 72 | } | |
| 77 | 73 | ||
| 78 | 74 | func TestAttachXwaylandStopped(t *testing.T) { | |
| @@ -82,8 +78,7 @@ func TestAttachXwaylandStopped(t *testing.T) { | |||
| 82 | 78 | } | |
| 83 | 79 | d.Close() | |
| 84 | 80 | s := &Server{display: d} | |
| 85 | - | wl, wm := xwaylandPair(t), xwaylandPair(t) | |
| 86 | - | s.AttachXwayland(wl[0], wm[0]) | |
| 81 | + | wl := xwaylandPair(t) | |
| 82 | + | s.AttachXwayland(wl[0]) | |
| 87 | 83 | assertFDClosed(t, wl[0]) | |
| 88 | - | assertFDClosed(t, wm[0]) | |
| 89 | 84 | } | |
| @@ -31,16 +31,15 @@ func assertFDClosed(t *testing.T, fd int) { | |||||
| 31 | 31 | ||||
| 32 | func TestAttachXwaylandLifecycle(t *testing.T) { | 32 | func TestAttachXwaylandLifecycle(t *testing.T) { | ||
| 33 | h := newAdmissionHarness(t, 1) | 33 | h := newAdmissionHarness(t, 1) | ||
| 34 | - | wl, wm := xwaylandPair(t), xwaylandPair(t) | 34 | + | wl := xwaylandPair(t) |
| 35 | - | h.s.AttachXwayland(wl[0], wm[0]) | 35 | + | h.s.AttachXwayland(wl[0]) |
| 36 | var first server.Client | 36 | var first server.Client | ||
| 37 | h.s.display.Do(func() { first = h.s.xwayland.client }) | 37 | h.s.display.Do(func() { first = h.s.xwayland.client }) | ||
| 38 | if first == (server.Client{}) { | 38 | if first == (server.Client{}) { | ||
| 39 | t.Fatal("client not recorded") | 39 | t.Fatal("client not recorded") | ||
| 40 | } | 40 | } | ||
| 41 | - | wl2, wm2 := xwaylandPair(t), xwaylandPair(t) | 41 | + | wl2 := xwaylandPair(t) |
| 42 | - | h.s.AttachXwayland(wl2[0], wm2[0]) | 42 | + | h.s.AttachXwayland(wl2[0]) |
| 43 | - | assertFDClosed(t, wm[0]) | |||
| 44 | unix.Close(wl[1]) | 43 | unix.Close(wl[1]) | ||
| 45 | wl[1] = -1 // Old client's destroy must not clear the new record. | 44 | wl[1] = -1 // Old client's destroy must not clear the new record. | ||
| 46 | waitTrue(t, "old client destroyed", func() bool { | 45 | waitTrue(t, "old client destroyed", func() bool { | ||
| @@ -59,7 +58,6 @@ func TestAttachXwaylandLifecycle(t *testing.T) { | |||||
| 59 | h.s.display.Do(func() { clear = h.s.xwayland.client == (server.Client{}) }) | 58 | h.s.display.Do(func() { clear = h.s.xwayland.client == (server.Client{}) }) | ||
| 60 | return clear | 59 | return clear | ||
| 61 | }) | 60 | }) | ||
| 62 | - | assertFDClosed(t, wm2[0]) | |||
| 63 | } | 61 | } | ||
| 64 | 62 | ||||
| 65 | func TestAttachXwaylandFailureClosesFDs(t *testing.T) { | 63 | func TestAttachXwaylandFailureClosesFDs(t *testing.T) { | ||
| @@ -69,10 +67,8 @@ func TestAttachXwaylandFailureClosesFDs(t *testing.T) { | |||||
| 69 | t.Fatal(err) | 67 | t.Fatal(err) | ||
| 70 | } | 68 | } | ||
| 71 | defer unix.Close(p[1]) | 69 | defer unix.Close(p[1]) | ||
| 72 | - | wm := xwaylandPair(t) | 70 | + | h.s.AttachXwayland(p[0]) |
| 73 | - | h.s.AttachXwayland(p[0], wm[0]) | |||
| 74 | assertFDClosed(t, p[0]) | 71 | assertFDClosed(t, p[0]) | ||
| 75 | - | assertFDClosed(t, wm[0]) | |||
| 76 | } | 72 | } | ||
| 77 | 73 | ||||
| 78 | func TestAttachXwaylandStopped(t *testing.T) { | 74 | func TestAttachXwaylandStopped(t *testing.T) { | ||
| @@ -82,8 +78,7 @@ func TestAttachXwaylandStopped(t *testing.T) { | |||||
| 82 | } | 78 | } | ||
| 83 | d.Close() | 79 | d.Close() | ||
| 84 | s := &Server{display: d} | 80 | s := &Server{display: d} | ||
| 85 | - | wl, wm := xwaylandPair(t), xwaylandPair(t) | 81 | + | wl := xwaylandPair(t) |
| 86 | - | s.AttachXwayland(wl[0], wm[0]) | 82 | + | s.AttachXwayland(wl[0]) |
| 87 | assertFDClosed(t, wl[0]) | 83 | assertFDClosed(t, wl[0]) | ||
| 88 | - | assertFDClosed(t, wm[0]) | |||
| 89 | } | 84 | } | ||
M internal/adapters/xwayland/builtin.go +20 -13
| @@ -18,7 +18,7 @@ type builtinStarter struct{} | |||
| 18 | 18 | ||
| 19 | 19 | func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter) error { | |
| 20 | 20 | // Files own all setup descriptors until Start succeeds and Attach takes | |
| 21 | - | // the two parent sockets. This also unwinds partial setup failures. | |
| 21 | + | // the Wayland socket. This also unwinds partial setup failures. | |
| 22 | 22 | var files []*os.File | |
| 23 | 23 | defer func() { | |
| 24 | 24 | for _, f := range files { | |
| @@ -77,23 +77,16 @@ func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter) | |||
| 77 | 77 | d.opts.Log.Info().Str("display", d.Name()).Int("pid", cmd.Process.Pid).Msg("Xwayland started") | |
| 78 | 78 | // Detach the raw descriptors from os.File ownership before handing them | |
| 79 | 79 | // to the display (including its finalizers). | |
| 80 | - | wlFD, wmFD := int(files[wl].Fd()), int(files[wm].Fd()) | |
| 81 | 80 | if d.opts.Attach != nil { | |
| 82 | - | // Dup transfers independent descriptors; closing the File wrappers below | |
| 83 | - | // cannot race with the display's ownership of the transferred descriptors. | |
| 84 | - | wlFD, err = unix.FcntlInt(uintptr(wlFD), unix.F_DUPFD_CLOEXEC, 0) | |
| 81 | + | // Dup transfers an independent descriptor; closing the File wrapper below | |
| 82 | + | // cannot race with the display's ownership of the transferred descriptor. | |
| 83 | + | var wlFD int | |
| 84 | + | wlFD, err = unix.FcntlInt(uintptr(files[wl].Fd()), unix.F_DUPFD_CLOEXEC, 0) | |
| 85 | 85 | if err == nil { | |
| 86 | - | wmFD, err = unix.FcntlInt(uintptr(wmFD), unix.F_DUPFD_CLOEXEC, 0) | |
| 87 | - | if err != nil { | |
| 88 | - | unix.Close(wlFD) | |
| 89 | - | } | |
| 90 | - | } | |
| 91 | - | if err == nil { | |
| 92 | - | d.opts.Attach(wlFD, wmFD) | |
| 86 | + | d.opts.Attach(wlFD) | |
| 93 | 87 | } | |
| 94 | 88 | } | |
| 95 | 89 | files[wl].Close() | |
| 96 | - | files[wm].Close() | |
| 97 | 90 | done := make(chan error, 1) | |
| 98 | 91 | go func() { done <- cmd.Wait() }() | |
| 99 | 92 | if err != nil { | |
| @@ -114,6 +107,20 @@ func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter) | |||
| 114 | 107 | } | |
| 115 | 108 | rd.Close() | |
| 116 | 109 | d.opts.Log.Info().Str("display", d.Name()).Msg("Xwayland ready") | |
| 110 | + | if d.opts.AttachWM != nil { | |
| 111 | + | // The WM socket is bootstrapped only now: Xwayland serves it after its | |
| 112 | + | // Wayland round trips, which the display goroutine must be free to answer. | |
| 113 | + | wmFD, err := unix.FcntlInt(uintptr(files[wm].Fd()), unix.F_DUPFD_CLOEXEC, 0) | |
| 114 | + | if err != nil { | |
| 115 | + | stopGroup(cmd, done) | |
| 116 | + | return err | |
| 117 | + | } | |
| 118 | + | stop := d.opts.AttachWM(wmFD) | |
| 119 | + | if stop != nil { | |
| 120 | + | // Runs before start returns so the restart loop never overlaps two XWMs. | |
| 121 | + | defer stop() | |
| 122 | + | } | |
| 123 | + | } | |
| 117 | 124 | select { | |
| 118 | 125 | case err := <-done: | |
| 119 | 126 | return err | |
| @@ -18,7 +18,7 @@ type builtinStarter struct{} | |||||
| 18 | 18 | ||||
| 19 | func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter) error { | 19 | func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter) error { | ||
| 20 | // Files own all setup descriptors until Start succeeds and Attach takes | 20 | // Files own all setup descriptors until Start succeeds and Attach takes | ||
| 21 | - | // the two parent sockets. This also unwinds partial setup failures. | 21 | + | // the Wayland socket. This also unwinds partial setup failures. |
| 22 | var files []*os.File | 22 | var files []*os.File | ||
| 23 | defer func() { | 23 | defer func() { | ||
| 24 | for _, f := range files { | 24 | for _, f := range files { | ||
| @@ -77,23 +77,16 @@ func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter) | |||||
| 77 | d.opts.Log.Info().Str("display", d.Name()).Int("pid", cmd.Process.Pid).Msg("Xwayland started") | 77 | d.opts.Log.Info().Str("display", d.Name()).Int("pid", cmd.Process.Pid).Msg("Xwayland started") | ||
| 78 | // Detach the raw descriptors from os.File ownership before handing them | 78 | // Detach the raw descriptors from os.File ownership before handing them | ||
| 79 | // to the display (including its finalizers). | 79 | // to the display (including its finalizers). | ||
| 80 | - | wlFD, wmFD := int(files[wl].Fd()), int(files[wm].Fd()) | |||
| 81 | if d.opts.Attach != nil { | 80 | if d.opts.Attach != nil { | ||
| 82 | - | // Dup transfers independent descriptors; closing the File wrappers below | 81 | + | // Dup transfers an independent descriptor; closing the File wrapper below |
| 83 | - | // cannot race with the display's ownership of the transferred descriptors. | 82 | + | // cannot race with the display's ownership of the transferred descriptor. |
| 84 | - | wlFD, err = unix.FcntlInt(uintptr(wlFD), unix.F_DUPFD_CLOEXEC, 0) | 83 | + | var wlFD int |
| 84 | + | wlFD, err = unix.FcntlInt(uintptr(files[wl].Fd()), unix.F_DUPFD_CLOEXEC, 0) | |||
| 85 | if err == nil { | 85 | if err == nil { | ||
| 86 | - | wmFD, err = unix.FcntlInt(uintptr(wmFD), unix.F_DUPFD_CLOEXEC, 0) | 86 | + | d.opts.Attach(wlFD) |
| 87 | - | if err != nil { | |||
| 88 | - | unix.Close(wlFD) | |||
| 89 | - | } | |||
| 90 | - | } | |||
| 91 | - | if err == nil { | |||
| 92 | - | d.opts.Attach(wlFD, wmFD) | |||
| 93 | } | 87 | } | ||
| 94 | } | 88 | } | ||
| 95 | files[wl].Close() | 89 | files[wl].Close() | ||
| 96 | - | files[wm].Close() | |||
| 97 | done := make(chan error, 1) | 90 | done := make(chan error, 1) | ||
| 98 | go func() { done <- cmd.Wait() }() | 91 | go func() { done <- cmd.Wait() }() | ||
| 99 | if err != nil { | 92 | if err != nil { | ||
| @@ -114,6 +107,20 @@ func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter) | |||||
| 114 | } | 107 | } | ||
| 115 | rd.Close() | 108 | rd.Close() | ||
| 116 | d.opts.Log.Info().Str("display", d.Name()).Msg("Xwayland ready") | 109 | d.opts.Log.Info().Str("display", d.Name()).Msg("Xwayland ready") | ||
| 110 | + | if d.opts.AttachWM != nil { | |||
| 111 | + | // The WM socket is bootstrapped only now: Xwayland serves it after its | |||
| 112 | + | // Wayland round trips, which the display goroutine must be free to answer. | |||
| 113 | + | wmFD, err := unix.FcntlInt(uintptr(files[wm].Fd()), unix.F_DUPFD_CLOEXEC, 0) | |||
| 114 | + | if err != nil { | |||
| 115 | + | stopGroup(cmd, done) | |||
| 116 | + | return err | |||
| 117 | + | } | |||
| 118 | + | stop := d.opts.AttachWM(wmFD) | |||
| 119 | + | if stop != nil { | |||
| 120 | + | // Runs before start returns so the restart loop never overlaps two XWMs. | |||
| 121 | + | defer stop() | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 117 | select { | 124 | select { | ||
| 118 | case err := <-done: | 125 | case err := <-done: | ||
| 119 | return err | 126 | return err | ||
M internal/adapters/xwayland/xwayland.go +8 -2
| @@ -24,8 +24,14 @@ import ( | |||
| 24 | 24 | // Options configures the X11 display. | |
| 25 | 25 | type Options struct { | |
| 26 | 26 | Backend ports.XwaylandBackend | |
| 27 | - | // Attach takes ownership of both descriptors when called. | |
| 28 | - | Attach func(wlFD, wmFD int) | |
| 27 | + | // Attach takes ownership of the Wayland descriptor right after the backend | |
| 28 | + | // starts. | |
| 29 | + | Attach func(wlFD int) | |
| 30 | + | // AttachWM takes ownership of the window manager descriptor once the | |
| 31 | + | // backend is ready. It may block to bootstrap the X11 connection. The | |
| 32 | + | // returned stop (nil for none) is called before the run ends, on backend | |
| 33 | + | // exit or context cancellation. | |
| 34 | + | AttachWM func(wmFD int) (stop func()) | |
| 29 | 35 | // Binary is the selected backend executable. | |
| 30 | 36 | Binary string | |
| 31 | 37 | // Env is the backend environment; satellite requires WAYLAND_DISPLAY. | |
| @@ -24,8 +24,14 @@ import ( | |||||
| 24 | // Options configures the X11 display. | 24 | // Options configures the X11 display. | ||
| 25 | type Options struct { | 25 | type Options struct { | ||
| 26 | Backend ports.XwaylandBackend | 26 | Backend ports.XwaylandBackend | ||
| 27 | - | // Attach takes ownership of both descriptors when called. | 27 | + | // Attach takes ownership of the Wayland descriptor right after the backend |
| 28 | - | Attach func(wlFD, wmFD int) | 28 | + | // starts. |
| 29 | + | Attach func(wlFD int) | |||
| 30 | + | // AttachWM takes ownership of the window manager descriptor once the | |||
| 31 | + | // backend is ready. It may block to bootstrap the X11 connection. The | |||
| 32 | + | // returned stop (nil for none) is called before the run ends, on backend | |||
| 33 | + | // exit or context cancellation. | |||
| 34 | + | AttachWM func(wmFD int) (stop func()) | |||
| 29 | // Binary is the selected backend executable. | 35 | // Binary is the selected backend executable. | ||
| 30 | Binary string | 36 | Binary string | ||
| 31 | // Env is the backend environment; satellite requires WAYLAND_DISPLAY. | 37 | // Env is the backend environment; satellite requires WAYLAND_DISPLAY. | ||
M internal/adapters/xwayland/xwayland_test.go +70 -0
| @@ -369,3 +369,73 @@ func TestWaitReady(t *testing.T) { | |||
| 369 | 369 | } | |
| 370 | 370 | }) | |
| 371 | 371 | } | |
| 372 | + | ||
| 373 | + | // readyScript stands in for Xwayland: after a short delay it creates a marker | |
| 374 | + | // file, reports readiness on the -displayfd descriptor, then runs tail. | |
| 375 | + | func readyScript(t *testing.T, dir, tail string) (bin, marker string) { | |
| 376 | + | t.Helper() | |
| 377 | + | bin, marker = filepath.Join(dir, "xwayland"), filepath.Join(dir, "ready") | |
| 378 | + | script := "#!/bin/sh\nwhile [ $# -gt 0 ]; do [ \"$1\" = -displayfd ] && fd=$2; shift; done\n" + | |
| 379 | + | "sleep 0.3\n: > " + marker + "\neval \"echo 1 >&$fd\"\n" + tail + "\n" | |
| 380 | + | if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { | |
| 381 | + | t.Fatal(err) | |
| 382 | + | } | |
| 383 | + | return bin, marker | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | func runBuiltin(t *testing.T, ctx context.Context, tail string, attachWM func(wmFD int)) (events []string) { | |
| 387 | + | t.Helper() | |
| 388 | + | opts := testOptions(t, "") | |
| 389 | + | opts.Backend = ports.XwaylandBuiltin | |
| 390 | + | var marker string | |
| 391 | + | opts.Binary, marker = readyScript(t, opts.TmpDir, tail) | |
| 392 | + | exists := func() bool { _, err := os.Stat(marker); return err == nil } | |
| 393 | + | opts.Attach = func(wlFD int) { | |
| 394 | + | if exists() { | |
| 395 | + | t.Error("Attach ran after readiness") | |
| 396 | + | } | |
| 397 | + | events = append(events, "attach") | |
| 398 | + | unix.Close(wlFD) | |
| 399 | + | } | |
| 400 | + | opts.AttachWM = func(wmFD int) func() { | |
| 401 | + | if !exists() { | |
| 402 | + | t.Error("AttachWM ran before readiness") | |
| 403 | + | } | |
| 404 | + | events = append(events, "attachWM") | |
| 405 | + | attachWM(wmFD) | |
| 406 | + | return func() { events = append(events, "stop") } | |
| 407 | + | } | |
| 408 | + | d, err := Open(opts) | |
| 409 | + | if err != nil { | |
| 410 | + | t.Fatal(err) | |
| 411 | + | } | |
| 412 | + | defer d.Close() | |
| 413 | + | _ = d.starter.start(ctx, d, &logWriter{log: opts.Log, backend: d.backendName()}) | |
| 414 | + | return append(events, "returned") | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | // AttachWM runs only after -displayfd readiness; stop runs before start | |
| 418 | + | // returns, whether the process exits or the context ends. | |
| 419 | + | func TestBuiltinAttachWMAfterReadiness(t *testing.T) { | |
| 420 | + | for name, tc := range map[string]struct { | |
| 421 | + | tail string | |
| 422 | + | cancel bool | |
| 423 | + | }{"process exit": {tail: "exit 0"}, "context end": {tail: "exec sleep 30", cancel: true}} { | |
| 424 | + | t.Run(name, func(t *testing.T) { | |
| 425 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 426 | + | defer cancel() | |
| 427 | + | events := runBuiltin(t, ctx, tc.tail, func(wmFD int) { | |
| 428 | + | if _, err := unix.FcntlInt(uintptr(wmFD), unix.F_GETFD, 0); err != nil { | |
| 429 | + | t.Errorf("wmFD not open: %v", err) | |
| 430 | + | } | |
| 431 | + | unix.Close(wmFD) | |
| 432 | + | if tc.cancel { | |
| 433 | + | go func() { time.Sleep(200 * time.Millisecond); cancel() }() | |
| 434 | + | } | |
| 435 | + | }) | |
| 436 | + | if got := strings.Join(events, ","); got != "attach,attachWM,stop,returned" { | |
| 437 | + | t.Fatalf("events %s", got) | |
| 438 | + | } | |
| 439 | + | }) | |
| 440 | + | } | |
| 441 | + | } | |
| @@ -369,3 +369,73 @@ func TestWaitReady(t *testing.T) { | |||||
| 369 | } | 369 | } | ||
| 370 | }) | 370 | }) | ||
| 371 | } | 371 | } | ||
| 372 | + | ||||
| 373 | + | // readyScript stands in for Xwayland: after a short delay it creates a marker | |||
| 374 | + | // file, reports readiness on the -displayfd descriptor, then runs tail. | |||
| 375 | + | func readyScript(t *testing.T, dir, tail string) (bin, marker string) { | |||
| 376 | + | t.Helper() | |||
| 377 | + | bin, marker = filepath.Join(dir, "xwayland"), filepath.Join(dir, "ready") | |||
| 378 | + | script := "#!/bin/sh\nwhile [ $# -gt 0 ]; do [ \"$1\" = -displayfd ] && fd=$2; shift; done\n" + | |||
| 379 | + | "sleep 0.3\n: > " + marker + "\neval \"echo 1 >&$fd\"\n" + tail + "\n" | |||
| 380 | + | if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { | |||
| 381 | + | t.Fatal(err) | |||
| 382 | + | } | |||
| 383 | + | return bin, marker | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | func runBuiltin(t *testing.T, ctx context.Context, tail string, attachWM func(wmFD int)) (events []string) { | |||
| 387 | + | t.Helper() | |||
| 388 | + | opts := testOptions(t, "") | |||
| 389 | + | opts.Backend = ports.XwaylandBuiltin | |||
| 390 | + | var marker string | |||
| 391 | + | opts.Binary, marker = readyScript(t, opts.TmpDir, tail) | |||
| 392 | + | exists := func() bool { _, err := os.Stat(marker); return err == nil } | |||
| 393 | + | opts.Attach = func(wlFD int) { | |||
| 394 | + | if exists() { | |||
| 395 | + | t.Error("Attach ran after readiness") | |||
| 396 | + | } | |||
| 397 | + | events = append(events, "attach") | |||
| 398 | + | unix.Close(wlFD) | |||
| 399 | + | } | |||
| 400 | + | opts.AttachWM = func(wmFD int) func() { | |||
| 401 | + | if !exists() { | |||
| 402 | + | t.Error("AttachWM ran before readiness") | |||
| 403 | + | } | |||
| 404 | + | events = append(events, "attachWM") | |||
| 405 | + | attachWM(wmFD) | |||
| 406 | + | return func() { events = append(events, "stop") } | |||
| 407 | + | } | |||
| 408 | + | d, err := Open(opts) | |||
| 409 | + | if err != nil { | |||
| 410 | + | t.Fatal(err) | |||
| 411 | + | } | |||
| 412 | + | defer d.Close() | |||
| 413 | + | _ = d.starter.start(ctx, d, &logWriter{log: opts.Log, backend: d.backendName()}) | |||
| 414 | + | return append(events, "returned") | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | // AttachWM runs only after -displayfd readiness; stop runs before start | |||
| 418 | + | // returns, whether the process exits or the context ends. | |||
| 419 | + | func TestBuiltinAttachWMAfterReadiness(t *testing.T) { | |||
| 420 | + | for name, tc := range map[string]struct { | |||
| 421 | + | tail string | |||
| 422 | + | cancel bool | |||
| 423 | + | }{"process exit": {tail: "exit 0"}, "context end": {tail: "exec sleep 30", cancel: true}} { | |||
| 424 | + | t.Run(name, func(t *testing.T) { | |||
| 425 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 426 | + | defer cancel() | |||
| 427 | + | events := runBuiltin(t, ctx, tc.tail, func(wmFD int) { | |||
| 428 | + | if _, err := unix.FcntlInt(uintptr(wmFD), unix.F_GETFD, 0); err != nil { | |||
| 429 | + | t.Errorf("wmFD not open: %v", err) | |||
| 430 | + | } | |||
| 431 | + | unix.Close(wmFD) | |||
| 432 | + | if tc.cancel { | |||
| 433 | + | go func() { time.Sleep(200 * time.Millisecond); cancel() }() | |||
| 434 | + | } | |||
| 435 | + | }) | |||
| 436 | + | if got := strings.Join(events, ","); got != "attach,attachWM,stop,returned" { | |||
| 437 | + | t.Fatalf("events %s", got) | |||
| 438 | + | } | |||
| 439 | + | }) | |||
| 440 | + | } | |||
| 441 | + | } | |||
M internal/app/app.go +2 -2
| @@ -191,7 +191,7 @@ func logScenes(log zerowrap.Logger, set []ports.Scene) { | |||
| 191 | 191 | ||
| 192 | 192 | // openXwayland reserves an X11 display for the selected backend, or returns | |
| 193 | 193 | // nil when X11 is off or unavailable: the session runs without it. | |
| 194 | - | func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger, attach func(int, int)) *xwayland.Display { | |
| 194 | + | func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger, attach func(int), attachWM func(int) func()) *xwayland.Display { | |
| 195 | 195 | cfg := opts.Config.Xwayland | |
| 196 | 196 | bin := cfg.Binary | |
| 197 | 197 | if cfg.Backend == ports.XwaylandOff || opts.NoXwayland { | |
| @@ -206,7 +206,7 @@ func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap. | |||
| 206 | 206 | log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled") | |
| 207 | 207 | return nil | |
| 208 | 208 | } | |
| 209 | - | d, err := xwayland.Open(xwayland.Options{Backend: cfg.Backend, Attach: attach, Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log}) | |
| 209 | + | d, err := xwayland.Open(xwayland.Options{Backend: cfg.Backend, Attach: attach, AttachWM: attachWM, Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log}) | |
| 210 | 210 | if err != nil { | |
| 211 | 211 | log.Warn().Err(err).Msg("X11 display disabled") | |
| 212 | 212 | return nil | |
| @@ -191,7 +191,7 @@ func logScenes(log zerowrap.Logger, set []ports.Scene) { | |||||
| 191 | 191 | ||||
| 192 | // openXwayland reserves an X11 display for the selected backend, or returns | 192 | // openXwayland reserves an X11 display for the selected backend, or returns | ||
| 193 | // nil when X11 is off or unavailable: the session runs without it. | 193 | // nil when X11 is off or unavailable: the session runs without it. | ||
| 194 | - | func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger, attach func(int, int)) *xwayland.Display { | 194 | + | func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger, attach func(int), attachWM func(int) func()) *xwayland.Display { |
| 195 | cfg := opts.Config.Xwayland | 195 | cfg := opts.Config.Xwayland | ||
| 196 | bin := cfg.Binary | 196 | bin := cfg.Binary | ||
| 197 | if cfg.Backend == ports.XwaylandOff || opts.NoXwayland { | 197 | if cfg.Backend == ports.XwaylandOff || opts.NoXwayland { | ||
| @@ -206,7 +206,7 @@ func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap. | |||||
| 206 | log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled") | 206 | log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled") | ||
| 207 | return nil | 207 | return nil | ||
| 208 | } | 208 | } | ||
| 209 | - | d, err := xwayland.Open(xwayland.Options{Backend: cfg.Backend, Attach: attach, Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log}) | 209 | + | d, err := xwayland.Open(xwayland.Options{Backend: cfg.Backend, Attach: attach, AttachWM: attachWM, Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log}) |
| 210 | if err != nil { | 210 | if err != nil { | ||
| 211 | log.Warn().Err(err).Msg("X11 display disabled") | 211 | log.Warn().Err(err).Msg("X11 display disabled") | ||
| 212 | return nil | 212 | return nil | ||
M internal/app/session.go +1 -1
| @@ -152,7 +152,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error) | |||
| 152 | 152 | s.statePath = statePath | |
| 153 | 153 | s.childEnv = append(s.childEnv, statefile.Env+"="+statePath) | |
| 154 | 154 | } | |
| 155 | - | if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland"), s.server.AttachXwayland); s.xdisplay != nil { | |
| 155 | + | if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland"), s.server.AttachXwayland, s.server.AttachXWM); s.xdisplay != nil { | |
| 156 | 156 | s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name()) | |
| 157 | 157 | } | |
| 158 | 158 | s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher")) | |
| @@ -152,7 +152,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error) | |||||
| 152 | s.statePath = statePath | 152 | s.statePath = statePath | ||
| 153 | s.childEnv = append(s.childEnv, statefile.Env+"="+statePath) | 153 | s.childEnv = append(s.childEnv, statefile.Env+"="+statePath) | ||
| 154 | } | 154 | } | ||
| 155 | - | if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland"), s.server.AttachXwayland); s.xdisplay != nil { | 155 | + | if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland"), s.server.AttachXwayland, s.server.AttachXWM); s.xdisplay != nil { |
| 156 | s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name()) | 156 | s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name()) | ||
| 157 | } | 157 | } | ||
| 158 | s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher")) | 158 | s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher")) | ||