detached 0a0e1c0

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
7777
7878 - Ephemeral numbered sessions survive detach while the daemon retains them, but are not persisted.
7979 - 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.
8282 - 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.
8383 - Each connection has a 15-second handshake budget from connect through the initial committed publication.
8484 - 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
4242 vev --ui-control ... expose observation and input control
4343 ```
4444
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).
4646
4747 > [!NOTE]
4848 > 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
120120
121121 ```sh
122122 VEV_ENV=dev go run .
123-VEV_ENV=dev go run . kill --all
123+VEV_ENV=dev go run . kill --sessions
124124 rm -rf .dev/dev
125125 ```
126126
@@ -42,7 +42,7 @@ vev --ui-observe ... expose passive observation for an attach
42 vev --ui-control ... expose observation and input control42 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 ```sh121 ```sh
122 VEV_ENV=dev go run .122 VEV_ENV=dev go run .
123-VEV_ENV=dev go run . kill --all123+VEV_ENV=dev go run . kill --sessions
124 rm -rf .dev/dev124 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
120120
121121 ```sh
122122 VEV_ENV=dev go run .
123-VEV_ENV=dev go run . kill --all
123+VEV_ENV=dev go run . kill --sessions
124124 rm -rf .dev/dev
125125 ```
126126
@@ -120,7 +120,7 @@ Set `VEV_ENV` to a name such as `dev` to redirect all vev-owned paths into `.dev
120 120
121 ```sh121 ```sh
122 VEV_ENV=dev go run .122 VEV_ENV=dev go run .
123-VEV_ENV=dev go run . kill --all123+VEV_ENV=dev go run . kill --sessions
124 rm -rf .dev/dev124 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
88
99 ## Bulk purge
1010
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.
1212
1313 ## Recovery commands
1414
@@ -8,7 +8,7 @@ vev opens named-session state only while holding `$XDG_RUNTIME_DIR/vev/lifecycle
8 8
9 ## Bulk purge9 ## 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 commands13 ## 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
293293 return brokerStatusReport{}, err
294294 }
295295 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)
297297 }
298298
299299 if held == nil {
@@ -314,7 +314,7 @@ func ensureBrokerReady(ctx context.Context, req brokerStatusRequest, deps broker
314314 return brokerStatusReport{}, err
315315 }
316316 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)
318318 }
319319 case errors.Is(err, lifecycle.ErrBusy):
320320 // 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{}, err293 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{}, err314 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 elected320 // 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) {
7777 return nil, fmt.Errorf("vev: connect broker: %w", err)
7878 }
7979 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`)
8181 }
8282 if err := awaitBrokerPublication(ctx, service); err != nil {
8383 _ = 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 {
8787 hostAction string
8888 hostTarget string
8989 killAll bool
90- killDaemon bool
91- killBroker bool
90+ killSessions bool
9291 cmd cmdInvocation
9392 brokerServe brokerServeOptions
9493 brokerMux brokerMuxOptions
@@ -125,9 +124,9 @@ usage:
125124 vev host rm <host> remove a pinned remote host
126125 vev host list list known remote hosts
127126 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)
131130 vev cmd <command> run a control command (vev cmd --help)
132131 vev --ui-observe expose passive observation for this interactive client
133132 (optional: --ui-socket PATH)
@@ -311,7 +310,7 @@ parsedUIFlags:
311310 return command{kind: kindCmd, cmd: invocation}, nil
312311 case "kill":
313312 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")
315314 }
316315 if args[1] == "--" {
317316 if len(args) != 3 || args[2] == "" {
@@ -320,16 +319,17 @@ parsedUIFlags:
320319 return command{kind: kindKill, name: args[2]}, nil
321320 }
322321 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")
324323 }
325324 switch args[1] {
325+ case "--sessions":
326+ return command{kind: kindKill, killSessions: true}, nil
326327 case "--all":
327328 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
332329 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+ }
333333 return command{kind: kindKill, name: args[1]}, nil
334334 }
335335 case "-h", "--help", "help":
@@ -381,10 +381,10 @@ func dispatch(ctx context.Context, cmd command) error {
381381 case kindHost:
382382 return runHostCommand(ctx, cmd, defaultRemoteHostDeps())
383383 case kindKill:
384- if cmd.killBroker {
385- return runKillBroker(ctx)
384+ if cmd.killAll {
385+ return runKillAll(ctx, os.Stdout)
386386 }
387- return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon)
387+ return runKill(ctx, cmd.name, cmd.killSessions)
388388 case kindCmd:
389389 return runCmd(ctx, cmd.cmd)
390390 case kindAttach:
@@ -1179,16 +1179,9 @@ func unreadableCatalogueError(stateDir string) error {
11791179 " rm -rf %s", persist.ErrCatalogueUnreadable, stateDir, stateDir)
11801180 }
11811181
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 {
11921185 service, err := connectBroker(ctx)
11931186 if err != nil {
11941187 return unreachableBrokerError(err)
@@ -1200,10 +1193,9 @@ func runKill(ctx context.Context, name string, all, daemon bool) error {
12001193 }
12011194 route := localBrokerOperationRoute(service.Snapshot())
12021195 var result protocol.KillResult
1203- switch {
1204- case all:
1196+ if sessions {
12051197 result, err = operations.KillAll(ctx, route)
1206- default:
1198+ } else {
12071199 result, err = operations.Kill(ctx, route, name)
12081200 }
12091201 if err != nil {
@@ -1212,18 +1204,10 @@ func runKill(ctx context.Context, name string, all, daemon bool) error {
12121204 if err := brokerKillResultError(result); err != nil {
12131205 return err
12141206 }
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 {
12251208 fmt.Println("killed all sessions")
1226- return
1209+ } else {
1210+ fmt.Printf("killed %s\n", name)
12271211 }
1228- fmt.Printf("killed %s\n", name)
1212+ return nil
12291213 }
@@ -87,8 +87,7 @@ type command struct {
87 hostAction string87 hostAction string
88 hostTarget string88 hostTarget string
89 killAll bool89 killAll bool
90- killDaemon bool90+ killSessions bool
91- killBroker bool
92 cmd cmdInvocation91 cmd cmdInvocation
93 brokerServe brokerServeOptions92 brokerServe brokerServeOptions
94 brokerMux brokerMuxOptions93 brokerMux brokerMuxOptions
@@ -125,9 +124,9 @@ usage:
125 vev host rm <host> remove a pinned remote host124 vev host rm <host> remove a pinned remote host
126 vev host list list known remote hosts125 vev host list list known remote hosts
127 vev kill <name> kill a session126 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 daemon128+ vev kill --all stop everything: every vev window, the broker, and the
130- vev kill --broker stop the active vev broker129+ 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 client131 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}, nil310 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]}, nil319 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}, nil328 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]}, nil333 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-spawn1182+// 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.KillResult1195 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 err1205 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- return1209+ } 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) {
305305 wantStdout: "killed work",
306306 },
307307 {
308- name: "kill-all success",
308+ name: "kill-sessions success",
309309 kill: []string{"", "true", "false"},
310310 replies: []protocol.ServerMessage{seamKillResult(1, protocol.KillSucceeded, 0, "")},
311311 wantCode: 0,
312312 wantStdout: "killed all sessions",
313313 },
314- {
315- name: "daemon-stop success",
316- kill: []string{"", "false", "true"},
317- replies: []protocol.ServerMessage{seamKillResult(1, protocol.KillSucceeded, 0, "")},
318- wantCode: 0,
319- },
320314 {
321315 name: "definite failure is a bounded exit-one failure",
322316 kill: []string{"work", "false", "false"},
@@ -353,7 +347,7 @@ func TestRunKillExplicitOutcomesTable(t *testing.T) {
353347
354348 var err error
355349 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")
357351 })
358352 require.Equal(t, tt.wantCode, ExitCode(err), "exit code for %v", tt.kill)
359353 if tt.wantUnknown {
@@ -385,7 +379,7 @@ func TestRunKillUnreachableBrokerIsExitThree(t *testing.T) {
385379 }
386380 t.Cleanup(func() { connectBroker = previous })
387381
388- err := runKill(context.Background(), "work", false, false)
382+ err := runKill(context.Background(), "work", false)
389383 require.Equal(t, 3, ExitCode(err))
390384 require.ErrorIs(t, err, errDaemonUnreachable)
391385 }
@@ -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 error348 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) {
432432 wantHostAct string
433433 wantHostTgt string
434434 wantAll bool
435- wantDaemon bool
436- wantBroker bool
435+ wantSessions bool
437436 wantErr bool
438437 nonUsageErr bool
439438 }{
@@ -477,13 +476,13 @@ func TestParseArgs(t *testing.T) {
477476 {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"},
478477 {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},
479478 {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},
483483 {name: "kill without name", args: []string{"kill"}, wantErr: true},
484484 {name: "kill terminator without name", args: []string{"kill", "--"}, wantErr: true},
485485 {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},
487486 {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},
488487 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
489488 {name: "removed stdio", args: []string{"_stdio"}, wantErr: true},
@@ -549,11 +548,8 @@ func TestParseArgs(t *testing.T) {
549548 if got.killAll != tt.wantAll {
550549 t.Errorf("killAll = %v, want %v", got.killAll, tt.wantAll)
551550 }
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)
557553 }
558554 })
559555 }
@@ -432,8 +432,7 @@ func TestParseArgs(t *testing.T) {
432 wantHostAct string432 wantHostAct string
433 wantHostTgt string433 wantHostTgt string
434 wantAll bool434 wantAll bool
435- wantDaemon bool435+ wantSessions bool
436- wantBroker bool
437 wantErr bool436 wantErr bool
438 nonUsageErr bool437 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")
2828 // ErrLifecycleHeldByOther reports that another process kept the daemon
2929 // lifecycle lock for the whole start budget without publishing the carriage,
3030 // 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")
3232
3333 // spawnLockName is the mkdir-based lock directory guarding daemon spawn, so
3434 // 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 daemon28 // 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, so33 // spawnLockName is the mkdir-based lock directory guarding daemon spawn, so
34 // concurrent first-clients elect a single spawner instead of racing to34 // concurrent first-clients elect a single spawner instead of racing to

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close