detached 436e203

fix(kill): scope kill --all to one environment, rescan for respawns, and add a hidden daemon stop

commit
436e203130502f7a5aee206c7a80150f935f0356
author
brice · · 2026-09-23 21:18 UTC
parent
0a0e1c0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +281 -102
M README.md +2 -1
@@ -35,7 +35,8 @@ vev attach user@host[:session] attach to a remote daemon
3535 vev ls [<host>|--all] list sessions
3636 vev host add|rm <host> manage remote hosts
3737 vev host list list remote hosts
38-vev kill <name>|--all kill sessions
38+vev kill <name>|--sessions kill one or every session
39+vev kill --all stop every vev process (named sessions return)
3940 vev --web-daemon start the private browser terminal
4041 vev --ui-driver [options] drive a headless attachment over JSONL
4142 vev --ui-observe ... expose passive observation for an attach
@@ -35,7 +35,8 @@ vev attach user@host[:session] attach to a remote daemon
35 vev ls [<host>|--all] list sessions35 vev ls [<host>|--all] list sessions
36 vev host add|rm <host> manage remote hosts36 vev host add|rm <host> manage remote hosts
37 vev host list list remote hosts37 vev host list list remote hosts
38-vev kill <name>|--all kill sessions38+vev kill <name>|--sessions kill one or every session
39+vev kill --all stop every vev process (named sessions return)
39 vev --web-daemon start the private browser terminal40 vev --web-daemon start the private browser terminal
40 vev --ui-driver [options] drive a headless attachment over JSONL41 vev --ui-driver [options] drive a headless attachment over JSONL
41 vev --ui-observe ... expose passive observation for an attach42 vev --ui-observe ... expose passive observation for an attach
M cmd/vev-perf-harness/launcher.go +1 -1
@@ -335,7 +335,7 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces
335335 p.shutdown = func() error {
336336 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
337337 defer cancel()
338- shutdown := exec.CommandContext(ctx, bin, "kill", "--daemon")
338+ shutdown := exec.CommandContext(ctx, bin, "_daemon-stop")
339339 shutdown.Env = withoutEnv(cmd.Env, "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN")
340340 return shutdown.Run()
341341 }
@@ -335,7 +335,7 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces
335 p.shutdown = func() error {335 p.shutdown = func() error {
336 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)336 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
337 defer cancel()337 defer cancel()
338- shutdown := exec.CommandContext(ctx, bin, "kill", "--daemon")338+ shutdown := exec.CommandContext(ctx, bin, "_daemon-stop")
339 shutdown.Env = withoutEnv(cmd.Env, "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN")339 shutdown.Env = withoutEnv(cmd.Env, "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN")
340 return shutdown.Run()340 return shutdown.Run()
341 }341 }
M internal/app/daemon_integration_test.go +10 -10
@@ -703,7 +703,7 @@ func TestIntegration_EphemeralSurvivesDetachAndReattaches(t *testing.T) {
703703 case err := <-served:
704704 require.NoError(t, err)
705705 case <-time.After(5 * time.Second):
706- t.Fatal("daemon did not stop after kill --daemon")
706+ t.Fatal("daemon did not stop after a daemon stop request")
707707 }
708708 }
709709
@@ -726,7 +726,7 @@ func TestIntegration_EphemeralNotListedAfterDaemonRestart(t *testing.T) {
726726 case err := <-served:
727727 require.NoError(t, err)
728728 case <-time.After(5 * time.Second):
729- t.Fatal("daemon did not stop after kill --daemon")
729+ t.Fatal("daemon did not stop after a daemon stop request")
730730 }
731731
732732 _, served2 := startDaemonInDir(t, dir, daemon.WithShell("/bin/sh", []string{"-c", "sleep 30"}))
@@ -738,7 +738,7 @@ func TestIntegration_EphemeralNotListedAfterDaemonRestart(t *testing.T) {
738738 case err := <-served2:
739739 require.NoError(t, err)
740740 case <-time.After(5 * time.Second):
741- t.Fatal("restarted daemon did not stop after kill --daemon")
741+ t.Fatal("restarted daemon did not stop after a daemon stop request")
742742 }
743743 }
744744
@@ -795,7 +795,7 @@ func TestIntegration_KillDaemonPreservesMultipleNamedSessions(t *testing.T) {
795795 case err := <-served:
796796 require.NoError(t, err)
797797 case <-time.After(5 * time.Second):
798- t.Fatal("restarted daemon did not stop after kill --daemon")
798+ t.Fatal("restarted daemon did not stop after a daemon stop request")
799799 }
800800
801801 served = start()
@@ -899,7 +899,7 @@ func TestIntegration_NamedSessionLastRemovalKeepsDaemonServing(t *testing.T) {
899899 case err := <-served:
900900 require.NoError(t, err)
901901 case <-time.After(5 * time.Second):
902- t.Fatal("daemon did not stop after kill --daemon")
902+ t.Fatal("daemon did not stop after a daemon stop request")
903903 }
904904 }
905905
@@ -964,7 +964,7 @@ func TestIntegration_NamedSessionRestoresPersistedIdentity(t *testing.T) {
964964 case err := <-served:
965965 require.NoError(t, err)
966966 case <-time.After(5 * time.Second):
967- t.Fatal("daemon did not stop after kill --daemon")
967+ t.Fatal("daemon did not stop after a daemon stop request")
968968 }
969969 }
970970
@@ -1687,12 +1687,12 @@ func (*integrationTransport) Close() error { return nil }
16871687 func (*integrationTransport) LocalAddr() net.Addr { return nil }
16881688 func (*integrationTransport) RemoteAddr() net.Addr { return nil }
16891689
1690-// TestIntegration_KillAllPurgesSessionsAndKeepsDaemon is the P4.2 app-level
1691-// contract: `kill --all` removes every live and stopped session but leaves the
1690+// TestIntegration_KillSessionsPurgesSessionsAndKeepsDaemon is the P4.2 app-level
1691+// contract: `kill --sessions` removes every live and stopped session but leaves the
16921692 // daemon serving, and the same process accepts fresh named and ephemeral
16931693 // sessions that start PTYs, forward input, and open a tab through the ordinary
16941694 // command palette. Only the distinct explicit daemon stop ends it.
1695-func TestIntegration_KillAllPurgesSessionsAndKeepsDaemon(t *testing.T) {
1695+func TestIntegration_KillSessionsPurgesSessionsAndKeepsDaemon(t *testing.T) {
16961696 sz := domain.Size{Cols: 80, Rows: 24}
16971697 dir, served := startDaemon(t, daemon.WithShell("/bin/cat", nil))
16981698
@@ -1748,6 +1748,6 @@ func TestIntegration_KillAllPurgesSessionsAndKeepsDaemon(t *testing.T) {
17481748 case err := <-served:
17491749 require.NoError(t, err)
17501750 case <-time.After(5 * time.Second):
1751- t.Fatal("daemon did not stop after kill --daemon")
1751+ t.Fatal("daemon did not stop after a daemon stop request")
17521752 }
17531753 }
@@ -703,7 +703,7 @@ func TestIntegration_EphemeralSurvivesDetachAndReattaches(t *testing.T) {
703 case err := <-served:703 case err := <-served:
704 require.NoError(t, err)704 require.NoError(t, err)
705 case <-time.After(5 * time.Second):705 case <-time.After(5 * time.Second):
706- t.Fatal("daemon did not stop after kill --daemon")706+ t.Fatal("daemon did not stop after a daemon stop request")
707 }707 }
708 }708 }
709 709
@@ -726,7 +726,7 @@ func TestIntegration_EphemeralNotListedAfterDaemonRestart(t *testing.T) {
726 case err := <-served:726 case err := <-served:
727 require.NoError(t, err)727 require.NoError(t, err)
728 case <-time.After(5 * time.Second):728 case <-time.After(5 * time.Second):
729- t.Fatal("daemon did not stop after kill --daemon")729+ t.Fatal("daemon did not stop after a daemon stop request")
730 }730 }
731 731
732 _, served2 := startDaemonInDir(t, dir, daemon.WithShell("/bin/sh", []string{"-c", "sleep 30"}))732 _, served2 := startDaemonInDir(t, dir, daemon.WithShell("/bin/sh", []string{"-c", "sleep 30"}))
@@ -738,7 +738,7 @@ func TestIntegration_EphemeralNotListedAfterDaemonRestart(t *testing.T) {
738 case err := <-served2:738 case err := <-served2:
739 require.NoError(t, err)739 require.NoError(t, err)
740 case <-time.After(5 * time.Second):740 case <-time.After(5 * time.Second):
741- t.Fatal("restarted daemon did not stop after kill --daemon")741+ t.Fatal("restarted daemon did not stop after a daemon stop request")
742 }742 }
743 }743 }
744 744
@@ -795,7 +795,7 @@ func TestIntegration_KillDaemonPreservesMultipleNamedSessions(t *testing.T) {
795 case err := <-served:795 case err := <-served:
796 require.NoError(t, err)796 require.NoError(t, err)
797 case <-time.After(5 * time.Second):797 case <-time.After(5 * time.Second):
798- t.Fatal("restarted daemon did not stop after kill --daemon")798+ t.Fatal("restarted daemon did not stop after a daemon stop request")
799 }799 }
800 800
801 served = start()801 served = start()
@@ -899,7 +899,7 @@ func TestIntegration_NamedSessionLastRemovalKeepsDaemonServing(t *testing.T) {
899 case err := <-served:899 case err := <-served:
900 require.NoError(t, err)900 require.NoError(t, err)
901 case <-time.After(5 * time.Second):901 case <-time.After(5 * time.Second):
902- t.Fatal("daemon did not stop after kill --daemon")902+ t.Fatal("daemon did not stop after a daemon stop request")
903 }903 }
904 }904 }
905 905
@@ -964,7 +964,7 @@ func TestIntegration_NamedSessionRestoresPersistedIdentity(t *testing.T) {
964 case err := <-served:964 case err := <-served:
965 require.NoError(t, err)965 require.NoError(t, err)
966 case <-time.After(5 * time.Second):966 case <-time.After(5 * time.Second):
967- t.Fatal("daemon did not stop after kill --daemon")967+ t.Fatal("daemon did not stop after a daemon stop request")
968 }968 }
969 }969 }
970 970
@@ -1687,12 +1687,12 @@ func (*integrationTransport) Close() error { return nil }
1687 func (*integrationTransport) LocalAddr() net.Addr { return nil }1687 func (*integrationTransport) LocalAddr() net.Addr { return nil }
1688 func (*integrationTransport) RemoteAddr() net.Addr { return nil }1688 func (*integrationTransport) RemoteAddr() net.Addr { return nil }
1689 1689
1690-// TestIntegration_KillAllPurgesSessionsAndKeepsDaemon is the P4.2 app-level1690+// TestIntegration_KillSessionsPurgesSessionsAndKeepsDaemon is the P4.2 app-level
1691-// contract: `kill --all` removes every live and stopped session but leaves the1691+// contract: `kill --sessions` removes every live and stopped session but leaves the
1692 // daemon serving, and the same process accepts fresh named and ephemeral1692 // daemon serving, and the same process accepts fresh named and ephemeral
1693 // sessions that start PTYs, forward input, and open a tab through the ordinary1693 // sessions that start PTYs, forward input, and open a tab through the ordinary
1694 // command palette. Only the distinct explicit daemon stop ends it.1694 // command palette. Only the distinct explicit daemon stop ends it.
1695-func TestIntegration_KillAllPurgesSessionsAndKeepsDaemon(t *testing.T) {1695+func TestIntegration_KillSessionsPurgesSessionsAndKeepsDaemon(t *testing.T) {
1696 sz := domain.Size{Cols: 80, Rows: 24}1696 sz := domain.Size{Cols: 80, Rows: 24}
1697 dir, served := startDaemon(t, daemon.WithShell("/bin/cat", nil))1697 dir, served := startDaemon(t, daemon.WithShell("/bin/cat", nil))
1698 1698
@@ -1748,6 +1748,6 @@ func TestIntegration_KillAllPurgesSessionsAndKeepsDaemon(t *testing.T) {
1748 case err := <-served:1748 case err := <-served:
1749 require.NoError(t, err)1749 require.NoError(t, err)
1750 case <-time.After(5 * time.Second):1750 case <-time.After(5 * time.Second):
1751- t.Fatal("daemon did not stop after kill --daemon")1751+ t.Fatal("daemon did not stop after a daemon stop request")
1752 }1752 }
1753 }1753 }
M internal/app/kill_all_linux.go +144 -63
@@ -18,11 +18,17 @@ import (
1818
1919 "github.com/bnema/vev/internal/adapters/brokeripc"
2020 "github.com/bnema/vev/internal/adapters/ipc"
21+ "github.com/bnema/vev/internal/adapters/lifecycle"
2122 )
2223
23-// killAllStopTimeout bounds how long each wave waits after SIGTERM before it
24-// escalates to SIGKILL.
25-const killAllStopTimeout = 5 * time.Second
24+const (
25+ // killAllStopTimeout bounds how long each wave waits after SIGTERM
26+ // before it escalates to SIGKILL.
27+ killAllStopTimeout = 5 * time.Second
28+ // killAllRounds caps how many scan-and-stop rounds run, so a process that
29+ // a stopping one respawned is caught by the next scan.
30+ killAllRounds = 3
31+)
2632
2733 // vevProcess is one running process of the installed vev binary.
2834 type vevProcess struct {
@@ -30,11 +36,29 @@ type vevProcess struct {
3036 role string // "client", "broker", "daemon", or "helper"
3137 }
3238
33-// runKillAll stops every vev process of this user that runs the installed
34-// binary, including builds an upgrade has replaced on disk, then removes the
35-// sockets they left behind. It needs no broker, so it also works when the
36-// running broker is from another build. Named sessions are checkpointed by the
37-// daemon on SIGTERM and come back on the next start.
39+// killAllScope selects which processes `kill --all` may stop: this user's
40+// processes of the installed binary that run in the same vev runtime
41+// directory, so VEV_ENV and XDG-sandboxed runs never touch each other.
42+type killAllScope struct {
43+ procRoot string
44+ exe string
45+ uid int
46+ self int
47+ runtimeDir string // XDG_RUNTIME_DIR as vev resolved it; "" when unset
48+}
49+
50+// killAllReport is what one `kill --all` did.
51+type killAllReport struct {
52+ stopped []vevProcess
53+ forced []vevProcess // needed SIGKILL
54+ removed []string
55+ errs []error
56+}
57+
58+// runKillAll stops every vev process of this environment, including builds an
59+// upgrade replaced on disk, then removes the sockets they left behind. It needs
60+// no broker, so it also works when the running broker is from another build.
61+// The daemon checkpoints named sessions on SIGTERM; numbered sessions close.
3862 func runKillAll(ctx context.Context, out io.Writer) error {
3963 if os.Getenv("VEV") != "" {
4064 return errors.New("vev: `kill --all` stops the vev you are running in; run it from a terminal outside vev")
@@ -43,28 +67,54 @@ func runKillAll(ctx context.Context, out io.Writer) error {
4367 if err != nil {
4468 return fmt.Errorf("vev: resolve executable: %w", err)
4569 }
46- procs, err := findVevProcesses("/proc", installedPath(exe), os.Getuid(), os.Getpid())
47- if err != nil {
48- return err
70+ scope := killAllScope{
71+ procRoot: "/proc",
72+ exe: installedPath(exe),
73+ uid: os.Getuid(),
74+ self: os.Getpid(),
75+ runtimeDir: os.Getenv("XDG_RUNTIME_DIR"),
4976 }
50- // Clients first, so none of them can start a new broker or daemon from
51- // its old binary while the rest stop.
52- var clients, services []vevProcess
53- for _, p := range procs {
54- if p.role == "client" {
55- clients = append(clients, p)
56- } else {
57- services = append(services, p)
77+ report := stopEverything(ctx, scope, killAllStopTimeout, ipc.SocketDir())
78+ printKillAllReport(out, report)
79+ return errors.Join(report.errs...)
80+}
81+
82+// stopEverything runs scan-and-stop rounds until a scan finds nothing, then removes
83+// dead sockets.
84+func stopEverything(ctx context.Context, scope killAllScope, timeout time.Duration, socketDir string) killAllReport {
85+ var report killAllReport
86+ for round := 0; round < killAllRounds; round++ {
87+ procs, err := findVevProcesses(scope)
88+ if err != nil {
89+ report.errs = append(report.errs, err)
90+ return report
5891 }
59- }
60- for _, wave := range [][]vevProcess{clients, services} {
61- if err := stopProcesses(ctx, wave, killAllStopTimeout); err != nil {
62- return err
92+ if len(procs) == 0 {
93+ report.removed = removeDeadSockets(socketDir)
94+ return report
95+ }
96+ // Clients first, so none of them can start a broker or daemon from
97+ // its old binary while the rest stop.
98+ var clients, services []vevProcess
99+ for _, p := range procs {
100+ if p.role == "client" {
101+ clients = append(clients, p)
102+ } else {
103+ services = append(services, p)
104+ }
105+ }
106+ for _, wave := range [][]vevProcess{clients, services} {
107+ stopWave(ctx, wave, timeout, &report)
63108 }
109+ if ctx.Err() != nil {
110+ report.errs = append(report.errs, ctx.Err())
111+ return report
112+ }
113+ }
114+ if left, err := findVevProcesses(scope); err == nil && len(left) > 0 {
115+ report.errs = append(report.errs, fmt.Errorf("vev: %d vev processes kept starting again; run `vev kill --all` once more", len(left)))
64116 }
65- removed := removeDeadSockets(ipc.SocketDir())
66- printKillAllReport(out, append(clients, services...), removed)
67- return nil
117+ return report
68118 }
69119
70120 // installedPath is the on-disk path an executable was started from, without
@@ -73,31 +123,35 @@ func installedPath(exe string) string {
73123 return strings.TrimSuffix(exe, " (deleted)")
74124 }
75125
76-// findVevProcesses lists processes owned by uid whose executable is exe, or
77-// was exe before an upgrade replaced it. self is never included.
78-func findVevProcesses(procRoot, exe string, uid, self int) ([]vevProcess, error) {
79- entries, err := os.ReadDir(procRoot)
126+// findVevProcesses lists the processes in scope. The caller never matches
127+// itself.
128+func findVevProcesses(scope killAllScope) ([]vevProcess, error) {
129+ entries, err := os.ReadDir(scope.procRoot)
80130 if err != nil {
81131 return nil, fmt.Errorf("vev: list processes: %w", err)
82132 }
83133 var procs []vevProcess
84134 for _, entry := range entries {
85135 pid, err := strconv.Atoi(entry.Name())
86- if err != nil || pid == self {
136+ if err != nil || pid == scope.self {
87137 continue
88138 }
89- dir := filepath.Join(procRoot, entry.Name())
139+ dir := filepath.Join(scope.procRoot, entry.Name())
90140 info, err := os.Stat(dir)
91141 if err != nil {
92142 continue // the process exited meanwhile
93143 }
94- if stat, ok := info.Sys().(*syscall.Stat_t); !ok || int(stat.Uid) != uid {
144+ if stat, ok := info.Sys().(*syscall.Stat_t); !ok || int(stat.Uid) != scope.uid {
95145 continue
96146 }
97147 target, err := os.Readlink(filepath.Join(dir, "exe"))
98- if err != nil || installedPath(target) != exe {
148+ if err != nil || installedPath(target) != scope.exe {
99149 continue
100150 }
151+ environ, err := os.ReadFile(filepath.Join(dir, "environ"))
152+ if err != nil || environValue(environ, "XDG_RUNTIME_DIR") != scope.runtimeDir {
153+ continue // another VEV_ENV or sandbox
154+ }
101155 cmdline, err := os.ReadFile(filepath.Join(dir, "cmdline"))
102156 if err != nil {
103157 continue
@@ -107,6 +161,17 @@ func findVevProcesses(procRoot, exe string, uid, self int) ([]vevProcess, error)
107161 return procs, nil
108162 }
109163
164+// environValue returns key's value in a NUL-separated environment, or "".
165+func environValue(environ []byte, key string) string {
166+ prefix := key + "="
167+ for _, entry := range strings.Split(string(environ), "\x00") {
168+ if value, ok := strings.CutPrefix(entry, prefix); ok {
169+ return value
170+ }
171+ }
172+ return ""
173+}
174+
110175 // processRole names a vev process from its NUL-separated command line.
111176 func processRole(cmdline []byte) string {
112177 args := strings.Split(strings.TrimRight(string(cmdline), "\x00"), "\x00")
@@ -118,72 +183,88 @@ func processRole(cmdline []byte) string {
118183 return "daemon"
119184 case productionBrokerServeCommand:
120185 return "broker"
186+ case "--daemon-launcher", "--web-daemon", "--web-serve", "--ui-driver":
187+ return "helper"
121188 }
122- if strings.HasPrefix(args[1], "_") || args[1] == "--web-daemon" {
189+ if strings.HasPrefix(args[1], "_") {
123190 return "helper"
124191 }
125192 return "client"
126193 }
127194
128-// stopProcesses sends SIGTERM to every process, waits up to timeout for all of
129-// them to exit, then sends SIGKILL to the ones still running.
130-func stopProcesses(ctx context.Context, procs []vevProcess, timeout time.Duration) error {
195+// stopWave sends SIGTERM to every process, waits up to timeout for all of them
196+// to exit, then sends SIGKILL to the ones still running. One stuck process
197+// never stops the rest of the wave.
198+func stopWave(ctx context.Context, procs []vevProcess, timeout time.Duration, report *killAllReport) {
131199 for _, p := range procs {
132200 _ = syscall.Kill(p.pid, syscall.SIGTERM)
133201 }
134202 deadline := time.Now().Add(timeout)
135203 for _, p := range procs {
136- wait := max(time.Until(deadline), 0)
137- if err := waitProcessExit(ctx, p.pid, wait); err == nil {
204+ if waitProcessExit(ctx, p.pid, max(time.Until(deadline), 0)) == nil {
205+ report.stopped = append(report.stopped, p)
138206 continue
139207 }
140- if err := ctx.Err(); err != nil {
141- return err
142- }
143208 _ = syscall.Kill(p.pid, syscall.SIGKILL)
144- if err := waitProcessExit(ctx, p.pid, time.Second); err != nil {
145- return fmt.Errorf("vev: %s (pid %d) did not stop: %w", p.role, p.pid, err)
209+ if err := waitProcessExit(context.WithoutCancel(ctx), p.pid, time.Second); err != nil {
210+ report.errs = append(report.errs, fmt.Errorf("vev: %s (pid %d) did not stop: %w", p.role, p.pid, err))
211+ continue
146212 }
213+ report.stopped = append(report.stopped, p)
214+ report.forced = append(report.forced, p)
147215 }
148- return nil
149216 }
150217
151218 // removeDeadSockets deletes vev sockets nobody listens on any more, so the
152-// next start never trips over a leftover endpoint. A live socket is kept.
219+// next start never trips over a leftover endpoint. It holds each lifecycle
220+// lock while it checks, so a daemon or broker that is starting keeps its
221+// socket, and it never removes anything that is not a socket.
153222 func removeDeadSockets(dir string) []string {
154223 var removed []string
155- for _, path := range []string{
156- filepath.Join(dir, "daemon.sock"),
157- filepath.Join(dir, "daemonmux.sock"),
158- brokeripc.SocketPath(filepath.Join(dir, "broker")),
224+ for _, runtime := range []struct{ lockDir, socket string }{
225+ {dir, filepath.Join(dir, "daemon.sock")},
226+ {dir, filepath.Join(dir, "daemonmux.sock")},
227+ {filepath.Join(dir, "broker"), brokeripc.SocketPath(filepath.Join(dir, "broker"))},
159228 } {
160- conn, err := net.DialTimeout("unix", path, 200*time.Millisecond)
161- if err == nil {
162- _ = conn.Close()
229+ info, err := os.Lstat(runtime.socket)
230+ if err != nil || info.Mode().Type() != os.ModeSocket {
163231 continue
164232 }
165- if !errors.Is(err, syscall.ECONNREFUSED) {
166- continue
233+ owner, err := lifecycle.TryAcquire(runtime.lockDir)
234+ if err != nil {
235+ continue // busy: a process is starting or still owns it
167236 }
168- if os.Remove(path) == nil {
169- removed = append(removed, path)
237+ conn, err := net.DialTimeout("unix", runtime.socket, 200*time.Millisecond)
238+ if err == nil {
239+ _ = conn.Close()
240+ } else if errors.Is(err, syscall.ECONNREFUSED) && os.Remove(runtime.socket) == nil {
241+ removed = append(removed, runtime.socket)
170242 }
243+ _ = owner.Release()
171244 }
172245 return removed
173246 }
174247
175-func printKillAllReport(out io.Writer, procs []vevProcess, removed []string) {
176- if len(procs) == 0 && len(removed) == 0 {
248+func printKillAllReport(out io.Writer, report killAllReport) {
249+ if len(report.stopped) == 0 && len(report.removed) == 0 && len(report.errs) == 0 {
177250 _, _ = fmt.Fprintln(out, "nothing to stop: vev was not running")
178251 return
179252 }
180- for _, p := range procs {
253+ for _, p := range report.stopped {
181254 _, _ = fmt.Fprintf(out, "stopped %s (pid %d)\n", p.role, p.pid)
182255 }
183- for _, path := range removed {
256+ for _, path := range report.removed {
184257 _, _ = fmt.Fprintf(out, "removed stale socket %s\n", path)
185258 }
186- _, _ = fmt.Fprintln(out, "vev is fully stopped; named sessions come back on the next start")
259+ for _, p := range report.forced {
260+ if p.role == "daemon" {
261+ _, _ = fmt.Fprintf(out, "warning: the daemon (pid %d) had to be force-killed; recent output in named sessions may be lost\n", p.pid)
262+ }
263+ }
264+ if len(report.errs) > 0 {
265+ return
266+ }
267+ _, _ = fmt.Fprintln(out, "vev is fully stopped; named sessions come back on the next start, numbered sessions are closed")
187268 }
188269
189270 // waitProcessExit polls until pid no longer exists or is a zombie: a dead
@@ -18,11 +18,17 @@ import (
18 18
19 "github.com/bnema/vev/internal/adapters/brokeripc"19 "github.com/bnema/vev/internal/adapters/brokeripc"
20 "github.com/bnema/vev/internal/adapters/ipc"20 "github.com/bnema/vev/internal/adapters/ipc"
21+ "github.com/bnema/vev/internal/adapters/lifecycle"
21 )22 )
22 23
23-// killAllStopTimeout bounds how long each wave waits after SIGTERM before it24+const (
24-// escalates to SIGKILL.25+ // killAllStopTimeout bounds how long each wave waits after SIGTERM
25-const killAllStopTimeout = 5 * time.Second26+ // before it escalates to SIGKILL.
27+ killAllStopTimeout = 5 * time.Second
28+ // killAllRounds caps how many scan-and-stop rounds run, so a process that
29+ // a stopping one respawned is caught by the next scan.
30+ killAllRounds = 3
31+)
26 32
27 // vevProcess is one running process of the installed vev binary.33 // vevProcess is one running process of the installed vev binary.
28 type vevProcess struct {34 type vevProcess struct {
@@ -30,11 +36,29 @@ type vevProcess struct {
30 role string // "client", "broker", "daemon", or "helper"36 role string // "client", "broker", "daemon", or "helper"
31 }37 }
32 38
33-// runKillAll stops every vev process of this user that runs the installed39+// killAllScope selects which processes `kill --all` may stop: this user's
34-// binary, including builds an upgrade has replaced on disk, then removes the40+// processes of the installed binary that run in the same vev runtime
35-// sockets they left behind. It needs no broker, so it also works when the41+// directory, so VEV_ENV and XDG-sandboxed runs never touch each other.
36-// running broker is from another build. Named sessions are checkpointed by the42+type killAllScope struct {
37-// daemon on SIGTERM and come back on the next start.43+ procRoot string
44+ exe string
45+ uid int
46+ self int
47+ runtimeDir string // XDG_RUNTIME_DIR as vev resolved it; "" when unset
48+}
49+
50+// killAllReport is what one `kill --all` did.
51+type killAllReport struct {
52+ stopped []vevProcess
53+ forced []vevProcess // needed SIGKILL
54+ removed []string
55+ errs []error
56+}
57+
58+// runKillAll stops every vev process of this environment, including builds an
59+// upgrade replaced on disk, then removes the sockets they left behind. It needs
60+// no broker, so it also works when the running broker is from another build.
61+// The daemon checkpoints named sessions on SIGTERM; numbered sessions close.
38 func runKillAll(ctx context.Context, out io.Writer) error {62 func runKillAll(ctx context.Context, out io.Writer) error {
39 if os.Getenv("VEV") != "" {63 if os.Getenv("VEV") != "" {
40 return errors.New("vev: `kill --all` stops the vev you are running in; run it from a terminal outside vev")64 return errors.New("vev: `kill --all` stops the vev you are running in; run it from a terminal outside vev")
@@ -43,28 +67,54 @@ func runKillAll(ctx context.Context, out io.Writer) error {
43 if err != nil {67 if err != nil {
44 return fmt.Errorf("vev: resolve executable: %w", err)68 return fmt.Errorf("vev: resolve executable: %w", err)
45 }69 }
46- procs, err := findVevProcesses("/proc", installedPath(exe), os.Getuid(), os.Getpid())70+ scope := killAllScope{
47- if err != nil {71+ procRoot: "/proc",
48- return err72+ exe: installedPath(exe),
73+ uid: os.Getuid(),
74+ self: os.Getpid(),
75+ runtimeDir: os.Getenv("XDG_RUNTIME_DIR"),
49 }76 }
50- // Clients first, so none of them can start a new broker or daemon from77+ report := stopEverything(ctx, scope, killAllStopTimeout, ipc.SocketDir())
51- // its old binary while the rest stop.78+ printKillAllReport(out, report)
52- var clients, services []vevProcess79+ return errors.Join(report.errs...)
53- for _, p := range procs {80+}
54- if p.role == "client" {81+
55- clients = append(clients, p)82+// stopEverything runs scan-and-stop rounds until a scan finds nothing, then removes
56- } else {83+// dead sockets.
57- services = append(services, p)84+func stopEverything(ctx context.Context, scope killAllScope, timeout time.Duration, socketDir string) killAllReport {
85+ var report killAllReport
86+ for round := 0; round < killAllRounds; round++ {
87+ procs, err := findVevProcesses(scope)
88+ if err != nil {
89+ report.errs = append(report.errs, err)
90+ return report
58 }91 }
59- }92+ if len(procs) == 0 {
60- for _, wave := range [][]vevProcess{clients, services} {93+ report.removed = removeDeadSockets(socketDir)
61- if err := stopProcesses(ctx, wave, killAllStopTimeout); err != nil {94+ return report
62- return err95+ }
96+ // Clients first, so none of them can start a broker or daemon from
97+ // its old binary while the rest stop.
98+ var clients, services []vevProcess
99+ for _, p := range procs {
100+ if p.role == "client" {
101+ clients = append(clients, p)
102+ } else {
103+ services = append(services, p)
104+ }
105+ }
106+ for _, wave := range [][]vevProcess{clients, services} {
107+ stopWave(ctx, wave, timeout, &report)
63 }108 }
109+ if ctx.Err() != nil {
110+ report.errs = append(report.errs, ctx.Err())
111+ return report
112+ }
113+ }
114+ if left, err := findVevProcesses(scope); err == nil && len(left) > 0 {
115+ report.errs = append(report.errs, fmt.Errorf("vev: %d vev processes kept starting again; run `vev kill --all` once more", len(left)))
64 }116 }
65- removed := removeDeadSockets(ipc.SocketDir())117+ return report
66- printKillAllReport(out, append(clients, services...), removed)
67- return nil
68 }118 }
69 119
70 // installedPath is the on-disk path an executable was started from, without120 // installedPath is the on-disk path an executable was started from, without
@@ -73,31 +123,35 @@ func installedPath(exe string) string {
73 return strings.TrimSuffix(exe, " (deleted)")123 return strings.TrimSuffix(exe, " (deleted)")
74 }124 }
75 125
76-// findVevProcesses lists processes owned by uid whose executable is exe, or126+// findVevProcesses lists the processes in scope. The caller never matches
77-// was exe before an upgrade replaced it. self is never included.127+// itself.
78-func findVevProcesses(procRoot, exe string, uid, self int) ([]vevProcess, error) {128+func findVevProcesses(scope killAllScope) ([]vevProcess, error) {
79- entries, err := os.ReadDir(procRoot)129+ entries, err := os.ReadDir(scope.procRoot)
80 if err != nil {130 if err != nil {
81 return nil, fmt.Errorf("vev: list processes: %w", err)131 return nil, fmt.Errorf("vev: list processes: %w", err)
82 }132 }
83 var procs []vevProcess133 var procs []vevProcess
84 for _, entry := range entries {134 for _, entry := range entries {
85 pid, err := strconv.Atoi(entry.Name())135 pid, err := strconv.Atoi(entry.Name())
86- if err != nil || pid == self {136+ if err != nil || pid == scope.self {
87 continue137 continue
88 }138 }
89- dir := filepath.Join(procRoot, entry.Name())139+ dir := filepath.Join(scope.procRoot, entry.Name())
90 info, err := os.Stat(dir)140 info, err := os.Stat(dir)
91 if err != nil {141 if err != nil {
92 continue // the process exited meanwhile142 continue // the process exited meanwhile
93 }143 }
94- if stat, ok := info.Sys().(*syscall.Stat_t); !ok || int(stat.Uid) != uid {144+ if stat, ok := info.Sys().(*syscall.Stat_t); !ok || int(stat.Uid) != scope.uid {
95 continue145 continue
96 }146 }
97 target, err := os.Readlink(filepath.Join(dir, "exe"))147 target, err := os.Readlink(filepath.Join(dir, "exe"))
98- if err != nil || installedPath(target) != exe {148+ if err != nil || installedPath(target) != scope.exe {
99 continue149 continue
100 }150 }
151+ environ, err := os.ReadFile(filepath.Join(dir, "environ"))
152+ if err != nil || environValue(environ, "XDG_RUNTIME_DIR") != scope.runtimeDir {
153+ continue // another VEV_ENV or sandbox
154+ }
101 cmdline, err := os.ReadFile(filepath.Join(dir, "cmdline"))155 cmdline, err := os.ReadFile(filepath.Join(dir, "cmdline"))
102 if err != nil {156 if err != nil {
103 continue157 continue
@@ -107,6 +161,17 @@ func findVevProcesses(procRoot, exe string, uid, self int) ([]vevProcess, error)
107 return procs, nil161 return procs, nil
108 }162 }
109 163
164+// environValue returns key's value in a NUL-separated environment, or "".
165+func environValue(environ []byte, key string) string {
166+ prefix := key + "="
167+ for _, entry := range strings.Split(string(environ), "\x00") {
168+ if value, ok := strings.CutPrefix(entry, prefix); ok {
169+ return value
170+ }
171+ }
172+ return ""
173+}
174+
110 // processRole names a vev process from its NUL-separated command line.175 // processRole names a vev process from its NUL-separated command line.
111 func processRole(cmdline []byte) string {176 func processRole(cmdline []byte) string {
112 args := strings.Split(strings.TrimRight(string(cmdline), "\x00"), "\x00")177 args := strings.Split(strings.TrimRight(string(cmdline), "\x00"), "\x00")
@@ -118,72 +183,88 @@ func processRole(cmdline []byte) string {
118 return "daemon"183 return "daemon"
119 case productionBrokerServeCommand:184 case productionBrokerServeCommand:
120 return "broker"185 return "broker"
186+ case "--daemon-launcher", "--web-daemon", "--web-serve", "--ui-driver":
187+ return "helper"
121 }188 }
122- if strings.HasPrefix(args[1], "_") || args[1] == "--web-daemon" {189+ if strings.HasPrefix(args[1], "_") {
123 return "helper"190 return "helper"
124 }191 }
125 return "client"192 return "client"
126 }193 }
127 194
128-// stopProcesses sends SIGTERM to every process, waits up to timeout for all of195+// stopWave sends SIGTERM to every process, waits up to timeout for all of them
129-// them to exit, then sends SIGKILL to the ones still running.196+// to exit, then sends SIGKILL to the ones still running. One stuck process
130-func stopProcesses(ctx context.Context, procs []vevProcess, timeout time.Duration) error {197+// never stops the rest of the wave.
198+func stopWave(ctx context.Context, procs []vevProcess, timeout time.Duration, report *killAllReport) {
131 for _, p := range procs {199 for _, p := range procs {
132 _ = syscall.Kill(p.pid, syscall.SIGTERM)200 _ = syscall.Kill(p.pid, syscall.SIGTERM)
133 }201 }
134 deadline := time.Now().Add(timeout)202 deadline := time.Now().Add(timeout)
135 for _, p := range procs {203 for _, p := range procs {
136- wait := max(time.Until(deadline), 0)204+ if waitProcessExit(ctx, p.pid, max(time.Until(deadline), 0)) == nil {
137- if err := waitProcessExit(ctx, p.pid, wait); err == nil {205+ report.stopped = append(report.stopped, p)
138 continue206 continue
139 }207 }
140- if err := ctx.Err(); err != nil {
141- return err
142- }
143 _ = syscall.Kill(p.pid, syscall.SIGKILL)208 _ = syscall.Kill(p.pid, syscall.SIGKILL)
144- if err := waitProcessExit(ctx, p.pid, time.Second); err != nil {209+ if err := waitProcessExit(context.WithoutCancel(ctx), p.pid, time.Second); err != nil {
145- return fmt.Errorf("vev: %s (pid %d) did not stop: %w", p.role, p.pid, err)210+ report.errs = append(report.errs, fmt.Errorf("vev: %s (pid %d) did not stop: %w", p.role, p.pid, err))
211+ continue
146 }212 }
213+ report.stopped = append(report.stopped, p)
214+ report.forced = append(report.forced, p)
147 }215 }
148- return nil
149 }216 }
150 217
151 // removeDeadSockets deletes vev sockets nobody listens on any more, so the218 // removeDeadSockets deletes vev sockets nobody listens on any more, so the
152-// next start never trips over a leftover endpoint. A live socket is kept.219+// next start never trips over a leftover endpoint. It holds each lifecycle
220+// lock while it checks, so a daemon or broker that is starting keeps its
221+// socket, and it never removes anything that is not a socket.
153 func removeDeadSockets(dir string) []string {222 func removeDeadSockets(dir string) []string {
154 var removed []string223 var removed []string
155- for _, path := range []string{224+ for _, runtime := range []struct{ lockDir, socket string }{
156- filepath.Join(dir, "daemon.sock"),225+ {dir, filepath.Join(dir, "daemon.sock")},
157- filepath.Join(dir, "daemonmux.sock"),226+ {dir, filepath.Join(dir, "daemonmux.sock")},
158- brokeripc.SocketPath(filepath.Join(dir, "broker")),227+ {filepath.Join(dir, "broker"), brokeripc.SocketPath(filepath.Join(dir, "broker"))},
159 } {228 } {
160- conn, err := net.DialTimeout("unix", path, 200*time.Millisecond)229+ info, err := os.Lstat(runtime.socket)
161- if err == nil {230+ if err != nil || info.Mode().Type() != os.ModeSocket {
162- _ = conn.Close()
163 continue231 continue
164 }232 }
165- if !errors.Is(err, syscall.ECONNREFUSED) {233+ owner, err := lifecycle.TryAcquire(runtime.lockDir)
166- continue234+ if err != nil {
235+ continue // busy: a process is starting or still owns it
167 }236 }
168- if os.Remove(path) == nil {237+ conn, err := net.DialTimeout("unix", runtime.socket, 200*time.Millisecond)
169- removed = append(removed, path)238+ if err == nil {
239+ _ = conn.Close()
240+ } else if errors.Is(err, syscall.ECONNREFUSED) && os.Remove(runtime.socket) == nil {
241+ removed = append(removed, runtime.socket)
170 }242 }
243+ _ = owner.Release()
171 }244 }
172 return removed245 return removed
173 }246 }
174 247
175-func printKillAllReport(out io.Writer, procs []vevProcess, removed []string) {248+func printKillAllReport(out io.Writer, report killAllReport) {
176- if len(procs) == 0 && len(removed) == 0 {249+ if len(report.stopped) == 0 && len(report.removed) == 0 && len(report.errs) == 0 {
177 _, _ = fmt.Fprintln(out, "nothing to stop: vev was not running")250 _, _ = fmt.Fprintln(out, "nothing to stop: vev was not running")
178 return251 return
179 }252 }
180- for _, p := range procs {253+ for _, p := range report.stopped {
181 _, _ = fmt.Fprintf(out, "stopped %s (pid %d)\n", p.role, p.pid)254 _, _ = fmt.Fprintf(out, "stopped %s (pid %d)\n", p.role, p.pid)
182 }255 }
183- for _, path := range removed {256+ for _, path := range report.removed {
184 _, _ = fmt.Fprintf(out, "removed stale socket %s\n", path)257 _, _ = fmt.Fprintf(out, "removed stale socket %s\n", path)
185 }258 }
186- _, _ = fmt.Fprintln(out, "vev is fully stopped; named sessions come back on the next start")259+ for _, p := range report.forced {
260+ if p.role == "daemon" {
261+ _, _ = fmt.Fprintf(out, "warning: the daemon (pid %d) had to be force-killed; recent output in named sessions may be lost\n", p.pid)
262+ }
263+ }
264+ if len(report.errs) > 0 {
265+ return
266+ }
267+ _, _ = fmt.Fprintln(out, "vev is fully stopped; named sessions come back on the next start, numbered sessions are closed")
187 }268 }
188 269
189 // waitProcessExit polls until pid no longer exists or is a zombie: a dead270 // waitProcessExit polls until pid no longer exists or is a zombie: a dead
M internal/app/kill_all_linux_test.go +107 -25
@@ -5,6 +5,8 @@ package app
55 import (
66 "bytes"
77 "context"
8+ "errors"
9+ "io"
810 "net"
911 "os"
1012 "os/exec"
@@ -14,6 +16,8 @@ import (
1416 "time"
1517
1618 "github.com/stretchr/testify/require"
19+
20+ "github.com/bnema/vev/internal/adapters/lifecycle"
1721 )
1822
1923 func TestWaitProcessExit(t *testing.T) {
@@ -62,6 +66,8 @@ func TestProcessRole(t *testing.T) {
6266 {name: "broker", cmdline: "/proc/self/exe\x00_broker-production-serve\x00", want: "broker"},
6367 {name: "hidden helper", cmdline: "/proc/self/exe\x00_broker-mux-quic-proxy\x00", want: "helper"},
6468 {name: "web daemon", cmdline: "vev\x00--web-daemon\x00", want: "helper"},
69+ {name: "web server", cmdline: "/proc/self/exe\x00--web-serve\x00", want: "helper"},
70+ {name: "daemon launcher", cmdline: "/proc/self/exe\x00--daemon-launcher\x00", want: "helper"},
6571 }
6672 for _, tt := range tests {
6773 t.Run(tt.name, func(t *testing.T) {
@@ -71,50 +77,84 @@ func TestProcessRole(t *testing.T) {
7177 }
7278
7379 // TestFindVevProcesses runs against a fake /proc: only this user's processes
74-// of the installed binary (also once replaced on disk) are found, never self.
80+// of the installed binary (also once replaced on disk) in the same runtime
81+// directory are found, never self.
7582 func TestFindVevProcesses(t *testing.T) {
7683 root := t.TempDir()
7784 const exe = "/opt/bin/vev"
85+ const runtime = "XDG_RUNTIME_DIR=/run/user/1000"
7886 uid := os.Getuid()
79- add := func(pid, target, cmdline string) {
87+ add := func(pid, target, cmdline, environ string) {
8088 dir := filepath.Join(root, pid)
8189 require.NoError(t, os.Mkdir(dir, 0o755))
8290 require.NoError(t, os.Symlink(target, filepath.Join(dir, "exe")))
8391 require.NoError(t, os.WriteFile(filepath.Join(dir, "cmdline"), []byte(cmdline), 0o644))
92+ require.NoError(t, os.WriteFile(filepath.Join(dir, "environ"), []byte(environ), 0o644))
8493 }
85- add("10", exe, "vev\x00")
86- add("11", exe+" (deleted)", "/proc/self/exe\x00--daemon\x00")
87- add("12", "/usr/bin/emacs", "emacs\x00--daemon\x00")
88- add("13", "/tmp/vev-test-bin", "/tmp/vev-test-bin\x00--daemon\x00")
89- add("14", exe, "vev\x00") // self
94+ add("10", exe, "vev\x00", "HOME=/h\x00"+runtime+"\x00")
95+ add("11", exe+" (deleted)", "/proc/self/exe\x00--daemon\x00", runtime+"\x00")
96+ add("12", "/usr/bin/emacs", "emacs\x00--daemon\x00", runtime+"\x00")
97+ add("13", "/tmp/vev-test-bin", "/tmp/vev-test-bin\x00--daemon\x00", runtime+"\x00")
98+ add("14", exe, "vev\x00", runtime+"\x00") // self
99+ add("15", exe, "/proc/self/exe\x00--daemon\x00", "XDG_RUNTIME_DIR=/repo/.dev/test/runtime\x00")
90100 require.NoError(t, os.Mkdir(filepath.Join(root, "self"), 0o755))
91101
92- got, err := findVevProcesses(root, exe, uid, 14)
102+ scope := killAllScope{procRoot: root, exe: exe, uid: uid, self: 14, runtimeDir: "/run/user/1000"}
103+ got, err := findVevProcesses(scope)
93104 require.NoError(t, err)
94105 require.ElementsMatch(t, []vevProcess{{pid: 10, role: "client"}, {pid: 11, role: "daemon"}}, got)
95106
96- none, err := findVevProcesses(root, exe, uid+1, 14)
107+ scope.runtimeDir = "/repo/.dev/test/runtime"
108+ dev, err := findVevProcesses(scope)
109+ require.NoError(t, err)
110+ require.Equal(t, []vevProcess{{pid: 15, role: "daemon"}}, dev, "a VEV_ENV run only sees its own environment")
111+
112+ scope.uid = uid + 1
113+ none, err := findVevProcesses(scope)
97114 require.NoError(t, err)
98115 require.Empty(t, none, "another user's processes are never matched")
99116 }
100117
101-func TestStopProcessesEscalatesToSigkill(t *testing.T) {
102- // A shell that ignores SIGTERM only stops on SIGKILL.
103- cmd := exec.Command("sh", "-c", "trap '' TERM; while :; do sleep 0.05; done")
118+// startIgnoringTerm starts a shell that ignores SIGTERM and reports once the
119+// trap is installed.
120+func startIgnoringTerm(t *testing.T) (*exec.Cmd, <-chan *os.ProcessState) {
121+ t.Helper()
122+ cmd := exec.Command("sh", "-c", "trap '' TERM; echo ready; while :; do sleep 0.05; done")
123+ stdout, err := cmd.StdoutPipe()
124+ require.NoError(t, err)
104125 require.NoError(t, cmd.Start())
105- reaped := make(chan struct{})
106- go func() { _ = cmd.Wait(); close(reaped) }()
107- t.Cleanup(func() { _ = cmd.Process.Kill(); <-reaped })
108- time.Sleep(50 * time.Millisecond) // let the trap install
126+ line := make([]byte, 6)
127+ _, err = io.ReadFull(stdout, line)
128+ require.NoError(t, err)
129+ done := make(chan *os.ProcessState, 1)
130+ go func() { _ = cmd.Wait(); done <- cmd.ProcessState }()
131+ t.Cleanup(func() { _ = cmd.Process.Kill() })
132+ return cmd, done
133+}
109134
110- require.NoError(t, stopProcesses(context.Background(), []vevProcess{{pid: cmd.Process.Pid, role: "daemon"}}, 100*time.Millisecond))
111- <-reaped
135+func TestStopWaveEscalatesToSigkillAndKeepsGoing(t *testing.T) {
136+ stubborn, done := startIgnoringTerm(t)
137+ polite := exec.Command("sleep", "30")
138+ require.NoError(t, polite.Start())
139+ go func() { _ = polite.Wait() }()
140+
141+ var report killAllReport
142+ stopWave(context.Background(), []vevProcess{{pid: stubborn.Process.Pid, role: "daemon"}, {pid: polite.Process.Pid, role: "client"}}, 100*time.Millisecond, &report)
143+
144+ state := <-done
145+ status, ok := state.Sys().(syscall.WaitStatus)
146+ require.True(t, ok)
147+ require.Equal(t, syscall.SIGKILL, status.Signal(), "a process ignoring SIGTERM is killed")
148+ require.Empty(t, report.errs)
149+ require.Len(t, report.stopped, 2, "the rest of the wave still stops")
150+ require.Equal(t, []vevProcess{{pid: stubborn.Process.Pid, role: "daemon"}}, report.forced)
112151 }
113152
114153 func TestRemoveDeadSockets(t *testing.T) {
115154 dir, err := os.MkdirTemp("", "vevk")
116155 require.NoError(t, err)
117156 t.Cleanup(func() { _ = os.RemoveAll(dir) })
157+ require.NoError(t, os.Chmod(dir, 0o700))
118158 require.NoError(t, os.Mkdir(filepath.Join(dir, "broker"), 0o700))
119159
120160 live, err := net.Listen("unix", filepath.Join(dir, "daemon.sock"))
@@ -124,10 +164,30 @@ func TestRemoveDeadSockets(t *testing.T) {
124164 require.NoError(t, err)
125165 dead.(*net.UnixListener).SetUnlinkOnClose(false)
126166 require.NoError(t, dead.Close())
167+ notSocket := filepath.Join(dir, "broker", "broker.sock")
168+ require.NoError(t, os.WriteFile(notSocket, nil, 0o600))
127169
128170 removed := removeDeadSockets(dir)
129171 require.Equal(t, []string{filepath.Join(dir, "daemonmux.sock")}, removed)
130172 require.FileExists(t, filepath.Join(dir, "daemon.sock"), "a live socket is kept")
173+ require.FileExists(t, notSocket, "a regular file is never removed")
174+}
175+
176+func TestRemoveDeadSocketsSkipsBusyLifecycle(t *testing.T) {
177+ dir, err := os.MkdirTemp("", "vevk")
178+ require.NoError(t, err)
179+ t.Cleanup(func() { _ = os.RemoveAll(dir) })
180+ require.NoError(t, os.Chmod(dir, 0o700))
181+ dead, err := net.Listen("unix", filepath.Join(dir, "daemon.sock"))
182+ require.NoError(t, err)
183+ dead.(*net.UnixListener).SetUnlinkOnClose(false)
184+ require.NoError(t, dead.Close())
185+
186+ owner, err := lifecycle.TryAcquire(dir)
187+ require.NoError(t, err)
188+ t.Cleanup(func() { _ = owner.Release() })
189+
190+ require.Empty(t, removeDeadSockets(dir), "a starting owner keeps its socket")
131191 }
132192
133193 func TestKillAllRefusesInsideVev(t *testing.T) {
@@ -139,13 +199,35 @@ func TestKillAllRefusesInsideVev(t *testing.T) {
139199 }
140200
141201 func TestPrintKillAllReport(t *testing.T) {
142- var empty bytes.Buffer
143- printKillAllReport(&empty, nil, nil)
144- require.Equal(t, "nothing to stop: vev was not running\n", empty.String())
145-
146- var out bytes.Buffer
147- printKillAllReport(&out, []vevProcess{{pid: 7, role: "broker"}}, []string{"/run/x/daemon.sock"})
148- require.Equal(t, "stopped broker (pid 7)\nremoved stale socket /run/x/daemon.sock\nvev is fully stopped; named sessions come back on the next start\n", out.String())
202+ tests := []struct {
203+ name string
204+ report killAllReport
205+ want string
206+ }{
207+ {name: "nothing running", want: "nothing to stop: vev was not running\n"},
208+ {
209+ name: "clean stop",
210+ report: killAllReport{stopped: []vevProcess{{pid: 7, role: "broker"}}, removed: []string{"/run/x/daemon.sock"}},
211+ 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",
212+ },
213+ {
214+ name: "forced daemon warns",
215+ report: killAllReport{stopped: []vevProcess{{pid: 9, role: "daemon"}}, forced: []vevProcess{{pid: 9, role: "daemon"}}},
216+ 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",
217+ },
218+ {
219+ name: "failure never claims a full stop",
220+ report: killAllReport{errs: []error{errors.New("stuck")}},
221+ want: "",
222+ },
223+ }
224+ for _, tt := range tests {
225+ t.Run(tt.name, func(t *testing.T) {
226+ var out bytes.Buffer
227+ printKillAllReport(&out, tt.report)
228+ require.Equal(t, tt.want, out.String())
229+ })
230+ }
149231 }
150232
151233 // TestWaitProcessExitTreatsZombieAsGone pins that an exited child its parent
@@ -5,6 +5,8 @@ package app
5 import (5 import (
6 "bytes"6 "bytes"
7 "context"7 "context"
8+ "errors"
9+ "io"
8 "net"10 "net"
9 "os"11 "os"
10 "os/exec"12 "os/exec"
@@ -14,6 +16,8 @@ import (
14 "time"16 "time"
15 17
16 "github.com/stretchr/testify/require"18 "github.com/stretchr/testify/require"
19+
20+ "github.com/bnema/vev/internal/adapters/lifecycle"
17 )21 )
18 22
19 func TestWaitProcessExit(t *testing.T) {23 func TestWaitProcessExit(t *testing.T) {
@@ -62,6 +66,8 @@ func TestProcessRole(t *testing.T) {
62 {name: "broker", cmdline: "/proc/self/exe\x00_broker-production-serve\x00", want: "broker"},66 {name: "broker", cmdline: "/proc/self/exe\x00_broker-production-serve\x00", want: "broker"},
63 {name: "hidden helper", cmdline: "/proc/self/exe\x00_broker-mux-quic-proxy\x00", want: "helper"},67 {name: "hidden helper", cmdline: "/proc/self/exe\x00_broker-mux-quic-proxy\x00", want: "helper"},
64 {name: "web daemon", cmdline: "vev\x00--web-daemon\x00", want: "helper"},68 {name: "web daemon", cmdline: "vev\x00--web-daemon\x00", want: "helper"},
69+ {name: "web server", cmdline: "/proc/self/exe\x00--web-serve\x00", want: "helper"},
70+ {name: "daemon launcher", cmdline: "/proc/self/exe\x00--daemon-launcher\x00", want: "helper"},
65 }71 }
66 for _, tt := range tests {72 for _, tt := range tests {
67 t.Run(tt.name, func(t *testing.T) {73 t.Run(tt.name, func(t *testing.T) {
@@ -71,50 +77,84 @@ func TestProcessRole(t *testing.T) {
71 }77 }
72 78
73 // TestFindVevProcesses runs against a fake /proc: only this user's processes79 // TestFindVevProcesses runs against a fake /proc: only this user's processes
74-// of the installed binary (also once replaced on disk) are found, never self.80+// of the installed binary (also once replaced on disk) in the same runtime
81+// directory are found, never self.
75 func TestFindVevProcesses(t *testing.T) {82 func TestFindVevProcesses(t *testing.T) {
76 root := t.TempDir()83 root := t.TempDir()
77 const exe = "/opt/bin/vev"84 const exe = "/opt/bin/vev"
85+ const runtime = "XDG_RUNTIME_DIR=/run/user/1000"
78 uid := os.Getuid()86 uid := os.Getuid()
79- add := func(pid, target, cmdline string) {87+ add := func(pid, target, cmdline, environ string) {
80 dir := filepath.Join(root, pid)88 dir := filepath.Join(root, pid)
81 require.NoError(t, os.Mkdir(dir, 0o755))89 require.NoError(t, os.Mkdir(dir, 0o755))
82 require.NoError(t, os.Symlink(target, filepath.Join(dir, "exe")))90 require.NoError(t, os.Symlink(target, filepath.Join(dir, "exe")))
83 require.NoError(t, os.WriteFile(filepath.Join(dir, "cmdline"), []byte(cmdline), 0o644))91 require.NoError(t, os.WriteFile(filepath.Join(dir, "cmdline"), []byte(cmdline), 0o644))
92+ require.NoError(t, os.WriteFile(filepath.Join(dir, "environ"), []byte(environ), 0o644))
84 }93 }
85- add("10", exe, "vev\x00")94+ add("10", exe, "vev\x00", "HOME=/h\x00"+runtime+"\x00")
86- add("11", exe+" (deleted)", "/proc/self/exe\x00--daemon\x00")95+ add("11", exe+" (deleted)", "/proc/self/exe\x00--daemon\x00", runtime+"\x00")
87- add("12", "/usr/bin/emacs", "emacs\x00--daemon\x00")96+ add("12", "/usr/bin/emacs", "emacs\x00--daemon\x00", runtime+"\x00")
88- add("13", "/tmp/vev-test-bin", "/tmp/vev-test-bin\x00--daemon\x00")97+ add("13", "/tmp/vev-test-bin", "/tmp/vev-test-bin\x00--daemon\x00", runtime+"\x00")
89- add("14", exe, "vev\x00") // self98+ add("14", exe, "vev\x00", runtime+"\x00") // self
99+ add("15", exe, "/proc/self/exe\x00--daemon\x00", "XDG_RUNTIME_DIR=/repo/.dev/test/runtime\x00")
90 require.NoError(t, os.Mkdir(filepath.Join(root, "self"), 0o755))100 require.NoError(t, os.Mkdir(filepath.Join(root, "self"), 0o755))
91 101
92- got, err := findVevProcesses(root, exe, uid, 14)102+ scope := killAllScope{procRoot: root, exe: exe, uid: uid, self: 14, runtimeDir: "/run/user/1000"}
103+ got, err := findVevProcesses(scope)
93 require.NoError(t, err)104 require.NoError(t, err)
94 require.ElementsMatch(t, []vevProcess{{pid: 10, role: "client"}, {pid: 11, role: "daemon"}}, got)105 require.ElementsMatch(t, []vevProcess{{pid: 10, role: "client"}, {pid: 11, role: "daemon"}}, got)
95 106
96- none, err := findVevProcesses(root, exe, uid+1, 14)107+ scope.runtimeDir = "/repo/.dev/test/runtime"
108+ dev, err := findVevProcesses(scope)
109+ require.NoError(t, err)
110+ require.Equal(t, []vevProcess{{pid: 15, role: "daemon"}}, dev, "a VEV_ENV run only sees its own environment")
111+
112+ scope.uid = uid + 1
113+ none, err := findVevProcesses(scope)
97 require.NoError(t, err)114 require.NoError(t, err)
98 require.Empty(t, none, "another user's processes are never matched")115 require.Empty(t, none, "another user's processes are never matched")
99 }116 }
100 117
101-func TestStopProcessesEscalatesToSigkill(t *testing.T) {118+// startIgnoringTerm starts a shell that ignores SIGTERM and reports once the
102- // A shell that ignores SIGTERM only stops on SIGKILL.119+// trap is installed.
103- cmd := exec.Command("sh", "-c", "trap '' TERM; while :; do sleep 0.05; done")120+func startIgnoringTerm(t *testing.T) (*exec.Cmd, <-chan *os.ProcessState) {
121+ t.Helper()
122+ cmd := exec.Command("sh", "-c", "trap '' TERM; echo ready; while :; do sleep 0.05; done")
123+ stdout, err := cmd.StdoutPipe()
124+ require.NoError(t, err)
104 require.NoError(t, cmd.Start())125 require.NoError(t, cmd.Start())
105- reaped := make(chan struct{})126+ line := make([]byte, 6)
106- go func() { _ = cmd.Wait(); close(reaped) }()127+ _, err = io.ReadFull(stdout, line)
107- t.Cleanup(func() { _ = cmd.Process.Kill(); <-reaped })128+ require.NoError(t, err)
108- time.Sleep(50 * time.Millisecond) // let the trap install129+ done := make(chan *os.ProcessState, 1)
130+ go func() { _ = cmd.Wait(); done <- cmd.ProcessState }()
131+ t.Cleanup(func() { _ = cmd.Process.Kill() })
132+ return cmd, done
133+}
109 134
110- require.NoError(t, stopProcesses(context.Background(), []vevProcess{{pid: cmd.Process.Pid, role: "daemon"}}, 100*time.Millisecond))135+func TestStopWaveEscalatesToSigkillAndKeepsGoing(t *testing.T) {
111- <-reaped136+ stubborn, done := startIgnoringTerm(t)
137+ polite := exec.Command("sleep", "30")
138+ require.NoError(t, polite.Start())
139+ go func() { _ = polite.Wait() }()
140+
141+ var report killAllReport
142+ stopWave(context.Background(), []vevProcess{{pid: stubborn.Process.Pid, role: "daemon"}, {pid: polite.Process.Pid, role: "client"}}, 100*time.Millisecond, &report)
143+
144+ state := <-done
145+ status, ok := state.Sys().(syscall.WaitStatus)
146+ require.True(t, ok)
147+ require.Equal(t, syscall.SIGKILL, status.Signal(), "a process ignoring SIGTERM is killed")
148+ require.Empty(t, report.errs)
149+ require.Len(t, report.stopped, 2, "the rest of the wave still stops")
150+ require.Equal(t, []vevProcess{{pid: stubborn.Process.Pid, role: "daemon"}}, report.forced)
112 }151 }
113 152
114 func TestRemoveDeadSockets(t *testing.T) {153 func TestRemoveDeadSockets(t *testing.T) {
115 dir, err := os.MkdirTemp("", "vevk")154 dir, err := os.MkdirTemp("", "vevk")
116 require.NoError(t, err)155 require.NoError(t, err)
117 t.Cleanup(func() { _ = os.RemoveAll(dir) })156 t.Cleanup(func() { _ = os.RemoveAll(dir) })
157+ require.NoError(t, os.Chmod(dir, 0o700))
118 require.NoError(t, os.Mkdir(filepath.Join(dir, "broker"), 0o700))158 require.NoError(t, os.Mkdir(filepath.Join(dir, "broker"), 0o700))
119 159
120 live, err := net.Listen("unix", filepath.Join(dir, "daemon.sock"))160 live, err := net.Listen("unix", filepath.Join(dir, "daemon.sock"))
@@ -124,10 +164,30 @@ func TestRemoveDeadSockets(t *testing.T) {
124 require.NoError(t, err)164 require.NoError(t, err)
125 dead.(*net.UnixListener).SetUnlinkOnClose(false)165 dead.(*net.UnixListener).SetUnlinkOnClose(false)
126 require.NoError(t, dead.Close())166 require.NoError(t, dead.Close())
167+ notSocket := filepath.Join(dir, "broker", "broker.sock")
168+ require.NoError(t, os.WriteFile(notSocket, nil, 0o600))
127 169
128 removed := removeDeadSockets(dir)170 removed := removeDeadSockets(dir)
129 require.Equal(t, []string{filepath.Join(dir, "daemonmux.sock")}, removed)171 require.Equal(t, []string{filepath.Join(dir, "daemonmux.sock")}, removed)
130 require.FileExists(t, filepath.Join(dir, "daemon.sock"), "a live socket is kept")172 require.FileExists(t, filepath.Join(dir, "daemon.sock"), "a live socket is kept")
173+ require.FileExists(t, notSocket, "a regular file is never removed")
174+}
175+
176+func TestRemoveDeadSocketsSkipsBusyLifecycle(t *testing.T) {
177+ dir, err := os.MkdirTemp("", "vevk")
178+ require.NoError(t, err)
179+ t.Cleanup(func() { _ = os.RemoveAll(dir) })
180+ require.NoError(t, os.Chmod(dir, 0o700))
181+ dead, err := net.Listen("unix", filepath.Join(dir, "daemon.sock"))
182+ require.NoError(t, err)
183+ dead.(*net.UnixListener).SetUnlinkOnClose(false)
184+ require.NoError(t, dead.Close())
185+
186+ owner, err := lifecycle.TryAcquire(dir)
187+ require.NoError(t, err)
188+ t.Cleanup(func() { _ = owner.Release() })
189+
190+ require.Empty(t, removeDeadSockets(dir), "a starting owner keeps its socket")
131 }191 }
132 192
133 func TestKillAllRefusesInsideVev(t *testing.T) {193 func TestKillAllRefusesInsideVev(t *testing.T) {
@@ -139,13 +199,35 @@ func TestKillAllRefusesInsideVev(t *testing.T) {
139 }199 }
140 200
141 func TestPrintKillAllReport(t *testing.T) {201 func TestPrintKillAllReport(t *testing.T) {
142- var empty bytes.Buffer202+ tests := []struct {
143- printKillAllReport(&empty, nil, nil)203+ name string
144- require.Equal(t, "nothing to stop: vev was not running\n", empty.String())204+ report killAllReport
145-205+ want string
146- var out bytes.Buffer206+ }{
147- printKillAllReport(&out, []vevProcess{{pid: 7, role: "broker"}}, []string{"/run/x/daemon.sock"})207+ {name: "nothing running", want: "nothing to stop: vev was not running\n"},
148- require.Equal(t, "stopped broker (pid 7)\nremoved stale socket /run/x/daemon.sock\nvev is fully stopped; named sessions come back on the next start\n", out.String())208+ {
209+ name: "clean stop",
210+ report: killAllReport{stopped: []vevProcess{{pid: 7, role: "broker"}}, removed: []string{"/run/x/daemon.sock"}},
211+ 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",
212+ },
213+ {
214+ name: "forced daemon warns",
215+ report: killAllReport{stopped: []vevProcess{{pid: 9, role: "daemon"}}, forced: []vevProcess{{pid: 9, role: "daemon"}}},
216+ 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",
217+ },
218+ {
219+ name: "failure never claims a full stop",
220+ report: killAllReport{errs: []error{errors.New("stuck")}},
221+ want: "",
222+ },
223+ }
224+ for _, tt := range tests {
225+ t.Run(tt.name, func(t *testing.T) {
226+ var out bytes.Buffer
227+ printKillAllReport(&out, tt.report)
228+ require.Equal(t, tt.want, out.String())
229+ })
230+ }
149 }231 }
150 232
151 // TestWaitProcessExitTreatsZombieAsGone pins that an exited child its parent233 // TestWaitProcessExitTreatsZombieAsGone pins that an exited child its parent
M internal/app/run.go +12 -0
@@ -62,6 +62,7 @@ const (
6262 kindDaemonLauncher
6363 kindUIDriver
6464 kindUIRemoteCleanup
65+ kindDaemonStop
6566 kindWebDaemon
6667 kindWebRenew
6768 kindWebServe
@@ -248,6 +249,11 @@ parsedUIFlags:
248249 return command{}, usagef("`%s` does not accept arguments", uiRemoteCleanupCommand)
249250 }
250251 return command{kind: kindUIRemoteCleanup}, nil
252+ case daemonStopCommand:
253+ if len(args) != 1 {
254+ return command{}, usagef("`%s` does not accept arguments", daemonStopCommand)
255+ }
256+ return command{kind: kindDaemonStop}, nil
251257 case "new":
252258 if len(args) < 2 || args[1] == "" {
253259 return command{}, usagef("`new` requires a session name")
@@ -372,6 +378,8 @@ func dispatch(ctx context.Context, cmd command) error {
372378 return runProductionBrokerLauncherCommand(ctx)
373379 case kindBrokerReady:
374380 return runBrokerReady(ctx, cmd.brokerReady, os.Stdout)
381+ case kindDaemonStop:
382+ return requestDaemonStop(ctx)
375383 case kindUIRemoteCleanup:
376384 return runUIRemoteCleanup(ctx)
377385 case kindUIDriver:
@@ -917,6 +925,10 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
917925 const (
918926 envRemoteTransport = "VEV_REMOTE_TRANSPORT"
919927 uiRemoteCleanupCommand = "_ui-cleanup"
928+ // daemonStopCommand is the hidden, environment-scoped graceful stop of
929+ // this environment's daemon, for harnesses and scripts. Users run
930+ // `kill --all` instead.
931+ daemonStopCommand = "_daemon-stop"
920932 )
921933
922934 func remoteTransportModeFromEnv(value string) (string, error) {
@@ -62,6 +62,7 @@ const (
62 kindDaemonLauncher62 kindDaemonLauncher
63 kindUIDriver63 kindUIDriver
64 kindUIRemoteCleanup64 kindUIRemoteCleanup
65+ kindDaemonStop
65 kindWebDaemon66 kindWebDaemon
66 kindWebRenew67 kindWebRenew
67 kindWebServe68 kindWebServe
@@ -248,6 +249,11 @@ parsedUIFlags:
248 return command{}, usagef("`%s` does not accept arguments", uiRemoteCleanupCommand)249 return command{}, usagef("`%s` does not accept arguments", uiRemoteCleanupCommand)
249 }250 }
250 return command{kind: kindUIRemoteCleanup}, nil251 return command{kind: kindUIRemoteCleanup}, nil
252+ case daemonStopCommand:
253+ if len(args) != 1 {
254+ return command{}, usagef("`%s` does not accept arguments", daemonStopCommand)
255+ }
256+ return command{kind: kindDaemonStop}, nil
251 case "new":257 case "new":
252 if len(args) < 2 || args[1] == "" {258 if len(args) < 2 || args[1] == "" {
253 return command{}, usagef("`new` requires a session name")259 return command{}, usagef("`new` requires a session name")
@@ -372,6 +378,8 @@ func dispatch(ctx context.Context, cmd command) error {
372 return runProductionBrokerLauncherCommand(ctx)378 return runProductionBrokerLauncherCommand(ctx)
373 case kindBrokerReady:379 case kindBrokerReady:
374 return runBrokerReady(ctx, cmd.brokerReady, os.Stdout)380 return runBrokerReady(ctx, cmd.brokerReady, os.Stdout)
381+ case kindDaemonStop:
382+ return requestDaemonStop(ctx)
375 case kindUIRemoteCleanup:383 case kindUIRemoteCleanup:
376 return runUIRemoteCleanup(ctx)384 return runUIRemoteCleanup(ctx)
377 case kindUIDriver:385 case kindUIDriver:
@@ -917,6 +925,10 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
917 const (925 const (
918 envRemoteTransport = "VEV_REMOTE_TRANSPORT"926 envRemoteTransport = "VEV_REMOTE_TRANSPORT"
919 uiRemoteCleanupCommand = "_ui-cleanup"927 uiRemoteCleanupCommand = "_ui-cleanup"
928+ // daemonStopCommand is the hidden, environment-scoped graceful stop of
929+ // this environment's daemon, for harnesses and scripts. Users run
930+ // `kill --all` instead.
931+ daemonStopCommand = "_daemon-stop"
920 )932 )
921 933
922 func remoteTransportModeFromEnv(value string) (string, error) {934 func remoteTransportModeFromEnv(value string) (string, error) {
M internal/app/run_test.go +3 -0
@@ -478,6 +478,9 @@ func TestParseArgs(t *testing.T) {
478478 {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},
479479 {name: "kill sessions", args: []string{"kill", "--sessions"}, wantKind: kindKill, wantSessions: true},
480480 {name: "kill sessions rejects extra arg", args: []string{"kill", "--sessions", "extra"}, wantErr: true},
481+ {name: "hidden daemon stop", args: []string{"_daemon-stop"}, wantKind: kindDaemonStop},
482+ {name: "hidden daemon stop rejects args", args: []string{"_daemon-stop", "x"}, wantErr: true},
483+ {name: "kill unknown flag", args: []string{"kill", "-x"}, wantErr: true},
481484 {name: "kill removed daemon flag", args: []string{"kill", "--daemon"}, wantErr: true},
482485 {name: "kill removed broker flag", args: []string{"kill", "--broker"}, wantErr: true},
483486 {name: "kill without name", args: []string{"kill"}, wantErr: true},
@@ -478,6 +478,9 @@ func TestParseArgs(t *testing.T) {
478 {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},478 {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},
479 {name: "kill sessions", args: []string{"kill", "--sessions"}, wantKind: kindKill, wantSessions: true},479 {name: "kill sessions", args: []string{"kill", "--sessions"}, wantKind: kindKill, wantSessions: true},
480 {name: "kill sessions rejects extra arg", args: []string{"kill", "--sessions", "extra"}, wantErr: true},480 {name: "kill sessions rejects extra arg", args: []string{"kill", "--sessions", "extra"}, wantErr: true},
481+ {name: "hidden daemon stop", args: []string{"_daemon-stop"}, wantKind: kindDaemonStop},
482+ {name: "hidden daemon stop rejects args", args: []string{"_daemon-stop", "x"}, wantErr: true},
483+ {name: "kill unknown flag", args: []string{"kill", "-x"}, wantErr: true},
481 {name: "kill removed daemon flag", args: []string{"kill", "--daemon"}, wantErr: true},484 {name: "kill removed daemon flag", args: []string{"kill", "--daemon"}, wantErr: true},
482 {name: "kill removed broker flag", args: []string{"kill", "--broker"}, wantErr: true},485 {name: "kill removed broker flag", args: []string{"kill", "--broker"}, wantErr: true},
483 {name: "kill without name", args: []string{"kill"}, wantErr: true},486 {name: "kill without name", args: []string{"kill"}, wantErr: true},
M scripts/demo/test-remotes.sh +2 -2
@@ -227,9 +227,9 @@ cmd_down() {
227227 for host in "${hosts[@]}"; do
228228 vev_env timeout 15 "$vev_bin" host rm "$host" >/dev/null 2>&1 || true
229229 done
230- vev_env timeout 10 "$vev_bin" kill --daemon >/dev/null 2>&1 || true
230+ vev_env timeout 10 "$vev_bin" _daemon-stop >/dev/null 2>&1 || true
231231 fi
232- # vev has no broker stop command: TERM the sandbox broker and any leftover
232+ # `kill --all` would stop every vev of this user: TERM the sandbox broker and any leftover
233233 # sandbox roles, then KILL whatever ignores it.
234234 local pids
235235 pids=$(vev_pids | paste -sd' ')
@@ -227,9 +227,9 @@ cmd_down() {
227 for host in "${hosts[@]}"; do227 for host in "${hosts[@]}"; do
228 vev_env timeout 15 "$vev_bin" host rm "$host" >/dev/null 2>&1 || true228 vev_env timeout 15 "$vev_bin" host rm "$host" >/dev/null 2>&1 || true
229 done229 done
230- vev_env timeout 10 "$vev_bin" kill --daemon >/dev/null 2>&1 || true230+ vev_env timeout 10 "$vev_bin" _daemon-stop >/dev/null 2>&1 || true
231 fi231 fi
232- # vev has no broker stop command: TERM the sandbox broker and any leftover232+ # `kill --all` would stop every vev of this user: TERM the sandbox broker and any leftover
233 # sandbox roles, then KILL whatever ignores it.233 # sandbox roles, then KILL whatever ignores it.
234 local pids234 local pids
235 pids=$(vev_pids | paste -sd' ')235 pids=$(vev_pids | paste -sd' ')

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