detached 57e2ff1

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +122 -40
M internal/adapters/wayland/xwayland.go +14 -10
@@ -5,26 +5,23 @@ import (
55 "golang.org/x/sys/unix"
66 )
77
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.
109 type xwaylandClient struct {
1110 client server.Client
12- wmFD int
1311 }
1412
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) {
1816 ran := s.display.Do(func() {
1917 c, err := s.display.CreateClient(wlFD)
2018 if err != nil {
2119 s.log.Warn().Err(err).Msg("Xwayland connection not adopted")
2220 _ = unix.Close(wlFD) // CreateClient only adopts on success.
23- _ = unix.Close(wmFD)
2421 return
2522 }
2623 s.forgetXwayland()
27- s.xwayland = xwaylandClient{client: c, wmFD: wmFD}
24+ s.xwayland = xwaylandClient{client: c}
2825 c.OnDestroy(func() {
2926 if s.xwayland.client == c {
3027 s.forgetXwayland()
@@ -34,14 +31,21 @@ func (s *Server) AttachXwayland(wlFD, wmFD int) {
3431 })
3532 if !ran {
3633 _ = unix.Close(wlFD)
37- _ = unix.Close(wmFD)
3834 }
3935 }
4036
4137 // forgetXwayland runs on the display owner, or after Run has joined it.
4238 func (s *Server) forgetXwayland() {
4339 if s.xwayland.client != (server.Client{}) {
44- _ = unix.Close(s.xwayland.wmFD)
4540 s.xwayland = xwaylandClient{}
4641 }
4742 }
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 here8+// 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.Client10 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 return21 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) {
3131
3232 func TestAttachXwaylandLifecycle(t *testing.T) {
3333 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])
3636 var first server.Client
3737 h.s.display.Do(func() { first = h.s.xwayland.client })
3838 if first == (server.Client{}) {
3939 t.Fatal("client not recorded")
4040 }
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])
4443 unix.Close(wl[1])
4544 wl[1] = -1 // Old client's destroy must not clear the new record.
4645 waitTrue(t, "old client destroyed", func() bool {
@@ -59,7 +58,6 @@ func TestAttachXwaylandLifecycle(t *testing.T) {
5958 h.s.display.Do(func() { clear = h.s.xwayland.client == (server.Client{}) })
6059 return clear
6160 })
62- assertFDClosed(t, wm2[0])
6361 }
6462
6563 func TestAttachXwaylandFailureClosesFDs(t *testing.T) {
@@ -69,10 +67,8 @@ func TestAttachXwaylandFailureClosesFDs(t *testing.T) {
6967 t.Fatal(err)
7068 }
7169 defer unix.Close(p[1])
72- wm := xwaylandPair(t)
73- h.s.AttachXwayland(p[0], wm[0])
70+ h.s.AttachXwayland(p[0])
7471 assertFDClosed(t, p[0])
75- assertFDClosed(t, wm[0])
7672 }
7773
7874 func TestAttachXwaylandStopped(t *testing.T) {
@@ -82,8 +78,7 @@ func TestAttachXwaylandStopped(t *testing.T) {
8278 }
8379 d.Close()
8480 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])
8783 assertFDClosed(t, wl[0])
88- assertFDClosed(t, wm[0])
8984 }
@@ -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.Client36 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 clear59 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{}
1818
1919 func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
2020 // 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.
2222 var files []*os.File
2323 defer func() {
2424 for _, f := range files {
@@ -77,23 +77,16 @@ func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter)
7777 d.opts.Log.Info().Str("display", d.Name()).Int("pid", cmd.Process.Pid).Msg("Xwayland started")
7878 // Detach the raw descriptors from os.File ownership before handing them
7979 // to the display (including its finalizers).
80- wlFD, wmFD := int(files[wl].Fd()), int(files[wm].Fd())
8180 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)
8585 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)
9387 }
9488 }
9589 files[wl].Close()
96- files[wm].Close()
9790 done := make(chan error, 1)
9891 go func() { done <- cmd.Wait() }()
9992 if err != nil {
@@ -114,6 +107,20 @@ func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter)
114107 }
115108 rd.Close()
116109 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+ }
117124 select {
118125 case err := <-done:
119126 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 takes20 // 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.File22 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 them78 // 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 below81+ // 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 err126 return err
M internal/adapters/xwayland/xwayland.go +8 -2
@@ -24,8 +24,14 @@ import (
2424 // Options configures the X11 display.
2525 type Options struct {
2626 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())
2935 // Binary is the selected backend executable.
3036 Binary string
3137 // 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.XwaylandBackend26 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 string36 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) {
369369 }
370370 })
371371 }
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) {
191191
192192 // openXwayland reserves an X11 display for the selected backend, or returns
193193 // 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 {
195195 cfg := opts.Config.Xwayland
196196 bin := cfg.Binary
197197 if cfg.Backend == ports.XwaylandOff || opts.NoXwayland {
@@ -206,7 +206,7 @@ func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.
206206 log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
207207 return nil
208208 }
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})
210210 if err != nil {
211211 log.Warn().Err(err).Msg("X11 display disabled")
212212 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 returns192 // 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.Xwayland195 cfg := opts.Config.Xwayland
196 bin := cfg.Binary196 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 nil207 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 nil212 return nil
M internal/app/session.go +1 -1
@@ -152,7 +152,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
152152 s.statePath = statePath
153153 s.childEnv = append(s.childEnv, statefile.Env+"="+statePath)
154154 }
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 {
156156 s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name())
157157 }
158158 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 = statePath152 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"))

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