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:
HealthFailureThresholdwithout aWithStatusprobe. Health-driven restarts need both; with no probe, monitoring never starts however high the threshold.- A
RestartPolicyon a service whoseStartFuncreturnsniland which has noWithStatusprobe. A clean start is not an exit, so there is nothing to restart on; the service simply runs until shutdown. HealthFailureThresholdorHealthCheckIntervalon aChild. A child has noStatusprobe 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;
GetServiceInfocan only return one of them. - A health check sharing a name with a service.
RegisterHealthCheckonly checks for collisions among health checks, so this is accepted and produces two report entries with the samename. WithStopandWithStopErron 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.
Related¶
- Reference: the options, fields and defaults that do exist.
- Architecture and the lifecycle state machine: the design these boundaries follow from.
- Concurrency and shutdown correctness: the guarantees that are made, and what each one covers.