detached c2f37ec

Merge pull request #3 from bnema/feat/kill-all-daemon

feat(kill): add all and daemon flags
commit
c2f37ec9ff15d2db34526ffe038043b4e0c5a7c8
author
bnema · · 2026-07-03 07:28 UTC
committer
GitHub · · 2026-07-03 07:28 UTC
merge of
2889147 + 6489acf · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
8 files · +126 -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 and stop the daemon
23+vev kill --daemon stop 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 and stop the daemon
23+vev kill --daemon stop 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 +24 -0
@@ -185,3 +185,27 @@ 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+ defer func() { _ = killTr.Close() }()
201+ require.NoError(t, killTr.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}))
202+
203+ _, err = killTr.Recv()
204+ require.ErrorIs(t, err, io.EOF)
205+ select {
206+ case err := <-served:
207+ require.NoError(t, err)
208+ case <-time.After(5 * time.Second):
209+ t.Fatal("daemon did not shut down after kill all")
210+ }
211+}
@@ -185,3 +185,27 @@ 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+ defer func() { _ = killTr.Close() }()
201+ require.NoError(t, killTr.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}))
202+
203+ _, err = killTr.Recv()
204+ require.ErrorIs(t, err, io.EOF)
205+ select {
206+ case err := <-served:
207+ require.NoError(t, err)
208+ case <-time.After(5 * time.Second):
209+ t.Fatal("daemon did not shut down after kill all")
210+ }
211+}
M internal/app/run.go +38 -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 and stop the daemon
75+ vev kill --daemon stop the active vev daemon
7276 vev --help show this help
7377 vev --version show version`
7478
@@ -134,12 +138,25 @@ 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")
142+ }
143+ if args[1] == "--" {
144+ if len(args) != 3 || args[2] == "" {
145+ return command{}, usagef("`kill --` requires a session name")
146+ }
147+ return command{kind: kindKill, name: args[2]}, nil
138148 }
139149 if len(args) > 2 {
140- return command{}, usagef("`kill` accepts exactly one session name")
150+ return command{}, usagef("`kill` accepts exactly one session name, --all, or --daemon")
151+ }
152+ switch args[1] {
153+ case "--all":
154+ return command{kind: kindKill, killAll: true}, nil
155+ case "--daemon":
156+ return command{kind: kindKill, killDaemon: true}, nil
157+ default:
158+ return command{kind: kindKill, name: args[1]}, nil
141159 }
142- return command{kind: kindKill, name: args[1]}, nil
143160 case "-h", "--help", "help":
144161 return command{kind: kindHelp}, nil
145162 case "--version", "version":
@@ -165,7 +182,7 @@ func dispatch(ctx context.Context, cmd command) error {
165182 case kindList:
166183 return runList(ctx)
167184 case kindKill:
168- return runKill(ctx, cmd.name)
185+ return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon)
169186 case kindAttach:
170187 return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget)
171188 default:
@@ -332,15 +349,15 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
332349 _ = tw.Flush()
333350 }
334351
335-// runKill asks the daemon to terminate a named session.
336-func runKill(_ context.Context, name string) error {
352+// runKill asks the daemon to terminate a named session, every session, or the daemon.
353+func runKill(_ context.Context, name string, all, daemon bool) error {
337354 transport, err := realDial(ipc.SocketDir())
338355 if err != nil {
339356 return fmt.Errorf("vev: no daemon running")
340357 }
341358 defer func() { _ = transport.Close() }()
342359
343- if err := transport.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: name})}); err != nil {
360+ if err := transport.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: name, All: all || daemon})}); err != nil {
344361 return fmt.Errorf("vev: requesting kill: %w", err)
345362 }
346363 reply, err := transport.Recv()
@@ -348,7 +365,7 @@ func runKill(_ context.Context, name string) error {
348365 // The daemon may close the connection after killing; treat a clean
349366 // EOF as success.
350367 if errors.Is(err, io.EOF) {
351- fmt.Printf("killed %s\n", name)
368+ printKillSuccess(name, all, daemon)
352369 return nil
353370 }
354371 return fmt.Errorf("vev: reading kill reply: %w", err)
@@ -357,6 +374,18 @@ func runKill(_ context.Context, name string) error {
357374 em, _ := ports.UnmarshalErrorMsg(reply.Payload)
358375 return fmt.Errorf("vev: %s", em.Text)
359376 }
360- fmt.Printf("killed %s\n", name)
377+ printKillSuccess(name, all, daemon)
361378 return nil
362379 }
380+
381+func printKillSuccess(name string, all, daemon bool) {
382+ if daemon {
383+ fmt.Println("killed daemon")
384+ return
385+ }
386+ if all {
387+ fmt.Println("killed all sessions and stopped daemon")
388+ return
389+ }
390+ fmt.Printf("killed %s\n", name)
391+}
@@ -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 and stop the daemon
75+ vev kill --daemon stop 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,25 @@ 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")
142+ }
143+ if args[1] == "--" {
144+ if len(args) != 3 || args[2] == "" {
145+ return command{}, usagef("`kill --` requires a session name")
146+ }
147+ return command{kind: kindKill, name: args[2]}, nil
138 }148 }
139 if len(args) > 2 {149 if len(args) > 2 {
140- return command{}, usagef("`kill` accepts exactly one session name")150+ return command{}, usagef("`kill` accepts exactly one session name, --all, or --daemon")
151+ }
152+ switch args[1] {
153+ case "--all":
154+ return command{kind: kindKill, killAll: true}, nil
155+ case "--daemon":
156+ return command{kind: kindKill, killDaemon: true}, nil
157+ default:
158+ return command{kind: kindKill, name: args[1]}, nil
141 }159 }
142- return command{kind: kindKill, name: args[1]}, nil
143 case "-h", "--help", "help":160 case "-h", "--help", "help":
144 return command{kind: kindHelp}, nil161 return command{kind: kindHelp}, nil
145 case "--version", "version":162 case "--version", "version":
@@ -165,7 +182,7 @@ func dispatch(ctx context.Context, cmd command) error {
165 case kindList:182 case kindList:
166 return runList(ctx)183 return runList(ctx)
167 case kindKill:184 case kindKill:
168- return runKill(ctx, cmd.name)185+ return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon)
169 case kindAttach:186 case kindAttach:
170 return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget)187 return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget)
171 default:188 default:
@@ -332,15 +349,15 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
332 _ = tw.Flush()349 _ = tw.Flush()
333 }350 }
334 351
335-// runKill asks the daemon to terminate a named session.352+// runKill asks the daemon to terminate a named session, every session, or the daemon.
336-func runKill(_ context.Context, name string) error {353+func runKill(_ context.Context, name string, all, daemon bool) error {
337 transport, err := realDial(ipc.SocketDir())354 transport, err := realDial(ipc.SocketDir())
338 if err != nil {355 if err != nil {
339 return fmt.Errorf("vev: no daemon running")356 return fmt.Errorf("vev: no daemon running")
340 }357 }
341 defer func() { _ = transport.Close() }()358 defer func() { _ = transport.Close() }()
342 359
343- if err := transport.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: name})}); err != nil {360+ if err := transport.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: name, All: all || daemon})}); err != nil {
344 return fmt.Errorf("vev: requesting kill: %w", err)361 return fmt.Errorf("vev: requesting kill: %w", err)
345 }362 }
346 reply, err := transport.Recv()363 reply, err := transport.Recv()
@@ -348,7 +365,7 @@ func runKill(_ context.Context, name string) error {
348 // The daemon may close the connection after killing; treat a clean365 // The daemon may close the connection after killing; treat a clean
349 // EOF as success.366 // EOF as success.
350 if errors.Is(err, io.EOF) {367 if errors.Is(err, io.EOF) {
351- fmt.Printf("killed %s\n", name)368+ printKillSuccess(name, all, daemon)
352 return nil369 return nil
353 }370 }
354 return fmt.Errorf("vev: reading kill reply: %w", err)371 return fmt.Errorf("vev: reading kill reply: %w", err)
@@ -357,6 +374,18 @@ func runKill(_ context.Context, name string) error {
357 em, _ := ports.UnmarshalErrorMsg(reply.Payload)374 em, _ := ports.UnmarshalErrorMsg(reply.Payload)
358 return fmt.Errorf("vev: %s", em.Text)375 return fmt.Errorf("vev: %s", em.Text)
359 }376 }
360- fmt.Printf("killed %s\n", name)377+ printKillSuccess(name, all, daemon)
361 return nil378 return nil
362 }379 }
380+
381+func printKillSuccess(name string, all, daemon bool) {
382+ if daemon {
383+ fmt.Println("killed daemon")
384+ return
385+ }
386+ if all {
387+ fmt.Println("killed all sessions and stopped daemon")
388+ return
389+ }
390+ fmt.Printf("killed %s\n", name)
391+}
M internal/app/run_test.go +14 -0
@@ -17,6 +17,8 @@ func TestParseArgs(t *testing.T) {
1717 wantIntent uint8
1818 wantName string
1919 wantRemote string
20+ wantAll bool
21+ wantDaemon bool
2022 wantErr bool
2123 }{
2224 {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},
@@ -35,7 +37,13 @@ func TestParseArgs(t *testing.T) {
3537 {name: "ls", args: []string{"ls"}, wantKind: kindList},
3638 {name: "list", args: []string{"list"}, wantKind: kindList},
3739 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
40+ {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},
41+ {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},
42+ {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true},
3843 {name: "kill without name", args: []string{"kill"}, wantErr: true},
44+ {name: "kill terminator without name", args: []string{"kill", "--"}, wantErr: true},
45+ {name: "kill all rejects extra arg", args: []string{"kill", "--all", "extra"}, wantErr: true},
46+ {name: "kill daemon rejects extra arg", args: []string{"kill", "--daemon", "extra"}, wantErr: true},
3947 {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},
4048 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
4149 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
@@ -75,6 +83,12 @@ func TestParseArgs(t *testing.T) {
7583 if got.remoteTarget != tt.wantRemote {
7684 t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote)
7785 }
86+ if got.killAll != tt.wantAll {
87+ t.Errorf("killAll = %v, want %v", got.killAll, tt.wantAll)
88+ }
89+ if got.killDaemon != tt.wantDaemon {
90+ t.Errorf("killDaemon = %v, want %v", got.killDaemon, tt.wantDaemon)
91+ }
7892 })
7993 }
8094 }
@@ -17,6 +17,8 @@ func TestParseArgs(t *testing.T) {
17 wantIntent uint817 wantIntent uint8
18 wantName string18 wantName string
19 wantRemote string19 wantRemote string
20+ wantAll bool
21+ wantDaemon bool
20 wantErr bool22 wantErr bool
21 }{23 }{
22 {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},24 {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},
@@ -35,7 +37,13 @@ func TestParseArgs(t *testing.T) {
35 {name: "ls", args: []string{"ls"}, wantKind: kindList},37 {name: "ls", args: []string{"ls"}, wantKind: kindList},
36 {name: "list", args: []string{"list"}, wantKind: kindList},38 {name: "list", args: []string{"list"}, wantKind: kindList},
37 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},39 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
40+ {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},
41+ {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},
42+ {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true},
38 {name: "kill without name", args: []string{"kill"}, wantErr: true},43 {name: "kill without name", args: []string{"kill"}, wantErr: true},
44+ {name: "kill terminator without name", args: []string{"kill", "--"}, wantErr: true},
45+ {name: "kill all rejects extra arg", args: []string{"kill", "--all", "extra"}, wantErr: true},
46+ {name: "kill daemon rejects extra arg", args: []string{"kill", "--daemon", "extra"}, wantErr: true},
39 {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},47 {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},
40 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},48 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
41 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},49 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
@@ -75,6 +83,12 @@ func TestParseArgs(t *testing.T) {
75 if got.remoteTarget != tt.wantRemote {83 if got.remoteTarget != tt.wantRemote {
76 t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote)84 t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote)
77 }85 }
86+ if got.killAll != tt.wantAll {
87+ t.Errorf("killAll = %v, want %v", got.killAll, tt.wantAll)
88+ }
89+ if got.killDaemon != tt.wantDaemon {
90+ t.Errorf("killDaemon = %v, want %v", got.killDaemon, tt.wantDaemon)
91+ }
78 })92 })
79 }93 }
80 }94 }
M internal/ports/wire.go +13 -2
@@ -102,9 +102,10 @@ type Detached struct {
102102 // fields; the request is fully described by its message type.
103103 type List struct{}
104104
105-// Kill asks the daemon to terminate the named session.
105+// Kill asks the daemon to terminate one named session or all sessions.
106106 type Kill struct {
107107 Name string
108+ All bool
108109 }
109110
110111 // SessionInfo describes one session in a Sessions listing.
@@ -440,6 +441,9 @@ func UnmarshalList(b []byte) (List, error) {
440441 func MarshalKill(m Kill) []byte {
441442 w := payloadWriter{}
442443 w.putString(m.Name)
444+ if m.All {
445+ w.putUint8(1)
446+ }
443447 return w.b
444448 }
445449
@@ -450,10 +454,17 @@ func UnmarshalKill(b []byte) (Kill, error) {
450454 if err != nil {
451455 return Kill{}, err
452456 }
457+ var all uint8
458+ if len(r.b) > 0 {
459+ all, err = r.getUint8()
460+ if err != nil {
461+ return Kill{}, err
462+ }
463+ }
453464 if err := r.done(); err != nil {
454465 return Kill{}, err
455466 }
456- return Kill{Name: name}, nil
467+ return Kill{Name: name, All: all != 0}, nil
457468 }
458469
459470 // MarshalSessions encodes m into a Sessions message payload: a uint16 count
@@ -102,9 +102,10 @@ type Detached struct {
102 // fields; the request is fully described by its message type.102 // fields; the request is fully described by its message type.
103 type List struct{}103 type List struct{}
104 104
105-// Kill asks the daemon to terminate the named session.105+// Kill asks the daemon to terminate one named session or all sessions.
106 type Kill struct {106 type Kill struct {
107 Name string107 Name string
108+ All bool
108 }109 }
109 110
110 // SessionInfo describes one session in a Sessions listing.111 // SessionInfo describes one session in a Sessions listing.
@@ -440,6 +441,9 @@ func UnmarshalList(b []byte) (List, error) {
440 func MarshalKill(m Kill) []byte {441 func MarshalKill(m Kill) []byte {
441 w := payloadWriter{}442 w := payloadWriter{}
442 w.putString(m.Name)443 w.putString(m.Name)
444+ if m.All {
445+ w.putUint8(1)
446+ }
443 return w.b447 return w.b
444 }448 }
445 449
@@ -450,10 +454,17 @@ func UnmarshalKill(b []byte) (Kill, error) {
450 if err != nil {454 if err != nil {
451 return Kill{}, err455 return Kill{}, err
452 }456 }
457+ var all uint8
458+ if len(r.b) > 0 {
459+ all, err = r.getUint8()
460+ if err != nil {
461+ return Kill{}, err
462+ }
463+ }
453 if err := r.done(); err != nil {464 if err := r.done(); err != nil {
454 return Kill{}, err465 return Kill{}, err
455 }466 }
456- return Kill{Name: name}, nil467+ return Kill{Name: name, All: all != 0}, nil
457 }468 }
458 469
459 // MarshalSessions encodes m into a Sessions message payload: a uint16 count470 // MarshalSessions encodes m into a Sessions message payload: a uint16 count
M internal/ports/wire_test.go +11 -3
@@ -323,6 +323,7 @@ func TestKillGoldenAndRoundTrip(t *testing.T) {
323323 }{
324324 {name: "named", msg: Kill{Name: "main"}, want: []byte{0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e}},
325325 {name: "empty", msg: Kill{Name: ""}, want: []byte{0x00, 0x00}},
326+ {name: "all", msg: Kill{All: true}, want: []byte{0x00, 0x00, 0x01}},
326327 }
327328
328329 for _, tt := range tests {
@@ -341,9 +342,16 @@ func TestKillGoldenAndRoundTrip(t *testing.T) {
341342 })
342343 }
343344
344- full := MarshalKill(tests[0].msg)
345- assertAllPrefixesFail(t, full, UnmarshalKill)
346- assertTrailingGarbageFails(t, full, UnmarshalKill)
345+ legacy := MarshalKill(tests[0].msg)
346+ back, err := UnmarshalKill(legacy)
347+ if err != nil {
348+ t.Fatalf("UnmarshalKill(legacy) error = %v", err)
349+ }
350+ if !reflect.DeepEqual(back, Kill{Name: "main"}) {
351+ t.Fatalf("legacy round trip = %#v, want named kill", back)
352+ }
353+ assertAllPrefixesFail(t, legacy, UnmarshalKill)
354+ assertTrailingGarbageFails(t, append(append([]byte(nil), legacy...), 0x00), UnmarshalKill)
347355 }
348356
349357 func TestSessionsGoldenAndRoundTrip(t *testing.T) {
@@ -323,6 +323,7 @@ func TestKillGoldenAndRoundTrip(t *testing.T) {
323 }{323 }{
324 {name: "named", msg: Kill{Name: "main"}, want: []byte{0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e}},324 {name: "named", msg: Kill{Name: "main"}, want: []byte{0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e}},
325 {name: "empty", msg: Kill{Name: ""}, want: []byte{0x00, 0x00}},325 {name: "empty", msg: Kill{Name: ""}, want: []byte{0x00, 0x00}},
326+ {name: "all", msg: Kill{All: true}, want: []byte{0x00, 0x00, 0x01}},
326 }327 }
327 328
328 for _, tt := range tests {329 for _, tt := range tests {
@@ -341,9 +342,16 @@ func TestKillGoldenAndRoundTrip(t *testing.T) {
341 })342 })
342 }343 }
343 344
344- full := MarshalKill(tests[0].msg)345+ legacy := MarshalKill(tests[0].msg)
345- assertAllPrefixesFail(t, full, UnmarshalKill)346+ back, err := UnmarshalKill(legacy)
346- assertTrailingGarbageFails(t, full, UnmarshalKill)347+ if err != nil {
348+ t.Fatalf("UnmarshalKill(legacy) error = %v", err)
349+ }
350+ if !reflect.DeepEqual(back, Kill{Name: "main"}) {
351+ t.Fatalf("legacy round trip = %#v, want named kill", back)
352+ }
353+ assertAllPrefixesFail(t, legacy, UnmarshalKill)
354+ assertTrailingGarbageFails(t, append(append([]byte(nil), legacy...), 0x00), UnmarshalKill)
347 }355 }
348 356
349 func TestSessionsGoldenAndRoundTrip(t *testing.T) {357 func TestSessionsGoldenAndRoundTrip(t *testing.T) {
M internal/usecase/daemon/daemon.go +12 -2
@@ -421,7 +421,12 @@ func (d *Daemon) shutdownAll(reason uint8) {
421421 for _, s := range d.sessions {
422422 snapshot = append(snapshot, s)
423423 }
424+ empty := len(snapshot) == 0
424425 d.mu.Unlock()
426+ if empty {
427+ d.doneOnce.Do(func() { close(d.done) })
428+ return
429+ }
425430 for _, s := range snapshot {
426431 d.killSession(s, reason)
427432 }
@@ -477,8 +482,9 @@ func (d *Daemon) handleList(tr ports.Transport) {
477482 _ = tr.Send(frameSessions(infos))
478483 }
479484
480-// handleKill terminates the named session (if any) and closes the control
481-// connection; the resulting EOF is the client's success signal.
485+// handleKill terminates the named session (if any), or all sessions, and
486+// closes the control connection; the resulting EOF is the client's success
487+// signal.
482488 func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
483489 defer func() { _ = tr.Close() }()
484490
@@ -487,6 +493,10 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
487493 _ = tr.Send(frameError(ports.ErrInternal, "malformed kill request"))
488494 return
489495 }
496+ if k.All {
497+ d.shutdownAll(ports.ReasonServerShutdown)
498+ return
499+ }
490500
491501 d.mu.Lock()
492502 target := d.findByNameLocked(k.Name)
@@ -421,7 +421,12 @@ func (d *Daemon) shutdownAll(reason uint8) {
421 for _, s := range d.sessions {421 for _, s := range d.sessions {
422 snapshot = append(snapshot, s)422 snapshot = append(snapshot, s)
423 }423 }
424+ empty := len(snapshot) == 0
424 d.mu.Unlock()425 d.mu.Unlock()
426+ if empty {
427+ d.doneOnce.Do(func() { close(d.done) })
428+ return
429+ }
425 for _, s := range snapshot {430 for _, s := range snapshot {
426 d.killSession(s, reason)431 d.killSession(s, reason)
427 }432 }
@@ -477,8 +482,9 @@ func (d *Daemon) handleList(tr ports.Transport) {
477 _ = tr.Send(frameSessions(infos))482 _ = tr.Send(frameSessions(infos))
478 }483 }
479 484
480-// handleKill terminates the named session (if any) and closes the control485+// handleKill terminates the named session (if any), or all sessions, and
481-// connection; the resulting EOF is the client's success signal.486+// closes the control connection; the resulting EOF is the client's success
487+// signal.
482 func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {488 func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
483 defer func() { _ = tr.Close() }()489 defer func() { _ = tr.Close() }()
484 490
@@ -487,6 +493,10 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
487 _ = tr.Send(frameError(ports.ErrInternal, "malformed kill request"))493 _ = tr.Send(frameError(ports.ErrInternal, "malformed kill request"))
488 return494 return
489 }495 }
496+ if k.All {
497+ d.shutdownAll(ports.ReasonServerShutdown)
498+ return
499+ }
490 500
491 d.mu.Lock()501 d.mu.Lock()
492 target := d.findByNameLocked(k.Name)502 target := d.findByNameLocked(k.Name)
M internal/usecase/daemon/daemon_test.go +12 -0
@@ -807,6 +807,18 @@ func TestHandshakeNoSuchSession(t *testing.T) {
807807 require.Equal(t, ports.ErrNoSuchSession, em.Code)
808808 }
809809
810+func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) {
811+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
812+ tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})})
813+ d.handleConn(tr)
814+
815+ select {
816+ case <-d.done:
817+ case <-time.After(time.Second):
818+ t.Fatal("kill all on empty daemon did not signal shutdown")
819+ }
820+}
821+
810822 // --- ephemeral numbering ----------------------------------------------------
811823
812824 func TestEphemeralNumberingReuse(t *testing.T) {
@@ -807,6 +807,18 @@ func TestHandshakeNoSuchSession(t *testing.T) {
807 require.Equal(t, ports.ErrNoSuchSession, em.Code)807 require.Equal(t, ports.ErrNoSuchSession, em.Code)
808 }808 }
809 809
810+func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) {
811+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
812+ tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})})
813+ d.handleConn(tr)
814+
815+ select {
816+ case <-d.done:
817+ case <-time.After(time.Second):
818+ t.Fatal("kill all on empty daemon did not signal shutdown")
819+ }
820+}
821+
810 // --- ephemeral numbering ----------------------------------------------------822 // --- ephemeral numbering ----------------------------------------------------
811 823
812 func TestEphemeralNumberingReuse(t *testing.T) {824 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