Skip to content

controls

A small service-lifecycle supervisor for long-running Go processes: concurrent startup, health probes, ordered (reverse-registration) shutdown and self-healing restarts, behind one Controller, as a light, framework-free library.

controls is the same supervisor behind go-tool-base's service commands, extracted so any project can register its services (HTTP and gRPC servers, background workers, schedulers) and let the Controller start them concurrently, aggregate their health, optionally take ownership of SIGINT/SIGTERM, and drive a bounded graceful shutdown, without pulling in the framework.

import "gitlab.com/phpboyscout/go/controls"

The light-footprint promise

The module graph is deliberately tiny. The library has exactly one external dependency, go/errors, a sibling module in the same toolkit; the others in go.mod are for tests. No config framework, no TUI, no OpenTelemetry, no go-tool-base: depfootprint_test.go fails the build if any module but go/errors ever enters the library's graph.

The only logging seam is the standard library's *slog.Logger, injected with WithLogger. Supply one or supply none: with none the controller logs to a discard handler, so logging is never mandatory and never a dependency you have to adopt.

Design

  • Framework-free. One external dependency (go/errors); one logging seam (*slog.Logger). Everything else is functional options.
  • Correct concurrency. Idempotent Start/Stop; goroutines that terminate on shutdown rather than leak or busy-spin; a restart supervisor that distinguishes a clean start from a cancellation from a failure; failures delivered to a callback or the error channel without ever blocking a service; a stop callback that overruns the shutdown deadline is abandoned rather than waited for, and the shutdown's Outcome says so; readiness fails closed until the first async health check has run. Race-clean under -race.
  • Transport-agnostic health. Status(), Liveness() and Readiness() return plain HealthReport values. The module does not open a port or register HTTP or gRPC handlers. You wire the reports into whatever transport your process already speaks (see Add health checks).

Quick start

package main

import (
    "context"

    "gitlab.com/phpboyscout/go/controls"
)

func main() {
    c := controls.NewController(context.Background())

    c.Register("api",
        controls.WithStart(func(ctx context.Context) error {
            // start serving; return when ctx is cancelled
            <-ctx.Done()
            return ctx.Err()
        }),
        controls.WithStop(func(ctx context.Context) {
            // graceful shutdown, bounded by ctx
        }),
    )

    c.Start() // launches the services and returns; installs no signal handler
    c.Wait()  // blocks until Stop (or the parent context) drains the shutdown sequence
}

Add controls.WithSignals() to NewController when this process is the outermost layer and Ctrl-C should stop it. See Handle graceful shutdown and signals for why it is opt-in.

Key concepts

  • Controller is the supervisor. Register services before Start; Wait blocks until the full shutdown sequence has completed and every supervisor goroutine has unwound. Wait requires start callbacks that return when their context is cancelled; WaitContext is the deadline-bounded variant for a service that wraps third-party code which may not.
  • Health probes. Attach WithStatus, WithLiveness or WithReadiness to a service, or register standalone HealthChecks (sync, or async with an Interval). Status(), Liveness() and Readiness() aggregate them into a HealthReport.
  • Restart policy. WithRestartPolicy enables self-healing with exponential backoff, a MaxRestarts cap on consecutive failures, a health-failure threshold, and a counter that resets after a healthy window.
  • Supervisor manages a set of children that attach and detach while the process runs, under the same RestartPolicy rules. A Controller manages a fixed set with ordered shutdown; a Supervisor manages a changing one with concurrent shutdown, and a process usually wants both.
  • Generational owns one generation of a resource at a time, for a service that holds something single-use (a listener, a server, a supervisor) and has to survive a restart.
  • Options. WithLogger, WithShutdownTimeout, WithSignals for a standalone main, and WithValidError to exempt an expected terminal error such as http.ErrServerClosed from the restart count.

Install

go get gitlab.com/phpboyscout/go/controls

Where to go next

The documentation follows the Diátaxis framework:

Using go-tool-base? The framework wires this supervisor into its service commands and exposes the HealthReports through its own pkg/http and pkg/grpc handlers. That glue lives in go-tool-base, not here; this module is transport- and framework-agnostic.

Further reading

The blog carries a curated route through this subject: Building a command-line tool in Go collects everything written about it, ordered so you can start at the beginning rather than newest-first.

Ask phpbotscout

phpbotscout

He answers questions about the projects over on the Discord, citing the docs where they already cover it, and offering to raise an issue where they don't. Bring a bug, an idea, or a questionable engineering decision.

Join the Discord