detached eef302d

fix(kill): match processes by their resolved socket directory and skip stuck ones on rescan

commit
eef302d49e00166152e58de75486bdb7bd9e8df7
author
brice · · 2026-09-23 21:23 UTC
parent
436e203
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +123 -49
M internal/adapters/ipc/listener.go +5 -2
@@ -181,9 +181,12 @@ func (l *unixListener) Addr() string {
181181 // environments never fall back to the ordinary user's daemon. Listeners and
182182 // lifecycle locks still validate ownership and mode; no symlink is introduced.
183183 func SocketDir() string {
184- uid := os.Getuid()
184+ return SocketDirFor(os.Getenv("XDG_RUNTIME_DIR"), os.Getuid())
185+}
185186
186- if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
187+// SocketDirFor is SocketDir for a given XDG_RUNTIME_DIR value and uid.
188+func SocketDirFor(xdg string, uid int) string {
189+ if xdg != "" {
187190 dir := filepath.Join(xdg, "vev")
188191 if len(filepath.Join(dir, "broker", "broker.sock")) > muxSocketPathMax {
189192 return fmt.Sprintf("/tmp/vev-%d-%x", uid, sha256.Sum256([]byte(dir)))
@@ -181,9 +181,12 @@ func (l *unixListener) Addr() string {
181 // environments never fall back to the ordinary user's daemon. Listeners and181 // environments never fall back to the ordinary user's daemon. Listeners and
182 // lifecycle locks still validate ownership and mode; no symlink is introduced.182 // lifecycle locks still validate ownership and mode; no symlink is introduced.
183 func SocketDir() string {183 func SocketDir() string {
184- uid := os.Getuid()184+ return SocketDirFor(os.Getenv("XDG_RUNTIME_DIR"), os.Getuid())
185+}
185 186
186- if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {187+// SocketDirFor is SocketDir for a given XDG_RUNTIME_DIR value and uid.
188+func SocketDirFor(xdg string, uid int) string {
189+ if xdg != "" {
187 dir := filepath.Join(xdg, "vev")190 dir := filepath.Join(xdg, "vev")
188 if len(filepath.Join(dir, "broker", "broker.sock")) > muxSocketPathMax {191 if len(filepath.Join(dir, "broker", "broker.sock")) > muxSocketPathMax {
189 return fmt.Sprintf("/tmp/vev-%d-%x", uid, sha256.Sum256([]byte(dir)))192 return fmt.Sprintf("/tmp/vev-%d-%x", uid, sha256.Sum256([]byte(dir)))
M internal/app/kill_all_linux.go +58 -33
@@ -19,6 +19,7 @@ import (
1919 "github.com/bnema/vev/internal/adapters/brokeripc"
2020 "github.com/bnema/vev/internal/adapters/ipc"
2121 "github.com/bnema/vev/internal/adapters/lifecycle"
22+ "github.com/bnema/vev/internal/platform"
2223 )
2324
2425 const (
@@ -37,14 +38,14 @@ type vevProcess struct {
3738 }
3839
3940 // 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.
41+// processes of the installed binary that use the same vev socket directory,
42+// so VEV_ENV and XDG-sandboxed runs never touch each other.
4243 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
44+ procRoot string
45+ exe string
46+ uid int
47+ self int
48+ socketDir string // ipc.SocketDir of the caller
4849 }
4950
5051 // killAllReport is what one `kill --all` did.
@@ -68,29 +69,46 @@ func runKillAll(ctx context.Context, out io.Writer) error {
6869 return fmt.Errorf("vev: resolve executable: %w", err)
6970 }
7071 scope := killAllScope{
71- procRoot: "/proc",
72- exe: installedPath(exe),
73- uid: os.Getuid(),
74- self: os.Getpid(),
75- runtimeDir: os.Getenv("XDG_RUNTIME_DIR"),
72+ procRoot: "/proc",
73+ exe: installedPath(exe),
74+ uid: os.Getuid(),
75+ self: os.Getpid(),
76+ socketDir: ipc.SocketDir(),
7677 }
77- report := stopEverything(ctx, scope, killAllStopTimeout, ipc.SocketDir())
78+ report := stopEverything(ctx, scope, killAllStopTimeout)
7879 printKillAllReport(out, report)
7980 return errors.Join(report.errs...)
8081 }
8182
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 {
83+// stopEverything runs scan-and-stop rounds until a scan finds nothing, then
84+// removes dead sockets. A process that already failed to die is not retried.
85+func stopEverything(ctx context.Context, scope killAllScope, timeout time.Duration) killAllReport {
8586 var report killAllReport
86- for round := 0; round < killAllRounds; round++ {
87+ failed := map[int]bool{}
88+ scan := func() ([]vevProcess, error) {
8789 procs, err := findVevProcesses(scope)
90+ live := procs[:0]
91+ for _, p := range procs {
92+ if !failed[p.pid] {
93+ live = append(live, p)
94+ }
95+ }
96+ return live, err
97+ }
98+ for round := 0; ; round++ {
99+ procs, err := scan()
88100 if err != nil {
89101 report.errs = append(report.errs, err)
90102 return report
91103 }
92104 if len(procs) == 0 {
93- report.removed = removeDeadSockets(socketDir)
105+ if len(failed) == 0 {
106+ report.removed = removeDeadSockets(scope.socketDir)
107+ }
108+ return report
109+ }
110+ if round == killAllRounds {
111+ report.errs = append(report.errs, fmt.Errorf("vev: %d vev processes kept starting again; run `vev kill --all` once more", len(procs)))
94112 return report
95113 }
96114 // Clients first, so none of them can start a broker or daemon from
@@ -104,17 +122,15 @@ func stopEverything(ctx context.Context, scope killAllScope, timeout time.Durati
104122 }
105123 }
106124 for _, wave := range [][]vevProcess{clients, services} {
107- stopWave(ctx, wave, timeout, &report)
125+ for _, p := range stopWave(ctx, wave, timeout, &report) {
126+ failed[p.pid] = true
127+ }
108128 }
109129 if ctx.Err() != nil {
110130 report.errs = append(report.errs, ctx.Err())
111131 return report
112132 }
113133 }
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)))
116- }
117- return report
118134 }
119135
120136 // installedPath is the on-disk path an executable was started from, without
@@ -148,8 +164,7 @@ func findVevProcesses(scope killAllScope) ([]vevProcess, error) {
148164 if err != nil || installedPath(target) != scope.exe {
149165 continue
150166 }
151- environ, err := os.ReadFile(filepath.Join(dir, "environ"))
152- if err != nil || environValue(environ, "XDG_RUNTIME_DIR") != scope.runtimeDir {
167+ if processSocketDir(dir, scope.uid) != scope.socketDir {
153168 continue // another VEV_ENV or sandbox
154169 }
155170 cmdline, err := os.ReadFile(filepath.Join(dir, "cmdline"))
@@ -161,15 +176,23 @@ func findVevProcesses(scope killAllScope) ([]vevProcess, error) {
161176 return procs, nil
162177 }
163178
164-// environValue returns key's value in a NUL-separated environment, or "".
165-func environValue(environ []byte, key string) string {
166- prefix := key + "="
179+// processSocketDir resolves the vev socket directory of the process in
180+// procDir from its initial environment and working directory, the same way
181+// vev itself does after VEV_ENV activation. It returns "" when unreadable.
182+func processSocketDir(procDir string, uid int) string {
183+ environ, err := os.ReadFile(filepath.Join(procDir, "environ"))
184+ if err != nil {
185+ return ""
186+ }
187+ env := map[string]string{}
167188 for _, entry := range strings.Split(string(environ), "\x00") {
168- if value, ok := strings.CutPrefix(entry, prefix); ok {
169- return value
189+ if key, value, ok := strings.Cut(entry, "="); ok {
190+ env[key] = value
170191 }
171192 }
172- return ""
193+ cwd, _ := os.Readlink(filepath.Join(procDir, "cwd"))
194+ runtime := platform.RuntimeDirFor(func(key string) string { return env[key] }, cwd)
195+ return ipc.SocketDirFor(runtime, uid)
173196 }
174197
175198 // processRole names a vev process from its NUL-separated command line.
@@ -194,8 +217,8 @@ func processRole(cmdline []byte) string {
194217
195218 // stopWave sends SIGTERM to every process, waits up to timeout for all of them
196219 // 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) {
220+// never stops the rest of the wave; the ones that survive are returned.
221+func stopWave(ctx context.Context, procs []vevProcess, timeout time.Duration, report *killAllReport) (survivors []vevProcess) {
199222 for _, p := range procs {
200223 _ = syscall.Kill(p.pid, syscall.SIGTERM)
201224 }
@@ -208,11 +231,13 @@ func stopWave(ctx context.Context, procs []vevProcess, timeout time.Duration, re
208231 _ = syscall.Kill(p.pid, syscall.SIGKILL)
209232 if err := waitProcessExit(context.WithoutCancel(ctx), p.pid, time.Second); err != nil {
210233 report.errs = append(report.errs, fmt.Errorf("vev: %s (pid %d) did not stop: %w", p.role, p.pid, err))
234+ survivors = append(survivors, p)
211235 continue
212236 }
213237 report.stopped = append(report.stopped, p)
214238 report.forced = append(report.forced, p)
215239 }
240+ return survivors
216241 }
217242
218243 // removeDeadSockets deletes vev sockets nobody listens on any more, so the
@@ -19,6 +19,7 @@ import (
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 "github.com/bnema/vev/internal/adapters/lifecycle"
22+ "github.com/bnema/vev/internal/platform"
22 )23 )
23 24
24 const (25 const (
@@ -37,14 +38,14 @@ type vevProcess struct {
37 }38 }
38 39
39 // killAllScope selects which processes `kill --all` may stop: this user's40 // killAllScope selects which processes `kill --all` may stop: this user's
40-// processes of the installed binary that run in the same vev runtime41+// processes of the installed binary that use the same vev socket directory,
41-// directory, so VEV_ENV and XDG-sandboxed runs never touch each other.42+// so VEV_ENV and XDG-sandboxed runs never touch each other.
42 type killAllScope struct {43 type killAllScope struct {
43- procRoot string44+ procRoot string
44- exe string45+ exe string
45- uid int46+ uid int
46- self int47+ self int
47- runtimeDir string // XDG_RUNTIME_DIR as vev resolved it; "" when unset48+ socketDir string // ipc.SocketDir of the caller
48 }49 }
49 50
50 // killAllReport is what one `kill --all` did.51 // killAllReport is what one `kill --all` did.
@@ -68,29 +69,46 @@ func runKillAll(ctx context.Context, out io.Writer) error {
68 return fmt.Errorf("vev: resolve executable: %w", err)69 return fmt.Errorf("vev: resolve executable: %w", err)
69 }70 }
70 scope := killAllScope{71 scope := killAllScope{
71- procRoot: "/proc",72+ procRoot: "/proc",
72- exe: installedPath(exe),73+ exe: installedPath(exe),
73- uid: os.Getuid(),74+ uid: os.Getuid(),
74- self: os.Getpid(),75+ self: os.Getpid(),
75- runtimeDir: os.Getenv("XDG_RUNTIME_DIR"),76+ socketDir: ipc.SocketDir(),
76 }77 }
77- report := stopEverything(ctx, scope, killAllStopTimeout, ipc.SocketDir())78+ report := stopEverything(ctx, scope, killAllStopTimeout)
78 printKillAllReport(out, report)79 printKillAllReport(out, report)
79 return errors.Join(report.errs...)80 return errors.Join(report.errs...)
80 }81 }
81 82
82-// stopEverything runs scan-and-stop rounds until a scan finds nothing, then removes83+// stopEverything runs scan-and-stop rounds until a scan finds nothing, then
83-// dead sockets.84+// removes dead sockets. A process that already failed to die is not retried.
84-func stopEverything(ctx context.Context, scope killAllScope, timeout time.Duration, socketDir string) killAllReport {85+func stopEverything(ctx context.Context, scope killAllScope, timeout time.Duration) killAllReport {
85 var report killAllReport86 var report killAllReport
86- for round := 0; round < killAllRounds; round++ {87+ failed := map[int]bool{}
88+ scan := func() ([]vevProcess, error) {
87 procs, err := findVevProcesses(scope)89 procs, err := findVevProcesses(scope)
90+ live := procs[:0]
91+ for _, p := range procs {
92+ if !failed[p.pid] {
93+ live = append(live, p)
94+ }
95+ }
96+ return live, err
97+ }
98+ for round := 0; ; round++ {
99+ procs, err := scan()
88 if err != nil {100 if err != nil {
89 report.errs = append(report.errs, err)101 report.errs = append(report.errs, err)
90 return report102 return report
91 }103 }
92 if len(procs) == 0 {104 if len(procs) == 0 {
93- report.removed = removeDeadSockets(socketDir)105+ if len(failed) == 0 {
106+ report.removed = removeDeadSockets(scope.socketDir)
107+ }
108+ return report
109+ }
110+ if round == killAllRounds {
111+ report.errs = append(report.errs, fmt.Errorf("vev: %d vev processes kept starting again; run `vev kill --all` once more", len(procs)))
94 return report112 return report
95 }113 }
96 // Clients first, so none of them can start a broker or daemon from114 // Clients first, so none of them can start a broker or daemon from
@@ -104,17 +122,15 @@ func stopEverything(ctx context.Context, scope killAllScope, timeout time.Durati
104 }122 }
105 }123 }
106 for _, wave := range [][]vevProcess{clients, services} {124 for _, wave := range [][]vevProcess{clients, services} {
107- stopWave(ctx, wave, timeout, &report)125+ for _, p := range stopWave(ctx, wave, timeout, &report) {
126+ failed[p.pid] = true
127+ }
108 }128 }
109 if ctx.Err() != nil {129 if ctx.Err() != nil {
110 report.errs = append(report.errs, ctx.Err())130 report.errs = append(report.errs, ctx.Err())
111 return report131 return report
112 }132 }
113 }133 }
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)))
116- }
117- return report
118 }134 }
119 135
120 // installedPath is the on-disk path an executable was started from, without136 // installedPath is the on-disk path an executable was started from, without
@@ -148,8 +164,7 @@ func findVevProcesses(scope killAllScope) ([]vevProcess, error) {
148 if err != nil || installedPath(target) != scope.exe {164 if err != nil || installedPath(target) != scope.exe {
149 continue165 continue
150 }166 }
151- environ, err := os.ReadFile(filepath.Join(dir, "environ"))167+ if processSocketDir(dir, scope.uid) != scope.socketDir {
152- if err != nil || environValue(environ, "XDG_RUNTIME_DIR") != scope.runtimeDir {
153 continue // another VEV_ENV or sandbox168 continue // another VEV_ENV or sandbox
154 }169 }
155 cmdline, err := os.ReadFile(filepath.Join(dir, "cmdline"))170 cmdline, err := os.ReadFile(filepath.Join(dir, "cmdline"))
@@ -161,15 +176,23 @@ func findVevProcesses(scope killAllScope) ([]vevProcess, error) {
161 return procs, nil176 return procs, nil
162 }177 }
163 178
164-// environValue returns key's value in a NUL-separated environment, or "".179+// processSocketDir resolves the vev socket directory of the process in
165-func environValue(environ []byte, key string) string {180+// procDir from its initial environment and working directory, the same way
166- prefix := key + "="181+// vev itself does after VEV_ENV activation. It returns "" when unreadable.
182+func processSocketDir(procDir string, uid int) string {
183+ environ, err := os.ReadFile(filepath.Join(procDir, "environ"))
184+ if err != nil {
185+ return ""
186+ }
187+ env := map[string]string{}
167 for _, entry := range strings.Split(string(environ), "\x00") {188 for _, entry := range strings.Split(string(environ), "\x00") {
168- if value, ok := strings.CutPrefix(entry, prefix); ok {189+ if key, value, ok := strings.Cut(entry, "="); ok {
169- return value190+ env[key] = value
170 }191 }
171 }192 }
172- return ""193+ cwd, _ := os.Readlink(filepath.Join(procDir, "cwd"))
194+ runtime := platform.RuntimeDirFor(func(key string) string { return env[key] }, cwd)
195+ return ipc.SocketDirFor(runtime, uid)
173 }196 }
174 197
175 // processRole names a vev process from its NUL-separated command line.198 // processRole names a vev process from its NUL-separated command line.
@@ -194,8 +217,8 @@ func processRole(cmdline []byte) string {
194 217
195 // stopWave sends SIGTERM to every process, waits up to timeout for all of them218 // 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 process219 // to exit, then sends SIGKILL to the ones still running. One stuck process
197-// never stops the rest of the wave.220+// never stops the rest of the wave; the ones that survive are returned.
198-func stopWave(ctx context.Context, procs []vevProcess, timeout time.Duration, report *killAllReport) {221+func stopWave(ctx context.Context, procs []vevProcess, timeout time.Duration, report *killAllReport) (survivors []vevProcess) {
199 for _, p := range procs {222 for _, p := range procs {
200 _ = syscall.Kill(p.pid, syscall.SIGTERM)223 _ = syscall.Kill(p.pid, syscall.SIGTERM)
201 }224 }
@@ -208,11 +231,13 @@ func stopWave(ctx context.Context, procs []vevProcess, timeout time.Duration, re
208 _ = syscall.Kill(p.pid, syscall.SIGKILL)231 _ = syscall.Kill(p.pid, syscall.SIGKILL)
209 if err := waitProcessExit(context.WithoutCancel(ctx), p.pid, time.Second); err != nil {232 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))233 report.errs = append(report.errs, fmt.Errorf("vev: %s (pid %d) did not stop: %w", p.role, p.pid, err))
234+ survivors = append(survivors, p)
211 continue235 continue
212 }236 }
213 report.stopped = append(report.stopped, p)237 report.stopped = append(report.stopped, p)
214 report.forced = append(report.forced, p)238 report.forced = append(report.forced, p)
215 }239 }
240+ return survivors
216 }241 }
217 242
218 // removeDeadSockets deletes vev sockets nobody listens on any more, so the243 // removeDeadSockets deletes vev sockets nobody listens on any more, so the
M internal/app/kill_all_linux_test.go +45 -14
@@ -6,6 +6,7 @@ import (
66 "bytes"
77 "context"
88 "errors"
9+ "fmt"
910 "io"
1011 "net"
1112 "os"
@@ -17,7 +18,9 @@ import (
1718
1819 "github.com/stretchr/testify/require"
1920
21+ "github.com/bnema/vev/internal/adapters/ipc"
2022 "github.com/bnema/vev/internal/adapters/lifecycle"
23+ "github.com/bnema/vev/internal/platform"
2124 )
2225
2326 func TestWaitProcessExit(t *testing.T) {
@@ -77,44 +80,72 @@ func TestProcessRole(t *testing.T) {
7780 }
7881
7982 // TestFindVevProcesses runs against a fake /proc: only this user's processes
80-// of the installed binary (also once replaced on disk) in the same runtime
81-// directory are found, never self.
83+// of the installed binary (also once replaced on disk) that resolve to the
84+// caller's socket directory are found, never self.
8285 func TestFindVevProcesses(t *testing.T) {
8386 root := t.TempDir()
8487 const exe = "/opt/bin/vev"
85- const runtime = "XDG_RUNTIME_DIR=/run/user/1000"
8688 uid := os.Getuid()
87- add := func(pid, target, cmdline, environ string) {
89+ userRun := "XDG_RUNTIME_DIR=/run/user/1000\x00"
90+ add := func(pid, target, cmdline, environ, cwd string) {
8891 dir := filepath.Join(root, pid)
8992 require.NoError(t, os.Mkdir(dir, 0o755))
9093 require.NoError(t, os.Symlink(target, filepath.Join(dir, "exe")))
94+ require.NoError(t, os.Symlink(cwd, filepath.Join(dir, "cwd")))
9195 require.NoError(t, os.WriteFile(filepath.Join(dir, "cmdline"), []byte(cmdline), 0o644))
9296 require.NoError(t, os.WriteFile(filepath.Join(dir, "environ"), []byte(environ), 0o644))
9397 }
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")
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", "/")
100107 require.NoError(t, os.Mkdir(filepath.Join(root, "self"), 0o755))
101108
102- scope := killAllScope{procRoot: root, exe: exe, uid: uid, self: 14, runtimeDir: "/run/user/1000"}
109+ scope := killAllScope{procRoot: root, exe: exe, uid: uid, self: 14, socketDir: "/run/user/1000/vev"}
103110 got, err := findVevProcesses(scope)
104111 require.NoError(t, err)
105112 require.ElementsMatch(t, []vevProcess{{pid: 10, role: "client"}, {pid: 11, role: "daemon"}}, got)
106113
107- scope.runtimeDir = "/repo/.dev/test/runtime"
114+ scope.socketDir = "/repo/.dev/dev/runtime/vev"
108115 dev, err := findVevProcesses(scope)
109116 require.NoError(t, err)
110- require.Equal(t, []vevProcess{{pid: 15, role: "daemon"}}, dev, "a VEV_ENV run only sees its own environment")
117+ require.ElementsMatch(t, []vevProcess{{pid: 15, role: "client"}, {pid: 16, role: "daemon"}}, dev, "a VEV_ENV run sees its own client and children")
111118
112119 scope.uid = uid + 1
120+ scope.socketDir = "/run/user/1000/vev"
113121 none, err := findVevProcesses(scope)
114122 require.NoError(t, err)
115123 require.Empty(t, none, "another user's processes are never matched")
116124 }
117125
126+func TestRuntimeDirResolutionMatchesVev(t *testing.T) {
127+ tests := []struct {
128+ name string
129+ env map[string]string
130+ cwd string
131+ want string
132+ }{
133+ {name: "plain", env: map[string]string{"XDG_RUNTIME_DIR": "/run/user/1000"}, want: "/run/user/1000"},
134+ {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"},
135+ {name: "dev env with root", env: map[string]string{"VEV_ENV": "t", "VEV_ENV_ROOT": "/r"}, cwd: "/elsewhere", want: "/r/t/runtime"},
136+ {name: "invalid env name is ignored", env: map[string]string{"VEV_ENV": "../x", "XDG_RUNTIME_DIR": "/run/user/1000"}, want: "/run/user/1000"},
137+ }
138+ for _, tt := range tests {
139+ t.Run(tt.name, func(t *testing.T) {
140+ require.Equal(t, tt.want, platform.RuntimeDirFor(func(k string) string { return tt.env[k] }, tt.cwd))
141+ })
142+ }
143+ if info, err := os.Stat(fmt.Sprintf("/run/user/%d", os.Getuid())); err == nil && info.IsDir() {
144+ require.Equal(t, ipc.SocketDirFor(fmt.Sprintf("/run/user/%d", os.Getuid()), os.Getuid()), ipc.SocketDirFor("", os.Getuid()),
145+ "unset XDG_RUNTIME_DIR and /run/user/UID resolve to the same socket directory")
146+ }
147+}
148+
118149 // startIgnoringTerm starts a shell that ignores SIGTERM and reports once the
119150 // trap is installed.
120151 func startIgnoringTerm(t *testing.T) (*exec.Cmd, <-chan *os.ProcessState) {
@@ -139,7 +170,7 @@ func TestStopWaveEscalatesToSigkillAndKeepsGoing(t *testing.T) {
139170 go func() { _ = polite.Wait() }()
140171
141172 var report killAllReport
142- stopWave(context.Background(), []vevProcess{{pid: stubborn.Process.Pid, role: "daemon"}, {pid: polite.Process.Pid, role: "client"}}, 100*time.Millisecond, &report)
173+ _ = stopWave(context.Background(), []vevProcess{{pid: stubborn.Process.Pid, role: "daemon"}, {pid: polite.Process.Pid, role: "client"}}, 100*time.Millisecond, &report)
143174
144175 state := <-done
145176 status, ok := state.Sys().(syscall.WaitStatus)
@@ -6,6 +6,7 @@ import (
6 "bytes"6 "bytes"
7 "context"7 "context"
8 "errors"8 "errors"
9+ "fmt"
9 "io"10 "io"
10 "net"11 "net"
11 "os"12 "os"
@@ -17,7 +18,9 @@ import (
17 18
18 "github.com/stretchr/testify/require"19 "github.com/stretchr/testify/require"
19 20
21+ "github.com/bnema/vev/internal/adapters/ipc"
20 "github.com/bnema/vev/internal/adapters/lifecycle"22 "github.com/bnema/vev/internal/adapters/lifecycle"
23+ "github.com/bnema/vev/internal/platform"
21 )24 )
22 25
23 func TestWaitProcessExit(t *testing.T) {26 func TestWaitProcessExit(t *testing.T) {
@@ -77,44 +80,72 @@ func TestProcessRole(t *testing.T) {
77 }80 }
78 81
79 // TestFindVevProcesses runs against a fake /proc: only this user's processes82 // TestFindVevProcesses runs against a fake /proc: only this user's processes
80-// of the installed binary (also once replaced on disk) in the same runtime83+// of the installed binary (also once replaced on disk) that resolve to the
81-// directory are found, never self.84+// caller's socket directory are found, never self.
82 func TestFindVevProcesses(t *testing.T) {85 func TestFindVevProcesses(t *testing.T) {
83 root := t.TempDir()86 root := t.TempDir()
84 const exe = "/opt/bin/vev"87 const exe = "/opt/bin/vev"
85- const runtime = "XDG_RUNTIME_DIR=/run/user/1000"
86 uid := os.Getuid()88 uid := os.Getuid()
87- add := func(pid, target, cmdline, environ string) {89+ userRun := "XDG_RUNTIME_DIR=/run/user/1000\x00"
90+ add := func(pid, target, cmdline, environ, cwd string) {
88 dir := filepath.Join(root, pid)91 dir := filepath.Join(root, pid)
89 require.NoError(t, os.Mkdir(dir, 0o755))92 require.NoError(t, os.Mkdir(dir, 0o755))
90 require.NoError(t, os.Symlink(target, filepath.Join(dir, "exe")))93 require.NoError(t, os.Symlink(target, filepath.Join(dir, "exe")))
94+ require.NoError(t, os.Symlink(cwd, filepath.Join(dir, "cwd")))
91 require.NoError(t, os.WriteFile(filepath.Join(dir, "cmdline"), []byte(cmdline), 0o644))95 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))96 require.NoError(t, os.WriteFile(filepath.Join(dir, "environ"), []byte(environ), 0o644))
93 }97 }
94- add("10", exe, "vev\x00", "HOME=/h\x00"+runtime+"\x00")98+ add("10", exe, "vev\x00", "HOME=/h\x00"+userRun, "/h")
95- add("11", exe+" (deleted)", "/proc/self/exe\x00--daemon\x00", runtime+"\x00")99+ add("11", exe+" (deleted)", "/proc/self/exe\x00--daemon\x00", userRun, "/h")
96- add("12", "/usr/bin/emacs", "emacs\x00--daemon\x00", runtime+"\x00")100+ add("12", "/usr/bin/emacs", "emacs\x00--daemon\x00", userRun, "/h")
97- add("13", "/tmp/vev-test-bin", "/tmp/vev-test-bin\x00--daemon\x00", runtime+"\x00")101+ add("13", "/tmp/vev-test-bin", "/tmp/vev-test-bin\x00--daemon\x00", userRun, "/h")
98- add("14", exe, "vev\x00", runtime+"\x00") // self102+ add("14", exe, "vev\x00", userRun, "/h") // self
99- add("15", exe, "/proc/self/exe\x00--daemon\x00", "XDG_RUNTIME_DIR=/repo/.dev/test/runtime\x00")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", "/")
100 require.NoError(t, os.Mkdir(filepath.Join(root, "self"), 0o755))107 require.NoError(t, os.Mkdir(filepath.Join(root, "self"), 0o755))
101 108
102- scope := killAllScope{procRoot: root, exe: exe, uid: uid, self: 14, runtimeDir: "/run/user/1000"}109+ scope := killAllScope{procRoot: root, exe: exe, uid: uid, self: 14, socketDir: "/run/user/1000/vev"}
103 got, err := findVevProcesses(scope)110 got, err := findVevProcesses(scope)
104 require.NoError(t, err)111 require.NoError(t, err)
105 require.ElementsMatch(t, []vevProcess{{pid: 10, role: "client"}, {pid: 11, role: "daemon"}}, got)112 require.ElementsMatch(t, []vevProcess{{pid: 10, role: "client"}, {pid: 11, role: "daemon"}}, got)
106 113
107- scope.runtimeDir = "/repo/.dev/test/runtime"114+ scope.socketDir = "/repo/.dev/dev/runtime/vev"
108 dev, err := findVevProcesses(scope)115 dev, err := findVevProcesses(scope)
109 require.NoError(t, err)116 require.NoError(t, err)
110- require.Equal(t, []vevProcess{{pid: 15, role: "daemon"}}, dev, "a VEV_ENV run only sees its own environment")117+ require.ElementsMatch(t, []vevProcess{{pid: 15, role: "client"}, {pid: 16, role: "daemon"}}, dev, "a VEV_ENV run sees its own client and children")
111 118
112 scope.uid = uid + 1119 scope.uid = uid + 1
120+ scope.socketDir = "/run/user/1000/vev"
113 none, err := findVevProcesses(scope)121 none, err := findVevProcesses(scope)
114 require.NoError(t, err)122 require.NoError(t, err)
115 require.Empty(t, none, "another user's processes are never matched")123 require.Empty(t, none, "another user's processes are never matched")
116 }124 }
117 125
126+func TestRuntimeDirResolutionMatchesVev(t *testing.T) {
127+ tests := []struct {
128+ name string
129+ env map[string]string
130+ cwd string
131+ want string
132+ }{
133+ {name: "plain", env: map[string]string{"XDG_RUNTIME_DIR": "/run/user/1000"}, want: "/run/user/1000"},
134+ {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"},
135+ {name: "dev env with root", env: map[string]string{"VEV_ENV": "t", "VEV_ENV_ROOT": "/r"}, cwd: "/elsewhere", want: "/r/t/runtime"},
136+ {name: "invalid env name is ignored", env: map[string]string{"VEV_ENV": "../x", "XDG_RUNTIME_DIR": "/run/user/1000"}, want: "/run/user/1000"},
137+ }
138+ for _, tt := range tests {
139+ t.Run(tt.name, func(t *testing.T) {
140+ require.Equal(t, tt.want, platform.RuntimeDirFor(func(k string) string { return tt.env[k] }, tt.cwd))
141+ })
142+ }
143+ if info, err := os.Stat(fmt.Sprintf("/run/user/%d", os.Getuid())); err == nil && info.IsDir() {
144+ require.Equal(t, ipc.SocketDirFor(fmt.Sprintf("/run/user/%d", os.Getuid()), os.Getuid()), ipc.SocketDirFor("", os.Getuid()),
145+ "unset XDG_RUNTIME_DIR and /run/user/UID resolve to the same socket directory")
146+ }
147+}
148+
118 // startIgnoringTerm starts a shell that ignores SIGTERM and reports once the149 // startIgnoringTerm starts a shell that ignores SIGTERM and reports once the
119 // trap is installed.150 // trap is installed.
120 func startIgnoringTerm(t *testing.T) (*exec.Cmd, <-chan *os.ProcessState) {151 func startIgnoringTerm(t *testing.T) (*exec.Cmd, <-chan *os.ProcessState) {
@@ -139,7 +170,7 @@ func TestStopWaveEscalatesToSigkillAndKeepsGoing(t *testing.T) {
139 go func() { _ = polite.Wait() }()170 go func() { _ = polite.Wait() }()
140 171
141 var report killAllReport172 var report killAllReport
142- stopWave(context.Background(), []vevProcess{{pid: stubborn.Process.Pid, role: "daemon"}, {pid: polite.Process.Pid, role: "client"}}, 100*time.Millisecond, &report)173+ _ = stopWave(context.Background(), []vevProcess{{pid: stubborn.Process.Pid, role: "daemon"}, {pid: polite.Process.Pid, role: "client"}}, 100*time.Millisecond, &report)
143 174
144 state := <-done175 state := <-done
145 status, ok := state.Sys().(syscall.WaitStatus)176 status, ok := state.Sys().(syscall.WaitStatus)
M internal/platform/dir.go +15 -0
@@ -78,6 +78,21 @@ func asciiAlphaNumeric(value byte) bool {
7878 return value >= 'A' && value <= 'Z' || value >= 'a' && value <= 'z' || value >= '0' && value <= '9'
7979 }
8080
81+// RuntimeDirFor returns the XDG_RUNTIME_DIR a vev process with environment
82+// getenv and working directory cwd uses once VEV_ENV is activated. It lets a
83+// caller resolve another process' runtime from its initial environment.
84+func RuntimeDirFor(getenv func(string) string, cwd string) string {
85+ name := getenv("VEV_ENV")
86+ if name == "" || !validDevelopmentEnvironmentName(name) {
87+ return getenv("XDG_RUNTIME_DIR")
88+ }
89+ base := getenv(devEnvironmentRootEnv)
90+ if base == "" {
91+ base = filepath.Join(cwd, ".dev")
92+ }
93+ return filepath.Join(base, name, "runtime")
94+}
95+
8196 // DevelopmentEnvironmentTempDir returns the private temporary-file directory
8297 // for an activated development environment. Ordinary invocations use the
8398 // existing os.TempDir fallback in the daemon.
@@ -78,6 +78,21 @@ func asciiAlphaNumeric(value byte) bool {
78 return value >= 'A' && value <= 'Z' || value >= 'a' && value <= 'z' || value >= '0' && value <= '9'78 return value >= 'A' && value <= 'Z' || value >= 'a' && value <= 'z' || value >= '0' && value <= '9'
79 }79 }
80 80
81+// RuntimeDirFor returns the XDG_RUNTIME_DIR a vev process with environment
82+// getenv and working directory cwd uses once VEV_ENV is activated. It lets a
83+// caller resolve another process' runtime from its initial environment.
84+func RuntimeDirFor(getenv func(string) string, cwd string) string {
85+ name := getenv("VEV_ENV")
86+ if name == "" || !validDevelopmentEnvironmentName(name) {
87+ return getenv("XDG_RUNTIME_DIR")
88+ }
89+ base := getenv(devEnvironmentRootEnv)
90+ if base == "" {
91+ base = filepath.Join(cwd, ".dev")
92+ }
93+ return filepath.Join(base, name, "runtime")
94+}
95+
81 // DevelopmentEnvironmentTempDir returns the private temporary-file directory96 // DevelopmentEnvironmentTempDir returns the private temporary-file directory
82 // for an activated development environment. Ordinary invocations use the97 // for an activated development environment. Ordinary invocations use the
83 // existing os.TempDir fallback in the daemon.98 // existing os.TempDir fallback in the daemon.

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