detached cd05aa6

feat(kill): add all and daemon flags

commit
cd05aa6e9a78e6e7414371225c580cf426792f98
author
brice · · 2026-07-03 05:38 UTC
parent
cf5ca03
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +120 -16
M README.md +2 -0
@@ -19,6 +19,8 @@ vev attach <name> attach to an existing named session (alias: a)
1919 vev attach user@host[:session] attach to a remote vev daemon over SSH
2020 vev ls list sessions
2121 vev kill <name> kill a named session
22+vev kill --all kill all sessions
23+vev kill --daemon kill the active vev daemon
2224 vev --help show help
2325 vev --version show version
2426 ```
@@ -19,6 +19,8 @@ vev attach <name> attach to an existing named session (alias: a)
19 vev attach user@host[:session] attach to a remote vev daemon over SSH19 vev attach user@host[:session] attach to a remote vev daemon over SSH
20 vev ls list sessions20 vev ls list sessions
21 vev kill <name> kill a named session21 vev kill <name> kill a named session
22+vev kill --all kill all sessions
23+vev kill --daemon kill the active vev daemon
22 vev --help show help24 vev --help show help
23 vev --version show version25 vev --version show version
24 ```26 ```
M internal/app/daemon_integration_test.go +23 -0
@@ -185,3 +185,26 @@ func TestIntegration_NamedSurvivesReattach(t *testing.T) {
185185 defer func() { _ = tr2.Close() }()
186186 awaitText(t, p2, sz, "MARKER")
187187 }
188+
189+func TestIntegration_KillAllShutsDownDaemon(t *testing.T) {
190+ sz := domain.Size{Cols: 80, Rows: 24}
191+ dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "sleep 30"}))
192+
193+ tr1, _ := attach(t, dir, ports.IntentNew, "one", sz)
194+ require.NoError(t, tr1.Close())
195+ tr2, _ := attach(t, dir, ports.IntentNew, "two", sz)
196+ require.NoError(t, tr2.Close())
197+
198+ killTr, err := ipc.Dial(dir)
199+ require.NoError(t, err)
200+ require.NoError(t, killTr.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}))
201+
202+ _, err = killTr.Recv()
203+ require.ErrorIs(t, err, io.EOF)
204+ select {
205+ case err := <-served:
206+ require.NoError(t, err)
207+ case <-time.After(5 * time.Second):
208+ t.Fatal("daemon did not shut down after kill all")
209+ }
210+}
@@ -185,3 +185,26 @@ func TestIntegration_NamedSurvivesReattach(t *testing.T) {
185 defer func() { _ = tr2.Close() }()185 defer func() { _ = tr2.Close() }()
186 awaitText(t, p2, sz, "MARKER")186 awaitText(t, p2, sz, "MARKER")
187 }187 }
188+
189+func TestIntegration_KillAllShutsDownDaemon(t *testing.T) {
190+ sz := domain.Size{Cols: 80, Rows: 24}
191+ dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "sleep 30"}))
192+
193+ tr1, _ := attach(t, dir, ports.IntentNew, "one", sz)
194+ require.NoError(t, tr1.Close())
195+ tr2, _ := attach(t, dir, ports.IntentNew, "two", sz)
196+ require.NoError(t, tr2.Close())
197+
198+ killTr, err := ipc.Dial(dir)
199+ require.NoError(t, err)
200+ require.NoError(t, killTr.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}))
201+
202+ _, err = killTr.Recv()
203+ require.ErrorIs(t, err, io.EOF)
204+ select {
205+ case err := <-served:
206+ require.NoError(t, err)
207+ case <-time.After(5 * time.Second):
208+ t.Fatal("daemon did not shut down after kill all")
209+ }
210+}
M internal/app/run.go +32 -9
@@ -47,6 +47,8 @@ type command struct {
4747 intent uint8
4848 name string
4949 remoteTarget string
50+ killAll bool
51+ killDaemon bool
5052 }
5153
5254 // usageError is a user-facing argument error; the app prints it (with usage)
@@ -69,6 +71,8 @@ usage:
6971 attach through SSH to a remote vev daemon
7072 vev ls list sessions
7173 vev kill <name> kill a named session
74+ vev kill --all kill all sessions
75+ vev kill --daemon kill the active vev daemon
7276 vev --help show this help
7377 vev --version show version`
7478
@@ -134,12 +138,19 @@ func parseArgs(args []string) (command, error) {
134138 return command{kind: kindList}, nil
135139 case "kill":
136140 if len(args) < 2 || args[1] == "" {
137- return command{}, usagef("`kill` requires a session name")
141+ return command{}, usagef("`kill` requires a session name, --all, or --daemon")
138142 }
139143 if len(args) > 2 {
140- return command{}, usagef("`kill` accepts exactly one session name")
144+ return command{}, usagef("`kill` accepts exactly one session name, --all, or --daemon")
145+ }
146+ switch args[1] {
147+ case "--all":
148+ return command{kind: kindKill, killAll: true}, nil
149+ case "--daemon":
150+ return command{kind: kindKill, killDaemon: true}, nil
151+ default:
152+ return command{kind: kindKill, name: args[1]}, nil
141153 }
142- return command{kind: kindKill, name: args[1]}, nil
143154 case "-h", "--help", "help":
144155 return command{kind: kindHelp}, nil
145156 case "--version", "version":
@@ -165,7 +176,7 @@ func dispatch(ctx context.Context, cmd command) error {
165176 case kindList:
166177 return runList(ctx)
167178 case kindKill:
168- return runKill(ctx, cmd.name)
179+ return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon)
169180 case kindAttach:
170181 return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget)
171182 default:
@@ -328,15 +339,15 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
328339 _ = tw.Flush()
329340 }
330341
331-// runKill asks the daemon to terminate a named session.
332-func runKill(_ context.Context, name string) error {
342+// runKill asks the daemon to terminate a named session, every session, or the daemon.
343+func runKill(_ context.Context, name string, all, daemon bool) error {
333344 transport, err := realDial(ipc.SocketDir())
334345 if err != nil {
335346 return fmt.Errorf("vev: no daemon running")
336347 }
337348 defer func() { _ = transport.Close() }()
338349
339- if err := transport.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: name})}); err != nil {
350+ if err := transport.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: name, All: all || daemon})}); err != nil {
340351 return fmt.Errorf("vev: requesting kill: %w", err)
341352 }
342353 reply, err := transport.Recv()
@@ -344,7 +355,7 @@ func runKill(_ context.Context, name string) error {
344355 // The daemon may close the connection after killing; treat a clean
345356 // EOF as success.
346357 if errors.Is(err, io.EOF) {
347- fmt.Printf("killed %s\n", name)
358+ printKillSuccess(name, all, daemon)
348359 return nil
349360 }
350361 return fmt.Errorf("vev: reading kill reply: %w", err)
@@ -353,6 +364,18 @@ func runKill(_ context.Context, name string) error {
353364 em, _ := ports.UnmarshalErrorMsg(reply.Payload)
354365 return fmt.Errorf("vev: %s", em.Text)
355366 }
356- fmt.Printf("killed %s\n", name)
367+ printKillSuccess(name, all, daemon)
357368 return nil
358369 }
370+
371+func printKillSuccess(name string, all, daemon bool) {
372+ if daemon {
373+ fmt.Println("killed daemon")
374+ return
375+ }
376+ if all {
377+ fmt.Println("killed all sessions")
378+ return
379+ }
380+ fmt.Printf("killed %s\n", name)
381+}
@@ -47,6 +47,8 @@ type command struct {
47 intent uint847 intent uint8
48 name string48 name string
49 remoteTarget string49 remoteTarget string
50+ killAll bool
51+ killDaemon bool
50 }52 }
51 53
52 // usageError is a user-facing argument error; the app prints it (with usage)54 // usageError is a user-facing argument error; the app prints it (with usage)
@@ -69,6 +71,8 @@ usage:
69 attach through SSH to a remote vev daemon71 attach through SSH to a remote vev daemon
70 vev ls list sessions72 vev ls list sessions
71 vev kill <name> kill a named session73 vev kill <name> kill a named session
74+ vev kill --all kill all sessions
75+ vev kill --daemon kill the active vev daemon
72 vev --help show this help76 vev --help show this help
73 vev --version show version`77 vev --version show version`
74 78
@@ -134,12 +138,19 @@ func parseArgs(args []string) (command, error) {
134 return command{kind: kindList}, nil138 return command{kind: kindList}, nil
135 case "kill":139 case "kill":
136 if len(args) < 2 || args[1] == "" {140 if len(args) < 2 || args[1] == "" {
137- return command{}, usagef("`kill` requires a session name")141+ return command{}, usagef("`kill` requires a session name, --all, or --daemon")
138 }142 }
139 if len(args) > 2 {143 if len(args) > 2 {
140- return command{}, usagef("`kill` accepts exactly one session name")144+ return command{}, usagef("`kill` accepts exactly one session name, --all, or --daemon")
145+ }
146+ switch args[1] {
147+ case "--all":
148+ return command{kind: kindKill, killAll: true}, nil
149+ case "--daemon":
150+ return command{kind: kindKill, killDaemon: true}, nil
151+ default:
152+ return command{kind: kindKill, name: args[1]}, nil
141 }153 }
142- return command{kind: kindKill, name: args[1]}, nil
143 case "-h", "--help", "help":154 case "-h", "--help", "help":
144 return command{kind: kindHelp}, nil155 return command{kind: kindHelp}, nil
145 case "--version", "version":156 case "--version", "version":
@@ -165,7 +176,7 @@ func dispatch(ctx context.Context, cmd command) error {
165 case kindList:176 case kindList:
166 return runList(ctx)177 return runList(ctx)
167 case kindKill:178 case kindKill:
168- return runKill(ctx, cmd.name)179+ return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon)
169 case kindAttach:180 case kindAttach:
170 return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget)181 return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget)
171 default:182 default:
@@ -328,15 +339,15 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
328 _ = tw.Flush()339 _ = tw.Flush()
329 }340 }
330 341
331-// runKill asks the daemon to terminate a named session.342+// runKill asks the daemon to terminate a named session, every session, or the daemon.
332-func runKill(_ context.Context, name string) error {343+func runKill(_ context.Context, name string, all, daemon bool) error {
333 transport, err := realDial(ipc.SocketDir())344 transport, err := realDial(ipc.SocketDir())
334 if err != nil {345 if err != nil {
335 return fmt.Errorf("vev: no daemon running")346 return fmt.Errorf("vev: no daemon running")
336 }347 }
337 defer func() { _ = transport.Close() }()348 defer func() { _ = transport.Close() }()
338 349
339- if err := transport.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: name})}); err != nil {350+ if err := transport.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: name, All: all || daemon})}); err != nil {
340 return fmt.Errorf("vev: requesting kill: %w", err)351 return fmt.Errorf("vev: requesting kill: %w", err)
341 }352 }
342 reply, err := transport.Recv()353 reply, err := transport.Recv()
@@ -344,7 +355,7 @@ func runKill(_ context.Context, name string) error {
344 // The daemon may close the connection after killing; treat a clean355 // The daemon may close the connection after killing; treat a clean
345 // EOF as success.356 // EOF as success.
346 if errors.Is(err, io.EOF) {357 if errors.Is(err, io.EOF) {
347- fmt.Printf("killed %s\n", name)358+ printKillSuccess(name, all, daemon)
348 return nil359 return nil
349 }360 }
350 return fmt.Errorf("vev: reading kill reply: %w", err)361 return fmt.Errorf("vev: reading kill reply: %w", err)
@@ -353,6 +364,18 @@ func runKill(_ context.Context, name string) error {
353 em, _ := ports.UnmarshalErrorMsg(reply.Payload)364 em, _ := ports.UnmarshalErrorMsg(reply.Payload)
354 return fmt.Errorf("vev: %s", em.Text)365 return fmt.Errorf("vev: %s", em.Text)
355 }366 }
356- fmt.Printf("killed %s\n", name)367+ printKillSuccess(name, all, daemon)
357 return nil368 return nil
358 }369 }
370+
371+func printKillSuccess(name string, all, daemon bool) {
372+ if daemon {
373+ fmt.Println("killed daemon")
374+ return
375+ }
376+ if all {
377+ fmt.Println("killed all sessions")
378+ return
379+ }
380+ fmt.Printf("killed %s\n", name)
381+}
M internal/app/run_test.go +12 -0
@@ -15,6 +15,8 @@ func TestParseArgs(t *testing.T) {
1515 wantIntent uint8
1616 wantName string
1717 wantRemote string
18+ wantAll bool
19+ wantDaemon bool
1820 wantErr bool
1921 }{
2022 {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},
@@ -33,7 +35,11 @@ func TestParseArgs(t *testing.T) {
3335 {name: "ls", args: []string{"ls"}, wantKind: kindList},
3436 {name: "list", args: []string{"list"}, wantKind: kindList},
3537 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
38+ {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},
39+ {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true},
3640 {name: "kill without name", args: []string{"kill"}, wantErr: true},
41+ {name: "kill all rejects extra arg", args: []string{"kill", "--all", "extra"}, wantErr: true},
42+ {name: "kill daemon rejects extra arg", args: []string{"kill", "--daemon", "extra"}, wantErr: true},
3743 {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},
3844 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
3945 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
@@ -73,6 +79,12 @@ func TestParseArgs(t *testing.T) {
7379 if got.remoteTarget != tt.wantRemote {
7480 t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote)
7581 }
82+ if got.killAll != tt.wantAll {
83+ t.Errorf("killAll = %v, want %v", got.killAll, tt.wantAll)
84+ }
85+ if got.killDaemon != tt.wantDaemon {
86+ t.Errorf("killDaemon = %v, want %v", got.killDaemon, tt.wantDaemon)
87+ }
7688 })
7789 }
7890 }
@@ -15,6 +15,8 @@ func TestParseArgs(t *testing.T) {
15 wantIntent uint815 wantIntent uint8
16 wantName string16 wantName string
17 wantRemote string17 wantRemote string
18+ wantAll bool
19+ wantDaemon bool
18 wantErr bool20 wantErr bool
19 }{21 }{
20 {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},22 {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},
@@ -33,7 +35,11 @@ func TestParseArgs(t *testing.T) {
33 {name: "ls", args: []string{"ls"}, wantKind: kindList},35 {name: "ls", args: []string{"ls"}, wantKind: kindList},
34 {name: "list", args: []string{"list"}, wantKind: kindList},36 {name: "list", args: []string{"list"}, wantKind: kindList},
35 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},37 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
38+ {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},
39+ {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true},
36 {name: "kill without name", args: []string{"kill"}, wantErr: true},40 {name: "kill without name", args: []string{"kill"}, wantErr: true},
41+ {name: "kill all rejects extra arg", args: []string{"kill", "--all", "extra"}, wantErr: true},
42+ {name: "kill daemon rejects extra arg", args: []string{"kill", "--daemon", "extra"}, wantErr: true},
37 {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},43 {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},
38 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},44 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
39 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},45 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
@@ -73,6 +79,12 @@ func TestParseArgs(t *testing.T) {
73 if got.remoteTarget != tt.wantRemote {79 if got.remoteTarget != tt.wantRemote {
74 t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote)80 t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote)
75 }81 }
82+ if got.killAll != tt.wantAll {
83+ t.Errorf("killAll = %v, want %v", got.killAll, tt.wantAll)
84+ }
85+ if got.killDaemon != tt.wantDaemon {
86+ t.Errorf("killDaemon = %v, want %v", got.killDaemon, tt.wantDaemon)
87+ }
76 })88 })
77 }89 }
78 }90 }
M internal/ports/wire.go +15 -2
@@ -101,9 +101,10 @@ type Detached struct {
101101 // fields; the request is fully described by its message type.
102102 type List struct{}
103103
104-// Kill asks the daemon to terminate the named session.
104+// Kill asks the daemon to terminate one named session or all sessions.
105105 type Kill struct {
106106 Name string
107+ All bool
107108 }
108109
109110 // SessionInfo describes one session in a Sessions listing.
@@ -427,6 +428,11 @@ func UnmarshalList(b []byte) (List, error) {
427428 func MarshalKill(m Kill) []byte {
428429 w := payloadWriter{}
429430 w.putString(m.Name)
431+ if m.All {
432+ w.putUint8(1)
433+ } else {
434+ w.putUint8(0)
435+ }
430436 return w.b
431437 }
432438
@@ -437,10 +443,17 @@ func UnmarshalKill(b []byte) (Kill, error) {
437443 if err != nil {
438444 return Kill{}, err
439445 }
446+ var all uint8
447+ if len(r.b) > 0 {
448+ all, err = r.getUint8()
449+ if err != nil {
450+ return Kill{}, err
451+ }
452+ }
440453 if err := r.done(); err != nil {
441454 return Kill{}, err
442455 }
443- return Kill{Name: name}, nil
456+ return Kill{Name: name, All: all != 0}, nil
444457 }
445458
446459 // MarshalSessions encodes m into a Sessions message payload: a uint16 count
@@ -101,9 +101,10 @@ type Detached struct {
101 // fields; the request is fully described by its message type.101 // fields; the request is fully described by its message type.
102 type List struct{}102 type List struct{}
103 103
104-// Kill asks the daemon to terminate the named session.104+// Kill asks the daemon to terminate one named session or all sessions.
105 type Kill struct {105 type Kill struct {
106 Name string106 Name string
107+ All bool
107 }108 }
108 109
109 // SessionInfo describes one session in a Sessions listing.110 // SessionInfo describes one session in a Sessions listing.
@@ -427,6 +428,11 @@ func UnmarshalList(b []byte) (List, error) {
427 func MarshalKill(m Kill) []byte {428 func MarshalKill(m Kill) []byte {
428 w := payloadWriter{}429 w := payloadWriter{}
429 w.putString(m.Name)430 w.putString(m.Name)
431+ if m.All {
432+ w.putUint8(1)
433+ } else {
434+ w.putUint8(0)
435+ }
430 return w.b436 return w.b
431 }437 }
432 438
@@ -437,10 +443,17 @@ func UnmarshalKill(b []byte) (Kill, error) {
437 if err != nil {443 if err != nil {
438 return Kill{}, err444 return Kill{}, err
439 }445 }
446+ var all uint8
447+ if len(r.b) > 0 {
448+ all, err = r.getUint8()
449+ if err != nil {
450+ return Kill{}, err
451+ }
452+ }
440 if err := r.done(); err != nil {453 if err := r.done(); err != nil {
441 return Kill{}, err454 return Kill{}, err
442 }455 }
443- return Kill{Name: name}, nil456+ return Kill{Name: name, All: all != 0}, nil
444 }457 }
445 458
446 // MarshalSessions encodes m into a Sessions message payload: a uint16 count459 // MarshalSessions encodes m into a Sessions message payload: a uint16 count
M internal/ports/wire_test.go +12 -3
@@ -319,8 +319,9 @@ func TestKillGoldenAndRoundTrip(t *testing.T) {
319319 msg Kill
320320 want []byte
321321 }{
322- {name: "named", msg: Kill{Name: "main"}, want: []byte{0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e}},
323- {name: "empty", msg: Kill{Name: ""}, want: []byte{0x00, 0x00}},
322+ {name: "named", msg: Kill{Name: "main"}, want: []byte{0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x00}},
323+ {name: "empty", msg: Kill{Name: ""}, want: []byte{0x00, 0x00, 0x00}},
324+ {name: "all", msg: Kill{All: true}, want: []byte{0x00, 0x00, 0x01}},
324325 }
325326
326327 for _, tt := range tests {
@@ -340,7 +341,15 @@ func TestKillGoldenAndRoundTrip(t *testing.T) {
340341 }
341342
342343 full := MarshalKill(tests[0].msg)
343- assertAllPrefixesFail(t, full, UnmarshalKill)
344+ legacy := full[:len(full)-1]
345+ back, err := UnmarshalKill(legacy)
346+ if err != nil {
347+ t.Fatalf("UnmarshalKill(legacy) error = %v", err)
348+ }
349+ if !reflect.DeepEqual(back, Kill{Name: "main"}) {
350+ t.Fatalf("legacy round trip = %#v, want named kill", back)
351+ }
352+ assertAllPrefixesFail(t, legacy, UnmarshalKill)
344353 assertTrailingGarbageFails(t, full, UnmarshalKill)
345354 }
346355
@@ -319,8 +319,9 @@ func TestKillGoldenAndRoundTrip(t *testing.T) {
319 msg Kill319 msg Kill
320 want []byte320 want []byte
321 }{321 }{
322- {name: "named", msg: Kill{Name: "main"}, want: []byte{0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e}},322+ {name: "named", msg: Kill{Name: "main"}, want: []byte{0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x00}},
323- {name: "empty", msg: Kill{Name: ""}, want: []byte{0x00, 0x00}},323+ {name: "empty", msg: Kill{Name: ""}, want: []byte{0x00, 0x00, 0x00}},
324+ {name: "all", msg: Kill{All: true}, want: []byte{0x00, 0x00, 0x01}},
324 }325 }
325 326
326 for _, tt := range tests {327 for _, tt := range tests {
@@ -340,7 +341,15 @@ func TestKillGoldenAndRoundTrip(t *testing.T) {
340 }341 }
341 342
342 full := MarshalKill(tests[0].msg)343 full := MarshalKill(tests[0].msg)
343- assertAllPrefixesFail(t, full, UnmarshalKill)344+ legacy := full[:len(full)-1]
345+ back, err := UnmarshalKill(legacy)
346+ if err != nil {
347+ t.Fatalf("UnmarshalKill(legacy) error = %v", err)
348+ }
349+ if !reflect.DeepEqual(back, Kill{Name: "main"}) {
350+ t.Fatalf("legacy round trip = %#v, want named kill", back)
351+ }
352+ assertAllPrefixesFail(t, legacy, UnmarshalKill)
344 assertTrailingGarbageFails(t, full, UnmarshalKill)353 assertTrailingGarbageFails(t, full, UnmarshalKill)
345 }354 }
346 355
M internal/usecase/daemon/daemon.go +12 -2
@@ -389,7 +389,12 @@ func (d *Daemon) shutdownAll(reason uint8) {
389389 for _, s := range d.sessions {
390390 snapshot = append(snapshot, s)
391391 }
392+ empty := len(snapshot) == 0
392393 d.mu.Unlock()
394+ if empty {
395+ d.doneOnce.Do(func() { close(d.done) })
396+ return
397+ }
393398 for _, s := range snapshot {
394399 d.killSession(s, reason)
395400 }
@@ -445,8 +450,9 @@ func (d *Daemon) handleList(tr ports.Transport) {
445450 _ = tr.Send(frameSessions(infos))
446451 }
447452
448-// handleKill terminates the named session (if any) and closes the control
449-// connection; the resulting EOF is the client's success signal.
453+// handleKill terminates the named session (if any), or all sessions, and
454+// closes the control connection; the resulting EOF is the client's success
455+// signal.
450456 func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
451457 defer func() { _ = tr.Close() }()
452458
@@ -455,6 +461,10 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
455461 _ = tr.Send(frameError(ports.ErrInternal, "malformed kill request"))
456462 return
457463 }
464+ if k.All {
465+ d.shutdownAll(ports.ReasonServerShutdown)
466+ return
467+ }
458468
459469 d.mu.Lock()
460470 target := d.findByNameLocked(k.Name)
@@ -389,7 +389,12 @@ func (d *Daemon) shutdownAll(reason uint8) {
389 for _, s := range d.sessions {389 for _, s := range d.sessions {
390 snapshot = append(snapshot, s)390 snapshot = append(snapshot, s)
391 }391 }
392+ empty := len(snapshot) == 0
392 d.mu.Unlock()393 d.mu.Unlock()
394+ if empty {
395+ d.doneOnce.Do(func() { close(d.done) })
396+ return
397+ }
393 for _, s := range snapshot {398 for _, s := range snapshot {
394 d.killSession(s, reason)399 d.killSession(s, reason)
395 }400 }
@@ -445,8 +450,9 @@ func (d *Daemon) handleList(tr ports.Transport) {
445 _ = tr.Send(frameSessions(infos))450 _ = tr.Send(frameSessions(infos))
446 }451 }
447 452
448-// handleKill terminates the named session (if any) and closes the control453+// handleKill terminates the named session (if any), or all sessions, and
449-// connection; the resulting EOF is the client's success signal.454+// closes the control connection; the resulting EOF is the client's success
455+// signal.
450 func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {456 func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
451 defer func() { _ = tr.Close() }()457 defer func() { _ = tr.Close() }()
452 458
@@ -455,6 +461,10 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
455 _ = tr.Send(frameError(ports.ErrInternal, "malformed kill request"))461 _ = tr.Send(frameError(ports.ErrInternal, "malformed kill request"))
456 return462 return
457 }463 }
464+ if k.All {
465+ d.shutdownAll(ports.ReasonServerShutdown)
466+ return
467+ }
458 468
459 d.mu.Lock()469 d.mu.Lock()
460 target := d.findByNameLocked(k.Name)470 target := d.findByNameLocked(k.Name)
M internal/usecase/daemon/daemon_test.go +12 -0
@@ -699,6 +699,18 @@ func TestHandshakeNoSuchSession(t *testing.T) {
699699 require.Equal(t, ports.ErrNoSuchSession, em.Code)
700700 }
701701
702+func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) {
703+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
704+ tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})})
705+ d.handleConn(tr)
706+
707+ select {
708+ case <-d.done:
709+ case <-time.After(time.Second):
710+ t.Fatal("kill all on empty daemon did not signal shutdown")
711+ }
712+}
713+
702714 // --- ephemeral numbering ----------------------------------------------------
703715
704716 func TestEphemeralNumberingReuse(t *testing.T) {
@@ -699,6 +699,18 @@ func TestHandshakeNoSuchSession(t *testing.T) {
699 require.Equal(t, ports.ErrNoSuchSession, em.Code)699 require.Equal(t, ports.ErrNoSuchSession, em.Code)
700 }700 }
701 701
702+func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) {
703+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
704+ tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})})
705+ d.handleConn(tr)
706+
707+ select {
708+ case <-d.done:
709+ case <-time.After(time.Second):
710+ t.Fatal("kill all on empty daemon did not signal shutdown")
711+ }
712+}
713+
702 // --- ephemeral numbering ----------------------------------------------------714 // --- ephemeral numbering ----------------------------------------------------
703 715
704 func TestEphemeralNumberingReuse(t *testing.T) {716 func TestEphemeralNumberingReuse(t *testing.T) {

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