1//go:build linux
2
3package app
4
5import (
6 "bytes"
7 "context"
8 "errors"
9 "fmt"
10 "io"
11 "net"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "syscall"
16 "testing"
17 "time"
18
19 "github.com/stretchr/testify/require"
20
21 "github.com/bnema/vev/internal/adapters/ipc"
22 "github.com/bnema/vev/internal/adapters/lifecycle"
23 "github.com/bnema/vev/internal/platform"
24)
25
26func TestWaitProcessExit(t *testing.T) {
27 tests := []struct {
28 name string
29 signal bool
30 wantErr bool
31 }{
32 {name: "terminated process is reported gone", signal: true},
33 {name: "live process times out", signal: false, wantErr: true},
34 }
35 for _, tt := range tests {
36 t.Run(tt.name, func(t *testing.T) {
37 cmd := exec.Command("sleep", "30")
38 require.NoError(t, cmd.Start())
39 // Reap the child so it never lingers as a zombie that kill(pid, 0)
40 // would still report as present.
41 reaped := make(chan struct{})
42 go func() { _ = cmd.Wait(); close(reaped) }()
43 t.Cleanup(func() {
44 _ = cmd.Process.Kill()
45 <-reaped
46 })
47 if tt.signal {
48 require.NoError(t, syscall.Kill(cmd.Process.Pid, syscall.SIGTERM))
49 }
50 err := waitProcessExit(context.Background(), cmd.Process.Pid, 300*time.Millisecond)
51 if tt.wantErr {
52 require.Error(t, err)
53 return
54 }
55 require.NoError(t, err)
56 })
57 }
58}
59
60func TestProcessRole(t *testing.T) {
61 tests := []struct {
62 name string
63 cmdline string
64 want string
65 }{
66 {name: "bare client", cmdline: "vev\x00", want: "client"},
67 {name: "attach client", cmdline: "vev\x00attach\x00work\x00", want: "client"},
68 {name: "daemon", cmdline: "/proc/self/exe\x00--daemon\x00", want: "daemon"},
69 {name: "broker", cmdline: "/proc/self/exe\x00_broker-production-serve\x00", want: "broker"},
70 {name: "hidden helper", cmdline: "/proc/self/exe\x00_broker-mux-quic-proxy\x00", want: "helper"},
71 {name: "web daemon", cmdline: "vev\x00--web-daemon\x00", want: "helper"},
72 {name: "web server", cmdline: "/proc/self/exe\x00--web-serve\x00", want: "helper"},
73 {name: "daemon launcher", cmdline: "/proc/self/exe\x00--daemon-launcher\x00", want: "helper"},
74 }
75 for _, tt := range tests {
76 t.Run(tt.name, func(t *testing.T) {
77 require.Equal(t, tt.want, processRole([]byte(tt.cmdline)))
78 })
79 }
80}
81
82// TestFindVevProcesses runs against a fake /proc: only this user's processes
83// of the installed binary (also once replaced on disk) that resolve to the
84// caller's socket directory are found, never self.
85func TestFindVevProcesses(t *testing.T) {
86 root := t.TempDir()
87 const exe = "/opt/bin/vev"
88 uid := os.Getuid()
89 userRun := "XDG_RUNTIME_DIR=/run/user/1000\x00"
90 add := func(pid, target, cmdline, environ, cwd string) {
91 dir := filepath.Join(root, pid)
92 require.NoError(t, os.Mkdir(dir, 0o755))
93 require.NoError(t, os.Symlink(target, filepath.Join(dir, "exe")))
94 require.NoError(t, os.Symlink(cwd, filepath.Join(dir, "cwd")))
95 require.NoError(t, os.WriteFile(filepath.Join(dir, "cmdline"), []byte(cmdline), 0o644))
96 require.NoError(t, os.WriteFile(filepath.Join(dir, "environ"), []byte(environ), 0o644))
97 }
98 add("10", exe, "vev\x00", "HOME=/h\x00"+userRun, "/h")
99 add("11", exe+" (deleted)", "/proc/self/exe\x00--daemon\x00", userRun, "/h")
100 add("12", "/usr/bin/emacs", "emacs\x00--daemon\x00", userRun, "/h")
101 add("13", "/tmp/vev-test-bin", "/tmp/vev-test-bin\x00--daemon\x00", userRun, "/h")
102 add("14", exe, "vev\x00", userRun, "/h") // self
103 // A VEV_ENV=dev client keeps the user's XDG value in its initial
104 // environment; its children carry the rewritten one.
105 add("15", exe, "vev\x00", "VEV_ENV=dev\x00"+userRun, "/repo")
106 add("16", exe, "/proc/self/exe\x00--daemon\x00", "VEV_ENV=dev\x00VEV_ENV_ROOT=/repo/.dev\x00XDG_RUNTIME_DIR=/repo/.dev/dev/runtime\x00", "/")
107 require.NoError(t, os.Mkdir(filepath.Join(root, "self"), 0o755))
108
109 scope := killAllScope{procRoot: root, exe: exe, uid: uid, self: 14, socketDir: "/run/user/1000/vev"}
110 got, err := findVevProcesses(scope)
111 require.NoError(t, err)
112 require.ElementsMatch(t, []vevProcess{{pid: 10, role: "client"}, {pid: 11, role: "daemon"}}, got)
113
114 scope.socketDir = "/repo/.dev/dev/runtime/vev"
115 dev, err := findVevProcesses(scope)
116 require.NoError(t, err)
117 require.ElementsMatch(t, []vevProcess{{pid: 15, role: "client"}, {pid: 16, role: "daemon"}}, dev, "a VEV_ENV run sees its own client and children")
118
119 scope.uid = uid + 1
120 scope.socketDir = "/run/user/1000/vev"
121 none, err := findVevProcesses(scope)
122 require.NoError(t, err)
123 require.Empty(t, none, "another user's processes are never matched")
124}
125
126// TestProcessSocketDirPrefersSymlinkedPWD pins that a VEV_ENV client started
127// in a repo reached through a symlink resolves to the same path vev used.
128func TestProcessSocketDirPrefersSymlinkedPWD(t *testing.T) {
129 base := t.TempDir()
130 real := filepath.Join(base, "real")
131 require.NoError(t, os.Mkdir(real, 0o755))
132 link := filepath.Join(base, "link")
133 require.NoError(t, os.Symlink(real, link))
134
135 proc := filepath.Join(base, "proc", "20")
136 require.NoError(t, os.MkdirAll(proc, 0o755))
137 require.NoError(t, os.Symlink(real, filepath.Join(proc, "cwd")))
138 require.NoError(t, os.WriteFile(filepath.Join(proc, "environ"), []byte("VEV_ENV=dev\x00PWD="+link+"\x00"), 0o644))
139
140 uid := os.Getuid()
141 require.Equal(t, ipc.SocketDirFor(filepath.Join(link, ".dev", "dev", "runtime"), uid), processSocketDir(proc, uid))
142
143 // A stale PWD that names another directory is ignored.
144 require.NoError(t, os.WriteFile(filepath.Join(proc, "environ"), []byte("VEV_ENV=dev\x00PWD="+base+"\x00"), 0o644))
145 require.Equal(t, ipc.SocketDirFor(filepath.Join(real, ".dev", "dev", "runtime"), uid), processSocketDir(proc, uid))
146}
147
148func TestRuntimeDirResolutionMatchesVev(t *testing.T) {
149 tests := []struct {
150 name string
151 env map[string]string
152 cwd string
153 want string
154 }{
155 {name: "plain", env: map[string]string{"XDG_RUNTIME_DIR": "/run/user/1000"}, want: "/run/user/1000"},
156 {name: "dev env from cwd", env: map[string]string{"VEV_ENV": "dev", "XDG_RUNTIME_DIR": "/run/user/1000"}, cwd: "/repo", want: "/repo/.dev/dev/runtime"},
157 {name: "dev env with root", env: map[string]string{"VEV_ENV": "t", "VEV_ENV_ROOT": "/r"}, cwd: "/elsewhere", want: "/r/t/runtime"},
158 {name: "invalid env name is ignored", env: map[string]string{"VEV_ENV": "../x", "XDG_RUNTIME_DIR": "/run/user/1000"}, want: "/run/user/1000"},
159 }
160 for _, tt := range tests {
161 t.Run(tt.name, func(t *testing.T) {
162 require.Equal(t, tt.want, platform.RuntimeDirFor(func(k string) string { return tt.env[k] }, tt.cwd))
163 })
164 }
165 if info, err := os.Stat(fmt.Sprintf("/run/user/%d", os.Getuid())); err == nil && info.IsDir() {
166 require.Equal(t, ipc.SocketDirFor(fmt.Sprintf("/run/user/%d", os.Getuid()), os.Getuid()), ipc.SocketDirFor("", os.Getuid()),
167 "unset XDG_RUNTIME_DIR and /run/user/UID resolve to the same socket directory")
168 }
169}
170
171// startIgnoringTerm starts a shell that ignores SIGTERM and reports once the
172// trap is installed.
173func startIgnoringTerm(t *testing.T) (*exec.Cmd, <-chan *os.ProcessState) {
174 t.Helper()
175 cmd := exec.Command("sh", "-c", "trap '' TERM; echo ready; while :; do sleep 0.05; done")
176 stdout, err := cmd.StdoutPipe()
177 require.NoError(t, err)
178 require.NoError(t, cmd.Start())
179 line := make([]byte, 6)
180 _, err = io.ReadFull(stdout, line)
181 require.NoError(t, err)
182 done := make(chan *os.ProcessState, 1)
183 go func() { _ = cmd.Wait(); done <- cmd.ProcessState }()
184 t.Cleanup(func() { _ = cmd.Process.Kill() })
185 return cmd, done
186}
187
188func TestStopWaveEscalatesToSigkillAndKeepsGoing(t *testing.T) {
189 stubborn, done := startIgnoringTerm(t)
190 polite := exec.Command("sleep", "30")
191 require.NoError(t, polite.Start())
192 go func() { _ = polite.Wait() }()
193
194 var report killAllReport
195 _ = stopWave(context.Background(), []vevProcess{{pid: stubborn.Process.Pid, role: "daemon"}, {pid: polite.Process.Pid, role: "client"}}, 100*time.Millisecond, &report)
196
197 state := <-done
198 status, ok := state.Sys().(syscall.WaitStatus)
199 require.True(t, ok)
200 require.Equal(t, syscall.SIGKILL, status.Signal(), "a process ignoring SIGTERM is killed")
201 require.Empty(t, report.errs)
202 require.Len(t, report.stopped, 2, "the rest of the wave still stops")
203 require.Equal(t, []vevProcess{{pid: stubborn.Process.Pid, role: "daemon"}}, report.forced)
204}
205
206func TestRemoveDeadSockets(t *testing.T) {
207 dir, err := os.MkdirTemp("", "vevk")
208 require.NoError(t, err)
209 t.Cleanup(func() { _ = os.RemoveAll(dir) })
210 require.NoError(t, os.Chmod(dir, 0o700))
211 require.NoError(t, os.Mkdir(filepath.Join(dir, "broker"), 0o700))
212
213 live, err := net.Listen("unix", filepath.Join(dir, "daemon.sock"))
214 require.NoError(t, err)
215 t.Cleanup(func() { _ = live.Close() })
216 dead, err := net.Listen("unix", filepath.Join(dir, "daemonmux.sock"))
217 require.NoError(t, err)
218 dead.(*net.UnixListener).SetUnlinkOnClose(false)
219 require.NoError(t, dead.Close())
220 notSocket := filepath.Join(dir, "broker", "broker.sock")
221 require.NoError(t, os.WriteFile(notSocket, nil, 0o600))
222
223 removed := removeDeadSockets(dir)
224 require.Equal(t, []string{filepath.Join(dir, "daemonmux.sock")}, removed)
225 require.FileExists(t, filepath.Join(dir, "daemon.sock"), "a live socket is kept")
226 require.FileExists(t, notSocket, "a regular file is never removed")
227}
228
229func TestRemoveDeadSocketsSkipsBusyLifecycle(t *testing.T) {
230 dir, err := os.MkdirTemp("", "vevk")
231 require.NoError(t, err)
232 t.Cleanup(func() { _ = os.RemoveAll(dir) })
233 require.NoError(t, os.Chmod(dir, 0o700))
234 dead, err := net.Listen("unix", filepath.Join(dir, "daemon.sock"))
235 require.NoError(t, err)
236 dead.(*net.UnixListener).SetUnlinkOnClose(false)
237 require.NoError(t, dead.Close())
238
239 owner, err := lifecycle.TryAcquire(dir)
240 require.NoError(t, err)
241 t.Cleanup(func() { _ = owner.Release() })
242
243 require.Empty(t, removeDeadSockets(dir), "a starting owner keeps its socket")
244}
245
246func TestKillAllRefusesInsideVev(t *testing.T) {
247 t.Setenv("VEV", "1")
248 var out bytes.Buffer
249 err := runKillAll(context.Background(), &out)
250 require.ErrorContains(t, err, "outside vev")
251 require.Empty(t, out.String())
252}
253
254func TestPrintKillAllReport(t *testing.T) {
255 tests := []struct {
256 name string
257 report killAllReport
258 want string
259 }{
260 {name: "nothing running", want: "nothing to stop: vev was not running\n"},
261 {
262 name: "clean stop",
263 report: killAllReport{stopped: []vevProcess{{pid: 7, role: "broker"}}, removed: []string{"/run/x/daemon.sock"}},
264 want: "stopped broker (pid 7)\nremoved stale socket /run/x/daemon.sock\nvev is fully stopped; named sessions come back on the next start, numbered sessions are closed\n",
265 },
266 {
267 name: "forced daemon warns",
268 report: killAllReport{stopped: []vevProcess{{pid: 9, role: "daemon"}}, forced: []vevProcess{{pid: 9, role: "daemon"}}},
269 want: "stopped daemon (pid 9)\nwarning: the daemon (pid 9) had to be force-killed; recent output in named sessions may be lost\nvev is fully stopped; named sessions come back on the next start, numbered sessions are closed\n",
270 },
271 {
272 name: "failure never claims a full stop",
273 report: killAllReport{errs: []error{errors.New("stuck")}},
274 want: "",
275 },
276 }
277 for _, tt := range tests {
278 t.Run(tt.name, func(t *testing.T) {
279 var out bytes.Buffer
280 printKillAllReport(&out, tt.report)
281 require.Equal(t, tt.want, out.String())
282 })
283 }
284}
285
286// TestWaitProcessExitTreatsZombieAsGone pins that an exited child its parent
287// has not reaped yet counts as stopped.
288func TestWaitProcessExitTreatsZombieAsGone(t *testing.T) {
289 cmd := exec.Command("sh", "-c", "exit 0")
290 require.NoError(t, cmd.Start())
291 t.Cleanup(func() { _ = cmd.Wait() }) // reap only after the check
292 require.NoError(t, waitProcessExit(context.Background(), cmd.Process.Pid, 2*time.Second))
293}