sxcli.devSimple Extensible CLI
You are viewing an archived snapshot (v0.1.0). Switch to the current version →

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#

code
text
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 (enable can pull a service in), so the core's own values must be read first, leniently.
  • --help and --write-config short-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 reaches Configured, Start or Run.

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 your inject fields are set. Every dependency's Configured() 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 implement Starter/Stopper itself.
  • Stop() — called in the exact reverse order of the successful Start calls: your dependencies are still running while you stop. A service whose Start never 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, a Configured or Start returning an error — aborts startup: services already started get their reverse-order Stop first, the buffered startup log flushes to stderr, and the process exits with the framework's exit code 2.
  • Stop errors are logged, never change the exit code, and never prevent the remaining Stop calls. 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.