diff --git a/src/schedule/schedule_manager.cpp b/src/schedule/schedule_manager.cpp index a16bc94..395b540 100644 --- a/src/schedule/schedule_manager.cpp +++ b/src/schedule/schedule_manager.cpp @@ -87,16 +87,20 @@ void ScheduleManager::refreshOnce() { void ScheduleManager::start(db::DatabaseManager& db, const std::string& timezone, std::function onChanged) { - std::lock_guard lock(mutex_); - if (started_) return; - started_ = true; - db_ = &db; - timezone_ = timezone; - onChanged_ = std::move(onChanged); - licenseKey_ = db.getKV(db::KV_LICENSE_KEY); - enabled_ = true; // config supplies the toggle via setEnabled() + { + std::lock_guard lock(mutex_); + if (started_) return; + started_ = true; + db_ = &db; + timezone_ = timezone; + onChanged_ = std::move(onChanged); + licenseKey_ = db.getKV(db::KV_LICENSE_KEY); + enabled_ = true; // config supplies the toggle via setEnabled() + } - // Offline first: load any cached payload immediately. + // Offline first: load any cached payload immediately. Called outside the + // lock because loadCache() acquires mutex_ itself; holding it here would + // self-deadlock on the non-recursive mutex when a cache file exists. loadCache(); stop_.store(false); diff --git a/tests/test_schedule_engine.cpp b/tests/test_schedule_engine.cpp index a7c11d7..9c154d6 100644 --- a/tests/test_schedule_engine.cpp +++ b/tests/test_schedule_engine.cpp @@ -297,6 +297,34 @@ TEST(ScheduleManagerTest, OfflineFallsBackToCache) { radiantwave::sync::schedule_cache::clear(); } +// Regression: start() must not deadlock when a cache file is present. It used +// to hold mutex_ and then call loadCache(), which re-locked the same +// non-recursive mutex (self-deadlock), blocking SDL_AppInit before the first +// frame was ever presented — the kiosk sat on a black screen. +TEST(ScheduleManagerTest, StartWithCachedPayloadDoesNotDeadlock) { + const std::string dir = tempCacheDir(); + setenv("HOME", dir.c_str(), 1); + + SchedulePayload p = dailyPayload(); + ASSERT_TRUE(radiantwave::sync::schedule_cache::write(p, "\"cached\"")); + + const std::string dbPath = + (std::filesystem::temp_directory_path() / "rw_schedule_start_test.db") + .string(); + std::filesystem::remove(dbPath); + radiantwave::db::DatabaseManager db; + ASSERT_TRUE(db.initialize(dbPath)); + + radiantwave::schedule::ScheduleManager manager; + manager.start(db, "UTC"); + EXPECT_TRUE(manager.hasSchedule()); + manager.shutdown(); + + db.close(); + std::filesystem::remove(dbPath); + radiantwave::sync::schedule_cache::clear(); +} + // ─── Phase 5: consume real Portal-generated payloads ─────────────────── // // These fixtures were captured from a running Portal (GET