From 2e1d6a3b7a71cf8295cc524574f7c20aed88657a Mon Sep 17 00:00:00 2001 From: Leonard Souza Date: Tue, 17 Mar 2026 15:57:21 -0700 Subject: [PATCH 01/12] Use condition_variable for interruptible thread sleep Replace std::this_thread::sleep_for() in updateLoop() with std::condition_variable::wait_for() so that _deleteThread() can wake the background thread immediately via notify_one(). This makes stop() return instantly instead of blocking for up to the full update interval (e.g. 30 seconds in production builds). Co-Authored-By: Claude Opus 4.6 --- include/countly.hpp | 2 ++ src/countly.cpp | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/include/countly.hpp b/include/countly.hpp index 6065b9d..4c05d6e 100644 --- a/include/countly.hpp +++ b/include/countly.hpp @@ -5,6 +5,7 @@ #include "countly/countly_configuration.hpp" #include +#include #include #include #include @@ -351,6 +352,7 @@ class Countly : public cly::CountlyDelegates { bool enable_automatic_session = false; bool stop_thread = false; bool running = false; + std::condition_variable stop_cv; // Wakes updateLoop immediately on stop size_t wait_milliseconds = COUNTLY_KEEPALIVE_INTERVAL; size_t max_events = COUNTLY_MAX_EVENTS_DEFAULT; diff --git a/src/countly.cpp b/src/countly.cpp index bf01fef..35da9cc 100644 --- a/src/countly.cpp +++ b/src/countly.cpp @@ -504,6 +504,7 @@ void Countly::_deleteThread() { mutex->lock(); stop_thread = true; mutex->unlock(); + stop_cv.notify_one(); if (thread && thread->joinable()) { try { thread->join(); @@ -1217,15 +1218,17 @@ void Countly::updateLoop() { running = true; mutex->unlock(); while (true) { - mutex->lock(); - if (stop_thread) { - stop_thread = false; - mutex->unlock(); - break; + { + std::unique_lock lk(*mutex); + stop_cv.wait_for(lk, std::chrono::milliseconds(wait_milliseconds), [this] { + return stop_thread; + }); + if (stop_thread) { + stop_thread = false; + running = false; + return; + } } - size_t last_wait_milliseconds = wait_milliseconds; - mutex->unlock(); - std::this_thread::sleep_for(std::chrono::milliseconds(last_wait_milliseconds)); if (enable_automatic_session == true && configuration->manualSessionControl == false) { updateSession(); } else if (configuration->manualSessionControl == true) { From fc10493266d54358b2876389b1dafb794acce5cb Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 23 Mar 2026 17:14:32 +0300 Subject: [PATCH 02/12] feat: configuration option for it --- include/countly.hpp | 2 ++ include/countly/countly_configuration.hpp | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/include/countly.hpp b/include/countly.hpp index 4c05d6e..2bd4fb8 100644 --- a/include/countly.hpp +++ b/include/countly.hpp @@ -61,6 +61,8 @@ class Countly : public cly::CountlyDelegates { void disableAutoEventsOnUserProperties(); + void enableImmediateRequestOnStop(); + void setHTTPClient(HTTPClientFunction fun); void setMetrics(const std::string &os, const std::string &os_version, const std::string &device, const std::string &resolution, const std::string &carrier, const std::string &app_version); diff --git a/include/countly/countly_configuration.hpp b/include/countly/countly_configuration.hpp index 6540338..09b7c1e 100644 --- a/include/countly/countly_configuration.hpp +++ b/include/countly/countly_configuration.hpp @@ -72,6 +72,13 @@ struct CountlyConfiguration { bool autoEventsOnUserProperties = true; + /** + * Enable immediate stop notification using a condition variable. + * When enabled, the update loop wakes immediately on stop instead of + * waiting for the current sleep interval to expire. + */ + bool immediateRequestOnStop = false; + HTTPClientFunction http_client_function = nullptr; nlohmann::json metrics; From 3d6e5ad9e19a6c7b54656ded0aab9a5dd42a619a Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 23 Mar 2026 17:15:31 +0300 Subject: [PATCH 03/12] refactor: add protections --- src/countly.cpp | 98 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 71 insertions(+), 27 deletions(-) diff --git a/src/countly.cpp b/src/countly.cpp index 35da9cc..0fa1772 100644 --- a/src/countly.cpp +++ b/src/countly.cpp @@ -156,6 +156,16 @@ void Countly::disableAutoEventsOnUserProperties() { mutex->unlock(); } +void Countly::enableImmediateRequestOnStop() { + if (is_sdk_initialized) { + log(LogLevel::WARNING, "[Countly][enableImmediateRequestOnStop] You can not enable immediate request on stop after SDK initialization."); + return; + } + + std::lock_guard lk(*mutex); + configuration->immediateRequestOnStop = true; +} + void Countly::setMetrics(const std::string &os, const std::string &os_version, const std::string &device, const std::string &resolution, const std::string &carrier, const std::string &app_version) { if (is_sdk_initialized) { log(LogLevel::WARNING, "[Countly][setMetrics] You can not set metrics after SDK initialization."); @@ -501,10 +511,13 @@ void Countly::stop() { } void Countly::_deleteThread() { - mutex->lock(); - stop_thread = true; - mutex->unlock(); - stop_cv.notify_one(); + { + std::lock_guard lk(*mutex); + stop_thread = true; + } + if (configuration->immediateRequestOnStop) { + stop_cv.notify_one(); + } if (thread && thread->joinable()) { try { thread->join(); @@ -516,9 +529,13 @@ void Countly::_deleteThread() { } void Countly::setUpdateInterval(size_t milliseconds) { - mutex->lock(); - wait_milliseconds = milliseconds; - mutex->unlock(); + { + std::lock_guard lk(*mutex); + wait_milliseconds = milliseconds; + } + if (configuration->immediateRequestOnStop) { + stop_cv.notify_one(); + } } void Countly::addEvent(const cly::Event &event) { @@ -1214,31 +1231,58 @@ std::chrono::system_clock::duration Countly::getSessionDuration() { return Count void Countly::updateLoop() { log(LogLevel::DEBUG, "[Countly][updateLoop]"); - mutex->lock(); - running = true; - mutex->unlock(); - while (true) { - { - std::unique_lock lk(*mutex); - stop_cv.wait_for(lk, std::chrono::milliseconds(wait_milliseconds), [this] { - return stop_thread; - }); + { + std::lock_guard lk(*mutex); + running = true; + } + if (configuration->immediateRequestOnStop) { + try { + while (true) { + { + std::unique_lock lk(*mutex); + stop_cv.wait_for(lk, std::chrono::milliseconds(wait_milliseconds), [this] { + return stop_thread; + }); + if (stop_thread) { + stop_thread = false; + running = false; + return; + } + } + if (enable_automatic_session == true && configuration->manualSessionControl == false) { + updateSession(); + } else if (configuration->manualSessionControl == true) { + packEvents(); + } + requestModule->processQueue(mutex); + } + } catch (...) { + std::lock_guard lk(*mutex); + running = false; + log(LogLevel::ERROR, "[Countly][updateLoop] unexpected exception, stopping update loop"); + } + } else { + while (true) { + mutex->lock(); if (stop_thread) { stop_thread = false; - running = false; - return; + mutex->unlock(); + break; } + size_t last_wait_milliseconds = wait_milliseconds; + mutex->unlock(); + std::this_thread::sleep_for(std::chrono::milliseconds(last_wait_milliseconds)); + if (enable_automatic_session == true && configuration->manualSessionControl == false) { + updateSession(); + } else if (configuration->manualSessionControl == true) { + packEvents(); + } + requestModule->processQueue(mutex); } - if (enable_automatic_session == true && configuration->manualSessionControl == false) { - updateSession(); - } else if (configuration->manualSessionControl == true) { - packEvents(); - } - requestModule->processQueue(mutex); + mutex->lock(); + running = false; + mutex->unlock(); } - mutex->lock(); - running = false; - mutex->unlock(); } void Countly::enableRemoteConfig() { From b670d8271dfbcbca3f2cf2f4e9c78b593b990aa5 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 23 Mar 2026 17:15:58 +0300 Subject: [PATCH 04/12] feat: add changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c1bf66..9c6683b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## XX.XX.XX +- Added `enableImmediateRequestOnStop` configuration option. When enabled, the update loop uses a condition variable instead of polling, allowing `stop()` and `setUpdateInterval()` to take effect immediately rather than waiting for the current sleep interval to expire. + ## 23.2.4 - Mitigated an issue where cached events were not queued when a user property was recorded. From 579fdd26ac09f5208cd1bbfe8011f9a0c7a2945f Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 23 Mar 2026 17:26:35 +0300 Subject: [PATCH 05/12] feat: add tests --- CMakeLists.txt | 3 +- tests/config.cpp | 5 ++ tests/immediate_stop.cpp | 189 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 tests/immediate_stop.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1d72104..5f1a6d0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -113,7 +113,8 @@ if(COUNTLY_BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/tests/event.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/crash.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/request.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/tests/config.cpp) + ${CMAKE_CURRENT_SOURCE_DIR}/tests/config.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/immediate_stop.cpp) target_compile_options(countly-tests PRIVATE -g) target_compile_definitions(countly-tests PRIVATE COUNTLY_BUILD_TESTS) diff --git a/tests/config.cpp b/tests/config.cpp index 002a590..c17c006 100644 --- a/tests/config.cpp +++ b/tests/config.cpp @@ -52,6 +52,7 @@ TEST_CASE("Validate setting configuration values") { CHECK(config.forcePost == false); CHECK(config.port == 443); CHECK(config.manualSessionControl == false); + CHECK(config.immediateRequestOnStop == false); CHECK(config.sha256_function == nullptr); CHECK(config.http_client_function == nullptr); CHECK(config.metrics.empty()); @@ -78,6 +79,7 @@ TEST_CASE("Validate setting configuration values") { ct.SetPath(TEST_DATABASE_NAME); ct.setMaxRQProcessingBatchSize(10); ct.enableManualSessionControl(); + ct.enableImmediateRequestOnStop(); ct.start("YOUR_APP_KEY", "https://try.count.ly", -1, false); // Get configuration values using Countly getters @@ -97,6 +99,7 @@ TEST_CASE("Validate setting configuration values") { CHECK(config.forcePost == true); CHECK(config.port == 443); CHECK(config.manualSessionControl == true); + CHECK(config.immediateRequestOnStop == true); CHECK(config.sha256_function("custom SHA256") == customSha_1_returnValue); HTTPResponse response = config.http_client_function(true, "", ""); @@ -182,6 +185,7 @@ TEST_CASE("Validate setting configuration values") { ct.setSalt("new-salt"); ct.setMaxRequestQueueSize(100); ct.SetPath("new_database.db"); + ct.enableImmediateRequestOnStop(); // get SDK configuration again and make sure that they haven't changed config = ct.getConfiguration(); @@ -199,6 +203,7 @@ TEST_CASE("Validate setting configuration values") { CHECK(config.breadcrumbsThreshold == 100); CHECK(config.forcePost == true); CHECK(config.port == 443); + CHECK(config.immediateRequestOnStop == false); // was never enabled before init, should stay false CHECK(config.sha256_function("custom SHA256") == customSha_1_returnValue); response = config.http_client_function(true, "", ""); diff --git a/tests/immediate_stop.cpp b/tests/immediate_stop.cpp new file mode 100644 index 0000000..8f9f38b --- /dev/null +++ b/tests/immediate_stop.cpp @@ -0,0 +1,189 @@ +#include "countly.hpp" +#include "doctest.h" +#include "nlohmann/json.hpp" +#include "test_utils.hpp" +#include +#include + +using namespace cly; +using namespace test_utils; + +/** + * Integration tests for the immediateRequestOnStop feature. + * + * These tests verify that the condition-variable-based update loop + * behaves correctly end-to-end: session lifecycle, event delivery, + * manual session control, and immediate shutdown responsiveness. + * A separate test case verifies the fallback (sleep-based) path. + */ + +// Helper: search http_call_queue for a request containing a specific key=value pair +static bool httpQueueContains(const std::string &key, const std::string &value) { + for (const auto &call : http_call_queue) { + auto it = call.data.find(key); + if (it != call.data.end() && it->second == value) { + return true; + } + } + return false; +} + +// Helper: search http_call_queue for a request containing a specific event key +static bool httpQueueContainsEvent(const std::string &event_key) { + for (const auto &call : http_call_queue) { + auto it = call.data.find("events"); + if (it != call.data.end()) { + nlohmann::json events = nlohmann::json::parse(it->second); + for (const auto &e : events) { + if (e["key"].get() == event_key) { + return true; + } + } + } + } + return false; +} + +TEST_CASE("immediateRequestOnStop - session lifecycle through CV loop") { + clearSDK(); + Countly &ct = Countly::getInstance(); + ct.setHTTPClient(fakeSendHTTP); + ct.setDeviceID(COUNTLY_TEST_DEVICE_ID); + ct.SetPath(TEST_DATABASE_NAME); + ct.enableImmediateRequestOnStop(); + ct.setAutomaticSessionUpdateInterval(1); + http_call_queue.clear(); + + ct.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, true); + // Wait for the loop to process the begin request + std::this_thread::sleep_for(std::chrono::seconds(2)); + // Flush any remaining RQ items through our fakeSendHTTP + ct.processRQDebug(); + + // Verify session begin was sent + CHECK(httpQueueContains("begin_session", "1")); + + // Now stop and verify session end + http_call_queue.clear(); + ct.stop(); + ct.processRQDebug(); + + CHECK(httpQueueContains("end_session", "1")); +} + +TEST_CASE("immediateRequestOnStop - event delivery through CV loop") { + clearSDK(); + Countly &ct = Countly::getInstance(); + ct.setHTTPClient(fakeSendHTTP); + ct.setDeviceID(COUNTLY_TEST_DEVICE_ID); + ct.SetPath(TEST_DATABASE_NAME); + ct.enableImmediateRequestOnStop(); + ct.setAutomaticSessionUpdateInterval(1); + http_call_queue.clear(); + + ct.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, true); + + // Add events after the loop is running + cly::Event event1("purchase", 1); + ct.addEvent(event1); + cly::Event event2("login", 1); + ct.addEvent(event2); + + // Wait for the threaded update loop to pick up and process events + // With 1-second interval, 3 seconds gives at least 2 full cycles + std::this_thread::sleep_for(std::chrono::seconds(3)); + ct.stop(); + + CHECK(httpQueueContainsEvent("purchase")); + CHECK(httpQueueContainsEvent("login")); +} + +TEST_CASE("immediateRequestOnStop - stop responsiveness with long interval") { + clearSDK(); + Countly &ct = Countly::getInstance(); + ct.setHTTPClient(fakeSendHTTP); + ct.setDeviceID(COUNTLY_TEST_DEVICE_ID); + ct.SetPath(TEST_DATABASE_NAME); + ct.enableImmediateRequestOnStop(); + // Use a long update interval to prove the CV wakes the thread, not the timeout + ct.setAutomaticSessionUpdateInterval(60); + http_call_queue.clear(); + + ct.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, true); + // Let the thread enter wait_for with the 60-second interval + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + + auto before = std::chrono::steady_clock::now(); + ct.stop(); + auto elapsed_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - before) + .count(); + + // Must complete well under the 60-second interval. + // A generous 5-second threshold avoids CI flakiness while still + // proving the CV woke the thread (60s vs <5s is unambiguous). + CHECK(elapsed_ms < 5000); + + // Verify the session was still properly ended despite the immediate stop + ct.processRQDebug(); + CHECK(httpQueueContains("end_session", "1")); +} + +TEST_CASE("immediateRequestOnStop - manual session control through CV loop") { + clearSDK(); + Countly &ct = Countly::getInstance(); + ct.setHTTPClient(fakeSendHTTP); + ct.setDeviceID(COUNTLY_TEST_DEVICE_ID); + ct.SetPath(TEST_DATABASE_NAME); + ct.enableImmediateRequestOnStop(); + ct.enableManualSessionControl(); + ct.setAutomaticSessionUpdateInterval(1); + http_call_queue.clear(); + + ct.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, true); + + // In manual session mode, the loop calls packEvents() instead of updateSession() + cly::Event event("manual_event", 5); + ct.addEvent(event); + + // Wait for the thread to pack and send events + std::this_thread::sleep_for(std::chrono::seconds(3)); + ct.stop(); + // Flush any remaining items from the RQ + ct.processRQDebug(); + + // Events should be packed and delivered + CHECK(httpQueueContainsEvent("manual_event")); + + // No automatic session begin should have been sent + CHECK_FALSE(httpQueueContains("begin_session", "1")); +} + +TEST_CASE("immediateRequestOnStop - fallback sleep path") { + clearSDK(); + Countly &ct = Countly::getInstance(); + ct.setHTTPClient(fakeSendHTTP); + ct.setDeviceID(COUNTLY_TEST_DEVICE_ID); + ct.SetPath(TEST_DATABASE_NAME); + // Do NOT enable immediateRequestOnStop -- exercises the old sleep_for path + ct.setAutomaticSessionUpdateInterval(1); + http_call_queue.clear(); + + ct.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, true); + + // Add an event while the loop is running + cly::Event event("fallback_event", 3); + ct.addEvent(event); + + // With 1-second interval, 3 seconds gives enough cycles to process + std::this_thread::sleep_for(std::chrono::seconds(3)); + ct.stop(); + ct.processRQDebug(); + + // Verify event delivery works through the fallback path + CHECK(httpQueueContainsEvent("fallback_event")); + + // Verify session lifecycle works through the fallback path + CHECK(httpQueueContains("begin_session", "1")); + CHECK(httpQueueContains("end_session", "1")); +} From f51a4316d44b3a11494a5e5e7ff776c978f86f7e Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 23 Mar 2026 17:51:25 +0300 Subject: [PATCH 06/12] fix: test for pointer arithmetic --- src/storage_module_db.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/storage_module_db.cpp b/src/storage_module_db.cpp index 60c73e9..5020503 100644 --- a/src/storage_module_db.cpp +++ b/src/storage_module_db.cpp @@ -172,7 +172,7 @@ void StorageModuleDB::RQRemoveFront(std::shared_ptr request) { } // Log the request ID being removed - _logger->log(LogLevel::DEBUG, "[Countly][StorageModuleDB] RQRemoveFront RequestID = " + request->getId()); + _logger->log(LogLevel::DEBUG, "[Countly][StorageModuleDB] RQRemoveFront RequestID = " + std::to_string(request->getId())); #ifdef COUNTLY_USE_SQLITE sqlite3 *database; From 4e8e4168e7baebd28c9b6f370c207bf87c6f4338 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 23 Mar 2026 18:22:18 +0300 Subject: [PATCH 07/12] fix: increase wait time in test --- tests/immediate_stop.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/immediate_stop.cpp b/tests/immediate_stop.cpp index 8f9f38b..d2ee058 100644 --- a/tests/immediate_stop.cpp +++ b/tests/immediate_stop.cpp @@ -146,8 +146,9 @@ TEST_CASE("immediateRequestOnStop - manual session control through CV loop") { cly::Event event("manual_event", 5); ct.addEvent(event); - // Wait for the thread to pack and send events - std::this_thread::sleep_for(std::chrono::seconds(3)); + // Wait for the thread to pack events (cycle 1) and send them via HTTP (cycle 2). + // With a 1-second interval, 5 seconds gives enough margin. + std::this_thread::sleep_for(std::chrono::seconds(5)); ct.stop(); // Flush any remaining items from the RQ ct.processRQDebug(); From 8c3c5edbb983cad3dc9241a126fde47bdbbdea51 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 16 Apr 2026 14:47:33 +0300 Subject: [PATCH 08/12] fix: tests --- src/countly.cpp | 56 +++++++++++++++++++++++----------------- tests/immediate_stop.cpp | 42 ++++++------------------------ tests/test_utils.hpp | 31 ++++++++++++++++++++++ 3 files changed, 72 insertions(+), 57 deletions(-) diff --git a/src/countly.cpp b/src/countly.cpp index 0fa1772..0f3d99b 100644 --- a/src/countly.cpp +++ b/src/countly.cpp @@ -522,7 +522,7 @@ void Countly::_deleteThread() { try { thread->join(); } catch (const std::system_error &e) { - log(LogLevel::WARNING, "Could not join thread"); + log(LogLevel::WARNING, std::string("[Countly][_deleteThread] Could not join thread: ") + e.what()); } thread.reset(); } @@ -1235,8 +1235,8 @@ void Countly::updateLoop() { std::lock_guard lk(*mutex); running = true; } - if (configuration->immediateRequestOnStop) { - try { + try { + if (configuration->immediateRequestOnStop) { while (true) { { std::unique_lock lk(*mutex); @@ -1256,32 +1256,42 @@ void Countly::updateLoop() { } requestModule->processQueue(mutex); } - } catch (...) { + } else { + while (true) { + size_t last_wait_milliseconds; + { + std::lock_guard lk(*mutex); + if (stop_thread) { + stop_thread = false; + break; + } + last_wait_milliseconds = wait_milliseconds; + } + std::this_thread::sleep_for(std::chrono::milliseconds(last_wait_milliseconds)); + if (enable_automatic_session == true && configuration->manualSessionControl == false) { + updateSession(); + } else if (configuration->manualSessionControl == true) { + packEvents(); + } + requestModule->processQueue(mutex); + } std::lock_guard lk(*mutex); running = false; - log(LogLevel::ERROR, "[Countly][updateLoop] unexpected exception, stopping update loop"); } - } else { - while (true) { - mutex->lock(); - if (stop_thread) { - stop_thread = false; - mutex->unlock(); - break; - } - size_t last_wait_milliseconds = wait_milliseconds; + } catch (const std::exception &e) { + bool acquired = mutex->try_lock(); + running = false; + log(LogLevel::ERROR, std::string("[Countly][updateLoop] exception in update loop: ") + e.what()); + if (acquired) { mutex->unlock(); - std::this_thread::sleep_for(std::chrono::milliseconds(last_wait_milliseconds)); - if (enable_automatic_session == true && configuration->manualSessionControl == false) { - updateSession(); - } else if (configuration->manualSessionControl == true) { - packEvents(); - } - requestModule->processQueue(mutex); } - mutex->lock(); + } catch (...) { + bool acquired = mutex->try_lock(); running = false; - mutex->unlock(); + log(LogLevel::FATAL, "[Countly][updateLoop] unknown non-std::exception caught, stopping update loop"); + if (acquired) { + mutex->unlock(); + } } } diff --git a/tests/immediate_stop.cpp b/tests/immediate_stop.cpp index d2ee058..45f22c0 100644 --- a/tests/immediate_stop.cpp +++ b/tests/immediate_stop.cpp @@ -17,33 +17,6 @@ using namespace test_utils; * A separate test case verifies the fallback (sleep-based) path. */ -// Helper: search http_call_queue for a request containing a specific key=value pair -static bool httpQueueContains(const std::string &key, const std::string &value) { - for (const auto &call : http_call_queue) { - auto it = call.data.find(key); - if (it != call.data.end() && it->second == value) { - return true; - } - } - return false; -} - -// Helper: search http_call_queue for a request containing a specific event key -static bool httpQueueContainsEvent(const std::string &event_key) { - for (const auto &call : http_call_queue) { - auto it = call.data.find("events"); - if (it != call.data.end()) { - nlohmann::json events = nlohmann::json::parse(it->second); - for (const auto &e : events) { - if (e["key"].get() == event_key) { - return true; - } - } - } - } - return false; -} - TEST_CASE("immediateRequestOnStop - session lifecycle through CV loop") { clearSDK(); Countly &ct = Countly::getInstance(); @@ -51,7 +24,7 @@ TEST_CASE("immediateRequestOnStop - session lifecycle through CV loop") { ct.setDeviceID(COUNTLY_TEST_DEVICE_ID); ct.SetPath(TEST_DATABASE_NAME); ct.enableImmediateRequestOnStop(); - ct.setAutomaticSessionUpdateInterval(1); + ct.setUpdateInterval(1000); http_call_queue.clear(); ct.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, true); @@ -78,7 +51,7 @@ TEST_CASE("immediateRequestOnStop - event delivery through CV loop") { ct.setDeviceID(COUNTLY_TEST_DEVICE_ID); ct.SetPath(TEST_DATABASE_NAME); ct.enableImmediateRequestOnStop(); - ct.setAutomaticSessionUpdateInterval(1); + ct.setUpdateInterval(1000); http_call_queue.clear(); ct.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, true); @@ -93,6 +66,7 @@ TEST_CASE("immediateRequestOnStop - event delivery through CV loop") { // With 1-second interval, 3 seconds gives at least 2 full cycles std::this_thread::sleep_for(std::chrono::seconds(3)); ct.stop(); + ct.processRQDebug(); CHECK(httpQueueContainsEvent("purchase")); CHECK(httpQueueContainsEvent("login")); @@ -105,8 +79,8 @@ TEST_CASE("immediateRequestOnStop - stop responsiveness with long interval") { ct.setDeviceID(COUNTLY_TEST_DEVICE_ID); ct.SetPath(TEST_DATABASE_NAME); ct.enableImmediateRequestOnStop(); - // Use a long update interval to prove the CV wakes the thread, not the timeout - ct.setAutomaticSessionUpdateInterval(60); + // Use a long loop interval to prove the CV wakes the thread, not the timeout + ct.setUpdateInterval(60000); http_call_queue.clear(); ct.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, true); @@ -137,7 +111,7 @@ TEST_CASE("immediateRequestOnStop - manual session control through CV loop") { ct.SetPath(TEST_DATABASE_NAME); ct.enableImmediateRequestOnStop(); ct.enableManualSessionControl(); - ct.setAutomaticSessionUpdateInterval(1); + ct.setUpdateInterval(1000); http_call_queue.clear(); ct.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, true); @@ -146,7 +120,7 @@ TEST_CASE("immediateRequestOnStop - manual session control through CV loop") { cly::Event event("manual_event", 5); ct.addEvent(event); - // Wait for the thread to pack events (cycle 1) and send them via HTTP (cycle 2). + // Wait for the thread to pack events and send them via HTTP. // With a 1-second interval, 5 seconds gives enough margin. std::this_thread::sleep_for(std::chrono::seconds(5)); ct.stop(); @@ -167,7 +141,7 @@ TEST_CASE("immediateRequestOnStop - fallback sleep path") { ct.setDeviceID(COUNTLY_TEST_DEVICE_ID); ct.SetPath(TEST_DATABASE_NAME); // Do NOT enable immediateRequestOnStop -- exercises the old sleep_for path - ct.setAutomaticSessionUpdateInterval(1); + ct.setUpdateInterval(1000); http_call_queue.clear(); ct.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, true); diff --git a/tests/test_utils.hpp b/tests/test_utils.hpp index 4062db9..8fbd068 100644 --- a/tests/test_utils.hpp +++ b/tests/test_utils.hpp @@ -152,6 +152,37 @@ static HTTPResponse fakeSendHTTP(bool use_post, const std::string &url, const st return response; } +// Search http_call_queue for a request containing a specific key=value pair +static bool httpQueueContains(const std::string &key, const std::string &value) { + for (const auto &call : http_call_queue) { + auto it = call.data.find(key); + if (it != call.data.end() && it->second == value) { + return true; + } + } + return false; +} + +// Search http_call_queue for a request containing a specific event key +static bool httpQueueContainsEvent(const std::string &event_key) { + for (const auto &call : http_call_queue) { + auto it = call.data.find("events"); + if (it != call.data.end()) { + try { + nlohmann::json events = nlohmann::json::parse(it->second); + for (const auto &e : events) { + if (e["key"].get() == event_key) { + return true; + } + } + } catch (const nlohmann::json::exception &) { + // Malformed events JSON — skip this entry + } + } + } + return false; +} + static void initCountlyWithFakeNetworking(bool clearInitialNetworkingState, cly::Countly &countly) { // set the HTTP client to the fake one which just stores the HTTP calls in a queue countly.setHTTPClient(fakeSendHTTP); From c3c6a7ed1df92311e9837bf1c102e4003ca5aa7c Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray <57103426+arifBurakDemiray@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:16:08 +0300 Subject: [PATCH 09/12] Update CMakeLists.txt --- CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4792405..ae71747 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -118,7 +118,7 @@ if(COUNTLY_BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/tests/crash.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/request.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/config.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/tests/immediate_stop.cpp) + ${CMAKE_CURRENT_SOURCE_DIR}/tests/immediate_stop.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/sbs.cpp) target_compile_options(countly-tests PRIVATE -g) @@ -160,4 +160,4 @@ endif() CXX_EXTENSIONS NO) endif() -install(TARGETS countly ARCHIVE DESTINATION lib PUBLIC_HEADER DESTINATION include/countly) \ No newline at end of file +install(TARGETS countly ARCHIVE DESTINATION lib PUBLIC_HEADER DESTINATION include/countly) From ebfaf3bd574c482ddfd2459e96bdf6dd7b8dc2f2 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 16 Apr 2026 15:53:54 +0300 Subject: [PATCH 10/12] fix: tests --- tests/test_utils.hpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_utils.hpp b/tests/test_utils.hpp index fc84409..0663810 100644 --- a/tests/test_utils.hpp +++ b/tests/test_utils.hpp @@ -172,7 +172,9 @@ static HTTPResponse fakeSendHTTP(bool use_post, const std::string &url, const st // Search http_call_queue for a request containing a specific key=value pair static bool httpQueueContains(const std::string &key, const std::string &value) { - for (const auto &call : http_call_queue) { + size_t n = http_call_queue.size(); + for (size_t i = 0; i < n; i++) { + HTTPCall call = http_call_queue.at(i); auto it = call.data.find(key); if (it != call.data.end() && it->second == value) { return true; @@ -183,7 +185,9 @@ static bool httpQueueContains(const std::string &key, const std::string &value) // Search http_call_queue for a request containing a specific event key static bool httpQueueContainsEvent(const std::string &event_key) { - for (const auto &call : http_call_queue) { + size_t n = http_call_queue.size(); + for (size_t i = 0; i < n; i++) { + HTTPCall call = http_call_queue.at(i); auto it = call.data.find("events"); if (it != call.data.end()) { try { From c6e14e9028b8b6529df12b9f4adc6931454c4dac Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 16 Apr 2026 16:21:47 +0300 Subject: [PATCH 11/12] fix: tsan --- include/countly.hpp | 11 ++++++----- src/countly.cpp | 6 ++---- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/include/countly.hpp b/include/countly.hpp index e4caef4..64de2b7 100644 --- a/include/countly.hpp +++ b/include/countly.hpp @@ -4,6 +4,7 @@ #include "countly/constants.hpp" #include "countly/countly_configuration.hpp" +#include #include #include #include @@ -366,8 +367,8 @@ class Countly : public cly::CountlyDelegates { void updateLoop(); void packEvents(); bool began_session = false; - bool is_being_disposed = false; - bool is_sdk_initialized = false; + std::atomic is_being_disposed{false}; + std::atomic is_sdk_initialized{false}; std::chrono::system_clock::time_point last_sent_session_request; nlohmann::json session_params; @@ -385,9 +386,9 @@ class Countly : public cly::CountlyDelegates { std::shared_ptr mutex = std::make_shared(); bool is_queue_being_processed = false; - bool enable_automatic_session = false; - bool stop_thread = false; - bool running = false; + std::atomic enable_automatic_session{false}; + std::atomic stop_thread{false}; + std::atomic running{false}; std::condition_variable stop_cv; // Wakes updateLoop immediately on stop size_t wait_milliseconds = COUNTLY_KEEPALIVE_INTERVAL; diff --git a/src/countly.cpp b/src/countly.cpp index 24755a9..d89e653 100644 --- a/src/countly.cpp +++ b/src/countly.cpp @@ -580,9 +580,7 @@ void Countly::_deleteThread() { std::lock_guard lk(*mutex); stop_thread = true; } - if (configuration->immediateRequestOnStop) { - stop_cv.notify_one(); - } + stop_cv.notify_one(); if (thread && thread->joinable()) { try { thread->join(); @@ -1409,7 +1407,7 @@ void Countly::updateLoop() { { std::unique_lock lk(*mutex); stop_cv.wait_for(lk, std::chrono::milliseconds(wait_milliseconds), [this] { - return stop_thread; + return stop_thread.load(); }); if (stop_thread) { stop_thread = false; From a33a7a42c30725eb3b239289ceb84bd6baeddba0 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 16 Apr 2026 16:37:31 +0300 Subject: [PATCH 12/12] fix: tsan --- src/countly.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/countly.cpp b/src/countly.cpp index d89e653..c7cb4ef 100644 --- a/src/countly.cpp +++ b/src/countly.cpp @@ -45,7 +45,11 @@ Countly &Countly::getInstance() { } #ifdef COUNTLY_BUILD_TESTS -void Countly::halt() { _sharedInstance.reset(new Countly()); } +void Countly::halt() { + if (_sharedInstance) { + _sharedInstance->stop(); // joins threads, releases mutex normally + } + _sharedInstance.reset(new Countly()); } #endif /**