kill_all_linux_test.go RawHistoryFolder

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}

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 12

Tags 11

↑↓ moveenter openesc close