Repository: anchore/grype
Pull request #1505 (2023-09-20 15:52:30)

fix: terminal clobbering when commands return errors
This PR corrects an issue where the terminal cursor wasn't being restored after certain errors occur. For example, running:
```
grype docker:asdf.asdf
```
or 
```
grype alpine:3.15 --fail-on high
```
... both resulted in an error being returned from the command, resulting in the terminal getting clobbered _most of the time_, but this was actually due to a race condition, so occasionally it did not happen.

Additionally, this PR:
* Cleans up a few items from the CLIO port
* Restores the TUI elements for showing image / cataloging progress
* Reworks the `runGrype` function a bit to be easier to understand
* Removes the `internal.Grype` constant altogether

Fixes: #1492


Patch:
diff --git a/cmd/grype/cli/cli.go b/cmd/grype/cli/cli.go
index 574a8333..7f89528f 100644
--- a/cmd/grype/cli/cli.go
+++ b/cmd/grype/cli/cli.go
@@ -8,13 +8,14 @@ import (
 
 	"github.com/anchore/clio"
 	"github.com/anchore/grype/cmd/grype/cli/commands"
-	handler "github.com/anchore/grype/cmd/grype/cli/ui"
+	grypeHandler "github.com/anchore/grype/cmd/grype/cli/ui"
 	"github.com/anchore/grype/cmd/grype/internal/ui"
 	"github.com/anchore/grype/grype/vulnerability"
 	"github.com/anchore/grype/internal/bus"
 	"github.com/anchore/grype/internal/log"
 	"github.com/anchore/grype/internal/redact"
 	"github.com/anchore/stereoscope"
+	syftHandler "github.com/anchore/syft/cmd/syft/cli/ui"
 	"github.com/anchore/syft/syft"
 )
 
@@ -41,10 +42,11 @@ func create(id clio.Identification) (clio.Application, *cobra.Command) {
 					return []clio.UI{noUI}, nil
 				}
 
-				h := handler.New(handler.DefaultHandlerConfig())
-
 				return []clio.UI{
-					ui.New(cfg.Log.Quiet, h),
+					ui.New(cfg.Log.Quiet,
+						grypeHandler.New(grypeHandler.DefaultHandlerConfig()),
+						syftHandler.New(syftHandler.DefaultHandlerConfig()),
+					),
 					noUI,
 				}, nil
 			},
@@ -59,13 +61,16 @@ func create(id clio.Identification) (clio.Application, *cobra.Command) {
 
 				redact.Set(state.RedactStore)
 
-				stereoscope.SetLogger(state.Logger)
-				syft.SetLogger(state.Logger)
 				log.Set(state.Logger)
+				syft.SetLogger(state.Logger)
+				stereoscope.SetLogger(state.Logger)
 
 				return nil
 			},
-		)
+		).
+		WithPostRuns(func(state *clio.State, err error) {
+			stereoscope.Cleanup()
+		})
 
 	app := clio.New(*clioCfg)
 
diff --git a/cmd/grype/cli/commands/db_check.go b/cmd/grype/cli/commands/db_check.go
index bd054500..54768b40 100644
--- a/cmd/grype/cli/commands/db_check.go
+++ b/cmd/grype/cli/commands/db_check.go
@@ -9,7 +9,6 @@ import (
 	"github.com/anchore/clio"
 	"github.com/anchore/grype/cmd/grype/cli/options"
 	"github.com/anchore/grype/grype/db"
-	"github.com/anchore/grype/internal/bus"
 )
 
 const (
@@ -30,8 +29,6 @@ func DBCheck(app clio.Application) *cobra.Command {
 }
 
 func runDBCheck(opts options.Database) error {
-	defer bus.Exit()
-
 	dbCurator, err := db.NewCurator(opts.ToCuratorConfig())
 	if err != nil {
 		return err
diff --git a/cmd/grype/cli/commands/db_delete.go b/cmd/grype/cli/commands/db_delete.go
index 2832e017..57b1b006 100644
--- a/cmd/grype/cli/commands/db_delete.go
+++ b/cmd/grype/cli/commands/db_delete.go
@@ -8,7 +8,6 @@ import (
 	"github.com/anchore/clio"
 	"github.com/anchore/grype/cmd/grype/cli/options"
 	"github.com/anchore/grype/grype/db"
-	"github.com/anchore/grype/internal/bus"
 )
 
 func DBDelete(app clio.Application) *cobra.Command {
@@ -25,8 +24,6 @@ func DBDelete(app clio.Application) *cobra.Command {
 }
 
 func runDBDelete(opts options.Database) error {
-	defer bus.Exit()
-
 	dbCurator, err := db.NewCurator(opts.ToCuratorConfig())
 	if err != nil {
 		return err
diff --git a/cmd/grype/cli/commands/db_diff.go b/cmd/grype/cli/commands/db_diff.go
index 77324e17..03b074bf 100644
--- a/cmd/grype/cli/commands/db_diff.go
+++ b/cmd/grype/cli/commands/db_diff.go
@@ -65,8 +65,6 @@ func DBDiff(app clio.Application) *cobra.Command {
 }
 
 func runDBDiff(opts *dbDiffOptions, base string, target string) (errs error) {
-	defer bus.Exit()
-
 	d, err := differ.NewDiffer(opts.DB.ToCuratorConfig())
 	if err != nil {
 		return err
diff --git a/cmd/grype/cli/commands/db_import.go b/cmd/grype/cli/commands/db_import.go
index 3dd9bd52..cb98c350 100644
--- a/cmd/grype/cli/commands/db_import.go
+++ b/cmd/grype/cli/commands/db_import.go
@@ -9,7 +9,6 @@ import (
 	"github.com/anchore/grype/cmd/grype/cli/options"
 	"github.com/anchore/grype/grype/db"
 	"github.com/anchore/grype/internal"
-	"github.com/anchore/grype/internal/bus"
 )
 
 func DBImport(app clio.Application) *cobra.Command {
@@ -27,8 +26,6 @@ func DBImport(app clio.Application) *cobra.Command {
 }
 
 func runDBImport(opts options.Database, dbArchivePath string) error {
-	defer bus.Exit()
-
 	dbCurator, err := db.NewCurator(opts.ToCuratorConfig())
 	if err != nil {
 		return err
diff --git a/cmd/grype/cli/commands/db_list.go b/cmd/grype/cli/commands/db_list.go
index 70ac44c6..ca1ace5c 100644
--- a/cmd/grype/cli/commands/db_list.go
+++ b/cmd/grype/cli/commands/db_list.go
@@ -9,7 +9,6 @@ import (
 
 	"github.com/anchore/clio"
 	"github.com/anchore/grype/grype/db"
-	"github.com/anchore/grype/internal/bus"
 )
 
 type dbListOptions struct {
@@ -40,8 +39,6 @@ func DBList(app clio.Application) *cobra.Command {
 }
 
 func runDBList(opts *dbListOptions) error {
-	defer bus.Exit()
-
 	dbCurator, err := db.NewCurator(opts.DB.ToCuratorConfig())
 	if err != nil {
 		return err
diff --git a/cmd/grype/cli/commands/db_status.go b/cmd/grype/cli/commands/db_status.go
index 30c1d533..5dbfb2e7 100644
--- a/cmd/grype/cli/commands/db_status.go
+++ b/cmd/grype/cli/commands/db_status.go
@@ -8,7 +8,6 @@ import (
 	"github.com/anchore/clio"
 	"github.com/anchore/grype/cmd/grype/cli/options"
 	"github.com/anchore/grype/grype/db"
-	"github.com/anchore/grype/internal/bus"
 )
 
 func DBStatus(app clio.Application) *cobra.Command {
@@ -25,8 +24,6 @@ func DBStatus(app clio.Application) *cobra.Command {
 }
 
 func runDBStatus(opts options.Database) error {
-	defer bus.Exit()
-
 	dbCurator, err := db.NewCurator(opts.ToCuratorConfig())
 	if err != nil {
 		return err
diff --git a/cmd/grype/cli/commands/db_update.go b/cmd/grype/cli/commands/db_update.go
index 13cfa624..e395d508 100644
--- a/cmd/grype/cli/commands/db_update.go
+++ b/cmd/grype/cli/commands/db_update.go
@@ -26,8 +26,6 @@ func DBUpdate(app clio.Application) *cobra.Command {
 }
 
 func runDBUpdate(opts options.Database) error {
-	defer bus.Exit()
-
 	dbCurator, err := db.NewCurator(opts.ToCuratorConfig())
 	if err != nil {
 		return err
diff --git a/cmd/grype/cli/commands/root.go b/cmd/grype/cli/commands/root.go
index e3dfc167..10514d6b 100644
--- a/cmd/grype/cli/commands/root.go
+++ b/cmd/grype/cli/commands/root.go
@@ -4,9 +4,7 @@ import (
 	"errors"
 	"fmt"
 	"strings"
-	"sync"
 
-	"github.com/hashicorp/go-multierror"
 	"github.com/spf13/cobra"
 	"github.com/wagoodman/go-partybus"
 
@@ -101,47 +99,33 @@ var ignoreVEXFixedNotAffected = []match.IgnoreRule{
 }
 
 //nolint:funlen
-func runGrype(app clio.Application, opts *options.Grype, userInput string) error {
-	errs := make(chan error)
-	go func() {
-		defer close(errs)
-		defer bus.Exit()
-
-		writer, err := format.MakeScanResultWriter(opts.Outputs, opts.File, format.PresentationConfig{
-			TemplateFilePath: opts.OutputTemplateFile,
-			ShowSuppressed:   opts.ShowSuppressed,
-		})
-		if err != nil {
-			errs <- err
-			return
-		}
-
-		checkForAppUpdate(app.ID(), opts)
-
-		var str *store.Store
-		var status *db.Status
-		var dbCloser *db.Closer
-		var packages []pkg.Package
-		var s *sbom.SBOM
-		var pkgContext pkg.Context
-		var wg = &sync.WaitGroup{}
-		var loadedDB, gatheredPackages bool
-
-		wg.Add(2)
+func runGrype(app clio.Application, opts *options.Grype, userInput string) (errs error) {
+	writer, err := format.MakeScanResultWriter(opts.Outputs, opts.File, format.PresentationConfig{
+		TemplateFilePath: opts.OutputTemplateFile,
+		ShowSuppressed:   opts.ShowSuppressed,
+	})
+	if err != nil {
+		return err
+	}
 
-		go func() {
-			defer wg.Done()
+	var str *store.Store
+	var status *db.Status
+	var dbCloser *db.Closer
+	var packages []pkg.Package
+	var s *sbom.SBOM
+	var pkgContext pkg.Context
+
+	err = parallel(
+		func() error {
+			checkForAppUpdate(app.ID(), opts)
+			return nil
+		},
+		func() (err error) {
 			log.Debug("loading DB")
 			str, status, dbCloser, err = grype.LoadVulnerabilityDB(opts.DB.ToCuratorConfig(), opts.DB.AutoUpdate)
-			if err = validateDBLoad(err, status); err != nil {
-				errs <- err
-				return
-			}
-			loadedDB = true
-		}()
-
-		go func() {
-			defer wg.Done()
+			return validateDBLoad(err, status)
+		},
+		func() (err error) {
 			log.Debugf("gathering packages")
 			// packages are grype.Package, not syft.Package
 			// the SBOM is returned for downstream formatting concerns
@@ -149,88 +133,68 @@ func runGrype(app clio.Application, opts *options.Grype, userInput string) error
 			// with vulnerability information appended
 			packages, pkgContext, s, err = pkg.Provide(userInput, getProviderConfig(opts))
 			if err != nil {
-				errs <- fmt.Errorf("failed to catalog: %w", err)
-				return
+				return fmt.Errorf("failed to catalog: %w", err)
 			}
-			gatheredPackages = true
-		}()
-
-		wg.Wait()
-		if !loadedDB || !gatheredPackages {
-			return
-		}
+			return nil
+		},
+	)
 
-		if dbCloser != nil {
-			defer dbCloser.Close()
-		}
+	if err != nil {
+		return err
+	}
 
-		if opts.OnlyFixed {
-			opts.Ignore = append(opts.Ignore, ignoreNonFixedMatches...)
-		}
+	if dbCloser != nil {
+		defer dbCloser.Close()
+	}
 
-		if opts.OnlyNotFixed {
-			opts.Ignore = append(opts.Ignore, ignoreFixedMatches...)
-		}
+	if opts.OnlyFixed {
+		opts.Ignore = append(opts.Ignore, ignoreNonFixedMatches...)
+	}
 
-		if err := applyVexRules(opts); err != nil {
-			errs <- fmt.Errorf("applying vex rules: %w", err)
-			return
-		}
+	if opts.OnlyNotFixed {
+		opts.Ignore = append(opts.Ignore, ignoreFixedMatches...)
+	}
 
-		applyDistroHint(packages, &pkgContext, opts)
-
-		vulnMatcher := grype.VulnerabilityMatcher{
-			Store:          *str,
-			IgnoreRules:    opts.Ignore,
-			NormalizeByCVE: opts.ByCVE,
-			FailSeverity:   opts.FailOnServerity(),
-			Matchers:       getMatchers(opts),
-			VexProcessor: vex.NewProcessor(vex.ProcessorOptions{
-				Documents:   opts.VexDocuments,
-				IgnoreRules: opts.Ignore,
-			}),
-		}
+	if err = applyVexRules(opts); err != nil {
+		return fmt.Errorf("applying vex rules: %w", err)
+	}
 
-		remainingMatches, ignoredMatches, err := vulnMatcher.FindMatches(packages, pkgContext)
-		if err != nil {
-			errs <- err
-			if !errors.Is(err, grypeerr.ErrAboveSeverityThreshold) {
-				return
-			}
-		}
+	applyDistroHint(packages, &pkgContext, opts)
+
+	vulnMatcher := grype.VulnerabilityMatcher{
+		Store:          *str,
+		IgnoreRules:    opts.Ignore,
+		NormalizeByCVE: opts.ByCVE,
+		FailSeverity:   opts.FailOnServerity(),
+		Matchers:       getMatchers(opts),
+		VexProcessor: vex.NewProcessor(vex.ProcessorOptions{
+			Documents:   opts.VexDocuments,
+			IgnoreRules: opts.Ignore,
+		}),
+	}
 
-		if err := writer.Write(models.PresenterConfig{
-			Matches:          *remainingMatches,
-			IgnoredMatches:   ignoredMatches,
-			Packages:         packages,
-			Context:          pkgContext,
-			MetadataProvider: str,
-			SBOM:             s,
-			AppConfig:        opts,
-			DBStatus:         status,
-		}); err != nil {
-			errs <- err
+	remainingMatches, ignoredMatches, err := vulnMatcher.FindMatches(packages, pkgContext)
+	if err != nil {
+		if !errors.Is(err, grypeerr.ErrAboveSeverityThreshold) {
+			return err
 		}
-	}()
-
-	return readAllErrors(errs)
-}
+		errs = appendErrors(errs, err)
+	}
 
-func readAllErrors(errs <-chan error) (out error) {
-	for {
-		if errs == nil {
-			break
-		}
-		err, isOpen := <-errs
-		if !isOpen {
-			errs = nil
-			continue
-		}
-		if err != nil {
-			out = multierror.Append(out, err)
-		}
+	if err = writer.Write(models.PresenterConfig{
+		Matches:          *remainingMatches,
+		IgnoredMatches:   ignoredMatches,
+		Packages:         packages,
+		Context:          pkgContext,
+		MetadataProvider: str,
+		SBOM:             s,
+		AppConfig:        opts,
+		DBStatus:         status,
+	}); err != nil {
+		errs = appendErrors(errs, err)
 	}
-	return out
+
+	return errs
 }
 
 func applyDistroHint(pkgs []pkg.Package, context *pkg.Context, opts *options.Grype) {
diff --git a/cmd/grype/cli/commands/update.go b/cmd/grype/cli/commands/update.go
index 14c8075e..2916a588 100644
--- a/cmd/grype/cli/commands/update.go
+++ b/cmd/grype/cli/commands/update.go
@@ -15,7 +15,7 @@ var latestAppVersionURL = struct {
 	path string
 }{
 	host: "https://toolbox-data.anchore.io",
-	path: fmt.Sprintf("/%s/releases/latest/VERSION", internal.Grype),
+	path: "/grype/releases/latest/VERSION",
 }
 
 func isProductionBuild(version string) bool {
diff --git a/cmd/grype/cli/commands/util.go b/cmd/grype/cli/commands/util.go
index 500938ac..f587f33c 100644
--- a/cmd/grype/cli/commands/util.go
+++ b/cmd/grype/cli/commands/util.go
@@ -4,6 +4,10 @@ import (
 	"fmt"
 	"os"
 	"strings"
+	"sync"
+
+	"github.com/hashicorp/go-multierror"
+	"golang.org/x/exp/maps"
 )
 
 func stderrPrintLnf(message string, args ...interface{}) error {
@@ -13,3 +17,52 @@ func stderrPrintLnf(message string, args ...interface{}) error {
 	_, err := fmt.Fprintf(os.Stderr, message, args...)
 	return err
 }
+
+// parallel takes a set of functions and runs them in parallel, capturing all errors returned and
+// returning the single error returned by one of the parallel funcs, or a multierror.Error with all
+// the errors if more than one
+func parallel(funcs ...func() error) error {
+	errs := parallelMapped(funcs...)
+	if len(errs) > 0 {
+		values := maps.Values(errs)
+		if len(values) == 1 {
+			return values[0]
+		}
+		return multierror.Append(nil, values...)
+	}
+	return nil
+}
+
+// parallelMapped takes a set of functions and runs them in parallel, capturing all errors returned in
+// a map indicating which func, by index returned which error
+func parallelMapped(funcs ...func() error) map[int]error {
+	errs := map[int]error{}
+	errorLock := &sync.Mutex{}
+	wg := &sync.WaitGroup{}
+	wg.Add(len(funcs))
+	for i, fn := range funcs {
+		go func(i int, fn func() error) {
+			defer wg.Done()
+			err := fn()
+			if err != nil {
+				errorLock.Lock()
+				defer errorLock.Unlock()
+				errs[i] = err
+			}
+		}(i, fn)
+	}
+	wg.Wait()
+	return errs
+}
+
+func appendErrors(errs error, err ...error) error {
+	if errs == nil {
+		switch len(err) {
+		case 0:
+			return nil
+		case 1:
+			return err[0]
+		}
+	}
+	return multierror.Append(errs, err...)
+}
diff --git a/cmd/grype/internal/constants.go b/cmd/grype/internal/constants.go
index 54521d7e..eedbdb0e 100644
--- a/cmd/grype/internal/constants.go
+++ b/cmd/grype/internal/constants.go
@@ -1,6 +1,5 @@
 package internal
 
 const (
-	Grype       = "grype"
 	NotProvided = "[not provided]"
 )
diff --git a/cmd/grype/internal/ui/no_ui.go b/cmd/grype/internal/ui/no_ui.go
index e78489fc..a05b2f49 100644
--- a/cmd/grype/internal/ui/no_ui.go
+++ b/cmd/grype/internal/ui/no_ui.go
@@ -33,8 +33,6 @@ func (n *NoUI) Handle(e partybus.Event) error {
 	case event.CLIReport, event.CLINotification:
 		// keep these for when the UI is terminated to show to the screen (or perform other events)
 		n.finalizeEvents = append(n.finalizeEvents, e)
-	case event.CLIExit:
-		return n.subscription.Unsubscribe()
 	}
 	return nil
 }
diff --git a/cmd/grype/internal/ui/ui.go b/cmd/grype/internal/ui/ui.go
index e0563f41..bd529023 100644
--- a/cmd/grype/internal/ui/ui.go
+++ b/cmd/grype/internal/ui/ui.go
@@ -1,8 +1,10 @@
 package ui
 
 import (
+	"fmt"
 	"os"
 	"sync"
+	"time"
 
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/wagoodman/go-partybus"
@@ -33,9 +35,9 @@ type UI struct {
 	frame   tea.Model
 }
 
-func New(quiet bool, hs ...bubbly.EventHandler) *UI {
+func New(quiet bool, handlers ...bubbly.EventHandler) *UI {
 	return &UI{
-		handler: bubbly.NewHandlerCollection(hs...),
+		handler: bubbly.NewHandlerCollection(handlers...),
 		frame:   frame.New(),
 		running: &sync.WaitGroup{},
 		quiet:   quiet,
@@ -56,24 +58,16 @@ func (m *UI) Setup(subscription partybus.Unsubscribable) error {
 		defer m.running.Done()
 		if _, err := m.program.Run(); err != nil {
 			log.Errorf("unable to start UI: %+v", err)
-			m.exit()
+			bus.ExitWithInterrupt()
 		}
 	}()
 
 	return nil
 }
 
-func (m *UI) exit() {
-	// stop the event loop
-	bus.Exit()
-}
-
 func (m *UI) Handle(e partybus.Event) error {
 	if m.program != nil {
 		m.program.Send(e)
-		if e.Type == event.CLIExit {
-			return m.subscription.Unsubscribe()
-		}
 	}
 	return nil
 }
@@ -88,7 +82,19 @@ func (m *UI) Teardown(force bool) error {
 		// string from the worker (outside of the UI after teardown).
 		m.running.Wait()
 	} else {
-		m.program.Kill()
+		_ = runWithTimeout(250*time.Millisecond, func() error {
+			m.handler.Wait()
+			return nil
+		})
+
+		// it may be tempting to use Kill() however it has been found that this can cause the terminal to be left in
+		// a bad state (where Ctrl+C and other control characters no longer works for future processes in that terminal).
+		m.program.Quit()
+
+		_ = runWithTimeout(250*time.Millisecond, func() error {
+			m.running.Wait()
+			return nil
+		})
 	}
 
 	// TODO: allow for writing out the full log output to the screen (only a partial log is shown currently)
@@ -107,7 +113,6 @@ func (m UI) RespondsTo() []partybus.EventType {
 	return append([]partybus.EventType{
 		event.CLIReport,
 		event.CLINotification,
-		event.CLIExit,
 		event.CLIAppUpdateAvailable,
 	}, m.handler.RespondsTo()...)
 }
@@ -126,8 +131,10 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.KeyMsg:
 		switch msg.String() {
+		// today we treat esc and ctrl+c the same, but in the future when the worker has a graceful way to
+		// cancel in-flight work via a context, we can wire up esc to this path with bus.Exit()
 		case "esc", "ctrl+c":
-			m.exit()
+			bus.ExitWithInterrupt()
 			return m, tea.Quit
 		}
 
@@ -135,12 +142,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		log.WithFields("component", "ui").Tracef("event: %q", msg.Type)
 
 		switch msg.Type {
-		case event.CLIReport, event.CLINotification, event.CLIExit, event.CLIAppUpdateAvailable:
+		case event.CLIReport, event.CLINotification, event.CLIAppUpdateAvailable:
 			// keep these for when the UI is terminated to show to the screen (or perform other events)
 			m.finalizeEvents = append(m.finalizeEvents, msg)
 
 			// why not return tea.Quit here for exit events? because there may be UI components that still need the update-render loop.
-			// for this reason we'll let the syft event loop call Teardown() which will explicitly wait for these components
+			// for this reason we'll let the event loop call Teardown() which will explicitly wait for these components
 			return m, nil
 		}
 
@@ -164,3 +171,17 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 func (m UI) View() string {
 	return m.frame.View()
 }
+
+func runWithTimeout(timeout time.Duration, fn func() error) (err error) {
+	c := make(chan struct{}, 1)
+	go func() {
+		err = fn()
+		c <- struct{}{}
+	}()
+	select {
+	case <-c:
+	case <-time.After(timeout):
+		return fmt.Errorf("timed out after %v", timeout)
+	}
+	return err
+}
diff --git a/cmd/grype/main.go b/cmd/grype/main.go
index 23f1f7e2..f3f11363 100644
--- a/cmd/grype/main.go
+++ b/cmd/grype/main.go
@@ -9,7 +9,7 @@ import (
 )
 
 // applicationName is the non-capitalized name of the application (do not change this)
-const applicationName = internal.Grype
+const applicationName = "grype"
 
 // all variables here are provided as build-time arguments, with clear default values
 var (
diff --git a/go.mod b/go.mod
index c2ea92f7..4989f87a 100644
--- a/go.mod
+++ b/go.mod
@@ -62,6 +62,8 @@ require (
 	modernc.org/sqlite v1.25.0 // indirect
 )
 
+require golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63
+
 require (
 	cloud.google.com/go v0.110.2 // indirect
 	cloud.google.com/go/compute v1.20.1 // indirect
@@ -199,11 +201,11 @@ require (
 	github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
 	github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
 	github.com/zclconf/go-cty v1.10.0 // indirect
+	github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1 // indirect
 	go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect
 	go.opencensus.io v0.24.0 // indirect
 	go.uber.org/goleak v1.2.0 // indirect
 	golang.org/x/crypto v0.13.0 // indirect
-	golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 // indirect
 	golang.org/x/mod v0.12.0 // indirect
 	golang.org/x/net v0.15.0 // indirect
 	golang.org/x/oauth2 v0.8.0 // indirect
diff --git a/go.sum b/go.sum
index 46834e98..1d08cf68 100644
--- a/go.sum
+++ b/go.sum
@@ -945,6 +945,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/zclconf/go-cty v1.10.0 h1:mp9ZXQeIcN8kAwuqorjH+Q+njbJKjLrvB2yIh4q7U+0=
 github.com/zclconf/go-cty v1.10.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk=
+github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1 h1:V+UsotZpAVvfj3X/LMoEytoLzSiP6Lg0F7wdVyu9gGg=
+github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1/go.mod h1:ly2RBz4mnz1yeuVbQA/VFwGjK3mnHGRj1JuoG336Bis=
 go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
 go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
 go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs=
diff --git a/grype/event/event.go b/grype/event/event.go
index 9db979ab..6f0e36f8 100644
--- a/grype/event/event.go
+++ b/grype/event/event.go
@@ -24,7 +24,4 @@ const (
 
 	// CLINotification is a partybus event that occurs when auxiliary information is ready for presentation to stderr
 	CLINotification partybus.EventType = cliTypePrefix + "-notification"
-
-	// CLIExit is a partybus event that occurs when an analysis result is ready for final presentation
-	CLIExit partybus.EventType = cliTypePrefix + "-exit-event"
 )
diff --git a/grype/vulnerability_matcher.go b/grype/vulnerability_matcher.go
index e26dc323..83c3799c 100644
--- a/grype/vulnerability_matcher.go
+++ b/grype/vulnerability_matcher.go
@@ -61,19 +61,25 @@ func (m *VulnerabilityMatcher) WithIgnoreRules(ignoreRules []match.IgnoreRule) *
 	return m
 }
 
-func (m *VulnerabilityMatcher) FindMatches(pkgs []pkg.Package, context pkg.Context) (*match.Matches, []match.IgnoredMatch, error) {
+func (m *VulnerabilityMatcher) FindMatches(pkgs []pkg.Package, context pkg.Context) (remainingMatches *match.Matches, ignoredMatches []match.IgnoredMatch, err error) {
 	progressMonitor := trackMatcher(len(pkgs))
 
-	defer progressMonitor.SetCompleted()
+	defer func() {
+		progressMonitor.SetCompleted()
+		if err != nil {
+			progressMonitor.MatchesDiscovered.SetError(err)
+		}
+	}()
 
-	remainingMatches, ignoredMatches, err := m.findDBMatches(pkgs, context, progressMonitor)
+	remainingMatches, ignoredMatches, err = m.findDBMatches(pkgs, context, progressMonitor)
 	if err != nil {
 		return remainingMatches, ignoredMatches, err
 	}
 
 	remainingMatches, ignoredMatches, err = m.findVEXMatches(context, remainingMatches, ignoredMatches, progressMonitor)
 	if err != nil {
-		return remainingMatches, ignoredMatches, fmt.Errorf("unable to find matches against VEX sources: %w", err)
+		err = fmt.Errorf("unable to find matches against VEX sources: %w", err)
+		return remainingMatches, ignoredMatches, err
 	}
 
 	logListSummary(progressMonitor)
diff --git a/internal/bus/helpers.go b/internal/bus/helpers.go
index e1b19935..9157ad49 100644
--- a/internal/bus/helpers.go
+++ b/internal/bus/helpers.go
@@ -3,14 +3,17 @@ package bus
 import (
 	"github.com/wagoodman/go-partybus"
 
+	"github.com/anchore/clio"
 	"github.com/anchore/grype/grype/event"
 	"github.com/anchore/grype/internal/redact"
 )
 
 func Exit() {
-	Publish(partybus.Event{
-		Type: event.CLIExit,
-	})
+	Publish(clio.ExitEvent(false))
+}
+
+func ExitWithInterrupt() {
+	Publish(clio.ExitEvent(true))
 }
 
 func Report(report string) {


Test patch:
diff --git a/cmd/grype/cli/commands/util_test.go b/cmd/grype/cli/commands/util_test.go
new file mode 100644
index 00000000..fb04777c
--- /dev/null
+++ b/cmd/grype/cli/commands/util_test.go
@@ -0,0 +1,165 @@
+package commands
+
+import (
+	"fmt"
+	"sync"
+	"sync/atomic"
+	"testing"
+
+	"github.com/hashicorp/go-multierror"
+	"github.com/stretchr/testify/require"
+)
+
+const lotsaParallel = 100
+
+func Test_lotsaLotsaParallel(t *testing.T) {
+	funcs := []func() error{}
+	for i := 0; i < lotsaParallel; i++ {
+		funcs = append(funcs, func() error {
+			Test_lotsaParallel(t)
+			return nil
+		})
+	}
+	err := parallel(funcs...)
+	require.NoError(t, err)
+}
+
+func Test_lotsaParallel(t *testing.T) {
+	for i := 0; i < lotsaParallel; i++ {
+		Test_parallel(t)
+	}
+}
+
+// Test_parallel tests the parallel function by executing a set of functions that can only execute in a specific
+// order if they are actually running in parallel.
+func Test_parallel(t *testing.T) {
+	count := atomic.Int32{}
+	count.Store(0)
+
+	wg1 := sync.WaitGroup{}
+	wg1.Add(1)
+
+	wg2 := sync.WaitGroup{}
+	wg2.Add(1)
+
+	wg3 := sync.WaitGroup{}
+	wg3.Add(1)
+
+	err1 := fmt.Errorf("error-1")
+	err2 := fmt.Errorf("error-2")
+	err3 := fmt.Errorf("error-3")
+
+	order := ""
+
+	got := parallel(
+		func() error {
+			wg1.Wait()
+			count.Add(1)
+			order = order + "_0"
+			return nil
+		},
+		func() error {
+			wg3.Wait()
+			defer wg2.Done()
+			count.Add(10)
+			order = order + "_1"
+			return err1
+		},
+		func() error {
+			wg2.Wait()
+			defer wg1.Done()
+			count.Add(100)
+			order = order + "_2"
+			return err2
+		},
+		func() error {
+			defer wg3.Done()
+			count.Add(1000)
+			order = order + "_3"
+			return err3
+		},
+	)
+	require.Equal(t, int32(1111), count.Load())
+	require.Equal(t, "_3_1_2_0", order)
+
+	errs := got.(*multierror.Error).Errors
+
+	// cannot check equality to a slice with err1,2,3 because the functions above are running in parallel, for example:
+	// after func()#4 returns and the `wg3.Done()` has executed, the thread could immediately pause
+	// and the remaining functions execute first and err3 becomes the last in the list instead of the first
+	require.Contains(t, errs, err1)
+	require.Contains(t, errs, err2)
+	require.Contains(t, errs, err3)
+}
+
+func Test_parallelMapped(t *testing.T) {
+	err0 := fmt.Errorf("error-0")
+	err1 := fmt.Errorf("error-1")
+	err2 := fmt.Errorf("error-2")
+
+	tests := []struct {
+		name     string
+		funcs    []func() error
+		expected map[int]error
+	}{
+		{
+			name: "basic",
+			funcs: []func() error{
+				func() error {
+					return nil
+				},
+				func() error {
+					return err1
+				},
+				func() error {
+					return nil
+				},
+				func() error {
+					return err2
+				},
+			},
+			expected: map[int]error{
+				1: err1,
+				3: err2,
+			},
+		},
+		{
+			name: "no errors",
+			funcs: []func() error{
+				func() error {
+					return nil
+				},
+				func() error {
+					return nil
+				},
+			},
+			expected: map[int]error{},
+		},
+		{
+			name: "all errors",
+			funcs: []func() error{
+				func() error {
+					return err0
+				},
+				func() error {
+					return err1
+				},
+				func() error {
+					return err2
+				},
+			},
+			expected: map[int]error{
+				0: err0,
+				1: err1,
+				2: err2,
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			got := parallelMapped(test.funcs...)
+			require.Equal(t, test.expected, got)
+		})
+	}
+}