diff --git a/go.mod b/go.mod index 618e2e9..2707998 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/slok/go-http-metrics v0.13.0 github.com/spf13/cobra v1.10.2 + go.uber.org/dig v1.19.0 go.uber.org/fx v1.24.0 golang.org/x/crypto v0.41.0 golang.org/x/sync v0.19.0 @@ -126,7 +127,6 @@ require ( go.opentelemetry.io/otel v1.36.0 // indirect go.opentelemetry.io/otel/metric v1.36.0 // indirect go.opentelemetry.io/otel/trace v1.36.0 // indirect - go.uber.org/dig v1.19.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect diff --git a/internal/app/app.go b/internal/app/app.go index 0d9daf9..1481ef7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -2,13 +2,16 @@ package app import ( + "context" "fmt" + "io" "os" "os/signal" "syscall" "github.com/prometheus/client_golang/prometheus" "github.com/spf13/cobra" + "go.uber.org/dig" "go.uber.org/fx" "sneak.berlin/go/pixa/internal/config" "sneak.berlin/go/pixa/internal/database" @@ -53,7 +56,10 @@ func run(_ *cobra.Command, _ []string) { // A write to a closed stdout or stderr must not end the process. signal.Ignore(syscall.SIGPIPE) - fx.New( + app := fx.New( + // pixad logs with slog only; runApp prints a startup or shutdown + // error itself. + fx.NopLogger, fx.Provide( config.New, database.New, @@ -71,5 +77,40 @@ func run(_ *cobra.Command, _ []string) { func(log *logger.Logger) { log.Identify() }, func(*server.Server) {}, ), - ).Run() + ) + + exitCode := runApp(app, os.Stderr) + if exitCode != 0 { + os.Exit(exitCode) + } +} + +// runApp starts app, waits for a signal or a shutdown request, stops app, +// and returns the status pixad exits with, as fx.App.Run does. It prints +// a start or stop error to stderr, once, as the error that caused it: +// dig.RootCause drops the frames fx and dig wrap around it. +func runApp(app *fx.App, stderr io.Writer) int { + startCtx, cancelStart := context.WithTimeout(context.Background(), app.StartTimeout()) + defer cancelStart() + + err := app.Start(startCtx) + if err != nil { + _, _ = fmt.Fprintln(stderr, dig.RootCause(err)) + + return 1 + } + + shutdown := <-app.Wait() + + stopCtx, cancelStop := context.WithTimeout(context.Background(), app.StopTimeout()) + defer cancelStop() + + err = app.Stop(stopCtx) + if err != nil { + _, _ = fmt.Fprintln(stderr, dig.RootCause(err)) + + return 1 + } + + return shutdown.ExitCode } diff --git a/internal/app/app_internal_test.go b/internal/app/app_internal_test.go new file mode 100644 index 0000000..d59b25d --- /dev/null +++ b/internal/app/app_internal_test.go @@ -0,0 +1,65 @@ +package app + +import ( + "bytes" + "context" + "errors" + "io" + "testing" + + "go.uber.org/fx" +) + +var errTestStopFailed = errors.New("test stop hook failed") + +// TestRunAppPrintsStopErrorOnce checks that when a stop hook fails, runApp +// prints the hook's error once and returns exit status 1. +func TestRunAppPrintsStopErrorOnce(t *testing.T) { + t.Parallel() + + app := fx.New( + fx.NopLogger, + fx.Invoke(func(lifecycle fx.Lifecycle, shutdowner fx.Shutdowner) { + lifecycle.Append(fx.Hook{ + // Ask to shut down as soon as the app has started, so + // that runApp goes on to stop it. + OnStart: func(context.Context) error { return shutdowner.Shutdown() }, + OnStop: func(context.Context) error { return errTestStopFailed }, + }) + }), + ) + + var stderr bytes.Buffer + + exitCode := runApp(app, &stderr) + if exitCode != 1 { + t.Errorf("runApp() = %d, want 1", exitCode) + } + + want := errTestStopFailed.Error() + "\n" + if stderr.String() != want { + t.Errorf("stderr = %q, want %q", stderr.String(), want) + } +} + +// TestRunAppReturnsShutdownExitCode checks that runApp returns the exit +// code a shutdown request names. +func TestRunAppReturnsShutdownExitCode(t *testing.T) { + t.Parallel() + + app := fx.New( + fx.NopLogger, + fx.Invoke(func(lifecycle fx.Lifecycle, shutdowner fx.Shutdowner) { + lifecycle.Append(fx.Hook{ + OnStart: func(context.Context) error { + return shutdowner.Shutdown(fx.ExitCode(3)) + }, + }) + }), + ) + + exitCode := runApp(app, io.Discard) + if exitCode != 3 { + t.Errorf("runApp() = %d, want 3", exitCode) + } +}