From 0aeb36054ee20e9c639ae43217823d51c8a484d8 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 12 Apr 2026 17:21:14 +0200 Subject: [PATCH] Fix control config persistence for restart-only changes --- internal/control/control.go | 15 ++++- internal/control/control_test.go | 111 +++++++++++++++++++++++++++++++ internal/control/server_test.go | 11 +-- 3 files changed, 132 insertions(+), 5 deletions(-) diff --git a/internal/control/control.go b/internal/control/control.go index 451e548..d2271d4 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -714,7 +714,20 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { return } } - // Commit the server snapshot only after validation and any required live update succeeded. + // Persist the validated config snapshot when a config saver is available. + // This ensures restart-required UI changes survive process restarts instead + // of only updating the in-memory snapshot. + s.mu.RLock() + save := s.saveConfig + s.mu.RUnlock() + if save != nil { + if err := save(next); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + // Commit the server snapshot only after validation, optional persistence, + // and any required live update succeeded. s.mu.Lock() s.cfg = next s.mu.Unlock() diff --git a/internal/control/control_test.go b/internal/control/control_test.go index f5ac246..b0492e5 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -740,6 +740,117 @@ func TestConfigPatchUpdatesSnapshot(t *testing.T) { } } +func TestConfigPatchPersistsRestartRequiredFieldsWhenSaverConfigured(t *testing.T) { + cfg := cfgpkg.Default() + srv := NewServer(cfg) + + dir := t.TempDir() + configPath := filepath.Join(dir, "saved.json") + if err := cfgpkg.Save(configPath, cfg); err != nil { + t.Fatalf("seed config: %v", err) + } + srv.SetConfigSaver(func(next cfgpkg.Config) error { + return cfgpkg.Save(configPath, next) + }) + + rec := httptest.NewRecorder() + body := []byte(`{ + "preEmphasisTauUS":75, + "audioGain":1.5, + "pi":"BEEF", + "pty":10, + "ms":false, + "ctEnabled":false, + "rtPlusEnabled":false, + "rtPlusSeparator":"/", + "ptyn":"ALTROCK", + "lps":"My Radio Station", + "ertEnabled":true, + "ert":"Grüezi mitenand", + "rds2Enabled":true, + "stationLogoPath":"C:\\logo.png", + "af":[93.3,95.7], + "bs412Enabled":true, + "bs412ThresholdDBr":0.5, + "mpxGain":1.3, + "compositeClipperIterations":4, + "compositeClipperSoftKnee":0.22, + "compositeClipperLookaheadMs":1.4 + }`) + srv.Handler().ServeHTTP(rec, newConfigPostRequest(body)) + if rec.Code != http.StatusOK { + t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String()) + } + + saved, err := cfgpkg.Load(configPath) + if err != nil { + t.Fatalf("load saved config: %v", err) + } + if saved.FM.PreEmphasisTauUS != 75 { + t.Fatalf("expected saved preEmphasisTauUS=75, got %v", saved.FM.PreEmphasisTauUS) + } + if saved.Audio.Gain != 1.5 { + t.Fatalf("expected saved audio.gain=1.5, got %v", saved.Audio.Gain) + } + if saved.RDS.PI != "BEEF" { + t.Fatalf("expected saved rds.pi=BEEF, got %q", saved.RDS.PI) + } + if saved.RDS.PTY != 10 { + t.Fatalf("expected saved rds.pty=10, got %v", saved.RDS.PTY) + } + if saved.RDS.MS != false { + t.Fatalf("expected saved rds.ms=false, got %v", saved.RDS.MS) + } + if saved.RDS.CTEnabled != false { + t.Fatalf("expected saved rds.ctEnabled=false, got %v", saved.RDS.CTEnabled) + } + if saved.RDS.RTPlusEnabled != false { + t.Fatalf("expected saved rds.rtPlusEnabled=false, got %v", saved.RDS.RTPlusEnabled) + } + if saved.RDS.RTPlusSeparator != "/" { + t.Fatalf("expected saved rds.rtPlusSeparator='/', got %q", saved.RDS.RTPlusSeparator) + } + if saved.RDS.PTYN != "ALTROCK" { + t.Fatalf("expected saved rds.ptyn=ALTROCK, got %q", saved.RDS.PTYN) + } + if saved.RDS.LPS != "My Radio Station" { + t.Fatalf("expected saved rds.lps, got %q", saved.RDS.LPS) + } + if saved.RDS.ERTEnabled != true { + t.Fatalf("expected saved rds.ertEnabled=true, got %v", saved.RDS.ERTEnabled) + } + if saved.RDS.ERT != "Grüezi mitenand" { + t.Fatalf("expected saved rds.ert, got %q", saved.RDS.ERT) + } + if saved.RDS.RDS2Enabled != true { + t.Fatalf("expected saved rds.rds2Enabled=true, got %v", saved.RDS.RDS2Enabled) + } + if saved.RDS.StationLogoPath != "C:\\logo.png" { + t.Fatalf("expected saved rds.stationLogoPath, got %q", saved.RDS.StationLogoPath) + } + if len(saved.RDS.AF) != 2 || saved.RDS.AF[0] != 93.3 || saved.RDS.AF[1] != 95.7 { + t.Fatalf("expected saved rds.af=[93.3 95.7], got %v", saved.RDS.AF) + } + if !saved.FM.BS412Enabled { + t.Fatalf("expected saved bs412Enabled=true, got false") + } + if saved.FM.BS412ThresholdDBr != 0.5 { + t.Fatalf("expected saved bs412ThresholdDBr=0.5, got %v", saved.FM.BS412ThresholdDBr) + } + if saved.FM.MpxGain != 1.3 { + t.Fatalf("expected saved fm.mpxGain=1.3, got %v", saved.FM.MpxGain) + } + if saved.FM.CompositeClipper.Iterations != 4 { + t.Fatalf("expected saved compositeClipper.iterations=4, got %v", saved.FM.CompositeClipper.Iterations) + } + if saved.FM.CompositeClipper.SoftKnee != 0.22 { + t.Fatalf("expected saved compositeClipper.softKnee=0.22, got %v", saved.FM.CompositeClipper.SoftKnee) + } + if saved.FM.CompositeClipper.LookaheadMs != 1.4 { + t.Fatalf("expected saved compositeClipper.lookaheadMs=1.4, got %v", saved.FM.CompositeClipper.LookaheadMs) + } +} + func TestConfigPatchEngineRejectsDoesNotUpdateSnapshot(t *testing.T) { srv := NewServer(cfgpkg.Default()) srv.SetTXController(&fakeTXController{updateErr: errors.New("boom")}) diff --git a/internal/control/server_test.go b/internal/control/server_test.go index 9f8cb95..b7bb5f8 100644 --- a/internal/control/server_test.go +++ b/internal/control/server_test.go @@ -18,11 +18,14 @@ func TestNewHTTPServerConfig(t *testing.T) { if srv.Handler != handler { t.Fatalf("expected handler to be preserved") } - if srv.ReadTimeout != defaultReadTimeout { - t.Fatalf("expected read timeout %s, got %s", defaultReadTimeout, srv.ReadTimeout) + if srv.ReadTimeout != 0 { + t.Fatalf("expected read timeout to remain disabled for streaming requests, got %s", srv.ReadTimeout) } - if srv.WriteTimeout != defaultWriteTimeout { - t.Fatalf("expected write timeout %s, got %s", defaultWriteTimeout, srv.WriteTimeout) + if srv.WriteTimeout != 0 { + t.Fatalf("expected write timeout to remain disabled for streaming requests, got %s", srv.WriteTimeout) + } + if srv.ReadHeaderTimeout != defaultReadHeaderTimeout { + t.Fatalf("expected read header timeout %s, got %s", defaultReadHeaderTimeout, srv.ReadHeaderTimeout) } if srv.IdleTimeout != defaultIdleTimeout { t.Fatalf("expected idle timeout %s, got %s", defaultIdleTimeout, srv.IdleTimeout)