servo package
import "github.com/okian/servo/v3/servo"
Who this is for: anyone reading a generated file’s calls into the runtime, or handling a
Report in their own code.
This package is two unrelated halves. The markers (Build, Root, Bind, Override,
Scoped, Value, Include, and the two scope options) are read as syntax by servo generate and
panic if they ever execute. The runtime — around 430 lines — is what generated code actually
calls at run time. It imports nothing outside the standard library, and neither it nor any generated
output imports reflect.
Your components never import this package. Capability interfaces are satisfied structurally, by having the method.
Index
| Identifier | Kind | |
|---|---|---|
Build |
func | Declares an injector (marker) |
Root |
func | Declares a graph root (marker) |
Bind |
func | Binds an interface to a concrete type (marker) |
Override |
func | Test-only binding (marker) |
Scoped |
func | Declares a keyed, refcounted instance (marker) |
Value |
func | Declares a type the caller supplies (marker) |
Include |
func | Splices another function’s marker list in (marker) |
Linger, Max |
funcs | Scope policy (markers) |
Marker |
type | The markers’ opaque return type |
ScopeOption |
type | Linger/Max’s opaque return type |
DefaultLinger, DefaultMax |
consts | What an omitted scope option becomes |
ErrNoScopeKey … ErrScopeClosed |
vars | The four scope errors |
ScopeStats |
type | A point-in-time view of one scope |
LingerOverride |
var | Test hook for the linger window |
LingerWindow |
func | Reads that hook; called by generated code |
Initializer … Readier |
types | The seven capability interfaces |
Report |
type | Every node’s outcome for one pass |
NodeResult |
type | One node’s outcome |
NodeStatus |
type | ok, failed, or abandoned |
MergeNodeResults |
func | Combines a node’s per-phase results |
RunStop |
func | Runs a stop call under a budget |
DefaultStopBudget |
var | The budget every stop call gets |
Graph, GraphNode, GraphScope |
types | The resolved graph as data |
StartupReport, StartupNode |
types | Per-node Init timings |
Markers
All of them exist to be read, not run. Each one panics if executed, which is what a missing
servoinject build tag or a skipped generation looks like at run time — a loud failure instead of a
nil app. Full semantics are on Spec file and markers.
Build
func Build(...Marker)
Declares an injector’s roots and bindings. Every argument must be a marker call written inline with explicit type arguments.
Root
func Root[T any]() Marker
Declares T as a root: T and everything it transitively depends on is constructed, and nothing
else.
Bind
func Bind[I, C any]() Marker
Declares that concrete type C satisfies interface I wherever I is requested. Wins over
structural search, including over an otherwise-unambiguous match.
Override
func Override[I, C any]() Marker
Declares a test-only replacement for I, used only when emitting NewTestApp. Takes priority over
a Bind for the same interface.
Scoped
func Scoped[T, I any](...ScopeOption) Marker
Declares T as a keyed, refcounted, lifecycle-managed instance instead of a singleton, reachable
through the accessor interface I that you declare in your own package. T must have a ScopeKey
method. Panics if it ever runs.
Full treatment: Scoped instances.
Value
func Value[T any]() Marker
Declares that T is supplied by the caller rather than built by a provider. It beats any provider
that also produces T — declaring one is how you say “this comes from the caller”, which is only
meaningful if it wins — and a declared T nothing in the graph depends on is a generate-time
diagnostic rather than a struct field every caller keeps supplying and the app never reads.
Declaring at least one changes the generated API additively: the injector keeps New(ctx) and gains
type Values struct{…} and func NewWith(ctx context.Context, v Values) (*App, error). See
Generated API, and Spec file and
markers for the full contract.
Include
func Include(func() []Marker) Marker
Splices the marker list returned by the named function into this Build call, so a set of
declarations shared by several injectors is written once. The function is named, never called:
its body is read as syntax exactly as Build’s own argument list is, which is why it must be
exactly return []servo.Marker{ …marker calls… } and why the file it lives in needs the
servoinject build tag the same way a spec file does.
It may name a function in another package, includes may nest, a cycle is a diagnostic, and a
Bind/Override written locally in the spec file supersedes an included one for the same
interface. Full contract: Spec file and markers.
Linger and Max
func Linger(time.Duration) ScopeOption
func Max(int) ScopeOption
const DefaultLinger = 30 * time.Second
const DefaultMax = 10_000
Linger is how long a scope keeps an instance alive after its last holder releases it; Max caps
how many keys it will hold instances for at once. Both arguments must be constant expressions —
the spec file is read, never run — and both panic if they ever execute.
DefaultLinger and DefaultMax are what servo generate bakes in when the option is omitted.
They are generate-time constants: changing them changes what the next generation emits and has no
effect on already-generated code.
Marker
type Marker struct{}
The opaque return type of every Build marker — Root, Bind, Override, Scoped, Value and
Include. Carries no data; it exists so Build’s argument list type-checks, and so an included
function has a slice element type to return.
ScopeOption
type ScopeOption struct{}
Linger and Max’s opaque return type, for the same reason Marker exists: it gives them a type
that makes Scoped’s argument list type-check. It carries no data.
Capability interfaces
type Initializer interface{ Init(ctx context.Context) error }
type Runner interface{ Run(ctx context.Context) error }
type Drainer interface{ Drain(ctx context.Context) error }
type Flusher interface{ Flush(ctx context.Context) error }
type Finalizer interface{ Stop(ctx context.Context) error }
type Healther interface{ Health(ctx context.Context) error }
type Readier interface{ Ready(ctx context.Context) error }
Detected with types.Implements at generation time, never with a runtime type assertion. Declare
these method signatures on your component and it joins the corresponding phase; declare none and no
lifecycle code is emitted for it. There is no base type to embed and nothing to register.
What each one is for, and when to write one:
| Interface | It means | Write one when |
|---|---|---|
Initializer |
Acquire what construction could not: dial the connection, open the file, run the migration. Failure aborts startup and rolls back everything already built. | Your constructor can produce the value but cannot yet use it. Keep the constructor pure and put the I/O here. |
Runner |
A loop that owns the process’s time: an HTTP server, a queue consumer, a ticker. Returns when its context is cancelled. | Your component does work nobody calls it for. |
Drainer |
Stop accepting new work; let what is in flight finish. | Requests, messages or jobs can be mid-flight when shutdown starts, and cutting them off would lose them. |
Flusher |
Push buffered state somewhere durable. | You hold data in memory that would be lost on exit — a write buffer, a metrics batch, a spool. |
Finalizer |
Release the resource: close the pool, disconnect, unsubscribe. | You hold something the OS or a remote service is also tracking. |
Healther |
“This process is not broken.” Restart me if it fails. | A dependency can fail in a way that a restart would fix. |
Readier |
“Send me traffic.” Take me out of the load balancer if it fails. | You can be alive but temporarily unable to serve — warming a cache, waiting on a leader election. |
Drain, Flush and Stop must tolerate a component that was constructed but never Inited.
This is the one contract here that is easy to miss and expensive to get wrong. Both rollback paths
inside New reach the stop methods of nodes whose Init never ran — see
Lifecycle — so a Stop that assumes Init
succeeded panics during the unwind of an unrelated failure:
// Wrong: nil pool if Init never ran.
func (d *DB) Stop(ctx context.Context) error { return d.pool.Close() }
// Right.
func (d *DB) Stop(ctx context.Context) error {
if d.pool == nil {
return nil
}
return d.pool.Close()
}
Three distinctions decide most of the questions people have here:
DrainvsStop.Drainwaits for work to finish;Stopreleases the resource. A server implements both:Drainstops accepting connections and waits for open ones, thenStopcloses the listener. If you only have one of the two behaviours, only write that one.HealthvsReady. Neither is called by servo — you callapp.Health(ctx)orapp.Ready(ctx)yourself, typically from a handler. The distinction is the Kubernetes one, and conflating them means a slow cache warm-up gets your pod killed rather than briefly removed from the load balancer.- A method vs a cleanup
func(). PreferStop: it takes a context and returns an error, and a cleanup func does neither. Reach for the closure only when teardown is not about the value — removing a temp directory the value never knew about, undoing a global. See the cleanup func.
Every one of these is optional and independent. Implement three and exactly those three are called. When each runs, in what order, and under what budget: Lifecycle.
Reports
Report
type Report struct {
Nodes []NodeResult
}
func (r Report) Clean() bool
func (r Report) Error() string
func (r Report) Unwrap() []error
Every node’s outcome for one Shutdown, Health or Ready pass, in the order the framework
processed them. Shutdown’s report is in the order nodes were actually stopped, which is what makes
it assertable rather than assumed.
Clean reports whether every node reached StatusOK. An empty report is clean — a graph with
nothing to stop stopped fine.
Error renders every non-OK node as one message, ; -separated:
*api.Server: failed: connection reset; *postgres.DB: abandoned: context deadline exceeded. For a
clean report it returns the empty string.
Unwrap returns one wrapped error per node that carried one, so errors.Is and errors.As
traverse into them normally.
Report satisfies error by value, which composes nicely with errors.Join — the generated
New uses exactly that to combine an Init failure with the shutdown it triggered, when that
shutdown had something to say. When the rollback comes out clean it returns the bare error instead,
because joining a clean report appends its empty Error() as a trailing newline. It also means one
trap worth naming:
// Always non-nil, even on a completely clean shutdown.
var err error = app.Shutdown(ctx)
// Correct.
if r := app.Shutdown(ctx); !r.Clean() {
log.Print(r)
}
A Report value in an error interface is never nil. Check Clean().
NodeResult
type NodeResult struct {
Name string
Status NodeStatus
Err error
}
One node’s outcome. Name is the node’s full type string — the same string used as its identity
everywhere else, so it lines up with servo explain, servo graph, and GraphNode.Type. Err is
nil when Status is StatusOK.
NodeStatus
type NodeStatus int
const (
StatusOK NodeStatus = iota
StatusFailed
StatusAbandoned
)
func (s NodeStatus) String() string
| Constant | String() |
Meaning |
|---|---|---|
StatusOK |
ok |
Returned nil within its budget |
StatusFailed |
failed |
Returned an error within its budget |
StatusAbandoned |
abandoned |
Didn’t return in time; the goroutine was left running |
Any other value stringifies as unknown. Health and Ready never produce StatusAbandoned —
they apply no budget.
MergeNodeResults
func MergeNodeResults(name string, results ...NodeResult) NodeResult
Combines one node’s per-phase results (Drain, Flush, Stop, cleanup) into the single per-node
outcome a Report enumerates. Abandoned outranks failed outranks OK, and every phase error is
joined with errors.Join. Called by generated code; exported because generated code is in your
package, not servo’s.
Scopes
Scope errors
var ErrNoScopeKey = errors.New("servo: no scope key in context")
var ErrNoLifetime = errors.New("servo: context has no Done channel — …")
var ErrScopeFull = errors.New("servo: scope is at its Max live-instance cap")
var ErrScopeClosed = errors.New("servo: scope is shut down")
| Error | Returned by | Means |
|---|---|---|
ErrNoScopeKey |
Your own ScopeKey method |
The context carries no key. A convention, not a requirement — return any error you like |
ErrNoLifetime |
Acquire |
The context can never be done, so the release backstop would never fire. Background, TODO and WithoutCancel are refused |
ErrScopeFull |
Acquire |
The scope already holds Max live instances and this key is not one of them |
ErrScopeClosed |
Acquire |
Shutdown has begun; the scope no longer accepts acquires |
All four are distinct sentinels — match them with errors.Is.
ScopeStats
type ScopeStats struct {
Live int `json:"live"`
Refs int `json:"refs"`
Acquires uint64 `json:"acquires"`
Evictions uint64 `json:"evictions"`
Failures uint64 `json:"failures"`
}
Returned by a generated accessor’s Stats(). Live counts instances, including one whose teardown
is still running — so waiting for it to reach zero is a valid way to wait for a scope to go quiet.
Refs is outstanding references across every instance. Acquires and Evictions are monotonic
totals, and an eviction counts once its instance has finished draining and stopping. Failures is
how many of those evictions did not come out clean — the only signal there is for a mid-life
teardown that failed, since no Report is being assembled at the time.
Live and Refs are sampled a nanosecond apart under a scope other goroutines are still using:
a snapshot of a moving system, not two halves of one atomic read.
Test- and debug-facing. Wiring it to Prometheus is your job — servo exports no metrics.
LingerOverride and LingerWindow
var LingerOverride time.Duration = -1
func LingerWindow(declared time.Duration) time.Duration
Generated code calls LingerWindow exactly once per scope, inside New, to decide that scope’s
window. LingerOverride replaces every declared window when it is non-negative — which is why the
“no override” sentinel is -1 and not 0: zero is a real policy.
Set it through servotest.Linger rather than directly. Like
DefaultStopBudget, it is a package variable, so tests that use it must not run in parallel with
each other or with tests that depend on a scope’s real window, and must set it before New.
Stop budget
DefaultStopBudget
var DefaultStopBudget = 5 * time.Second
Bounds every shutdown and rollback phase call when no other budget is given. A package variable
rather than configuration, because parsing configuration is out of scope for a code generator — set
it before New if 5 seconds is wrong for your service.
Note that it applies per phase call, not per node: a component implementing Drain, Flush and
Stop can consume three budgets in the worst case.
servotest.Timeout shrinks it for a single test and restores it via
t.Cleanup.
RunStop
func RunStop(ctx context.Context, budget time.Duration, name string, fn func(context.Context) error) NodeResult
Runs fn in its own goroutine under a context derived from ctx with budget as its deadline, and
returns:
StatusOKiffnreturned nil in timeStatusFailed, with the error, if it returned an error in timeStatusFailed, with the panic value and the stack, if it panickedStatusAbandoned, with the context’s error, if it didn’t return
The result channel is buffered, so a goroutine that outlives its budget can still send without leaking — but it does keep running. That is the honest trade: servo reports an abandoned node rather than blocking shutdown forever on a component that won’t stop.
A panic in fn is recovered, and reported as one failed node:
*api.Server: failed: servo: *api.Server panicked during stop: send on closed channel
goroutine 42 [running]:
…
It has to be. The goroutine is servo’s, not the caller’s, so no recover in your main can reach a
panic here: unrecovered, one panicking Stop kills the process mid-unwind, leaving every node
behind it running and no Report to say which. Turning it into a StatusFailed result lets the
rest of the teardown finish and names the culprit.
Generated code calls this once per phase per node. You’d call it directly only when writing your own teardown around a graph.
Graph data
Graph and GraphNode
type Graph struct {
Nodes []GraphNode `json:"nodes"`
Scopes []GraphScope `json:"scopes,omitempty"`
}
type GraphNode struct {
Type string `json:"type"`
Level int `json:"level"`
Deps []string `json:"deps"`
Capabilities []string `json:"capabilities"`
Binding string `json:"binding"`
Pos string `json:"pos"`
Scope string `json:"scope,omitempty"`
}
The resolved graph as plain data, returned by the generated App.Graph() and produced by
servo graph --format=json — the same struct populated by both paths, down to nil versus [] and
the form of every position, so the build-time and run-time views can’t drift.
| Field | Contents |
|---|---|
Type |
Full type string, the node’s identity |
Level |
1 + max(level of deps); the unit of Init concurrency. 0 for a supplied value, which the app has before it builds anything |
Deps |
Direct dependencies, as type strings. nil, not [], when there are none — the generated App.Graph() emits a Go literal, and servo graph --format=json matches it |
Capabilities |
Detected capability names, in a fixed order: Initializer, Runner, Drainer, Flusher, Finalizer, Healther, Readier |
Binding |
explicit bind, sole candidate, sole implementation, or supplied for a servo.Value |
Pos |
The provider’s declaration site — or, for a supplied value, the servo.Value[…]() call site. Relative to the module root in both producers, so two checkouts at different absolute paths agree |
Scope |
The key type of the scope this node belongs to, or empty for a singleton |
Display-only: type strings are labels, never lookup keys, and there is no path from a GraphNode
back to the instance it describes.
GraphScope
type GraphScope struct {
Key string `json:"key"`
Linger string `json:"linger"`
Max int `json:"max"`
Accessors []string `json:"accessors"`
Members []string `json:"members"`
Borrows []string `json:"borrows"`
}
One per declared scope, in Graph.Scopes. Members is what one instance holds; Borrows is the
singletons it shares with the rest of the app. A scoped node’s GraphNode carries the same Key
string in its Scope field, and its Level counts from the scope’s own floor.
Both GraphNode.Scope and Graph.Scopes are omitempty, so a graph with nothing scoped
serialises exactly as it did before scopes existed.
StartupReport and StartupNode
type StartupReport struct {
Nodes []StartupNode `json:"nodes"`
}
type StartupNode struct {
Type string `json:"type"`
Duration time.Duration `json:"duration"`
}
Per-node Init timing, recorded by the generated New and returned by App.Report(). Only nodes
implementing Initializer appear. Entries are ordered by level, and within a level that ran
concurrently, by completion.