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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 35 | 35 | vev ls [<host>|--all] list sessions | |
| 36 | 36 | vev host add|rm <host> manage remote hosts | |
| 37 | 37 | 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) | |
| 39 | 40 | vev --web-daemon start the private browser terminal | |
| 40 | 41 | vev --ui-driver [options] drive a headless attachment over JSONL | |
| 41 | 42 | 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 sessions | 35 | vev ls [<host>|--all] list sessions | ||
| 36 | vev host add|rm <host> manage remote hosts | 36 | vev host add|rm <host> manage remote hosts | ||
| 37 | vev host list list remote hosts | 37 | 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) | |||
| 39 | vev --web-daemon start the private browser terminal | 40 | vev --web-daemon start the private browser terminal | ||
| 40 | vev --ui-driver [options] drive a headless attachment over JSONL | 41 | vev --ui-driver [options] drive a headless attachment over JSONL | ||
| 41 | vev --ui-observe ... expose passive observation for an attach | 42 | 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 | |||
| 335 | 335 | p.shutdown = func() error { | |
| 336 | 336 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 337 | 337 | defer cancel() | |
| 338 | - | shutdown := exec.CommandContext(ctx, bin, "kill", "--daemon") | |
| 338 | + | shutdown := exec.CommandContext(ctx, bin, "_daemon-stop") | |
| 339 | 339 | shutdown.Env = withoutEnv(cmd.Env, "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN") | |
| 340 | 340 | return shutdown.Run() | |
| 341 | 341 | } | |
| @@ -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) { | |||
| 703 | 703 | case err := <-served: | |
| 704 | 704 | require.NoError(t, err) | |
| 705 | 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 | 726 | case err := <-served: | |
| 727 | 727 | require.NoError(t, err) | |
| 728 | 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 | 732 | _, served2 := startDaemonInDir(t, dir, daemon.WithShell("/bin/sh", []string{"-c", "sleep 30"})) | |
| @@ -738,7 +738,7 @@ func TestIntegration_EphemeralNotListedAfterDaemonRestart(t *testing.T) { | |||
| 738 | 738 | case err := <-served2: | |
| 739 | 739 | require.NoError(t, err) | |
| 740 | 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 | 795 | case err := <-served: | |
| 796 | 796 | require.NoError(t, err) | |
| 797 | 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 | 801 | served = start() | |
| @@ -899,7 +899,7 @@ func TestIntegration_NamedSessionLastRemovalKeepsDaemonServing(t *testing.T) { | |||
| 899 | 899 | case err := <-served: | |
| 900 | 900 | require.NoError(t, err) | |
| 901 | 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 | 964 | case err := <-served: | |
| 965 | 965 | require.NoError(t, err) | |
| 966 | 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 | 1687 | func (*integrationTransport) LocalAddr() net.Addr { return nil } | |
| 1688 | 1688 | func (*integrationTransport) RemoteAddr() net.Addr { return nil } | |
| 1689 | 1689 | ||
| 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 | |
| 1692 | 1692 | // daemon serving, and the same process accepts fresh named and ephemeral | |
| 1693 | 1693 | // sessions that start PTYs, forward input, and open a tab through the ordinary | |
| 1694 | 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 | 1696 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 1697 | 1697 | dir, served := startDaemon(t, daemon.WithShell("/bin/cat", nil)) | |
| 1698 | 1698 | ||
| @@ -1748,6 +1748,6 @@ func TestIntegration_KillAllPurgesSessionsAndKeepsDaemon(t *testing.T) { | |||
| 1748 | 1748 | case err := <-served: | |
| 1749 | 1749 | require.NoError(t, err) | |
| 1750 | 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 | } | |
| @@ -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-level | 1690 | + | // TestIntegration_KillSessionsPurgesSessionsAndKeepsDaemon is the P4.2 app-level |
| 1691 | - | // contract: `kill --all` removes every live and stopped session but leaves the | 1691 | + | // contract: `kill --sessions` removes every live and stopped session but leaves the |
| 1692 | // daemon serving, and the same process accepts fresh named and ephemeral | 1692 | // daemon serving, and the same process accepts fresh named and ephemeral | ||
| 1693 | // sessions that start PTYs, forward input, and open a tab through the ordinary | 1693 | // 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 ( | |||
| 18 | 18 | ||
| 19 | 19 | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 20 | 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 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 | + | ) | |
| 26 | 32 | ||
| 27 | 33 | // vevProcess is one running process of the installed vev binary. | |
| 28 | 34 | type vevProcess struct { | |
| @@ -30,11 +36,29 @@ type vevProcess struct { | |||
| 30 | 36 | role string // "client", "broker", "daemon", or "helper" | |
| 31 | 37 | } | |
| 32 | 38 | ||
| 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. | |
| 38 | 62 | func runKillAll(ctx context.Context, out io.Writer) error { | |
| 39 | 63 | if os.Getenv("VEV") != "" { | |
| 40 | 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 | 67 | if err != nil { | |
| 44 | 68 | return fmt.Errorf("vev: resolve executable: %w", err) | |
| 45 | 69 | } | |
| 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"), | |
| 49 | 76 | } | |
| 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 | |
| 58 | 91 | } | |
| 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) | |
| 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()) | |
| 66 | - | printKillAllReport(out, append(clients, services...), removed) | |
| 67 | - | return nil | |
| 117 | + | return report | |
| 68 | 118 | } | |
| 69 | 119 | ||
| 70 | 120 | // installedPath is the on-disk path an executable was started from, without | |
| @@ -73,31 +123,35 @@ func installedPath(exe string) string { | |||
| 73 | 123 | return strings.TrimSuffix(exe, " (deleted)") | |
| 74 | 124 | } | |
| 75 | 125 | ||
| 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) | |
| 80 | 130 | if err != nil { | |
| 81 | 131 | return nil, fmt.Errorf("vev: list processes: %w", err) | |
| 82 | 132 | } | |
| 83 | 133 | var procs []vevProcess | |
| 84 | 134 | for _, entry := range entries { | |
| 85 | 135 | pid, err := strconv.Atoi(entry.Name()) | |
| 86 | - | if err != nil || pid == self { | |
| 136 | + | if err != nil || pid == scope.self { | |
| 87 | 137 | continue | |
| 88 | 138 | } | |
| 89 | - | dir := filepath.Join(procRoot, entry.Name()) | |
| 139 | + | dir := filepath.Join(scope.procRoot, entry.Name()) | |
| 90 | 140 | info, err := os.Stat(dir) | |
| 91 | 141 | if err != nil { | |
| 92 | 142 | 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 | 145 | continue | |
| 96 | 146 | } | |
| 97 | 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 | 149 | 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 | 155 | cmdline, err := os.ReadFile(filepath.Join(dir, "cmdline")) | |
| 102 | 156 | if err != nil { | |
| 103 | 157 | continue | |
| @@ -107,6 +161,17 @@ func findVevProcesses(procRoot, exe string, uid, self int) ([]vevProcess, error) | |||
| 107 | 161 | 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 | 175 | // processRole names a vev process from its NUL-separated command line. | |
| 111 | 176 | func processRole(cmdline []byte) string { | |
| 112 | 177 | args := strings.Split(strings.TrimRight(string(cmdline), "\x00"), "\x00") | |
| @@ -118,72 +183,88 @@ func processRole(cmdline []byte) string { | |||
| 118 | 183 | return "daemon" | |
| 119 | 184 | case productionBrokerServeCommand: | |
| 120 | 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 | 190 | return "helper" | |
| 124 | 191 | } | |
| 125 | 192 | return "client" | |
| 126 | 193 | } | |
| 127 | 194 | ||
| 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) { | |
| 131 | 199 | for _, p := range procs { | |
| 132 | 200 | _ = syscall.Kill(p.pid, syscall.SIGTERM) | |
| 133 | 201 | } | |
| 134 | 202 | deadline := time.Now().Add(timeout) | |
| 135 | 203 | 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) | |
| 138 | 206 | continue | |
| 139 | 207 | } | |
| 140 | - | if err := ctx.Err(); err != nil { | |
| 141 | - | return err | |
| 142 | - | } | |
| 143 | 208 | _ = 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 | |
| 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 | 218 | // 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 | 222 | func removeDeadSockets(dir string) []string { | |
| 154 | 223 | 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"))}, | |
| 159 | 228 | } { | |
| 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 { | |
| 163 | 231 | continue | |
| 164 | 232 | } | |
| 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 | |
| 167 | 236 | } | |
| 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) | |
| 170 | 242 | } | |
| 243 | + | _ = owner.Release() | |
| 171 | 244 | } | |
| 172 | 245 | return removed | |
| 173 | 246 | } | |
| 174 | 247 | ||
| 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 { | |
| 177 | 250 | _, _ = fmt.Fprintln(out, "nothing to stop: vev was not running") | |
| 178 | 251 | return | |
| 179 | 252 | } | |
| 180 | - | for _, p := range procs { | |
| 253 | + | for _, p := range report.stopped { | |
| 181 | 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 | 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 | 270 | // 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 it | 24 | + | const ( |
| 24 | - | // escalates to SIGKILL. | 25 | + | // killAllStopTimeout bounds how long each wave waits after SIGTERM |
| 25 | - | const killAllStopTimeout = 5 * time.Second | 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 | + | ) | |||
| 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 installed | 39 | + | // killAllScope selects which processes `kill --all` may stop: this user's |
| 34 | - | // binary, including builds an upgrade has replaced on disk, then removes the | 40 | + | // 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 the | 41 | + | // directory, so VEV_ENV and XDG-sandboxed runs never touch each other. |
| 36 | - | // running broker is from another build. Named sessions are checkpointed by the | 42 | + | 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 err | 72 | + | 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 from | 77 | + | report := stopEverything(ctx, scope, killAllStopTimeout, ipc.SocketDir()) |
| 51 | - | // its old binary while the rest stop. | 78 | + | printKillAllReport(out, report) |
| 52 | - | var clients, services []vevProcess | 79 | + | 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 err | 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) | |||
| 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, without | 120 | // 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, or | 126 | + | // 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 []vevProcess | 133 | 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 | continue | 137 | 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 meanwhile | 142 | 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 | continue | 145 | 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 | continue | 149 | 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 | continue | 157 | continue | ||
| @@ -107,6 +161,17 @@ func findVevProcesses(procRoot, exe string, uid, self int) ([]vevProcess, error) | |||||
| 107 | return procs, nil | 161 | 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 of | 195 | + | // 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 | continue | 206 | 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 the | 218 | // 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 []string | 223 | 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 | continue | 231 | continue | ||
| 164 | } | 232 | } | ||
| 165 | - | if !errors.Is(err, syscall.ECONNREFUSED) { | 233 | + | owner, err := lifecycle.TryAcquire(runtime.lockDir) |
| 166 | - | continue | 234 | + | 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 removed | 245 | 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 | return | 251 | 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 dead | 270 | // 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 | |||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | 7 | "context" | |
| 8 | + | "errors" | |
| 9 | + | "io" | |
| 8 | 10 | "net" | |
| 9 | 11 | "os" | |
| 10 | 12 | "os/exec" | |
| @@ -14,6 +16,8 @@ import ( | |||
| 14 | 16 | "time" | |
| 15 | 17 | ||
| 16 | 18 | "github.com/stretchr/testify/require" | |
| 19 | + | ||
| 20 | + | "github.com/bnema/vev/internal/adapters/lifecycle" | |
| 17 | 21 | ) | |
| 18 | 22 | ||
| 19 | 23 | func TestWaitProcessExit(t *testing.T) { | |
| @@ -62,6 +66,8 @@ func TestProcessRole(t *testing.T) { | |||
| 62 | 66 | {name: "broker", cmdline: "/proc/self/exe\x00_broker-production-serve\x00", want: "broker"}, | |
| 63 | 67 | {name: "hidden helper", cmdline: "/proc/self/exe\x00_broker-mux-quic-proxy\x00", want: "helper"}, | |
| 64 | 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 | 72 | for _, tt := range tests { | |
| 67 | 73 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -71,50 +77,84 @@ func TestProcessRole(t *testing.T) { | |||
| 71 | 77 | } | |
| 72 | 78 | ||
| 73 | 79 | // 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 | 82 | func TestFindVevProcesses(t *testing.T) { | |
| 76 | 83 | root := t.TempDir() | |
| 77 | 84 | const exe = "/opt/bin/vev" | |
| 85 | + | const runtime = "XDG_RUNTIME_DIR=/run/user/1000" | |
| 78 | 86 | uid := os.Getuid() | |
| 79 | - | add := func(pid, target, cmdline string) { | |
| 87 | + | add := func(pid, target, cmdline, environ string) { | |
| 80 | 88 | dir := filepath.Join(root, pid) | |
| 81 | 89 | require.NoError(t, os.Mkdir(dir, 0o755)) | |
| 82 | 90 | require.NoError(t, os.Symlink(target, filepath.Join(dir, "exe"))) | |
| 83 | 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") | |
| 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") | |
| 90 | 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 | 104 | require.NoError(t, err) | |
| 94 | 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 | 114 | require.NoError(t, err) | |
| 98 | 115 | require.Empty(t, none, "another user's processes are never matched") | |
| 99 | 116 | } | |
| 100 | 117 | ||
| 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) | |
| 104 | 125 | 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 | + | } | |
| 109 | 134 | ||
| 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) | |
| 112 | 151 | } | |
| 113 | 152 | ||
| 114 | 153 | func TestRemoveDeadSockets(t *testing.T) { | |
| 115 | 154 | dir, err := os.MkdirTemp("", "vevk") | |
| 116 | 155 | require.NoError(t, err) | |
| 117 | 156 | t.Cleanup(func() { _ = os.RemoveAll(dir) }) | |
| 157 | + | require.NoError(t, os.Chmod(dir, 0o700)) | |
| 118 | 158 | require.NoError(t, os.Mkdir(filepath.Join(dir, "broker"), 0o700)) | |
| 119 | 159 | ||
| 120 | 160 | live, err := net.Listen("unix", filepath.Join(dir, "daemon.sock")) | |
| @@ -124,10 +164,30 @@ func TestRemoveDeadSockets(t *testing.T) { | |||
| 124 | 164 | require.NoError(t, err) | |
| 125 | 165 | dead.(*net.UnixListener).SetUnlinkOnClose(false) | |
| 126 | 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 | 170 | removed := removeDeadSockets(dir) | |
| 129 | 171 | require.Equal(t, []string{filepath.Join(dir, "daemonmux.sock")}, removed) | |
| 130 | 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 | 193 | func TestKillAllRefusesInsideVev(t *testing.T) { | |
| @@ -139,13 +199,35 @@ func TestKillAllRefusesInsideVev(t *testing.T) { | |||
| 139 | 199 | } | |
| 140 | 200 | ||
| 141 | 201 | 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 | + | } | |
| 149 | 231 | } | |
| 150 | 232 | ||
| 151 | 233 | // 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 processes | 79 | // 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") // self | 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") | |||
| 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 install | 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 | + | } | |||
| 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 | - | <-reaped | 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) | |||
| 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.Buffer | 202 | + | 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.Buffer | 206 | + | }{ |
| 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 parent | 233 | // TestWaitProcessExitTreatsZombieAsGone pins that an exited child its parent | ||
M internal/app/run.go +12 -0
| @@ -62,6 +62,7 @@ const ( | |||
| 62 | 62 | kindDaemonLauncher | |
| 63 | 63 | kindUIDriver | |
| 64 | 64 | kindUIRemoteCleanup | |
| 65 | + | kindDaemonStop | |
| 65 | 66 | kindWebDaemon | |
| 66 | 67 | kindWebRenew | |
| 67 | 68 | kindWebServe | |
| @@ -248,6 +249,11 @@ parsedUIFlags: | |||
| 248 | 249 | return command{}, usagef("`%s` does not accept arguments", uiRemoteCleanupCommand) | |
| 249 | 250 | } | |
| 250 | 251 | 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 | 257 | case "new": | |
| 252 | 258 | if len(args) < 2 || args[1] == "" { | |
| 253 | 259 | return command{}, usagef("`new` requires a session name") | |
| @@ -372,6 +378,8 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 372 | 378 | return runProductionBrokerLauncherCommand(ctx) | |
| 373 | 379 | case kindBrokerReady: | |
| 374 | 380 | return runBrokerReady(ctx, cmd.brokerReady, os.Stdout) | |
| 381 | + | case kindDaemonStop: | |
| 382 | + | return requestDaemonStop(ctx) | |
| 375 | 383 | case kindUIRemoteCleanup: | |
| 376 | 384 | return runUIRemoteCleanup(ctx) | |
| 377 | 385 | case kindUIDriver: | |
| @@ -917,6 +925,10 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re | |||
| 917 | 925 | const ( | |
| 918 | 926 | envRemoteTransport = "VEV_REMOTE_TRANSPORT" | |
| 919 | 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 | 934 | func remoteTransportModeFromEnv(value string) (string, error) { | |
| @@ -62,6 +62,7 @@ const ( | |||||
| 62 | kindDaemonLauncher | 62 | kindDaemonLauncher | ||
| 63 | kindUIDriver | 63 | kindUIDriver | ||
| 64 | kindUIRemoteCleanup | 64 | kindUIRemoteCleanup | ||
| 65 | + | kindDaemonStop | |||
| 65 | kindWebDaemon | 66 | kindWebDaemon | ||
| 66 | kindWebRenew | 67 | kindWebRenew | ||
| 67 | kindWebServe | 68 | 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}, nil | 251 | 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) { | |||
| 478 | 478 | {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true}, | |
| 479 | 479 | {name: "kill sessions", args: []string{"kill", "--sessions"}, wantKind: kindKill, wantSessions: true}, | |
| 480 | 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 | 484 | {name: "kill removed daemon flag", args: []string{"kill", "--daemon"}, wantErr: true}, | |
| 482 | 485 | {name: "kill removed broker flag", args: []string{"kill", "--broker"}, wantErr: true}, | |
| 483 | 486 | {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() { | |||
| 227 | 227 | for host in "${hosts[@]}"; do | |
| 228 | 228 | vev_env timeout 15 "$vev_bin" host rm "$host" >/dev/null 2>&1 || true | |
| 229 | 229 | 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 | |
| 231 | 231 | 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 | |
| 233 | 233 | # sandbox roles, then KILL whatever ignores it. | |
| 234 | 234 | local pids | |
| 235 | 235 | pids=$(vev_pids | paste -sd' ') | |
| @@ -227,9 +227,9 @@ cmd_down() { | |||||
| 227 | for host in "${hosts[@]}"; do | 227 | for host in "${hosts[@]}"; do | ||
| 228 | vev_env timeout 15 "$vev_bin" host rm "$host" >/dev/null 2>&1 || true | 228 | vev_env timeout 15 "$vev_bin" host rm "$host" >/dev/null 2>&1 || true | ||
| 229 | done | 229 | 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 |
| 231 | fi | 231 | 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 |
| 233 | # sandbox roles, then KILL whatever ignores it. | 233 | # sandbox roles, then KILL whatever ignores it. | ||
| 234 | local pids | 234 | local pids | ||
| 235 | pids=$(vev_pids | paste -sd' ') | 235 | pids=$(vev_pids | paste -sd' ') | ||