detached 8540222

fix(xwayland): bound Wait on inherited stderr, log its tail, test group kill

commit
854022286c0242c3bfab6838b6e312371761d73a
author
brice · · 2026-09-25 13:02 UTC
parent
996a519
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +78 -12
M internal/adapters/xwayland/xwayland.go +23 -7
@@ -10,6 +10,7 @@ import (
1010 "os"
1111 "os/exec"
1212 "path/filepath"
13+ "slices"
1314 "strconv"
1415 "strings"
1516 "sync"
@@ -211,12 +212,13 @@ func (d *Display) Run(ctx context.Context) error {
211212 return err
212213 }
213214 start := time.Now()
214- err := d.serve(ctx)
215+ stderr := &logWriter{log: d.opts.Log}
216+ err := d.serve(ctx, stderr)
215217 if ctx.Err() != nil {
216218 return nil
217219 }
218220 if err != nil {
219- d.opts.Log.Warn().Err(err).Str("display", d.Name()).Msg("xwayland-satellite exited")
221+ d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg("xwayland-satellite exited")
220222 } else {
221223 d.opts.Log.Info().Str("display", d.Name()).Msg("xwayland-satellite exited")
222224 }
@@ -260,7 +262,7 @@ func (d *Display) waitClient(ctx context.Context, wake int) error {
260262 }
261263
262264 // serve runs the satellite on the sockets until it exits or ctx ends.
263-func (d *Display) serve(ctx context.Context) error {
265+func (d *Display) serve(ctx context.Context, stderr *logWriter) error {
264266 args := []string{d.Name()}
265267 for i := range d.sockets {
266268 args = append(args, "-listenfd", strconv.Itoa(3+i))
@@ -268,7 +270,9 @@ func (d *Display) serve(ctx context.Context) error {
268270 cmd := exec.Command(d.opts.Binary, args...)
269271 cmd.Env = d.opts.Env
270272 cmd.ExtraFiles = d.sockets
271- cmd.Stderr = &logWriter{log: d.opts.Log}
273+ cmd.Stderr = stderr
274+ // Xwayland inherits the stderr pipe: Wait must not wait for it.
275+ cmd.WaitDelay = 2 * time.Second
272276 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
273277 if err := cmd.Start(); err != nil {
274278 return err
@@ -311,12 +315,20 @@ func (d *Display) dropPending() {
311315 }
312316 }
313317
314-// logWriter logs the satellite's stderr line by line; exec copies it from
315-// a pipe on its own goroutine.
318+// logWriter logs the satellite's stderr line by line and keeps the last
319+// lines for the exit log; exec copies it from a pipe on its own goroutine.
316320 type logWriter struct {
317321 mu sync.Mutex
318322 log zerowrap.Logger
319323 line []byte
324+ last []string
325+}
326+
327+// tail is the last lines written.
328+func (w *logWriter) tail() []string {
329+ w.mu.Lock()
330+ defer w.mu.Unlock()
331+ return slices.Clone(w.last)
320332 }
321333
322334 func (w *logWriter) Write(p []byte) (int, error) {
@@ -329,7 +341,11 @@ func (w *logWriter) Write(p []byte) (int, error) {
329341 break
330342 }
331343 if i > 0 {
332- w.log.Debug().Str("line", string(w.line[:i])).Msg("xwayland-satellite")
344+ line := string(w.line[:i])
345+ w.log.Debug().Str("line", line).Msg("xwayland-satellite")
346+ if w.last = append(w.last, line); len(w.last) > 5 {
347+ w.last = w.last[1:]
348+ }
333349 }
334350 w.line = w.line[i+1:]
335351 }
@@ -10,6 +10,7 @@ import (
10 "os"10 "os"
11 "os/exec"11 "os/exec"
12 "path/filepath"12 "path/filepath"
13+ "slices"
13 "strconv"14 "strconv"
14 "strings"15 "strings"
15 "sync"16 "sync"
@@ -211,12 +212,13 @@ func (d *Display) Run(ctx context.Context) error {
211 return err212 return err
212 }213 }
213 start := time.Now()214 start := time.Now()
214- err := d.serve(ctx)215+ stderr := &logWriter{log: d.opts.Log}
216+ err := d.serve(ctx, stderr)
215 if ctx.Err() != nil {217 if ctx.Err() != nil {
216 return nil218 return nil
217 }219 }
218 if err != nil {220 if err != nil {
219- d.opts.Log.Warn().Err(err).Str("display", d.Name()).Msg("xwayland-satellite exited")221+ d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg("xwayland-satellite exited")
220 } else {222 } else {
221 d.opts.Log.Info().Str("display", d.Name()).Msg("xwayland-satellite exited")223 d.opts.Log.Info().Str("display", d.Name()).Msg("xwayland-satellite exited")
222 }224 }
@@ -260,7 +262,7 @@ func (d *Display) waitClient(ctx context.Context, wake int) error {
260 }262 }
261 263
262 // serve runs the satellite on the sockets until it exits or ctx ends.264 // serve runs the satellite on the sockets until it exits or ctx ends.
263-func (d *Display) serve(ctx context.Context) error {265+func (d *Display) serve(ctx context.Context, stderr *logWriter) error {
264 args := []string{d.Name()}266 args := []string{d.Name()}
265 for i := range d.sockets {267 for i := range d.sockets {
266 args = append(args, "-listenfd", strconv.Itoa(3+i))268 args = append(args, "-listenfd", strconv.Itoa(3+i))
@@ -268,7 +270,9 @@ func (d *Display) serve(ctx context.Context) error {
268 cmd := exec.Command(d.opts.Binary, args...)270 cmd := exec.Command(d.opts.Binary, args...)
269 cmd.Env = d.opts.Env271 cmd.Env = d.opts.Env
270 cmd.ExtraFiles = d.sockets272 cmd.ExtraFiles = d.sockets
271- cmd.Stderr = &logWriter{log: d.opts.Log}273+ cmd.Stderr = stderr
274+ // Xwayland inherits the stderr pipe: Wait must not wait for it.
275+ cmd.WaitDelay = 2 * time.Second
272 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}276 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
273 if err := cmd.Start(); err != nil {277 if err := cmd.Start(); err != nil {
274 return err278 return err
@@ -311,12 +315,20 @@ func (d *Display) dropPending() {
311 }315 }
312 }316 }
313 317
314-// logWriter logs the satellite's stderr line by line; exec copies it from318+// logWriter logs the satellite's stderr line by line and keeps the last
315-// a pipe on its own goroutine.319+// lines for the exit log; exec copies it from a pipe on its own goroutine.
316 type logWriter struct {320 type logWriter struct {
317 mu sync.Mutex321 mu sync.Mutex
318 log zerowrap.Logger322 log zerowrap.Logger
319 line []byte323 line []byte
324+ last []string
325+}
326+
327+// tail is the last lines written.
328+func (w *logWriter) tail() []string {
329+ w.mu.Lock()
330+ defer w.mu.Unlock()
331+ return slices.Clone(w.last)
320 }332 }
321 333
322 func (w *logWriter) Write(p []byte) (int, error) {334 func (w *logWriter) Write(p []byte) (int, error) {
@@ -329,7 +341,11 @@ func (w *logWriter) Write(p []byte) (int, error) {
329 break341 break
330 }342 }
331 if i > 0 {343 if i > 0 {
332- w.log.Debug().Str("line", string(w.line[:i])).Msg("xwayland-satellite")344+ line := string(w.line[:i])
345+ w.log.Debug().Str("line", line).Msg("xwayland-satellite")
346+ if w.last = append(w.last, line); len(w.last) > 5 {
347+ w.last = w.last[1:]
348+ }
333 }349 }
334 w.line = w.line[i+1:]350 w.line = w.line[i+1:]
335 }351 }
M internal/adapters/xwayland/xwayland_test.go +55 -5
@@ -2,6 +2,7 @@ package xwayland
22
33 import (
44 "context"
5+ "errors"
56 "os"
67 "path/filepath"
78 "strconv"
@@ -62,7 +63,6 @@ func TestOpenPicksFreeDisplay(t *testing.T) {
6263 }
6364 }
6465
65-
6666 // A satellite that exits without accepting drops the waiting client and is
6767 // not respawned until another client connects.
6868 func TestRunDropsClientsOfFailedSatellite(t *testing.T) {
@@ -123,8 +123,8 @@ func readRuns(t *testing.T, path string, want int) []string {
123123 // Each connection starts the satellite once, with both sockets handed over.
124124 func TestRunStartsSatelliteOnConnect(t *testing.T) {
125125 opts := testOptions(t, "")
126- opts.Binary, _ = satelliteScript(t, opts.TmpDir)
127- _, runs := satelliteScript(t, opts.TmpDir)
126+ var runs string
127+ opts.Binary, runs = satelliteScript(t, opts.TmpDir)
128128 opts.Abstract = true
129129 d, err := Open(opts)
130130 if err != nil {
@@ -176,6 +176,9 @@ func TestStaleLockReclaimed(t *testing.T) {
176176 if d.Name() != ":0" {
177177 t.Fatalf("display %s", d.Name())
178178 }
179+ if b, _ := os.ReadFile(d.lock); strings.TrimSpace(string(b)) != strconv.Itoa(os.Getpid()) {
180+ t.Fatalf("lock holds %q", b)
181+ }
179182 }
180183
181184 func TestDirChecks(t *testing.T) {
@@ -210,8 +213,55 @@ func TestSupported(t *testing.T) {
210213 if !Supported(context.Background(), "true") || Supported(context.Background(), "false") {
211214 t.Fatal("exit status not honoured")
212215 }
216+ hang := filepath.Join(t.TempDir(), "hang")
217+ if err := os.WriteFile(hang, []byte("#!/bin/sh\nexec sleep 10\n"), 0o755); err != nil {
218+ t.Fatal(err)
219+ }
213220 start := time.Now()
214- if Supported(context.Background(), "sleep") && time.Since(start) > 3*time.Second {
215- t.Fatal("hang")
221+ if Supported(context.Background(), hang) || time.Since(start) > 3*time.Second {
222+ t.Fatalf("hanging binary: %v", time.Since(start))
223+ }
224+}
225+
226+// Stopping kills the satellite's whole process group, even when a child
227+// keeps its stderr open.
228+func TestRunKillsSatelliteGroup(t *testing.T) {
229+ opts := testOptions(t, "")
230+ pidfile := filepath.Join(opts.TmpDir, "pid")
231+ opts.Binary = filepath.Join(opts.TmpDir, "satellite")
232+ script := "#!/bin/sh\ntrap '' TERM\nsleep 30 &\necho $! > " + pidfile + "\nwait\n"
233+ if err := os.WriteFile(opts.Binary, []byte(script), 0o755); err != nil {
234+ t.Fatal(err)
235+ }
236+ d, err := Open(opts)
237+ if err != nil {
238+ t.Fatal(err)
239+ }
240+ defer d.Close()
241+ ctx, cancel := context.WithCancel(context.Background())
242+ done := make(chan error, 1)
243+ go func() { done <- d.Run(ctx) }()
244+ unix.Close(dial(t, d.socket))
245+ var pid int
246+ for deadline := time.Now().Add(3 * time.Second); pid == 0 && time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
247+ b, _ := os.ReadFile(pidfile)
248+ pid, _ = strconv.Atoi(strings.TrimSpace(string(b)))
249+ }
250+ if pid == 0 {
251+ t.Fatal("satellite did not start")
252+ }
253+ cancel()
254+ select {
255+ case <-done:
256+ case <-time.After(5 * time.Second):
257+ t.Fatal("Run did not stop")
258+ }
259+ for deadline := time.Now().Add(2 * time.Second); ; time.Sleep(20 * time.Millisecond) {
260+ if errors.Is(unix.Kill(pid, 0), unix.ESRCH) {
261+ break
262+ }
263+ if time.Now().After(deadline) {
264+ t.Fatalf("child %d survived", pid)
265+ }
216266 }
217267 }
@@ -2,6 +2,7 @@ package xwayland
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "os"6 "os"
6 "path/filepath"7 "path/filepath"
7 "strconv"8 "strconv"
@@ -62,7 +63,6 @@ func TestOpenPicksFreeDisplay(t *testing.T) {
62 }63 }
63 }64 }
64 65
65-
66 // A satellite that exits without accepting drops the waiting client and is66 // A satellite that exits without accepting drops the waiting client and is
67 // not respawned until another client connects.67 // not respawned until another client connects.
68 func TestRunDropsClientsOfFailedSatellite(t *testing.T) {68 func TestRunDropsClientsOfFailedSatellite(t *testing.T) {
@@ -123,8 +123,8 @@ func readRuns(t *testing.T, path string, want int) []string {
123 // Each connection starts the satellite once, with both sockets handed over.123 // Each connection starts the satellite once, with both sockets handed over.
124 func TestRunStartsSatelliteOnConnect(t *testing.T) {124 func TestRunStartsSatelliteOnConnect(t *testing.T) {
125 opts := testOptions(t, "")125 opts := testOptions(t, "")
126- opts.Binary, _ = satelliteScript(t, opts.TmpDir)126+ var runs string
127- _, runs := satelliteScript(t, opts.TmpDir)127+ opts.Binary, runs = satelliteScript(t, opts.TmpDir)
128 opts.Abstract = true128 opts.Abstract = true
129 d, err := Open(opts)129 d, err := Open(opts)
130 if err != nil {130 if err != nil {
@@ -176,6 +176,9 @@ func TestStaleLockReclaimed(t *testing.T) {
176 if d.Name() != ":0" {176 if d.Name() != ":0" {
177 t.Fatalf("display %s", d.Name())177 t.Fatalf("display %s", d.Name())
178 }178 }
179+ if b, _ := os.ReadFile(d.lock); strings.TrimSpace(string(b)) != strconv.Itoa(os.Getpid()) {
180+ t.Fatalf("lock holds %q", b)
181+ }
179 }182 }
180 183
181 func TestDirChecks(t *testing.T) {184 func TestDirChecks(t *testing.T) {
@@ -210,8 +213,55 @@ func TestSupported(t *testing.T) {
210 if !Supported(context.Background(), "true") || Supported(context.Background(), "false") {213 if !Supported(context.Background(), "true") || Supported(context.Background(), "false") {
211 t.Fatal("exit status not honoured")214 t.Fatal("exit status not honoured")
212 }215 }
216+ hang := filepath.Join(t.TempDir(), "hang")
217+ if err := os.WriteFile(hang, []byte("#!/bin/sh\nexec sleep 10\n"), 0o755); err != nil {
218+ t.Fatal(err)
219+ }
213 start := time.Now()220 start := time.Now()
214- if Supported(context.Background(), "sleep") && time.Since(start) > 3*time.Second {221+ if Supported(context.Background(), hang) || time.Since(start) > 3*time.Second {
215- t.Fatal("hang")222+ t.Fatalf("hanging binary: %v", time.Since(start))
223+ }
224+}
225+
226+// Stopping kills the satellite's whole process group, even when a child
227+// keeps its stderr open.
228+func TestRunKillsSatelliteGroup(t *testing.T) {
229+ opts := testOptions(t, "")
230+ pidfile := filepath.Join(opts.TmpDir, "pid")
231+ opts.Binary = filepath.Join(opts.TmpDir, "satellite")
232+ script := "#!/bin/sh\ntrap '' TERM\nsleep 30 &\necho $! > " + pidfile + "\nwait\n"
233+ if err := os.WriteFile(opts.Binary, []byte(script), 0o755); err != nil {
234+ t.Fatal(err)
235+ }
236+ d, err := Open(opts)
237+ if err != nil {
238+ t.Fatal(err)
239+ }
240+ defer d.Close()
241+ ctx, cancel := context.WithCancel(context.Background())
242+ done := make(chan error, 1)
243+ go func() { done <- d.Run(ctx) }()
244+ unix.Close(dial(t, d.socket))
245+ var pid int
246+ for deadline := time.Now().Add(3 * time.Second); pid == 0 && time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
247+ b, _ := os.ReadFile(pidfile)
248+ pid, _ = strconv.Atoi(strings.TrimSpace(string(b)))
249+ }
250+ if pid == 0 {
251+ t.Fatal("satellite did not start")
252+ }
253+ cancel()
254+ select {
255+ case <-done:
256+ case <-time.After(5 * time.Second):
257+ t.Fatal("Run did not stop")
258+ }
259+ for deadline := time.Now().Add(2 * time.Second); ; time.Sleep(20 * time.Millisecond) {
260+ if errors.Is(unix.Kill(pid, 0), unix.ESRCH) {
261+ break
262+ }
263+ if time.Now().After(deadline) {
264+ t.Fatalf("child %d survived", pid)
265+ }
216 }266 }
217 }267 }

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 26

Tags 10

↑↓ moveenter openesc close