feat(kill): make kill --all stop every vev process and rename the session purge to --sessions
- commit
- 0a0e1c0ccea3965ea15eda4d95cb31889b3d8e21
- author
- brice · · 2026-09-23 21:07 UTC
- parent
- 4df485d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +431 -211
M AGENTS.md +2 -2
| @@ -77,8 +77,8 @@ Typed messages and negotiated version live in `internal/protocol`. Remote discov | |||
| 77 | 77 | ||
| 78 | 78 | - Ephemeral numbered sessions survive detach while the daemon retains them, but are not persisted. | |
| 79 | 79 | - Named sessions survive headless and persist across daemon restarts. | |
| 80 | - | - The daemon starts on first use and survives an empty session registry: final session removal is not shutdown, so it can be reused; it ends only on an explicit shutdown request or process cancellation. | |
| 81 | - | - A bulk purge (`kill --all`) takes a transient admission gate and bounds each phase with its own deadline: admission drain, stopped/broken sweep, and a shared live-teardown budget. A repository delete that ignores cancellation is detached behind an exact name/incarnation/created-time fence and reported as a typed failure instead of holding purge admission or control. | |
| 80 | + | - The daemon starts on first use and survives an empty session registry: final session removal is not shutdown, so it can be reused; it ends only on an explicit shutdown request, `kill --all`, or process cancellation. | |
| 81 | + | - A bulk purge (`kill --sessions`) takes a transient admission gate and bounds each phase with its own deadline: admission drain, stopped/broken sweep, and a shared live-teardown budget. A repository delete that ignores cancellation is detached behind an exact name/incarnation/created-time fence and reported as a typed failure instead of holding purge admission or control. | |
| 82 | 82 | - A live unit whose teardown aborts before destructive ownership stays registered, and its snapshot-coordinator quarantine is rolled back so the surviving session resumes checkpoint scheduling and its attached clients are unfrozen. | |
| 83 | 83 | - Each connection has a 15-second handshake budget from connect through the initial committed publication. | |
| 84 | 84 | - Local and remote attach use the same typed `Hello`/`Welcome` session protocol. `sessionwire` translates typed traffic to Protobuf envelopes carried by IPC, QUIC, or SSH stdio. | |
| @@ -77,8 +77,8 @@ Typed messages and negotiated version live in `internal/protocol`. Remote discov | |||||
| 77 | 77 | ||||
| 78 | - Ephemeral numbered sessions survive detach while the daemon retains them, but are not persisted. | 78 | - Ephemeral numbered sessions survive detach while the daemon retains them, but are not persisted. | ||
| 79 | - Named sessions survive headless and persist across daemon restarts. | 79 | - Named sessions survive headless and persist across daemon restarts. | ||
| 80 | - | - The daemon starts on first use and survives an empty session registry: final session removal is not shutdown, so it can be reused; it ends only on an explicit shutdown request or process cancellation. | 80 | + | - The daemon starts on first use and survives an empty session registry: final session removal is not shutdown, so it can be reused; it ends only on an explicit shutdown request, `kill --all`, or process cancellation. |
| 81 | - | - A bulk purge (`kill --all`) takes a transient admission gate and bounds each phase with its own deadline: admission drain, stopped/broken sweep, and a shared live-teardown budget. A repository delete that ignores cancellation is detached behind an exact name/incarnation/created-time fence and reported as a typed failure instead of holding purge admission or control. | 81 | + | - A bulk purge (`kill --sessions`) takes a transient admission gate and bounds each phase with its own deadline: admission drain, stopped/broken sweep, and a shared live-teardown budget. A repository delete that ignores cancellation is detached behind an exact name/incarnation/created-time fence and reported as a typed failure instead of holding purge admission or control. |
| 82 | - A live unit whose teardown aborts before destructive ownership stays registered, and its snapshot-coordinator quarantine is rolled back so the surviving session resumes checkpoint scheduling and its attached clients are unfrozen. | 82 | - A live unit whose teardown aborts before destructive ownership stays registered, and its snapshot-coordinator quarantine is rolled back so the surviving session resumes checkpoint scheduling and its attached clients are unfrozen. | ||
| 83 | - Each connection has a 15-second handshake budget from connect through the initial committed publication. | 83 | - Each connection has a 15-second handshake budget from connect through the initial committed publication. | ||
| 84 | - Local and remote attach use the same typed `Hello`/`Welcome` session protocol. `sessionwire` translates typed traffic to Protobuf envelopes carried by IPC, QUIC, or SSH stdio. | 84 | - Local and remote attach use the same typed `Hello`/`Welcome` session protocol. `sessionwire` translates typed traffic to Protobuf envelopes carried by IPC, QUIC, or SSH stdio. | ||
M README.md +2 -2
| @@ -42,7 +42,7 @@ vev --ui-observe ... expose passive observation for an attach | |||
| 42 | 42 | vev --ui-control ... expose observation and input control | |
| 43 | 43 | ``` | |
| 44 | 44 | ||
| 45 | - | The daemon starts on first use and survives an empty session registry: removing the last session is not shutdown, so it can be reused. `kill --all` purges every session and leaves the daemon running; it ends only on an explicit `kill --daemon` or process cancellation. Numbered sessions survive detach; named sessions also survive daemon restarts. Exiting the final shell removes that session and returns to the most recently used session; with no previous session, vev exits. See [durable-session recovery](docs/durable-session-recovery.md). | |
| 45 | + | The daemon starts on first use and survives an empty session registry: removing the last session is not shutdown, so it can be reused. `kill --sessions` purges every session and leaves vev running. `kill --all` stops everything: every vev window, the broker, and the daemon, even from an older build; named sessions come back on the next start. Numbered sessions survive detach; named sessions also survive daemon restarts. Exiting the final shell removes that session and returns to the most recently used session; with no previous session, vev exits. See [durable-session recovery](docs/durable-session-recovery.md). | |
| 46 | 46 | ||
| 47 | 47 | > [!NOTE] | |
| 48 | 48 | > A release that bumps vev's protocol version resets named sessions saved by an older protocol. Session names are retained, but layouts, tabs, terminal history, recovery transcripts, and process-recovery state are discarded. Each local or remote daemon applies this reset to its own sessions when it first starts with the new protocol. | |
| @@ -120,7 +120,7 @@ Use `VEV_ENV` to keep development runs separate from your installed vev and from | |||
| 120 | 120 | ||
| 121 | 121 | ```sh | |
| 122 | 122 | VEV_ENV=dev go run . | |
| 123 | - | VEV_ENV=dev go run . kill --all | |
| 123 | + | VEV_ENV=dev go run . kill --sessions | |
| 124 | 124 | rm -rf .dev/dev | |
| 125 | 125 | ``` | |
| 126 | 126 | ||
| @@ -42,7 +42,7 @@ vev --ui-observe ... expose passive observation for an attach | |||||
| 42 | vev --ui-control ... expose observation and input control | 42 | vev --ui-control ... expose observation and input control | ||
| 43 | ``` | 43 | ``` | ||
| 44 | 44 | ||||
| 45 | - | The daemon starts on first use and survives an empty session registry: removing the last session is not shutdown, so it can be reused. `kill --all` purges every session and leaves the daemon running; it ends only on an explicit `kill --daemon` or process cancellation. Numbered sessions survive detach; named sessions also survive daemon restarts. Exiting the final shell removes that session and returns to the most recently used session; with no previous session, vev exits. See [durable-session recovery](docs/durable-session-recovery.md). | 45 | + | The daemon starts on first use and survives an empty session registry: removing the last session is not shutdown, so it can be reused. `kill --sessions` purges every session and leaves vev running. `kill --all` stops everything: every vev window, the broker, and the daemon, even from an older build; named sessions come back on the next start. Numbered sessions survive detach; named sessions also survive daemon restarts. Exiting the final shell removes that session and returns to the most recently used session; with no previous session, vev exits. See [durable-session recovery](docs/durable-session-recovery.md). |
| 46 | 46 | ||||
| 47 | > [!NOTE] | 47 | > [!NOTE] | ||
| 48 | > A release that bumps vev's protocol version resets named sessions saved by an older protocol. Session names are retained, but layouts, tabs, terminal history, recovery transcripts, and process-recovery state are discarded. Each local or remote daemon applies this reset to its own sessions when it first starts with the new protocol. | 48 | > A release that bumps vev's protocol version resets named sessions saved by an older protocol. Session names are retained, but layouts, tabs, terminal history, recovery transcripts, and process-recovery state are discarded. Each local or remote daemon applies this reset to its own sessions when it first starts with the new protocol. | ||
| @@ -120,7 +120,7 @@ Use `VEV_ENV` to keep development runs separate from your installed vev and from | |||||
| 120 | 120 | ||||
| 121 | ```sh | 121 | ```sh | ||
| 122 | VEV_ENV=dev go run . | 122 | VEV_ENV=dev go run . | ||
| 123 | - | VEV_ENV=dev go run . kill --all | 123 | + | VEV_ENV=dev go run . kill --sessions |
| 124 | rm -rf .dev/dev | 124 | rm -rf .dev/dev | ||
| 125 | ``` | 125 | ``` | ||
| 126 | 126 | ||||
M docs/configuration.md +1 -1
| @@ -120,7 +120,7 @@ Set `VEV_ENV` to a name such as `dev` to redirect all vev-owned paths into `.dev | |||
| 120 | 120 | ||
| 121 | 121 | ```sh | |
| 122 | 122 | VEV_ENV=dev go run . | |
| 123 | - | VEV_ENV=dev go run . kill --all | |
| 123 | + | VEV_ENV=dev go run . kill --sessions | |
| 124 | 124 | rm -rf .dev/dev | |
| 125 | 125 | ``` | |
| 126 | 126 | ||
| @@ -120,7 +120,7 @@ Set `VEV_ENV` to a name such as `dev` to redirect all vev-owned paths into `.dev | |||||
| 120 | 120 | ||||
| 121 | ```sh | 121 | ```sh | ||
| 122 | VEV_ENV=dev go run . | 122 | VEV_ENV=dev go run . | ||
| 123 | - | VEV_ENV=dev go run . kill --all | 123 | + | VEV_ENV=dev go run . kill --sessions |
| 124 | rm -rf .dev/dev | 124 | rm -rf .dev/dev | ||
| 125 | ``` | 125 | ``` | ||
| 126 | 126 | ||||
M docs/durable-session-recovery.md +1 -1
| @@ -8,7 +8,7 @@ vev opens named-session state only while holding `$XDG_RUNTIME_DIR/vev/lifecycle | |||
| 8 | 8 | ||
| 9 | 9 | ## Bulk purge | |
| 10 | 10 | ||
| 11 | - | `kill --all` purges every live, stopped, and broken session while leaving the daemon running. Its admission gate is transient and each phase spends its own bounded deadline (admission drain, stopped/broken sweep, shared live teardown), so an uncooperative repository can never hold purge admission or control indefinitely. A durable delete that ignores cancellation is completed by a detached worker under the exact session name, incarnation, and creation time it captured, so it can never remove a same-name replacement; until it returns, the stopped authority still reserves the name and the purge reports a typed failure. A live unit whose teardown aborts before it owns destructive work stays registered, and its snapshot-coordinator quarantine is rolled back so it keeps checkpointing. | |
| 11 | + | `kill --sessions` purges every live, stopped, and broken session while leaving the daemon running. Its admission gate is transient and each phase spends its own bounded deadline (admission drain, stopped/broken sweep, shared live teardown), so an uncooperative repository can never hold purge admission or control indefinitely. A durable delete that ignores cancellation is completed by a detached worker under the exact session name, incarnation, and creation time it captured, so it can never remove a same-name replacement; until it returns, the stopped authority still reserves the name and the purge reports a typed failure. A live unit whose teardown aborts before it owns destructive work stays registered, and its snapshot-coordinator quarantine is rolled back so it keeps checkpointing. | |
| 12 | 12 | ||
| 13 | 13 | ## Recovery commands | |
| 14 | 14 | ||
| @@ -8,7 +8,7 @@ vev opens named-session state only while holding `$XDG_RUNTIME_DIR/vev/lifecycle | |||||
| 8 | 8 | ||||
| 9 | ## Bulk purge | 9 | ## Bulk purge | ||
| 10 | 10 | ||||
| 11 | - | `kill --all` purges every live, stopped, and broken session while leaving the daemon running. Its admission gate is transient and each phase spends its own bounded deadline (admission drain, stopped/broken sweep, shared live teardown), so an uncooperative repository can never hold purge admission or control indefinitely. A durable delete that ignores cancellation is completed by a detached worker under the exact session name, incarnation, and creation time it captured, so it can never remove a same-name replacement; until it returns, the stopped authority still reserves the name and the purge reports a typed failure. A live unit whose teardown aborts before it owns destructive work stays registered, and its snapshot-coordinator quarantine is rolled back so it keeps checkpointing. | 11 | + | `kill --sessions` purges every live, stopped, and broken session while leaving the daemon running. Its admission gate is transient and each phase spends its own bounded deadline (admission drain, stopped/broken sweep, shared live teardown), so an uncooperative repository can never hold purge admission or control indefinitely. A durable delete that ignores cancellation is completed by a detached worker under the exact session name, incarnation, and creation time it captured, so it can never remove a same-name replacement; until it returns, the stopped authority still reserves the name and the purge reports a typed failure. A live unit whose teardown aborts before it owns destructive work stays registered, and its snapshot-coordinator quarantine is rolled back so it keeps checkpointing. |
| 12 | 12 | ||||
| 13 | ## Recovery commands | 13 | ## Recovery commands | ||
| 14 | 14 | ||||
D internal/app/broker_kill_linux.go +0 -83
| @@ -1,83 +0,0 @@ | |||
| 1 | - | //go:build linux | |
| 2 | - | ||
| 3 | - | package app | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "context" | |
| 7 | - | "errors" | |
| 8 | - | "fmt" | |
| 9 | - | "net" | |
| 10 | - | "os" | |
| 11 | - | "syscall" | |
| 12 | - | "time" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | // runKillBroker signals only the process proven by kernel credentials to own | |
| 18 | - | // the current broker socket. Dialing does not launch a broker or mutate state. | |
| 19 | - | func runKillBroker(ctx context.Context) error { | |
| 20 | - | path := brokeripc.SocketPath(productionBrokerLayout().Runtime) | |
| 21 | - | conn, err := (&net.Dialer{}).DialContext(ctx, "unix", path) | |
| 22 | - | if err != nil { | |
| 23 | - | if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ECONNREFUSED) { | |
| 24 | - | fmt.Println("no broker running") | |
| 25 | - | return nil | |
| 26 | - | } | |
| 27 | - | return fmt.Errorf("vev: connect broker to stop: %w", err) | |
| 28 | - | } | |
| 29 | - | defer func() { _ = conn.Close() }() | |
| 30 | - | unix, ok := conn.(*net.UnixConn) | |
| 31 | - | if !ok { | |
| 32 | - | return errors.New("vev: broker endpoint is not a Unix socket") | |
| 33 | - | } | |
| 34 | - | raw, err := unix.SyscallConn() | |
| 35 | - | if err != nil { | |
| 36 | - | return err | |
| 37 | - | } | |
| 38 | - | var peer *syscall.Ucred | |
| 39 | - | var peerErr error | |
| 40 | - | if err := raw.Control(func(fd uintptr) { | |
| 41 | - | peer, peerErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED) | |
| 42 | - | }); err != nil { | |
| 43 | - | return err | |
| 44 | - | } | |
| 45 | - | if peerErr != nil { | |
| 46 | - | return peerErr | |
| 47 | - | } | |
| 48 | - | if peer == nil || peer.Uid != uint32(os.Geteuid()) || peer.Pid <= 1 || int(peer.Pid) == os.Getpid() { | |
| 49 | - | return errors.New("vev: broker peer identity is invalid") | |
| 50 | - | } | |
| 51 | - | if err := syscall.Kill(int(peer.Pid), syscall.SIGTERM); err != nil { | |
| 52 | - | return fmt.Errorf("vev: stop broker: %w", err) | |
| 53 | - | } | |
| 54 | - | // Report success only once the process is gone, so a command that follows | |
| 55 | - | // immediately never reaches the draining broker. | |
| 56 | - | if err := waitProcessExit(ctx, int(peer.Pid), brokerStopTimeout); err != nil { | |
| 57 | - | return fmt.Errorf("vev: stop broker (pid %d): %w", peer.Pid, err) | |
| 58 | - | } | |
| 59 | - | fmt.Println("stopped broker") | |
| 60 | - | return nil | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | // brokerStopTimeout bounds how long kill --broker waits for an orderly exit. | |
| 64 | - | const brokerStopTimeout = 5 * time.Second | |
| 65 | - | ||
| 66 | - | // waitProcessExit polls until pid no longer exists. The broker is reparented | |
| 67 | - | // at launch, so it is never this process' zombie child. | |
| 68 | - | func waitProcessExit(ctx context.Context, pid int, timeout time.Duration) error { | |
| 69 | - | ctx, cancel := context.WithTimeout(ctx, timeout) | |
| 70 | - | defer cancel() | |
| 71 | - | ticker := time.NewTicker(20 * time.Millisecond) | |
| 72 | - | defer ticker.Stop() | |
| 73 | - | for { | |
| 74 | - | if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) { | |
| 75 | - | return nil | |
| 76 | - | } | |
| 77 | - | select { | |
| 78 | - | case <-ctx.Done(): | |
| 79 | - | return fmt.Errorf("still running after %s: %w", timeout, ctx.Err()) | |
| 80 | - | case <-ticker.C: | |
| 81 | - | } | |
| 82 | - | } | |
| 83 | - | } | |
| @@ -1,83 +0,0 @@ | |||||
| 1 | - | //go:build linux | |||
| 2 | - | ||||
| 3 | - | package app | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "context" | |||
| 7 | - | "errors" | |||
| 8 | - | "fmt" | |||
| 9 | - | "net" | |||
| 10 | - | "os" | |||
| 11 | - | "syscall" | |||
| 12 | - | "time" | |||
| 13 | - | ||||
| 14 | - | "github.com/bnema/vev/internal/adapters/brokeripc" | |||
| 15 | - | ) | |||
| 16 | - | ||||
| 17 | - | // runKillBroker signals only the process proven by kernel credentials to own | |||
| 18 | - | // the current broker socket. Dialing does not launch a broker or mutate state. | |||
| 19 | - | func runKillBroker(ctx context.Context) error { | |||
| 20 | - | path := brokeripc.SocketPath(productionBrokerLayout().Runtime) | |||
| 21 | - | conn, err := (&net.Dialer{}).DialContext(ctx, "unix", path) | |||
| 22 | - | if err != nil { | |||
| 23 | - | if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ECONNREFUSED) { | |||
| 24 | - | fmt.Println("no broker running") | |||
| 25 | - | return nil | |||
| 26 | - | } | |||
| 27 | - | return fmt.Errorf("vev: connect broker to stop: %w", err) | |||
| 28 | - | } | |||
| 29 | - | defer func() { _ = conn.Close() }() | |||
| 30 | - | unix, ok := conn.(*net.UnixConn) | |||
| 31 | - | if !ok { | |||
| 32 | - | return errors.New("vev: broker endpoint is not a Unix socket") | |||
| 33 | - | } | |||
| 34 | - | raw, err := unix.SyscallConn() | |||
| 35 | - | if err != nil { | |||
| 36 | - | return err | |||
| 37 | - | } | |||
| 38 | - | var peer *syscall.Ucred | |||
| 39 | - | var peerErr error | |||
| 40 | - | if err := raw.Control(func(fd uintptr) { | |||
| 41 | - | peer, peerErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED) | |||
| 42 | - | }); err != nil { | |||
| 43 | - | return err | |||
| 44 | - | } | |||
| 45 | - | if peerErr != nil { | |||
| 46 | - | return peerErr | |||
| 47 | - | } | |||
| 48 | - | if peer == nil || peer.Uid != uint32(os.Geteuid()) || peer.Pid <= 1 || int(peer.Pid) == os.Getpid() { | |||
| 49 | - | return errors.New("vev: broker peer identity is invalid") | |||
| 50 | - | } | |||
| 51 | - | if err := syscall.Kill(int(peer.Pid), syscall.SIGTERM); err != nil { | |||
| 52 | - | return fmt.Errorf("vev: stop broker: %w", err) | |||
| 53 | - | } | |||
| 54 | - | // Report success only once the process is gone, so a command that follows | |||
| 55 | - | // immediately never reaches the draining broker. | |||
| 56 | - | if err := waitProcessExit(ctx, int(peer.Pid), brokerStopTimeout); err != nil { | |||
| 57 | - | return fmt.Errorf("vev: stop broker (pid %d): %w", peer.Pid, err) | |||
| 58 | - | } | |||
| 59 | - | fmt.Println("stopped broker") | |||
| 60 | - | return nil | |||
| 61 | - | } | |||
| 62 | - | ||||
| 63 | - | // brokerStopTimeout bounds how long kill --broker waits for an orderly exit. | |||
| 64 | - | const brokerStopTimeout = 5 * time.Second | |||
| 65 | - | ||||
| 66 | - | // waitProcessExit polls until pid no longer exists. The broker is reparented | |||
| 67 | - | // at launch, so it is never this process' zombie child. | |||
| 68 | - | func waitProcessExit(ctx context.Context, pid int, timeout time.Duration) error { | |||
| 69 | - | ctx, cancel := context.WithTimeout(ctx, timeout) | |||
| 70 | - | defer cancel() | |||
| 71 | - | ticker := time.NewTicker(20 * time.Millisecond) | |||
| 72 | - | defer ticker.Stop() | |||
| 73 | - | for { | |||
| 74 | - | if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) { | |||
| 75 | - | return nil | |||
| 76 | - | } | |||
| 77 | - | select { | |||
| 78 | - | case <-ctx.Done(): | |||
| 79 | - | return fmt.Errorf("still running after %s: %w", timeout, ctx.Err()) | |||
| 80 | - | case <-ticker.C: | |||
| 81 | - | } | |||
| 82 | - | } | |||
| 83 | - | } | |||
D internal/app/broker_kill_linux_test.go +0 -47
| @@ -1,47 +0,0 @@ | |||
| 1 | - | //go:build linux | |
| 2 | - | ||
| 3 | - | package app | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "context" | |
| 7 | - | "os/exec" | |
| 8 | - | "syscall" | |
| 9 | - | "testing" | |
| 10 | - | "time" | |
| 11 | - | ||
| 12 | - | "github.com/stretchr/testify/require" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | func TestWaitProcessExit(t *testing.T) { | |
| 16 | - | tests := []struct { | |
| 17 | - | name string | |
| 18 | - | signal bool | |
| 19 | - | wantErr bool | |
| 20 | - | }{ | |
| 21 | - | {name: "terminated process is reported gone", signal: true}, | |
| 22 | - | {name: "live process times out", signal: false, wantErr: true}, | |
| 23 | - | } | |
| 24 | - | for _, tt := range tests { | |
| 25 | - | t.Run(tt.name, func(t *testing.T) { | |
| 26 | - | cmd := exec.Command("sleep", "30") | |
| 27 | - | require.NoError(t, cmd.Start()) | |
| 28 | - | // Reap the child so it never lingers as a zombie that kill(pid, 0) | |
| 29 | - | // would still report as present. | |
| 30 | - | reaped := make(chan struct{}) | |
| 31 | - | go func() { _ = cmd.Wait(); close(reaped) }() | |
| 32 | - | t.Cleanup(func() { | |
| 33 | - | _ = cmd.Process.Kill() | |
| 34 | - | <-reaped | |
| 35 | - | }) | |
| 36 | - | if tt.signal { | |
| 37 | - | require.NoError(t, syscall.Kill(cmd.Process.Pid, syscall.SIGTERM)) | |
| 38 | - | } | |
| 39 | - | err := waitProcessExit(context.Background(), cmd.Process.Pid, 300*time.Millisecond) | |
| 40 | - | if tt.wantErr { | |
| 41 | - | require.Error(t, err) | |
| 42 | - | return | |
| 43 | - | } | |
| 44 | - | require.NoError(t, err) | |
| 45 | - | }) | |
| 46 | - | } | |
| 47 | - | } | |
| @@ -1,47 +0,0 @@ | |||||
| 1 | - | //go:build linux | |||
| 2 | - | ||||
| 3 | - | package app | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "context" | |||
| 7 | - | "os/exec" | |||
| 8 | - | "syscall" | |||
| 9 | - | "testing" | |||
| 10 | - | "time" | |||
| 11 | - | ||||
| 12 | - | "github.com/stretchr/testify/require" | |||
| 13 | - | ) | |||
| 14 | - | ||||
| 15 | - | func TestWaitProcessExit(t *testing.T) { | |||
| 16 | - | tests := []struct { | |||
| 17 | - | name string | |||
| 18 | - | signal bool | |||
| 19 | - | wantErr bool | |||
| 20 | - | }{ | |||
| 21 | - | {name: "terminated process is reported gone", signal: true}, | |||
| 22 | - | {name: "live process times out", signal: false, wantErr: true}, | |||
| 23 | - | } | |||
| 24 | - | for _, tt := range tests { | |||
| 25 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 26 | - | cmd := exec.Command("sleep", "30") | |||
| 27 | - | require.NoError(t, cmd.Start()) | |||
| 28 | - | // Reap the child so it never lingers as a zombie that kill(pid, 0) | |||
| 29 | - | // would still report as present. | |||
| 30 | - | reaped := make(chan struct{}) | |||
| 31 | - | go func() { _ = cmd.Wait(); close(reaped) }() | |||
| 32 | - | t.Cleanup(func() { | |||
| 33 | - | _ = cmd.Process.Kill() | |||
| 34 | - | <-reaped | |||
| 35 | - | }) | |||
| 36 | - | if tt.signal { | |||
| 37 | - | require.NoError(t, syscall.Kill(cmd.Process.Pid, syscall.SIGTERM)) | |||
| 38 | - | } | |||
| 39 | - | err := waitProcessExit(context.Background(), cmd.Process.Pid, 300*time.Millisecond) | |||
| 40 | - | if tt.wantErr { | |||
| 41 | - | require.Error(t, err) | |||
| 42 | - | return | |||
| 43 | - | } | |||
| 44 | - | require.NoError(t, err) | |||
| 45 | - | }) | |||
| 46 | - | } | |||
| 47 | - | } | |||
D internal/app/broker_kill_other.go +0 -12
| @@ -1,12 +0,0 @@ | |||
| 1 | - | //go:build !linux | |
| 2 | - | ||
| 3 | - | package app | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "context" | |
| 7 | - | "errors" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | func runKillBroker(context.Context) error { | |
| 11 | - | return errors.New("vev: stopping the broker requires Linux peer credentials") | |
| 12 | - | } | |
| @@ -1,12 +0,0 @@ | |||||
| 1 | - | //go:build !linux | |||
| 2 | - | ||||
| 3 | - | package app | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "context" | |||
| 7 | - | "errors" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | func runKillBroker(context.Context) error { | |||
| 11 | - | return errors.New("vev: stopping the broker requires Linux peer credentials") | |||
| 12 | - | } | |||
M internal/app/broker_offline_status.go +2 -2
| @@ -293,7 +293,7 @@ func ensureBrokerReady(ctx context.Context, req brokerStatusRequest, deps broker | |||
| 293 | 293 | return brokerStatusReport{}, err | |
| 294 | 294 | } | |
| 295 | 295 | if !errors.Is(err, errBrokerAbsent) { | |
| 296 | - | return brokerStatusReport{}, fmt.Errorf("vev: broker endpoint %s is live but incompatible: %w; run \"vev kill --broker\" and retry", req.socketPath, err) | |
| 296 | + | return brokerStatusReport{}, fmt.Errorf("vev: broker endpoint %s is live but incompatible: %w; run \"vev kill --all\" and retry", req.socketPath, err) | |
| 297 | 297 | } | |
| 298 | 298 | ||
| 299 | 299 | if held == nil { | |
| @@ -314,7 +314,7 @@ func ensureBrokerReady(ctx context.Context, req brokerStatusRequest, deps broker | |||
| 314 | 314 | return brokerStatusReport{}, err | |
| 315 | 315 | } | |
| 316 | 316 | if !errors.Is(err, errBrokerAbsent) { | |
| 317 | - | return brokerStatusReport{}, fmt.Errorf("vev: broker endpoint %s is live but incompatible: %w; run \"vev kill --broker\" and retry", req.socketPath, err) | |
| 317 | + | return brokerStatusReport{}, fmt.Errorf("vev: broker endpoint %s is live but incompatible: %w; run \"vev kill --all\" and retry", req.socketPath, err) | |
| 318 | 318 | } | |
| 319 | 319 | case errors.Is(err, lifecycle.ErrBusy): | |
| 320 | 320 | // Another caller is elected and spawning. Only the elected | |
| @@ -293,7 +293,7 @@ func ensureBrokerReady(ctx context.Context, req brokerStatusRequest, deps broker | |||||
| 293 | return brokerStatusReport{}, err | 293 | return brokerStatusReport{}, err | ||
| 294 | } | 294 | } | ||
| 295 | if !errors.Is(err, errBrokerAbsent) { | 295 | if !errors.Is(err, errBrokerAbsent) { | ||
| 296 | - | return brokerStatusReport{}, fmt.Errorf("vev: broker endpoint %s is live but incompatible: %w; run \"vev kill --broker\" and retry", req.socketPath, err) | 296 | + | return brokerStatusReport{}, fmt.Errorf("vev: broker endpoint %s is live but incompatible: %w; run \"vev kill --all\" and retry", req.socketPath, err) |
| 297 | } | 297 | } | ||
| 298 | 298 | ||||
| 299 | if held == nil { | 299 | if held == nil { | ||
| @@ -314,7 +314,7 @@ func ensureBrokerReady(ctx context.Context, req brokerStatusRequest, deps broker | |||||
| 314 | return brokerStatusReport{}, err | 314 | return brokerStatusReport{}, err | ||
| 315 | } | 315 | } | ||
| 316 | if !errors.Is(err, errBrokerAbsent) { | 316 | if !errors.Is(err, errBrokerAbsent) { | ||
| 317 | - | return brokerStatusReport{}, fmt.Errorf("vev: broker endpoint %s is live but incompatible: %w; run \"vev kill --broker\" and retry", req.socketPath, err) | 317 | + | return brokerStatusReport{}, fmt.Errorf("vev: broker endpoint %s is live but incompatible: %w; run \"vev kill --all\" and retry", req.socketPath, err) |
| 318 | } | 318 | } | ||
| 319 | case errors.Is(err, lifecycle.ErrBusy): | 319 | case errors.Is(err, lifecycle.ErrBusy): | ||
| 320 | // Another caller is elected and spawning. Only the elected | 320 | // Another caller is elected and spawning. Only the elected | ||
M internal/app/broker_production_connect.go +1 -1
| @@ -77,7 +77,7 @@ func connectProductionBroker(ctx context.Context) (ports.BrokerService, error) { | |||
| 77 | 77 | return nil, fmt.Errorf("vev: connect broker: %w", err) | |
| 78 | 78 | } | |
| 79 | 79 | if _, stale := brokeripc.StaleBuild(service); stale { | |
| 80 | - | fmt.Fprintln(os.Stderr, `vev: the running broker is from another build; run "vev kill --broker" to restart it`) | |
| 80 | + | fmt.Fprintln(os.Stderr, `vev: the running broker is from another build; run "vev kill --all" to restart it`) | |
| 81 | 81 | } | |
| 82 | 82 | if err := awaitBrokerPublication(ctx, service); err != nil { | |
| 83 | 83 | _ = service.Close() | |
| @@ -77,7 +77,7 @@ func connectProductionBroker(ctx context.Context) (ports.BrokerService, error) { | |||||
| 77 | return nil, fmt.Errorf("vev: connect broker: %w", err) | 77 | return nil, fmt.Errorf("vev: connect broker: %w", err) | ||
| 78 | } | 78 | } | ||
| 79 | if _, stale := brokeripc.StaleBuild(service); stale { | 79 | if _, stale := brokeripc.StaleBuild(service); stale { | ||
| 80 | - | fmt.Fprintln(os.Stderr, `vev: the running broker is from another build; run "vev kill --broker" to restart it`) | 80 | + | fmt.Fprintln(os.Stderr, `vev: the running broker is from another build; run "vev kill --all" to restart it`) |
| 81 | } | 81 | } | ||
| 82 | if err := awaitBrokerPublication(ctx, service); err != nil { | 82 | if err := awaitBrokerPublication(ctx, service); err != nil { | ||
| 83 | _ = service.Close() | 83 | _ = service.Close() | ||
A internal/app/kill_all_linux.go +217 -0
| @@ -0,0 +1,217 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "net" | |
| 12 | + | "os" | |
| 13 | + | "path/filepath" | |
| 14 | + | "strconv" | |
| 15 | + | "strings" | |
| 16 | + | "syscall" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 20 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // killAllStopTimeout bounds how long each wave waits after SIGTERM before it | |
| 24 | + | // escalates to SIGKILL. | |
| 25 | + | const killAllStopTimeout = 5 * time.Second | |
| 26 | + | ||
| 27 | + | // vevProcess is one running process of the installed vev binary. | |
| 28 | + | type vevProcess struct { | |
| 29 | + | pid int | |
| 30 | + | role string // "client", "broker", "daemon", or "helper" | |
| 31 | + | } | |
| 32 | + | ||
| 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. | |
| 38 | + | func runKillAll(ctx context.Context, out io.Writer) error { | |
| 39 | + | 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") | |
| 41 | + | } | |
| 42 | + | exe, err := os.Executable() | |
| 43 | + | if err != nil { | |
| 44 | + | return fmt.Errorf("vev: resolve executable: %w", err) | |
| 45 | + | } | |
| 46 | + | procs, err := findVevProcesses("/proc", installedPath(exe), os.Getuid(), os.Getpid()) | |
| 47 | + | if err != nil { | |
| 48 | + | return err | |
| 49 | + | } | |
| 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) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | for _, wave := range [][]vevProcess{clients, services} { | |
| 61 | + | if err := stopProcesses(ctx, wave, killAllStopTimeout); err != nil { | |
| 62 | + | return err | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | removed := removeDeadSockets(ipc.SocketDir()) | |
| 66 | + | printKillAllReport(out, append(clients, services...), removed) | |
| 67 | + | return nil | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // installedPath is the on-disk path an executable was started from, without | |
| 71 | + | // the " (deleted)" suffix the kernel adds once an upgrade replaced it. | |
| 72 | + | func installedPath(exe string) string { | |
| 73 | + | return strings.TrimSuffix(exe, " (deleted)") | |
| 74 | + | } | |
| 75 | + | ||
| 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) | |
| 80 | + | if err != nil { | |
| 81 | + | return nil, fmt.Errorf("vev: list processes: %w", err) | |
| 82 | + | } | |
| 83 | + | var procs []vevProcess | |
| 84 | + | for _, entry := range entries { | |
| 85 | + | pid, err := strconv.Atoi(entry.Name()) | |
| 86 | + | if err != nil || pid == self { | |
| 87 | + | continue | |
| 88 | + | } | |
| 89 | + | dir := filepath.Join(procRoot, entry.Name()) | |
| 90 | + | info, err := os.Stat(dir) | |
| 91 | + | if err != nil { | |
| 92 | + | continue // the process exited meanwhile | |
| 93 | + | } | |
| 94 | + | if stat, ok := info.Sys().(*syscall.Stat_t); !ok || int(stat.Uid) != uid { | |
| 95 | + | continue | |
| 96 | + | } | |
| 97 | + | target, err := os.Readlink(filepath.Join(dir, "exe")) | |
| 98 | + | if err != nil || installedPath(target) != exe { | |
| 99 | + | continue | |
| 100 | + | } | |
| 101 | + | cmdline, err := os.ReadFile(filepath.Join(dir, "cmdline")) | |
| 102 | + | if err != nil { | |
| 103 | + | continue | |
| 104 | + | } | |
| 105 | + | procs = append(procs, vevProcess{pid: pid, role: processRole(cmdline)}) | |
| 106 | + | } | |
| 107 | + | return procs, nil | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // processRole names a vev process from its NUL-separated command line. | |
| 111 | + | func processRole(cmdline []byte) string { | |
| 112 | + | args := strings.Split(strings.TrimRight(string(cmdline), "\x00"), "\x00") | |
| 113 | + | if len(args) < 2 { | |
| 114 | + | return "client" | |
| 115 | + | } | |
| 116 | + | switch args[1] { | |
| 117 | + | case "--daemon": | |
| 118 | + | return "daemon" | |
| 119 | + | case productionBrokerServeCommand: | |
| 120 | + | return "broker" | |
| 121 | + | } | |
| 122 | + | if strings.HasPrefix(args[1], "_") || args[1] == "--web-daemon" { | |
| 123 | + | return "helper" | |
| 124 | + | } | |
| 125 | + | return "client" | |
| 126 | + | } | |
| 127 | + | ||
| 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 { | |
| 131 | + | for _, p := range procs { | |
| 132 | + | _ = syscall.Kill(p.pid, syscall.SIGTERM) | |
| 133 | + | } | |
| 134 | + | deadline := time.Now().Add(timeout) | |
| 135 | + | for _, p := range procs { | |
| 136 | + | wait := max(time.Until(deadline), 0) | |
| 137 | + | if err := waitProcessExit(ctx, p.pid, wait); err == nil { | |
| 138 | + | continue | |
| 139 | + | } | |
| 140 | + | if err := ctx.Err(); err != nil { | |
| 141 | + | return err | |
| 142 | + | } | |
| 143 | + | _ = 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) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | return nil | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // 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. | |
| 153 | + | func removeDeadSockets(dir string) []string { | |
| 154 | + | 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")), | |
| 159 | + | } { | |
| 160 | + | conn, err := net.DialTimeout("unix", path, 200*time.Millisecond) | |
| 161 | + | if err == nil { | |
| 162 | + | _ = conn.Close() | |
| 163 | + | continue | |
| 164 | + | } | |
| 165 | + | if !errors.Is(err, syscall.ECONNREFUSED) { | |
| 166 | + | continue | |
| 167 | + | } | |
| 168 | + | if os.Remove(path) == nil { | |
| 169 | + | removed = append(removed, path) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | return removed | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func printKillAllReport(out io.Writer, procs []vevProcess, removed []string) { | |
| 176 | + | if len(procs) == 0 && len(removed) == 0 { | |
| 177 | + | _, _ = fmt.Fprintln(out, "nothing to stop: vev was not running") | |
| 178 | + | return | |
| 179 | + | } | |
| 180 | + | for _, p := range procs { | |
| 181 | + | _, _ = fmt.Fprintf(out, "stopped %s (pid %d)\n", p.role, p.pid) | |
| 182 | + | } | |
| 183 | + | for _, path := range removed { | |
| 184 | + | _, _ = fmt.Fprintf(out, "removed stale socket %s\n", path) | |
| 185 | + | } | |
| 186 | + | _, _ = fmt.Fprintln(out, "vev is fully stopped; named sessions come back on the next start") | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // waitProcessExit polls until pid no longer exists or is a zombie: a dead | |
| 190 | + | // process whose parent (for example a shell) has not reaped it yet. | |
| 191 | + | func waitProcessExit(ctx context.Context, pid int, timeout time.Duration) error { | |
| 192 | + | ctx, cancel := context.WithTimeout(ctx, timeout) | |
| 193 | + | defer cancel() | |
| 194 | + | ticker := time.NewTicker(20 * time.Millisecond) | |
| 195 | + | defer ticker.Stop() | |
| 196 | + | for { | |
| 197 | + | if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) || isZombie(pid) { | |
| 198 | + | return nil | |
| 199 | + | } | |
| 200 | + | select { | |
| 201 | + | case <-ctx.Done(): | |
| 202 | + | return fmt.Errorf("still running after %s: %w", timeout, ctx.Err()) | |
| 203 | + | case <-ticker.C: | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // isZombie reports whether /proc says pid has exited but is not reaped yet. | |
| 209 | + | func isZombie(pid int) bool { | |
| 210 | + | stat, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat")) | |
| 211 | + | if err != nil { | |
| 212 | + | return false | |
| 213 | + | } | |
| 214 | + | // The state follows the parenthesised command name, which may hold spaces. | |
| 215 | + | end := bytes.LastIndexByte(stat, ')') | |
| 216 | + | return end >= 0 && end+2 < len(stat) && stat[end+2] == 'Z' | |
| 217 | + | } | |
| @@ -0,0 +1,217 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "io" | |||
| 11 | + | "net" | |||
| 12 | + | "os" | |||
| 13 | + | "path/filepath" | |||
| 14 | + | "strconv" | |||
| 15 | + | "strings" | |||
| 16 | + | "syscall" | |||
| 17 | + | "time" | |||
| 18 | + | ||||
| 19 | + | "github.com/bnema/vev/internal/adapters/brokeripc" | |||
| 20 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // killAllStopTimeout bounds how long each wave waits after SIGTERM before it | |||
| 24 | + | // escalates to SIGKILL. | |||
| 25 | + | const killAllStopTimeout = 5 * time.Second | |||
| 26 | + | ||||
| 27 | + | // vevProcess is one running process of the installed vev binary. | |||
| 28 | + | type vevProcess struct { | |||
| 29 | + | pid int | |||
| 30 | + | role string // "client", "broker", "daemon", or "helper" | |||
| 31 | + | } | |||
| 32 | + | ||||
| 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. | |||
| 38 | + | func runKillAll(ctx context.Context, out io.Writer) error { | |||
| 39 | + | 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") | |||
| 41 | + | } | |||
| 42 | + | exe, err := os.Executable() | |||
| 43 | + | if err != nil { | |||
| 44 | + | return fmt.Errorf("vev: resolve executable: %w", err) | |||
| 45 | + | } | |||
| 46 | + | procs, err := findVevProcesses("/proc", installedPath(exe), os.Getuid(), os.Getpid()) | |||
| 47 | + | if err != nil { | |||
| 48 | + | return err | |||
| 49 | + | } | |||
| 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) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | for _, wave := range [][]vevProcess{clients, services} { | |||
| 61 | + | if err := stopProcesses(ctx, wave, killAllStopTimeout); err != nil { | |||
| 62 | + | return err | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | removed := removeDeadSockets(ipc.SocketDir()) | |||
| 66 | + | printKillAllReport(out, append(clients, services...), removed) | |||
| 67 | + | return nil | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // installedPath is the on-disk path an executable was started from, without | |||
| 71 | + | // the " (deleted)" suffix the kernel adds once an upgrade replaced it. | |||
| 72 | + | func installedPath(exe string) string { | |||
| 73 | + | return strings.TrimSuffix(exe, " (deleted)") | |||
| 74 | + | } | |||
| 75 | + | ||||
| 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) | |||
| 80 | + | if err != nil { | |||
| 81 | + | return nil, fmt.Errorf("vev: list processes: %w", err) | |||
| 82 | + | } | |||
| 83 | + | var procs []vevProcess | |||
| 84 | + | for _, entry := range entries { | |||
| 85 | + | pid, err := strconv.Atoi(entry.Name()) | |||
| 86 | + | if err != nil || pid == self { | |||
| 87 | + | continue | |||
| 88 | + | } | |||
| 89 | + | dir := filepath.Join(procRoot, entry.Name()) | |||
| 90 | + | info, err := os.Stat(dir) | |||
| 91 | + | if err != nil { | |||
| 92 | + | continue // the process exited meanwhile | |||
| 93 | + | } | |||
| 94 | + | if stat, ok := info.Sys().(*syscall.Stat_t); !ok || int(stat.Uid) != uid { | |||
| 95 | + | continue | |||
| 96 | + | } | |||
| 97 | + | target, err := os.Readlink(filepath.Join(dir, "exe")) | |||
| 98 | + | if err != nil || installedPath(target) != exe { | |||
| 99 | + | continue | |||
| 100 | + | } | |||
| 101 | + | cmdline, err := os.ReadFile(filepath.Join(dir, "cmdline")) | |||
| 102 | + | if err != nil { | |||
| 103 | + | continue | |||
| 104 | + | } | |||
| 105 | + | procs = append(procs, vevProcess{pid: pid, role: processRole(cmdline)}) | |||
| 106 | + | } | |||
| 107 | + | return procs, nil | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // processRole names a vev process from its NUL-separated command line. | |||
| 111 | + | func processRole(cmdline []byte) string { | |||
| 112 | + | args := strings.Split(strings.TrimRight(string(cmdline), "\x00"), "\x00") | |||
| 113 | + | if len(args) < 2 { | |||
| 114 | + | return "client" | |||
| 115 | + | } | |||
| 116 | + | switch args[1] { | |||
| 117 | + | case "--daemon": | |||
| 118 | + | return "daemon" | |||
| 119 | + | case productionBrokerServeCommand: | |||
| 120 | + | return "broker" | |||
| 121 | + | } | |||
| 122 | + | if strings.HasPrefix(args[1], "_") || args[1] == "--web-daemon" { | |||
| 123 | + | return "helper" | |||
| 124 | + | } | |||
| 125 | + | return "client" | |||
| 126 | + | } | |||
| 127 | + | ||||
| 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 { | |||
| 131 | + | for _, p := range procs { | |||
| 132 | + | _ = syscall.Kill(p.pid, syscall.SIGTERM) | |||
| 133 | + | } | |||
| 134 | + | deadline := time.Now().Add(timeout) | |||
| 135 | + | for _, p := range procs { | |||
| 136 | + | wait := max(time.Until(deadline), 0) | |||
| 137 | + | if err := waitProcessExit(ctx, p.pid, wait); err == nil { | |||
| 138 | + | continue | |||
| 139 | + | } | |||
| 140 | + | if err := ctx.Err(); err != nil { | |||
| 141 | + | return err | |||
| 142 | + | } | |||
| 143 | + | _ = 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) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | return nil | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // 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. | |||
| 153 | + | func removeDeadSockets(dir string) []string { | |||
| 154 | + | 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")), | |||
| 159 | + | } { | |||
| 160 | + | conn, err := net.DialTimeout("unix", path, 200*time.Millisecond) | |||
| 161 | + | if err == nil { | |||
| 162 | + | _ = conn.Close() | |||
| 163 | + | continue | |||
| 164 | + | } | |||
| 165 | + | if !errors.Is(err, syscall.ECONNREFUSED) { | |||
| 166 | + | continue | |||
| 167 | + | } | |||
| 168 | + | if os.Remove(path) == nil { | |||
| 169 | + | removed = append(removed, path) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | return removed | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func printKillAllReport(out io.Writer, procs []vevProcess, removed []string) { | |||
| 176 | + | if len(procs) == 0 && len(removed) == 0 { | |||
| 177 | + | _, _ = fmt.Fprintln(out, "nothing to stop: vev was not running") | |||
| 178 | + | return | |||
| 179 | + | } | |||
| 180 | + | for _, p := range procs { | |||
| 181 | + | _, _ = fmt.Fprintf(out, "stopped %s (pid %d)\n", p.role, p.pid) | |||
| 182 | + | } | |||
| 183 | + | for _, path := range removed { | |||
| 184 | + | _, _ = fmt.Fprintf(out, "removed stale socket %s\n", path) | |||
| 185 | + | } | |||
| 186 | + | _, _ = fmt.Fprintln(out, "vev is fully stopped; named sessions come back on the next start") | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // waitProcessExit polls until pid no longer exists or is a zombie: a dead | |||
| 190 | + | // process whose parent (for example a shell) has not reaped it yet. | |||
| 191 | + | func waitProcessExit(ctx context.Context, pid int, timeout time.Duration) error { | |||
| 192 | + | ctx, cancel := context.WithTimeout(ctx, timeout) | |||
| 193 | + | defer cancel() | |||
| 194 | + | ticker := time.NewTicker(20 * time.Millisecond) | |||
| 195 | + | defer ticker.Stop() | |||
| 196 | + | for { | |||
| 197 | + | if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) || isZombie(pid) { | |||
| 198 | + | return nil | |||
| 199 | + | } | |||
| 200 | + | select { | |||
| 201 | + | case <-ctx.Done(): | |||
| 202 | + | return fmt.Errorf("still running after %s: %w", timeout, ctx.Err()) | |||
| 203 | + | case <-ticker.C: | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | // isZombie reports whether /proc says pid has exited but is not reaped yet. | |||
| 209 | + | func isZombie(pid int) bool { | |||
| 210 | + | stat, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat")) | |||
| 211 | + | if err != nil { | |||
| 212 | + | return false | |||
| 213 | + | } | |||
| 214 | + | // The state follows the parenthesised command name, which may hold spaces. | |||
| 215 | + | end := bytes.LastIndexByte(stat, ')') | |||
| 216 | + | return end >= 0 && end+2 < len(stat) && stat[end+2] == 'Z' | |||
| 217 | + | } | |||
A internal/app/kill_all_linux_test.go +158 -0
| @@ -0,0 +1,158 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "net" | |
| 9 | + | "os" | |
| 10 | + | "os/exec" | |
| 11 | + | "path/filepath" | |
| 12 | + | "syscall" | |
| 13 | + | "testing" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func TestWaitProcessExit(t *testing.T) { | |
| 20 | + | tests := []struct { | |
| 21 | + | name string | |
| 22 | + | signal bool | |
| 23 | + | wantErr bool | |
| 24 | + | }{ | |
| 25 | + | {name: "terminated process is reported gone", signal: true}, | |
| 26 | + | {name: "live process times out", signal: false, wantErr: true}, | |
| 27 | + | } | |
| 28 | + | for _, tt := range tests { | |
| 29 | + | t.Run(tt.name, func(t *testing.T) { | |
| 30 | + | cmd := exec.Command("sleep", "30") | |
| 31 | + | require.NoError(t, cmd.Start()) | |
| 32 | + | // Reap the child so it never lingers as a zombie that kill(pid, 0) | |
| 33 | + | // would still report as present. | |
| 34 | + | reaped := make(chan struct{}) | |
| 35 | + | go func() { _ = cmd.Wait(); close(reaped) }() | |
| 36 | + | t.Cleanup(func() { | |
| 37 | + | _ = cmd.Process.Kill() | |
| 38 | + | <-reaped | |
| 39 | + | }) | |
| 40 | + | if tt.signal { | |
| 41 | + | require.NoError(t, syscall.Kill(cmd.Process.Pid, syscall.SIGTERM)) | |
| 42 | + | } | |
| 43 | + | err := waitProcessExit(context.Background(), cmd.Process.Pid, 300*time.Millisecond) | |
| 44 | + | if tt.wantErr { | |
| 45 | + | require.Error(t, err) | |
| 46 | + | return | |
| 47 | + | } | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | }) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestProcessRole(t *testing.T) { | |
| 54 | + | tests := []struct { | |
| 55 | + | name string | |
| 56 | + | cmdline string | |
| 57 | + | want string | |
| 58 | + | }{ | |
| 59 | + | {name: "bare client", cmdline: "vev\x00", want: "client"}, | |
| 60 | + | {name: "attach client", cmdline: "vev\x00attach\x00work\x00", want: "client"}, | |
| 61 | + | {name: "daemon", cmdline: "/proc/self/exe\x00--daemon\x00", want: "daemon"}, | |
| 62 | + | {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"}, | |
| 64 | + | {name: "web daemon", cmdline: "vev\x00--web-daemon\x00", want: "helper"}, | |
| 65 | + | } | |
| 66 | + | for _, tt := range tests { | |
| 67 | + | t.Run(tt.name, func(t *testing.T) { | |
| 68 | + | require.Equal(t, tt.want, processRole([]byte(tt.cmdline))) | |
| 69 | + | }) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // 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. | |
| 75 | + | func TestFindVevProcesses(t *testing.T) { | |
| 76 | + | root := t.TempDir() | |
| 77 | + | const exe = "/opt/bin/vev" | |
| 78 | + | uid := os.Getuid() | |
| 79 | + | add := func(pid, target, cmdline string) { | |
| 80 | + | dir := filepath.Join(root, pid) | |
| 81 | + | require.NoError(t, os.Mkdir(dir, 0o755)) | |
| 82 | + | require.NoError(t, os.Symlink(target, filepath.Join(dir, "exe"))) | |
| 83 | + | require.NoError(t, os.WriteFile(filepath.Join(dir, "cmdline"), []byte(cmdline), 0o644)) | |
| 84 | + | } | |
| 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 | |
| 90 | + | require.NoError(t, os.Mkdir(filepath.Join(root, "self"), 0o755)) | |
| 91 | + | ||
| 92 | + | got, err := findVevProcesses(root, exe, uid, 14) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | require.ElementsMatch(t, []vevProcess{{pid: 10, role: "client"}, {pid: 11, role: "daemon"}}, got) | |
| 95 | + | ||
| 96 | + | none, err := findVevProcesses(root, exe, uid+1, 14) | |
| 97 | + | require.NoError(t, err) | |
| 98 | + | require.Empty(t, none, "another user's processes are never matched") | |
| 99 | + | } | |
| 100 | + | ||
| 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") | |
| 104 | + | 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 | |
| 109 | + | ||
| 110 | + | require.NoError(t, stopProcesses(context.Background(), []vevProcess{{pid: cmd.Process.Pid, role: "daemon"}}, 100*time.Millisecond)) | |
| 111 | + | <-reaped | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestRemoveDeadSockets(t *testing.T) { | |
| 115 | + | dir, err := os.MkdirTemp("", "vevk") | |
| 116 | + | require.NoError(t, err) | |
| 117 | + | t.Cleanup(func() { _ = os.RemoveAll(dir) }) | |
| 118 | + | require.NoError(t, os.Mkdir(filepath.Join(dir, "broker"), 0o700)) | |
| 119 | + | ||
| 120 | + | live, err := net.Listen("unix", filepath.Join(dir, "daemon.sock")) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | t.Cleanup(func() { _ = live.Close() }) | |
| 123 | + | dead, err := net.Listen("unix", filepath.Join(dir, "daemonmux.sock")) | |
| 124 | + | require.NoError(t, err) | |
| 125 | + | dead.(*net.UnixListener).SetUnlinkOnClose(false) | |
| 126 | + | require.NoError(t, dead.Close()) | |
| 127 | + | ||
| 128 | + | removed := removeDeadSockets(dir) | |
| 129 | + | require.Equal(t, []string{filepath.Join(dir, "daemonmux.sock")}, removed) | |
| 130 | + | require.FileExists(t, filepath.Join(dir, "daemon.sock"), "a live socket is kept") | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestKillAllRefusesInsideVev(t *testing.T) { | |
| 134 | + | t.Setenv("VEV", "1") | |
| 135 | + | var out bytes.Buffer | |
| 136 | + | err := runKillAll(context.Background(), &out) | |
| 137 | + | require.ErrorContains(t, err, "outside vev") | |
| 138 | + | require.Empty(t, out.String()) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | 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()) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // TestWaitProcessExitTreatsZombieAsGone pins that an exited child its parent | |
| 152 | + | // has not reaped yet counts as stopped. | |
| 153 | + | func TestWaitProcessExitTreatsZombieAsGone(t *testing.T) { | |
| 154 | + | cmd := exec.Command("sh", "-c", "exit 0") | |
| 155 | + | require.NoError(t, cmd.Start()) | |
| 156 | + | t.Cleanup(func() { _ = cmd.Wait() }) // reap only after the check | |
| 157 | + | require.NoError(t, waitProcessExit(context.Background(), cmd.Process.Pid, 2*time.Second)) | |
| 158 | + | } | |
| @@ -0,0 +1,158 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "net" | |||
| 9 | + | "os" | |||
| 10 | + | "os/exec" | |||
| 11 | + | "path/filepath" | |||
| 12 | + | "syscall" | |||
| 13 | + | "testing" | |||
| 14 | + | "time" | |||
| 15 | + | ||||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func TestWaitProcessExit(t *testing.T) { | |||
| 20 | + | tests := []struct { | |||
| 21 | + | name string | |||
| 22 | + | signal bool | |||
| 23 | + | wantErr bool | |||
| 24 | + | }{ | |||
| 25 | + | {name: "terminated process is reported gone", signal: true}, | |||
| 26 | + | {name: "live process times out", signal: false, wantErr: true}, | |||
| 27 | + | } | |||
| 28 | + | for _, tt := range tests { | |||
| 29 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 30 | + | cmd := exec.Command("sleep", "30") | |||
| 31 | + | require.NoError(t, cmd.Start()) | |||
| 32 | + | // Reap the child so it never lingers as a zombie that kill(pid, 0) | |||
| 33 | + | // would still report as present. | |||
| 34 | + | reaped := make(chan struct{}) | |||
| 35 | + | go func() { _ = cmd.Wait(); close(reaped) }() | |||
| 36 | + | t.Cleanup(func() { | |||
| 37 | + | _ = cmd.Process.Kill() | |||
| 38 | + | <-reaped | |||
| 39 | + | }) | |||
| 40 | + | if tt.signal { | |||
| 41 | + | require.NoError(t, syscall.Kill(cmd.Process.Pid, syscall.SIGTERM)) | |||
| 42 | + | } | |||
| 43 | + | err := waitProcessExit(context.Background(), cmd.Process.Pid, 300*time.Millisecond) | |||
| 44 | + | if tt.wantErr { | |||
| 45 | + | require.Error(t, err) | |||
| 46 | + | return | |||
| 47 | + | } | |||
| 48 | + | require.NoError(t, err) | |||
| 49 | + | }) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestProcessRole(t *testing.T) { | |||
| 54 | + | tests := []struct { | |||
| 55 | + | name string | |||
| 56 | + | cmdline string | |||
| 57 | + | want string | |||
| 58 | + | }{ | |||
| 59 | + | {name: "bare client", cmdline: "vev\x00", want: "client"}, | |||
| 60 | + | {name: "attach client", cmdline: "vev\x00attach\x00work\x00", want: "client"}, | |||
| 61 | + | {name: "daemon", cmdline: "/proc/self/exe\x00--daemon\x00", want: "daemon"}, | |||
| 62 | + | {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"}, | |||
| 64 | + | {name: "web daemon", cmdline: "vev\x00--web-daemon\x00", want: "helper"}, | |||
| 65 | + | } | |||
| 66 | + | for _, tt := range tests { | |||
| 67 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 68 | + | require.Equal(t, tt.want, processRole([]byte(tt.cmdline))) | |||
| 69 | + | }) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // 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. | |||
| 75 | + | func TestFindVevProcesses(t *testing.T) { | |||
| 76 | + | root := t.TempDir() | |||
| 77 | + | const exe = "/opt/bin/vev" | |||
| 78 | + | uid := os.Getuid() | |||
| 79 | + | add := func(pid, target, cmdline string) { | |||
| 80 | + | dir := filepath.Join(root, pid) | |||
| 81 | + | require.NoError(t, os.Mkdir(dir, 0o755)) | |||
| 82 | + | require.NoError(t, os.Symlink(target, filepath.Join(dir, "exe"))) | |||
| 83 | + | require.NoError(t, os.WriteFile(filepath.Join(dir, "cmdline"), []byte(cmdline), 0o644)) | |||
| 84 | + | } | |||
| 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 | |||
| 90 | + | require.NoError(t, os.Mkdir(filepath.Join(root, "self"), 0o755)) | |||
| 91 | + | ||||
| 92 | + | got, err := findVevProcesses(root, exe, uid, 14) | |||
| 93 | + | require.NoError(t, err) | |||
| 94 | + | require.ElementsMatch(t, []vevProcess{{pid: 10, role: "client"}, {pid: 11, role: "daemon"}}, got) | |||
| 95 | + | ||||
| 96 | + | none, err := findVevProcesses(root, exe, uid+1, 14) | |||
| 97 | + | require.NoError(t, err) | |||
| 98 | + | require.Empty(t, none, "another user's processes are never matched") | |||
| 99 | + | } | |||
| 100 | + | ||||
| 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") | |||
| 104 | + | 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 | |||
| 109 | + | ||||
| 110 | + | require.NoError(t, stopProcesses(context.Background(), []vevProcess{{pid: cmd.Process.Pid, role: "daemon"}}, 100*time.Millisecond)) | |||
| 111 | + | <-reaped | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestRemoveDeadSockets(t *testing.T) { | |||
| 115 | + | dir, err := os.MkdirTemp("", "vevk") | |||
| 116 | + | require.NoError(t, err) | |||
| 117 | + | t.Cleanup(func() { _ = os.RemoveAll(dir) }) | |||
| 118 | + | require.NoError(t, os.Mkdir(filepath.Join(dir, "broker"), 0o700)) | |||
| 119 | + | ||||
| 120 | + | live, err := net.Listen("unix", filepath.Join(dir, "daemon.sock")) | |||
| 121 | + | require.NoError(t, err) | |||
| 122 | + | t.Cleanup(func() { _ = live.Close() }) | |||
| 123 | + | dead, err := net.Listen("unix", filepath.Join(dir, "daemonmux.sock")) | |||
| 124 | + | require.NoError(t, err) | |||
| 125 | + | dead.(*net.UnixListener).SetUnlinkOnClose(false) | |||
| 126 | + | require.NoError(t, dead.Close()) | |||
| 127 | + | ||||
| 128 | + | removed := removeDeadSockets(dir) | |||
| 129 | + | require.Equal(t, []string{filepath.Join(dir, "daemonmux.sock")}, removed) | |||
| 130 | + | require.FileExists(t, filepath.Join(dir, "daemon.sock"), "a live socket is kept") | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestKillAllRefusesInsideVev(t *testing.T) { | |||
| 134 | + | t.Setenv("VEV", "1") | |||
| 135 | + | var out bytes.Buffer | |||
| 136 | + | err := runKillAll(context.Background(), &out) | |||
| 137 | + | require.ErrorContains(t, err, "outside vev") | |||
| 138 | + | require.Empty(t, out.String()) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | 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()) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // TestWaitProcessExitTreatsZombieAsGone pins that an exited child its parent | |||
| 152 | + | // has not reaped yet counts as stopped. | |||
| 153 | + | func TestWaitProcessExitTreatsZombieAsGone(t *testing.T) { | |||
| 154 | + | cmd := exec.Command("sh", "-c", "exit 0") | |||
| 155 | + | require.NoError(t, cmd.Start()) | |||
| 156 | + | t.Cleanup(func() { _ = cmd.Wait() }) // reap only after the check | |||
| 157 | + | require.NoError(t, waitProcessExit(context.Background(), cmd.Process.Pid, 2*time.Second)) | |||
| 158 | + | } | |||
A internal/app/kill_all_other.go +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | //go:build !linux | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func runKillAll(context.Context, io.Writer) error { | |
| 12 | + | return errors.New("vev: `kill --all` requires Linux /proc") | |
| 13 | + | } | |
| @@ -0,0 +1,13 @@ | |||||
| 1 | + | //go:build !linux | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "io" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func runKillAll(context.Context, io.Writer) error { | |||
| 12 | + | return errors.New("vev: `kill --all` requires Linux /proc") | |||
| 13 | + | } | |||
M internal/app/run.go +23 -39
| @@ -87,8 +87,7 @@ type command struct { | |||
| 87 | 87 | hostAction string | |
| 88 | 88 | hostTarget string | |
| 89 | 89 | killAll bool | |
| 90 | - | killDaemon bool | |
| 91 | - | killBroker bool | |
| 90 | + | killSessions bool | |
| 92 | 91 | cmd cmdInvocation | |
| 93 | 92 | brokerServe brokerServeOptions | |
| 94 | 93 | brokerMux brokerMuxOptions | |
| @@ -125,9 +124,9 @@ usage: | |||
| 125 | 124 | vev host rm <host> remove a pinned remote host | |
| 126 | 125 | vev host list list known remote hosts | |
| 127 | 126 | vev kill <name> kill a session | |
| 128 | - | vev kill --all kill all sessions (the daemon keeps running) | |
| 129 | - | vev kill --daemon stop the active vev daemon | |
| 130 | - | vev kill --broker stop the active vev broker | |
| 127 | + | vev kill --sessions kill all sessions (vev keeps running) | |
| 128 | + | vev kill --all stop everything: every vev window, the broker, and the | |
| 129 | + | daemon (named sessions come back on the next start) | |
| 131 | 130 | vev cmd <command> run a control command (vev cmd --help) | |
| 132 | 131 | vev --ui-observe expose passive observation for this interactive client | |
| 133 | 132 | (optional: --ui-socket PATH) | |
| @@ -311,7 +310,7 @@ parsedUIFlags: | |||
| 311 | 310 | return command{kind: kindCmd, cmd: invocation}, nil | |
| 312 | 311 | case "kill": | |
| 313 | 312 | if len(args) < 2 || args[1] == "" { | |
| 314 | - | return command{}, usagef("`kill` requires a session name, --all, --daemon, or --broker") | |
| 313 | + | return command{}, usagef("`kill` requires a session name, --sessions, or --all") | |
| 315 | 314 | } | |
| 316 | 315 | if args[1] == "--" { | |
| 317 | 316 | if len(args) != 3 || args[2] == "" { | |
| @@ -320,16 +319,17 @@ parsedUIFlags: | |||
| 320 | 319 | return command{kind: kindKill, name: args[2]}, nil | |
| 321 | 320 | } | |
| 322 | 321 | if len(args) > 2 { | |
| 323 | - | return command{}, usagef("`kill` accepts exactly one session name, --all, --daemon, or --broker") | |
| 322 | + | return command{}, usagef("`kill` accepts exactly one session name, --sessions, or --all") | |
| 324 | 323 | } | |
| 325 | 324 | switch args[1] { | |
| 325 | + | case "--sessions": | |
| 326 | + | return command{kind: kindKill, killSessions: true}, nil | |
| 326 | 327 | case "--all": | |
| 327 | 328 | return command{kind: kindKill, killAll: true}, nil | |
| 328 | - | case "--daemon": | |
| 329 | - | return command{kind: kindKill, killDaemon: true}, nil | |
| 330 | - | case "--broker": | |
| 331 | - | return command{kind: kindKill, killBroker: true}, nil | |
| 332 | 329 | default: | |
| 330 | + | if strings.HasPrefix(args[1], "-") { | |
| 331 | + | return command{}, usagef("unknown flag %q for `kill`; use `kill -- NAME` for a dashed session name", args[1]) | |
| 332 | + | } | |
| 333 | 333 | return command{kind: kindKill, name: args[1]}, nil | |
| 334 | 334 | } | |
| 335 | 335 | case "-h", "--help", "help": | |
| @@ -381,10 +381,10 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 381 | 381 | case kindHost: | |
| 382 | 382 | return runHostCommand(ctx, cmd, defaultRemoteHostDeps()) | |
| 383 | 383 | case kindKill: | |
| 384 | - | if cmd.killBroker { | |
| 385 | - | return runKillBroker(ctx) | |
| 384 | + | if cmd.killAll { | |
| 385 | + | return runKillAll(ctx, os.Stdout) | |
| 386 | 386 | } | |
| 387 | - | return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon) | |
| 387 | + | return runKill(ctx, cmd.name, cmd.killSessions) | |
| 388 | 388 | case kindCmd: | |
| 389 | 389 | return runCmd(ctx, cmd.cmd) | |
| 390 | 390 | case kindAttach: | |
| @@ -1179,16 +1179,9 @@ func unreadableCatalogueError(stateDir string) error { | |||
| 1179 | 1179 | " rm -rf %s", persist.ErrCatalogueUnreadable, stateDir, stateDir) | |
| 1180 | 1180 | } | |
| 1181 | 1181 | ||
| 1182 | - | // runKill uses the existing broker for daemon shutdown and connect-or-spawn | |
| 1183 | - | // for session mutations. | |
| 1184 | - | func runKill(ctx context.Context, name string, all, daemon bool) error { | |
| 1185 | - | if daemon { | |
| 1186 | - | err := requestDaemonStop(ctx) | |
| 1187 | - | if err == nil { | |
| 1188 | - | printKillSuccess(name, all, daemon) | |
| 1189 | - | } | |
| 1190 | - | return err | |
| 1191 | - | } | |
| 1182 | + | // runKill deletes one session or, with sessions set, every session through the | |
| 1183 | + | // broker, starting it if needed. | |
| 1184 | + | func runKill(ctx context.Context, name string, sessions bool) error { | |
| 1192 | 1185 | service, err := connectBroker(ctx) | |
| 1193 | 1186 | if err != nil { | |
| 1194 | 1187 | return unreachableBrokerError(err) | |
| @@ -1200,10 +1193,9 @@ func runKill(ctx context.Context, name string, all, daemon bool) error { | |||
| 1200 | 1193 | } | |
| 1201 | 1194 | route := localBrokerOperationRoute(service.Snapshot()) | |
| 1202 | 1195 | var result protocol.KillResult | |
| 1203 | - | switch { | |
| 1204 | - | case all: | |
| 1196 | + | if sessions { | |
| 1205 | 1197 | result, err = operations.KillAll(ctx, route) | |
| 1206 | - | default: | |
| 1198 | + | } else { | |
| 1207 | 1199 | result, err = operations.Kill(ctx, route, name) | |
| 1208 | 1200 | } | |
| 1209 | 1201 | if err != nil { | |
| @@ -1212,18 +1204,10 @@ func runKill(ctx context.Context, name string, all, daemon bool) error { | |||
| 1212 | 1204 | if err := brokerKillResultError(result); err != nil { | |
| 1213 | 1205 | return err | |
| 1214 | 1206 | } | |
| 1215 | - | printKillSuccess(name, all, daemon) | |
| 1216 | - | return nil | |
| 1217 | - | } | |
| 1218 | - | ||
| 1219 | - | func printKillSuccess(name string, all, daemon bool) { | |
| 1220 | - | if daemon { | |
| 1221 | - | fmt.Println("killed daemon") | |
| 1222 | - | return | |
| 1223 | - | } | |
| 1224 | - | if all { | |
| 1207 | + | if sessions { | |
| 1225 | 1208 | fmt.Println("killed all sessions") | |
| 1226 | - | return | |
| 1209 | + | } else { | |
| 1210 | + | fmt.Printf("killed %s\n", name) | |
| 1227 | 1211 | } | |
| 1228 | - | fmt.Printf("killed %s\n", name) | |
| 1212 | + | return nil | |
| 1229 | 1213 | } | |
| @@ -87,8 +87,7 @@ type command struct { | |||||
| 87 | hostAction string | 87 | hostAction string | ||
| 88 | hostTarget string | 88 | hostTarget string | ||
| 89 | killAll bool | 89 | killAll bool | ||
| 90 | - | killDaemon bool | 90 | + | killSessions bool |
| 91 | - | killBroker bool | |||
| 92 | cmd cmdInvocation | 91 | cmd cmdInvocation | ||
| 93 | brokerServe brokerServeOptions | 92 | brokerServe brokerServeOptions | ||
| 94 | brokerMux brokerMuxOptions | 93 | brokerMux brokerMuxOptions | ||
| @@ -125,9 +124,9 @@ usage: | |||||
| 125 | vev host rm <host> remove a pinned remote host | 124 | vev host rm <host> remove a pinned remote host | ||
| 126 | vev host list list known remote hosts | 125 | vev host list list known remote hosts | ||
| 127 | vev kill <name> kill a session | 126 | vev kill <name> kill a session | ||
| 128 | - | vev kill --all kill all sessions (the daemon keeps running) | 127 | + | vev kill --sessions kill all sessions (vev keeps running) |
| 129 | - | vev kill --daemon stop the active vev daemon | 128 | + | vev kill --all stop everything: every vev window, the broker, and the |
| 130 | - | vev kill --broker stop the active vev broker | 129 | + | daemon (named sessions come back on the next start) |
| 131 | vev cmd <command> run a control command (vev cmd --help) | 130 | vev cmd <command> run a control command (vev cmd --help) | ||
| 132 | vev --ui-observe expose passive observation for this interactive client | 131 | vev --ui-observe expose passive observation for this interactive client | ||
| 133 | (optional: --ui-socket PATH) | 132 | (optional: --ui-socket PATH) | ||
| @@ -311,7 +310,7 @@ parsedUIFlags: | |||||
| 311 | return command{kind: kindCmd, cmd: invocation}, nil | 310 | return command{kind: kindCmd, cmd: invocation}, nil | ||
| 312 | case "kill": | 311 | case "kill": | ||
| 313 | if len(args) < 2 || args[1] == "" { | 312 | if len(args) < 2 || args[1] == "" { | ||
| 314 | - | return command{}, usagef("`kill` requires a session name, --all, --daemon, or --broker") | 313 | + | return command{}, usagef("`kill` requires a session name, --sessions, or --all") |
| 315 | } | 314 | } | ||
| 316 | if args[1] == "--" { | 315 | if args[1] == "--" { | ||
| 317 | if len(args) != 3 || args[2] == "" { | 316 | if len(args) != 3 || args[2] == "" { | ||
| @@ -320,16 +319,17 @@ parsedUIFlags: | |||||
| 320 | return command{kind: kindKill, name: args[2]}, nil | 319 | return command{kind: kindKill, name: args[2]}, nil | ||
| 321 | } | 320 | } | ||
| 322 | if len(args) > 2 { | 321 | if len(args) > 2 { | ||
| 323 | - | return command{}, usagef("`kill` accepts exactly one session name, --all, --daemon, or --broker") | 322 | + | return command{}, usagef("`kill` accepts exactly one session name, --sessions, or --all") |
| 324 | } | 323 | } | ||
| 325 | switch args[1] { | 324 | switch args[1] { | ||
| 325 | + | case "--sessions": | |||
| 326 | + | return command{kind: kindKill, killSessions: true}, nil | |||
| 326 | case "--all": | 327 | case "--all": | ||
| 327 | return command{kind: kindKill, killAll: true}, nil | 328 | return command{kind: kindKill, killAll: true}, nil | ||
| 328 | - | case "--daemon": | |||
| 329 | - | return command{kind: kindKill, killDaemon: true}, nil | |||
| 330 | - | case "--broker": | |||
| 331 | - | return command{kind: kindKill, killBroker: true}, nil | |||
| 332 | default: | 329 | default: | ||
| 330 | + | if strings.HasPrefix(args[1], "-") { | |||
| 331 | + | return command{}, usagef("unknown flag %q for `kill`; use `kill -- NAME` for a dashed session name", args[1]) | |||
| 332 | + | } | |||
| 333 | return command{kind: kindKill, name: args[1]}, nil | 333 | return command{kind: kindKill, name: args[1]}, nil | ||
| 334 | } | 334 | } | ||
| 335 | case "-h", "--help", "help": | 335 | case "-h", "--help", "help": | ||
| @@ -381,10 +381,10 @@ func dispatch(ctx context.Context, cmd command) error { | |||||
| 381 | case kindHost: | 381 | case kindHost: | ||
| 382 | return runHostCommand(ctx, cmd, defaultRemoteHostDeps()) | 382 | return runHostCommand(ctx, cmd, defaultRemoteHostDeps()) | ||
| 383 | case kindKill: | 383 | case kindKill: | ||
| 384 | - | if cmd.killBroker { | 384 | + | if cmd.killAll { |
| 385 | - | return runKillBroker(ctx) | 385 | + | return runKillAll(ctx, os.Stdout) |
| 386 | } | 386 | } | ||
| 387 | - | return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon) | 387 | + | return runKill(ctx, cmd.name, cmd.killSessions) |
| 388 | case kindCmd: | 388 | case kindCmd: | ||
| 389 | return runCmd(ctx, cmd.cmd) | 389 | return runCmd(ctx, cmd.cmd) | ||
| 390 | case kindAttach: | 390 | case kindAttach: | ||
| @@ -1179,16 +1179,9 @@ func unreadableCatalogueError(stateDir string) error { | |||||
| 1179 | " rm -rf %s", persist.ErrCatalogueUnreadable, stateDir, stateDir) | 1179 | " rm -rf %s", persist.ErrCatalogueUnreadable, stateDir, stateDir) | ||
| 1180 | } | 1180 | } | ||
| 1181 | 1181 | ||||
| 1182 | - | // runKill uses the existing broker for daemon shutdown and connect-or-spawn | 1182 | + | // runKill deletes one session or, with sessions set, every session through the |
| 1183 | - | // for session mutations. | 1183 | + | // broker, starting it if needed. |
| 1184 | - | func runKill(ctx context.Context, name string, all, daemon bool) error { | 1184 | + | func runKill(ctx context.Context, name string, sessions bool) error { |
| 1185 | - | if daemon { | |||
| 1186 | - | err := requestDaemonStop(ctx) | |||
| 1187 | - | if err == nil { | |||
| 1188 | - | printKillSuccess(name, all, daemon) | |||
| 1189 | - | } | |||
| 1190 | - | return err | |||
| 1191 | - | } | |||
| 1192 | service, err := connectBroker(ctx) | 1185 | service, err := connectBroker(ctx) | ||
| 1193 | if err != nil { | 1186 | if err != nil { | ||
| 1194 | return unreachableBrokerError(err) | 1187 | return unreachableBrokerError(err) | ||
| @@ -1200,10 +1193,9 @@ func runKill(ctx context.Context, name string, all, daemon bool) error { | |||||
| 1200 | } | 1193 | } | ||
| 1201 | route := localBrokerOperationRoute(service.Snapshot()) | 1194 | route := localBrokerOperationRoute(service.Snapshot()) | ||
| 1202 | var result protocol.KillResult | 1195 | var result protocol.KillResult | ||
| 1203 | - | switch { | 1196 | + | if sessions { |
| 1204 | - | case all: | |||
| 1205 | result, err = operations.KillAll(ctx, route) | 1197 | result, err = operations.KillAll(ctx, route) | ||
| 1206 | - | default: | 1198 | + | } else { |
| 1207 | result, err = operations.Kill(ctx, route, name) | 1199 | result, err = operations.Kill(ctx, route, name) | ||
| 1208 | } | 1200 | } | ||
| 1209 | if err != nil { | 1201 | if err != nil { | ||
| @@ -1212,18 +1204,10 @@ func runKill(ctx context.Context, name string, all, daemon bool) error { | |||||
| 1212 | if err := brokerKillResultError(result); err != nil { | 1204 | if err := brokerKillResultError(result); err != nil { | ||
| 1213 | return err | 1205 | return err | ||
| 1214 | } | 1206 | } | ||
| 1215 | - | printKillSuccess(name, all, daemon) | 1207 | + | if sessions { |
| 1216 | - | return nil | |||
| 1217 | - | } | |||
| 1218 | - | ||||
| 1219 | - | func printKillSuccess(name string, all, daemon bool) { | |||
| 1220 | - | if daemon { | |||
| 1221 | - | fmt.Println("killed daemon") | |||
| 1222 | - | return | |||
| 1223 | - | } | |||
| 1224 | - | if all { | |||
| 1225 | fmt.Println("killed all sessions") | 1208 | fmt.Println("killed all sessions") | ||
| 1226 | - | return | 1209 | + | } else { |
| 1210 | + | fmt.Printf("killed %s\n", name) | |||
| 1227 | } | 1211 | } | ||
| 1228 | - | fmt.Printf("killed %s\n", name) | 1212 | + | return nil |
| 1229 | } | 1213 | } | ||
M internal/app/run_broker_test.go +3 -9
| @@ -305,18 +305,12 @@ func TestRunKillExplicitOutcomesTable(t *testing.T) { | |||
| 305 | 305 | wantStdout: "killed work", | |
| 306 | 306 | }, | |
| 307 | 307 | { | |
| 308 | - | name: "kill-all success", | |
| 308 | + | name: "kill-sessions success", | |
| 309 | 309 | kill: []string{"", "true", "false"}, | |
| 310 | 310 | replies: []protocol.ServerMessage{seamKillResult(1, protocol.KillSucceeded, 0, "")}, | |
| 311 | 311 | wantCode: 0, | |
| 312 | 312 | wantStdout: "killed all sessions", | |
| 313 | 313 | }, | |
| 314 | - | { | |
| 315 | - | name: "daemon-stop success", | |
| 316 | - | kill: []string{"", "false", "true"}, | |
| 317 | - | replies: []protocol.ServerMessage{seamKillResult(1, protocol.KillSucceeded, 0, "")}, | |
| 318 | - | wantCode: 0, | |
| 319 | - | }, | |
| 320 | 314 | { | |
| 321 | 315 | name: "definite failure is a bounded exit-one failure", | |
| 322 | 316 | kill: []string{"work", "false", "false"}, | |
| @@ -353,7 +347,7 @@ func TestRunKillExplicitOutcomesTable(t *testing.T) { | |||
| 353 | 347 | ||
| 354 | 348 | var err error | |
| 355 | 349 | got := captureStdout(t, func() { | |
| 356 | - | err = runKill(context.Background(), tt.kill[0], tt.kill[1] == "true", tt.kill[2] == "true") | |
| 350 | + | err = runKill(context.Background(), tt.kill[0], tt.kill[1] == "true") | |
| 357 | 351 | }) | |
| 358 | 352 | require.Equal(t, tt.wantCode, ExitCode(err), "exit code for %v", tt.kill) | |
| 359 | 353 | if tt.wantUnknown { | |
| @@ -385,7 +379,7 @@ func TestRunKillUnreachableBrokerIsExitThree(t *testing.T) { | |||
| 385 | 379 | } | |
| 386 | 380 | t.Cleanup(func() { connectBroker = previous }) | |
| 387 | 381 | ||
| 388 | - | err := runKill(context.Background(), "work", false, false) | |
| 382 | + | err := runKill(context.Background(), "work", false) | |
| 389 | 383 | require.Equal(t, 3, ExitCode(err)) | |
| 390 | 384 | require.ErrorIs(t, err, errDaemonUnreachable) | |
| 391 | 385 | } | |
| @@ -305,18 +305,12 @@ func TestRunKillExplicitOutcomesTable(t *testing.T) { | |||||
| 305 | wantStdout: "killed work", | 305 | wantStdout: "killed work", | ||
| 306 | }, | 306 | }, | ||
| 307 | { | 307 | { | ||
| 308 | - | name: "kill-all success", | 308 | + | name: "kill-sessions success", |
| 309 | kill: []string{"", "true", "false"}, | 309 | kill: []string{"", "true", "false"}, | ||
| 310 | replies: []protocol.ServerMessage{seamKillResult(1, protocol.KillSucceeded, 0, "")}, | 310 | replies: []protocol.ServerMessage{seamKillResult(1, protocol.KillSucceeded, 0, "")}, | ||
| 311 | wantCode: 0, | 311 | wantCode: 0, | ||
| 312 | wantStdout: "killed all sessions", | 312 | wantStdout: "killed all sessions", | ||
| 313 | }, | 313 | }, | ||
| 314 | - | { | |||
| 315 | - | name: "daemon-stop success", | |||
| 316 | - | kill: []string{"", "false", "true"}, | |||
| 317 | - | replies: []protocol.ServerMessage{seamKillResult(1, protocol.KillSucceeded, 0, "")}, | |||
| 318 | - | wantCode: 0, | |||
| 319 | - | }, | |||
| 320 | { | 314 | { | ||
| 321 | name: "definite failure is a bounded exit-one failure", | 315 | name: "definite failure is a bounded exit-one failure", | ||
| 322 | kill: []string{"work", "false", "false"}, | 316 | kill: []string{"work", "false", "false"}, | ||
| @@ -353,7 +347,7 @@ func TestRunKillExplicitOutcomesTable(t *testing.T) { | |||||
| 353 | 347 | ||||
| 354 | var err error | 348 | var err error | ||
| 355 | got := captureStdout(t, func() { | 349 | got := captureStdout(t, func() { | ||
| 356 | - | err = runKill(context.Background(), tt.kill[0], tt.kill[1] == "true", tt.kill[2] == "true") | 350 | + | err = runKill(context.Background(), tt.kill[0], tt.kill[1] == "true") |
| 357 | }) | 351 | }) | ||
| 358 | require.Equal(t, tt.wantCode, ExitCode(err), "exit code for %v", tt.kill) | 352 | require.Equal(t, tt.wantCode, ExitCode(err), "exit code for %v", tt.kill) | ||
| 359 | if tt.wantUnknown { | 353 | if tt.wantUnknown { | ||
| @@ -385,7 +379,7 @@ func TestRunKillUnreachableBrokerIsExitThree(t *testing.T) { | |||||
| 385 | } | 379 | } | ||
| 386 | t.Cleanup(func() { connectBroker = previous }) | 380 | t.Cleanup(func() { connectBroker = previous }) | ||
| 387 | 381 | ||||
| 388 | - | err := runKill(context.Background(), "work", false, false) | 382 | + | err := runKill(context.Background(), "work", false) |
| 389 | require.Equal(t, 3, ExitCode(err)) | 383 | require.Equal(t, 3, ExitCode(err)) | ||
| 390 | require.ErrorIs(t, err, errDaemonUnreachable) | 384 | require.ErrorIs(t, err, errDaemonUnreachable) | ||
| 391 | } | 385 | } | ||
M internal/app/run_test.go +7 -11
| @@ -432,8 +432,7 @@ func TestParseArgs(t *testing.T) { | |||
| 432 | 432 | wantHostAct string | |
| 433 | 433 | wantHostTgt string | |
| 434 | 434 | wantAll bool | |
| 435 | - | wantDaemon bool | |
| 436 | - | wantBroker bool | |
| 435 | + | wantSessions bool | |
| 437 | 436 | wantErr bool | |
| 438 | 437 | nonUsageErr bool | |
| 439 | 438 | }{ | |
| @@ -477,13 +476,13 @@ func TestParseArgs(t *testing.T) { | |||
| 477 | 476 | {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"}, | |
| 478 | 477 | {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"}, | |
| 479 | 478 | {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true}, | |
| 480 | - | {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true}, | |
| 481 | - | {name: "kill broker", args: []string{"kill", "--broker"}, wantKind: kindKill, wantBroker: true}, | |
| 482 | - | {name: "kill broker rejects extra arg", args: []string{"kill", "--broker", "extra"}, wantErr: 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}, | |
| 481 | + | {name: "kill removed daemon flag", args: []string{"kill", "--daemon"}, wantErr: true}, | |
| 482 | + | {name: "kill removed broker flag", args: []string{"kill", "--broker"}, wantErr: true}, | |
| 483 | 483 | {name: "kill without name", args: []string{"kill"}, wantErr: true}, | |
| 484 | 484 | {name: "kill terminator without name", args: []string{"kill", "--"}, wantErr: true}, | |
| 485 | 485 | {name: "kill all rejects extra arg", args: []string{"kill", "--all", "extra"}, wantErr: true}, | |
| 486 | - | {name: "kill daemon rejects extra arg", args: []string{"kill", "--daemon", "extra"}, wantErr: true}, | |
| 487 | 486 | {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true}, | |
| 488 | 487 | {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon}, | |
| 489 | 488 | {name: "removed stdio", args: []string{"_stdio"}, wantErr: true}, | |
| @@ -549,11 +548,8 @@ func TestParseArgs(t *testing.T) { | |||
| 549 | 548 | if got.killAll != tt.wantAll { | |
| 550 | 549 | t.Errorf("killAll = %v, want %v", got.killAll, tt.wantAll) | |
| 551 | 550 | } | |
| 552 | - | if got.killDaemon != tt.wantDaemon { | |
| 553 | - | t.Errorf("killDaemon = %v, want %v", got.killDaemon, tt.wantDaemon) | |
| 554 | - | } | |
| 555 | - | if got.killBroker != tt.wantBroker { | |
| 556 | - | t.Errorf("killBroker = %v, want %v", got.killBroker, tt.wantBroker) | |
| 551 | + | if got.killSessions != tt.wantSessions { | |
| 552 | + | t.Errorf("killSessions = %v, want %v", got.killSessions, tt.wantSessions) | |
| 557 | 553 | } | |
| 558 | 554 | }) | |
| 559 | 555 | } | |
| @@ -432,8 +432,7 @@ func TestParseArgs(t *testing.T) { | |||||
| 432 | wantHostAct string | 432 | wantHostAct string | ||
| 433 | wantHostTgt string | 433 | wantHostTgt string | ||
| 434 | wantAll bool | 434 | wantAll bool | ||
| 435 | - | wantDaemon bool | 435 | + | wantSessions bool |
| 436 | - | wantBroker bool | |||
| 437 | wantErr bool | 436 | wantErr bool | ||
| 438 | nonUsageErr bool | 437 | nonUsageErr bool | ||
| 439 | }{ | 438 | }{ | ||
| @@ -477,13 +476,13 @@ func TestParseArgs(t *testing.T) { | |||||
| 477 | {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"}, | 476 | {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"}, | ||
| 478 | {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"}, | 477 | {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"}, | ||
| 479 | {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true}, | 478 | {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true}, | ||
| 480 | - | {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true}, | 479 | + | {name: "kill sessions", args: []string{"kill", "--sessions"}, wantKind: kindKill, wantSessions: true}, |
| 481 | - | {name: "kill broker", args: []string{"kill", "--broker"}, wantKind: kindKill, wantBroker: true}, | 480 | + | {name: "kill sessions rejects extra arg", args: []string{"kill", "--sessions", "extra"}, wantErr: true}, |
| 482 | - | {name: "kill broker rejects extra arg", args: []string{"kill", "--broker", "extra"}, wantErr: true}, | 481 | + | {name: "kill removed daemon flag", args: []string{"kill", "--daemon"}, wantErr: true}, |
| 482 | + | {name: "kill removed broker flag", args: []string{"kill", "--broker"}, wantErr: true}, | |||
| 483 | {name: "kill without name", args: []string{"kill"}, wantErr: true}, | 483 | {name: "kill without name", args: []string{"kill"}, wantErr: true}, | ||
| 484 | {name: "kill terminator without name", args: []string{"kill", "--"}, wantErr: true}, | 484 | {name: "kill terminator without name", args: []string{"kill", "--"}, wantErr: true}, | ||
| 485 | {name: "kill all rejects extra arg", args: []string{"kill", "--all", "extra"}, wantErr: true}, | 485 | {name: "kill all rejects extra arg", args: []string{"kill", "--all", "extra"}, wantErr: true}, | ||
| 486 | - | {name: "kill daemon rejects extra arg", args: []string{"kill", "--daemon", "extra"}, wantErr: true}, | |||
| 487 | {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true}, | 486 | {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true}, | ||
| 488 | {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon}, | 487 | {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon}, | ||
| 489 | {name: "removed stdio", args: []string{"_stdio"}, wantErr: true}, | 488 | {name: "removed stdio", args: []string{"_stdio"}, wantErr: true}, | ||
| @@ -549,11 +548,8 @@ func TestParseArgs(t *testing.T) { | |||||
| 549 | if got.killAll != tt.wantAll { | 548 | if got.killAll != tt.wantAll { | ||
| 550 | t.Errorf("killAll = %v, want %v", got.killAll, tt.wantAll) | 549 | t.Errorf("killAll = %v, want %v", got.killAll, tt.wantAll) | ||
| 551 | } | 550 | } | ||
| 552 | - | if got.killDaemon != tt.wantDaemon { | 551 | + | if got.killSessions != tt.wantSessions { |
| 553 | - | t.Errorf("killDaemon = %v, want %v", got.killDaemon, tt.wantDaemon) | 552 | + | t.Errorf("killSessions = %v, want %v", got.killSessions, tt.wantSessions) |
| 554 | - | } | |||
| 555 | - | if got.killBroker != tt.wantBroker { | |||
| 556 | - | t.Errorf("killBroker = %v, want %v", got.killBroker, tt.wantBroker) | |||
| 557 | } | 553 | } | ||
| 558 | }) | 554 | }) | ||
| 559 | } | 555 | } | ||
M internal/app/spawn.go +1 -1
| @@ -28,7 +28,7 @@ var ErrDaemonUnreachable = errors.New("vev: daemon did not become reachable") | |||
| 28 | 28 | // ErrLifecycleHeldByOther reports that another process kept the daemon | |
| 29 | 29 | // lifecycle lock for the whole start budget without publishing the carriage, | |
| 30 | 30 | // for example a vev daemon from an older build. Spawning cannot recover this. | |
| 31 | - | var ErrLifecycleHeldByOther = errors.New("vev: a vev daemon holds the lifecycle lock but did not answer within the start budget; if it is from an older build, stop it (for example `pkill -f 'vev --daemon'`) and retry") | |
| 31 | + | var ErrLifecycleHeldByOther = errors.New("vev: a vev daemon holds the lifecycle lock but did not answer within the start budget; if it is from an older build, run `vev kill --all` and retry") | |
| 32 | 32 | ||
| 33 | 33 | // spawnLockName is the mkdir-based lock directory guarding daemon spawn, so | |
| 34 | 34 | // concurrent first-clients elect a single spawner instead of racing to | |
| @@ -28,7 +28,7 @@ var ErrDaemonUnreachable = errors.New("vev: daemon did not become reachable") | |||||
| 28 | // ErrLifecycleHeldByOther reports that another process kept the daemon | 28 | // ErrLifecycleHeldByOther reports that another process kept the daemon | ||
| 29 | // lifecycle lock for the whole start budget without publishing the carriage, | 29 | // lifecycle lock for the whole start budget without publishing the carriage, | ||
| 30 | // for example a vev daemon from an older build. Spawning cannot recover this. | 30 | // for example a vev daemon from an older build. Spawning cannot recover this. | ||
| 31 | - | var ErrLifecycleHeldByOther = errors.New("vev: a vev daemon holds the lifecycle lock but did not answer within the start budget; if it is from an older build, stop it (for example `pkill -f 'vev --daemon'`) and retry") | 31 | + | var ErrLifecycleHeldByOther = errors.New("vev: a vev daemon holds the lifecycle lock but did not answer within the start budget; if it is from an older build, run `vev kill --all` and retry") |
| 32 | 32 | ||||
| 33 | // spawnLockName is the mkdir-based lock directory guarding daemon spawn, so | 33 | // spawnLockName is the mkdir-based lock directory guarding daemon spawn, so | ||
| 34 | // concurrent first-clients elect a single spawner instead of racing to | 34 | // concurrent first-clients elect a single spawner instead of racing to | ||