fix(summer): pass the app binary's exit code through and print its error once
- bonfire.WithExitCode (additive) makes an error carry the exit code bonfire.Execute returns; a code outside 1..255 gives 1 - runAppCmd, factored out of runAppBinary, returns Reported(WithExitCode(err, code)) when the binary exited with a code, so summer prints no second error block; a binary killed by a signal keeps summer's block and exit 1 - README, console introduction and upgrading notes say so (CR-02)
This commit is contained in:
@@ -90,7 +90,7 @@ func delegateCommand(name, description, hint string) bonfire.Command {
|
||||
long := description + ".\n\nsummer runs bin/<binary> " + name +
|
||||
" in the application directory, building the binary first when it is missing. Every flag and argument after " +
|
||||
name + ", including --help, is passed to the app binary unchanged, and so are the global options (-v, -q, --ansi, --no-ansi, -n, --env). " +
|
||||
"summer fails when the app binary fails."
|
||||
"When the app binary fails, summer exits with its exit code."
|
||||
if hint != "" {
|
||||
long += "\n\nCommon flags: " + hint + "."
|
||||
}
|
||||
@@ -141,12 +141,26 @@ func runAppBinary(ctx context.Context, argv []string, out bonfire.Output) error
|
||||
return fmt.Errorf("summer: refusing to recurse into %s", bin)
|
||||
}
|
||||
}
|
||||
cmd := newAppCmd(ctx, bin, appDir, argv, out)
|
||||
return runAppCmd(newAppCmd(ctx, bin, appDir, argv, out), argv)
|
||||
}
|
||||
|
||||
// runAppCmd runs cmd, the app binary started with argv, forwarding SIGINT
|
||||
// and SIGTERM to it. When the binary exits with a code, the error carries
|
||||
// that code (bonfire.WithExitCode) and is marked as already reported.
|
||||
func runAppCmd(cmd *exec.Cmd, argv []string) error {
|
||||
sigs := make(chan os.Signal, 2)
|
||||
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)
|
||||
defer signal.Stop(sigs)
|
||||
if err := runChild(cmd, sigs); err != nil {
|
||||
return fmt.Errorf("summer: %s: %w", strings.Join(argv, " "), err)
|
||||
err = fmt.Errorf("summer: %s: %w", strings.Join(argv, " "), err)
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) && exit.Exited() {
|
||||
// The binary printed its own error; summer exits with its
|
||||
// code and prints nothing more. A binary ended by a signal
|
||||
// printed nothing, so it gets summer's error block.
|
||||
return bonfire.Reported(bonfire.WithExitCode(err, exit.ExitCode()))
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -288,3 +288,75 @@ func TestDelegateExamples(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExitHelperProcess is not a real test: TestDelegateExitCodePassthrough
|
||||
// re-executes the test binary with SUMMER_EXIT_HELPER set to get a child
|
||||
// that prints an error on stderr and exits 2, or that is killed by SIGKILL.
|
||||
func TestExitHelperProcess(t *testing.T) {
|
||||
switch os.Getenv("SUMMER_EXIT_HELPER") {
|
||||
case "exit2":
|
||||
fmt.Fprintln(os.Stderr, "ERROR the app binary failed")
|
||||
os.Exit(2)
|
||||
case "sigkill":
|
||||
p, _ := os.FindProcess(os.Getpid())
|
||||
_ = p.Signal(syscall.SIGKILL)
|
||||
time.Sleep(10 * time.Second)
|
||||
os.Exit(3)
|
||||
}
|
||||
}
|
||||
|
||||
// exitHelperRunner replaces appRunner with one that runs the exit helper
|
||||
// as the app binary.
|
||||
func exitHelperRunner(t *testing.T, mode string) {
|
||||
t.Helper()
|
||||
prev := appRunner
|
||||
appRunner = func(ctx context.Context, argv []string, out bonfire.Output) error {
|
||||
cmd := newAppCmd(ctx, os.Args[0], ".", []string{"-test.run=^TestExitHelperProcess$"}, out)
|
||||
cmd.Env = append(os.Environ(), "SUMMER_EXIT_HELPER="+mode)
|
||||
return runAppCmd(cmd, argv)
|
||||
}
|
||||
t.Cleanup(func() { appRunner = prev })
|
||||
}
|
||||
|
||||
// TestDelegateExitCodePassthrough (CR-02): summer exits with the app
|
||||
// binary's exit code and prints no error block of its own; a binary killed
|
||||
// by a signal gets summer's error block and exit 1.
|
||||
func TestDelegateExitCodePassthrough(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("SIGKILL is POSIX-only")
|
||||
}
|
||||
run := func(t *testing.T, mode string) (string, string, int) {
|
||||
t.Helper()
|
||||
exitHelperRunner(t, mode)
|
||||
var out, errW syncBuffer
|
||||
root, err := bonfire.NewRootIO("summer", toolCommands(), strings.NewReader(""), &out, &errW)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code := bonfire.Execute(context.Background(), root, []string{"migrate", "--bogus"})
|
||||
return out.String(), errW.String(), code
|
||||
}
|
||||
|
||||
t.Run("exit code", func(t *testing.T) {
|
||||
out, errOut, code := run(t, "exit2")
|
||||
if code != 2 {
|
||||
t.Errorf("exit %d, want the binary's 2", code)
|
||||
}
|
||||
for _, s := range []string{out, errOut} {
|
||||
if strings.Contains(s, " ERROR ") || strings.Contains(s, "summer: migrate") {
|
||||
t.Errorf("summer printed an error block of its own:\nstdout %q\nstderr %q", out, errOut)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("killed by a signal", func(t *testing.T) {
|
||||
out, errOut, code := run(t, "sigkill")
|
||||
if code != bonfire.ExitFailure {
|
||||
t.Errorf("exit %d, want 1", code)
|
||||
}
|
||||
if n := strings.Count(errOut, " ERROR "); n != 1 || !strings.Contains(errOut, "summer: migrate --bogus: signal: killed") {
|
||||
t.Errorf("want one error block naming the signal, got %d:\nstdout %q\nstderr %q", n, out, errOut)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -141,6 +141,8 @@ summer -v migrate --pretend
|
||||
summer admin:token:create jane --scope journal.posts --json
|
||||
```
|
||||
|
||||
When the binary fails, `summer` exits with the binary's exit code (a usage error stays `2`) and prints nothing more: the binary has already printed its error. A binary killed by a signal gets an error block from `summer` and exit code `1`.
|
||||
|
||||
When `summer` receives SIGINT or SIGTERM while the binary runs, it passes the signal on and waits for the binary to shut down, so stopping `summer serve` with Ctrl+C or `kill` frees its port.
|
||||
|
||||
`summer` does not rebuild an existing binary before it delegates. Run `summer build` after you change code, or keep `summer dev` running. Every other runtime command, such as `deferred:purge` or the commands your plugins add, is run on the binary directly.
|
||||
|
||||
@@ -43,6 +43,7 @@ Applications pin exact versions, so a new release never reaches an application u
|
||||
- **Exit codes.** A failed command exits 1 and a usage error (an unknown command or flag, a wrong number of arguments) exits 2. Errors print as a block on stderr with a hint where the framework knows the fix, and `-v` shows the wrapped errors.
|
||||
- **`-q` keeps data.** `-q` hides information and success lines, spinners and progress bars, but still prints what a command exists to produce: the token of `admin:token:create`, the key of `key:generate` and every `--json` output.
|
||||
- **Production confirmation.** `migrate`, `migrate:rollback`, `deferred:purge` and `queue:clear` ask before they run when the environment is `production` and they run on an interactive terminal; `--force` skips the question. Off a terminal, as in deploy scripts over SSH, nothing changes.
|
||||
- **`summer` delegates keep the binary's exit code.** Since 0.4.2 `summer migrate`, `summer serve` and the other delegated commands exit with the app binary's exit code and print only the binary's error block; 0.4.0 and 0.4.1 printed a second block and exited 1.
|
||||
- **`summer dev` runs `serve`.** It used to start the binary without a command, which printed the help and stopped; it now runs `bin/<app> serve` with the global options you give `summer dev`.
|
||||
- **Result lines.** `summer build` ends with `Built bin/<app> in <time>`, the `make:` commands print `Created <path>` per file, `migrate` prints `Applied N migrations` or `Nothing to migrate`, and `queue:clear` prints `Cleared N jobs from <queue>`. Adjust scripts that matched the old lower-case lines.
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ Use cases:
|
||||
- Hidden commands (`bonfire.Command.Hidden`) run but stay out of the list unless `list --all`.
|
||||
- JSON catalog: `list --format=json` prints every command, hidden ones flagged, with arguments, options, defaults and examples as a `bonfire.Listing` (`bonfire.Describe` builds it from a command slice).
|
||||
- Documentation coverage: `bonfire.CheckDocs` reports commands lacking a description, long help, examples, or argument and option descriptions.
|
||||
- Error block and exit codes: `bonfire.Execute` runs the root through fang and returns `bonfire.ExitOK`, `bonfire.ExitFailure`, or `bonfire.ExitUsage` for an unknown command or flag and a wrong number of arguments (`bonfire.UsageError`). Errors print on the error stream as a block (`bonfire.RenderError`): the ERROR badge and the message, the explanation and Try lines of `bonfire.WithHint` (`{bin}` becomes `bin/<name>` or `summer`), cobra's "did you mean" suggestions as Try lines, and "Run with -v for details." or, at `-v`, the wrapped error chain; every message passes through `sunscreen.Scrub`. Errors wrapped by `bonfire.Reported` print nothing. `bonfire.Fail` prints errors that happen before the root exists. For an application binary (any root other than `summer`) that fails, other than with a usage error, while the working directory has no `config/` directory and a parent directory holds `summer.yaml`, both add the hint "This command runs from the application root." with `cd <relative path>; bin/<name> <args>` (only the message changes).
|
||||
- Error block and exit codes: `bonfire.Execute` runs the root through fang and returns `bonfire.ExitOK`, `bonfire.ExitFailure`, or `bonfire.ExitUsage` for an unknown command or flag and a wrong number of arguments (`bonfire.UsageError`). Errors print on the error stream as a block (`bonfire.RenderError`): the ERROR badge and the message, the explanation and Try lines of `bonfire.WithHint` (`{bin}` becomes `bin/<name>` or `summer`), cobra's "did you mean" suggestions as Try lines, and "Run with -v for details." or, at `-v`, the wrapped error chain; every message passes through `sunscreen.Scrub`. Errors wrapped by `bonfire.Reported` print nothing. An error carrying a code through `bonfire.WithExitCode` exits with that code; summer uses it to exit with the code of an app binary it ran. `bonfire.Fail` prints errors that happen before the root exists. For an application binary (any root other than `summer`) that fails, other than with a usage error, while the working directory has no `config/` directory and a parent directory holds `summer.yaml`, both add the hint "This command runs from the application root." with `cd <relative path>; bin/<name> <args>` (only the message changes).
|
||||
- Name checks at boot: two commands with the same name, or a command named `list` or `help`, make `bonfire.NewRoot` fail with `bonfire.ErrDuplicateCommand`; a command flag that reuses a global option's name or shorthand fails with `bonfire.ErrReservedFlag` instead of panicking.
|
||||
- Injectable streams (`bonfire.NewRootIO`, `bonfire.NewOutput`) so commands can be tested against buffers.
|
||||
- In-process calls: `bonfire.Call` runs a named command with arguments against any writer (Laravel `Artisan::call`), and `bonfire.Catalog` holds an application binary's final command list so code outside the Cobra root, such as the conga scheduler, can call any registered command.
|
||||
@@ -139,7 +139,8 @@ if catalog.Has("blog:import") {
|
||||
| `bonfire.HintError` | An error with an `Explanation` and a `Try` text that the error block prints. |
|
||||
| `bonfire.WithHint` | Wraps an error in a `bonfire.HintError`; `{bin}` in the Try text becomes `bin/<name>` or `summer`. |
|
||||
| `bonfire.ErrReported` | Marks an error whose details the command already printed. |
|
||||
| `bonfire.Reported` | Wraps an error so it matches `bonfire.ErrReported`: exit code 1, no error block. |
|
||||
| `bonfire.Reported` | Wraps an error so it matches `bonfire.ErrReported`: exit code 1, or the code of `bonfire.WithExitCode`, and no error block. |
|
||||
| `bonfire.WithExitCode` | Makes an error carry the exit code `bonfire.Execute` returns for it (1 to 255; other codes give 1). |
|
||||
| `bonfire.Listing` | The JSON catalog of `list --format=json`: `Application` (`bonfire.ApplicationInfo`), `GlobalOptions` and `Commands`. |
|
||||
| `bonfire.ApplicationInfo` | Name, version and framework version of a binary in a `bonfire.Listing`. |
|
||||
| `bonfire.CommandInfo` | One command of a `bonfire.Listing`: name, description, long help, usage, hidden and passthrough switches, `Arguments`, `Options` and `Examples`. |
|
||||
|
||||
@@ -93,6 +93,28 @@ func Reported(err error) error {
|
||||
return &reportedError{err: err}
|
||||
}
|
||||
|
||||
// exitCodeError carries the exit code Execute returns for an error.
|
||||
type exitCodeError struct {
|
||||
err error
|
||||
code int
|
||||
}
|
||||
|
||||
func (e *exitCodeError) Error() string { return e.err.Error() }
|
||||
|
||||
func (e *exitCodeError) Unwrap() error { return e.err }
|
||||
|
||||
// WithExitCode returns err carrying code as the process exit code: Execute
|
||||
// returns code instead of ExitFailure or ExitUsage. A code outside 1 to 255
|
||||
// gives ExitFailure. A nil err stays nil. Wrap it with Reported when the
|
||||
// error was already printed, as summer does with the exit code of an app
|
||||
// binary it ran.
|
||||
func WithExitCode(err error, code int) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return &exitCodeError{err: err, code: code}
|
||||
}
|
||||
|
||||
// RenderError writes err to w as the console error block: the ERROR badge
|
||||
// and the message, the explanation and the Try lines of a HintError ({bin}
|
||||
// replaced by summer or bin/<name>, for the root named name), then "Run with
|
||||
|
||||
@@ -182,3 +182,39 @@ func TestWrongDirectoryHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithExitCode (CR-02): Execute returns the code an error carries
|
||||
// through WithExitCode, prints no block when it is also Reported, maps a
|
||||
// code outside 1..255 to ExitFailure, and errors.Is and errors.As still see
|
||||
// the wrapped error.
|
||||
func TestWithExitCode(t *testing.T) {
|
||||
plainEnv(t)
|
||||
base := errors.New("child failed")
|
||||
command := func(err error) []Command {
|
||||
return []Command{{Name: "acme:run", Description: "Run", Run: func(context.Context, Input, Output) error { return err }}}
|
||||
}
|
||||
_, errOut, code := runGolden(t, command(Reported(WithExitCode(base, 2))), "acme:run")
|
||||
if code != 2 || errOut != "" {
|
||||
t.Errorf("Reported(WithExitCode(err, 2)): exit %d, stderr %q; want 2 and no block", code, errOut)
|
||||
}
|
||||
_, errOut, code = runGolden(t, command(WithExitCode(base, 3)), "acme:run")
|
||||
if code != 3 || !strings.Contains(errOut, "ERROR") || !strings.Contains(errOut, "child failed") {
|
||||
t.Errorf("WithExitCode(err, 3): exit %d, stderr %q; want 3 and the block", code, errOut)
|
||||
}
|
||||
for _, bad := range []int{0, -1, 256} {
|
||||
if _, _, code := runGolden(t, command(Reported(WithExitCode(base, bad))), "acme:run"); code != ExitFailure {
|
||||
t.Errorf("WithExitCode(err, %d): exit %d, want %d", bad, code, ExitFailure)
|
||||
}
|
||||
}
|
||||
if WithExitCode(nil, 2) != nil {
|
||||
t.Error("WithExitCode(nil, 2) is not nil")
|
||||
}
|
||||
wrapped := fmt.Errorf("outer: %w", WithExitCode(&HintError{Err: base, Try: "x"}, 4))
|
||||
var hint *HintError
|
||||
if !errors.Is(wrapped, base) || !errors.As(wrapped, &hint) || wrapped.Error() != "outer: child failed" {
|
||||
t.Errorf("WithExitCode hides the wrapped error: %v", wrapped)
|
||||
}
|
||||
if code := exitCode(wrapped); code != 4 {
|
||||
t.Errorf("exitCode through a wrapper = %d, want 4", code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package bonfire
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
@@ -23,11 +24,11 @@ const (
|
||||
)
|
||||
|
||||
// Execute runs root with args and returns the process exit code: ExitOK,
|
||||
// ExitFailure, or ExitUsage for usage errors. With no command word in args
|
||||
// (only global options, -h or --help) it prints the command list. Errors are
|
||||
// rendered on the root's error stream by RenderError through fang's error
|
||||
// handler; fang is used without its version flag, man page and completion
|
||||
// commands.
|
||||
// ExitFailure, ExitUsage for usage errors, or the code an error carries
|
||||
// through WithExitCode. With no command word in args (only global options,
|
||||
// -h or --help) it prints the command list. Errors are rendered on the
|
||||
// root's error stream by RenderError through fang's error handler; fang is
|
||||
// used without its version flag, man page and completion commands.
|
||||
func Execute(ctx context.Context, root *cobra.Command, args []string) int {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -73,9 +74,15 @@ func Fail(w io.Writer, name string, err error, g Globals) int {
|
||||
}
|
||||
|
||||
func exitCode(err error) int {
|
||||
var carrier *exitCodeError
|
||||
switch {
|
||||
case err == nil:
|
||||
return ExitOK
|
||||
case errors.As(err, &carrier):
|
||||
if carrier.code < 1 || carrier.code > 255 {
|
||||
return ExitFailure
|
||||
}
|
||||
return carrier.code
|
||||
case isUsage(err):
|
||||
return ExitUsage
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user