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:
Jakub Zych
2026-10-22 02:17:09 +02:00
parent d151a252c5
commit 323413c741
8 changed files with 165 additions and 10 deletions

View File

@@ -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
}

View File

@@ -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)
}
})
}