Skip to content

What controls does not do

controls supervises service lifecycles and nothing else. Everything below is either deliberately absent or a known boundary of the design, and worth reading before you plan around a capability that is not there. Where there is a way to get the effect anyway, it is named.

It does not serve health endpoints

The module produces HealthReport values. It opens no port, registers no HTTP or gRPC handler, and speaks no orchestrator's health protocol. Wiring a report onto /readyz, a gRPC health service or anything else is your code, a handful of lines, shown in Add health checks. This is what keeps the dependency graph to one external module, and it is why the same supervisor works under go-tool-base's transports and under a hand-rolled net/http server.

It does not order or gate service startup

Services start concurrently. The controller launches one supervisor goroutine per service in registration order and does not wait for a StartFunc to return, or a readiness probe to pass, before launching the next. There is no dependency declaration, no DependsOn, and no barrier.

Only shutdown is ordered: stop callbacks run one at a time in reverse registration order. If service B must not begin work until service A is up, gate it inside B's own StartFunc, for example by waiting on a channel A closes.

It does not stop the process when a service dies, unless you ask

By default a StartFunc that returns an error, or a service that exhausts MaxRestarts, does not shut the controller down. The error is recorded on ServiceInfo, forwarded on the error channel and logged; every other service carries on.

WithStopOnGiveUp changes that: a service that fails for good stops the controller with CauseGaveUp, naming it on Outcome, and a server with no restart policy is watched for a death after a clean start. WithRunToCompletion stops it with CauseCompleted when a unit of work finishes. See Stop when a service gives up.

Two deaths stay invisible even then: a service with a restart policy and no Status or no HealthFailureThreshold, and a Supervisor's child.

A health report is not proof a service is running

Reports aggregate the probes you supplied. A service with no probe always reports "OK", including one whose StartFunc returned an error minutes ago, and one registered after Start that never ran at all. controls does not inspect goroutine liveness. Give a service a probe that checks something real if you want its report entry to mean anything.

It cannot recover a panic on a goroutine your callback started

Every callback the controller or a Supervisor calls is recovered on the goroutine that called it, and the panic becomes an error wrapping ErrPanicked (spec 0009):

Callback Panic behaviour
WithStart recovered; the service's failure, handled by its restart policy like an error. With no policy it is UnableToStart
WithStop / WithStopErr recovered; recorded on ServiceInfo.StopErr, and shutdown continues with the next service
WithStatus / WithLiveness / WithReadiness, called while a report is built recovered; reported as probe panicked: <value>
WithStatus, polled by the restart supervisor recovered; a health breach at once, or one failure under WithProbePanicsAsFailures
HealthCheck.Check recovered; reports unhealthy, health check panicked: <value>
Child.Start, run by a Supervisor recovered; counted, and fed to the restart policy
Child.Stop recovered

Two things remain outside it. Go recovers a panic only on the goroutine that panicked, so a StartFunc that launches a server goroutine and returns has handed that goroutine to Go: recover inside it. And concurrent map writes, stack exhaustion and running out of memory are fatal runtime errors, not panics, so nothing recovers them.

A WithValidError predicate is called unrecovered, on the supervisor goroutine, so it must not panic.

It cannot be restarted, and it cannot be reconfigured while running

A controller is single-use. The lifecycle runs NeverStarted → Running → Stopping → Stopped once, with an optional Running → UnableToStart → Stopping branch when a registered service proves it will never start; calling Start() on a Stopped controller logs a warning and does nothing. To run services again, construct a new controller.

A Stop before any Start is the one exception: it is a no-op, leaves the state at NeverStarted, and the controller stays startable.

Nor can it be reconfigured mid-flight. Services and health checks must be registered before Start; there is no deregistration for either. A Supervisor is the answer when the set has to change: its children can be detached at any time, and attached at any time before shutdown begins (Attach returns ErrSupervisorStopped once Stop has started). Detaching is the deregistration a Controller does not have. The SetX setters exist for construction. They mutate fields the control goroutines read without synchronisation, so calling one on a running controller is a race, not a supported reconfiguration path.

A Supervisor is single-use too: Start after Stop returns ErrSupervisorStopped. A service that owns one and must survive its own restart builds a fresh supervisor per run, which is what Generational is for.

Configurations that are silently inert

Each of these compiles, runs and does nothing:

  • HealthFailureThreshold without a WithStatus probe. Health-driven restarts need both; with no probe, monitoring never starts however high the threshold.
  • A RestartPolicy on a service whose StartFunc returns nil and which has no WithStatus probe. A clean start is not an exit, so there is nothing to restart on; the service simply runs until shutdown.
  • HealthFailureThreshold or HealthCheckInterval on a Child. A child has no Status probe for them to drive.
  • WithShutdownTimeout(0). Zero is not "use the default". It is an already-expired budget, so stop callbacks are abandoned as soon as they are launched and may never run.
  • Two services registered under the same name. Both run; both appear in reports under that name; GetServiceInfo can only return one of them.
  • A health check sharing a name with a service. RegisterHealthCheck only checks for collisions among health checks, so this is accepted and produces two report entries with the same name.
  • WithStop and WithStopErr on the same service. The error-reporting one runs and the plain one is ignored, because calling a service's stop twice would be worse.

It will not own OS signals unless you ask, and it cannot share them

Signal disposition is process-global, and signal.Notify is additive: every registered channel gets a copy. So the controller registers nothing by default, and WithSignals is for the case where the controller genuinely is the outermost layer.

There is no supported configuration in which two things own the signal. Under a CLI framework that already turns signals into context cancellation (go-tool-base does), passing WithSignals gives you two shutdown drivers racing on one Ctrl-C. Let the outer layer own it and cancel the context you passed to NewController.

You cannot tell from the context why the controller stopped

Since v0.2.0 the controller owns its own cancellation, so context.Cause(ctx) is ErrShutdown for every stop it drives: a direct Stop(), a parent cancellation, an expired parent deadline, or a signal. That is the point: the guarantee is unconditional. The cost is that the cause no longer distinguishes those triggers. A service that needs to know watches the parent context itself. The controller's owner reads Outcome() once Done() has closed: its Cause says which trigger started the shutdown, with the parent's cause or the signal.

Wait can still hang on a start callback that ignores cancellation

The shutdown sequence is bounded, and it abandons a stuck supervisor at the deadline while naming the service in a WARN and in Outcome().Unfinished. The bare Wait() is not bounded: it waits until every service has either started cleanly or had its supervisor goroutine exit, which a StartFunc that fails, then never returns after cancellation on its retry, makes impossible. Wait on Done() to know shutdown has finished; use WaitContext(ctx) when you need the supervisors themselves, as it returns ctx.Err() and leaks the stuck goroutine rather than blocking forever.

There is no restart curve to tune, and no jitter

Backoff doubles from InitialBackoff to MaxBackoff. The multiplier is fixed at 2.0, there is no jitter, and there is no way to substitute a strategy of your own. A fleet of instances restarting against the same downed dependency will retry in step.

There are no metrics, traces or start events

No counters, no OpenTelemetry, and no event for a start or a clean stop. The observable surface is the *slog.Logger you inject, failures through WithOnEvent and the error channel, GetState(), GetServiceInfo, Outcome(), and the health reports.

The one exception is a Supervisor, which fires WithOnFailure and sends on Failures() when a child reaches a terminal failure. That is a single transition, not an event stream: nothing fires on a start, a restart or a clean stop. Keeping instrumentation out is what the dependency-footprint guard test enforces. Instrument in the layer above, from those signals.

It reads no configuration and ships no mocks

There are no environment variables, config files or flags: every setting is a functional option or a struct field, listed in the reference tier. And no mocks are generated or published. Write a stub, or generate one for the interface your code depends on.