diff --git a/docs/API.md b/docs/API.md index 57301bb..c8807d4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -15,6 +15,8 @@ Health check. {"ok": true} ``` +`engine.state` spiegelt jetzt die Runtime-State-Maschine wider (idle, arming, prebuffering, running, degraded, muted, faulted, stopping) und bietet eine erste beobachtbare Basis für Fault-Transitions. + --- ### `GET /status` @@ -73,6 +75,8 @@ Live engine and driver telemetry. Only populated when TX is active. } ``` +`engine.state` spiegelt jetzt die Runtime-State-Maschine wider (idle, arming, prebuffering, running, degraded, muted, faulted, stopping) und bietet eine erste beobachtbare Basis für Fault-Transitions. + --- ### `GET /config` diff --git a/docs/pro-runtime-hardening-workboard.md b/docs/pro-runtime-hardening-workboard.md index b825bb4..e993968 100644 --- a/docs/pro-runtime-hardening-workboard.md +++ b/docs/pro-runtime-hardening-workboard.md @@ -266,11 +266,14 @@ Generator/Upsampler und Hardwarewriter werden als getrennte Stufen mit kleinem, # WS-02 — Explizite Runtime-State-Maschine und Fault-Handling **Priorität:** P0 -**Gesamtstatus:** TODO +**Gesamtstatus:** IN PROGRESS ## Ziel Einführen eines klaren Betriebsmodells mit Fault-, Recovery- und Muted-Zuständen. +## Fortschritt +- EngineStats liefert das Runtime-State-Feld (`idle`, `arming`, `prebuffering`, `running`) und schafft eine beobachtbare Baseline für die nächste Fault-Maschine. + ## Zielzustände laut Konzept - `idle` - `arming` diff --git a/internal/app/engine.go b/internal/app/engine.go index a4836e9..2736766 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -38,6 +38,19 @@ func (s EngineState) String() string { } } +type RuntimeState string + +const ( + RuntimeStateIdle RuntimeState = "idle" + RuntimeStateArming RuntimeState = "arming" + RuntimeStatePrebuffering RuntimeState = "prebuffering" + RuntimeStateRunning RuntimeState = "running" + RuntimeStateDegraded RuntimeState = "degraded" + RuntimeStateMuted RuntimeState = "muted" + RuntimeStateFaulted RuntimeState = "faulted" + RuntimeStateStopping RuntimeState = "stopping" +) + func updateMaxDuration(dst *atomic.Uint64, d time.Duration) { v := uint64(d) for { @@ -96,11 +109,12 @@ type Engine struct { deviceRate float64 frameQueue *output.FrameQueue - mu sync.Mutex - state EngineState - cancel context.CancelFunc - startedAt time.Time - wg sync.WaitGroup + mu sync.Mutex + state EngineState + cancel context.CancelFunc + startedAt time.Time + wg sync.WaitGroup + runtimeState atomic.Value chunksProduced atomic.Uint64 totalSamples atomic.Uint64 @@ -177,7 +191,7 @@ func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine { log.Printf("engine: same-rate mode — DSP@%dHz", cfg.FM.CompositeRateHz) } - return &Engine{ + engine := &Engine{ cfg: cfg, driver: driver, generator: offpkg.NewGenerator(cfg), @@ -187,6 +201,8 @@ func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine { state: EngineIdle, frameQueue: output.NewFrameQueue(cfg.Runtime.FrameQueueCapacity), } + engine.setRuntimeState(RuntimeStateIdle) + return engine } func (e *Engine) SetChunkDuration(d time.Duration) { @@ -306,6 +322,7 @@ func (e *Engine) Start(ctx context.Context) error { runCtx, cancel := context.WithCancel(ctx) e.cancel = cancel e.state = EngineRunning + e.setRuntimeState(RuntimeStateArming) e.startedAt = time.Now() e.wg.Add(1) e.mu.Unlock() @@ -321,6 +338,7 @@ func (e *Engine) Stop(ctx context.Context) error { return nil } e.state = EngineStopping + e.setRuntimeState(RuntimeStateStopping) e.cancel() e.mu.Unlock() @@ -336,6 +354,7 @@ func (e *Engine) Stop(ctx context.Context) error { e.mu.Lock() e.state = EngineIdle + e.setRuntimeState(RuntimeStateIdle) e.mu.Unlock() return nil } @@ -359,7 +378,7 @@ func (e *Engine) Stats() EngineStats { hasRecentLateBuffers := lateAlertAt > 0 && now.Sub(time.Unix(0, int64(lateAlertAt))) <= lateBufferIndicatorWindow ri := runtimeIndicator(queue.Health, hasRecentLateBuffers) return EngineStats{ - State: state.String(), + State: string(e.currentRuntimeState()), ChunksProduced: e.chunksProduced.Load(), TotalSamples: e.totalSamples.Load(), Underruns: e.underruns.Load(), @@ -401,6 +420,7 @@ func runtimeAlert(queueHealth output.QueueHealth, recentLateBuffers bool) string } func (e *Engine) run(ctx context.Context) { + e.setRuntimeState(RuntimeStateRunning) e.wg.Add(1) go e.writerLoop(ctx) defer e.wg.Done() @@ -530,3 +550,16 @@ func cloneFrame(src *output.CompositeFrame) *output.CompositeFrame { Sequence: src.Sequence, } } + +func (e *Engine) setRuntimeState(state RuntimeState) { + e.runtimeState.Store(state) +} + +func (e *Engine) currentRuntimeState() RuntimeState { + if v := e.runtimeState.Load(); v != nil { + if rs, ok := v.(RuntimeState); ok { + return rs + } + } + return RuntimeStateIdle +} diff --git a/internal/app/runtime_state_test.go b/internal/app/runtime_state_test.go new file mode 100644 index 0000000..9ef04be --- /dev/null +++ b/internal/app/runtime_state_test.go @@ -0,0 +1,26 @@ +package app + +import ( + "testing" + + cfgpkg "github.com/jan/fm-rds-tx/internal/config" + "github.com/jan/fm-rds-tx/internal/platform" +) + +func TestEngineRuntimeStateReporting(t *testing.T) { + e := NewEngine(cfgpkg.Default(), platform.NewSimulatedDriver(nil)) + + if got := e.Stats().State; got != string(RuntimeStateIdle) { + t.Fatalf("expected initial state idle, got %s", got) + } + + e.setRuntimeState(RuntimeStatePrebuffering) + if got := e.Stats().State; got != string(RuntimeStatePrebuffering) { + t.Fatalf("expected prebuffering, got %s", got) + } + + e.setRuntimeState(RuntimeStateRunning) + if got := e.currentRuntimeState(); got != RuntimeStateRunning { + t.Fatalf("currentRuntimeState mismatch: %s", got) + } +}