diff --git a/internal/mock/integration.go b/internal/mock/integration.go index 22bb9c3d5..1b88079d5 100644 --- a/internal/mock/integration.go +++ b/internal/mock/integration.go @@ -14,6 +14,7 @@ import ( var ( dataMu sync.RWMutex + setEnabledMu sync.Mutex mockData models.StateSnapshot mockAlerts []models.Alert mockConfig = DefaultConfig @@ -44,6 +45,9 @@ func SetEnabled(enable bool) { } func setEnabled(enable bool, fromInit bool) { + setEnabledMu.Lock() + defer setEnabledMu.Unlock() + current := enabled.Load() if current == enable { // Still update env so other processes see the latest value when not invoked from init. @@ -111,7 +115,12 @@ func disableMockMode() { return } enabled.Store(false) - stopUpdateLoopLocked() + stopUpdateLoopSignalLocked() + dataMu.Unlock() + + waitForUpdateLoopStop() + + dataMu.Lock() mockData = models.StateSnapshot{} mockAlerts = nil dataMu.Unlock() @@ -142,6 +151,11 @@ func startUpdateLoopLocked() { } func stopUpdateLoopLocked() { + stopUpdateLoopSignalLocked() + waitForUpdateLoopStop() +} + +func stopUpdateLoopSignalLocked() { if ch := stopUpdatesCh; ch != nil { close(ch) stopUpdatesCh = nil @@ -150,7 +164,9 @@ func stopUpdateLoopLocked() { ticker.Stop() updateTicker = nil } - // Wait for the update goroutine to exit +} + +func waitForUpdateLoopStop() { updateLoopWg.Wait() } diff --git a/internal/mock/integration_shutdown_test.go b/internal/mock/integration_shutdown_test.go new file mode 100644 index 000000000..f80499677 --- /dev/null +++ b/internal/mock/integration_shutdown_test.go @@ -0,0 +1,38 @@ +package mock + +import ( + "testing" + "time" +) + +func TestSetEnabledDisableDoesNotDeadlockWhenLoopNeedsStateLock(t *testing.T) { + SetEnabled(false) + t.Cleanup(func() { + SetEnabled(false) + }) + + dataMu.Lock() + enabled.Store(true) + stopUpdatesCh = make(chan struct{}) + updateTicker = time.NewTicker(time.Hour) + updateLoopWg.Add(1) + go func(stop <-chan struct{}) { + defer updateLoopWg.Done() + <-stop + dataMu.Lock() + dataMu.Unlock() + }(stopUpdatesCh) + dataMu.Unlock() + + done := make(chan struct{}) + go func() { + SetEnabled(false) + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("SetEnabled(false) timed out waiting for update loop shutdown") + } +} diff --git a/pkg/server/server.go b/pkg/server/server.go index cc86f60a2..1c9457f19 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -23,7 +23,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/license" "github.com/rcourtman/pulse-go-rewrite/internal/license/conversion" "github.com/rcourtman/pulse-go-rewrite/internal/logging" - _ "github.com/rcourtman/pulse-go-rewrite/internal/mock" // Import for init() to run + "github.com/rcourtman/pulse-go-rewrite/internal/mock" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" "github.com/rcourtman/pulse-go-rewrite/pkg/audit" @@ -443,6 +443,11 @@ shutdown: // Stop AI chat service (kills sidecar process group) router.StopAIChat(shutdownCtx) + // Ensure mock-mode background update ticker is stopped before process exit. + if mock.IsMockEnabled() { + mock.SetEnabled(false) + } + cancel() reloadableMonitor.Stop()