Concepts

Application lifecycle

The app package: the signal-driven graceful shutdown every gpsystem binary sits on.

In PHP the process lifecycle was not your problem: php-fpm started the worker, ran the request, and on deploy the supervisor handled the swap. In Go your binary is the server: it has to watch for SIGTERM, wait out in-flight requests, close the connection pool and flush telemetry before exiting. That is typically the ~60 lines of boilerplate copied into every service's main.go, and gotten slightly wrong in a different way each time.

The kit writes this once, in the app package, which makes the topic a non-topic again: in generated projects graceful shutdown is simply there, and now you can also see what happens.

app.Run and Params

The app package is engine-agnostic: it knows neither HTTP nor any other transport. It has a single entry point:

// Closer is a named cleanup function executed during graceful shutdown.
type Closer struct {
    Name string
    Fn   func(context.Context) error
}

// Params describes one process run.
type Params struct {
    ListenAddr      string                          // startup log only
    ShutdownTimeout time.Duration                   // budget for the in-flight drain
    Serve           func() error                    // blocking listen; runs in a goroutine
    Shutdown        func(context.Context) error     // stop accepting, drain in-flight
    Closers         []Closer                        // ordered cleanup list
    Flush           func(context.Context) error     // telemetry flush, very last
}

func Run(ctx context.Context, p Params) error

Run blocks until the process stops, driving the following machinery:

  1. signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM): SIGINT/SIGTERM cancels the context. Since it derives from the parent ctx, cancelling the parent context also triggers shutdown: in tests this is how you stop a running service without a signal.
  2. Serve starts in a goroutine. If it returns an error on its own (e.g. the port is taken), Run exits with an app: listen: error, but the closers and the flush still run.
  3. On signal: Run restores default signal handling (which is why a second Ctrl-C kills the process immediately, no mercy), then calls Shutdown with a ShutdownTimeout-bounded context. This is where in-flight requests drain.
  4. Cleanup with its own budget: the closers and the flush get a fresh context, also ShutdownTimeout long. Even if the HTTP drain consumed the whole budget, cleanup never starts with zero time: closing the pool and exporting telemetry don't fall victim to one slow request. (Both shutdown contexts detach from the parent via context.WithoutCancel: an already-cancelled parent cannot cut the orderly shutdown short.)
  5. Closers run in LIFO order, then Flush runs very last.
  6. A clean shutdown returns nil; partial failures accumulate via errors.Join, so one failing closer doesn't swallow the others'.

Why LIFO, and why is the flush last?

Closers run in reverse registration order because that closes dependencies in the right direction: what you opened first (say, the pgx pool) may still be needed by what you built on it later (say, a consumer that uses it). What you opened last, you close first, exactly how defer works within a function.

The telemetry flush is last because closers themselves emit telemetry: the log line of a pool shutdown, a span opened during cleanup only reaches the OTLP endpoint or Sentry if the exporters are still alive. If the flush ran before the closers, the last seconds of shutdown would be a blind spot.

Who sits on it?

All three chassis fill in the same app.Run, only the contents of Params differ:

Paramsfiberx.Runchix.Runworker.Run
ServefiberApp.Listen(...)srv.ListenAndServeasynq server + outbox relay + scheduler
ShutdownfiberApp.ShutdownWithContextsrv.Shutdownstop the loops, drain in-flight tasks
Closersthe WithCloser optionsthe WithCloser optionsthe WithCloser options
Flushthe shutdown function from telemetry.Setupsamesame

All three start with telemetry.Setup and pass the returned shutdown function as Flush, so the "Sentry and OTel flush at the very end" guarantee is identical regardless of engine or binary type. In the worker, the in-flight drain means something different: tasks not finished within WORKER_SHUTDOWN_TIMEOUT are requeued by asynq and redelivered later (at-least-once).

If you were to build your own fourth chassis (say, a gRPC server), you would put it on app.Run the same way: the package has no HTTP dependency.

The shop's main.go

In practice you touch the lifecycle at exactly one point: you register a closer for everything you opened in main. In the shop sample app's entry point that is the pgx pool:

pool := pg.MustNewPool(ctx, cfg.DB)

err := fiberx.Run(ctx, cfg.Server, func(app *fiber.App) error {
    // ... module registration ...
    return nil
}, fiberx.WithCloser("pgxpool", func(context.Context) error {
    pool.Close()
    return nil
}))

So when a SIGTERM arrives, the order is: the shop stops accepting requests → the in-progress PlaceOrder still completes (with its transaction and its outbox write) → the pgxpool closer closes the pool → telemetry flushes the request's spans. Under Kubernetes this is exactly the behavior a rolling deploy expects: tune ShutdownTimeout (SHUTDOWN_TIMEOUT, default 10s) to be shorter than the pod's terminationGracePeriodSeconds.

The closer's name ("pgxpool") is not decoration: when a closer fails, Run returns the error as app: closer "pgxpool": ..., so the log immediately shows which cleanup broke.

What this means day to day

  • You don't write signal handling. Ever. Calling fiberx.Run / chix.Run / worker.Run is the lifecycle.
  • Resources opened in main get a closer: the generators wire the pgx pool's for you; your own (say, a Redis client) you add with WithCloser, in opening order.
  • The double Ctrl-C escape hatch stays: if a drain ever hangs during development, the second signal kills instantly.

The HTTP-side sequence (telemetry → app construction → registration → listen → shutdown) is walked through on the Architecture page; the worker-side drain details on the Worker page.

Copyright © 2026