Merge pull request #3 from bnema/feat/kill-all-daemon
feat(kill): add all and daemon flags
8 files · +126 -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 and stop the daemon | |
| 23 | + | vev kill --daemon stop 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 and stop the daemon | |||
| 23 | + | vev kill --daemon stop 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 +24 -0
| @@ -185,3 +185,27 @@ 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 | + | 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 { | |||
| 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 and stop the daemon | |
| 75 | + | vev kill --daemon stop the active vev daemon | |
| 72 | 76 | vev --help show this help | |
| 73 | 77 | vev --version show version` | |
| 74 | 78 | ||
| @@ -134,12 +138,25 @@ 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") | |
| 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 | 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 | 160 | case "-h", "--help", "help": | |
| 144 | 161 | return command{kind: kindHelp}, nil | |
| 145 | 162 | case "--version", "version": | |
| @@ -165,7 +182,7 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 165 | 182 | case kindList: | |
| 166 | 183 | return runList(ctx) | |
| 167 | 184 | case kindKill: | |
| 168 | - | return runKill(ctx, cmd.name) | |
| 185 | + | return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon) | |
| 169 | 186 | case kindAttach: | |
| 170 | 187 | return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget) | |
| 171 | 188 | default: | |
| @@ -332,15 +349,15 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||
| 332 | 349 | _ = tw.Flush() | |
| 333 | 350 | } | |
| 334 | 351 | ||
| 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 { | |
| 337 | 354 | transport, err := realDial(ipc.SocketDir()) | |
| 338 | 355 | if err != nil { | |
| 339 | 356 | return fmt.Errorf("vev: no daemon running") | |
| 340 | 357 | } | |
| 341 | 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 | 361 | return fmt.Errorf("vev: requesting kill: %w", err) | |
| 345 | 362 | } | |
| 346 | 363 | reply, err := transport.Recv() | |
| @@ -348,7 +365,7 @@ func runKill(_ context.Context, name string) error { | |||
| 348 | 365 | // The daemon may close the connection after killing; treat a clean | |
| 349 | 366 | // EOF as success. | |
| 350 | 367 | if errors.Is(err, io.EOF) { | |
| 351 | - | fmt.Printf("killed %s\n", name) | |
| 368 | + | printKillSuccess(name, all, daemon) | |
| 352 | 369 | return nil | |
| 353 | 370 | } | |
| 354 | 371 | return fmt.Errorf("vev: reading kill reply: %w", err) | |
| @@ -357,6 +374,18 @@ func runKill(_ context.Context, name string) error { | |||
| 357 | 374 | em, _ := ports.UnmarshalErrorMsg(reply.Payload) | |
| 358 | 375 | return fmt.Errorf("vev: %s", em.Text) | |
| 359 | 376 | } | |
| 360 | - | fmt.Printf("killed %s\n", name) | |
| 377 | + | printKillSuccess(name, all, daemon) | |
| 361 | 378 | 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 | + | } | |
| @@ -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 and stop the daemon | |||
| 75 | + | vev kill --daemon stop 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,25 @@ 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") |
| 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}, nil | 161 | 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 clean | 365 | // 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 nil | 369 | 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 nil | 378 | 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) { | |||
| 17 | 17 | wantIntent uint8 | |
| 18 | 18 | wantName string | |
| 19 | 19 | wantRemote string | |
| 20 | + | wantAll bool | |
| 21 | + | wantDaemon bool | |
| 20 | 22 | wantErr bool | |
| 21 | 23 | }{ | |
| 22 | 24 | {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral}, | |
| @@ -35,7 +37,13 @@ func TestParseArgs(t *testing.T) { | |||
| 35 | 37 | {name: "ls", args: []string{"ls"}, wantKind: kindList}, | |
| 36 | 38 | {name: "list", args: []string{"list"}, wantKind: kindList}, | |
| 37 | 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 | 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 | 47 | {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true}, | |
| 40 | 48 | {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon}, | |
| 41 | 49 | {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio}, | |
| @@ -75,6 +83,12 @@ func TestParseArgs(t *testing.T) { | |||
| 75 | 83 | if got.remoteTarget != tt.wantRemote { | |
| 76 | 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 | } | |
| @@ -17,6 +17,8 @@ func TestParseArgs(t *testing.T) { | |||||
| 17 | wantIntent uint8 | 17 | wantIntent uint8 | ||
| 18 | wantName string | 18 | wantName string | ||
| 19 | wantRemote string | 19 | wantRemote string | ||
| 20 | + | wantAll bool | |||
| 21 | + | wantDaemon bool | |||
| 20 | wantErr bool | 22 | 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 { | |||
| 102 | 102 | // fields; the request is fully described by its message type. | |
| 103 | 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 | 106 | type Kill struct { | |
| 107 | 107 | Name string | |
| 108 | + | All bool | |
| 108 | 109 | } | |
| 109 | 110 | ||
| 110 | 111 | // SessionInfo describes one session in a Sessions listing. | |
| @@ -440,6 +441,9 @@ func UnmarshalList(b []byte) (List, error) { | |||
| 440 | 441 | func MarshalKill(m Kill) []byte { | |
| 441 | 442 | w := payloadWriter{} | |
| 442 | 443 | w.putString(m.Name) | |
| 444 | + | if m.All { | |
| 445 | + | w.putUint8(1) | |
| 446 | + | } | |
| 443 | 447 | return w.b | |
| 444 | 448 | } | |
| 445 | 449 | ||
| @@ -450,10 +454,17 @@ func UnmarshalKill(b []byte) (Kill, error) { | |||
| 450 | 454 | if err != nil { | |
| 451 | 455 | 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 | 464 | if err := r.done(); err != nil { | |
| 454 | 465 | return Kill{}, err | |
| 455 | 466 | } | |
| 456 | - | return Kill{Name: name}, nil | |
| 467 | + | return Kill{Name: name, All: all != 0}, nil | |
| 457 | 468 | } | |
| 458 | 469 | ||
| 459 | 470 | // 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 string | 107 | 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.b | 447 | 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{}, err | 455 | 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{}, err | 465 | return Kill{}, err | ||
| 455 | } | 466 | } | ||
| 456 | - | return Kill{Name: name}, nil | 467 | + | return Kill{Name: name, All: all != 0}, nil |
| 457 | } | 468 | } | ||
| 458 | 469 | ||||
| 459 | // MarshalSessions encodes m into a Sessions message payload: a uint16 count | 470 | // 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) { | |||
| 323 | 323 | }{ | |
| 324 | 324 | {name: "named", msg: Kill{Name: "main"}, want: []byte{0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e}}, | |
| 325 | 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 | 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 | - | 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) | |
| 347 | 355 | } | |
| 348 | 356 | ||
| 349 | 357 | 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) { | |||
| 421 | 421 | for _, s := range d.sessions { | |
| 422 | 422 | snapshot = append(snapshot, s) | |
| 423 | 423 | } | |
| 424 | + | empty := len(snapshot) == 0 | |
| 424 | 425 | d.mu.Unlock() | |
| 426 | + | if empty { | |
| 427 | + | d.doneOnce.Do(func() { close(d.done) }) | |
| 428 | + | return | |
| 429 | + | } | |
| 425 | 430 | for _, s := range snapshot { | |
| 426 | 431 | d.killSession(s, reason) | |
| 427 | 432 | } | |
| @@ -477,8 +482,9 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||
| 477 | 482 | _ = tr.Send(frameSessions(infos)) | |
| 478 | 483 | } | |
| 479 | 484 | ||
| 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. | |
| 482 | 488 | func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |
| 483 | 489 | defer func() { _ = tr.Close() }() | |
| 484 | 490 | ||
| @@ -487,6 +493,10 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |||
| 487 | 493 | _ = tr.Send(frameError(ports.ErrInternal, "malformed kill request")) | |
| 488 | 494 | return | |
| 489 | 495 | } | |
| 496 | + | if k.All { | |
| 497 | + | d.shutdownAll(ports.ReasonServerShutdown) | |
| 498 | + | return | |
| 499 | + | } | |
| 490 | 500 | ||
| 491 | 501 | d.mu.Lock() | |
| 492 | 502 | 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 control | 485 | + | // 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 | return | 494 | 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) { | |||
| 807 | 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 | 822 | // --- ephemeral numbering ---------------------------------------------------- | |
| 811 | 823 | ||
| 812 | 824 | 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) { | ||