diff --git a/internal/config/config.go b/internal/config/config.go index 45f64bd..699d70d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -140,7 +140,8 @@ type IngestAES67DiscoveryConfig struct { func Default() Config { return Config{ - Audio: AudioConfig{Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0.4}, + // BUG-C fix: tones off by default (was 0.4 — caused unintended audio output). + Audio: AudioConfig{Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0}, RDS: RDSConfig{Enabled: true, PI: "1234", PS: "FMRTX", RadioText: "fm-rds-tx", PTY: 0}, FM: FMConfig{ FrequencyMHz: 100.0, @@ -256,8 +257,9 @@ func (c Config) Validate() error { if c.Audio.Gain < 0 || c.Audio.Gain > 4 { return fmt.Errorf("audio.gain out of range") } - if c.Audio.ToneLeftHz <= 0 || c.Audio.ToneRightHz <= 0 { - return fmt.Errorf("audio tone frequencies must be positive") + // BUG-B fix: only enforce positive freq when amplitude is non-zero. + if c.Audio.ToneAmplitude > 0 && (c.Audio.ToneLeftHz <= 0 || c.Audio.ToneRightHz <= 0) { + return fmt.Errorf("audio tone frequencies must be positive when toneAmplitude > 0") } if c.Audio.ToneAmplitude < 0 || c.Audio.ToneAmplitude > 1 { return fmt.Errorf("audio.toneAmplitude out of range") @@ -411,11 +413,15 @@ func (c Config) Validate() error { if c.Ingest.Icecast.RadioText.MaxLen < 0 || c.Ingest.Icecast.RadioText.MaxLen > 64 { return fmt.Errorf("ingest.icecast.radioText.maxLen out of range (0-64)") } - // Fail-loud PI validation - if c.RDS.Enabled { + // BUG-D fix: validate PI whenever non-empty, not only when RDS is enabled. + // An invalid PI stored in config causes a silent failure when RDS is later + // enabled via live-patch. + if strings.TrimSpace(c.RDS.PI) != "" { if _, err := ParsePI(c.RDS.PI); err != nil { return fmt.Errorf("rds config: %w", err) } + } else if c.RDS.Enabled { + return fmt.Errorf("rds.pi is required when rds.enabled is true") } if c.RDS.PTY < 0 || c.RDS.PTY > 31 { return fmt.Errorf("rds.pty out of range (0-31)") diff --git a/internal/control/control.go b/internal/control/control.go index ef67f30..4f021c8 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -61,6 +61,9 @@ type Server struct { ingestRt IngestRuntime // optional, for /runtime ingest stats saveConfig func(config.Config) error hardReload func() + // BUG-F fix: reloadPending prevents multiple concurrent goroutines from + // calling hardReload when handleIngestSave is hit multiple times quickly. + reloadPending atomic.Bool audit auditCounters } @@ -693,9 +696,10 @@ func (s *Server) handleIngestSave(w http.ResponseWriter, r *http.Request) { "saved": true, "reloadScheduled": reloadScheduled, }) - if reloadScheduled { + if reloadScheduled && s.reloadPending.CompareAndSwap(false, true) { go func(fn func()) { time.Sleep(250 * time.Millisecond) + s.reloadPending.Store(false) fn() }(reload) } diff --git a/internal/dsp/bs412.go b/internal/dsp/bs412.go index 98bb2ea..7a6303c 100644 --- a/internal/dsp/bs412.go +++ b/internal/dsp/bs412.go @@ -115,6 +115,13 @@ func (l *BS412Limiter) ProcessChunk(audioPower float64) float64 { old := l.powerBuf[l.bufIdx] l.powerBuf[l.bufIdx] = audioPower l.powerSum += audioPower - old + // BUG-G fix: float64 accumulation over 1200+ chunks can drift slightly + // negative due to rounding. A negative powerSum → negative avgPower → + // math.Sqrt of negative → NaN → gain becomes NaN, silently disabling + // the limiter. Clamp to zero to keep the invariant powerSum >= 0. + if l.powerSum < 0 { + l.powerSum = 0 + } l.bufIdx++ if l.bufIdx >= len(l.powerBuf) { l.bufIdx = 0 diff --git a/internal/ingest/adapters/icecast/reconnect.go b/internal/ingest/adapters/icecast/reconnect.go index 44fe2c2..c9e9fac 100644 --- a/internal/ingest/adapters/icecast/reconnect.go +++ b/internal/ingest/adapters/icecast/reconnect.go @@ -20,11 +20,15 @@ func (c ReconnectConfig) nextBackoff(attempt int) time.Duration { if max <= 0 { max = 15000 } + maxD := time.Duration(max) * time.Millisecond d := time.Duration(initial) * time.Millisecond + // BUG-E fix: check d <= 0 (overflow) as well as d >= max. + // int64 overflow after ~63 doublings caused d to go negative, + // producing spurious short backoffs before recovering. for i := 1; i < attempt; i++ { d *= 2 - if d >= time.Duration(max)*time.Millisecond { - return time.Duration(max) * time.Millisecond + if d <= 0 || d >= maxD { + return maxD } } return d diff --git a/internal/output/frame_queue.go b/internal/output/frame_queue.go index 0443eec..223714c 100644 --- a/internal/output/frame_queue.go +++ b/internal/output/frame_queue.go @@ -45,6 +45,9 @@ const ( type FrameQueue struct { capacity int ch chan *CompositeFrame + // closeCh is closed by Close() and used in Push/Pop selects so that + // a concurrent Close() can never race with a channel send. + closeCh chan struct{} mu sync.Mutex depth int @@ -68,6 +71,7 @@ func NewFrameQueue(capacity int) *FrameQueue { fq := &FrameQueue{ capacity: capacity, ch: make(chan *CompositeFrame, capacity), + closeCh: make(chan struct{}), lowWaterMark: capacity, } fq.trackDepth(0) @@ -121,17 +125,18 @@ func (q *FrameQueue) Push(ctx context.Context, frame *CompositeFrame) error { if frame == nil { return errors.New("frame required") } - if q.isClosed() { - return ErrFrameQueueClosed - } - // BUG-05 fix: increment depth BEFORE the channel send so that Stats() - // never reports fill=0 while a frame is in the channel awaiting receive. - // On context cancellation, undo the increment. + // BUG-A fix: use closeCh in the select so that a concurrent Close() can + // never race with the send. The old isClosed() pre-check + separate + // ch<- send had a TOCTOU gap that could panic with "send on closed channel". + // BUG-05 fix: increment depth before the send; undo on cancel/close. q.updateDepth(+1) select { case q.ch <- frame: return nil + case <-q.closeCh: + q.updateDepth(-1) + return ErrFrameQueueClosed case <-ctx.Done(): q.updateDepth(-1) q.recordPushTimeout() @@ -160,6 +165,9 @@ func (q *FrameQueue) Close() { q.mu.Lock() q.closed = true q.mu.Unlock() + // Close closeCh first so blocked Push() calls unblock safely + // before close(ch) removes the destination. + close(q.closeCh) close(q.ch) }) }