App lifecycle
gas.App serves HTTP. It embeds a gas.Worker, which owns the DI container,
the event bus, migrations, and service management; the App adds the router,
CSRF protection, and the HTTP server. Everything on this page about startup and
shutdown applies to a Worker too, except the steps marked App only. For
processes without an HTTP server, see Workers.
Run, or its parts
Section titled “Run, or its parts”app.Run() is the whole lifecycle in one call: start, serve, wait for SIGINT or
SIGTERM, stop. It uses context.Background() for startup and shutdown.
It is built from smaller methods, which you call yourself when something else owns the process, or in tests:
| Method | Does |
|---|---|
Start(ctx) |
Builds and initializes services, runs migrations and ready hooks, binds the config. Does not block or listen. |
Serve() |
Starts the HTTP server and blocks until it stops. A clean shutdown returns nil. |
Handler() |
Returns what the server serves: the router behind CSRF protection. |
Stop(ctx) |
Shuts the HTTP server down, then closes every service. |
Run calls Stop whether a signal arrives or Serve fails, so a port that is
already taken still closes the services Start initialized; Run then returns
the server error joined with any error from Stop.
Startup order
Section titled “Startup order”Start runs these steps in order. Knowing the order answers most “why hasn’t
my thing happened yet” questions.
-
Build singletons. The container validates every registration, runs
Initon everyServiceregistered as an instance, then constructs every singleton in dependency order, calling each one’sInitas it is built. The details are on Services and DI. -
Seal the router and check handlers (App only). The router builds its routing tree from everything registered so far; until then,
Handler()serves none of your routes. Every DI-aware handler’s parameter types are checked against the container. -
Emit
gas.SystemAllServicesInitialized, and wait for its subscribers to return. -
Run migrations. If a
gas.MigrationManageris registered, itsRunPendingapplies every pending migration. See Migrations. -
Run ready hooks, in registration order.
-
Bind the config (App only). If a
gas.ConfigProvideris registered, the App’sConfig(server settings and environment) is bound from it, andgas.AppConfigUpdatedis emitted with the result. The event is emitted even when no provider is registered.
Run then logs the route map and starts the server.
A failure in any step returns an error from Start, and the server never
accepts a request against a half-built app. The error from a failed migration
starts with gas: migrations:, from a ready hook with gas: ready hook:, and
from config binding with gas: failed to bind config:.
Running the lifecycle sample prints the order directly.
A failed start leaves nothing running
Section titled “A failed start leaves nothing running”When Start fails, it closes, in reverse order, every service it had already
initialized, and returns the startup error joined with any Close errors. The
Close calls get the context passed to Start with its cancellation removed,
so cleanup runs even if that context is what failed.
The App or Worker cannot be started again: every later Start returns the same
error, and a later Shutdown has nothing to close. Build a new one to retry.
Init and Close contexts
Section titled “Init and Close contexts”Start, InitServices, Stop, Shutdown, CloseService, RestartService,
ServiceContainer.BuildAll, ServiceContainer.NewScope, and Scope.Close all
take a context. Run takes none.
Init of a singleton gets the context passed to Start (or to
RestartService) with a per-service deadline added: 15 seconds by default.
gas.WithServiceInitTimeout(d)changes the default for an App or Worker.0disables the deadline, and a negative value panics.- A service that implements
gas.ServiceInitTimeoutsets its own.0means no deadline, and a negative value fails itsInitwithout running it.
// Search needs longer than the default 15 seconds to warm up, so it sets its// own Init deadline.type Search struct{}
func NewSearch() *Search { return &Search{} }
func (s *Search) InitTimeout() time.Duration { return time.Minute }
func (s *Search) Name() string { return "search" }
func (s *Search) Init(ctx context.Context) error { // Honor ctx: when the deadline passes it is canceled, and startup fails // once Init returns. return warmIndex(ctx)}
func (s *Search) Close(context.Context) error { return nil }When the deadline passes, the container cancels the context, waits for Init
to return, and fails with an error that wraps context.DeadlineExceeded. An
Init that ignores the cancellation and returns nil late is closed again
before the error is returned, so nothing it acquired is left open.
While a deadline applies, the context is canceled as soon as Init returns.
Use it for synchronous work in Init only: a goroutine started in Init must
not derive its context from it. Workers
shows the pattern for a background loop.
Init of a scoped service gets the scope’s context with the same deadline
added, the default or the service’s own InitTimeout, and the same timeout
handling. In an App that is the request’s context. A scoped Init that times
out fails the resolution.
Close gets the caller’s shutdown context: the one passed to Stop,
Shutdown, CloseService, or Scope.Close. In an App, a request’s scoped
services are closed with the request’s context minus its cancellation, so they
clean up even after the client has gone.
Ready hooks
Section titled “Ready hooks”A ready hook is work that needs the built container but must finish before the app takes traffic, such as seeding data:
app := gas.NewApp( gas.WithServiceInstance[*trace](t), gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()), gas.WithSingletonService[*DB](NewDB), gas.WithSingletonService[*Cache](NewCache), gas.WithSingletonService[*Probes](NewProbes),
// Runs after migrations and before the server starts. gas.WithReadyFunc(func(sc *gas.ServiceContainer) error { db, err := sc.Resolve[*DB]() if err != nil { return err } return seed(db) }),)Hooks run after migrations, so the schema exists, and before Start returns.
They run in registration order, and the first error aborts startup; it is also
logged as ready hook failed. app.ReadyFunc(fn) is the imperative form, and
panics if it is called after the hooks have run, because the function would
never run.
Shutdown
Section titled “Shutdown”Run shuts down on SIGINT or SIGTERM. Stop(ctx) does it explicitly:
-
Emit
gas.SystemServerShuttingDownand wait for its subscribers (App only). -
Shut the HTTP server down gracefully, giving in-flight requests up to
Server.ShutdownTimeout(30 seconds by default), or less ifctxends first (App only). An error here, including the timeout, is logged asserver shutdown errorand returned, and the sequence continues. -
Emit
gas.SystemShuttingDownand wait for its subscribers. -
Close every active service in reverse initialization order, passing
ctxto eachClose. Services already closed withCloseServiceare skipped.
Steps 3 and 4 are Worker.Shutdown, which is all a Worker does. A failing
Close does not stop the others; every Close error is returned, joined, each
prefixed with gas: close <name>:. An App’s Stop returns the server
shutdown error and those errors joined, prefixed with gas: shutdown error:,
or nil when both steps succeed.
Shutdown runs once. Later calls, including concurrent ones, wait for the first
to finish and return nil. Once it has begun, CloseService and
RestartService return an error.
Start and Stop in tests
Section titled “Start and Stop in tests”Handler() returns exactly what the server would serve, so a test can drive
the full middleware stack without binding a port:
// Start builds and initializes every service, runs migrations and ready// hooks, and binds the config. It does not block or listen.if err := app.Start(context.Background()); err != nil { log.Fatal(err)}
// Handler is what the server would serve: the router behind CSRF// protection.srv := httptest.NewServer(app.Handler())
resp, err := http.Get(srv.URL + "/readyz")if err != nil { log.Fatal(err)}_ = resp.Body.Close()srv.Close()
// Stop shuts the server down, then closes every service in reverse// initialization order.if err := app.Stop(context.Background()); err != nil { log.Fatal(err)}More on this in Testing.
Health and readiness
Section titled “Health and readiness”A service opts in to probes by implementing either interface:
type HealthReporter interface { CheckHealth(ctx context.Context) error // nil means healthy}
type ReadyReporter interface { CheckReady(ctx context.Context) error // nil means ready}Worker.CheckHealth(ctx) and Worker.CheckReady(ctx) call every active service
that implements the interface, concurrently, and return a map from service name
to result. Services that do not implement it are left out, and so are services
closed with CloseService. A reporter that panics is recovered and reported as
an error.
The Worker registers itself in the container as gas.HealthProvider and
gas.ReadyProvider, the interfaces those two methods satisfy, so a service can
inject them. Gas adds no probe endpoints of its own; expose them like any other
route:
// Probes exposes liveness and readiness endpoints. gas.HealthProvider and// gas.ReadyProvider are registered by the Worker itself.type Probes struct { router *gas.Router health gas.HealthProvider ready gas.ReadyProvider}
func NewProbes(router *gas.Router, health gas.HealthProvider, ready gas.ReadyProvider) *Probes { return &Probes{router: router, health: health, ready: ready}}
func (p *Probes) Name() string { return "probes" }
func (p *Probes) Init(context.Context) error { p.router.Get(p, "/livez", func(ctx gas.Context) error { return report(ctx, p.health.CheckHealth(ctx)) }) p.router.Get(p, "/readyz", func(ctx gas.Context) error { return report(ctx, p.ready.CheckReady(ctx)) }) return nil}
func (p *Probes) Close(context.Context) error { return nil }
// report answers 200 when every reporter returned nil, and 503 with the// failing services otherwise.func report(ctx gas.Context, results map[string]error) error { failing := make(map[string]string) for name, err := range results { if err != nil { failing[name] = err.Error() } } if len(failing) > 0 { return ctx.JSON(http.StatusServiceUnavailable, failing) } return ctx.NoContent()}The two answer different questions. Health (liveness) asks whether the process is broken in a way a restart would fix: a connection that will not recover, corrupt internal state. Readiness asks whether traffic should be sent to this instance right now: a dependency is briefly unreachable, a cache is still warming. Report a transient outage as unready, never unhealthy, or a dependency blip becomes a restart loop.
The whole sequence
Section titled “The whole sequence”The lifecycle sample
registers DB and Cache (which depends on DB), subscribes to the system
events, adds a ready hook, serves one request, and stops. Its output, with log
lines left out:
GET /readyz: 204init dbinit cacheevent SystemAllServicesInitializedready hookevent AppConfigUpdatedevent SystemServerShuttingDownevent SystemShuttingDownclose cacheclose dbThere is no migration step in the output because the sample registers no
gas.MigrationManager. The events are covered in Events.