Lifecycle
The framework drives one pipeline, sequentially and in dependency order — there is no concurrency in the core, ever. Your services never need locks to protect their lifecycle methods, and the ordering guarantees below are exact, not statistical.
The pipeline#
init() registrations → fw.Main()
1. validate the registry — every recorded violation reported at once
2. dispatch — the applet id is known; the env prefix is fixed from here
3. lenient first pass: the core's own config from args/env
(--config, --help, disable/enable/override, …); unknown args ignored
4. config file discovery and loading
5. closure resolution — applet + AlwaysOn + transitive dependencies,
with disable/enable/override applied; cold (unused) services ejected
6. strict full parse — the complete schema is now known;
unknown argument = error
7. fill each closure member's config struct (merged values, in place)
8. inject dependency fields
9. Configured() on each Configurable, dependency order
10. assemble the log multihandler, replay the startup buffer,
swap the slog default
11. Start() on each Starter, dependency order, sequential
12. code = applet.Run() (Windows service mode: applet.Execute(…))
13. Stop() on each started Starter, exact reverse of start order
14. os.Exit(code)Two orderings that look odd until they don't:
- Arguments are parsed twice (steps 3 and 6) because the full
argument schema is only known after closure resolution — and closure
resolution itself is configuration-driven (
enablecan pull a service in), so the core's own values must be read first, leniently. --helpand--write-configshort-circuit. Help renders the dispatched applet's full schema with current effective values and exits 0; write-config exits 0 right after step 7's merge. Neither ever reachesConfigured,StartorRun.
What your service experiences#
By the time a lifecycle method runs, the earlier steps are settled:
Configured()— your config struct holds the final merged values, and yourinjectfields are set. Every dependency'sConfigured()has already run. Acquire resources here if you want to be useful during the startup replay (sinks do — see Logging).Start()— every dependency is configured and started. Do the things that need the whole graph live.Run()— applets only. The application lifecycle brackets it: everything is started before, everything stops after. This is why an applet must not implementStarter/Stopperitself.Stop()— called in the exact reverse order of the successfulStartcalls: your dependencies are still running while you stop. A service whoseStartnever ran (or failed) is never asked to stop.
Cycles#
Dependency cycles are legal but logged as warnings. Injection is
unaffected — every instance exists before anything runs — but the
started-before-you promise cannot hold inside a cycle. Ordering treats
each cycle as a single unit (the strongly-connected-components
condensation of the dependency graph, for the graph-theory inclined).
The guarantee holds between those units. Within a cycle,
registration order applies, and a member may receive Start with a
dependency that is injected but not yet started. If you close a cycle,
tolerate that.
Failure semantics#
- Any error before
Run— config parsing, resolution, aConfiguredorStartreturning an error — aborts startup: services already started get their reverse-orderStopfirst, the buffered startup log flushes to stderr, and the process exits with the framework's exit code 2. Stoperrors are logged, never change the exit code, and never prevent the remainingStopcalls. Shutdown always runs to the end.- Applet panics are not recovered. The applet owns its own recovery
and returns its error code from
Run()— the framework refuses to turn a crash into a half-alive process. - Exit codes:
Run()'s return value is the process exit code; framework-detected failures exit 2; a dispatch failure prints usage to stderr and exits non-zero. - In Windows service mode, failures map to the appropriate SCM status instead.
The reserved ConfigurationUpdated() hook (config reload) is designed
but deliberately unimplemented in v1 — and constrained in advance: a
reload will only ever re-fill config values of closure members. The
graph is immutable once resolved; nothing is added, removed or rewired
at runtime.
Related: Services & injection for how the closure is declared, Core config & arguments for the operator-facing knobs, and the design spec for the full rules.