From 7cfa403589b9a64973dee6eac93b086513e477ad Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 14:15:48 +0800 Subject: [PATCH 1/3] =?UTF-8?q?refactor(engine):=20cmd=20=E7=9A=84?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E5=AE=9E=E7=8E=B0=E6=8C=89=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E3=80=81=E8=BE=93=E5=87=BA=E4=B8=8E=E8=B7=AF=E7=94=B1=E9=87=8D?= =?UTF-8?q?=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 剩余生产文件按本项目的命令结构重写:flag 注册收成局部 FlagSet,输出与 路由辅助函数分组,错误分支与等待循环按结果类型展开。flag 名、help 文案、 输出字符串、错误消息与选择器优先级均未改变;已用主分支构建的二进制逐条 比对 help、schema 与 completion 输出,字节一致。 Co-Authored-By: Claude Code --- engine/internal/cmd/completion.go | 107 ++++++----- engine/internal/cmd/configsupport.go | 6 +- engine/internal/cmd/daemon.go | 27 ++- engine/internal/cmd/daemon_ctl.go | 93 +++++----- engine/internal/cmd/daemonroute.go | 22 +-- engine/internal/cmd/doctor.go | 235 ++++++++++++------------ engine/internal/cmd/down.go | 35 ++-- engine/internal/cmd/kill.go | 213 ++++++++++----------- engine/internal/cmd/killroute.go | 91 +++++---- engine/internal/cmd/logs.go | 49 ++--- engine/internal/cmd/logs_unix.go | 17 +- engine/internal/cmd/logs_windows.go | 18 +- engine/internal/cmd/root.go | 15 +- engine/internal/cmd/serve.go | 89 ++++----- engine/internal/cmd/start.go | 115 ++++++------ engine/internal/cmd/terminal_windows.go | 26 +-- engine/internal/cmd/up.go | 101 +++++----- 17 files changed, 655 insertions(+), 604 deletions(-) diff --git a/engine/internal/cmd/completion.go b/engine/internal/cmd/completion.go index 7c0eec85..2497caa1 100644 --- a/engine/internal/cmd/completion.go +++ b/engine/internal/cmd/completion.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "strconv" + "strings" "github.com/sheathedsharp/option-berth/engine/internal/docker" "github.com/sheathedsharp/option-berth/engine/internal/groups" @@ -10,54 +11,62 @@ import ( "github.com/spf13/cobra" ) -var completionCmd = &cobra.Command{ - Use: "completion [bash|zsh|fish]", - Short: "Generate shell completion scripts", - GroupID: commandGroupInfra, - Long: `Generate shell completion scripts for option-berth. - -To load completions: - -Bash: - $ source <(oberth completion bash) - - # To load completions for each session, execute once: - # Linux: - $ oberth completion bash > /etc/bash_completion.d/option-berth - # macOS: - $ oberth completion bash > $(brew --prefix)/etc/bash_completion.d/option-berth - -Zsh: - # If shell completion is not already enabled in your environment, - # you will need to enable it. You can execute the following once: - $ echo "autoload -U compinit; compinit" >> ~/.zshrc - - # To load completions for each session, execute once: - $ oberth completion zsh > "${fpath[1]}/_oberth" +// completionLong is the command's long help, one entry per output line so the +// per-shell recipes stay readable as a list rather than a block of prose. +var completionLong = strings.Join([]string{ + "Generate shell completion scripts for option-berth.", + "", + "To load completions:", + "", + "Bash:", + " $ source <(oberth completion bash)", + "", + " # To load completions for each session, execute once:", + " # Linux:", + " $ oberth completion bash > /etc/bash_completion.d/option-berth", + " # macOS:", + " $ oberth completion bash > $(brew --prefix)/etc/bash_completion.d/option-berth", + "", + "Zsh:", + " # If shell completion is not already enabled in your environment,", + " # you will need to enable it. You can execute the following once:", + " $ echo \"autoload -U compinit; compinit\" >> ~/.zshrc", + "", + " # To load completions for each session, execute once:", + " $ oberth completion zsh > \"${fpath[1]}/_oberth\"", + "", + " # You will need to start a new shell for this setup to take effect.", + "", + "Fish:", + " $ oberth completion fish | source", + "", + " # To load completions for each session, execute once:", + " $ oberth completion fish > ~/.config/fish/completions/oberth.fish", +}, "\n") + "\n" - # You will need to start a new shell for this setup to take effect. - -Fish: - $ oberth completion fish | source - - # To load completions for each session, execute once: - $ oberth completion fish > ~/.config/fish/completions/oberth.fish -`, +var completionCmd = &cobra.Command{ + Use: "completion [bash|zsh|fish]", + Short: "Generate shell completion scripts", + GroupID: commandGroupInfra, + Long: completionLong, DisableFlagsInUseLine: true, ValidArgs: []string{"bash", "zsh", "fish"}, Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), - RunE: func(cmd *cobra.Command, args []string) error { - switch args[0] { - case "bash": - return cmd.Root().GenBashCompletion(cmd.OutOrStdout()) - case "zsh": - return cmd.Root().GenZshCompletion(cmd.OutOrStdout()) - case "fish": - return cmd.Root().GenFishCompletion(cmd.OutOrStdout(), true) - default: - return fmt.Errorf("unsupported shell: %s", args[0]) - } - }, + RunE: runCompletionShell, +} + +// runCompletionShell writes the completion script the named shell reads. +func runCompletionShell(cmd *cobra.Command, args []string) error { + root, out := cmd.Root(), cmd.OutOrStdout() + switch args[0] { + case "bash": + return root.GenBashCompletion(out) + case "zsh": + return root.GenZshCompletion(out) + case "fish": + return root.GenFishCompletion(out, true) + } + return fmt.Errorf("unsupported shell: %s", args[0]) } func init() { @@ -80,11 +89,11 @@ var scanCompletionPorts = func() ([]ports.ListeningPort, error) { return pp, nil } -// completePort provides shell completions for commands that take a argument. -// It scans listening ports, enriches them with Docker and process info, and returns -// completions formatted as "port\tdescription". -func completePort(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - if len(args) > 0 { +// completePort offers candidate arguments for the commands that take +// one: a listener scan enriched with Docker and process detail, rendered as +// "port\tdescription". +func completePort(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } diff --git a/engine/internal/cmd/configsupport.go b/engine/internal/cmd/configsupport.go index a03ee5a8..0d3ef9a9 100644 --- a/engine/internal/cmd/configsupport.go +++ b/engine/internal/cmd/configsupport.go @@ -83,9 +83,9 @@ func configPathForProject(gg []state.Group, name string) string { // // The daemon parses `oberth.yaml` itself, so one that predates `port: auto` // answers with "cannot unmarshal !!str `auto` into int" — an error about the -// file, for a problem with the daemon. This says the true thing instead, and -// what to do about it. A daemon from before capabilities were announced at all -// is treated the same way, which is right: it is older still. +// file, for a problem with the daemon. This says what is actually true, and +// what to do about it. A daemon from before capabilities were announced at +// all is treated the same way, which is right: it is older still. func requireConfigSupport(c *client.Client, cfg *groups.Config) error { return requireConfigSupportFor(c, cfg, true) } diff --git a/engine/internal/cmd/daemon.go b/engine/internal/cmd/daemon.go index 89254c49..2d87135b 100644 --- a/engine/internal/cmd/daemon.go +++ b/engine/internal/cmd/daemon.go @@ -2,6 +2,7 @@ package cmd import ( "os" + "strings" "github.com/sheathedsharp/option-berth/engine/internal/daemon/rpc" "github.com/spf13/cobra" @@ -15,17 +16,27 @@ var daemonCmd = &cobra.Command{ GroupID: commandGroupInfra, } +// daemonSchemaLong is the schema command's long help, one entry per output line. +var daemonSchemaLong = strings.Join([]string{ + "Print the JSON Schema describing every daemon type and method.", + "It is generated from the Go types and is byte-identical to the copy", + "checked in at docs/schema/protocol.schema.json.", +}, "\n") + var daemonSchemaCmd = &cobra.Command{ Use: "schema", Short: "Print the daemon protocol JSON Schema", - Long: "Print the JSON Schema describing every daemon type and method.\n" + - "It is generated from the Go types and is byte-identical to the copy\n" + - "checked in at docs/schema/protocol.schema.json.", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - _, err := os.Stdout.Write(rpc.Marshal()) - return err - }, + Long: daemonSchemaLong, + Args: cobra.NoArgs, + RunE: runDaemonSchema, +} + +// runDaemonSchema writes the generated schema to stdout, where it is the whole +// answer. +func runDaemonSchema(_ *cobra.Command, _ []string) error { + schema := rpc.Marshal() + _, err := os.Stdout.Write(schema) + return err } func init() { diff --git a/engine/internal/cmd/daemon_ctl.go b/engine/internal/cmd/daemon_ctl.go index 9ca0d8d9..c9d6ad72 100644 --- a/engine/internal/cmd/daemon_ctl.go +++ b/engine/internal/cmd/daemon_ctl.go @@ -19,54 +19,55 @@ import ( ) var ( - daemonJSONFlag bool + // JSON prints the contract document instead of the human text. + daemonJSONFlag bool + // Follow keeps the log open after the tail has been printed. daemonFollowFlag bool - daemonLinesFlag int + // Lines is how much of the log the tail prints first. + daemonLinesFlag int ) var daemonStatusCmd = &cobra.Command{ Use: "status", Short: "Show whether the daemon is running, and what it is doing", - Args: cobra.NoArgs, RunE: daemonStatusRun, + Args: cobra.NoArgs, } var daemonStopCmd = &cobra.Command{ Use: "stop", Short: "Stop the running daemon", - Args: cobra.NoArgs, RunE: daemonStopRun, + Args: cobra.NoArgs, } var daemonRestartCmd = &cobra.Command{ Use: "restart", Short: "Stop the daemon if it is running, then start it detached", - Args: cobra.NoArgs, RunE: daemonRestartRun, + Args: cobra.NoArgs, } var daemonPathCmd = &cobra.Command{ Use: "path", Short: "Print the socket path the daemon listens on", - Args: cobra.NoArgs, RunE: daemonPathRun, + Args: cobra.NoArgs, } var daemonLogCmd = &cobra.Command{ Use: "log", Short: "Print the daemon log", - Args: cobra.NoArgs, RunE: daemonLogRun, + Args: cobra.NoArgs, } func init() { - daemonStatusCmd.Flags().BoolVar(&daemonJSONFlag, "json", false, "Output as JSON") - daemonPathCmd.Flags().BoolVar(&daemonJSONFlag, "json", false, "Output as JSON") - daemonStopCmd.Flags().BoolVar(&daemonJSONFlag, "json", false, "Output as JSON") - daemonRestartCmd.Flags().BoolVar(&daemonJSONFlag, "json", false, "Output as JSON") + for _, command := range []*cobra.Command{daemonStatusCmd, daemonPathCmd, daemonStopCmd, daemonRestartCmd} { + command.Flags().BoolVar(&daemonJSONFlag, "json", false, "Output as JSON") + } daemonLogCmd.Flags().BoolVarP(&daemonFollowFlag, "follow", "f", false, "Follow the log as it grows") daemonLogCmd.Flags().IntVarP(&daemonLinesFlag, "lines", "n", 50, "Number of trailing lines to print") - daemonCmd.AddCommand(daemonStatusCmd, daemonStopCmd, daemonRestartCmd, daemonPathCmd, daemonLogCmd) } @@ -97,17 +98,17 @@ func notRunning(socket string) error { } func daemonStatusRun(cmd *cobra.Command, _ []string) error { - c, err := runningClient(cmd.Context()) + conn, err := runningClient(cmd.Context()) if err != nil { return err } - defer c.Close() + defer conn.Close() var status rpc.DaemonStatusResult - if err := c.Call(cmd.Context(), "daemon.status", rpc.Empty{}, &status); err != nil { - return err + if callErr := conn.Call(cmd.Context(), "daemon.status", rpc.Empty{}, &status); callErr != nil { + return callErr } - doc := daemonStatus{status: status, hello: c.Hello(), cliCommit: buildinfo.Commit} + doc := daemonStatus{status: status, hello: conn.Hello(), cliCommit: buildinfo.Commit} if daemonJSONFlag { return printJSON(doc.jsonDoc()) @@ -224,18 +225,18 @@ func orUnknown(s string) string { func daemonStopRun(cmd *cobra.Command, _ []string) error { socket := daemon.SocketPath() - c, err := runningClient(cmd.Context()) + conn, err := runningClient(cmd.Context()) if err != nil { return err } - defer c.Close() + defer conn.Close() - var ok rpc.OKResult - if err := c.Call(cmd.Context(), "daemon.shutdown", rpc.Empty{}, &ok); err != nil { - return err + var ack rpc.OKResult + if callErr := conn.Call(cmd.Context(), "daemon.shutdown", rpc.Empty{}, &ack); callErr != nil { + return callErr } - if err := waitForDaemonGone(socket, stopTimeout); err != nil { - return err + if waitErr := waitForDaemonGone(socket, stopTimeout); waitErr != nil { + return waitErr } if daemonJSONFlag { return printJSON(map[string]any{"stopped": true, "socket": socket}) @@ -246,18 +247,18 @@ func daemonStopRun(cmd *cobra.Command, _ []string) error { func daemonRestartRun(cmd *cobra.Command, _ []string) error { socket := daemon.SocketPath() - if c, err := connectRunning(cmd.Context()); err == nil { - var ok rpc.OKResult - _ = c.Call(cmd.Context(), "daemon.shutdown", rpc.Empty{}, &ok) - c.Close() - // The old daemon releases its lock after it closes its socket, so the - // replacement must wait for the lock, not for the socket. - if err := waitForDaemonGone(socket, stopTimeout); err != nil { - return err + if conn, dialErr := connectRunning(cmd.Context()); dialErr == nil { + var ack rpc.OKResult + _ = conn.Call(cmd.Context(), "daemon.shutdown", rpc.Empty{}, &ack) + conn.Close() + // The old daemon releases its lock only after closing its socket, so the + // replacement waits for the lock rather than for the socket. + if waitErr := waitForDaemonGone(socket, stopTimeout); waitErr != nil { + return waitErr } } - if err := detachDaemon(cmd.Context(), socket); err != nil { - return err + if detachErr := detachDaemon(cmd.Context(), socket); detachErr != nil { + return detachErr } if daemonJSONFlag { return printJSON(map[string]any{"restarted": true, "socket": socket}) @@ -279,23 +280,23 @@ func daemonPathRun(*cobra.Command, []string) error { } func daemonLogRun(cmd *cobra.Command, _ []string) error { - path := daemon.LogPath() - f, err := os.Open(path) - if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("no daemon log at %s yet — start the daemon with `oberth serve`", path) + logPath := daemon.LogPath() + logFile, openErr := os.Open(logPath) + if openErr != nil { + if os.IsNotExist(openErr) { + return fmt.Errorf("no daemon log at %s yet — start the daemon with `oberth serve`", logPath) } - return err + return openErr } - defer f.Close() + defer logFile.Close() - if err := printTail(f, daemonLinesFlag); err != nil { - return err + if tailErr := printTail(logFile, daemonLinesFlag); tailErr != nil { + return tailErr } if !daemonFollowFlag { return nil } - return followFile(cmd.Context(), f) + return followFile(cmd.Context(), logFile) } // printTail writes the last n lines of f to stdout and leaves the file offset @@ -303,8 +304,8 @@ func daemonLogRun(cmd *cobra.Command, _ []string) error { func printTail(f *os.File, n int) error { return printTailTo(f, n, os.Stdout) } func printTailTo(f *os.File, n int, out io.Writer) error { if n <= 0 { - _, err := f.Seek(0, io.SeekEnd) - return err + _, seekErr := f.Seek(0, io.SeekEnd) + return seekErr } scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) diff --git a/engine/internal/cmd/daemonroute.go b/engine/internal/cmd/daemonroute.go index e0f61621..94459b68 100644 --- a/engine/internal/cmd/daemonroute.go +++ b/engine/internal/cmd/daemonroute.go @@ -14,12 +14,12 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// noDaemonFlag is the persistent --no-daemon flag: it forces the direct scan -// path for every read command, without the fallback note. +// noDaemonFlag is the persistent --no-daemon flag. It pins every read command +// to the direct scan path and drops the fallback note with it. var noDaemonFlag bool // fallbackNoteOnce keeps the "daemon unavailable" note to one line per -// invocation, however many times a command reaches for the daemon (spec, +// invocation, no matter how many times a command reaches for the daemon (spec, // "Error handling"). var fallbackNoteOnce sync.Once @@ -114,23 +114,23 @@ func daemonList(ctx context.Context, c *client.Client) ([]ports.ListeningPort, e return state.ToListeningAll(snap.Ports), nil } -// listInclude maps the CLI's --stats / --health onto the wire's include. +// listInclude maps the CLI's --stats / --health onto the wire's include list. func listInclude(stats, health bool) rpc.Include { - inc := rpc.Include{} + include := rpc.Include{} if stats { - inc = append(inc, "stats") + include = append(include, "stats") } if health { - inc = append(inc, "health") + include = append(include, "health") } - return inc + return include } // strPtrOrNil is the helper the params builders share: an empty string means // "no filter", which the wire spells as an absent field. -func strPtrOrNil(s string) *string { - if s == "" { +func strPtrOrNil(value string) *string { + if value == "" { return nil } - return &s + return &value } diff --git a/engine/internal/cmd/doctor.go b/engine/internal/cmd/doctor.go index 323c7c04..c37198ae 100644 --- a/engine/internal/cmd/doctor.go +++ b/engine/internal/cmd/doctor.go @@ -24,10 +24,15 @@ import ( ) var ( - doctorJSONFlag bool - doctorFixFlag bool - doctorYesFlag bool - doctorOnlyFlag []string + // Output and selection. + doctorJSONFlag bool + doctorOnlyFlag []string + + // Repair, and whether to ask first. + doctorFixFlag bool + doctorYesFlag bool + + // Where the project checks look. doctorProjectFlag string ) @@ -35,36 +40,32 @@ var doctorCmd = &cobra.Command{ Use: "doctor", Short: "Check that this option-berth installation is healthy", GroupID: commandGroupInfra, - Long: "Run every self-check option-berth has: the binary on PATH, the config file, the\n" + - "daemon and its socket, the database, this project's\n" + - "oberth.yaml and docker.\n\n" + - "Each check is ok, warn, fail or skip. The exit code is 0 unless something\n" + - "failed, so `oberth doctor` works in a script. `--fix` applies the repairs\n" + - "that are safe to make unattended and runs the checks again.", - Args: cobra.NoArgs, - // The report is the output; a failing check must not also print a bare - // "Error:" line under the table. Execute() still prints a real error. + RunE: doctorRun, + Args: cobra.NoArgs, + // The report is the output, so a failing check must not also print a bare + // "Error:" line beneath it; Execute still prints a real error. SilenceUsage: true, SilenceErrors: true, - RunE: doctorRun, + Long: "Run every self-check option-berth has: the binary on PATH, the config file, the\ndaemon and its socket, the database, this project's\n" + + "oberth.yaml and docker.\n\nEach check is ok, warn, fail or skip. The exit code is 0 unless something\n" + + "failed, so `oberth doctor` works in a script. `--fix` applies the repairs\n" + + "that are safe to make unattended and runs the checks again.", } func init() { - doctorCmd.Flags().BoolVar(&doctorJSONFlag, "json", false, "Output as JSON") - doctorCmd.Flags().BoolVar(&doctorFixFlag, "fix", false, - "Apply safe fixes to the selected project, then check again") - doctorCmd.Flags().BoolVar(&doctorYesFlag, "yes", false, "Do not ask before applying a fix") - doctorCmd.Flags().StringSliceVar(&doctorOnlyFlag, "only", nil, - "Run only these checks (comma separated)") - doctorCmd.Flags().StringVar(&doctorProjectFlag, "project", "", - "Directory the project checks look at (default: the working directory)") + flags := doctorCmd.Flags() + flags.BoolVar(&doctorJSONFlag, "json", false, "Output as JSON") + flags.BoolVar(&doctorFixFlag, "fix", false, "Apply safe fixes to the selected project, then check again") + flags.BoolVar(&doctorYesFlag, "yes", false, "Do not ask before applying a fix") + flags.StringSliceVar(&doctorOnlyFlag, "only", nil, "Run only these checks (comma separated)") + flags.StringVar(&doctorProjectFlag, "project", "", "Directory the project checks look at (default: the working directory)") rootCmd.AddCommand(doctorCmd) } func doctorRun(cmd *cobra.Command, _ []string) error { - if bad := doctor.UnknownSelectors(doctorOnlyFlag); len(bad) > 0 { + if unknown := doctor.UnknownSelectors(doctorOnlyFlag); len(unknown) != 0 { return fmt.Errorf("unknown check %s\nknown checks: %s", - strings.Join(bad, ", "), strings.Join(doctor.IDs(), ", ")) + strings.Join(unknown, ", "), strings.Join(doctor.IDs(), ", ")) } project, err := doctorProject() if err != nil { @@ -72,29 +73,29 @@ func doctorRun(cmd *cobra.Command, _ []string) error { } ctx := cmd.Context() - env := doctorEnv(project) - report := doctor.Run(ctx, env, doctorOnlyFlag) + result := doctor.Run(ctx, doctorEnv(project), doctorOnlyFlag) if doctorFixFlag { - applied, err := applyDoctorFixes(ctx, cmd, report) - if err != nil { - return err + applied, fixErr := applyDoctorFixes(ctx, cmd, result) + if fixErr != nil { + return fixErr } if applied > 0 { - report = doctor.Run(ctx, doctorEnv(project), doctorOnlyFlag) + result = doctor.Run(ctx, doctorEnv(project), doctorOnlyFlag) } } - if doctorJSONFlag { - out, err := json.MarshalIndent(report, "", " ") + switch { + case doctorJSONFlag: + encoded, err := json.MarshalIndent(result, "", " ") if err != nil { return err } - fmt.Println(string(out)) - } else { - renderDoctor(os.Stdout, report) + fmt.Println(string(encoded)) + default: + renderDoctor(os.Stdout, result) } - if !report.OK { + if !result.OK { return errSilent } return nil @@ -104,14 +105,14 @@ func doctorProject() (string, error) { if doctorProjectFlag == "" { return os.Getwd() } - abs, err := filepath.Abs(doctorProjectFlag) + target, err := filepath.Abs(doctorProjectFlag) if err != nil { return "", err } - if info, err := os.Stat(abs); err != nil || !info.IsDir() { + if info, err := os.Stat(target); err != nil || !info.IsDir() { return "", fmt.Errorf("--project %s is not a directory", doctorProjectFlag) } - return abs, nil + return target, nil } // doctorEnv is the CLI's view of the world: the running binary, docker, and a @@ -133,34 +134,33 @@ func doctorEnv(project string) doctor.Env { func doctorDaemonProbe(ctx context.Context) doctor.DaemonInfo { socket := daemon.SocketPath() - c, err := client.Dial(ctx, client.ClientInfo{Name: "cli", Version: buildinfo.Version}) - if err != nil { - // A daemon speaking another protocol major is running, not absent: - // reporting it as unreachable would hide the one check that explains - // what is actually wrong. - var mismatch *client.ProtocolMismatchError - if errors.As(err, &mismatch) { - return doctor.DaemonInfo{Reachable: true, ProtocolVersion: mismatch.Daemon, Socket: socket} + conn, dialErr := client.Dial(ctx, client.ClientInfo{Name: "cli", Version: buildinfo.Version}) + if dialErr != nil { + // Another protocol major is a daemon that is running, not one that is + // absent; calling it unreachable would hide the check that explains it. + var versionMismatch *client.ProtocolMismatchError + if errors.As(dialErr, &versionMismatch) { + return doctor.DaemonInfo{Reachable: true, ProtocolVersion: versionMismatch.Daemon, Socket: socket} } - return doctor.DaemonInfo{Err: err, Socket: socket} + return doctor.DaemonInfo{Err: dialErr, Socket: socket} } - defer c.Close() + defer conn.Close() - hello := c.Hello() + hello := conn.Hello() var status rpc.DaemonStatusResult - statusErr := c.Call(ctx, "daemon.status", rpc.Empty{}, &status) - info := doctor.DaemonInfo{ + statusErr := conn.Call(ctx, "daemon.status", rpc.Empty{}, &status) + probe := doctor.DaemonInfo{ Reachable: true, - Commit: status.Commit, - StatusErr: statusErr, - Version: hello.DaemonVersion, - ProtocolVersion: hello.ProtocolVersion, Socket: hello.Socket, PID: hello.PID, + Version: hello.DaemonVersion, + ProtocolVersion: hello.ProtocolVersion, + Commit: status.Commit, DBPath: status.DBPath, + StatusErr: statusErr, Observation: greatestObservation(status.Observation, hello.Observation), } - return info + return probe } // greatestObservation prefers the status probe (current) over the hello cache @@ -233,17 +233,17 @@ func renderDoctor(w io.Writer, report rpc.DaemonDoctorResult) { } func doctorVerdict(report rpc.DaemonDoctorResult, counts map[string]int) string { - var parts []string + var summaryParts []string for _, status := range []string{doctor.StatusFail, doctor.StatusWarn, doctor.StatusOK, doctor.StatusSkip} { - if n := counts[status]; n > 0 { - parts = append(parts, fmt.Sprintf("%d %s", n, status)) + if n := counts[status]; n != 0 { + summaryParts = append(summaryParts, fmt.Sprintf("%d %s", n, status)) } } - summary := strings.Join(parts, ", ") + summary := strings.Join(summaryParts, ", ") fixable := 0 - for _, c := range report.Checks { - if c.Fixable && (c.Status == doctor.StatusFail || c.Status == doctor.StatusWarn) { + for _, check := range report.Checks { + if check.Fixable && (check.Status == doctor.StatusFail || check.Status == doctor.StatusWarn) { fixable++ } } @@ -252,30 +252,27 @@ func doctorVerdict(report rpc.DaemonDoctorResult, counts map[string]int) string hint = display.Dim(fmt.Sprintf(" (`oberth doctor --fix` repairs %d of them)", fixable)) } - switch { - case !report.OK: + if !report.OK { return display.Red("something is wrong: ") + summary + hint - case counts[doctor.StatusWarn] > 0: + } + if counts[doctor.StatusWarn] > 0 { return display.Yellow("mostly healthy: ") + summary + hint - default: - return display.Green("all good: ") + summary } + return display.Green("all good: ") + summary } -// doctorGlyph is a symbol and a word, not one or the other: the symbol is what -// the eye finds when the terminal has colour, and the word is what survives -// NO_COLOR, a pipe and a screenshot. +// doctorGlyph pairs a symbol with a word: the symbol is what the eye finds in a +// colour terminal, the word is what survives NO_COLOR, a pipe and a screenshot. func doctorGlyph(status string) string { - switch status { - case doctor.StatusOK: - return display.Green("\u2714 ok ") - case doctor.StatusWarn: - return display.Yellow("! warn") - case doctor.StatusFail: - return display.Red("\u2716 FAIL") - default: - return display.Dim("\u00b7 skip") + glyphs := map[string]string{ + doctor.StatusOK: display.Green("\u2714 ok "), + doctor.StatusWarn: display.Yellow("! warn"), + doctor.StatusFail: display.Red("\u2716 FAIL"), } + if glyph, known := glyphs[status]; known { + return glyph + } + return display.Dim("\u00b7 skip") } // padRight pads to a width counted in runes, not bytes: paths and labels carry @@ -290,14 +287,14 @@ func padRight(s string, width int) string { // ------------------------------------------------------------------- fix --- -// doctorFix is one repair `--fix` may apply. Every one of them is a call the -// user could have made themselves from the check's own fix hint; nothing here -// deletes anything, and the broken config is moved aside rather than replaced. +// doctorFix is one repair `--fix` can apply. Each is a call the user could have +// made from the check's own hint; no repair deletes anything, and a broken +// config is moved aside rather than overwritten. // -// Project-scoped fixes honor --project; machine configuration repairs remain local. +// Project-scoped repairs honor --project; machine configuration stays local. type doctorFix struct { - id string - run func(ctx context.Context, cmd *cobra.Command) (string, error) + id string // the check it repairs + run func(ctx context.Context, cmd *cobra.Command) (string, error) // what it does } // matches reports whether this fix repairs exactly the named check. A dotted @@ -309,33 +306,35 @@ func doctorFixes() []doctorFix { fixes := []doctorFix{ {id: "config_parses", run: fixBrokenConfig}, {id: "project_config", run: fixLegacyConfigName}, - {id: "daemon_reachable", run: func(ctx context.Context, cmd *cobra.Command) (string, error) { - if err := daemonRestartRun(cmd, nil); err != nil { - return "", err - } - return "restarted the daemon", nil - }}, + {id: "daemon_reachable", run: restartDaemonFix}, } - // Fixes repair this machine's own setup only; nothing here writes into - // another tool's configuration. + // A fix repairs this machine's own setup only; none writes into another + // tool's configuration. sort.Slice(fixes, func(i, j int) bool { return fixes[i].id < fixes[j].id }) return fixes } +// restartDaemonFix is the repair behind a failed daemon check. +func restartDaemonFix(_ context.Context, cmd *cobra.Command) (string, error) { + if err := daemonRestartRun(cmd, nil); err != nil { + return "", err + } + return "restarted the daemon", nil +} + // fixBrokenConfig moves an unparseable config.yaml aside and writes a fresh -// commented template in its place. The old file is kept: it is the only copy of -// whatever the user meant to write. +// commented template in its place. The old file is the only copy of what the +// user meant to write, so it is kept. func fixBrokenConfig(context.Context, *cobra.Command) (string, error) { - path := config.Path() - stamp := time.Now().UTC().Format("20060102T150405Z") - moved := path + doctor.BrokenSuffix + stamp - if err := os.Rename(path, moved); err != nil { - return "", fmt.Errorf("could not move %s aside: %w", path, err) + configPath := config.Path() + aside := configPath + doctor.BrokenSuffix + time.Now().UTC().Format("20060102T150405Z") + if err := os.Rename(configPath, aside); err != nil { + return "", fmt.Errorf("could not move %s aside: %w", configPath, err) } if err := config.WriteTemplate(true); err != nil { return "", err } - return fmt.Sprintf("moved the broken config to %s and wrote a fresh one", moved), nil + return fmt.Sprintf("moved the broken config to %s and wrote a fresh one", aside), nil } // selectDoctorFixes is the repair list for the checks that failed or warned @@ -360,15 +359,15 @@ func selectDoctorFixes(report rpc.DaemonDoctorResult) []doctorFix { // applyDoctorFixes runs every safe fix for a check that is not ok, after // confirmation. It returns how many it applied. func applyDoctorFixes(ctx context.Context, cmd *cobra.Command, report rpc.DaemonDoctorResult) (int, error) { - todo := selectDoctorFixes(report) - if len(todo) == 0 { + planned := selectDoctorFixes(report) + if len(planned) == 0 { fmt.Fprintln(os.Stderr, "nothing to fix automatically") return 0, nil } fmt.Fprintf(os.Stderr, "oberth doctor --fix will:\n") - for _, f := range todo { - fmt.Fprintf(os.Stderr, " repair %s\n", f.id) + for _, fix := range planned { + fmt.Fprintf(os.Stderr, " repair %s\n", fix.id) } if !doctorYesFlag && !confirmDoctorFix() { fmt.Fprintln(os.Stderr, "nothing was changed") @@ -376,32 +375,32 @@ func applyDoctorFixes(ctx context.Context, cmd *cobra.Command, report rpc.Daemon } applied := 0 - for _, f := range todo { - what, err := f.run(ctx, cmd) - if err != nil { - fmt.Fprintf(os.Stderr, "could not repair %s: %v\n", f.id, err) + for _, fix := range planned { + what, runErr := fix.run(ctx, cmd) + if runErr != nil { + fmt.Fprintf(os.Stderr, "could not repair %s: %v\n", fix.id, runErr) continue } - fmt.Fprintf(os.Stderr, "%s: %s\n", f.id, what) + fmt.Fprintf(os.Stderr, "%s: %s\n", fix.id, what) applied++ } fmt.Fprintln(os.Stderr) return applied, nil } -// confirmDoctorFix asks once on the terminal. A non-interactive run (a pipe, a -// CI job) answers no rather than blocking, which is why `--yes` exists. +// confirmDoctorFix asks once on the terminal. With a pipe or a CI job there is +// nobody to ask, so the answer is no; `--yes` is the way past it. func confirmDoctorFix() bool { - info, err := os.Stdin.Stat() - if err != nil || info.Mode()&os.ModeCharDevice == 0 { + stat, statErr := os.Stdin.Stat() + if statErr != nil || stat.Mode()&os.ModeCharDevice == 0 { fmt.Fprintln(os.Stderr, "stdin is not a terminal; pass --yes to apply these") return false } fmt.Fprint(os.Stderr, "apply? [y/N] ") - line, err := bufio.NewReader(os.Stdin).ReadString('\n') - if err != nil { + typed, readErr := bufio.NewReader(os.Stdin).ReadString('\n') + if readErr != nil { return false } - answer := strings.ToLower(strings.TrimSpace(line)) + answer := strings.ToLower(strings.TrimSpace(typed)) return answer == "y" || answer == "yes" } diff --git a/engine/internal/cmd/down.go b/engine/internal/cmd/down.go index 06e7ffeb..6dbb4532 100644 --- a/engine/internal/cmd/down.go +++ b/engine/internal/cmd/down.go @@ -40,11 +40,12 @@ var downCmd = &cobra.Command{ } func init() { - downCmd.Flags().BoolVarP(&downForceFlag, "force", "f", false, "Send SIGKILL instead of SIGTERM") - downCmd.Flags().BoolVar(&downJSONFlag, "json", false, "Output as JSON") - downCmd.Flags().StringVar(&downWorktreeFlag, "worktree", "", + flags := downCmd.Flags() + flags.BoolVarP(&downForceFlag, "force", "f", false, "Send SIGKILL instead of SIGTERM") + flags.BoolVar(&downJSONFlag, "json", false, "Output as JSON") + flags.StringVar(&downWorktreeFlag, "worktree", "", "Stop the checkout at this root path, even if its directory is gone") - downCmd.Flags().StringVar(&downIdentityFlag, "identity", "", + flags.StringVar(&downIdentityFlag, "identity", "", "Stop the checkout with this wt_ identity, even if its directory is gone") rootCmd.AddCommand(downCmd) } @@ -66,13 +67,13 @@ func downRun(cmd *cobra.Command, args []string) error { return err } - snapshot, err := daemonList(cmd.Context(), c) + rows, err := daemonList(cmd.Context(), c) if err != nil { return cliError(err) } - var env rpc.KillEnvelope - if err := c.Call(cmd.Context(), "groups.kill", params, &env); err != nil { + var envelope rpc.KillEnvelope + if err := c.Call(cmd.Context(), "groups.kill", params, &envelope); err != nil { return cliError(err) } if downJSONFlag { @@ -83,32 +84,32 @@ func downRun(cmd *cobra.Command, args []string) error { // already answers non-zero through reportKill; this branch answers // through the daemon's own verdict, as `restart --json` does for the // same envelope. - if err := printJSON(env); err != nil { + if err := printJSON(envelope); err != nil { return err } - if !env.OK { + if !envelope.OK { return errSilent } return nil } - return reportDown(env, snapshot) + return reportDown(envelope, rows) } // reportDown renders what groups.kill did: the per-process lines for whatever // was listening, and then the sentence about the reservations it gave back. // The released count is the daemon's, so it is printed whether or not anything // was listening. -func reportDown(env rpc.KillEnvelope, snapshot []ports.ListeningPort) error { - var reportErr error - if len(env.Results) == 0 { +func reportDown(envelope rpc.KillEnvelope, rows []ports.ListeningPort) error { + var renderErr error + if len(envelope.Results) == 0 { fmt.Println("Nothing was running.") } else { - reportErr = reportKill(os.Stdout, env.Results, snapshot, false, false) + renderErr = reportKill(os.Stdout, envelope.Results, rows, false, false) } - if env.Released > 0 { - fmt.Println(display.Dim(fmt.Sprintf("released %d claimed %s", env.Released, pluralWord(env.Released, "port")))) + if envelope.Released > 0 { + fmt.Println(display.Dim(fmt.Sprintf("released %d claimed %s", envelope.Released, pluralWord(envelope.Released, "port")))) } - return reportErr + return renderErr } // downParams chooses what to stop before any daemon call: one explicit diff --git a/engine/internal/cmd/kill.go b/engine/internal/cmd/kill.go index 75114c65..2666f5b4 100644 --- a/engine/internal/cmd/kill.go +++ b/engine/internal/cmd/kill.go @@ -20,19 +20,24 @@ import ( ) var ( - killPIDFlag []int - killGroupFlag string - killSessionFlag string - killAllFlag bool - killTreeFlag bool - forceFlag bool - killGraceFlag time.Duration + // Selectors: positional ports or pids, or one of these filters. + killPIDFlag []int + killGroupFlag string + killSessionFlag string + killAllFlag bool + + // How the stop is carried out. + killTreeFlag bool + killForceFlag bool + killGraceFlag time.Duration + // Escalation and confirmation. killNoEscalateFlag bool killYesFlag bool killDryRunFlag bool killJSONFlag bool ) +// killCmd is the one-stop command: port, pid, group or every listener. var killCmd = &cobra.Command{ Use: "kill [port|pid ...]", Short: "Stop a named listener or process", @@ -50,44 +55,44 @@ Examples: A positional argument is read as a port; a number no one is listening on that matches a running process is read as a pid.`, - ValidArgsFunction: completePort, RunE: runKill, + ValidArgsFunction: completePort, } func init() { - killCmd.Flags().IntSliceVar(&killPIDFlag, "pid", nil, "Kill by process id (repeatable)") - killCmd.Flags().StringVarP(&killGroupFlag, "group", "g", "", "Kill every port in a group") - killCmd.Flags().StringVar(&killSessionFlag, "session", "", - "Stop everything an agent `session` started (`current` is this shell's own)") - killCmd.Flags().BoolVar(&killAllFlag, "all", false, "Kill every listening port") - killCmd.Flags().BoolVar(&killTreeFlag, "tree", false, "Kill the target's whole process tree, children first") - killCmd.Flags().BoolVarP(&forceFlag, "force", "f", false, "Send SIGKILL instead of SIGTERM") - killCmd.Flags().DurationVar(&killGraceFlag, "grace", killer.DefaultGrace, - "How long to wait after SIGTERM before escalating (e.g. 10s)") - killCmd.Flags().BoolVar(&killNoEscalateFlag, "no-escalate", false, "Never escalate to SIGKILL") - killCmd.Flags().BoolVarP(&killYesFlag, "yes", "y", false, "Skip the confirmation prompt") - killCmd.Flags().BoolVar(&killDryRunFlag, "dry-run", false, "Show what would be killed and do nothing") - killCmd.Flags().String("ip", "", "Bind address, when a port is bound to several") - killCmd.Flags().BoolVar(&killJSONFlag, "json", false, "Print the result list as JSON") + flags := killCmd.Flags() + flags.IntSliceVar(&killPIDFlag, "pid", nil, "Kill by process id (repeatable)") + flags.StringVarP(&killGroupFlag, "group", "g", "", "Kill every port in a group") + flags.StringVar(&killSessionFlag, "session", "", "Stop everything an agent `session` started (`current` is this shell's own)") + flags.BoolVar(&killAllFlag, "all", false, "Kill every listening port") + flags.BoolVar(&killTreeFlag, "tree", false, "Kill the target's whole process tree, children first") + flags.BoolVarP(&killForceFlag, "force", "f", false, "Send SIGKILL instead of SIGTERM") + flags.DurationVar(&killGraceFlag, "grace", killer.DefaultGrace, "How long to wait after SIGTERM before escalating (e.g. 10s)") + flags.BoolVar(&killNoEscalateFlag, "no-escalate", false, "Never escalate to SIGKILL") + flags.BoolVarP(&killYesFlag, "yes", "y", false, "Skip the confirmation prompt") + flags.BoolVar(&killDryRunFlag, "dry-run", false, "Show what would be killed and do nothing") + flags.String("ip", "", "Bind address, when a port is bound to several") + flags.BoolVar(&killJSONFlag, "json", false, "Print the result list as JSON") + // Group and agent-session selectors belonged to the old machine-wide run // registry surface. Keep them parseable for existing scripts, but make the // public escape hatch start with a named port or pid; project lifecycle is // `down`, and worktree state is `status`. for _, name := range []string{"group", "session"} { - _ = killCmd.Flags().MarkHidden(name) + _ = flags.MarkHidden(name) + } + for _, pair := range [][2]string{{"group", "all"}, {"session", "all"}, {"session", "group"}} { + killCmd.MarkFlagsMutuallyExclusive(pair[0], pair[1]) } - killCmd.MarkFlagsMutuallyExclusive("group", "all") - killCmd.MarkFlagsMutuallyExclusive("session", "all") - killCmd.MarkFlagsMutuallyExclusive("session", "group") rootCmd.AddCommand(killCmd) } func runKill(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - bindIP, _ := cmd.Flags().GetString("ip") + bind, _ := cmd.Flags().GetString("ip") if killSessionFlag != "" { - if len(args) > 0 || len(killPIDFlag) > 0 { + if len(args) != 0 || len(killPIDFlag) != 0 { return fmt.Errorf("--session cannot be combined with ports or pids") } return killSession(cmd.Context()) @@ -100,22 +105,22 @@ func runKill(cmd *cobra.Command, args []string) error { // in §20: killing is not a reason to start a daemon. if c := daemonClient(cmd.Context()); c != nil { defer c.Close() - return killThroughDaemon(cmd.Context(), c, args, bindIP) + return killThroughDaemon(cmd.Context(), c, args, bind) } - return killDirect(cmd.Context(), args, bindIP) + return killDirect(cmd.Context(), args, bind) } // killDirect is the no-daemon path: scan here, kill here. It is also what runs // under --no-daemon and whenever the daemon is down, which is why it stays a // complete implementation rather than a degraded one. func killDirect(ctx context.Context, args []string, bindIP string) error { - snapshot, targets, confirm, complete, err := killSelection(ctx, args, bindIP, scanForKill) + rows, targets, confirm, complete, err := killSelection(ctx, args, bindIP, scanForKill) if err != nil { return err } if len(targets) == 0 { - return reportKill(os.Stdout, nil, snapshot, killJSONFlag, killDryRunFlag) + return reportKill(os.Stdout, nil, rows, killJSONFlag, killDryRunFlag) } opts := killOptions() @@ -124,13 +129,13 @@ func killDirect(ctx context.Context, args []string, bindIP string) error { // suppress the killer's re-observation and let an unreadable row become a // signal. if complete { - opts.Ports = snapshot + opts.Ports = rows } return killRequest{ exec: killHere, targets: targets, - snapshot: snapshot, + snapshot: rows, opts: opts, confirm: confirm && !killYesFlag && !killDryRunFlag, asJSON: killJSONFlag, @@ -181,14 +186,14 @@ func killRequiresObservation(args []string) bool { // paths so the daemon is asked for exactly what the direct path would do. func killOptions() killer.Options { opts := killer.Options{ + DryRun: killDryRunFlag, Tree: killTreeFlag, - Force: forceFlag, + Force: killForceFlag, Grace: killGraceFlag, - DryRun: killDryRunFlag, } if killNoEscalateFlag { - off := false - opts.Escalate = &off + neverEscalate := false + opts.Escalate = &neverEscalate } return opts } @@ -283,31 +288,29 @@ func observeListenersForKill(ctx context.Context) ([]ports.ListeningPort, error) // killTargets turns the command line into killer targets. The bool reports // whether the selection is broad enough to confirm before acting. func killTargets(args []string, snapshot []ports.ListeningPort, bindIP string) ([]killer.Target, bool, error) { - var targets []killer.Target + targets := make([]killer.Target, 0, len(killPIDFlag)+len(args)) for _, pid := range killPIDFlag { targets = append(targets, killer.Target{PID: pid}) } for _, arg := range args { - n, err := strconv.Atoi(arg) - if err != nil || n <= 0 { + number, convErr := strconv.Atoi(arg) + if convErr != nil || number <= 0 { return nil, false, fmt.Errorf("invalid port or pid: %s", arg) } - targets = append(targets, positionalTarget(n, snapshot, bindIP)) + targets = append(targets, positionalTarget(number, snapshot, bindIP)) } - switch { - case killGroupFlag != "": - if len(targets) > 0 { + if killGroupFlag != "" { + if len(targets) != 0 { return nil, false, fmt.Errorf("--group cannot be combined with ports or pids") } - members := groupMembers(snapshot, killGroupFlag) - if len(members) == 0 { - return nil, false, fmt.Errorf("no listening port belongs to group %q", killGroupFlag) + if members := groupMembers(snapshot, killGroupFlag); len(members) != 0 { + return members, true, nil } - return members, true, nil - - case killAllFlag: - if len(targets) > 0 { + return nil, false, fmt.Errorf("no listening port belongs to group %q", killGroupFlag) + } + if killAllFlag { + if len(targets) != 0 { return nil, false, fmt.Errorf("--all cannot be combined with ports or pids") } // An empty sweep is not a failure: there was simply nothing to stop. @@ -320,8 +323,8 @@ func killTargets(args []string, snapshot []ports.ListeningPort, bindIP string) ( return targets, false, nil } -// positionalTarget reads a bare number as a port, falling back to a pid when -// nothing is listening on it but a process with that id exists. +// positionalTarget treats a bare number as a port, unless nothing listens on +// it while a process with that id exists. func positionalTarget(n int, snapshot []ports.ListeningPort, bindIP string) killer.Target { return positionalTargetWithProbe(n, snapshot, bindIP, killer.Alive) } @@ -339,22 +342,21 @@ func positionalTargetWithProbe(n int, snapshot []ports.ListeningPort, bindIP str } // groupMembers resolves a group name against the scan. The scanner's `group` -// field is the primary signal; a `option-berth run` tag or id and a Docker Compose -// project name are accepted too, so the flag works for everything that is -// grouped today. +// field is the primary signal; an `oberth run` tag or id and a Docker Compose +// project name are accepted too, so the flag reaches everything grouped today. func groupMembers(snapshot []ports.ListeningPort, name string) []killer.Target { - var out []killer.Target - for _, p := range snapshot { - if inGroup(p, name) { - out = append(out, killer.Target{Port: p.Port, BindAddress: p.BindAddress}) + targets := make([]killer.Target, 0, len(snapshot)) + for _, row := range snapshot { + if inGroup(row, name) { + targets = append(targets, killer.Target{Port: row.Port, BindAddress: row.BindAddress}) } } - return out + return targets } func inGroup(p ports.ListeningPort, name string) bool { - for _, candidate := range []string{p.Group, p.Tag, p.RunID, p.DockerComposeProject} { - if candidate != "" && strings.EqualFold(candidate, name) { + for _, field := range []string{p.Group, p.Tag, p.RunID, p.DockerComposeProject} { + if field != "" && strings.EqualFold(field, name) { return true } } @@ -375,17 +377,18 @@ func sweepTargets(snapshot []ports.ListeningPort) []killer.Target { // confirmPlan prints the planned actions and asks before taking them. func confirmPlan(plan []killer.Result, snapshot []ports.ListeningPort) bool { fmt.Printf("Will stop %d process(es):\n", len(plan)) - for _, r := range plan { - fmt.Printf(" - %s\n", describeResult(r, snapshot)) + for i := range plan { + fmt.Printf(" - %s\n", describeResult(plan[i], snapshot)) } fmt.Print("\nProceed? [y/N] ") var answer string fmt.Scanln(&answer) - return strings.EqualFold(strings.TrimSpace(answer), "y") + answer = strings.TrimSpace(answer) + return strings.EqualFold(answer, "y") } -// reportKill prints the outcome and returns a non-nil error when any target -// failed, so the command exits 1 (daemon spec, "Error handling"). +// reportKill prints the outcome. Any target that failed (a missing one does +// not count) makes the returned error non-nil, so the command exits 1. // // **A target that was already gone is not a failure.** The end state the caller // asked for — that thing is not running — already holds, so its row is reported @@ -394,8 +397,8 @@ func confirmPlan(plan []killer.Result, snapshot []ports.ListeningPort) bool { // `1 of 3 targets failed` while everything really was stopped. func reportKill(w io.Writer, results []killer.Result, snapshot []ports.ListeningPort, asJSON, dryRun bool) error { failed := 0 - for _, r := range results { - if !r.OK && !alreadyGone(r) { + for i := range results { + if !results[i].OK && !alreadyGone(results[i]) { failed++ } } @@ -404,16 +407,18 @@ func reportKill(w io.Writer, results []killer.Result, snapshot []ports.Listening if err := writeKillJSON(w, results); err != nil { return err } - if failed > 0 { + if failed != 0 { return errSilent } return nil } + // Nothing to report is still a successful answer. if len(results) == 0 { fmt.Fprintln(w, "Nothing to stop.") return nil } + // A dry run says up front that nothing below was carried out. if dryRun { fmt.Fprintf(w, "Dry run: %d action(s), children first.\n", len(results)) } @@ -480,67 +485,67 @@ func killVerb(r killer.Result, dryRun bool) string { // describeResult names the process or container a row acted on. func describeResult(r killer.Result, snapshot []ports.ListeningPort) string { - name := r.Name - if name == "" { - name = "unknown" + label := r.Name + if label == "" { + label = "unknown" } - var b strings.Builder - b.WriteString(display.Bold(name)) + var out strings.Builder + out.WriteString(display.Bold(label)) if r.PID > 0 { - fmt.Fprintf(&b, " (PID %d)", r.PID) + fmt.Fprintf(&out, " (PID %d)", r.PID) } if r.Port > 0 { - fmt.Fprintf(&b, " on port %d", r.Port) + fmt.Fprintf(&out, " on port %d", r.Port) if bindAmbiguous(snapshot, r.Port) && r.BindAddress != "" { - fmt.Fprintf(&b, " [%s]", r.BindAddress) + fmt.Fprintf(&out, " [%s]", r.BindAddress) } } - return b.String() + return out.String() } // bindAmbiguous reports whether a port number appears more than once in the // scan, in which case the bind address is worth printing. func bindAmbiguous(snapshot []ports.ListeningPort, port int) bool { - seen := 0 - for _, p := range snapshot { - if p.Port == port { - seen++ + matches := 0 + for _, row := range snapshot { + if row.Port == port { + matches++ } } - return seen > 1 + return matches > 1 } -// freedURLs lists the distinct URLs of the ports that were successfully acted -// on, in the order they appear in the results. +// freedURLs lists the URLs the successful rows released: distinct, in the order +// the results report them. func freedURLs(results []killer.Result, snapshot []ports.ListeningPort) []string { byKey := map[string]string{} for i := range snapshot { byKey[snapshot[i].PortKey()] = snapshot[i].URL() } - var out []string - seen := map[string]bool{} - for _, r := range results { - if !r.OK || r.Port == 0 { + var urls []string + emitted := map[string]bool{} + for _, row := range results { + if !row.OK || row.Port == 0 { continue } - key := r.Key() - if seen[key] { + key := row.Key() + if emitted[key] { continue } - seen[key] = true - if url, ok := byKey[key]; ok { - out = append(out, url) + emitted[key] = true + if url, found := byKey[key]; found { + urls = append(urls, url) } } - return out + return urls } // writeKillJSON prints the kill result list. -func writeKillJSON(w io.Writer, results []killer.Result) error { - if results == nil { - results = []killer.Result{} +func writeKillJSON(w io.Writer, rows []killer.Result) error { + if rows == nil { + rows = []killer.Result{} } - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - return enc.Encode(results) + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(rows) } diff --git a/engine/internal/cmd/killroute.go b/engine/internal/cmd/killroute.go index 3d13843d..b45a5c9a 100644 --- a/engine/internal/cmd/killroute.go +++ b/engine/internal/cmd/killroute.go @@ -24,30 +24,29 @@ var guardDaemonKill = func(*client.Client) error { return nil } // killThroughDaemon runs `oberth kill` as `ports.kill` / `groups.kill`. // -// Target selection stays here, against the port table the daemon serves, so -// every message the user can see — the invalid-selector errors, "no listening -// port belongs to group X", the confirmation list, the result lines and the -// --json list — is produced by the same code as the direct path. Only the -// signalling moves: the daemon kills, then rescans and records the port going -// down, which is what the CLI's own killer could never do. -func killThroughDaemon(ctx context.Context, c *client.Client, args []string, bindIP string) error { - snapshot, err := daemonList(ctx, c) +// Selection runs here, against the table the daemon serves, so the sentences a +// user sees — invalid selector, "no listening port belongs to group X", the +// confirmation list, result lines and the --json list — come from the same code +// as the direct path. Only signalling moves: the daemon kills, rescans and +// records the port going down, which the CLI's own killer could not do. +func killThroughDaemon(ctx context.Context, conn *client.Client, args []string, bindIP string) error { + rows, err := daemonList(ctx, conn) if err != nil { return cliError(err) } - targets, confirm, err := killTargets(args, snapshot, bindIP) + targets, confirm, err := killTargets(args, rows, bindIP) if err != nil { return err } if len(targets) == 0 { - return reportKill(os.Stdout, nil, snapshot, killJSONFlag, killDryRunFlag) + return reportKill(os.Stdout, nil, rows, killJSONFlag, killDryRunFlag) } opts := killOptions() return killRequest{ - exec: killInDaemon(c), + exec: killInDaemon(conn), targets: targets, - snapshot: snapshot, + snapshot: rows, opts: opts, confirm: confirm && !killYesFlag && !killDryRunFlag, asJSON: killJSONFlag, @@ -99,70 +98,70 @@ func killMethodFor(group string, opts killer.Options) string { return "ports.kill" } -// killSelectors turns the targets killTargets resolved into wire selectors. A -// selector sets exactly one of port and pid, which is what the targets already -// are: nothing here re-selects. -func killSelectors(targets []killer.Target) []rpc.Selector { - out := make([]rpc.Selector, 0, len(targets)) - for _, t := range targets { - var sel rpc.Selector +// killSelectors turns resolved targets into wire selectors. A selector carries +// exactly one of port and pid — the targets are already that shape, so nothing +// is re-selected here. +func killSelectors(resolved []killer.Target) []rpc.Selector { + selectors := make([]rpc.Selector, 0, len(resolved)) + for _, target := range resolved { + var selector rpc.Selector switch { - case t.RunID != "": - id := t.RunID - sel.RunID = &id - case t.PID > 0: - pid := t.PID - sel.PID = &pid + case target.RunID != "": + runID := target.RunID + selector.RunID = &runID + case target.PID > 0: + pid := target.PID + selector.PID = &pid default: - port := t.Port - sel.Port = &port - if t.BindAddress != "" { - bind := t.BindAddress - sel.BindAddress = &bind + port := target.Port + selector.Port = &port + if target.BindAddress != "" { + bind := target.BindAddress + selector.BindAddress = &bind } } - out = append(out, sel) + selectors = append(selectors, selector) } - return out + return selectors } -// graceMs is --grace on the wire. Zero means "the daemon's default", which is -// the same default the killer applies locally. +// graceMs is --grace on the wire. Zero means "the daemon's default", the same +// default the killer applies locally. func graceMs(opts killer.Options) int { - if opts.Grace <= 0 { - return 0 + if opts.Grace > 0 { + return int(opts.Grace.Milliseconds()) } - return int(opts.Grace.Milliseconds()) + return 0 } -// killSession is `oberth kill --session `: the session form of `-g`, +// killSession is `oberth kill --session `, the session form of `-g`. It is // answered by `sessions.kill` for the same reason `-g` is answered by -// `groups.kill` — the daemon owns the membership, so it does the selecting. -// There is no direct-scan path: a session only exists in the daemon. +// `groups.kill`: the daemon owns the membership and therefore selects. No +// direct-scan path exists, because a session only exists in the daemon. func killSession(ctx context.Context) error { - id, err := currentSession(killSessionFlag) + sessionID, err := currentSession(killSessionFlag) if err != nil { return err } - c, err := sessionsDaemon(ctx) + conn, err := sessionsDaemon(ctx) if err != nil { return err } - defer c.Close() + defer conn.Close() - snapshot, err := daemonList(ctx, c) + snapshot, err := daemonList(ctx, conn) if err != nil { return cliError(err) } return killRequest{ - exec: killSessionInDaemon(c, id), + exec: killSessionInDaemon(conn, sessionID), snapshot: snapshot, opts: killOptions(), confirm: !killYesFlag && !killDryRunFlag, asJSON: killJSONFlag, emptyPlan: func() { - fmt.Printf("Session %s has nothing running.\n", id) + fmt.Printf("Session %s has nothing running.\n", sessionID) }, }.execute(ctx) } diff --git a/engine/internal/cmd/logs.go b/engine/internal/cmd/logs.go index 969336b0..02dc708a 100644 --- a/engine/internal/cmd/logs.go +++ b/engine/internal/cmd/logs.go @@ -141,10 +141,7 @@ func logsDirect(_ context.Context, port int, bindIP string) error { groups.StampRuns(enriched) *lp = enriched[0] - fmt.Printf("%s %s (PID %s)\n\n", - display.Dim("Attaching to"), - display.Bold(lp.DisplayName()), - display.Cyan(fmt.Sprintf("%d", lp.PID))) + printAttachHeader(lp.DisplayName(), lp.PID) // Docker containers: use docker logs. if lp.Type == ports.PortTypeDocker && lp.DockerContainer != "" { @@ -418,10 +415,7 @@ func logsFollowThroughDaemon(ctx context.Context, c *client.Client, row ports.Li if !logsJSONFlag { // In JSON mode this header would be plain text in front of a JSON // document. - fmt.Printf("%s %s (PID %s)\n\n", - display.Dim("Attaching to"), - display.Bold(row.DisplayName()), - display.Cyan(fmt.Sprintf("%d", row.PID))) + printAttachHeader(row.DisplayName(), row.PID) } params := rpc.PortsLogsParams{ @@ -430,29 +424,38 @@ func logsFollowThroughDaemon(ctx context.Context, c *client.Client, row ports.Li Follow: true, } - var res rpc.PortsLogsResult - s, err := c.Stream(ctx, "ports.logs", params, &res) + var result rpc.PortsLogsResult + stream, err := c.Stream(ctx, "ports.logs", params, &result) if err != nil { return cliError(err) } - defer s.Close() + defer stream.Close() if logsJSONFlag { - return streamLogsJSON(s, meta) + return streamLogsJSON(stream, meta) } - return printLogStream(s, res.Source) + return printLogStream(stream, result.Source) +} + +// printAttachHeader is the line a follow session opens with: the listener it +// attached to, named and identified. +func printAttachHeader(name string, pid int) { + fmt.Printf("%s %s (PID %s)\n\n", + display.Dim("Attaching to"), + display.Bold(name), + display.Cyan(fmt.Sprintf("%d", pid))) } // printLogStream writes one log line per chunk as it arrives, under the header // naming the source the daemon reported. func printLogStream(s *client.Stream, source string) error { printLogHeader(source) - for raw := range s.Chunks() { - var chunk rpc.PortsLogsChunk - if err := json.Unmarshal(raw, &chunk); err != nil { + for payload := range s.Chunks() { + var entry rpc.PortsLogsChunk + if err := json.Unmarshal(payload, &entry); err != nil { continue } - fmt.Println(chunk.Line) + fmt.Println(entry.Line) } if end := <-s.End(); end.Err != nil { return cliError(end.Err) @@ -695,17 +698,17 @@ func selectBoundPort(rows []ports.ListeningPort, port int, bindIP string) (*port // listenersOnPort is the filter half: every row the selector could mean, in // table order. func listenersOnPort(rows []ports.ListeningPort, port int, bindIP string) []ports.ListeningPort { - var matches []ports.ListeningPort - for _, row := range rows { - if row.Port != port { + var hits []ports.ListeningPort + for _, listener := range rows { + if listener.Port != port { continue } - if bindIP != "" && row.BindAddress != bindIP { + if bindIP != "" && listener.BindAddress != bindIP { continue } - matches = append(matches, row) + hits = append(hits, listener) } - return matches + return hits } // ambiguousBoundPort names the addresses a port answers on and the flag that diff --git a/engine/internal/cmd/logs_unix.go b/engine/internal/cmd/logs_unix.go index 756d6230..18203128 100644 --- a/engine/internal/cmd/logs_unix.go +++ b/engine/internal/cmd/logs_unix.go @@ -14,15 +14,15 @@ import ( // execDockerLogs execs into docker logs for a container. func execDockerLogs(container string) error { - dockerPath, err := exec.LookPath("docker") + binary, err := exec.LookPath("docker") if err != nil { return fmt.Errorf("docker not found in PATH") } - args := append([]string{"docker", "logs"}, followArgs()...) - args = append(args, container) + argv := append([]string{"docker", "logs"}, followArgs()...) + argv = append(argv, container) - return syscall.Exec(dockerPath, args, os.Environ()) + return syscall.Exec(binary, argv, os.Environ()) } // followArgs is the one place `--follow` becomes a collector flag. @@ -90,11 +90,10 @@ func tailProcFD(pid int) error { } if len(paths) == 0 { - return fmt.Errorf("no log sources found for PID %d\n\n%s", - pid, - display.Dim("The process may be writing to a terminal or pipe that cannot be captured.\n"+ - "Try restarting the process with output redirected to a file:\n"+ - " command > output.log 2>&1")) + hint := display.Dim("The process may be writing to a terminal or pipe that cannot be captured.\n" + + "Try restarting the process with output redirected to a file:\n" + + " command > output.log 2>&1") + return fmt.Errorf("no log sources found for PID %d\n\n%s", pid, hint) } printLogTargets(labels) diff --git a/engine/internal/cmd/logs_windows.go b/engine/internal/cmd/logs_windows.go index fb6e0ea9..e7571d77 100644 --- a/engine/internal/cmd/logs_windows.go +++ b/engine/internal/cmd/logs_windows.go @@ -12,22 +12,22 @@ import ( // execDockerLogs runs docker logs for a container. func execDockerLogs(container string) error { - dockerPath, err := exec.LookPath("docker") + binary, err := exec.LookPath("docker") if err != nil { return fmt.Errorf("docker not found in PATH") } - args := []string{"logs"} + argv := []string{"logs"} if logsFollow { - args = append(args, "-f") + argv = append(argv, "-f") } - args = append(args, container) + argv = append(argv, container) - cmd := exec.Command(dockerPath, args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + c := exec.Command(binary, argv...) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return c.Run() } // tailLogSources is not supported on Windows. diff --git a/engine/internal/cmd/root.go b/engine/internal/cmd/root.go index d35d0495..a43da254 100644 --- a/engine/internal/cmd/root.go +++ b/engine/internal/cmd/root.go @@ -382,17 +382,18 @@ func init() { } cfg, warnings := config.Load() - for _, w := range warnings { - fmt.Fprintln(os.Stderr, w) + for _, warning := range warnings { + fmt.Fprintln(os.Stderr, warning) } loadedConfig = cfg - // color: false in config disables ANSI; an explicit --no-color flag - // also wins. We never force color ON (would corrupt piped output). - if cfg.Color != nil && !*cfg.Color { - display.NoColor = true - } + // color: false in the config turns ANSI off, and so does an explicit + // --no-color. Color is never forced ON: that would corrupt piped output. + off := cfg.Color != nil && !*cfg.Color if nc, _ := cmd.Flags().GetBool("no-color"); nc { + off = true + } + if off { display.NoColor = true } diff --git a/engine/internal/cmd/serve.go b/engine/internal/cmd/serve.go index f50f47ac..cb0adc93 100644 --- a/engine/internal/cmd/serve.go +++ b/engine/internal/cmd/serve.go @@ -40,12 +40,13 @@ var serveCmd = &cobra.Command{ } func init() { - serveCmd.Flags().BoolVarP(&serveDetach, "detach", "d", false, + flags := serveCmd.Flags() + flags.BoolVarP(&serveDetach, "detach", "d", false, "Start the daemon in the background and return once it accepts connections") rootCmd.AddCommand(serveCmd) } -func serveRun(cmd *cobra.Command, _ []string) error { +func serveRun(command *cobra.Command, _ []string) error { if err := refuseTestBinaryDaemon(); err != nil { return err } @@ -53,9 +54,9 @@ func serveRun(cmd *cobra.Command, _ []string) error { if serveDetach { // The parent only forks; the process that becomes the daemon is the // child, and it is the one that asks for the PATH below. - return detachDaemon(cmd.Context(), daemon.SocketPath()) + return detachDaemon(command.Context(), daemon.SocketPath()) } - return runForegroundDaemon(cmd.Context()) + return runForegroundDaemon(command.Context()) } // runForegroundDaemon is the process that becomes the daemon. It owns the @@ -95,16 +96,17 @@ func runForegroundDaemon(ctx context.Context) error { ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer stop() - if err := srv.Serve(ctx); err != nil { - if daemon.IsAlreadyRunning(err) { - // Not a crash: another daemon is doing the job. - fmt.Fprintln(os.Stderr, "error:", err) - fmt.Fprintf(os.Stderr, "hint: `oberth daemon status` shows it, `oberth daemon restart` replaces it\n") - os.Exit(1) - } - return err + serveErr := srv.Serve(ctx) + switch { + case serveErr == nil: + return nil + case daemon.IsAlreadyRunning(serveErr): + // Another daemon already does the job; this is a refusal, not a crash. + fmt.Fprintln(os.Stderr, "error:", serveErr) + fmt.Fprintf(os.Stderr, "hint: `oberth daemon status` shows it, `oberth daemon restart` replaces it\n") + os.Exit(1) } - return nil + return serveErr } // enterDaemonWorkDir moves the daemon into option-berth's own state directory. @@ -134,22 +136,22 @@ func openDaemonLogger() (*slog.Logger, io.Closer, error) { daemon.ParseLogLevel(loadedConfig.Daemon.ResolvedLogLevel()), alsoStderr) } -// refuseTestBinaryDaemon stops a Go test binary from becoming the daemon. +// refuseTestBinaryDaemon keeps a Go test binary from turning into the daemon. // // `go test` links a package into .test, and Go's flag parser stops at the // first non-flag argument — so `cmd.test serve --detach` silently ignores both // words and runs the entire test suite in the background against the config -// directory it inherited. Every such process is a stray test run holding the -// user's real ~/.oberth open, not a daemon. Both entry -// points are covered: `serve` refuses here, and `serve --detach` re-execs this -// same binary, so it never gets far enough to fork. +// directory it inherited. A process like that is a stray test run holding the +// user's real ~/.oberth open, not a daemon. Both entry points are covered: +// `serve` refuses here, and `serve --detach` re-execs this same binary, so it +// never gets far enough to fork. func refuseTestBinaryDaemon() error { - exe, err := os.Executable() - if err != nil || !daemon.IsTestBinary(exe) || daemon.TestDaemonAllowed() { + self, err := os.Executable() + if err != nil || !daemon.IsTestBinary(self) || daemon.TestDaemonAllowed() { return nil } return fmt.Errorf("refusing to run %s as the oberth daemon: it is a Go test binary, so this would re-run the test suite rather than serve\nhint: start a daemon in-process from the test, or set %s=1 if you really mean it", - filepath.Base(exe), daemon.AllowTestDaemonEnv) + filepath.Base(self), daemon.AllowTestDaemonEnv) } // detachDaemon re-execs this binary as a background `oberth serve` and waits for @@ -181,7 +183,7 @@ func detachDaemon(ctx context.Context, socket string) error { // the daemon log as its only output — so a panic before its logger is up is // still recoverable. func detachedChild() (*exec.Cmd, *os.File, error) { - exe, err := os.Executable() + binary, err := os.Executable() if err != nil { return nil, nil, fmt.Errorf("locating the option-berth binary: %w", err) } @@ -197,14 +199,14 @@ func detachedChild() (*exec.Cmd, *os.File, error) { return nil, nil, err } - child := exec.Command(exe, "serve") - child.Env = append(os.Environ(), detachedEnv+"=1") - child.Stdin = nil - child.Stdout = logFile - child.Stderr = logFile - child.Dir = workDir - detachProcess(child) - return child, logFile, nil + proc := exec.Command(binary, "serve") + proc.Env = append(os.Environ(), detachedEnv+"=1") + proc.Stdin = nil + proc.Stdout = logFile + proc.Stderr = logFile + proc.Dir = workDir + detachProcess(proc) + return proc, logFile, nil } // startDaemonChild launches the detached child and releases it: the daemon has @@ -259,34 +261,37 @@ func daemonWorkDir() (string, error) { } // stopTimeout bounds how long `daemon stop` and `daemon restart` wait for the -// previous daemon to let go of the socket and the lock. +// previous daemon to release the socket and the lock. const stopTimeout = 10 * time.Second // waitForSocketGone is the inverse of WaitForSocket, used by `daemon restart`. func waitForSocketGone(socket string, timeout time.Duration) { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { + giveUp := time.Now().Add(timeout) + for { if !daemon.SocketAlive(socket) { return } + if !time.Now().Before(giveUp) { + return + } time.Sleep(25 * time.Millisecond) } } // waitForDaemonGone waits until the previous daemon has both stopped accepting -// connections and released the single-instance lock. The socket closes first -// and the lock is released last, so a restart that only waited for the socket -// raced the old daemon's teardown: the replacement started, lost the lock, and -// exited "already running", leaving nothing running. +// connections and released the single-instance lock. The socket closes first, +// the lock goes last, so a restart that waited only for the socket raced the +// old daemon's teardown: the replacement started, lost the lock, and exited +// "already running", leaving nothing running. func waitForDaemonGone(socket string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) + giveUp := time.Now().Add(timeout) waitForSocketGone(socket, timeout) - remaining := time.Until(deadline) - if remaining < time.Second { - remaining = time.Second + left := time.Until(giveUp) + if left < time.Second { + left = time.Second } - if err := daemon.WaitForLockRelease(daemon.LockPath(), remaining); err != nil { + if err := daemon.WaitForLockRelease(daemon.LockPath(), left); err != nil { return fmt.Errorf("the running daemon did not shut down: %w\nhint: `oberth daemon status` shows it; kill it and retry", err) } return nil diff --git a/engine/internal/cmd/start.go b/engine/internal/cmd/start.go index cb33943c..a9b3f69a 100644 --- a/engine/internal/cmd/start.go +++ b/engine/internal/cmd/start.go @@ -24,18 +24,21 @@ import ( ) var ( - startGroup string - startName string - startPort int + // What the run is called and where it belongs. + startGroup string + startName string + startPort int + + // Whether to hand the run to the daemon instead of staying in this shell. startDetach bool - // startSession is the agent session this invocation belongs to, detected + // startSession is the agent session this invocation belongs to: detected // once in startRun and carried into whichever spawn path runs. startSession state.Session ) -// registerTimeout bounds the daemon round-trips around a run. A slow or absent -// daemon must never delay the command the user actually asked for. +// registerTimeout bounds the daemon round-trips around a run; a slow or absent +// daemon must never delay the command the user asked for. const registerTimeout = 5 * time.Second var startCmd = &cobra.Command{ @@ -113,48 +116,49 @@ func startRun(cmd *cobra.Command, args []string) error { // child owns its own process group, signals are forwarded to it and option-berth exits // with the child's code. func startAttached(cmd *cobra.Command, argv []string, cwd string, res spawn.Resolution) error { - // Catch the interrupts before the child exists: a `oberth start` line in a - // dev.sh runs as a background job with SIGINT ignored, and installing a + // Catch interrupts before the child exists: an `oberth start` line in a + // dev.sh runs as a background job with SIGINT ignored, so installing the // handler here is what gives the child a working Ctrl+C again. fwd := spawn.CatchSignals() defer fwd.Stop() - h, err := spawn.Spawn(cmd.Context(), spawn.Request{ - Argv: argv, + request := spawn.Request{ + PortHint: startPort, Cwd: cwd, + Argv: argv, Group: res.Group, Name: res.Name, - PortHint: startPort, Session: startSession, - }) + } + h, err := spawn.Spawn(cmd.Context(), request) if err != nil { return err } fwd.Forward(h) - daemonKnows := registerRun(h) - defer unregisterRun(h.PID, daemonKnows) + inDaemon := registerRun(h) + defer unregisterRun(h.PID, inDaemon) // The run is recorded in the worktree's active workplace as it happens, // with the run identity and log path that make the block checkable later. recorder := startRecording(cwd) defer recorder.Close() blockID := recorder.startShell(argv, h.ID, h.PID, h.LogPath, h.StartedAt) - code, err := h.Wait() + exitCode, err := h.Wait() if err != nil { return fmt.Errorf("running %q: %w", argv[0], err) } // The daemon keeps how it ended, and knows a Ctrl+C we forwarded is not a // crash however the child chose to exit. - finishRun(h.PID, daemonKnows, code, fwd.Interrupted()) - recorder.endShell(blockID, code, fwd.Interrupted()) + finishRun(h.PID, inDaemon, exitCode, fwd.Interrupted()) + recorder.endShell(blockID, exitCode, fwd.Interrupted()) // The record is flushed before the exit below, which skips deferred work. recorder.Close() - if code != 0 { + if exitCode != 0 { // Mirror the child's exit code without cobra printing usage over it. cmd.SilenceUsage, cmd.SilenceErrors = true, true fwd.Stop() - os.Exit(code) + os.Exit(exitCode) } return nil } @@ -183,15 +187,16 @@ func startDetached(cmd *cobra.Command, argv []string, cwd string, res spawn.Reso // No daemon: start it here anyway. The run is recorded in runs.json and // adopted by the next daemon that starts. - h, err := spawn.Spawn(cmd.Context(), spawn.Request{ - Argv: argv, + request := spawn.Request{ + Detach: true, + PortHint: startPort, Cwd: cwd, + Argv: argv, Group: res.Group, Name: res.Name, - PortHint: startPort, Session: startSession, - Detach: true, - }) + } + h, err := spawn.Spawn(cmd.Context(), request) if err != nil { return err } @@ -217,19 +222,19 @@ func recordDetachedRun(cwd string, argv []string, runID string, pid int, logPath // groups.start take, so what the daemon starts runs with the user's PATH, // toolchain and virtualenv rather than the daemon's own. func callerEnv() map[string]string { - out := map[string]string{} - for _, kv := range os.Environ() { + env := make(map[string]string) + for _, entry := range os.Environ() { + key, value, found := strings.Cut(entry, "=") // Windows keeps per-drive entries like "=C:=C:\" with an empty key. - if k, v, ok := strings.Cut(kv, "="); ok && k != "" { - out[k] = v + if found && key != "" { + env[key] = value } } - return out + return env } func printStarted(res spawn.Resolution, pid int, logPath string) { - fmt.Printf("%s %s (pid %d)\n", - display.Dim("started"), display.Cyan(res.Group+"/"+res.Name), pid) + fmt.Printf("%s %s (pid %d)\n", display.Dim("started"), display.Cyan(res.Group+"/"+res.Name), pid) if logPath != "" { fmt.Printf("%s %s\n", display.Dim("logs"), logPath) } @@ -240,21 +245,21 @@ func printStarted(res spawn.Resolution, pid int, logPath string) { // only when there is one, because an absent field is the wire's "none". func detachedSpawnParams(argv []string, cwd string, res spawn.Resolution) rpc.RunsSpawnParams { params := rpc.RunsSpawnParams{ + Group: &res.Group, + Name: &res.Name, Argv: argv, Cwd: cwd, Env: callerEnv(), - Group: &res.Group, - Name: &res.Name, // The CLI is the user: it may start commands anywhere. AllowOutsideHome: true, } if startSession.ID != "" { - s := startSession - params.Session = &s + session := startSession + params.Session = &session } if startPort > 0 { - hint := startPort - params.PortHint = &hint + portHint := startPort + params.PortHint = &portHint } return params } @@ -279,45 +284,45 @@ func registerRun(h *spawn.Handle) bool { // session and port hint only when they carry a value. func registerParams(h *spawn.Handle) rpc.RunsRegisterParams { params := rpc.RunsRegisterParams{ - PID: h.PID, - PPID: h.PPID, + AllowOutsideHome: true, Group: h.Group, Name: h.Name, + PID: h.PID, + PPID: h.PPID, + ID: &h.ID, Cmd: h.Cmd, Cwd: h.Cwd, StartedAt: h.StartedAt.Format(time.RFC3339), - ID: &h.ID, - AllowOutsideHome: true, } if h.Session.ID != "" { - s := h.Session - params.Session = &s + session := h.Session + params.Session = &session } if h.PortHint > 0 { - hint := h.PortHint - params.PortHint = &hint + portHint := h.PortHint + params.PortHint = &portHint } return params } // unregisterRun removes the run again, wherever it was recorded. -func unregisterRun(pid int, daemonKnows bool) { - endRun(pid, daemonKnows, rpc.RunsUnregisterParams{PID: pid}) +func unregisterRun(pid int, inDaemon bool) { + endRun(pid, inDaemon, rpc.RunsUnregisterParams{PID: pid}) } // finishRun reports how an attached run ended, so the daemon keeps it among // the runs that exited. Without a daemon there is nowhere to keep it and the // runs.json entry is simply removed. -func finishRun(pid int, daemonKnows bool, code int, stopped bool) { - endRun(pid, daemonKnows, rpc.RunsUnregisterParams{PID: pid, ExitCode: &code, Stopped: stopped}) +func finishRun(pid int, inDaemon bool, code int, stopped bool) { + endRun(pid, inDaemon, rpc.RunsUnregisterParams{PID: pid, ExitCode: &code, Stopped: stopped}) } // endRun removes the run from wherever it was recorded. params carries the // exit facts only when this call ends a run. Cleanup never autostarts a // daemon, and losing the daemon after registration leaves runs.json as the // place to remove from. -func endRun(pid int, daemonKnows bool, params rpc.RunsUnregisterParams) { - if daemonKnows { +func endRun(pid int, inDaemon bool, params rpc.RunsUnregisterParams) { + if inDaemon { var out rpc.OKResult if err := daemonCall(context.Background(), connectRunningDaemon, "runs.unregister", params, &out); err == nil { return @@ -346,16 +351,16 @@ func daemonCall(ctx context.Context, dial func(context.Context) (*client.Client, // the group so an older `oberth list` still attributes the ports. func fallbackEntry(h *spawn.Handle) runs.Entry { return runs.Entry{ + ID: h.ID, PID: h.PID, + PPID: h.PPID, Tag: h.Group, - ID: h.ID, - Cmd: h.Cmd, - StartedAt: h.StartedAt.Format(time.RFC3339), Group: h.Group, Name: h.Name, + Cmd: h.Cmd, Cwd: h.Cwd, - PPID: h.PPID, PortHint: h.PortHint, + StartedAt: h.StartedAt.Format(time.RFC3339), } } diff --git a/engine/internal/cmd/terminal_windows.go b/engine/internal/cmd/terminal_windows.go index 8af5a38a..6b036c6f 100644 --- a/engine/internal/cmd/terminal_windows.go +++ b/engine/internal/cmd/terminal_windows.go @@ -9,26 +9,28 @@ import ( ) var ( - kernel32 = syscall.NewLazyDLL("kernel32.dll") - procGetConsoleMode = kernel32.NewProc("GetConsoleMode") - procSetConsoleMode = kernel32.NewProc("SetConsoleMode") - enableVirtualTerminalProcessing uint32 = 0x0004 + k32 = syscall.NewLazyDLL("kernel32.dll") + queryConsoleMode = k32.NewProc("GetConsoleMode") + applyConsoleMode = k32.NewProc("SetConsoleMode") + vtOutputModeFlag uint32 = 0x0004 ) -var vtEnabled bool +// vtReady records that this process already asked the console for VT support; +// the request is made at most once. +var vtReady bool -// enableVTProcessing enables ANSI escape code support on Windows consoles. +// enableVTProcessing turns on ANSI escape code support for a Windows console. func enableVTProcessing() { - if vtEnabled { + if vtReady { return } - vtEnabled = true + vtReady = true - handle := os.Stdout.Fd() + out := os.Stdout.Fd() var mode uint32 - r, _, _ := procGetConsoleMode.Call(handle, uintptr(unsafe.Pointer(&mode))) - if r == 0 { + ok, _, _ := queryConsoleMode.Call(out, uintptr(unsafe.Pointer(&mode))) + if ok == 0 { return } - procSetConsoleMode.Call(handle, uintptr(mode|enableVirtualTerminalProcessing)) + applyConsoleMode.Call(out, uintptr(mode|vtOutputModeFlag)) } diff --git a/engine/internal/cmd/up.go b/engine/internal/cmd/up.go index 5477f02b..2298d0ea 100644 --- a/engine/internal/cmd/up.go +++ b/engine/internal/cmd/up.go @@ -178,35 +178,39 @@ func projectStartParams(args []string, only []string, allowOutsideHome bool) (rp Only: only, AllowOutsideHome: allowOutsideHome, } - // The services run as if started from this shell: the toolchain they need is - // this machine's, and there is no other machine to be confused about. + // Services inherit this shell's environment: the toolchain they need is this + // machine's, and there is no other machine to be confused about. params.Env = callerEnv() if len(args) == 1 { - // Named from anywhere: the file is the daemon's to find, so there is - // nothing here to check against it. - name := strings.TrimSpace(args[0]) - params.Name = &name + // A named project is resolved by the daemon, which is the side that + // reads the file, so nothing here has to check it first. + named := strings.TrimSpace(args[0]) + params.Name = &named return params, nil, nil } - wd, err := os.Getwd() + cwd, err := os.Getwd() if err != nil { return params, nil, fmt.Errorf("resolving the working directory: %w", err) } index := groups.NewIndex() - index.Observe(wd) - cfg := index.Nearest(wd) - if cfg == nil { - // A file that is there but broken is a different problem from no file - // at all, and saying so is the difference between a two-second fix and - // a puzzled `ls -a`. - if bad := index.Invalid(); len(bad) > 0 { - return params, nil, fmt.Errorf("%s cannot be used: %w", groups.ConfigName, bad[0].Err) - } - return params, nil, fmt.Errorf("no %s at or above %s\nhint: `oberth init` writes one, or name a project: `oberth up `", - groups.ConfigName, shortPath(wd)) + index.Observe(cwd) + found := index.Nearest(cwd) + if found == nil { + return params, nil, missingConfigError(index, cwd) } - params.ConfigPath = &cfg.Path - return params, cfg, nil + params.ConfigPath = &found.Path + return params, found, nil +} + +// missingConfigError explains why no manifest answered for dir. A file that is +// there but unparseable is a different problem from no file at all, and naming +// the difference is what separates a two-second fix from a puzzled `ls -a`. +func missingConfigError(index *groups.Index, dir string) error { + if invalid := index.Invalid(); len(invalid) > 0 { + return fmt.Errorf("%s cannot be used: %w", groups.ConfigName, invalid[0].Err) + } + return fmt.Errorf("no %s at or above %s\nhint: `oberth init` writes one, or name a project: `oberth up `", + groups.ConfigName, shortPath(dir)) } // consumeStart prints one line per service as the daemon reports it, then the @@ -218,7 +222,7 @@ func consumeStart(stream *client.Stream, asJSON bool) error { func consumeStartWithWait(ctx context.Context, c *client.Client, stream *client.Stream, asJSON, wait bool, waitTimeout time.Duration, waitTimeoutSet bool, params rpc.GroupsStartParams, cfg *groups.Config) error { - chunks, summary, err := collectStartChunks(stream, os.Stderr, wait, asJSON) + results, summary, err := collectStartChunks(stream, os.Stderr, wait, asJSON) if err != nil { return err } @@ -227,7 +231,7 @@ func consumeStartWithWait(ctx context.Context, c *client.Client, stream *client. if !asJSON { fmt.Println(display.Dim("waiting for services to be ready…")) } - timedOut, err := waitForStartReady(ctx, params, cfg, chunks, &summary, waitTimeout, waitTimeoutSet) + timedOut, err := waitForStartReady(ctx, params, cfg, results, &summary, waitTimeout, waitTimeoutSet) if err != nil { return err } @@ -240,13 +244,13 @@ func consumeStartWithWait(ctx context.Context, c *client.Client, stream *client. if err := printJSON(struct { Services []rpc.GroupsStartChunk `json:"services"` rpc.GroupsStartEnd - }{Services: chunks, GroupsStartEnd: summary}); err != nil { + }{Services: results, GroupsStartEnd: summary}); err != nil { return err } } else { if wait { - for _, chunk := range chunks { - printStartChunk(chunk) + for _, result := range results { + printStartChunk(result) } } printStartSummary(summary) @@ -347,40 +351,40 @@ type startChunkStream interface { // go to progress (stderr in production) and are dropped here, so services[], // the readiness wait and the summary never treat them as services. func collectStartChunks(stream startChunkStream, progress io.Writer, wait, asJSON bool) ([]rpc.GroupsStartChunk, rpc.GroupsStartEnd, error) { - var chunks []rpc.GroupsStartChunk - for chunk := range stream.Chunks() { - var c rpc.GroupsStartChunk - if err := json.Unmarshal(chunk, &c); err != nil { + var outcomes []rpc.GroupsStartChunk + for payload := range stream.Chunks() { + var outcome rpc.GroupsStartChunk + if err := json.Unmarshal(payload, &outcome); err != nil { continue } - if isStartProgressChunk(c) { + if isStartProgressChunk(outcome) { // A daemon may still send progress when a peer asked for it; // this invocation's own contract decides whether it is shown. if !asJSON { - printStartProgress(progress, c) + printStartProgress(progress, outcome) } continue } - normalizeStartChunk(&c) + normalizeStartChunk(&outcome) if !wait { // This invocation did not wait, so it cannot claim readiness. // Say only what it knows: the daemon reported a started process. - markStartChunkUnconfirmed(&c) + markStartChunkUnconfirmed(&outcome) } - chunks = append(chunks, c) + outcomes = append(outcomes, outcome) if !asJSON && !wait { - printStartChunk(c) + printStartChunk(outcome) } } end := <-stream.End() var summary rpc.GroupsStartEnd if end.Err != nil { - return chunks, summary, cliError(end.Err) + return outcomes, summary, cliError(end.Err) } if err := end.Decode(&summary); err != nil { - return chunks, summary, err + return outcomes, summary, err } - return chunks, summary, nil + return outcomes, summary, nil } // isStartProgressChunk reports whether a chunk is an in-flight observation @@ -490,9 +494,9 @@ func printStartChunk(c rpc.GroupsStartChunk) { default: // The address, not the port number: a terminal makes a URL clickable, // and opening the thing you just started is the next thing you do. - where := display.Dim(fmt.Sprintf("pid %d %s", c.PID, shortPath(c.LogPath))) + location := display.Dim(fmt.Sprintf("pid %d %s", c.PID, shortPath(c.LogPath))) if c.Port > 0 { - where = display.Underline(groups.URL(c.Port)) + " " + where + location = display.Underline(groups.URL(c.Port)) + " " + location } marker, suffix := display.Green("✓"), "" if c.Ready == nil || !*c.Ready { @@ -503,13 +507,14 @@ func printStartChunk(c rpc.GroupsStartChunk) { if len(c.IncludedBy) > 0 { suffix += " " + display.Dim("(dependency of "+strings.Join(c.IncludedBy, ", ")+")") } - fmt.Printf(" %s %s %s%s\n", marker, display.Bold(c.Service), where, suffix) + fmt.Printf(" %s %s %s%s\n", marker, display.Bold(c.Service), location, suffix) } } func printStartSummary(end rpc.GroupsStartEnd) { parts := []string{fmt.Sprintf("%d started", len(end.Started))} - if len(end.Skipped) > 0 { + switch { + case len(end.Skipped) > 0: parts = append(parts, fmt.Sprintf("%d already running", len(end.Skipped))) } if len(end.Errors) > 0 { @@ -524,12 +529,18 @@ func printStartSummary(end rpc.GroupsStartEnd) { // shortPath renders a path under the home directory as ~/…. func shortPath(path string) string { home, err := os.UserHomeDir() - if err != nil || home == "" || path == "" || !strings.HasPrefix(path, home) { + if err != nil { + return path + } + switch { + case home == "", path == "": + return path + case !strings.HasPrefix(path, home): return path } - rel, err := filepath.Rel(home, path) + rest, err := filepath.Rel(home, path) if err != nil { return path } - return filepath.Join("~", rel) + return filepath.Join("~", rest) } From 9821e2d4687f8bda5582e5032dc4f512726fb66d Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 14:15:48 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test(engine):=20cmd=20=E7=9A=84=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E5=A4=B9=E5=85=B7=E4=B8=8E=E6=96=AD=E8=A8=80=E6=8C=89?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E9=87=8D=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 夹具的端口、pid、服务名与 run 标识换成等价取值并同步断言与 golden JSON; 表驱动用例与辅助函数重构,跨平台用例的入参和期望一起更新。373 个测试 函数与改动前一致,断言数量未减少,未删用例或加 Skip。 Co-Authored-By: Claude Code --- engine/internal/cmd/daemonroute_test.go | 125 +++++---- engine/internal/cmd/doctor_configname_test.go | 50 ++-- engine/internal/cmd/doctor_test.go | 187 ++++++------- engine/internal/cmd/isolation_test.go | 78 +++--- engine/internal/cmd/kill_test.go | 246 +++++++++--------- engine/internal/cmd/killroute_test.go | 174 +++++++------ engine/internal/cmd/sessions_test.go | 40 +-- engine/internal/cmd/up_test.go | 82 +++--- 8 files changed, 501 insertions(+), 481 deletions(-) diff --git a/engine/internal/cmd/daemonroute_test.go b/engine/internal/cmd/daemonroute_test.go index 5cbe2eb8..34511291 100644 --- a/engine/internal/cmd/daemonroute_test.go +++ b/engine/internal/cmd/daemonroute_test.go @@ -18,82 +18,81 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/scanner" ) -// routedRows is the scan the in-process daemon serves. Nothing here is -// enriched, so what the daemon publishes and what the direct path renders from -// the same rows are comparable byte for byte. Groups are left unset: both paths -// run the resolver, and it is the resolver's answer that has to agree. +// routedRows is the scan the in-process daemon serves. None of it is enriched, +// so the rows the daemon publishes and the rows the direct path renders are +// comparable byte for byte. Groups stay unset: both paths run the resolver, and +// it is the resolver's answer that has to agree. func routedRows() []ports.ListeningPort { return []ports.ListeningPort{ { - Port: 3000, PID: 100, Process: "node", Command: "node server.js", BindAddress: "127.0.0.1", IPVersion: "IPv4", Type: ports.PortTypeUser, + Port: 3000, PID: 100, Process: "node", Command: "node server.js", User: "dev", Cwd: "/home/dev/web", }, { - Port: 5432, PID: 200, Process: "com.docker.backend", BindAddress: "0.0.0.0", IPVersion: "IPv4", Type: ports.PortTypeDocker, + Port: 5432, PID: 200, Process: "com.docker.backend", DockerContainer: "db-1", DockerImage: "postgres:17", DockerComposeService: "db", DockerComposeProject: "shop", DockerContainerPort: 5432, }, { - Port: 7000, PID: 300, Process: "Figma", BindAddress: "127.0.0.1", IPVersion: "IPv4", Type: ports.PortTypeUser, + Port: 7000, PID: 300, Process: "Figma", }, } } -// startTestDaemon runs a daemon over a real socket with a fixed scan result and -// points the CLI's dialer at it for the rest of the test. -func startTestDaemon(t *testing.T, rows []ports.ListeningPort) { +// startTestDaemon brings up a daemon over a real socket whose scan answers with +// rows, and points the CLI's dialers at it for the rest of the test. +func startTestDaemon(t *testing.T, table []ports.ListeningPort) { t.Helper() if runtime.GOOS == "windows" { t.Skip("the unix-socket harness does not apply to named pipes") } - // Not t.TempDir(): a unix socket path is capped at ~104 bytes on macOS and - // the test name would blow the budget. - dir, err := os.MkdirTemp("", "sn") + // Deliberately not t.TempDir(): a unix socket path is capped at ~104 bytes on + // macOS, and the test name alone would blow that budget. + socketDir, err := os.MkdirTemp("", "sn") if err != nil { t.Fatalf("temp dir: %v", err) } - t.Cleanup(func() { os.RemoveAll(dir) }) - socket := filepath.Join(dir, "d.sock") + t.Cleanup(func() { os.RemoveAll(socketDir) }) + socket := filepath.Join(socketDir, "d.sock") - srv := daemon.New(daemon.Options{ - Socket: socket, - Version: "test", + server := daemon.New(daemon.Options{ Scanner: scanner.New(scanner.Options{ DaemonVersion: "test", Scan: func(scanner.Include) ([]ports.ListeningPort, error) { - return append([]ports.ListeningPort{}, rows...), nil + return append([]ports.ListeningPort{}, table...), nil }, }), + Socket: socket, + Version: "test", }) - ctx, cancel := context.WithCancel(context.Background()) - go func() { _ = srv.Serve(ctx) }() + ctx, stop := context.WithCancel(context.Background()) + go func() { _ = server.Serve(ctx) }() t.Cleanup(func() { - cancel() - <-srv.Done() + stop() + <-server.Done() }) if err := client.WaitForSocket(ctx, socket, 5*time.Second); err != nil { t.Fatalf("daemon did not come up: %v", err) } - prev := dialDaemon - dialDaemon = func(ctx context.Context) (*client.Client, error) { - return client.Dial(ctx, client.ClientInfo{Name: "cli", Version: "test", Socket: socket}) - } - t.Cleanup(func() { dialDaemon = prev }) - - // The same daemon is where this CLI writes. A command that autostarts would - // otherwise start a second daemon of its own instead of reaching this one — - // which is what the harness is here to prevent. - prevWrite := connectForWrite - connectForWrite = func(ctx context.Context) (*client.Client, error) { + savedDialer := dialDaemon + reach := func(ctx context.Context) (*client.Client, error) { return client.Dial(ctx, client.ClientInfo{Name: "cli", Version: "test", Socket: socket}) } - t.Cleanup(func() { connectForWrite = prevWrite }) + dialDaemon = reach + t.Cleanup(func() { dialDaemon = savedDialer }) + + // Reads and writes must both land on this daemon: a command that autostarts + // would otherwise start a second daemon of its own instead of reaching this + // one, which is precisely what the harness exists to prevent. + savedWrite := connectForWrite + connectForWrite = reach + t.Cleanup(func() { connectForWrite = savedWrite }) } // noDaemonReachable makes every connection attempt fail, the way an empty @@ -115,56 +114,56 @@ func noDaemonReachable(t *testing.T) { // keep, so tests do not leak into each other. func resetRouting(t *testing.T) { t.Helper() - prevFlag := noDaemonFlag + savedFlag := noDaemonFlag noDaemonFlag = false fallbackNoteOnce = sync.Once{} t.Cleanup(func() { - noDaemonFlag = prevFlag + noDaemonFlag = savedFlag fallbackNoteOnce = sync.Once{} }) } -// captureStderr collects what fn writes to the real os.Stderr, which is where -// the fallback note goes. +// captureStderr collects what fn wrote to the real os.Stderr; the fallback note +// goes there. func captureStderr(t *testing.T, fn func()) string { t.Helper() - r, w, err := os.Pipe() + reader, writer, err := os.Pipe() if err != nil { t.Fatalf("pipe: %v", err) } - prev := os.Stderr - os.Stderr = w + saved := os.Stderr + os.Stderr = writer - done := make(chan string, 1) + collected := make(chan string, 1) go func() { - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - done <- buf.String() + var sink bytes.Buffer + _, _ = io.Copy(&sink, reader) + collected <- sink.String() }() fn() - os.Stderr = prev - w.Close() - out := <-done - r.Close() - return out + os.Stderr = saved + writer.Close() + text := <-collected + reader.Close() + return text } func TestFallbackNoteIsPrintedOncePerInvocation(t *testing.T) { resetRouting(t) noDaemonReachable(t) - out := captureStderr(t, func() { + stderrText := captureStderr(t, func() { for range 3 { - if c := daemonClient(context.Background()); c != nil { + if conn := daemonClient(context.Background()); conn != nil { t.Error("daemonClient returned a client with no daemon reachable") - c.Close() + conn.Close() } } }) - if n := bytes.Count([]byte(out), []byte("note: daemon unavailable, using direct scan")); n != 1 { - t.Fatalf("the fallback note appeared %d times, want exactly 1:\n%s", n, out) + if count := bytes.Count([]byte(stderrText), []byte("note: daemon unavailable, using direct scan")); count != 1 { + t.Fatalf("the fallback note appeared %d times, want exactly 1:\n%s", count, stderrText) } } @@ -189,20 +188,20 @@ func TestNoDaemonFlagNeverDialsAndSaysNothing(t *testing.T) { resetRouting(t) noDaemonFlag = true - prev := dialDaemon + saved := dialDaemon dialDaemon = func(context.Context) (*client.Client, error) { t.Error("--no-daemon still dialled the daemon") return nil, errors.New("unreachable") } - t.Cleanup(func() { dialDaemon = prev }) + t.Cleanup(func() { dialDaemon = saved }) - out := captureStderr(t, func() { - if c := daemonClient(context.Background()); c != nil { + stderrText := captureStderr(t, func() { + if conn := daemonClient(context.Background()); conn != nil { t.Error("--no-daemon returned a client") - c.Close() + conn.Close() } }) - if out != "" { - t.Fatalf("--no-daemon printed %q; it is a deliberate choice, not a fallback", out) + if stderrText != "" { + t.Fatalf("--no-daemon printed %q; it is a deliberate choice, not a fallback", stderrText) } } diff --git a/engine/internal/cmd/doctor_configname_test.go b/engine/internal/cmd/doctor_configname_test.go index 63ce58bc..fa1f03c1 100644 --- a/engine/internal/cmd/doctor_configname_test.go +++ b/engine/internal/cmd/doctor_configname_test.go @@ -9,20 +9,20 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/groups" ) -// TestRenameLegacyConfigPlain: outside git the fix is a plain rename. +// TestRenameLegacyConfigPlain: outside git the repair is a plain rename. func TestRenameLegacyConfigPlain(t *testing.T) { - dir := t.TempDir() - old := filepath.Join(dir, groups.LegacyConfigName) - if err := os.WriteFile(old, []byte("name: demo\n"), 0o644); err != nil { - t.Fatal(err) + root := t.TempDir() + legacy := filepath.Join(root, groups.LegacyConfigName) + if err := os.WriteFile(legacy, []byte("name: catalog\n"), 0o644); err != nil { + t.Fatalf("writing the legacy file: %v", err) } - if _, err := renameLegacyConfig(dir, groups.FilesIn(dir)); err != nil { + if _, err := renameLegacyConfig(root, groups.FilesIn(root)); err != nil { t.Fatalf("rename: %v", err) } - if _, err := os.Stat(old); !os.IsNotExist(err) { - t.Errorf("%s is still there", old) + if _, err := os.Stat(legacy); !os.IsNotExist(err) { + t.Errorf("%s is still there", legacy) } - if data, err := os.ReadFile(filepath.Join(dir, groups.ConfigName)); err != nil || string(data) != "name: demo\n" { + if data, err := os.ReadFile(filepath.Join(root, groups.ConfigName)); err != nil || string(data) != "name: catalog\n" { t.Errorf("%s = %q, %v", groups.ConfigName, data, err) } } @@ -32,43 +32,45 @@ func TestRenameLegacyConfigPreservesGitIndex(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not installed") } - dir := t.TempDir() + root := t.TempDir() gitIn := func(args ...string) { t.Helper() - if out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput(); err != nil { + cmd := exec.Command("git", append([]string{"-C", root}, args...)...) + if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %v: %v: %s", args, err, out) } } gitIn("init", "-q") - if err := os.WriteFile(filepath.Join(dir, groups.LegacyConfigName), []byte("name: demo\n"), 0o644); err != nil { - t.Fatal(err) + if err := os.WriteFile(filepath.Join(root, groups.LegacyConfigName), []byte("name: catalog\n"), 0o644); err != nil { + t.Fatalf("writing the legacy file: %v", err) } gitIn("add", groups.LegacyConfigName) - if _, err := renameLegacyConfig(dir, groups.FilesIn(dir)); err != nil { + if _, err := renameLegacyConfig(root, groups.FilesIn(root)); err != nil { t.Fatalf("rename: %v", err) } - if tracked(dir, groups.ConfigName) { + if tracked(root, groups.ConfigName) { t.Errorf("%s was unexpectedly staged by the fix", groups.ConfigName) } - if !tracked(dir, groups.LegacyConfigName) { + if !tracked(root, groups.LegacyConfigName) { t.Errorf("%s was unexpectedly removed from the Git index", groups.LegacyConfigName) } } -// TestRenameLegacyConfigRefusesTwoFiles: with both names present the fix -// touches nothing — one of them would be lost. +// TestRenameLegacyConfigRefusesTwoFiles: with both names present the repair +// touches nothing, because one of the two would be lost. func TestRenameLegacyConfigRefusesTwoFiles(t *testing.T) { - dir := t.TempDir() - for name, body := range map[string]string{groups.ConfigName: "name: new\n", groups.LegacyConfigName: "name: old\n"} { - if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { - t.Fatal(err) + root := t.TempDir() + present := map[string]string{groups.ConfigName: "name: new\n", groups.LegacyConfigName: "name: old\n"} + for name, body := range present { + if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o644); err != nil { + t.Fatalf("writing %s: %v", name, err) } } - if _, err := renameLegacyConfig(dir, groups.FilesIn(dir)); err == nil { + if _, err := renameLegacyConfig(root, groups.FilesIn(root)); err == nil { t.Fatal("renamed with two config files present") } - if data, _ := os.ReadFile(filepath.Join(dir, groups.ConfigName)); string(data) != "name: new\n" { + if data, _ := os.ReadFile(filepath.Join(root, groups.ConfigName)); string(data) != "name: new\n" { t.Errorf("%s changed: %q", groups.ConfigName, data) } } diff --git a/engine/internal/cmd/doctor_test.go b/engine/internal/cmd/doctor_test.go index b2c21b85..b29a69da 100644 --- a/engine/internal/cmd/doctor_test.go +++ b/engine/internal/cmd/doctor_test.go @@ -17,11 +17,13 @@ import ( "github.com/spf13/cobra" ) -func sampleReport() rpc.DaemonDoctorResult { +// doctorSample is one report with a row of every status: ok, skip, fail and +// warn, each carrying the fields the table renders for it. +func doctorSample() rpc.DaemonDoctorResult { return rpc.DaemonDoctorResult{ OK: false, - Version: "v1.2.3", - DaemonVersion: "v1.2.3", + Version: "v2.3.4", + DaemonVersion: "v2.3.4", Checks: []rpc.DoctorCheck{ {ID: "cli_on_path", Status: doctor.StatusOK, Summary: "oberth resolves from PATH", Detail: "/usr/local/bin/oberth"}, {ID: "docker", Status: doctor.StatusSkip, Summary: "docker is not installed"}, @@ -41,15 +43,15 @@ func sampleReport() rpc.DaemonDoctorResult { } func TestRenderDoctorTable(t *testing.T) { - restore := display.NoColor + previous := display.NoColor display.NoColor = true - t.Cleanup(func() { display.NoColor = restore }) + t.Cleanup(func() { display.NoColor = previous }) - var buf bytes.Buffer - renderDoctor(&buf, sampleReport()) - out := buf.String() + var out bytes.Buffer + renderDoctor(&out, doctorSample()) + rendered := out.String() - for _, want := range []string{ + for _, expected := range []string{ "ok cli_on_path", "skip docker", "FAIL config_parses", @@ -60,57 +62,57 @@ func TestRenderDoctorTable(t *testing.T) { "list: [broken", "^", } { - if !strings.Contains(out, want) { - t.Errorf("table is missing %q:\n%s", want, out) + if !strings.Contains(rendered, expected) { + t.Errorf("table is missing %q:\n%s", expected, rendered) } } // A healthy row must not carry a fix hint or its detail. - if strings.Contains(out, "/usr/local/bin/oberth") { - t.Errorf("an ok row should not print its detail:\n%s", out) + if strings.Contains(rendered, "/usr/local/bin/oberth") { + t.Errorf("an ok row should not print its detail:\n%s", rendered) } - if !strings.Contains(out, "something is wrong: 1 fail, 1 warn, 1 ok, 1 skip") { - t.Errorf("verdict is missing or wrong:\n%s", out) + if !strings.Contains(rendered, "something is wrong: 1 fail, 1 warn, 1 ok, 1 skip") { + t.Errorf("verdict is missing or wrong:\n%s", rendered) } } func TestRenderDoctorVerdicts(t *testing.T) { - restore := display.NoColor + previous := display.NoColor display.NoColor = true - t.Cleanup(func() { display.NoColor = restore }) + t.Cleanup(func() { display.NoColor = previous }) cases := []struct { - name string - report rpc.DaemonDoctorResult - want string + label string + input rpc.DaemonDoctorResult + verdict string }{ { - name: "all ok", - report: rpc.DaemonDoctorResult{OK: true, Checks: []rpc.DoctorCheck{ - {ID: "a", Status: doctor.StatusOK, Summary: "fine"}, + label: "all ok", + input: rpc.DaemonDoctorResult{OK: true, Checks: []rpc.DoctorCheck{ + {ID: "only", Status: doctor.StatusOK, Summary: "nothing to report"}, }}, - want: "all good: 1 ok", + verdict: "all good: 1 ok", }, { - name: "warnings only", - report: rpc.DaemonDoctorResult{OK: true, Checks: []rpc.DoctorCheck{ - {ID: "a", Status: doctor.StatusWarn, Summary: "hmm"}, + label: "warnings only", + input: rpc.DaemonDoctorResult{OK: true, Checks: []rpc.DoctorCheck{ + {ID: "only", Status: doctor.StatusWarn, Summary: "check the port table"}, }}, - want: "mostly healthy: 1 warn", + verdict: "mostly healthy: 1 warn", }, } for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - var buf bytes.Buffer - renderDoctor(&buf, tc.report) - if !strings.Contains(buf.String(), tc.want) { - t.Errorf("verdict = %q, want it to contain %q", buf.String(), tc.want) + t.Run(tc.label, func(t *testing.T) { + var out bytes.Buffer + renderDoctor(&out, tc.input) + if !strings.Contains(out.String(), tc.verdict) { + t.Errorf("verdict = %q, want it to contain %q", out.String(), tc.verdict) } }) } } -// TestDoctorJSON drives the command itself, so the JSON a script parses is the -// JSON this test asserts on. +// TestDoctorJSON drives the real command, so the document asserted here is the +// document a script parses. func TestDoctorJSON(t *testing.T) { withDoctorFlags(t, func() { doctorJSONFlag = true @@ -123,79 +125,80 @@ func TestDoctorJSON(t *testing.T) { } }) - var got rpc.DaemonDoctorResult - if err := json.Unmarshal([]byte(out), &got); err != nil { + var doc rpc.DaemonDoctorResult + if err := json.Unmarshal([]byte(out), &doc); err != nil { t.Fatalf("output is not JSON: %v\n%s", err, out) } - if len(got.Checks) != 2 { - t.Fatalf("got %d checks, want the 2 that were selected: %+v", len(got.Checks), got.Checks) + if len(doc.Checks) != 2 { + t.Fatalf("got %d checks, want the 2 that were selected: %+v", len(doc.Checks), doc.Checks) } - ids := []string{got.Checks[0].ID, got.Checks[1].ID} + ids := []string{doc.Checks[0].ID, doc.Checks[1].ID} if ids[0] != "db_ok" || ids[1] != "docker" { t.Errorf("ids = %v, want [db_ok docker] in table order", ids) } - for _, c := range got.Checks { - switch c.Status { + for _, check := range doc.Checks { + switch check.Status { case doctor.StatusOK, doctor.StatusWarn, doctor.StatusFail, doctor.StatusSkip: default: - t.Errorf("%s has status %q", c.ID, c.Status) + t.Errorf("%s has status %q", check.ID, check.Status) } } - if got.Version == "" { - t.Error("version is empty") + if doc.Version == "" { + t.Error("the report carries no version") } } // TestDoctorExitsNonZeroOnAFailure pins the contract a script depends on: the -// command returns errSilent, which Execute turns into exit 1 without printing -// anything more. +// command answers errSilent, which Execute turns into exit 1 and no further +// output. func TestDoctorExitsNonZeroOnAFailure(t *testing.T) { withDoctorFlags(t, func() { // No daemon runs in an isolated test binary, so this one fails. doctorOnlyFlag = []string{"daemon_reachable"} }) - err := errors.New("") - captureStdout(t, func() { err = doctorRun(testCommand(), nil) }) - if !errors.Is(err, errSilent) { - t.Fatalf("error = %v, want errSilent", err) + runErr := errors.New("") + captureStdout(t, func() { runErr = doctorRun(testCommand(), nil) }) + if !errors.Is(runErr, errSilent) { + t.Fatalf("error = %v, want errSilent", runErr) } } func TestDoctorRejectsAnUnknownCheck(t *testing.T) { withDoctorFlags(t, func() { doctorOnlyFlag = []string{"not_a_check"} }) - err := doctorRun(testCommand(), nil) - if err == nil || !strings.Contains(err.Error(), "not_a_check") { - t.Fatalf("error = %v, want it to name the unknown check", err) + runErr := doctorRun(testCommand(), nil) + if runErr == nil || !strings.Contains(runErr.Error(), "not_a_check") { + t.Fatalf("error = %v, want it to name the unknown check", runErr) } } func TestDoctorProjectMustBeADirectory(t *testing.T) { - withDoctorFlags(t, func() { doctorProjectFlag = os.DevNull }) + target := os.DevNull + withDoctorFlags(t, func() { doctorProjectFlag = target }) if _, err := doctorProject(); err == nil { t.Fatal("a --project that is not a directory should be refused") } } // TestEveryFixableCheckHasAFix keeps the two lists in step: a check that -// advertises Fixable with no entry in doctorFixes would print an offer -// `--fix` cannot keep. +// advertises Fixable with nothing registered in doctorFixes would print an +// offer `--fix` cannot keep. func TestEveryFixableCheckHasAFix(t *testing.T) { - have := map[string]bool{} - for _, f := range doctorFixes() { - have[f.id] = true + registered := map[string]bool{} + for _, fix := range doctorFixes() { + registered[fix.id] = true } // The ids the doctor package marks fixable, spelled out here so a new one // has to be considered rather than silently unhandled. for _, id := range []string{ "config_parses", "project_config", "daemon_reachable", } { - if !have[id] { + if !registered[id] { t.Errorf("no fix registered for %s", id) } } - for _, f := range doctorFixes() { - if !contains(doctor.IDs(), f.id) { - t.Errorf("fix %s repairs a check that does not exist", f.id) + for _, fix := range doctorFixes() { + if !contains(doctor.IDs(), fix.id) { + t.Errorf("fix %s repairs a check that does not exist", fix.id) } } } @@ -206,9 +209,9 @@ func TestEveryFixableCheckHasAFix(t *testing.T) { func TestDoctorFixMatchingIsExact(t *testing.T) { fix := doctorFix{id: "config_parses"} for _, id := range []string{"config_parses", "config_parses.claude_code", "config", "config_parse"} { - want := id == "config_parses" - if got := fix.matches(id); got != want { - t.Errorf("matches(%q) = %v, want %v", id, got, want) + expected := id == "config_parses" + if matched := fix.matches(id); matched != expected { + t.Errorf("matches(%q) = %v, want %v", id, matched, expected) } } } @@ -217,32 +220,40 @@ func TestDoctorFixMatchingIsExact(t *testing.T) { // are repaired, in report order, and a fixable row without a registered fix // contributes nothing rather than a guessed repair. func TestSelectDoctorFixesFollowsTheReport(t *testing.T) { - todo := selectDoctorFixes(sampleReport()) + todo := selectDoctorFixes(doctorSample()) if len(todo) != 1 || todo[0].id != "config_parses" { t.Fatalf("todo = %+v, want only config_parses", todo) } } -func contains(haystack []string, needle string) bool { - for _, s := range haystack { - if s == needle { - return true +func contains(list []string, want string) bool { + found := false + for _, item := range list { + if item == want { + found = true + break } } - return false + return found } -// ---------------------------------------------------------------- helpers --- +// ------------------------------------------------------------------ shared --- -// withDoctorFlags sets the command's package-level flags for one test and puts -// them back afterwards. +// withDoctorFlags installs one set of package-level flag values for a test and +// restores the previous ones when it ends. func withDoctorFlags(t *testing.T, set func()) { t.Helper() - oldJSON, oldFix, oldYes := doctorJSONFlag, doctorFixFlag, doctorYesFlag - oldOnly, oldProject := doctorOnlyFlag, doctorProjectFlag + type flagSet struct { + json bool + fix bool + yes bool + only []string + project string + } + saved := flagSet{doctorJSONFlag, doctorFixFlag, doctorYesFlag, doctorOnlyFlag, doctorProjectFlag} t.Cleanup(func() { - doctorJSONFlag, doctorFixFlag, doctorYesFlag = oldJSON, oldFix, oldYes - doctorOnlyFlag, doctorProjectFlag = oldOnly, oldProject + doctorJSONFlag, doctorFixFlag, doctorYesFlag = saved.json, saved.fix, saved.yes + doctorOnlyFlag, doctorProjectFlag = saved.only, saved.project }) doctorJSONFlag, doctorFixFlag, doctorYesFlag = false, false, false doctorOnlyFlag, doctorProjectFlag = nil, "" @@ -250,9 +261,9 @@ func withDoctorFlags(t *testing.T, set func()) { } func testCommand() *cobra.Command { - cmd := &cobra.Command{} - cmd.SetContext(context.Background()) - return cmd + c := &cobra.Command{} + c.SetContext(context.Background()) + return c } // applyDoctorFixesReport drives the repair round around one report and returns @@ -293,7 +304,7 @@ func TestApplyDoctorFixesAsksBeforeTouchingAnything(t *testing.T) { withDoctorFlags(t, func() { doctorYesFlag = false }) answerNextPrompt(t) - out, applied, err := applyDoctorFixesReport(t, sampleReport()) + out, applied, err := applyDoctorFixesReport(t, doctorSample()) if err != nil { t.Fatalf("applyDoctorFixes: %v", err) } @@ -323,13 +334,13 @@ func TestApplyDoctorFixesRepairsTheBrokenConfig(t *testing.T) { path := config.Path() if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - t.Fatal(err) + t.Fatalf("creating the config directory: %v", err) } if err := os.WriteFile(path, []byte("daemon: [broken\n"), 0o600); err != nil { - t.Fatal(err) + t.Fatalf("writing the broken config: %v", err) } - out, applied, err := applyDoctorFixesReport(t, sampleReport()) + out, applied, err := applyDoctorFixesReport(t, doctorSample()) if err != nil { t.Fatalf("applyDoctorFixes: %v", err) } @@ -342,7 +353,7 @@ func TestApplyDoctorFixesRepairsTheBrokenConfig(t *testing.T) { entries, readErr := os.ReadDir(filepath.Dir(path)) if readErr != nil { - t.Fatal(readErr) + t.Fatalf("reading the config directory: %v", readErr) } kept := 0 for _, entry := range entries { diff --git a/engine/internal/cmd/isolation_test.go b/engine/internal/cmd/isolation_test.go index 222024dc..e8b63773 100644 --- a/engine/internal/cmd/isolation_test.go +++ b/engine/internal/cmd/isolation_test.go @@ -15,71 +15,77 @@ import ( ) // This is the package that leaked. Its tests drive the real commands, so every -// path the CLI resolves has to land in the test's own temp directory — checked -// through the production resolvers, not through a restatement of them. +// path the CLI resolves must land in the test's own temp directory, and the +// check goes through the production resolvers rather than a restatement. func TestEverythingTheCLIResolvesIsIsolated(t *testing.T) { - testenv.RequireIsolated(t, - config.Path(), - store.Path(), - daemon.ConfigDir(), + resolved := []string{ daemon.SocketPath(), - daemon.LogPath(), daemon.LockPath(), - ) + daemon.LogPath(), + daemon.ConfigDir(), + config.Path(), + store.Path(), + } + testenv.RequireIsolated(t, resolved...) - // The live install is the thing these tests spent an afternoon writing to, - // so it gets named explicitly rather than only implied by the temp check. - if live := testenv.RealHome(); live != "" { - if under(config.Path(), filepath.Join(live, ".option-berth")) { + // The live install is what these tests spent an afternoon writing to, so it + // is named outright instead of being left implied by the temp check. + if realHome := testenv.RealHome(); realHome != "" { + live := filepath.Join(realHome, ".option-berth") + if under(config.Path(), live) { t.Fatalf("config.Path() = %s: the tests are pointed at the live install", config.Path()) } - if under(store.Path(), filepath.Join(live, ".option-berth")) { + if under(store.Path(), live) { t.Fatalf("store.Path() = %s: the tests would write to the live database", store.Path()) } } } +// under reports whether path is dir itself or lives below it. func under(path, dir string) bool { - rel, err := filepath.Rel(dir, path) - return err == nil && !strings.HasPrefix(rel, "..") + rest, err := filepath.Rel(dir, path) + if err != nil { + return false + } + return !strings.HasPrefix(rest, "..") } // The autostart path is what forked `cmd.test serve --detach`. Two independent -// things now stop it, and both are checked here because either alone would -// have been enough to prevent the 2,150 leaked processes. +// guards stop it now, and both are checked here: either one alone would have +// been enough to prevent the 2,150 leaked processes. func TestAutostartIsRefusedInTests(t *testing.T) { - // One: the process-wide ban TestMain sets. - err := client.Autostart(context.Background(), "", daemon.SocketPath()) - if err == nil { + // Guard one: the process-wide ban TestMain installs. + firstErr := client.Autostart(context.Background(), "", daemon.SocketPath()) + if firstErr == nil { t.Fatal("Autostart succeeded in a test binary") } - if !strings.Contains(err.Error(), client.NoAutostartEnv) { - t.Errorf("error = %v, want it to name %s", err, client.NoAutostartEnv) + if !strings.Contains(firstErr.Error(), client.NoAutostartEnv) { + t.Errorf("error = %v, want it to name %s", firstErr, client.NoAutostartEnv) } - // Two: even with the ban lifted, the executable is a test binary. + // Guard two: even with the ban lifted, the executable is a test binary. testenv.AllowAutostart(t) - err = client.Autostart(context.Background(), "", daemon.SocketPath()) - if err == nil { + secondErr := client.Autostart(context.Background(), "", daemon.SocketPath()) + if secondErr == nil { t.Fatal("Autostart succeeded with a Go test binary as the daemon") } - if !strings.Contains(err.Error(), "test binary") { - t.Errorf("error = %v, want it to say the binary is a test binary", err) + if !strings.Contains(secondErr.Error(), "test binary") { + t.Errorf("error = %v, want it to say the binary is a test binary", secondErr) } } // The production seam on the other side: `oberth serve` inside a test binary -// refuses rather than re-running the suite in the background. +// refuses instead of re-running the suite in the background. func TestServeRefusesToRunAsATestBinary(t *testing.T) { - cmd := &cobra.Command{} - cmd.SetContext(context.Background()) + command := &cobra.Command{} + command.SetContext(context.Background()) - err := serveRun(cmd, nil) - if err == nil { + runErr := serveRun(command, nil) + if runErr == nil { t.Fatal("serve started inside a test binary") } - if !strings.Contains(err.Error(), daemon.AllowTestDaemonEnv) { - t.Errorf("error = %v, want it to name the opt-out %s", err, daemon.AllowTestDaemonEnv) + if !strings.Contains(runErr.Error(), daemon.AllowTestDaemonEnv) { + t.Errorf("error = %v, want it to name the opt-out %s", runErr, daemon.AllowTestDaemonEnv) } t.Setenv(daemon.AllowTestDaemonEnv, "1") @@ -89,8 +95,8 @@ func TestServeRefusesToRunAsATestBinary(t *testing.T) { } // Nothing here starts a daemon of its own, so nothing may be left running. -// TestMain checks this for the whole binary; this makes the failure land on a -// named test when it is this package's fault. +// TestMain checks that for the whole binary; this lands the failure on a named +// test when the fault is this package's. func TestNoDaemonSurvivesThisPackage(t *testing.T) { testenv.RequireNoLeakedDaemons(t) } diff --git a/engine/internal/cmd/kill_test.go b/engine/internal/cmd/kill_test.go index 01984459..82051c51 100644 --- a/engine/internal/cmd/kill_test.go +++ b/engine/internal/cmd/kill_test.go @@ -24,14 +24,14 @@ import ( "github.com/spf13/cobra" ) -func sampleResults() []killer.Result { - const here = state.LocalhostName +func killResults() []killer.Result { + const host = state.LocalhostName return []killer.Result{ - {Host: here, Port: 3000, BindAddress: "127.0.0.1", PID: 400, Name: "esbuild", Method: state.MethodSIGTERM, OK: true}, - {Host: here, Port: 3000, BindAddress: "127.0.0.1", PID: 300, Name: "vite", Method: state.MethodSIGKILL, OK: true}, - {Host: here, Port: 5432, BindAddress: "0.0.0.0", PID: 0, Name: "db", Method: state.MethodDockerStop, OK: true}, - {Host: here, Port: 8080, BindAddress: "127.0.0.1", PID: 900, Name: "nginx", Method: state.MethodNone, OK: false, - Error: "not permitted to signal PID 900"}, + {Host: host, Port: 3100, BindAddress: "127.0.0.1", PID: 410, Name: "bundler", Method: state.MethodSIGTERM, OK: true}, + {Host: host, Port: 3100, BindAddress: "127.0.0.1", PID: 310, Name: "devserver", Method: state.MethodSIGKILL, OK: true}, + {Host: host, Port: 5433, BindAddress: "0.0.0.0", PID: 0, Name: "postgres", Method: state.MethodDockerStop, OK: true}, + {Host: host, Port: 8081, BindAddress: "127.0.0.1", PID: 910, Name: "proxy", Method: state.MethodNone, OK: false, + Error: "not permitted to signal PID 910"}, } } @@ -39,67 +39,67 @@ func sampleResults() []killer.Result { // built against it, so it is pinned here character for character. func TestKillJSONGolden(t *testing.T) { var buf bytes.Buffer - if err := writeKillJSON(&buf, sampleResults()); err != nil { + if err := writeKillJSON(&buf, killResults()); err != nil { t.Fatal(err) } const want = `[ { "host": "localhost", - "port": 3000, + "port": 3100, "bind_address": "127.0.0.1", - "pid": 400, - "name": "esbuild", + "pid": 410, + "name": "bundler", "method": "sigterm", "ok": true }, { "host": "localhost", - "port": 3000, + "port": 3100, "bind_address": "127.0.0.1", - "pid": 300, - "name": "vite", + "pid": 310, + "name": "devserver", "method": "sigkill", "ok": true }, { "host": "localhost", - "port": 5432, + "port": 5433, "bind_address": "0.0.0.0", "pid": 0, - "name": "db", + "name": "postgres", "method": "docker_stop", "ok": true }, { "host": "localhost", - "port": 8080, + "port": 8081, "bind_address": "127.0.0.1", - "pid": 900, - "name": "nginx", + "pid": 910, + "name": "proxy", "method": "none", "ok": false, - "error": "not permitted to signal PID 900" + "error": "not permitted to signal PID 910" } ] ` - if got := buf.String(); got != want { - t.Fatalf("--json output:\n%s\nwant:\n%s", got, want) + if rendered := buf.String(); rendered != want { + t.Fatalf("--json output:\n%s\nwant:\n%s", rendered, want) } } func TestKillJSONEmptyIsAnEmptyArray(t *testing.T) { - var buf bytes.Buffer - if err := writeKillJSON(&buf, nil); err != nil { - t.Fatal(err) + var out bytes.Buffer + if err := writeKillJSON(&out, nil); err != nil { + t.Fatalf("writeKillJSON: %v", err) } - if got := buf.String(); got != "[]\n" { - t.Fatalf("got %q, want an empty array", got) + if rendered := out.String(); rendered != "[]\n" { + t.Fatalf("rendered %q, want an empty array", rendered) } } func TestReportKillJSONExitsNonZeroOnFailure(t *testing.T) { var buf bytes.Buffer - err := reportKill(&buf, sampleResults(), nil, true, false) + err := reportKill(&buf, killResults(), nil, true, false) if !errors.Is(err, errSilent) { t.Fatalf("err = %v, want the silent sentinel so --json output stands alone", err) } @@ -107,7 +107,7 @@ func TestReportKillJSONExitsNonZeroOnFailure(t *testing.T) { t.Fatal("the full result list must still be printed") } - ok := sampleResults()[:3] + ok := killResults()[:3] if err := reportKill(&bytes.Buffer{}, ok, nil, true, false); err != nil { t.Fatalf("err = %v, want nil when every target succeeded", err) } @@ -140,27 +140,27 @@ func TestReportKillTreatsAnAlreadyGoneTargetAsStopped(t *testing.T) { func TestReportKillText(t *testing.T) { display.NoColor = true - snapshot := []ports.ListeningPort{ - {Port: 3000, BindAddress: "127.0.0.1", PID: 300}, - {Port: 5432, BindAddress: "0.0.0.0", PID: 900}, + table := []ports.ListeningPort{ + {Port: 3100, BindAddress: "127.0.0.1", PID: 310}, + {Port: 5433, BindAddress: "0.0.0.0", PID: 910}, } var buf bytes.Buffer - err := reportKill(&buf, sampleResults(), snapshot, false, false) + err := reportKill(&buf, killResults(), table, false, false) if err == nil { t.Fatal("a failed target must make the command exit 1") } - got := buf.String() - for _, want := range []string{ - "stopped (SIGTERM) esbuild (PID 400) on port 3000", - "stopped (SIGKILL) vite (PID 300) on port 3000", - "stopped container db on port 5432", - "error: not permitted to signal PID 900", - "released http://127.0.0.1:3000", - "released http://localhost:5432", + rendered := buf.String() + for _, expected := range []string{ + "stopped (SIGTERM) bundler (PID 410) on port 3100", + "stopped (SIGKILL) devserver (PID 310) on port 3100", + "stopped container postgres on port 5433", + "error: not permitted to signal PID 910", + "released http://127.0.0.1:3100", + "released http://localhost:5433", "3/4 stopped.", } { - if !strings.Contains(got, want) { - t.Errorf("output is missing %q:\n%s", want, got) + if !strings.Contains(rendered, expected) { + t.Errorf("output is missing %q:\n%s", expected, rendered) } } } @@ -168,24 +168,24 @@ func TestReportKillText(t *testing.T) { func TestReportKillDryRunSaysWould(t *testing.T) { display.NoColor = true var buf bytes.Buffer - if err := reportKill(&buf, sampleResults()[:1], nil, false, true); err != nil { + if err := reportKill(&buf, killResults()[:1], nil, false, true); err != nil { t.Fatal(err) } - got := buf.String() - if !strings.Contains(got, "Dry run: 1 action(s), children first.") { - t.Errorf("missing the dry-run header:\n%s", got) + rendered := buf.String() + if !strings.Contains(rendered, "Dry run: 1 action(s), children first.") { + t.Errorf("missing the dry-run header:\n%s", rendered) } - if !strings.Contains(got, "would stop (SIGTERM) esbuild") { - t.Errorf("a dry run reports what would happen:\n%s", got) + if !strings.Contains(rendered, "would stop (SIGTERM) bundler") { + t.Errorf("a dry run reports what would happen:\n%s", rendered) } - if strings.Contains(got, "stopped.") || strings.Contains(got, "released") { - t.Errorf("a dry run must not claim anything happened:\n%s", got) + if strings.Contains(rendered, "stopped.") || strings.Contains(rendered, "released") { + t.Errorf("a dry run must not claim anything happened:\n%s", rendered) } } // ------------------------------------------------------------- selectors --- -func resetKillFlags(t *testing.T) { +func resetKillSelectors(t *testing.T) { t.Helper() killPIDFlag, killGroupFlag, killAllFlag = nil, "", false t.Cleanup(func() { @@ -193,70 +193,70 @@ func resetKillFlags(t *testing.T) { }) } -func killSnapshot() []ports.ListeningPort { +func killTable() []ports.ListeningPort { return []ports.ListeningPort{ - {Port: 3000, PID: 300, BindAddress: "127.0.0.1", Group: "storefront", GroupSource: "auto"}, - {Port: 3001, PID: 301, BindAddress: "127.0.0.1", Tag: "web", RunID: "abc123"}, - {Port: 5432, PID: 900, BindAddress: "0.0.0.0", Type: ports.PortTypeDocker, - DockerContainer: "pg", DockerComposeProject: "my-app"}, - {Port: 7000, PID: 700, BindAddress: "127.0.0.1"}, + {Port: 3100, PID: 310, BindAddress: "127.0.0.1", Group: "shopfront", GroupSource: "auto"}, + {Port: 3101, PID: 311, BindAddress: "127.0.0.1", Tag: "edge", RunID: "run9"}, + {Port: 5433, PID: 910, BindAddress: "0.0.0.0", Type: ports.PortTypeDocker, + DockerContainer: "postgres", DockerComposeProject: "shop-app"}, + {Port: 7100, PID: 710, BindAddress: "127.0.0.1"}, } } func TestKillTargetsByPortAndPID(t *testing.T) { - resetKillFlags(t) + resetKillSelectors(t) killPIDFlag = []int{12345} - targets, confirm, err := killTargets([]string{"3000"}, killSnapshot(), "127.0.0.1") + targets, confirm, err := killTargets([]string{"3100"}, killTable(), "127.0.0.1") if err != nil { t.Fatal(err) } if confirm { t.Error("an explicit port or pid needs no confirmation") } - want := []killer.Target{{PID: 12345}, {Port: 3000, BindAddress: "127.0.0.1"}} + want := []killer.Target{{PID: 12345}, {Port: 3100, BindAddress: "127.0.0.1"}} if len(targets) != 2 || targets[0] != want[0] || targets[1] != want[1] { t.Fatalf("targets = %+v, want %+v", targets, want) } } func TestKillTargetsByGroup(t *testing.T) { - snapshot := killSnapshot() + snapshot := killTable() cases := map[string][]int{ - "storefront": {3000}, // the scanner's group field - "web": {3001}, // a option-berth run tag - "abc123": {3001}, // or its run id - "my-app": {5432}, // a Docker Compose project - "MY-APP": {5432}, // case-insensitively - } - for name, wantPorts := range cases { - resetKillFlags(t) - killGroupFlag = name - targets, confirm, err := killTargets(nil, snapshot, "") + "shopfront": {3100}, // the scanner's group field + "edge": {3101}, // an option-berth run tag + "run9": {3101}, // or its run id + "shop-app": {5433}, // a Docker Compose project + "SHOP-APP": {5433}, // case-insensitively + } + for group, expectedPorts := range cases { + resetKillSelectors(t) + killGroupFlag = group + selected, confirm, err := killTargets(nil, snapshot, "") if err != nil { - t.Fatalf("group %q: %v", name, err) + t.Fatalf("group %q: %v", group, err) } if !confirm { - t.Errorf("group %q: a group kill must confirm unless --yes", name) + t.Errorf("group %q: a group kill must confirm unless --yes", group) } - if len(targets) != len(wantPorts) || targets[0].Port != wantPorts[0] { - t.Errorf("group %q: targets = %+v, want ports %v", name, targets, wantPorts) + if len(selected) != len(expectedPorts) || selected[0].Port != expectedPorts[0] { + t.Errorf("group %q: targets = %+v, want ports %v", group, selected, expectedPorts) } } } func TestKillTargetsUnknownGroup(t *testing.T) { - resetKillFlags(t) + resetKillSelectors(t) killGroupFlag = "nope" - if _, _, err := killTargets(nil, killSnapshot(), ""); err == nil { + if _, _, err := killTargets(nil, killTable(), ""); err == nil { t.Fatal("an unknown group must be an error, not an empty sweep") } } func TestKillTargetsAllIncludesEveryListener(t *testing.T) { - resetKillFlags(t) + resetKillSelectors(t) killAllFlag = true - targets, confirm, err := killTargets(nil, killSnapshot(), "") + targets, confirm, err := killTargets(nil, killTable(), "") if err != nil { t.Fatal(err) } @@ -269,25 +269,25 @@ func TestKillTargetsAllIncludesEveryListener(t *testing.T) { } func TestKillTargetsRejectsMixedSelectors(t *testing.T) { - resetKillFlags(t) - killGroupFlag = "storefront" - if _, _, err := killTargets([]string{"3000"}, killSnapshot(), ""); err == nil { + resetKillSelectors(t) + killGroupFlag = "shopfront" + if _, _, err := killTargets([]string{"3100"}, killTable(), ""); err == nil { t.Error("--group with a positional port must be rejected") } - resetKillFlags(t) + resetKillSelectors(t) killAllFlag = true - if _, _, err := killTargets([]string{"3000"}, killSnapshot(), ""); err == nil { + if _, _, err := killTargets([]string{"3100"}, killTable(), ""); err == nil { t.Error("--all with a positional port must be rejected") } - resetKillFlags(t) - if _, _, err := killTargets(nil, killSnapshot(), ""); err == nil { + resetKillSelectors(t) + if _, _, err := killTargets(nil, killTable(), ""); err == nil { t.Error("no selector at all must be rejected") } - resetKillFlags(t) - if _, _, err := killTargets([]string{"http"}, killSnapshot(), ""); err == nil { + resetKillSelectors(t) + if _, _, err := killTargets([]string{"http"}, killTable(), ""); err == nil { t.Error("a non-numeric argument must be rejected") } } @@ -300,14 +300,14 @@ func TestPositionalTargetReadsAPortFirst(t *testing.T) { port, pid int probes int }{ - {"listener wins over same PID", 3000, true, 3000, 0, 0}, + {"listener wins over same PID", 3100, true, 3100, 0, 0}, {"no listener or process", 4321, false, 4321, 0, 1}, {"live PID without listener", 4321, true, 0, 4321, 1}, {"outside port range", 70000, false, 0, 70000, 0}, } { t.Run(tc.name, func(t *testing.T) { calls := 0 - got := positionalTargetWithProbe(tc.n, killSnapshot(), "127.0.0.1", func(pid int) bool { + got := positionalTargetWithProbe(tc.n, killTable(), "127.0.0.1", func(pid int) bool { calls++ if pid != tc.n { t.Fatal(pid) @@ -330,35 +330,35 @@ func TestPositionalTargetReadsAPortFirst(t *testing.T) { func TestKillTargetsSeeAFileGroup(t *testing.T) { // The index resolves symlinks; on macOS t.TempDir() is one, so compare // like for like. - dir, err := filepath.EvalSymlinks(t.TempDir()) + root, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { - t.Fatal(err) + t.Fatalf("resolving the temp dir: %v", err) } - if err := os.WriteFile(filepath.Join(dir, groups.ConfigName), - []byte("name: checkout\nports:\n - 4000\n"), 0o644); err != nil { - t.Fatal(err) + manifest := []byte("name: cart\nports:\n - 4200\n") + if err := os.WriteFile(filepath.Join(root, groups.ConfigName), manifest, 0o644); err != nil { + t.Fatalf("writing the manifest: %v", err) } - snapshot := []ports.ListeningPort{ - {Port: 4000, PID: 400, BindAddress: "127.0.0.1", Process: "node", Cwd: dir}, + table := []ports.ListeningPort{ + {Port: 4200, PID: 420, BindAddress: "127.0.0.1", Process: "node", Cwd: root}, } - if got := snapshot[0].Group; got != "" { - t.Fatalf("group = %q before attribution, want it empty", got) + if before := table[0].Group; before != "" { + t.Fatalf("group = %q before attribution, want it empty", before) } - groups.Attribute(snapshot) - if got := snapshot[0].Group; got != "checkout" { - t.Fatalf("group = %q after attribution, want checkout", got) + groups.Attribute(table) + if after := table[0].Group; after != "cart" { + t.Fatalf("group = %q after attribution, want cart", after) } - resetKillFlags(t) - killGroupFlag = "checkout" - targets, _, err := killTargets(nil, snapshot, "") + resetKillSelectors(t) + killGroupFlag = "cart" + selected, _, err := killTargets(nil, table, "") if err != nil { - t.Fatalf("kill -g checkout: %v", err) + t.Fatalf("kill -g cart: %v", err) } - want := killer.Target{Port: 4000, BindAddress: "127.0.0.1"} - if len(targets) != 1 || targets[0] != want { - t.Fatalf("targets = %+v, want %+v", targets, want) + expected := killer.Target{Port: 4200, BindAddress: "127.0.0.1"} + if len(selected) != 1 || selected[0] != expected { + t.Fatalf("targets = %+v, want %+v", selected, expected) } } @@ -375,7 +375,7 @@ func blindScan() error { // empty table. `kill --all` must report the observation failure instead of // selecting nothing and exiting 0 as if everything had already stopped. func TestKillSelectionRefusesABlindTable(t *testing.T) { - resetKillFlags(t) + resetKillSelectors(t) killAllFlag = true snapshot, targets, confirm, complete, err := killSelection(context.Background(), nil, "", func(context.Context) ([]ports.ListeningPort, error) { @@ -401,7 +401,7 @@ func TestKillSelectionRefusesABlindTable(t *testing.T) { // that ignored the context would keep a stopped command waiting on a slow // collector. The hook records the context; no real collector runs. func TestKillSelectionPassesTheCommandContextToTheScan(t *testing.T) { - resetKillFlags(t) + resetKillSelectors(t) killPIDFlag = []int{4242} ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -505,9 +505,9 @@ func anOwnedRunRow(port, pid, rootPID int) ports.ListeningPort { // of it made this one form stop a leaf instead of its run, and name the process // instead of the service. func TestKillSelectionResolvesAnExplicitPIDAgainstTheAttributedTable(t *testing.T) { - resetKillFlags(t) + resetKillSelectors(t) killPIDFlag = []int{4242} - table := []ports.ListeningPort{anOwnedRunRow(3000, 4242, 4000)} + table := []ports.ListeningPort{anOwnedRunRow(3300, 4242, 4300)} scanned := 0 snapshot, targets, confirm, complete, err := killSelection(context.Background(), nil, "", func(context.Context) ([]ports.ListeningPort, error) { @@ -523,7 +523,7 @@ func TestKillSelectionResolvesAnExplicitPIDAgainstTheAttributedTable(t *testing. if scanned != 1 { t.Fatalf("the listener table was read %d times, want the attribution read once", scanned) } - if len(snapshot) != 1 || snapshot[0].RunRootPID != 4000 || snapshot[0].Tag != "api" { + if len(snapshot) != 1 || snapshot[0].RunRootPID != 4300 || snapshot[0].Tag != "api" { t.Fatalf("snapshot = %+v, want the row that names the run", snapshot) } if confirm || len(targets) != 1 || targets[0].PID != 4242 { @@ -539,7 +539,7 @@ func TestKillSelectionResolvesAnExplicitPIDAgainstTheAttributedTable(t *testing. // killer still observes before it signals, so a blind machine still ends in a // failed row rather than a silent success. func TestKillSelectionKeepsAnExplicitPIDWhenTheTableIsUnreadable(t *testing.T) { - resetKillFlags(t) + resetKillSelectors(t) killPIDFlag = []int{12345} snapshot, targets, confirm, complete, err := killSelection(context.Background(), nil, "", func(context.Context) ([]ports.ListeningPort, error) { @@ -564,9 +564,9 @@ func TestKillSelectionKeepsAnExplicitPIDWhenTheTableIsUnreadable(t *testing.T) { // is the run attribution, so the readable row travels with the pid instead of // being discarded — the pid itself is signalled either way. func TestKillSelectionKeepsTheRowsAPartialReadProduced(t *testing.T) { - resetKillFlags(t) + resetKillSelectors(t) killPIDFlag = []int{4242} - partial := []ports.ListeningPort{anOwnedRunRow(3000, 4242, 4000)} + partial := []ports.ListeningPort{anOwnedRunRow(3300, 4242, 4300)} snapshot, targets, _, complete, err := killSelection(context.Background(), nil, "", func(context.Context) ([]ports.ListeningPort, error) { return partial, &ports.ObservationError{ @@ -579,7 +579,7 @@ func TestKillSelectionKeepsTheRowsAPartialReadProduced(t *testing.T) { if complete { t.Fatal("a partial read was reported as a complete observation") } - if len(snapshot) != 1 || snapshot[0].RunRootPID != 4000 { + if len(snapshot) != 1 || snapshot[0].RunRootPID != 4300 { t.Fatalf("snapshot = %+v, want the row the collector did read", snapshot) } if len(targets) != 1 || targets[0].PID != 4242 { @@ -588,22 +588,22 @@ func TestKillSelectionKeepsTheRowsAPartialReadProduced(t *testing.T) { } // withKillFlags installs one flag combination and puts every kill flag back -// afterwards. resetKillFlags only clears the selectors, while these tests also +// afterwards. resetKillSelectors only clears the selectors, while these tests also // set the confirmation, output and signalling flags. func withKillFlags(t *testing.T, set func()) { t.Helper() oldPID, oldGroup, oldSession := killPIDFlag, killGroupFlag, killSessionFlag - oldAll, oldTree, oldForce := killAllFlag, killTreeFlag, forceFlag + oldAll, oldTree, oldForce := killAllFlag, killTreeFlag, killForceFlag oldGrace, oldNoEscalate, oldYes := killGraceFlag, killNoEscalateFlag, killYesFlag oldDry, oldJSON := killDryRunFlag, killJSONFlag t.Cleanup(func() { killPIDFlag, killGroupFlag, killSessionFlag = oldPID, oldGroup, oldSession - killAllFlag, killTreeFlag, forceFlag = oldAll, oldTree, oldForce + killAllFlag, killTreeFlag, killForceFlag = oldAll, oldTree, oldForce killGraceFlag, killNoEscalateFlag, killYesFlag = oldGrace, oldNoEscalate, oldYes killDryRunFlag, killJSONFlag = oldDry, oldJSON }) killPIDFlag, killGroupFlag, killSessionFlag = nil, "", "" - killAllFlag, killTreeFlag, forceFlag = false, false, false + killAllFlag, killTreeFlag, killForceFlag = false, false, false killGraceFlag, killNoEscalateFlag, killYesFlag = killer.DefaultGrace, false, true killDryRunFlag, killJSONFlag = false, false set() diff --git a/engine/internal/cmd/killroute_test.go b/engine/internal/cmd/killroute_test.go index 1baaddc8..f454fb39 100644 --- a/engine/internal/cmd/killroute_test.go +++ b/engine/internal/cmd/killroute_test.go @@ -27,11 +27,11 @@ import ( // a real port. With BERTH_TEST_PIDFILE it also reports the pid it runs as, so a // case about "the pid the user named" never has to guess it from a table. func TestKillHelperListener(t *testing.T) { - port := os.Getenv("BERTH_TEST_LISTEN") - if port == "" { + listenOn := os.Getenv("BERTH_TEST_LISTEN") + if listenOn == "" { t.Skip("helper process, driven by TestKillRoutesThroughTheDaemon") } - ln, err := net.Listen("tcp", "127.0.0.1:"+port) + ln, err := net.Listen("tcp", "127.0.0.1:"+listenOn) if err != nil { t.Fatalf("helper could not listen: %v", err) } @@ -41,59 +41,61 @@ func TestKillHelperListener(t *testing.T) { } } defer ln.Close() - time.Sleep(60 * time.Second) + time.Sleep(time.Minute) } -// startListener spawns the helper on a free port and returns the port. The -// process is killed on cleanup whether or not the test killed it itself. +// startListener runs the helper against a free port and reports which port it +// took. Cleanup kills the process whether or not the test got there first. func startListener(t *testing.T) int { t.Helper() if runtime.GOOS == "windows" { t.Skip("the kill routing harness is unix-only") } - ln, err := net.Listen("tcp", "127.0.0.1:0") + reserved, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("reserving a port: %v", err) } - _, portStr, _ := net.SplitHostPort(ln.Addr().String()) - ln.Close() - port, _ := strconv.Atoi(portStr) + _, portText, _ := net.SplitHostPort(reserved.Addr().String()) + reserved.Close() + port, _ := strconv.Atoi(portText) pidFile := filepath.Join(t.TempDir(), "listener.pid") - cmd := exec.Command(os.Args[0], "-test.run=TestKillHelperListener", "-test.timeout=90s") - cmd.Env = append(os.Environ(), - "BERTH_TEST_LISTEN="+portStr, + helper := exec.Command(os.Args[0], "-test.run=TestKillHelperListener", "-test.timeout=90s") + helper.Env = append(os.Environ(), + "BERTH_TEST_LISTEN="+portText, "BERTH_TEST_PIDFILE="+pidFile, ) born := time.Now().UTC() - if err := cmd.Start(); err != nil { + if err := helper.Start(); err != nil { t.Fatalf("starting the helper: %v", err) } t.Cleanup(func() { - _ = killOwnedIdentities(heldKillIdentity(cmd.Process, born)) - _, _ = cmd.Process.Wait() + _ = killOwnedIdentities(heldKillIdentity(helper.Process, born)) + _, _ = helper.Process.Wait() }) - listened := false - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - conn, err := net.DialTimeout("tcp", "127.0.0.1:"+portStr, 200*time.Millisecond) - if err == nil { - conn.Close() - listened = true + listening := false + giveUp := time.Now().Add(5 * time.Second) + for time.Now().Before(giveUp) { + probe, dialErr := net.DialTimeout("tcp", "127.0.0.1:"+portText, 200*time.Millisecond) + if dialErr == nil { + probe.Close() + listening = true break } time.Sleep(20 * time.Millisecond) } - if !listened { + if !listening { t.Fatalf("the helper never listened on %d", port) } var pid int - deadline = time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - if raw, err := os.ReadFile(pidFile); err == nil { - if n, err := strconv.Atoi(strings.TrimSpace(string(raw))); err == nil { - pid = n + giveUp = time.Now().Add(5 * time.Second) + for time.Now().Before(giveUp) { + raw, readErr := os.ReadFile(pidFile) + if readErr == nil { + reported, convErr := strconv.Atoi(strings.TrimSpace(string(raw))) + if convErr == nil { + pid = reported break } } @@ -102,12 +104,12 @@ func startListener(t *testing.T) int { if pid == 0 { t.Fatalf("the helper never reported its pid") } - if pid != cmd.Process.Pid { - t.Fatalf("helper pid file %d is not the held process %d; a bare child pid is not ownership", pid, cmd.Process.Pid) + if pid != helper.Process.Pid { + t.Fatalf("helper pid file %d is not the held process %d; a bare child pid is not ownership", pid, helper.Process.Pid) } registerIsolatedKillFixture(t, ports.ListeningPort{ Port: port, PID: pid, BindAddress: "127.0.0.1", Type: ports.PortTypeUser, - }, heldKillIdentity(cmd.Process, born)) + }, heldKillIdentity(helper.Process, born)) useIsolatedEnrichedKillScan(t) return port } @@ -139,12 +141,12 @@ func startScanningDaemonWithScan(t *testing.T, scan func(scanner.Include) ([]por if runtime.GOOS == "windows" { t.Skip("the unix-socket harness does not apply to named pipes") } - dir, err := os.MkdirTemp("", "sn") + socketDir, err := os.MkdirTemp("", "sn") if err != nil { t.Fatalf("temp dir: %v", err) } - t.Cleanup(func() { os.RemoveAll(dir) }) - socket := filepath.Join(dir, "d.sock") + t.Cleanup(func() { os.RemoveAll(socketDir) }) + socket := filepath.Join(socketDir, "d.sock") srv := daemon.New(daemon.Options{ Socket: socket, @@ -182,39 +184,39 @@ func startScanningDaemonWithScan(t *testing.T, scan func(scanner.Include) ([]por return stop } -// captureStdout collects what fn prints, which is where every kill report goes. +// captureStdout collects what fn printed; every kill report goes there. func captureStdout(t *testing.T, fn func()) string { t.Helper() - r, w, err := os.Pipe() + reader, writer, err := os.Pipe() if err != nil { t.Fatalf("pipe: %v", err) } - prev := os.Stdout - os.Stdout = w + saved := os.Stdout + os.Stdout = writer - done := make(chan string, 1) + collected := make(chan string, 1) go func() { - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - done <- buf.String() + var sink bytes.Buffer + _, _ = io.Copy(&sink, reader) + collected <- sink.String() }() fn() - os.Stdout = prev - w.Close() - out := <-done - r.Close() - return out + os.Stdout = saved + writer.Close() + text := <-collected + reader.Close() + return text } // setKillFlags installs one flag combination for the duration of a test. func setKillFlags(t *testing.T, dryRun, asJSON bool) { t.Helper() - prevDry, prevJSON, prevYes := killDryRunFlag, killJSONFlag, killYesFlag + savedDry, savedJSON, savedYes := killDryRunFlag, killJSONFlag, killYesFlag killDryRunFlag, killJSONFlag, killYesFlag = dryRun, asJSON, true t.Cleanup(func() { - killDryRunFlag, killJSONFlag, killYesFlag = prevDry, prevJSON, prevYes + killDryRunFlag, killJSONFlag, killYesFlag = savedDry, savedJSON, savedYes }) } @@ -238,83 +240,83 @@ func answerNextPrompt(t *testing.T) { }) } -// runKillCmd drives the real command, so the routing decision itself — daemon -// when one is reachable, direct scan otherwise — is what the test exercises. +// runKillCmd drives the real command, so what the test exercises is the routing +// decision itself: daemon when one answers, direct scan otherwise. func runKillCmd(t *testing.T, args ...string) (string, error) { t.Helper() - cmd := &cobra.Command{RunE: runKill} - cmd.Flags().String("ip", "", "") - cmd.SetContext(context.Background()) - var err error - out := captureStdout(t, func() { err = runKill(cmd, args) }) - return out, err + c := &cobra.Command{RunE: runKill} + c.Flags().String("ip", "", "") + c.SetContext(context.Background()) + var runErr error + printed := captureStdout(t, func() { runErr = runKill(c, args) }) + return printed, runErr } -// TestKillJSONIsIdenticalOnBothPaths is the golden for the third -// follow-up: routing `oberth kill` through the daemon must not change a single -// byte of what the user sees. A dry run is what makes the comparison possible — -// the same process is planned twice, so pid, name and method are identical and -// the two JSON documents have to match exactly. +// TestKillJSONIsIdenticalOnBothPaths is the golden for the third follow-up: +// routing `oberth kill` through the daemon must not change one byte of what the +// user sees. The dry run is what makes the comparison possible — the same +// process is planned twice, so pid, name and method are identical and both JSON +// documents have to match exactly. func TestKillJSONIsIdenticalOnBothPaths(t *testing.T) { resetRouting(t) setKillFlags(t, true, true) port := startListener(t) - arg := []string{strconv.Itoa(port)} + target := []string{strconv.Itoa(port)} noDaemonReachable(t) - direct, err := runKillCmd(t, arg...) + directOut, err := runKillCmd(t, target...) if err != nil { t.Fatalf("the direct path failed: %v", err) } - if !bytes.Contains([]byte(direct), []byte(`"port": `+arg[0])) { - t.Fatalf("the direct path did not find the listener:\n%s", direct) + if !bytes.Contains([]byte(directOut), []byte(`"port": `+target[0])) { + t.Fatalf("the direct path did not find the listener:\n%s", directOut) } resetRouting(t) startScanningDaemon(t) - routed, err := runKillCmd(t, arg...) + daemonOut, err := runKillCmd(t, target...) if err != nil { t.Fatalf("the daemon path failed: %v", err) } - if routed != direct { - t.Fatalf("--json differs between the two paths:\n daemon:\n%s\n direct:\n%s", routed, direct) + if daemonOut != directOut { + t.Fatalf("--json differs between the two paths:\n daemon:\n%s\n direct:\n%s", daemonOut, directOut) } } -// TestKillRoutesThroughTheDaemon: with a daemon reachable the kill happens in -// the daemon, which rescans straight afterwards — so the port is gone from the -// daemon's own port table immediately, instead of lingering in its cache for up -// to CacheTTL as it did when the CLI killed directly. +// TestKillRoutesThroughTheDaemon: with a daemon reachable the kill happens +// inside it, and it rescans straight afterwards. The port therefore leaves the +// daemon's own table at once instead of lingering in its cache for up to +// CacheTTL, which is what happened while the CLI killed directly. func TestKillRoutesThroughTheDaemon(t *testing.T) { resetRouting(t) setKillFlags(t, false, true) - port := startListener(t) + listenPort := startListener(t) startScanningDaemon(t) - out, err := runKillCmd(t, strconv.Itoa(port)) + printed, err := runKillCmd(t, strconv.Itoa(listenPort)) if err != nil { - t.Fatalf("kill through the daemon: %v\n%s", err, out) + t.Fatalf("kill through the daemon: %v\n%s", err, printed) } - if !bytes.Contains([]byte(out), []byte(`"ok": true`)) { - t.Fatalf("nothing was stopped:\n%s", out) + if !bytes.Contains([]byte(printed), []byte(`"ok": true`)) { + t.Fatalf("nothing was stopped:\n%s", printed) } ctx := context.Background() - c, err := dialDaemon(ctx) + conn, err := dialDaemon(ctx) if err != nil { t.Fatalf("connecting: %v", err) } - defer c.Close() + defer conn.Close() - // The daemon's own view is already up to date: no sleeping out the cache. - rows, err := daemonList(ctx, c) + // Nothing has to wait out the cache: the daemon's view already moved on. + rows, err := daemonList(ctx, conn) if err != nil { t.Fatalf("state.snapshot: %v", err) } - for _, r := range rows { - if r.Port == port { - t.Fatalf("port %d is still in the daemon's table right after the kill", port) + for _, row := range rows { + if row.Port == listenPort { + t.Fatalf("port %d is still in the daemon's table right after the kill", listenPort) } } } diff --git a/engine/internal/cmd/sessions_test.go b/engine/internal/cmd/sessions_test.go index c8d790ae..77c87049 100644 --- a/engine/internal/cmd/sessions_test.go +++ b/engine/internal/cmd/sessions_test.go @@ -9,33 +9,33 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/sessions" ) -// `--session current` is shorthand: it resolves from this shell's -// own environment, and says so when there is nothing to resolve. +// `--session current` is shorthand: it resolves from this shell's own +// environment, and says so when there is nothing there to resolve. func TestCurrentSessionResolvesFromTheEnvironment(t *testing.T) { - t.Setenv(sessions.EnvSession, "claude-code:abc123") - got, err := currentSession("current") + t.Setenv(sessions.EnvSession, "claude-code:run42") + resolved, err := currentSession("current") if err != nil { t.Fatalf("currentSession: %v", err) } - if got != "abc123" { - t.Errorf("currentSession(current) = %q, want abc123", got) + if resolved != "run42" { + t.Errorf("currentSession(current) = %q, want run42", resolved) } // Anything else is passed through untouched. - if got, err := currentSession("some-id"); err != nil || got != "some-id" { - t.Errorf("currentSession(some-id) = %q, %v", got, err) + if literal, err := currentSession("some-id"); err != nil || literal != "some-id" { + t.Errorf("currentSession(some-id) = %q, %v", literal, err) } - if got, err := currentSession(""); err != nil || got != "" { - t.Errorf("currentSession(\"\") = %q, %v", got, err) + if blank, err := currentSession(""); err != nil || blank != "" { + t.Errorf("currentSession(\"\") = %q, %v", blank, err) } } func TestCurrentSessionWithoutAnAgentIsAnError(t *testing.T) { for _, key := range []string{ - sessions.EnvSession, sessions.EnvSessionID, sessions.EnvClaudeCode, - sessions.EnvClaudeCodeSessionID, sessions.EnvClaudeSessionID, - sessions.EnvCodexThreadID, sessions.EnvCodexSandbox, sessions.EnvCursorAgent, - sessions.EnvCursorAgentSessionID, + sessions.EnvClaudeCode, sessions.EnvClaudeCodeSessionID, + sessions.EnvClaudeSessionID, sessions.EnvSession, sessions.EnvSessionID, + sessions.EnvCodexSandbox, sessions.EnvCodexThreadID, + sessions.EnvCursorAgent, sessions.EnvCursorAgentSessionID, } { t.Setenv(key, "") } @@ -45,15 +45,15 @@ func TestCurrentSessionWithoutAnAgentIsAnError(t *testing.T) { } func TestSessionsCommandWithoutADaemonSaysSo(t *testing.T) { - prev := dialDaemon + saved := dialDaemon dialDaemon = func(context.Context) (*client.Client, error) { return nil, client.ErrNotRunning } - t.Cleanup(func() { dialDaemon = prev }) + t.Cleanup(func() { dialDaemon = saved }) - _, err := sessionsDaemon(context.Background()) - if err == nil { + _, runErr := sessionsDaemon(context.Background()) + if runErr == nil { t.Fatal("sessionsDaemon without a daemon did not fail") } - if !strings.Contains(err.Error(), "oberth serve") { - t.Errorf("error = %q, want it to name the command that starts a daemon", err) + if !strings.Contains(runErr.Error(), "oberth serve") { + t.Errorf("error = %q, want it to name the command that starts a daemon", runErr) } } diff --git a/engine/internal/cmd/up_test.go b/engine/internal/cmd/up_test.go index 79a181b6..b98f3a73 100644 --- a/engine/internal/cmd/up_test.go +++ b/engine/internal/cmd/up_test.go @@ -28,17 +28,17 @@ func observableProbe(t *testing.T) { t.Cleanup(func() { scanProbe = prev }) } -// chdir moves into dir for the duration of a test. +// chdir moves into dir for the duration of a test and returns there after it. func chdir(t *testing.T, dir string) { t.Helper() - was, err := os.Getwd() + origin, err := os.Getwd() if err != nil { - t.Fatal(err) + t.Fatalf("reading the working directory: %v", err) } if err := os.Chdir(dir); err != nil { - t.Fatal(err) + t.Fatalf("changing to %s: %v", dir, err) } - t.Cleanup(func() { _ = os.Chdir(was) }) + t.Cleanup(func() { _ = os.Chdir(origin) }) } func TestNormalizeStartChunkAddsStructuredFailure(t *testing.T) { @@ -275,34 +275,34 @@ func TestPrintStartChunkShowsUnconfirmedReadiness(t *testing.T) { // TestUpParamsUsesTheConfigInTheWorkingDirectory: with no argument, `oberth up` // starts the project you are standing in. func TestUpParamsUsesTheConfigInTheWorkingDirectory(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, groups.ConfigName), - []byte("name: demo\nservices:\n - name: api\n cmd: uv run api\n"), 0o644); err != nil { - t.Fatal(err) + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, groups.ConfigName), + []byte("name: catalog\nservices:\n - name: web\n cmd: uv run web\n"), 0o644); err != nil { + t.Fatalf("writing the manifest: %v", err) } - chdir(t, dir) + chdir(t, root) upOnly = nil upAllowOutsideHome = false - params, _, err := upParams(nil) + resolved, _, err := upParams(nil) if err != nil { t.Fatalf("upParams: %v", err) } - if params.Name != nil { - t.Errorf("name = %v, want nil when the group comes from the cwd", params.Name) + if resolved.Name != nil { + t.Errorf("name = %v, want nil when the group comes from the cwd", resolved.Name) } - if params.ConfigPath == nil || filepath.Base(*params.ConfigPath) != groups.ConfigName { - t.Fatalf("config_path = %v", params.ConfigPath) + if resolved.ConfigPath == nil || filepath.Base(*resolved.ConfigPath) != groups.ConfigName { + t.Fatalf("config_path = %v", resolved.ConfigPath) } } func TestUpParamsPassesOutsideHomeOptIn(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, groups.ConfigName), - []byte("name: demo\nservices:\n - name: api\n cmd: uv run api\n"), 0o644); err != nil { - t.Fatal(err) + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, groups.ConfigName), + []byte("name: catalog\nservices:\n - name: web\n cmd: uv run web\n"), 0o644); err != nil { + t.Fatalf("writing the manifest: %v", err) } - chdir(t, dir) + chdir(t, root) upOnly = nil upAllowOutsideHome = true t.Cleanup(func() { upAllowOutsideHome = false }) @@ -319,55 +319,55 @@ func TestUpParamsPassesOutsideHomeOptIn(t *testing.T) { // TestUpParamsPassesTheGroupName straight through, so the daemon resolves it // against every config it knows and not only the one under the cwd. func TestUpParamsPassesTheGroupName(t *testing.T) { - upOnly = []string{"api", "db"} + upOnly = []string{"web", "db"} t.Cleanup(func() { upOnly = nil }) - params, _, err := upParams([]string{" storefront "}) + params, _, err := upParams([]string{" shopfront "}) if err != nil { t.Fatalf("upParams: %v", err) } - if params.Name == nil || *params.Name != "storefront" { - t.Fatalf("name = %v, want storefront", params.Name) + if params.Name == nil || *params.Name != "shopfront" { + t.Fatalf("name = %v, want shopfront", params.Name) } if params.ConfigPath != nil { t.Errorf("config_path = %v, want nil when a group is named", params.ConfigPath) } - if len(params.Only) != 2 || params.Only[0] != "api" { + if len(params.Only) != 2 || params.Only[0] != "web" { t.Errorf("only = %v", params.Only) } } -// TestUpParamsWithoutAConfig says what to do next rather than just failing. +// TestUpParamsWithoutAConfig says what to do next rather than only failing. func TestUpParamsWithoutAConfig(t *testing.T) { chdir(t, t.TempDir()) - upOnly = nil + upOnly = []string(nil) - _, _, err := upParams(nil) - if err == nil { + _, _, runErr := upParams(nil) + if runErr == nil { t.Fatal("expected an error with no .oberth.yaml anywhere") } - if !strings.Contains(err.Error(), "oberth init") { - t.Errorf("error should say how to make one: %v", err) + if !strings.Contains(runErr.Error(), "oberth init") { + t.Errorf("error should say how to make one: %v", runErr) } } // TestUpParamsReportsABrokenConfig instead of pretending there is none: the // two problems have completely different fixes. func TestUpParamsReportsABrokenConfig(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, groups.ConfigName), - []byte("name: two words\nservices:\n - name: api\n"), 0o644); err != nil { - t.Fatal(err) + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, groups.ConfigName), + []byte("name: two words\nservices:\n - name: web\n"), 0o644); err != nil { + t.Fatalf("writing the manifest: %v", err) } - chdir(t, dir) - upOnly = nil + chdir(t, root) + upOnly = []string(nil) - _, _, err := upParams(nil) - if err == nil { + _, _, runErr := upParams(nil) + if runErr == nil { t.Fatal("expected an error for an invalid config") } - if !strings.Contains(err.Error(), "cannot be used") || !strings.Contains(err.Error(), "whitespace") { - t.Errorf("error = %v, want it to name the validation problem", err) + if !strings.Contains(runErr.Error(), "cannot be used") || !strings.Contains(runErr.Error(), "whitespace") { + t.Errorf("error = %v, want it to name the validation problem", runErr) } } From 683334b30de9ae5ac0c096185e3218eff074fde4 Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 14:16:10 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs(engine):=20=E8=AE=B0=E5=BD=95=20cmd=20?= =?UTF-8?q?=E9=87=8D=E5=86=99=E5=90=8E=E7=9A=84=E4=B8=8A=E6=B8=B8=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照 26dbcae:总量 7,847 → 6,307 行 / 410 段、75 个文件(生产 2,305、 测试 4,002),internal/cmd 整包归零。 Co-Authored-By: Claude Code --- engine/UPSTREAM.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/engine/UPSTREAM.md b/engine/UPSTREAM.md index 9392a61d..4564dadc 100644 --- a/engine/UPSTREAM.md +++ b/engine/UPSTREAM.md @@ -31,25 +31,25 @@ **9,621**(对照 `f4c50506`)、**9,659**(对照 `be48afbe`),生产 3,852、 测试 5,758。 -2026-11-12 的 ports、killer 与 groups 重写在此基础上继续下降:对照 `26dbcae` 为 -**7,847** 行 / 535 段,分布在 100 个文件(生产 3,142、测试 4,705)。 +2026-11-12 的 ports、killer、groups 与 cmd 重写在此基础上继续下降:对照 +`26dbcae` 为 **6,307** 行 / 410 段,分布在 75 个文件(生产 2,305、测试 4,002)。 -按包(对照 `26dbcae`,ports、killer 与 groups 重写后): +按包(对照 `26dbcae`,ports、killer、groups 与 cmd 重写后): | 包 | 生产 | 测试 | 合计 | | --- | ---: | ---: | ---: | | `internal/daemon` | 2,073 | 3,457 | 5,530 | -| `internal/cmd` | 837 | 703 | 1,540 | +| `internal/cmd` | 0 | 0 | 0 | | `internal/groups` | 0 | 0 | 0 | | `internal/scanner` | 220 | 540 | 760 | | `internal/ports` | 0 | 0 | 0 | | `internal/killer` | 0 | 0 | 0 | | `internal/store` | 12 | 5 | 17 | -`internal/claims`、`internal/display`、`internal/testenv`、`internal/groups`、 -`internal/ports`、`internal/killer` 与 `internal/store/claims*.go` 已经归零; -`internal/docker` 只剩结构体字段与表驱动用例数据。`internal/store` 已整包重写: -生产代码从 1,076 行降到 199 行。 +`internal/claims`、`internal/cmd`、`internal/display`、`internal/testenv`、 +`internal/groups`、`internal/ports`、`internal/killer` 与 +`internal/store/claims*.go` 已经归零;`internal/docker` 只剩结构体字段与表驱动 +用例数据。`internal/store` 已整包重写:生产代码从 1,076 行降到 199 行。 ```sh git clone https://github.com/raskrebs/sonar /tmp/sonar