diff --git a/cmd/buf/buf.go b/cmd/buf/buf.go index 6fe00af6a8..390d075f16 100644 --- a/cmd/buf/buf.go +++ b/cmd/buf/buf.go @@ -478,7 +478,7 @@ func newRootCommand(name string) *appcmd.Command { func newErrorInterceptor() appext.Interceptor { return func(next func(context.Context, appext.Container) error) func(context.Context, appext.Container) error { return func(ctx context.Context, container appext.Container) error { - return wrapError(next(ctx, container)) + return wrapError(ctx, next(ctx, container)) } } } @@ -486,10 +486,16 @@ func newErrorInterceptor() appext.Interceptor { // wrapError is used when a CLI command fails, regardless of its error code. // Note that this function will wrap the error so that the underlying error // can be recovered via 'errors.Is'. -func wrapError(err error) error { +func wrapError(ctx context.Context, err error) error { if err == nil { return nil } + // ctx is the root context that [app.Run] wrapped with interrupt.Handle. Nothing else cancels it, + // so a canceled ctx suggests SIGINT/SIGTERM. + if errors.Is(ctx.Err(), context.Canceled) && + (errors.Is(err, context.Canceled) || connect.CodeOf(err) == connect.CodeCanceled) { + return appFailureError(errors.New("interrupted")) + } var connectErr *connect.Error isConnectError := errors.As(err, &connectErr) diff --git a/cmd/buf/buf_test.go b/cmd/buf/buf_test.go index 54632a8d0b..40e05e4a86 100644 --- a/cmd/buf/buf_test.go +++ b/cmd/buf/buf_test.go @@ -16,6 +16,7 @@ package main import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -31,6 +32,7 @@ import ( "buf.build/go/app/appcmd/appcmdtesting" "buf.build/go/bufplugin/check" "buf.build/go/standard/xslices" + "connectrpc.com/connect" "github.com/bufbuild/buf/cmd/buf/internal/internaltesting" "github.com/bufbuild/buf/private/buf/bufcli" "github.com/bufbuild/buf/private/buf/bufctl" @@ -4798,3 +4800,18 @@ func testLsRuleOutputJSON( ) require.Equal(t, expectedRules, outputRules) } + +func TestWrapErrorInterrupt(t *testing.T) { + t.Parallel() + canceledCtx, cancel := context.WithCancel(t.Context()) + cancel() + connectCanceledErr := connect.NewError(connect.CodeCanceled, context.Canceled) + + // Root context is canceled. + require.EqualError(t, wrapError(canceledCtx, connectCanceledErr), "Failure: interrupted") + require.EqualError(t, wrapError(canceledCtx, context.Canceled), "Failure: interrupted") + + // Root context is canceled but the command failed for an unrelated reason. + require.EqualError(t, wrapError(canceledCtx, errors.New("parse failed")), "Failure: parse failed") + require.NoError(t, wrapError(canceledCtx, nil)) +}