Files
logos-test-modules/test-thread-safety/test_thread_safety.cpp
Dario LipicarandClaude Opus 4.8 ab4be29aae Retarget unit-test harnesses to the qt-split SDK stack (#23)
* fix(unit-tests): retarget test harnesses to the qt-split SDK stack

The unit-test CMakeLists linked logos-cpp-sdk::logos_sdk, which no longer
exists after the Qt split: the Qt developer layer (LogosAPI, provider
objects) moved to logos-qt-sdk and the transport/consumer core (types,
api_client, token_manager, mock transports) moved into the logos-protocol
library.

Both harnesses (test-ipc-module, test-ipc-module-new-api) now resolve
three roots (LOGOS_CPP_SDK_ROOT via logos_module_context.h, plus
LOGOS_QT_SDK_ROOT and LOGOS_PROTOCOL_ROOT), take mock-transport headers
from logos-protocol, link logos-qt-sdk::logos_qt_sdk (carries the
protocol library transitively) plus logos-cpp-sdk::logos_headers, and in
source layouts compile the qt-sdk sources while linking the protocol
library instead of compiling the old SDK sources in.

flake.nix threads the two new roots into both testBin derivations,
reaching the packages through logos-liblogos's inputs like the existing
cpp-sdk reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* lock: pin extraction-chain branch revs for standalone CI

Temporary — drop when the chain PRs merge (re-lock against masters).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* lock: qt-split chain fully merged — pins advance to masters (liblogos 050f2d3, logoscore-cli 519e7d3, module-builder 24cec35)

* test(thread-safety): update to liblogos simplified C API (#130)

liblogos#130 merged the redundant C API functions: set_modules_dir →
add_modules_dir; load_module gained a with_dependencies bool (folding in
load_module_with_dependencies); unload_module gained a with_dependents bool.
test_thread_safety.cpp still called the pre-#130 signatures, so it failed to
compile against current liblogos. Mechanical update — load_module(x) →
load_module(x,false), load_module_with_dependencies(x) → load_module(x,true),
unload_module(x) → unload_module(x,false). Now compiles; 8/10 tests pass
(the residual SIGKILL in ConcurrentLoadSamePlugin is the pre-existing
heavy-concurrency runtime instability, not API-related).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:55:05 -03:00

515 lines
18 KiB
C++

#include <gtest/gtest.h>
#include "logos_core.h"
#include "dummy_module_generator.h"
#include <QTemporaryDir>
#include <string>
#include <thread>
#include <vector>
#include <atomic>
#include <mutex>
#include <condition_variable>
#include <cstring>
// Reusable barrier so all threads start work at the same instant.
class Barrier {
public:
explicit Barrier(int count) : m_threshold(count), m_waiting(0), m_generation(0) {}
void wait() {
std::unique_lock<std::mutex> lock(m_mutex);
int gen = m_generation;
if (++m_waiting == m_threshold) {
m_generation++;
m_waiting = 0;
m_cv.notify_all();
} else {
m_cv.wait(lock, [&] { return gen != m_generation; });
}
}
private:
std::mutex m_mutex;
std::condition_variable m_cv;
int m_threshold;
int m_waiting;
int m_generation;
};
// Free a null-terminated array of strings allocated with new[].
static void freeStringArray(char** arr) {
if (!arr) return;
for (int i = 0; arr[i]; ++i)
delete[] arr[i];
delete[] arr;
}
// Count entries in a null-terminated string array.
static int stringArrayLen(char** arr) {
if (!arr) return 0;
int n = 0;
while (arr[n]) ++n;
return n;
}
// Return true if name appears in a null-terminated string array.
static bool stringArrayContains(char** arr, const char* name) {
if (!arr) return false;
for (int i = 0; arr[i]; ++i)
if (strcmp(arr[i], name) == 0) return true;
return false;
}
// Qt requires at least one argument (the program name) for QCoreApplication.
static int s_argc = 1;
static char s_name[] = "thread_safety_tests";
static char* s_argv[] = {s_name, nullptr};
static void initPluginState() {
logos_core_init(s_argc, s_argv);
logos_core_start();
}
static void initPluginState(const char* pluginsDir) {
logos_core_init(s_argc, s_argv);
logos_core_add_modules_dir(pluginsDir);
logos_core_start();
}
// =============================================================================
// Lightweight tests — no real plugin files needed. Exercise the C API with
// names that are unknown to the registry.
// =============================================================================
class PluginApiTest : public ::testing::Test {
protected:
void SetUp() override { initPluginState(); }
void TearDown() override { logos_core_cleanup(); }
static constexpr int kThreads = 8;
static constexpr int kIterations = 200;
};
// -----------------------------------------------------------------------------
// Multiple threads all try to load unknown plugins concurrently.
// Every call must return 0 (failure) without crashing.
// -----------------------------------------------------------------------------
TEST_F(PluginApiTest, ConcurrentLoadUnknownPlugins) {
Barrier barrier(kThreads);
std::vector<std::thread> threads;
for (int t = 0; t < kThreads; ++t) {
threads.emplace_back([&barrier, t]() {
barrier.wait();
for (int i = 0; i < kIterations; ++i) {
std::string name = "unknown_" + std::to_string(t) + "_" + std::to_string(i);
int ok = logos_core_load_module(name.c_str(), false);
EXPECT_EQ(ok, 0);
}
});
}
for (auto& th : threads) th.join();
}
// -----------------------------------------------------------------------------
// logos_core_load_module(…, true) on unknown plugins from many threads.
// Each call must return 0 (failure) without crashing.
// -----------------------------------------------------------------------------
TEST_F(PluginApiTest, ConcurrentLoadWithDepsUnknown) {
Barrier barrier(kThreads);
std::vector<std::thread> threads;
for (int t = 0; t < kThreads; ++t) {
threads.emplace_back([&barrier, t]() {
barrier.wait();
for (int i = 0; i < kIterations; ++i) {
std::string name = "nodeps_" + std::to_string(t) + "_" + std::to_string(i);
int rc = logos_core_load_module(name.c_str(), true);
EXPECT_EQ(rc, 0);
}
});
}
for (auto& th : threads) th.join();
}
// =============================================================================
// Real-plugin tests — use binary-patched copies of a real Qt plugin.
// All operations go through the public logos_core C API.
// =============================================================================
class RealPluginThreadSafetyTest : public ::testing::Test {
protected:
static constexpr int kThreads = 8;
static constexpr int kModuleCount = 100;
static constexpr int kIterations = 200;
QTemporaryDir tmpDir;
QVector<DummyModule> modules;
void SetUp() override {
ASSERT_TRUE(tmpDir.isValid());
modules = DummyModuleGenerator::generate(kModuleCount, tmpDir.path());
if (modules.isEmpty())
GTEST_SKIP() << "Dummy plugin template not found — skipping real-plugin tests";
ASSERT_EQ(modules.size(), kModuleCount) << "Partial plugin generation — expected "
<< kModuleCount << " but got " << modules.size();
std::string dir = tmpDir.path().toStdString();
initPluginState(dir.c_str());
}
void TearDown() override {
logos_core_cleanup();
}
};
// -----------------------------------------------------------------------------
// Each thread processes a disjoint slice of the generated plugins via
// logos_core_process_module. After joining, every plugin must appear in
// logos_core_get_known_modules().
// -----------------------------------------------------------------------------
TEST_F(RealPluginThreadSafetyTest, ConcurrentProcessPlugins) {
Barrier barrier(kThreads);
std::atomic<int> processed{0};
std::vector<std::thread> threads;
int perThread = kModuleCount / kThreads;
for (int t = 0; t < kThreads; ++t) {
int start = t * perThread;
int end = (t == kThreads - 1) ? kModuleCount : start + perThread;
threads.emplace_back([&, start, end]() {
barrier.wait();
for (int i = start; i < end; ++i) {
std::string path = modules[i].path.toStdString();
char* name = logos_core_process_module(path.c_str());
if (name) {
processed.fetch_add(1, std::memory_order_relaxed);
delete[] name;
}
}
});
}
for (auto& th : threads) th.join();
EXPECT_EQ(processed.load(), kModuleCount);
char** known = logos_core_get_known_modules();
EXPECT_EQ(stringArrayLen(known), kModuleCount);
for (const DummyModule& m : modules) {
std::string name = m.name.toStdString();
EXPECT_TRUE(stringArrayContains(known, name.c_str())) << name;
}
freeStringArray(known);
}
// -----------------------------------------------------------------------------
// All threads try to process the SAME set of plugins — tests idempotent
// insertion under heavy write contention.
// -----------------------------------------------------------------------------
TEST_F(RealPluginThreadSafetyTest, ConcurrentProcessSamePlugins) {
Barrier barrier(kThreads);
std::vector<std::thread> threads;
for (int t = 0; t < kThreads; ++t) {
threads.emplace_back([&]() {
barrier.wait();
for (const DummyModule& m : modules) {
std::string path = m.path.toStdString();
char* name = logos_core_process_module(path.c_str());
delete[] name;
}
});
}
for (auto& th : threads) th.join();
char** known = logos_core_get_known_modules();
EXPECT_EQ(stringArrayLen(known), kModuleCount);
freeStringArray(known);
}
// -----------------------------------------------------------------------------
// Half the threads process plugins while the other half continuously call
// logos_core_get_known_modules(). Tests reader safety during concurrent writes.
// -----------------------------------------------------------------------------
TEST_F(RealPluginThreadSafetyTest, ConcurrentProcessWhileQuerying) {
Barrier barrier(kThreads);
std::atomic<int> writersDone{0};
std::vector<std::thread> threads;
int writers = kThreads / 2;
for (int t = 0; t < writers; ++t) {
int start = t * (kModuleCount / writers);
int end = (t == writers - 1) ? kModuleCount : start + (kModuleCount / writers);
threads.emplace_back([&, start, end]() {
barrier.wait();
for (int i = start; i < end; ++i) {
std::string path = modules[i].path.toStdString();
char* name = logos_core_process_module(path.c_str());
delete[] name;
}
writersDone.fetch_add(1, std::memory_order_release);
});
}
for (int t = writers; t < kThreads; ++t) {
threads.emplace_back([&, writers]() {
barrier.wait();
while (writersDone.load(std::memory_order_acquire) < writers) {
char** known = logos_core_get_known_modules();
int len = stringArrayLen(known);
EXPECT_GE(len, 0);
freeStringArray(known);
}
});
}
for (auto& th : threads) th.join();
char** known = logos_core_get_known_modules();
EXPECT_EQ(stringArrayLen(known), kModuleCount);
freeStringArray(known);
}
// -----------------------------------------------------------------------------
// logos_core_get_known_modules() and logos_core_get_loaded_modules() are called
// concurrently while other threads repeatedly load and unload a small set of
// plugins. Tests that the list accessors are safe under concurrent state
// changes.
// -----------------------------------------------------------------------------
TEST_F(RealPluginThreadSafetyTest, ConcurrentGetListsDuringLoadUnload) {
constexpr int kSmall = kThreads;
for (int i = 0; i < kSmall; ++i) {
std::string path = modules[i].path.toStdString();
char* name = logos_core_process_module(path.c_str());
delete[] name;
}
Barrier barrier(kThreads);
std::atomic<bool> done{false};
std::vector<std::thread> threads;
// Writer threads repeatedly load then unload the same small set.
threads.emplace_back([&]() {
barrier.wait();
for (int iter = 0; iter < kIterations; ++iter) {
std::string name = modules[iter % kSmall].name.toStdString();
(void)logos_core_load_module(name.c_str(), false);
(void)logos_core_unload_module(name.c_str(), false);
}
done.store(true, std::memory_order_release);
});
// Reader threads call both list accessors in a tight loop.
for (int t = 1; t < kThreads; ++t) {
threads.emplace_back([&]() {
barrier.wait();
while (!done.load(std::memory_order_acquire)) {
char** known = logos_core_get_known_modules();
EXPECT_NE(known, nullptr);
freeStringArray(known);
char** loaded = logos_core_get_loaded_modules();
EXPECT_NE(loaded, nullptr);
freeStringArray(loaded);
}
});
}
for (auto& th : threads) th.join();
}
// -----------------------------------------------------------------------------
// Process all plugins, then each thread loads a disjoint slice via
// logos_core_load_module. Tests the load path under concurrent pressure.
// With logos_host available (LOGOS_HOST_PATH set), loads succeed; without it
// they return 0. Either way the registry must remain consistent.
// -----------------------------------------------------------------------------
TEST_F(RealPluginThreadSafetyTest, ConcurrentLoadPlugin) {
for (const DummyModule& m : modules) {
std::string path = m.path.toStdString();
char* name = logos_core_process_module(path.c_str());
delete[] name;
}
{
char** known = logos_core_get_known_modules();
ASSERT_EQ(stringArrayLen(known), kModuleCount);
freeStringArray(known);
}
Barrier barrier(kThreads);
std::vector<std::thread> threads;
int perThread = kModuleCount / kThreads;
for (int t = 0; t < kThreads; ++t) {
int start = t * perThread;
int end = (t == kThreads - 1) ? kModuleCount : start + perThread;
threads.emplace_back([&, start, end]() {
barrier.wait();
for (int i = start; i < end; ++i) {
std::string name = modules[i].name.toStdString();
(void)logos_core_load_module(name.c_str(), false);
}
});
}
for (auto& th : threads) th.join();
char** known = logos_core_get_known_modules();
EXPECT_EQ(stringArrayLen(known), kModuleCount);
for (const DummyModule& m : modules) {
std::string name = m.name.toStdString();
EXPECT_TRUE(stringArrayContains(known, name.c_str())) << name;
}
freeStringArray(known);
}
// -----------------------------------------------------------------------------
// All threads hammer the SAME small set of plugins with logos_core_load_module.
// Tests mutex contention and the "already loaded" fast-return branch.
// -----------------------------------------------------------------------------
TEST_F(RealPluginThreadSafetyTest, ConcurrentLoadSamePlugin) {
constexpr int kSmall = kThreads;
for (int i = 0; i < kSmall; ++i) {
std::string path = modules[i].path.toStdString();
char* name = logos_core_process_module(path.c_str());
delete[] name;
}
{
char** known = logos_core_get_known_modules();
ASSERT_EQ(stringArrayLen(known), kSmall);
freeStringArray(known);
}
Barrier barrier(kThreads);
std::vector<std::thread> threads;
for (int t = 0; t < kThreads; ++t) {
threads.emplace_back([&]() {
barrier.wait();
for (int i = 0; i < kSmall; ++i) {
std::string name = modules[i].name.toStdString();
(void)logos_core_load_module(name.c_str(), false);
}
});
}
for (auto& th : threads) th.join();
char** known = logos_core_get_known_modules();
EXPECT_EQ(stringArrayLen(known), kSmall);
freeStringArray(known);
}
// -----------------------------------------------------------------------------
// Each thread loads a disjoint slice via logos_core_load_module(…, true).
// Tests dependency resolution and loadMutex acquisition from multiple threads.
// -----------------------------------------------------------------------------
TEST_F(RealPluginThreadSafetyTest, ConcurrentLoadWithDeps) {
for (const DummyModule& m : modules) {
std::string path = m.path.toStdString();
char* name = logos_core_process_module(path.c_str());
delete[] name;
}
{
char** known = logos_core_get_known_modules();
ASSERT_EQ(stringArrayLen(known), kModuleCount);
freeStringArray(known);
}
Barrier barrier(kThreads);
std::vector<std::thread> threads;
int perThread = kModuleCount / kThreads;
for (int t = 0; t < kThreads; ++t) {
int start = t * perThread;
int end = (t == kThreads - 1) ? kModuleCount : start + perThread;
threads.emplace_back([&, start, end]() {
barrier.wait();
for (int i = start; i < end; ++i) {
std::string name = modules[i].name.toStdString();
(void)logos_core_load_module(name.c_str(), true);
}
});
}
for (auto& th : threads) th.join();
char** known = logos_core_get_known_modules();
EXPECT_EQ(stringArrayLen(known), kModuleCount);
freeStringArray(known);
}
// -----------------------------------------------------------------------------
// Half the threads call logos_core_load_module while the other half call
// logos_core_unload_module on the same small module set. Tests the load/unload
// interplay under concurrent pressure.
// -----------------------------------------------------------------------------
TEST_F(RealPluginThreadSafetyTest, ConcurrentLoadUnloadInterleaved) {
constexpr int kSmall = kThreads;
for (int i = 0; i < kSmall; ++i) {
std::string path = modules[i].path.toStdString();
char* name = logos_core_process_module(path.c_str());
delete[] name;
}
{
char** known = logos_core_get_known_modules();
ASSERT_EQ(stringArrayLen(known), kSmall);
freeStringArray(known);
}
Barrier barrier(kThreads);
std::vector<std::thread> threads;
int loaders = kThreads / 2;
int unloaders = kThreads - loaders;
for (int t = 0; t < loaders; ++t) {
threads.emplace_back([&]() {
barrier.wait();
for (int iter = 0; iter < 10; ++iter) {
for (int i = 0; i < kSmall; ++i) {
std::string name = modules[i].name.toStdString();
(void)logos_core_load_module(name.c_str(), false);
}
}
});
}
for (int t = 0; t < unloaders; ++t) {
threads.emplace_back([&]() {
barrier.wait();
for (int iter = 0; iter < 10; ++iter) {
for (int i = 0; i < kSmall; ++i) {
std::string name = modules[i].name.toStdString();
(void)logos_core_unload_module(name.c_str(), false);
}
}
});
}
for (auto& th : threads) th.join();
// All modules must still be registered; load/unload must not corrupt the registry.
char** known = logos_core_get_known_modules();
EXPECT_EQ(stringArrayLen(known), kSmall);
for (int i = 0; i < kSmall; ++i) {
std::string name = modules[i].name.toStdString();
EXPECT_TRUE(stringArrayContains(known, name.c_str())) << name;
}
freeStringArray(known);
}