Skip to content

Commit

Permalink
command: capture and report panics in command handlers
Browse files Browse the repository at this point in the history
Add a sentinel error that is injected when a panic reaches the root of Run.
Update the example to show it at work.  Add a test to make sure it does.
  • Loading branch information
creachadair committed Nov 11, 2024
1 parent a755450 commit 02b10e5
Show file tree
Hide file tree
Showing 5 changed files with 57 additions and 1 deletion.
2 changes: 2 additions & 0 deletions adapt_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Copyright (C) 2020 Michael J. Fromberger. All Rights Reserved.

package command_test

import (
Expand Down
14 changes: 13 additions & 1 deletion command.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,10 @@ func (c *C) FindSubcommand(name string) *C {
// ErrRequestHelp is returned from Run if the user requested help.
var ErrRequestHelp = errors.New("help requested")

// ErrRunPanicked is a sentinel error reported by [Run] if a panic occurred
// while evaluating a command.
var ErrRunPanicked = errors.New("run panicked")

// UsageError is the concrete type of errors reported by the Usagef function,
// indicating an error in the usage of a command.
type UsageError struct {
Expand Down Expand Up @@ -329,8 +333,16 @@ func RunOrFail(env *Env, rawArgs []string) {
// Run writes usage information to env and returns a [UsageError] if the
// command-line usage was incorrect, or [ErrRequestHelp] if the user requested
// help via the --help flag.
//
// If the Init or Run function of a command panics, Run reports an error that
// includes [ErrRunPanicked].
func Run(env *Env, rawArgs []string) (err error) {
defer func() { env.Cancel(err) }()
defer func() {
if x := recover(); x != nil {
err = fmt.Errorf("command %q %w: %v", env.Command.Name, ErrRunPanicked, x)
}
env.Cancel(err)
}()
cmd := env.Command
env.Args = rawArgs

Expand Down
28 changes: 28 additions & 0 deletions command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright (C) 2020 Michael J. Fromberger. All Rights Reserved.

package command_test

import (
"errors"
"strings"
"testing"

"github.com/creachadair/command"
)

func TestRun_panic(t *testing.T) {
const message = "omg the sky is falling"
cmd := &command.C{
Name: "freak-out",
Run: func(*command.Env) error {
panic(message)
},
}
err := command.Run(cmd.NewEnv(nil), []string{"freak-out"})
if !errors.Is(err, command.ErrRunPanicked) {
t.Fatalf("Run: should panic, got error: %v", err)
}
if !strings.Contains(err.Error(), message) {
t.Error("Panic error does not contain the probe string")
}
}
12 changes: 12 additions & 0 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ This help text is printed by the "help" subcommand.`,
return nil
},
},

{
Name: "fatal",

// Demonstrate that a command which panics is properly handled.
Run: func(env *command.Env) error { panic("ouch") },
},
},
}

Expand Down Expand Up @@ -152,13 +159,18 @@ This help text is printed by the "help" subcommand.`,
command.RunOrFail(env, []string{"secret", "fort"})
opt = options{}

// A command that panics is caught and reported as an error.
fmt.Println("[err]", command.Run(env, []string{"fatal"}))

// An unmerged flag.
command.RunOrFail(env, []string{"echo", "-n", "baz"})

// Output:
// foo bar
// [xyzzy] bar
// [foo] bar
// [ok] <25> bar
// easter-egg fort
// [err] command "fatal" run panicked: ouch
// baz
}
2 changes: 2 additions & 0 deletions parse_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Copyright (C) 2020 Michael J. Fromberger. All Rights Reserved.

package command_test

import (
Expand Down

0 comments on commit 02b10e5

Please sign in to comment.