feat(kill): add all and daemon flags
- commit
- cd05aa6e9a78e6e7414371225c580cf426792f98
- author
- brice · · 2026-07-03 05:38 UTC
- parent
- cf5ca03
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) | |||
| 19 | 19 | vev attach user@host[:session] attach to a remote vev daemon over SSH | |
| 20 | 20 | vev ls list sessions | |
| 21 | 21 | vev kill <name> kill a named session | |
| 22 | + | vev kill --all kill all sessions | |
| 23 | + | vev kill --daemon kill the active vev daemon | |
| 22 | 24 | vev --help show help | |
| 23 | 25 | vev --version show version | |
| 24 | 26 | ``` | |
| @@ -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 SSH | 19 | vev attach user@host[:session] attach to a remote vev daemon over SSH | ||
| 20 | vev ls list sessions | 20 | vev ls list sessions | ||
| 21 | vev kill <name> kill a named session | 21 | 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 help | 24 | vev --help show help | ||
| 23 | vev --version show version | 25 | 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) { | |||
| 185 | 185 | defer func() { _ = tr2.Close() }() | |
| 186 | 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 | + | } | |
| @@ -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 { | |||
| 47 | 47 | intent uint8 | |
| 48 | 48 | name string | |
| 49 | 49 | remoteTarget string | |
| 50 | + | killAll bool | |
| 51 | + | killDaemon bool | |
| 50 | 52 | } | |
| 51 | 53 | ||
| 52 | 54 | // usageError is a user-facing argument error; the app prints it (with usage) | |
| @@ -69,6 +71,8 @@ usage: | |||
| 69 | 71 | attach through SSH to a remote vev daemon | |
| 70 | 72 | vev ls list sessions | |
| 71 | 73 | vev kill <name> kill a named session | |
| 74 | + | vev kill --all kill all sessions | |
| 75 | + | vev kill --daemon kill the active vev daemon | |
| 72 | 76 | vev --help show this help | |
| 73 | 77 | vev --version show version` | |
| 74 | 78 | ||
| @@ -134,12 +138,19 @@ func parseArgs(args []string) (command, error) { | |||
| 134 | 138 | return command{kind: kindList}, nil | |
| 135 | 139 | case "kill": | |
| 136 | 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 | 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 | 154 | case "-h", "--help", "help": | |
| 144 | 155 | return command{kind: kindHelp}, nil | |
| 145 | 156 | case "--version", "version": | |
| @@ -165,7 +176,7 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 165 | 176 | case kindList: | |
| 166 | 177 | return runList(ctx) | |
| 167 | 178 | case kindKill: | |
| 168 | - | return runKill(ctx, cmd.name) | |
| 179 | + | return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon) | |
| 169 | 180 | case kindAttach: | |
| 170 | 181 | return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget) | |
| 171 | 182 | default: | |
| @@ -328,15 +339,15 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||
| 328 | 339 | _ = tw.Flush() | |
| 329 | 340 | } | |
| 330 | 341 | ||
| 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 { | |
| 333 | 344 | transport, err := realDial(ipc.SocketDir()) | |
| 334 | 345 | if err != nil { | |
| 335 | 346 | return fmt.Errorf("vev: no daemon running") | |
| 336 | 347 | } | |
| 337 | 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 | 351 | return fmt.Errorf("vev: requesting kill: %w", err) | |
| 341 | 352 | } | |
| 342 | 353 | reply, err := transport.Recv() | |
| @@ -344,7 +355,7 @@ func runKill(_ context.Context, name string) error { | |||
| 344 | 355 | // The daemon may close the connection after killing; treat a clean | |
| 345 | 356 | // EOF as success. | |
| 346 | 357 | if errors.Is(err, io.EOF) { | |
| 347 | - | fmt.Printf("killed %s\n", name) | |
| 358 | + | printKillSuccess(name, all, daemon) | |
| 348 | 359 | return nil | |
| 349 | 360 | } | |
| 350 | 361 | return fmt.Errorf("vev: reading kill reply: %w", err) | |
| @@ -353,6 +364,18 @@ func runKill(_ context.Context, name string) error { | |||
| 353 | 364 | em, _ := ports.UnmarshalErrorMsg(reply.Payload) | |
| 354 | 365 | return fmt.Errorf("vev: %s", em.Text) | |
| 355 | 366 | } | |
| 356 | - | fmt.Printf("killed %s\n", name) | |
| 367 | + | printKillSuccess(name, all, daemon) | |
| 357 | 368 | 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 | + | } | |
| @@ -47,6 +47,8 @@ type command struct { | |||||
| 47 | intent uint8 | 47 | intent uint8 | ||
| 48 | name string | 48 | name string | ||
| 49 | remoteTarget string | 49 | 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 daemon | 71 | attach through SSH to a remote vev daemon | ||
| 70 | vev ls list sessions | 72 | vev ls list sessions | ||
| 71 | vev kill <name> kill a named session | 73 | 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 help | 76 | 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}, nil | 138 | 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}, nil | 155 | 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 clean | 355 | // 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 nil | 359 | 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 nil | 368 | 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) { | |||
| 15 | 15 | wantIntent uint8 | |
| 16 | 16 | wantName string | |
| 17 | 17 | wantRemote string | |
| 18 | + | wantAll bool | |
| 19 | + | wantDaemon bool | |
| 18 | 20 | wantErr bool | |
| 19 | 21 | }{ | |
| 20 | 22 | {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral}, | |
| @@ -33,7 +35,11 @@ func TestParseArgs(t *testing.T) { | |||
| 33 | 35 | {name: "ls", args: []string{"ls"}, wantKind: kindList}, | |
| 34 | 36 | {name: "list", args: []string{"list"}, wantKind: kindList}, | |
| 35 | 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 | 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 | 43 | {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true}, | |
| 38 | 44 | {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon}, | |
| 39 | 45 | {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio}, | |
| @@ -73,6 +79,12 @@ func TestParseArgs(t *testing.T) { | |||
| 73 | 79 | if got.remoteTarget != tt.wantRemote { | |
| 74 | 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 | } | |
| @@ -15,6 +15,8 @@ func TestParseArgs(t *testing.T) { | |||||
| 15 | wantIntent uint8 | 15 | wantIntent uint8 | ||
| 16 | wantName string | 16 | wantName string | ||
| 17 | wantRemote string | 17 | wantRemote string | ||
| 18 | + | wantAll bool | |||
| 19 | + | wantDaemon bool | |||
| 18 | wantErr bool | 20 | 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 { | |||
| 101 | 101 | // fields; the request is fully described by its message type. | |
| 102 | 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 | 105 | type Kill struct { | |
| 106 | 106 | Name string | |
| 107 | + | All bool | |
| 107 | 108 | } | |
| 108 | 109 | ||
| 109 | 110 | // SessionInfo describes one session in a Sessions listing. | |
| @@ -427,6 +428,11 @@ func UnmarshalList(b []byte) (List, error) { | |||
| 427 | 428 | func MarshalKill(m Kill) []byte { | |
| 428 | 429 | w := payloadWriter{} | |
| 429 | 430 | w.putString(m.Name) | |
| 431 | + | if m.All { | |
| 432 | + | w.putUint8(1) | |
| 433 | + | } else { | |
| 434 | + | w.putUint8(0) | |
| 435 | + | } | |
| 430 | 436 | return w.b | |
| 431 | 437 | } | |
| 432 | 438 | ||
| @@ -437,10 +443,17 @@ func UnmarshalKill(b []byte) (Kill, error) { | |||
| 437 | 443 | if err != nil { | |
| 438 | 444 | 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 | 453 | if err := r.done(); err != nil { | |
| 441 | 454 | return Kill{}, err | |
| 442 | 455 | } | |
| 443 | - | return Kill{Name: name}, nil | |
| 456 | + | return Kill{Name: name, All: all != 0}, nil | |
| 444 | 457 | } | |
| 445 | 458 | ||
| 446 | 459 | // 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 string | 106 | 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.b | 436 | 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{}, err | 444 | 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{}, err | 454 | return Kill{}, err | ||
| 442 | } | 455 | } | ||
| 443 | - | return Kill{Name: name}, nil | 456 | + | return Kill{Name: name, All: all != 0}, nil |
| 444 | } | 457 | } | ||
| 445 | 458 | ||||
| 446 | // MarshalSessions encodes m into a Sessions message payload: a uint16 count | 459 | // 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) { | |||
| 319 | 319 | msg Kill | |
| 320 | 320 | want []byte | |
| 321 | 321 | }{ | |
| 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}}, | |
| 324 | 325 | } | |
| 325 | 326 | ||
| 326 | 327 | for _, tt := range tests { | |
| @@ -340,7 +341,15 @@ func TestKillGoldenAndRoundTrip(t *testing.T) { | |||
| 340 | 341 | } | |
| 341 | 342 | ||
| 342 | 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 | 353 | assertTrailingGarbageFails(t, full, UnmarshalKill) | |
| 345 | 354 | } | |
| 346 | 355 | ||
| @@ -319,8 +319,9 @@ func TestKillGoldenAndRoundTrip(t *testing.T) { | |||||
| 319 | msg Kill | 319 | msg Kill | ||
| 320 | want []byte | 320 | 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) { | |||
| 389 | 389 | for _, s := range d.sessions { | |
| 390 | 390 | snapshot = append(snapshot, s) | |
| 391 | 391 | } | |
| 392 | + | empty := len(snapshot) == 0 | |
| 392 | 393 | d.mu.Unlock() | |
| 394 | + | if empty { | |
| 395 | + | d.doneOnce.Do(func() { close(d.done) }) | |
| 396 | + | return | |
| 397 | + | } | |
| 393 | 398 | for _, s := range snapshot { | |
| 394 | 399 | d.killSession(s, reason) | |
| 395 | 400 | } | |
| @@ -445,8 +450,9 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||
| 445 | 450 | _ = tr.Send(frameSessions(infos)) | |
| 446 | 451 | } | |
| 447 | 452 | ||
| 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. | |
| 450 | 456 | func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |
| 451 | 457 | defer func() { _ = tr.Close() }() | |
| 452 | 458 | ||
| @@ -455,6 +461,10 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |||
| 455 | 461 | _ = tr.Send(frameError(ports.ErrInternal, "malformed kill request")) | |
| 456 | 462 | return | |
| 457 | 463 | } | |
| 464 | + | if k.All { | |
| 465 | + | d.shutdownAll(ports.ReasonServerShutdown) | |
| 466 | + | return | |
| 467 | + | } | |
| 458 | 468 | ||
| 459 | 469 | d.mu.Lock() | |
| 460 | 470 | 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 control | 453 | + | // 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 | return | 462 | 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) { | |||
| 699 | 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 | 714 | // --- ephemeral numbering ---------------------------------------------------- | |
| 703 | 715 | ||
| 704 | 716 | 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) { | ||