1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3//go:build linux
4
5package gitremote
6
7import (
8 "context"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "strconv"
13 "strings"
14 "sync"
15 "syscall"
16 "testing"
17 "time"
18
19 "github.com/stretchr/testify/assert"
20 "github.com/stretchr/testify/require"
21 "golang.org/x/sys/unix"
22)
23
24// recordKills replaces killGroup with one that records the groups it is asked
25// to kill, and does not kill them.
26func recordKills(t *testing.T, really bool) func() []int {
27 t.Helper()
28 var mu sync.Mutex
29 var got []int
30 old := killGroup
31 killGroup = func(pgid int) error {
32 mu.Lock()
33 got = append(got, pgid)
34 mu.Unlock()
35 if really {
36 return old(pgid)
37 }
38 return nil
39 }
40 t.Cleanup(func() { killGroup = old })
41 return func() []int {
42 mu.Lock()
43 defer mu.Unlock()
44 return append([]int(nil), got...)
45 }
46}
47
48func helperScript(t *testing.T) (cmd *exec.Cmd, pidFile string) {
49 t.Helper()
50 dir := t.TempDir()
51 pidFile = filepath.Join(dir, "pid")
52 // A leader with a helper in its group, which keeps its stdout open.
53 script := "sleep 30 &\necho $! > '" + pidFile + "'\nwait\n"
54 return exec.Command("/bin/sh", "-c", script), pidFile
55}
56
57func killPidFile(t *testing.T, pidFile string) {
58 t.Helper()
59 raw, err := os.ReadFile(pidFile)
60 if err != nil {
61 return
62 }
63 if pid, err := strconv.Atoi(strings.TrimSpace(string(raw))); err == nil {
64 _ = syscall.Kill(pid, syscall.SIGKILL)
65 }
66}
67
68// When waitid fails nothing proves that the leader is not reaped: the group
69// is not signalled, the leader alone is killed, and the error is returned.
70func TestRunDoesNotSignalTheGroupWhenWaitidFails(t *testing.T) {
71 calls := recordKills(t, true)
72 old := waitid
73 cmd, pidFile := helperScript(t)
74 // Fails once the helper is started, so that the test can tell it lives.
75 waitid = func(int, int, *unix.Siginfo, int, *unix.Rusage) error {
76 for i := 0; i < 200; i++ {
77 if _, err := os.Stat(pidFile); err == nil {
78 break
79 }
80 time.Sleep(10 * time.Millisecond)
81 }
82 return unix.ECHILD
83 }
84 t.Cleanup(func() { waitid = old })
85 t.Cleanup(func() { killPidFile(t, pidFile) })
86 cmd.WaitDelay = 100 * time.Millisecond
87 start := time.Now()
88 err := run(context.Background(), cmd)
89 require.ErrorIs(t, err, unix.ECHILD)
90 assert.Empty(t, calls(), "the group was signalled")
91 assert.Less(t, time.Since(start), 2*time.Second)
92 // The helper of the group was not killed.
93 require.Eventually(t, func() bool { _, err := os.Stat(pidFile); return err == nil }, time.Second, 10*time.Millisecond)
94 raw, _ := os.ReadFile(pidFile)
95 pid, _ := strconv.Atoi(strings.TrimSpace(string(raw)))
96 require.NoError(t, syscall.Kill(pid, 0), "the helper should still be alive")
97}
98
99// Same when the context is done: the group is signalled once, while the
100// leader is not reaped, and the leader is gone after run.
101func TestRunSignalsTheGroupBeforeTheLeaderIsReaped(t *testing.T) {
102 calls := recordKills(t, true)
103 cmd, pidFile := helperScript(t)
104 t.Cleanup(func() { killPidFile(t, pidFile) })
105 cmd.WaitDelay = time.Second
106 ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
107 defer cancel()
108 require.Error(t, run(ctx, cmd))
109 got := calls()
110 require.Len(t, got, 1)
111 assert.Equal(t, cmd.Process.Pid, got[0])
112 assert.True(t, cmd.ProcessState.Exited() || cmd.ProcessState.Sys() != nil)
113}