Repository navigation
fix: keep the exit code and message of a wrapped ExitCoder - #2479
Conversation
|
I will also review this PR in the morning (~9 hours from now) |
Juneezee
left a comment
There was a problem hiding this comment.
Thanks. The ExitCoder part fixes #2478, but the PR also changes MultiError handling, which the description says is untouched.
1. "Last exit code wins" breaks for multi-errors that implement Unwrap() []error. The new top-level errors.As runs before the MultiError branch and walks into them, so it picks the first ExitCoder:
Lines 178 to 196 in 5ed7a70
For example, with go.uber.org/multierr v1.11.0:
return multierr.Combine(cli.Exit("first", 2), cli.Exit("second", 3))- main: prints
firstandsecondon separate lines, exits 3. - This PR: prints
first; second, exits 2.
main matches the documented behavior:
Lines 164 to 166 in 05d84c1
2. The two MultiError lookups are not needed for #2478, are untested, and change behavior. Changing either back to a type assertion keeps go test ./... passing:
Lines 191 to 204 in 5ed7a70
newMultiError(errors.New("x"), Exit(newMultiError(errors.New("a"), errors.New("b")), 5))exits 5 on main and 1 here, so theExitCoderloses its code.fmt.Errorf("after: %w", newMultiError(errors.New("a"), errors.New("b")))inside aMultiErrorprintsainstead ofafter: a.
Suggestion for 1 and 2: keep both MultiError lookups as type assertions, and only fall back to errors.As for ExitCoder when the error is not a MultiError:
exitErr, isExitCoder := err.(ExitCoder)
if multiErr, ok := err.(MultiError); ok && !isExitCoder {
code := handleMultiError(multiErr)
OsExiter(code)
return
}
if isExitCoder || errors.As(err, &exitErr) {
// print and exit as before
}In handleMultiError, only the ExitCoder lookup changes to errors.As. With this, your new test and go test ./... pass, and all the cases above behave as on main. This test covers 1 (passes with the old errors.go, fails with this PR):
type unwrapMultiError []error
func (m unwrapMultiError) Error() string { return errors.Join(m...).Error() }
func (m unwrapMultiError) Errors() []error { return m }
func (m unwrapMultiError) Unwrap() []error { return m }
func TestHandleExitCoder_MultiErrorWithUnwrap(t *testing.T) {
code, called, _ := captureExit(unwrapMultiError{Exit("first", 2), Exit("second", 3)})
assert.True(t, called)
assert.Equal(t, 3, code)
}3. Wrapped *exec.ExitError now exits too. It has ExitCode() int (from *os.ProcessState), so errors.As matches it. For example:
cmd := &cli.Command{
Action: func(ctx context.Context, cmd *cli.Command) error {
return fmt.Errorf("git push: %w", exec.Command("sh", "-c", "exit 3").Run())
},
}
if err := cmd.Run(context.Background(), os.Args); err != nil {
log.Fatal(err)
}- main:
Runreturns the error,log.Fatalprints it, exit code 1. - This PR:
Runprints the error and callsos.Exit(3)itself, solog.Fatal(or any code afterRun) never runs. If the child is killed by a signal,ExitCode()is -1 and the process exits with 255 on Linux.
An unwrapped *exec.ExitError already behaves this way on main, so this is consistent, but it changes what existing programs do. Please mention it in the release note.
4. The test comment is not accurate. Exit does not document anything about being wrapped:
Lines 122 to 128 in 05d84c1
and Unwrap (#1090, #2444) goes the other way: it lets errors.Is/errors.As see the error inside an ExitCoder, not find an ExitCoder inside another error:
Lines 155 to 159 in 05d84c1
Could you reword or drop this part?
Lines 115 to 118 in 5ed7a70
5. Nit: captureExit saves fakeOsExiter as oldExiter and restores without defer. The other tests in this file use defer func() { OsExiter = fakeOsExiter }().
Lines 106 to 110 in 5ed7a70
|
Thanks for the detailed review — all five points are addressed in 17b4e59. 1 & 2. The top-level error is classified with type assertions again, and exitErr, isExitCoder := err.(ExitCoder)
if multiErr, ok := err.(MultiError); ok && !isExitCoder {
code := handleMultiError(multiErr)
OsExiter(code)
return
}
if isExitCoder || errors.As(err, &exitErr) {
// print and exit as before
}In 3. Mentioned in the release note, including the 4. Reworded — the comment no longer claims anything about what 5. The three cases are pinned as tests ( |
|
One check is failing on the new head, and it is not a test failure: Measured locally with Go 1.27.2, building So the change itself adds 12 KB to the example binary, which is what pushes the rounded figure from 2.2 to 2.3. Nothing in the diff warrants that on its face — it is three lookups moving from type assertions to
|
`HandleExitCoder` looked for an `ExitCoder`, and for a `MultiError`,
with a bare type assertion, so an exit error wrapped with
`fmt.Errorf("%w")` was not recognised: nothing called `OsExiter` and
nothing was written to `ErrWriter`. `Run` returned the error instead, so
a `main` that ends with `log.Fatal(err)` exited with code 1.
return fmt.Errorf("deploy failed: %w", cli.Exit("boom", 9))
// exited 1 instead of 9
Use `errors.As` for the three lookups: the top-level `ExitCoder`, the
top-level `MultiError`, and the nested ones in `handleMultiError`.
Fixes urfave#2478
The top-level `errors.As(err, &exitErr)` matched the first `ExitCoder`
inside any error that implements `Unwrap() []error`, so a `MultiError`
from `multierr.Combine(cli.Exit("first", 2), cli.Exit("second", 3))`
printed `first; second` and exited 2, where main prints both messages on
separate lines and exits 3. The documented "last exit code wins" rule
of `MultiError` was lost.
Keep the type assertions as the first classification and use `errors.As`
only where wrapping has to be seen through: the top-level `ExitCoder` is
still tried as an assertion first, a top-level `MultiError` that is not
itself an `ExitCoder` is handled as a whole, and `handleMultiError` only
unwraps its `ExitCoder` lookup. This restores two other behaviours the
widened lookups had changed: an `ExitCoder` whose message is a
`MultiError` keeps its own code, and a `MultiError` wrapped with
`fmt.Errorf` is printed as the wrapper message.
Add tests for the three cases and note the wrapped `*exec.ExitError`
case in the release note.
Reported by @Juneezee in review.
739fa96 to
43111bc
Compare
errors.As uses the value code of internal/reflectlite, which nothing else in examples/example-cli links. With Go 1.27.2 that adds 12288 bytes to the size check-binary-size measures and fails it on ubuntu-24.04 (2.3MB against a 2.2MB limit). Now that go.mod requires Go 1.26 (urfave#2483), use errors.AsType instead, which finds the same errors without reflection. That adds 4096 bytes, and the check passes. Reference: 851da03 ("fix: keep the exit code and message of a wrapped ExitCoder") Assisted-by: claude:claude-opus-5-5 Signed-off-by: Eng Zer Jun <engzerjun@gmail.com>
43111bc to
e2ccb61
Compare
Juneezee
left a comment
There was a problem hiding this comment.
Thanks for the fixes. Now that main requires Go 1.26 (#2483), I pushed a few changes to your branch:
-
Rebased onto main, squashed the two
chore:commits that regenerate the godoc goldens into the first commit, and dropped the merge commit. -
Replaced
errors.Aswitherrors.AsType.errors.Aslinks the reflection code ofinternal/reflectlite, which nothing else in the example binary uses. It added 12288 bytes and failedcheck-binary-size(2.3MB against the 2.2MB limit).errors.AsTypefinds the same errors without reflection, so the measured size grows by 4096 bytes instead:
Lines 178 to 187 in e2ccb61
Lines 203 to 209 in e2ccb61
-
Reworded the first commit message.
HandleExitCoderdoes nothing with an error it does not recognise, so the exit code 1 came frommain(for examplelog.Fatal(err)). The "last exit code wins" behaviour ofMultiErroralso changed in that commit; the second commit restores it. -
Updated the release note. A wrapped
MultiErrordoes not keep "last exit code wins":errors.AsTypefinds its firstExitCoder, aserrors.Aswould. For example, this exits with 2:
return fmt.Errorf("deploy: %w", multierr.Combine(cli.Exit("first", 2), cli.Exit("second", 3)))Only a MultiError passed directly still exits with its last exit code.
One merge landed after the last derivation: urfave/cli#2479, merged by Juneezee after he rebased it onto a main that now requires Go 1.26, squashed the two golden-regenerating commits, and switched the last lookup to errors.AsType. Two of the three commits are ours, so the repository is credited. 426 merges across 86 repositories, 393 credited in 84, the same 33 uncredited in 2, 155 open across 120. urfave/cli is credited at 24,300 stars, so the named table grows to 38 rows between laya and learn-harness-engineering; the long tail stays at 40.
|
Thanks for taking it the rest of the way — the rebase onto Go 1.26, folding the two golden commits into the first one, and |
What type of PR is this?
What this PR does / why we need it:
HandleExitCoderlooked for anExitCoder— and for aMultiError— with a bare type assertion, so an exit error wrapped withfmt.Errorf("%w")was not recognised. Nothing calledOsExiter, nothing was written toErrWriter, and the process exited with the default code1:exitError.Unwrap()(#1090, #2444) already letserrors.Is/errors.Assee the error inside anExitCoder. This makes the consumer honour the same relationship in the other direction, for anExitCoderthat was wrapped with%w.The top-level error is still classified with a type assertion first, and
errors.Asis only the fallback:MultiErrorthat is not itself anExitCoderis handled as a whole, so the documented "last exit code wins" also holds for multi-errors that implementUnwrap() []error(for examplemultierr.Combine) instead oferrors.Asfinding their firstExitCoder;handleMultiErroronly theExitCoderlookup unwraps, so anExitCoderwhose message is aMultiErrorkeeps its own code, and aMultiErrorwrapped infmt.Errorfis printed as the wrapper message.The doc comment gets two lines about the wrapped case, so the two generated goldens are updated in follow-up commits —
godoc-current.txtviamake generate, thentestdata/godoc-v3.x.txtvia whatmake v3approvedoes. Those are the only extra files;make v3diffpasses with them.Which issue(s) this PR fixes:
Fixes #2478
Special notes for your reviewer:
OsExiterandErrWriter, so they are not parallel — same constraint as the other thirteen tests inerrors_test.go, noted in a comment above the helper.MultiErrorordering is deliberate: a multi-error that also implementsUnwrap() []errormust be handled as a whole. The three new tests pin the three cases raised in review.godoc-current.txtbefore this PR, and the only difference after regeneration is those two added comment lines.Testing
On the previous head the new cases failed with
expected: 3, actual: 2,expected: 5, actual: 1and"a\nb\n" does not contain "after: a"; with the change all pass, and the existingTestHandleExitCoder*,TestExit*andTestMultiError*tests still pass.