detached b71082c

fix(app): wait for the broker to exit before kill --broker reports success

commit
b71082c9b9edcccebe0bcb16466d9cdc12108c6c
author
brice · · 2026-09-23 08:45 UTC
parent
0ec9504
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +75 -0
M internal/app/broker_kill_linux.go +28 -0
@@ -9,6 +9,7 @@ import (
99 "net"
1010 "os"
1111 "syscall"
12+ "time"
1213
1314 "github.com/bnema/vev/internal/adapters/brokeripc"
1415 )
@@ -50,6 +51,33 @@ func runKillBroker(ctx context.Context) error {
5051 if err := syscall.Kill(int(peer.Pid), syscall.SIGTERM); err != nil {
5152 return fmt.Errorf("vev: stop broker: %w", err)
5253 }
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+ }
5359 fmt.Println("stopped broker")
5460 return nil
5561 }
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+}
@@ -9,6 +9,7 @@ import (
9 "net"9 "net"
10 "os"10 "os"
11 "syscall"11 "syscall"
12+ "time"
12 13
13 "github.com/bnema/vev/internal/adapters/brokeripc"14 "github.com/bnema/vev/internal/adapters/brokeripc"
14 )15 )
@@ -50,6 +51,33 @@ func runKillBroker(ctx context.Context) error {
50 if err := syscall.Kill(int(peer.Pid), syscall.SIGTERM); err != nil {51 if err := syscall.Kill(int(peer.Pid), syscall.SIGTERM); err != nil {
51 return fmt.Errorf("vev: stop broker: %w", err)52 return fmt.Errorf("vev: stop broker: %w", err)
52 }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+ }
53 fmt.Println("stopped broker")59 fmt.Println("stopped broker")
54 return nil60 return nil
55 }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+}
A internal/app/broker_kill_linux_test.go +47 -0
@@ -0,0 +1,47 @@
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+}
@@ -0,0 +1,47 @@
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+}

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