Documentation
¶
Overview ¶
Package lifecycle owns mode start/stop orchestration and refcounted shared dependencies for SpeechKit hosts.
SpeechKit exposes three strict modes (Dictation, Assist, Voice Agent). A host that toggles a mode off at runtime should release everything that mode kept alive — Genkit runtime, TTS router, STT router, audio capture, the Voice Agent session manager, etc. Without a coordination layer, hosts either (a) leak goroutines and provider state forever, or (b) bake mode gating into ad-hoc if/else bootstrap code that is impossible to keep symmetric between Device-Target and Server-Target.
This package provides that coordination layer as a framework-neutral, dependency-free public API. It knows nothing about audio, STT, TTS, or LLM concretely — those live in their own internal packages and are surfaced here through SharedDepKey identifiers + factory callbacks registered by the host.
Two-layer model ¶
Registry orchestrates mode runtimes (which modes are on / off). Hosts call Registry.Apply with a target set; the registry diffs current state, stops modes that should be off, starts modes that should be on, and emits TransitionEvent values to subscribers.
SharedDepRegistry holds refcounted shared dependencies. A mode runtime declares its dependency keys via [ModeRuntime.Requires]; the registry acquires them on Start and releases them on Stop. A dep is lazily constructed on the first Acquire and torn down when its last holder releases.
Mode runtime contract ¶
A ModeRuntime implementation is responsible for translating an Deps map (delivered by the registry on Start) into whatever live state the mode needs (handlers, hot loops, background workers). Implementations MUST be idempotent on Start and Stop and MUST tear down all goroutines they spawned before returning from Stop — see [TestRegistryLeak] for the enforcement contract.
Audit events ¶
Hosts wire audit logging on top of Registry.Subscribe — the lifecycle package does not depend on the audit package. The canonical event names are documented in docs/compliance/audit-event-catalog.md under "Mode lifecycle":
- speechkit.mode.start (Outcome=success | failure)
- speechkit.mode.stop (Outcome=success | failure)
- speechkit.mode.failed (transition aborted by a dependency)
Stability ¶
pkg/speechkit/lifecycle is part of the OSS public surface and follows the same semver discipline as the rest of pkg/speechkit. Before v1.0 the API may still evolve — see CHANGELOG.md for breaking-change calls.
Index ¶
- Variables
- type Deps
- type Diff
- type ModeKey
- type ModeRuntime
- type Registry
- func (r *Registry) Apply(ctx context.Context, target Target) error
- func (r *Registry) Diff(target Target) Diff
- func (r *Registry) Register(rt ModeRuntime) error
- func (r *Registry) SetClock(clock func() time.Time)
- func (r *Registry) Shutdown(ctx context.Context) error
- func (r *Registry) Snapshot() SnapshotView
- func (r *Registry) Subscribe(buffer int) (<-chan TransitionEvent, func())
- type ReleaseFunc
- type SharedDepFactory
- type SharedDepKey
- type SharedDepRegistry
- type SharedDepStatus
- type SnapshotView
- type Status
- type Target
- type TransitionEvent
Constants ¶
This section is empty.
Variables ¶
var ErrModeAlreadyRegistered = errors.New("lifecycle: mode runtime already registered")
ErrModeAlreadyRegistered is returned by Registry.Register when a runtime with the same Name() is registered twice. Re-registering is not supported — hosts should construct the registry once at startup.
var ErrModeUnknown = errors.New("lifecycle: mode not registered")
ErrModeUnknown is returned by single-mode operations (Start, Stop) when the given key has no registered runtime.
var ErrNoFactory = errors.New("lifecycle: no factory registered for shared dep")
ErrNoFactory is returned by SharedDepRegistry.Acquire when no factory has been registered for the requested key. Hosts that wire the registry should register every key consumed by any mode they also register; this error indicates a wiring bug, not a runtime failure.
Functions ¶
This section is empty.
Types ¶
type Deps ¶
type Deps struct {
Values map[SharedDepKey]any
}
Deps is the dependency bundle delivered to [ModeRuntime.Start] by the registry. The Values map contains exactly the keys this runtime declared via [ModeRuntime.Requires], with values produced by the factories registered in SharedDepRegistry.
Helpers below provide typed access; ModeRuntime implementations should never reach into Values directly without a type assertion.
func (Deps) Get ¶
func (d Deps) Get(key SharedDepKey) (any, bool)
Get returns the value associated with key and whether the key was present. Callers that depend on a key MUST have declared it via Requires() — a missing key is a programmer error in the host wiring.
func (Deps) MustGet ¶
func (d Deps) MustGet(key SharedDepKey) any
MustGet returns the value or panics. Use sparingly — only in test fakes and in ModeRuntime.Start where the missing-key path is a non-recoverable wiring bug.
type Diff ¶
Diff describes the work Registry.Apply needs to do to reach a target state. ToStop is processed before ToStart so refcounted shared deps released by stopping a mode become available to a mode being started in the same transition.
type ModeKey ¶
type ModeKey string
ModeKey identifies one of the three SpeechKit strict modes.
Hosts may extend the value space (e.g. for experimental modes) but the three canonical keys below are guaranteed to be recognised by the Device-Target and Server-Target adapter layers.
type ModeRuntime ¶
type ModeRuntime interface {
// Name returns the mode identity. The registry uses Name() to key
// runtimes; duplicate registrations are rejected at Register time.
Name() ModeKey
// Start brings the runtime to Running. The deps map carries values
// for every key returned by Requires(). Returns error if any
// startup step fails; the registry treats this as a Failed
// transition and releases any shared deps it acquired on the
// runtime's behalf.
Start(ctx context.Context, deps Deps) error
// Stop tears the runtime down to Stopped. Idempotent. MUST drain
// goroutines, close channels, and free any state derived from
// borrowed deps before returning.
Stop(ctx context.Context) error
// Status returns the current lifecycle state. The registry treats
// this as authoritative for diff computations and observability —
// implementations must keep it consistent with actual goroutine /
// resource ownership.
Status() Status
// Requires returns the SharedDepKeys this runtime needs as Deps
// inputs. The slice is consumed at every Start; implementations
// should return a stable copy or a pre-built constant.
Requires() []SharedDepKey
}
ModeRuntime is the contract every SpeechKit mode implements so the Registry can orchestrate its lifecycle.
Implementations MUST:
- Be idempotent: Start on a Running runtime returns nil; Stop on a Stopped runtime returns nil.
- Terminate every goroutine they spawned before Stop returns.
- Treat the deps map as borrowed: don't retain references past Stop.
- Return the same Requires() list across the lifetime of the instance. The registry caches this set; mid-life changes are undefined behaviour.
The contract is verified by [TestRegistryLeak] in registry_leak_test.go.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry orchestrates mode runtimes and their shared dependencies.
Typical wiring:
sharedDeps := lifecycle.NewSharedDepRegistry()
sharedDeps.Register("audio.capture", audioFactory)
sharedDeps.Register("stt.router", sttFactory)
// ... etc
registry := lifecycle.NewRegistry(sharedDeps)
_ = registry.Register(dictationRuntime)
_ = registry.Register(assistRuntime)
_ = registry.Register(voiceAgentRuntime)
// At startup or when settings change:
if err := registry.Apply(ctx, lifecycle.Target{
lifecycle.ModeDictation: true,
lifecycle.ModeAssist: false,
lifecycle.ModeVoiceAgent: false,
}); err != nil { ... }
Registry is safe for concurrent use; Apply calls serialise on an internal mutex so transitions never interleave.
func NewRegistry ¶
func NewRegistry(sharedDeps *SharedDepRegistry) *Registry
NewRegistry returns an empty registry wired to the given shared-dep registry. Passing nil for sharedDeps creates an empty SharedDepRegistry — useful for tests that don't exercise shared deps.
func (*Registry) Apply ¶
Apply transitions every registered mode to the state described by target. Stops run first, then starts. If a start fails, the transition aborts after the failing mode; modes earlier in the ToStart order remain Running. Use TransitionEvent subscribers to observe per-mode outcomes.
Returns the first error encountered. Callers that want all-or- nothing semantics should compute the rollback themselves from subscriber events; the registry deliberately does not auto-roll-back because partial state may be desirable (e.g. Dictation stays up even if Voice Agent failed to acquire Gemini Live).
func (*Registry) Diff ¶
Diff computes what Apply would do without performing it. Useful for tests and for UIs that want to preview the transition before committing.
func (*Registry) Register ¶
func (r *Registry) Register(rt ModeRuntime) error
Register adds a mode runtime to the registry. Returns ErrModeAlreadyRegistered when the same Name() is used twice.
func (*Registry) SetClock ¶
SetClock overrides the timestamp source for TransitionEvent. Tests use this to inject a deterministic clock; production leaves the default time.Now.
func (*Registry) Shutdown ¶
Shutdown stops every Running mode and releases all shared deps. Hosts should call this on process exit before tearing down the rest of the application. Returns the first stop error, if any — remaining modes are still attempted.
func (*Registry) Snapshot ¶
func (r *Registry) Snapshot() SnapshotView
Snapshot returns a point-in-time view of the registry state.
func (*Registry) Subscribe ¶
func (r *Registry) Subscribe(buffer int) (<-chan TransitionEvent, func())
Subscribe returns a channel that receives every TransitionEvent. Buffer is the channel buffer size; events are dropped (with no error signal) if the consumer is slower than the producer — drain in a goroutine. Use the returned unsubscribe func to stop receiving and allow garbage collection of the channel.
type ReleaseFunc ¶
type ReleaseFunc func() error
ReleaseFunc is returned by SharedDepRegistry.Acquire; calling it decrements the refcount and triggers teardown when the count hits zero. ReleaseFunc is safe to call multiple times — second and subsequent calls are no-ops.
type SharedDepFactory ¶
SharedDepFactory produces a value plus a teardown callback the registry will invoke when the last holder releases the value.
The teardown MAY be nil for stateless values (e.g. a pre-built catalog struct). Non-nil teardown errors are surfaced to the releasing caller; the registry still drops the entry so a fresh Acquire rebuilds from scratch.
type SharedDepKey ¶
type SharedDepKey string
SharedDepKey identifies a refcounted shared dependency in SharedDepRegistry. Values are opaque strings — the lifecycle package does not interpret them. Hosts choose stable names that match their own module organisation (e.g. "audio.capture", "stt.router", "ai.genkit"). Two ModeRuntime implementations that declare the same key MUST be able to consume the same underlying value type.
type SharedDepRegistry ¶
type SharedDepRegistry struct {
// contains filtered or unexported fields
}
SharedDepRegistry refcounts shared dependencies so multiple modes can share expensive state (Genkit runtime, STT router, audio capture) without duplicating it, and so a dep is released deterministically when no mode is using it any more.
Construction sequence:
- NewSharedDepRegistry creates an empty registry.
- Host calls [Register] for every key it expects modes to claim.
- Mode runtimes call [Acquire] from their Start methods.
- Each Acquire returns a ReleaseFunc the runtime stashes and invokes from its Stop method.
The registry is safe for concurrent use.
func NewSharedDepRegistry ¶
func NewSharedDepRegistry() *SharedDepRegistry
NewSharedDepRegistry returns an empty registry. Register factories on the returned value before any mode tries to acquire.
func (*SharedDepRegistry) Acquire ¶
func (r *SharedDepRegistry) Acquire(ctx context.Context, key SharedDepKey) (any, ReleaseFunc, error)
Acquire returns the value associated with key, lazily constructing it via the registered factory if this is the first holder. The caller MUST invoke the returned ReleaseFunc exactly once when it no longer needs the value (typically from ModeRuntime.Stop).
Concurrent Acquire calls for the same key block on the registry mutex; the factory runs exactly once even under contention.
func (*SharedDepRegistry) Register ¶
func (r *SharedDepRegistry) Register(key SharedDepKey, factory SharedDepFactory)
Register associates a factory with a key. Re-registering the same key replaces the factory — useful in tests; production wiring should register each key exactly once.
func (*SharedDepRegistry) Snapshot ¶
func (r *SharedDepRegistry) Snapshot() []SharedDepStatus
Snapshot returns the current set of live shared deps sorted by key. Useful for OTel metrics emission and for debugging "why is this goroutine still running" investigations.
type SharedDepStatus ¶
type SharedDepStatus struct {
}
SharedDepStatus is a read-only view of one entry for observability.
type SnapshotView ¶
Snapshot returns the current Status of every registered mode, plus the active shared-dep refcounts. Used by /readyz handlers and OTel metric collection.
type Status ¶
type Status string
Status is a mode runtime's observable lifecycle state.
const ( // StatusStopped means the mode has never started, or has been cleanly // torn down. No goroutines, no live shared-dep references. StatusStopped Status = "stopped" // StatusStarting means a Start call is in flight but has not yet // returned. Shared deps are being acquired or the runtime is wiring up. StatusStarting Status = "starting" // StatusRunning means the mode is live and serving requests. StatusRunning Status = "running" // StatusStopping means a Stop call is in flight. The runtime is // draining and will release its shared deps once Stop returns. StatusStopping Status = "stopping" // StatusFailed means the last transition aborted with an error. The // runtime is back at rest from the registry's point of view; a fresh // Start may be attempted. StatusFailed Status = "failed" // StatusDisabled means an operator has explicitly turned the mode off // (e.g. config flag), distinct from Stopped (transient) — disabled // modes never auto-start and do not block readiness checks. // See internal/server/core/health.go for the analogous distinction // at the HTTP /readyz surface. StatusDisabled Status = "disabled" )
func (Status) IsActive ¶
IsActive reports whether the runtime is participating in serving traffic. Starting and Running are active; everything else is not.
func (Status) IsTerminal ¶
IsTerminal reports whether the runtime is at rest — neither serving nor in the middle of a transition. Hosts can take fresh action (Start, retry, surface error) only on terminal statuses.
func (Status) Rank ¶
Rank orders statuses for "worst-status-wins" aggregation across multiple modes. Higher rank = more attention required. The ordering mirrors core/health.go statusRank semantics so dashboards display consistent overall state.
Stopped / Disabled → 0 Starting / Stopping → 1 Running → 2 (not "worst" — Running is the goal) Failed → 3
Note that Running ranks above Starting because callers that compare statuses across modes want to detect transitions (Starting) as a distinct "interesting" state without elevating it above a steady Running mode. Failed always wins.
type Target ¶
Target is the desired-state input to Registry.Apply. A mode whose key is true should be running; a mode whose key is false (or absent) should be stopped. Modes not registered with the registry are silently ignored — Target may safely include keys for modes the current host build does not ship.
type TransitionEvent ¶
type TransitionEvent struct {
Mode ModeKey
From Status
To Status
Err error // populated when To == StatusFailed
Timestamp time.Time
}
TransitionEvent is published by Registry.Subscribe consumers. One event is emitted per status change.
Hosts wire the audit-log on top of this stream — see docs/compliance/audit-event-catalog.md "Mode lifecycle" for the canonical event-name mapping.