detached 206ab7a

fix(app): prompt before restarting stale daemon

commit
206ab7a12c563a21a23b27b22be2eed25580b524
author
brice · · 2026-07-03 08:45 UTC
parent
acdb950
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +215 -5
M internal/app/run.go +65 -4
@@ -25,6 +25,7 @@ import (
2525 "github.com/bnema/vev/internal/ports"
2626 "github.com/bnema/vev/internal/usecase/client"
2727 "github.com/bnema/vev/internal/usecase/daemon"
28+ "github.com/bnema/vev/internal/usecase/prompt"
2829 )
2930
3031 // cmdKind identifies which sub-command the CLI parsed.
@@ -243,12 +244,57 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
243244 }
244245 defer func() { _ = logFile.Close() }()
245246
246- var transport ports.Transport
247247 if remoteTarget != "" {
248- transport, err = sshstdio.Dial(remoteTarget, name)
249- } else {
250- transport, err = ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
248+ transport, err := sshstdio.Dial(remoteTarget, name)
249+ if err != nil {
250+ return err
251+ }
252+ // client.Attach owns and closes transport.
253+ return client.Attach(ctx, transport, term.New(), intent, name)
254+ }
255+
256+ return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
257+ confirmer: prompt.NewConfirmer(os.Stdin, os.Stderr),
258+ attach: attachLocalDaemon,
259+ killDaemon: requestDaemonStop,
260+ })
261+}
262+
263+type attachRecoveryDeps struct {
264+ confirmer prompt.Confirmer
265+ attach func(context.Context, uint8, string) error
266+ killDaemon func(context.Context) error
267+}
268+
269+func runLocalAttachWithRecovery(ctx context.Context, intent uint8, name string, deps attachRecoveryDeps) error {
270+ err := deps.attach(ctx, intent, name)
271+ if !isDaemonVersionDrift(err) {
272+ return err
251273 }
274+
275+ ok, promptErr := deps.confirmer.Confirm("Your vev version differs from the running daemon; kill it and restart?")
276+ if promptErr != nil {
277+ return fmt.Errorf("vev: reading confirmation: %w", promptErr)
278+ }
279+ if !ok {
280+ return err
281+ }
282+ if killErr := deps.killDaemon(ctx); killErr != nil {
283+ return killErr
284+ }
285+ return deps.attach(ctx, intent, name)
286+}
287+
288+func isDaemonVersionDrift(err error) bool {
289+ var protocolErr *client.ProtocolError
290+ if !errors.As(err, &protocolErr) {
291+ return false
292+ }
293+ return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == "malformed hello"
294+}
295+
296+func attachLocalDaemon(ctx context.Context, intent uint8, name string) error {
297+ transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
252298 if err != nil {
253299 return err
254300 }
@@ -256,6 +302,21 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
256302 return client.Attach(ctx, transport, term.New(), intent, name)
257303 }
258304
305+func requestDaemonStop(_ context.Context) error {
306+ transport, err := realDial(ipc.SocketDir())
307+ if err != nil {
308+ return fmt.Errorf("vev: no daemon running")
309+ }
310+ defer func() { _ = transport.Close() }()
311+ if err := transport.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}); err != nil {
312+ return fmt.Errorf("vev: requesting daemon stop: %w", err)
313+ }
314+ if _, err := transport.Recv(); err != nil && !errors.Is(err, io.EOF) {
315+ return fmt.Errorf("vev: reading daemon stop reply: %w", err)
316+ }
317+ return nil
318+}
319+
259320 // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it
260321 // connects to the per-user daemon (auto-spawning it if needed) and proxies the
261322 // framed protocol between process stdio and the daemon socket.
@@ -25,6 +25,7 @@ import (
25 "github.com/bnema/vev/internal/ports"25 "github.com/bnema/vev/internal/ports"
26 "github.com/bnema/vev/internal/usecase/client"26 "github.com/bnema/vev/internal/usecase/client"
27 "github.com/bnema/vev/internal/usecase/daemon"27 "github.com/bnema/vev/internal/usecase/daemon"
28+ "github.com/bnema/vev/internal/usecase/prompt"
28 )29 )
29 30
30 // cmdKind identifies which sub-command the CLI parsed.31 // cmdKind identifies which sub-command the CLI parsed.
@@ -243,12 +244,57 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
243 }244 }
244 defer func() { _ = logFile.Close() }()245 defer func() { _ = logFile.Close() }()
245 246
246- var transport ports.Transport
247 if remoteTarget != "" {247 if remoteTarget != "" {
248- transport, err = sshstdio.Dial(remoteTarget, name)248+ transport, err := sshstdio.Dial(remoteTarget, name)
249- } else {249+ if err != nil {
250- transport, err = ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)250+ return err
251+ }
252+ // client.Attach owns and closes transport.
253+ return client.Attach(ctx, transport, term.New(), intent, name)
254+ }
255+
256+ return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
257+ confirmer: prompt.NewConfirmer(os.Stdin, os.Stderr),
258+ attach: attachLocalDaemon,
259+ killDaemon: requestDaemonStop,
260+ })
261+}
262+
263+type attachRecoveryDeps struct {
264+ confirmer prompt.Confirmer
265+ attach func(context.Context, uint8, string) error
266+ killDaemon func(context.Context) error
267+}
268+
269+func runLocalAttachWithRecovery(ctx context.Context, intent uint8, name string, deps attachRecoveryDeps) error {
270+ err := deps.attach(ctx, intent, name)
271+ if !isDaemonVersionDrift(err) {
272+ return err
251 }273 }
274+
275+ ok, promptErr := deps.confirmer.Confirm("Your vev version differs from the running daemon; kill it and restart?")
276+ if promptErr != nil {
277+ return fmt.Errorf("vev: reading confirmation: %w", promptErr)
278+ }
279+ if !ok {
280+ return err
281+ }
282+ if killErr := deps.killDaemon(ctx); killErr != nil {
283+ return killErr
284+ }
285+ return deps.attach(ctx, intent, name)
286+}
287+
288+func isDaemonVersionDrift(err error) bool {
289+ var protocolErr *client.ProtocolError
290+ if !errors.As(err, &protocolErr) {
291+ return false
292+ }
293+ return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == "malformed hello"
294+}
295+
296+func attachLocalDaemon(ctx context.Context, intent uint8, name string) error {
297+ transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
252 if err != nil {298 if err != nil {
253 return err299 return err
254 }300 }
@@ -256,6 +302,21 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
256 return client.Attach(ctx, transport, term.New(), intent, name)302 return client.Attach(ctx, transport, term.New(), intent, name)
257 }303 }
258 304
305+func requestDaemonStop(_ context.Context) error {
306+ transport, err := realDial(ipc.SocketDir())
307+ if err != nil {
308+ return fmt.Errorf("vev: no daemon running")
309+ }
310+ defer func() { _ = transport.Close() }()
311+ if err := transport.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}); err != nil {
312+ return fmt.Errorf("vev: requesting daemon stop: %w", err)
313+ }
314+ if _, err := transport.Recv(); err != nil && !errors.Is(err, io.EOF) {
315+ return fmt.Errorf("vev: reading daemon stop reply: %w", err)
316+ }
317+ return nil
318+}
319+
259 // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it320 // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it
260 // connects to the per-user daemon (auto-spawning it if needed) and proxies the321 // connects to the per-user daemon (auto-spawning it if needed) and proxies the
261 // framed protocol between process stdio and the daemon socket.322 // framed protocol between process stdio and the daemon socket.
M internal/app/run_test.go +65 -0
@@ -1,12 +1,15 @@
11 package app
22
33 import (
4+ "bytes"
45 "context"
56 "errors"
67 "strings"
78 "testing"
89
910 "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/usecase/client"
12+ "github.com/bnema/vev/internal/usecase/prompt"
1013 )
1114
1215 func TestParseArgs(t *testing.T) {
@@ -93,6 +96,68 @@ func TestParseArgs(t *testing.T) {
9396 }
9497 }
9598
99+func TestRunLocalAttachPromptsAndRestartsOnProtocolMismatch(t *testing.T) {
100+ var prompts bytes.Buffer
101+ answers := strings.NewReader("y\n")
102+ attachCalls := 0
103+ killCalls := 0
104+
105+ err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
106+ confirmer: prompt.NewConfirmer(answers, &prompts),
107+ attach: func(context.Context, uint8, string) error {
108+ attachCalls++
109+ if attachCalls == 1 {
110+ return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}
111+ }
112+ return nil
113+ },
114+ killDaemon: func(context.Context) error {
115+ killCalls++
116+ return nil
117+ },
118+ })
119+ if err != nil {
120+ t.Fatalf("runLocalAttachWithRecovery returned error: %v", err)
121+ }
122+ if attachCalls != 2 {
123+ t.Fatalf("attach calls = %d, want 2", attachCalls)
124+ }
125+ if killCalls != 1 {
126+ t.Fatalf("kill calls = %d, want 1", killCalls)
127+ }
128+ if got := prompts.String(); !strings.Contains(got, "Your vev version differs") || !strings.Contains(got, "kill it") {
129+ t.Fatalf("prompt = %q, want version/kill prompt", got)
130+ }
131+}
132+
133+func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) {
134+ answers := strings.NewReader("n\n")
135+ wantErr := &client.ProtocolError{Code: ports.ErrInternal, Text: "malformed hello"}
136+ attachCalls := 0
137+ killCalls := 0
138+
139+ err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
140+ confirmer: prompt.NewConfirmer(answers, &bytes.Buffer{}),
141+ attach: func(context.Context, uint8, string) error {
142+ attachCalls++
143+ return wantErr
144+ },
145+ killDaemon: func(context.Context) error {
146+ killCalls++
147+ return nil
148+ },
149+ })
150+ if !errors.Is(err, wantErr) {
151+ t.Fatalf("error = %v, want original %v", err, wantErr)
152+ }
153+ if attachCalls != 1 {
154+ t.Fatalf("attach calls = %d, want 1", attachCalls)
155+ }
156+ if killCalls != 0 {
157+ t.Fatalf("kill calls = %d, want 0", killCalls)
158+ }
159+}
160+
96161 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
97162 t.Setenv("VEV", "outer")
98163 err := runAttach(context.Background(), ports.IntentEphemeral, "", "")
@@ -1,12 +1,15 @@
1 package app1 package app
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
5 "errors"6 "errors"
6 "strings"7 "strings"
7 "testing"8 "testing"
8 9
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/usecase/client"
12+ "github.com/bnema/vev/internal/usecase/prompt"
10 )13 )
11 14
12 func TestParseArgs(t *testing.T) {15 func TestParseArgs(t *testing.T) {
@@ -93,6 +96,68 @@ func TestParseArgs(t *testing.T) {
93 }96 }
94 }97 }
95 98
99+func TestRunLocalAttachPromptsAndRestartsOnProtocolMismatch(t *testing.T) {
100+ var prompts bytes.Buffer
101+ answers := strings.NewReader("y\n")
102+ attachCalls := 0
103+ killCalls := 0
104+
105+ err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
106+ confirmer: prompt.NewConfirmer(answers, &prompts),
107+ attach: func(context.Context, uint8, string) error {
108+ attachCalls++
109+ if attachCalls == 1 {
110+ return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}
111+ }
112+ return nil
113+ },
114+ killDaemon: func(context.Context) error {
115+ killCalls++
116+ return nil
117+ },
118+ })
119+ if err != nil {
120+ t.Fatalf("runLocalAttachWithRecovery returned error: %v", err)
121+ }
122+ if attachCalls != 2 {
123+ t.Fatalf("attach calls = %d, want 2", attachCalls)
124+ }
125+ if killCalls != 1 {
126+ t.Fatalf("kill calls = %d, want 1", killCalls)
127+ }
128+ if got := prompts.String(); !strings.Contains(got, "Your vev version differs") || !strings.Contains(got, "kill it") {
129+ t.Fatalf("prompt = %q, want version/kill prompt", got)
130+ }
131+}
132+
133+func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) {
134+ answers := strings.NewReader("n\n")
135+ wantErr := &client.ProtocolError{Code: ports.ErrInternal, Text: "malformed hello"}
136+ attachCalls := 0
137+ killCalls := 0
138+
139+ err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
140+ confirmer: prompt.NewConfirmer(answers, &bytes.Buffer{}),
141+ attach: func(context.Context, uint8, string) error {
142+ attachCalls++
143+ return wantErr
144+ },
145+ killDaemon: func(context.Context) error {
146+ killCalls++
147+ return nil
148+ },
149+ })
150+ if !errors.Is(err, wantErr) {
151+ t.Fatalf("error = %v, want original %v", err, wantErr)
152+ }
153+ if attachCalls != 1 {
154+ t.Fatalf("attach calls = %d, want 1", attachCalls)
155+ }
156+ if killCalls != 0 {
157+ t.Fatalf("kill calls = %d, want 0", killCalls)
158+ }
159+}
160+
96 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {161 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
97 t.Setenv("VEV", "outer")162 t.Setenv("VEV", "outer")
98 err := runAttach(context.Background(), ports.IntentEphemeral, "", "")163 err := runAttach(context.Background(), ports.IntentEphemeral, "", "")
M internal/ports/frame.go +1 -1
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 2
4+const ProtocolVersion uint16 = 3
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 24+const ProtocolVersion uint16 = 3
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
A internal/usecase/prompt/confirm.go +43 -0
@@ -0,0 +1,43 @@
1+package prompt
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "io"
7+ "strings"
8+)
9+
10+// Confirmer asks a yes/no question on a pair of streams.
11+type Confirmer struct {
12+ in io.Reader
13+ out io.Writer
14+}
15+
16+// NewConfirmer builds a confirmation prompt. Only y/yes answers confirm; empty,
17+// n/no, and unknown answers all decline.
18+func NewConfirmer(in io.Reader, out io.Writer) Confirmer {
19+ return Confirmer{in: in, out: out}
20+}
21+
22+// Confirm writes question with a [y/N] default and reads one answer line.
23+func (c Confirmer) Confirm(question string) (bool, error) {
24+ if c.out != nil {
25+ if _, err := fmt.Fprintf(c.out, "%s [y/N] ", question); err != nil {
26+ return false, err
27+ }
28+ }
29+ answer, err := bufio.NewReader(c.in).ReadString('\n')
30+ if err != nil && !isEOFWithAnswer(err, answer) {
31+ return false, err
32+ }
33+ switch strings.ToLower(strings.TrimSpace(answer)) {
34+ case "y", "yes":
35+ return true, nil
36+ default:
37+ return false, nil
38+ }
39+}
40+
41+func isEOFWithAnswer(err error, answer string) bool {
42+ return err == io.EOF && answer != ""
43+}
@@ -0,0 +1,43 @@
1+package prompt
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "io"
7+ "strings"
8+)
9+
10+// Confirmer asks a yes/no question on a pair of streams.
11+type Confirmer struct {
12+ in io.Reader
13+ out io.Writer
14+}
15+
16+// NewConfirmer builds a confirmation prompt. Only y/yes answers confirm; empty,
17+// n/no, and unknown answers all decline.
18+func NewConfirmer(in io.Reader, out io.Writer) Confirmer {
19+ return Confirmer{in: in, out: out}
20+}
21+
22+// Confirm writes question with a [y/N] default and reads one answer line.
23+func (c Confirmer) Confirm(question string) (bool, error) {
24+ if c.out != nil {
25+ if _, err := fmt.Fprintf(c.out, "%s [y/N] ", question); err != nil {
26+ return false, err
27+ }
28+ }
29+ answer, err := bufio.NewReader(c.in).ReadString('\n')
30+ if err != nil && !isEOFWithAnswer(err, answer) {
31+ return false, err
32+ }
33+ switch strings.ToLower(strings.TrimSpace(answer)) {
34+ case "y", "yes":
35+ return true, nil
36+ default:
37+ return false, nil
38+ }
39+}
40+
41+func isEOFWithAnswer(err error, answer string) bool {
42+ return err == io.EOF && answer != ""
43+}
A internal/usecase/prompt/confirm_test.go +41 -0
@@ -0,0 +1,41 @@
1+package prompt
2+
3+import (
4+ "bytes"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestConfirmerAcceptsYes(t *testing.T) {
10+ var out bytes.Buffer
11+ ok, err := NewConfirmer(strings.NewReader("y\n"), &out).Confirm("Kill daemon?")
12+ if err != nil {
13+ t.Fatalf("Confirm returned error: %v", err)
14+ }
15+ if !ok {
16+ t.Fatal("Confirm returned false, want true")
17+ }
18+ if got := out.String(); !strings.Contains(got, "Kill daemon?") || !strings.Contains(got, "[y/N]") {
19+ t.Fatalf("prompt output = %q, want question and default", got)
20+ }
21+}
22+
23+func TestConfirmerDefaultsNo(t *testing.T) {
24+ ok, err := NewConfirmer(strings.NewReader("\n"), &bytes.Buffer{}).Confirm("Kill daemon?")
25+ if err != nil {
26+ t.Fatalf("Confirm returned error: %v", err)
27+ }
28+ if ok {
29+ t.Fatal("Confirm returned true for empty answer, want false")
30+ }
31+}
32+
33+func TestConfirmerRejectsUnknownAnswer(t *testing.T) {
34+ ok, err := NewConfirmer(strings.NewReader("later\n"), &bytes.Buffer{}).Confirm("Kill daemon?")
35+ if err != nil {
36+ t.Fatalf("Confirm returned error: %v", err)
37+ }
38+ if ok {
39+ t.Fatal("Confirm returned true for unknown answer, want false")
40+ }
41+}
@@ -0,0 +1,41 @@
1+package prompt
2+
3+import (
4+ "bytes"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestConfirmerAcceptsYes(t *testing.T) {
10+ var out bytes.Buffer
11+ ok, err := NewConfirmer(strings.NewReader("y\n"), &out).Confirm("Kill daemon?")
12+ if err != nil {
13+ t.Fatalf("Confirm returned error: %v", err)
14+ }
15+ if !ok {
16+ t.Fatal("Confirm returned false, want true")
17+ }
18+ if got := out.String(); !strings.Contains(got, "Kill daemon?") || !strings.Contains(got, "[y/N]") {
19+ t.Fatalf("prompt output = %q, want question and default", got)
20+ }
21+}
22+
23+func TestConfirmerDefaultsNo(t *testing.T) {
24+ ok, err := NewConfirmer(strings.NewReader("\n"), &bytes.Buffer{}).Confirm("Kill daemon?")
25+ if err != nil {
26+ t.Fatalf("Confirm returned error: %v", err)
27+ }
28+ if ok {
29+ t.Fatal("Confirm returned true for empty answer, want false")
30+ }
31+}
32+
33+func TestConfirmerRejectsUnknownAnswer(t *testing.T) {
34+ ok, err := NewConfirmer(strings.NewReader("later\n"), &bytes.Buffer{}).Confirm("Kill daemon?")
35+ if err != nil {
36+ t.Fatalf("Confirm returned error: %v", err)
37+ }
38+ if ok {
39+ t.Fatal("Confirm returned true for unknown answer, want false")
40+ }
41+}

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