yams/tests/unit/daemon/daemon_catch2_test.cpp
2026-05-01 12:07:40 -04:00

947 lines
28 KiB
C++

// Catch2 migration of daemon_test.cpp
// Migration: yams-3s4 (daemon unit tests)
// Unit tests for YamsDaemon - creation, start/stop, signal handling, error paths
#include <catch2/catch_test_macros.hpp>
#include <atomic>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <future>
#include <iomanip>
#include <sstream>
#include <string_view>
#include <thread>
#include <nlohmann/json.hpp>
using nlohmann::json;
#include <yams/compat/unistd.h>
#include <yams/daemon/daemon.h>
#include <yams/daemon/daemon_lifecycle.h>
#include <yams/daemon/ipc/ipc_protocol.h>
#include <yams/daemon/ipc/proto_serializer.h>
#include <yams/metadata/document_metadata.h>
#include <yams/metadata/metadata_repository.h>
#include "tests/common/test_helpers_catch2.h"
#ifdef _WIN32
#include <fcntl.h>
#include <io.h>
#include <share.h>
using pid_t = int;
#else
#include <signal.h>
#include <sys/wait.h>
#endif
namespace fs = std::filesystem;
using namespace std::chrono_literals;
using namespace yams;
using namespace yams::daemon;
namespace yams::daemon::test {
// Test fixture for Daemon tests
struct DaemonFixture {
DaemonConfig config_;
std::unique_ptr<YamsDaemon> daemon_;
fs::path runtime_root_;
std::thread runLoopThread_;
DaemonFixture() {
#ifdef _WIN32
fs::path tmp = fs::temp_directory_path() / "yams_test";
#else
fs::path tmp{"/tmp"};
#endif
auto unique_suffix =
std::to_string(::getpid()) + "_" +
std::to_string(static_cast<unsigned long>(reinterpret_cast<uintptr_t>(this) & 0xffff));
runtime_root_ = tmp / ("ydtest_" + unique_suffix);
config_.dataDir = runtime_root_ / "data";
config_.socketPath = runtime_root_ / "sock";
config_.pidFile = runtime_root_ / "daemon.pid";
config_.logFile = runtime_root_ / "daemon.log";
config_.maxMemoryGb = 1.0;
// When ABI plugins are disabled, no model provider can be adopted,
// so disable the requirement to avoid init failure.
if (const char* v = std::getenv("YAMS_DISABLE_ABI_PLUGINS");
v && std::string_view(v) == "1") {
config_.enableModelProvider = false;
} else {
config_.enableModelProvider = true;
}
config_.autoLoadPlugins = true;
config_.modelPoolConfig.lazyLoading = true;
config_.modelPoolConfig.preloadModels.clear();
::setenv("YAMS_DB_OPEN_TIMEOUT_MS", "1500", 1);
::setenv("YAMS_DB_MIGRATE_TIMEOUT_MS", "2000", 1);
::setenv("YAMS_SEARCH_BUILD_TIMEOUT_MS", "1500", 1);
::setenv("YAMS_DISABLE_VECTORS", "1", 1);
::setenv("YAMS_DISABLE_SESSION_WATCHER", "1", 1);
std::error_code se;
fs::create_directories(config_.dataDir, se);
::setenv("YAMS_RUNTIME_DIR", runtime_root_.string().c_str(), 1);
::setenv("YAMS_SOCKET_PATH", config_.socketPath.string().c_str(), 1);
::setenv("YAMS_PID_FILE", config_.pidFile.string().c_str(), 1);
}
~DaemonFixture() {
stopRunLoop();
if (daemon_) {
daemon_->stop();
daemon_.reset();
}
cleanupDaemonFiles();
}
void startRunLoop() {
if (!daemon_ || runLoopThread_.joinable()) {
return;
}
runLoopThread_ = std::thread([this]() { daemon_->runLoop(); });
std::this_thread::sleep_for(50ms);
}
void stopRunLoop() {
if (daemon_ && daemon_->isRunning()) {
daemon_->requestStop();
}
if (runLoopThread_.joinable()) {
runLoopThread_.join();
}
}
void cleanupDaemonFiles() {
std::error_code ec;
if (!runtime_root_.empty()) {
fs::remove_all(runtime_root_, ec);
}
}
static bool isSocketPermissionDenied(const yams::Error& error) {
const std::string_view message{error.message};
return message.find("Operation not permitted") != std::string_view::npos ||
message.find("Permission denied") != std::string_view::npos;
}
bool waitForDaemonReady(std::chrono::milliseconds timeout = 5000ms) {
auto deadline = std::chrono::steady_clock::now() + timeout;
while (std::chrono::steady_clock::now() < deadline) {
const auto& state = daemon_->getState();
if (state.readiness.contentStoreReady.load()) {
return true;
}
std::this_thread::sleep_for(50ms);
}
return false;
}
static bool waitForCondition(std::chrono::milliseconds timeout,
const std::function<bool()>& predicate) {
auto deadline = std::chrono::steady_clock::now() + timeout;
while (std::chrono::steady_clock::now() < deadline) {
if (predicate()) {
return true;
}
std::this_thread::sleep_for(10ms);
}
return predicate();
}
};
#ifdef _WIN32
#define SKIP_ON_WINDOWS() SKIP("Daemon IPC tests unstable on Windows")
#else
#define SKIP_ON_WINDOWS() ((void)0)
#endif
TEST_CASE_METHOD(DaemonFixture, "Daemon creation and destruction", "[daemon][lifecycle]") {
SKIP_ON_WINDOWS();
daemon_ = std::make_unique<YamsDaemon>(config_);
REQUIRE(daemon_ != nullptr);
REQUIRE_FALSE(daemon_->isRunning());
}
TEST_CASE_METHOD(DaemonFixture, "Daemon tuning reload preserves unspecified values",
"[daemon][tuning][reload]") {
SKIP_ON_WINDOWS();
const auto configPath = runtime_root_ / "config.toml";
{
std::ofstream out(configPath);
REQUIRE(out.is_open());
out << "[tuning]\n";
out << "target_cpu_percent = 321\n";
out << "post_ingest_capacity = 111\n";
out << "control_interval_ms = 222\n";
}
config_.configFilePath = configPath;
config_.tuning.postIngestThreadsMin = 5;
config_.tuning.holdMs = 777;
daemon_ = std::make_unique<YamsDaemon>(config_);
REQUIRE(daemon_ != nullptr);
daemon_->reloadTuningConfig();
CHECK(daemon_->config_.tuning.targetCpuPercent == 321u);
CHECK(daemon_->config_.tuning.postIngestCapacity == 111u);
CHECK(daemon_->config_.tuning.controlIntervalMs == 222u);
CHECK(daemon_->config_.tuning.postIngestThreadsMin == 5u);
CHECK(daemon_->config_.tuning.holdMs == 777u);
}
TEST_CASE_METHOD(DaemonFixture, "Lifecycle shutdown waits for owner-thread stop",
"[daemon][lifecycle][shutdown-request]") {
SKIP_ON_WINDOWS();
daemon_ = std::make_unique<YamsDaemon>(config_);
auto startResult = daemon_->start();
if (!startResult && isSocketPermissionDenied(startResult.error())) {
SKIP("UNIX domain sockets not permitted");
}
REQUIRE(startResult);
startRunLoop();
REQUIRE(waitForCondition(
2s, [&] { return daemon_->runLoopStarted_.load(std::memory_order_acquire); }));
DaemonLifecycleAdapter lifecycle(daemon_.get());
lifecycle.requestShutdown(true, true);
REQUIRE(waitForCondition(2s, [&] {
return daemon_->stopRequested_.load(std::memory_order_acquire) &&
daemon_->shutdownThreadActive_.load(std::memory_order_acquire);
}));
std::this_thread::sleep_for(100ms);
CHECK(daemon_->shutdownThreadActive_.load(std::memory_order_acquire));
stopRunLoop();
auto stopResult = daemon_->stop();
REQUIRE(stopResult);
REQUIRE(waitForCondition(
2s, [&] { return !daemon_->shutdownThreadActive_.load(std::memory_order_acquire); }));
daemon_->reapCompletedShutdownThread();
}
TEST_CASE_METHOD(DaemonFixture, "Lifecycle shutdown falls back to direct stop without run loop",
"[daemon][lifecycle][shutdown-request][fallback]") {
SKIP_ON_WINDOWS();
daemon_ = std::make_unique<YamsDaemon>(config_);
auto startResult = daemon_->start();
if (!startResult && isSocketPermissionDenied(startResult.error())) {
SKIP("UNIX domain sockets not permitted");
}
REQUIRE(startResult);
REQUIRE_FALSE(daemon_->runLoopStarted_.load(std::memory_order_acquire));
DaemonLifecycleAdapter lifecycle(daemon_.get());
lifecycle.requestShutdown(true, true);
REQUIRE(waitForCondition(2s, [&] {
return !daemon_->isRunning() &&
!daemon_->shutdownThreadActive_.load(std::memory_order_acquire);
}));
daemon_->reapCompletedShutdownThread();
}
TEST_CASE_METHOD(DaemonFixture,
"Lifecycle shutdown avoids forced exit for foreground-managed daemon",
"[daemon][lifecycle][shutdown-request][foreground-managed]") {
SKIP_ON_WINDOWS();
yams::test::ScopedEnvVar foregroundManaged("YAMS_DAEMON_FOREGROUND", std::string("1"));
daemon_ = std::make_unique<YamsDaemon>(config_);
auto startResult = daemon_->start();
if (!startResult && isSocketPermissionDenied(startResult.error())) {
SKIP("UNIX domain sockets not permitted");
}
REQUIRE(startResult);
startRunLoop();
REQUIRE(waitForCondition(
2s, [&] { return daemon_->runLoopStarted_.load(std::memory_order_acquire); }));
DaemonLifecycleAdapter lifecycle(daemon_.get());
lifecycle.requestShutdown(true, false);
REQUIRE(waitForCondition(2s, [&] {
return daemon_->stopRequested_.load(std::memory_order_acquire) &&
daemon_->shutdownThreadActive_.load(std::memory_order_acquire);
}));
stopRunLoop();
auto stopResult = daemon_->stop();
REQUIRE(stopResult);
REQUIRE(waitForCondition(
2s, [&] { return !daemon_->shutdownThreadActive_.load(std::memory_order_acquire); }));
daemon_->reapCompletedShutdownThread();
REQUIRE_FALSE(daemon_->isRunning());
}
TEST_CASE_METHOD(DaemonFixture, "Daemon start and stop", "[daemon][lifecycle]") {
SKIP_ON_WINDOWS();
daemon_ = std::make_unique<YamsDaemon>(config_);
auto startResult = daemon_->start();
if (!startResult && isSocketPermissionDenied(startResult.error())) {
SKIP("UNIX domain sockets not permitted");
}
REQUIRE(startResult);
REQUIRE(daemon_->isRunning());
REQUIRE(fs::exists(config_.pidFile));
startRunLoop();
REQUIRE(waitForDaemonReady());
stopRunLoop();
auto result = daemon_->stop();
REQUIRE(result);
REQUIRE_FALSE(daemon_->isRunning());
REQUIRE_FALSE(fs::exists(config_.pidFile));
}
TEST_CASE_METHOD(DaemonFixture, "Daemon single instance enforcement", "[daemon][lifecycle]") {
SKIP_ON_WINDOWS();
daemon_ = std::make_unique<YamsDaemon>(config_);
auto result = daemon_->start();
if (!result && isSocketPermissionDenied(result.error())) {
SKIP("UNIX domain sockets not permitted");
}
REQUIRE(result);
auto daemon2 = std::make_unique<YamsDaemon>(config_);
auto result2 = daemon2->start();
REQUIRE_FALSE(result2);
REQUIRE(result2.error().code == ErrorCode::InvalidState);
daemon_->stop();
}
TEST_CASE_METHOD(DaemonFixture, "Daemon rejects pid file pointing to current process",
"[daemon][lifecycle]") {
SKIP_ON_WINDOWS();
json payload = json::object();
payload["pid"] = static_cast<std::int64_t>(getpid());
payload["start_ns"] = 1;
payload["token"] = "test";
std::ofstream pidFile(config_.pidFile);
pidFile << payload.dump();
pidFile.close();
daemon_ = std::make_unique<YamsDaemon>(config_);
auto result = daemon_->start();
REQUIRE_FALSE(result);
REQUIRE(result.error().code == ErrorCode::InvalidState);
}
TEST_CASE_METHOD(DaemonFixture, "Daemon refuses pid file pointing to unrelated running process",
"[daemon][lifecycle]") {
SKIP_ON_WINDOWS();
#ifndef _WIN32
pid_t child = fork();
if (child < 0) {
SKIP("fork not available");
}
if (child == 0) {
for (;;) {
::pause();
}
}
json payload = json::object();
payload["pid"] = static_cast<std::int64_t>(child);
payload["start_ns"] = 1;
payload["token"] = "test";
std::ofstream pidFile(config_.pidFile);
pidFile << payload.dump();
pidFile.close();
daemon_ = std::make_unique<YamsDaemon>(config_);
auto result = daemon_->start();
REQUIRE_FALSE(result);
REQUIRE(result.error().code == ErrorCode::InvalidState);
::kill(child, SIGKILL);
int status = 0;
(void)::waitpid(child, &status, 0);
#else
SKIP("POSIX process APIs not available on Windows");
#endif
}
TEST_CASE_METHOD(DaemonFixture, "Daemon restart", "[daemon][lifecycle]") {
SKIP_ON_WINDOWS();
daemon_ = std::make_unique<YamsDaemon>(config_);
auto result = daemon_->start();
if (!result && isSocketPermissionDenied(result.error())) {
SKIP("UNIX domain sockets not permitted");
}
REQUIRE(result);
result = daemon_->stop();
REQUIRE(result);
result = daemon_->start();
if (!result && isSocketPermissionDenied(result.error())) {
SKIP("UNIX domain sockets not permitted on restart");
}
REQUIRE(result);
REQUIRE(daemon_->isRunning());
daemon_->stop();
}
TEST_CASE_METHOD(DaemonFixture, "Daemon path resolution with empty config", "[daemon][config]") {
SKIP_ON_WINDOWS();
DaemonConfig autoConfig;
daemon_ = std::make_unique<YamsDaemon>(autoConfig);
REQUIRE(daemon_ != nullptr);
}
TEST_CASE_METHOD(DaemonFixture, "Daemon signal handling and PID file", "[daemon][signals]") {
SKIP_ON_WINDOWS();
daemon_ = std::make_unique<YamsDaemon>(config_);
auto result = daemon_->start();
if (!result && isSocketPermissionDenied(result.error())) {
SKIP("UNIX domain sockets not permitted");
}
REQUIRE(result);
#ifndef _WIN32
std::ifstream pidFile(config_.pidFile);
std::string content;
std::getline(pidFile, content, '\0');
pidFile.close();
auto parsed = json::parse(content, nullptr, false);
REQUIRE_FALSE(parsed.is_discarded());
REQUIRE(parsed.value("pid", 0) == getpid());
#endif
daemon_->stop();
}
TEST_CASE_METHOD(DaemonFixture, "Daemon stale PID file cleanup", "[daemon][lifecycle]") {
SKIP_ON_WINDOWS();
std::ofstream pidFile(config_.pidFile);
pidFile << "99999999\n";
pidFile.close();
daemon_ = std::make_unique<YamsDaemon>(config_);
auto result = daemon_->start();
if (!result && isSocketPermissionDenied(result.error())) {
SKIP("UNIX domain sockets not permitted");
}
REQUIRE(result);
REQUIRE(daemon_->isRunning());
daemon_->stop();
}
TEST_CASE_METHOD(DaemonFixture, "Daemon invalid socket path configuration", "[daemon][config]") {
SKIP_ON_WINDOWS();
config_.socketPath = "/root/cannot_write_here.sock";
daemon_ = std::make_unique<YamsDaemon>(config_);
auto result = daemon_->start();
REQUIRE_FALSE(result);
}
TEST_CASE_METHOD(DaemonFixture,
"Daemon socket preflight does not clean unrelated stale PID artifacts",
"[daemon][config][lifecycle]") {
SKIP_ON_WINDOWS();
// Seed a stale PID artifact that this process does not own.
{
std::ofstream pidFile(config_.pidFile);
pidFile << "99999999\n";
}
REQUIRE(fs::exists(config_.pidFile));
// Force an AF_UNIX-overlong socket path so start fails in preflight, before lifecycle init.
config_.socketPath = runtime_root_ / std::string(140, 'x') / "daemon.sock";
daemon_ = std::make_unique<YamsDaemon>(config_);
auto result = daemon_->start();
REQUIRE_FALSE(result);
CHECK(result.error().message.find("Socket path too long") != std::string::npos);
// Preflight failure must not mutate stale PID artifacts we did not create.
CHECK(fs::exists(config_.pidFile));
}
TEST_CASE_METHOD(DaemonFixture, "Daemon concurrent start attempts", "[daemon][concurrency]") {
SKIP_ON_WINDOWS();
{
YamsDaemon probe(config_);
auto result = probe.start();
if (!result && isSocketPermissionDenied(result.error())) {
SKIP("UNIX domain sockets not permitted");
}
REQUIRE(result);
probe.stop();
}
std::atomic<int> successCount{0};
std::atomic<int> failCount{0};
std::once_flag startedFlag;
std::promise<void> startedPromise;
auto startedFuture = startedPromise.get_future().share();
std::promise<void> releasePromise;
auto releaseFuture = releasePromise.get_future().share();
std::promise<void> attemptGatePromise;
auto attemptGateFuture = attemptGatePromise.get_future().share();
std::mutex readyMutex;
std::condition_variable readyCv;
int readyCount = 0;
std::vector<std::thread> threads;
constexpr int kAttempts = 5;
threads.reserve(kAttempts);
for (int i = 0; i < kAttempts; ++i) {
threads.emplace_back([this, &successCount, &failCount, &startedFlag, startedFuture,
&startedPromise, releaseFuture, attemptGateFuture, &readyMutex,
&readyCv, &readyCount]() {
{
std::lock_guard<std::mutex> lock(readyMutex);
++readyCount;
}
readyCv.notify_one();
attemptGateFuture.wait();
YamsDaemon local(config_);
auto result = local.start();
if (result) {
successCount.fetch_add(1, std::memory_order_relaxed);
std::call_once(startedFlag, [&]() { startedPromise.set_value(); });
releaseFuture.wait();
local.stop();
} else {
startedFuture.wait();
failCount.fetch_add(1, std::memory_order_relaxed);
}
});
}
{
std::unique_lock<std::mutex> lock(readyMutex);
REQUIRE(readyCv.wait_for(lock, std::chrono::seconds(1),
[&] { return readyCount == kAttempts; }));
}
attemptGatePromise.set_value();
if (startedFuture.wait_for(std::chrono::seconds(1)) != std::future_status::ready) {
std::call_once(startedFlag, [&]() { startedPromise.set_value(); });
}
const auto failDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
while (failCount.load(std::memory_order_relaxed) < kAttempts - 1 &&
std::chrono::steady_clock::now() < failDeadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
releasePromise.set_value();
for (auto& t : threads) {
t.join();
}
REQUIRE(successCount.load() == 1);
REQUIRE(failCount.load() == kAttempts - 1);
}
TEST_CASE_METHOD(DaemonFixture, "Daemon stats tracking", "[daemon][stats]") {
SKIP_ON_WINDOWS();
daemon_ = std::make_unique<YamsDaemon>(config_);
auto result = daemon_->start();
if (!result && isSocketPermissionDenied(result.error())) {
SKIP("UNIX domain sockets not permitted");
}
REQUIRE(result);
[[maybe_unused]] const auto& state = daemon_->getState();
daemon_->stop();
}
TEST_CASE_METHOD(DaemonFixture, "Daemon hybrid search smoke test", "[daemon][search]") {
SKIP_ON_WINDOWS();
daemon_ = std::make_unique<YamsDaemon>(config_);
auto result = daemon_->start();
if (!result && isSocketPermissionDenied(result.error())) {
SKIP("UNIX domain sockets not permitted");
}
REQUIRE(result);
SearchRequest req;
req.query = "seed";
req.limit = 5;
req.fuzzy = false;
req.literalText = false;
req.similarity = 0.7;
Message msg;
msg.version = 1;
msg.requestId = 42;
msg.payload = Request{req};
auto enc = ProtoSerializer::encode_payload(msg);
REQUIRE(enc);
auto parsed = ProtoSerializer::decode_payload(enc.value());
REQUIRE(parsed);
REQUIRE(daemon_->isRunning());
daemon_->stop();
}
TEST_CASE_METHOD(DaemonFixture, "Daemon fallback search when hybrid unavailable",
"[daemon][search]") {
SKIP_ON_WINDOWS();
daemon_ = std::make_unique<YamsDaemon>(config_);
auto result = daemon_->start();
if (!result && isSocketPermissionDenied(result.error())) {
SKIP("UNIX domain sockets not permitted");
}
REQUIRE(result);
SearchRequest req;
req.query = "fallback";
req.limit = 3;
req.fuzzy = true;
req.similarity = 0.6;
Message msg;
msg.version = 1;
msg.requestId = 7;
msg.payload = Request{req};
auto enc = ProtoSerializer::encode_payload(msg);
REQUIRE(enc);
auto parsed = ProtoSerializer::decode_payload(enc.value());
REQUIRE(parsed);
REQUIRE(daemon_->isRunning());
daemon_->stop();
}
TEST_CASE_METHOD(DaemonFixture, "StatusResponse serialization includes ready field",
"[daemon][serialization]") {
StatusResponse sr;
sr.running = true;
sr.ready = true;
sr.uptimeSeconds = 0;
sr.requestsProcessed = 0;
sr.activeConnections = 0;
sr.memoryUsageMb = 0.0;
sr.cpuUsagePercent = 0.0;
sr.version = "test";
Message msg;
msg.version = 1;
msg.requestId = 0;
msg.payload = Response{sr};
auto enc = ProtoSerializer::encode_payload(msg);
REQUIRE(enc);
auto des = ProtoSerializer::decode_payload(enc.value());
REQUIRE(des);
Message out = des.value();
auto respVariant = std::get<Response>(out.payload);
REQUIRE(std::holds_alternative<StatusResponse>(respVariant));
auto outSr = std::get<StatusResponse>(respVariant);
REQUIRE(outSr.ready == true);
REQUIRE(outSr.running == true);
REQUIRE(outSr.version == "test");
}
TEST_CASE_METHOD(DaemonFixture, "Daemon start fails with invalid data directory",
"[daemon][config]") {
SKIP_ON_WINDOWS();
DaemonConfig bad = config_;
bad.dataDir = "/root/forbidden_yams_test";
daemon_ = std::make_unique<YamsDaemon>(bad);
auto result = daemon_->start();
REQUIRE_FALSE(result);
}
TEST_CASE_METHOD(DaemonFixture, "Daemon PID file cannot be created in read-only directory",
"[daemon][errors]") {
SKIP_ON_WINDOWS();
fs::path readonly_dir = runtime_root_ / "readonly";
fs::create_directories(readonly_dir);
fs::permissions(readonly_dir, fs::perms::owner_read | fs::perms::owner_exec,
fs::perm_options::replace);
DaemonConfig bad_config = config_;
bad_config.pidFile = readonly_dir / "yams.pid";
daemon_ = std::make_unique<YamsDaemon>(bad_config);
auto result = daemon_->start();
REQUIRE_FALSE(result);
if (!result) {
REQUIRE_FALSE(result.error().message.empty());
}
fs::permissions(readonly_dir, fs::perms::owner_all, fs::perm_options::replace);
}
TEST_CASE_METHOD(DaemonFixture, "Daemon PID file cleaned up on start failure", "[daemon][errors]") {
SKIP_ON_WINDOWS();
DaemonConfig bad_config = config_;
bad_config.dataDir = "/nonexistent/forbidden_path";
daemon_ = std::make_unique<YamsDaemon>(bad_config);
auto result = daemon_->start();
REQUIRE_FALSE(result);
REQUIRE_FALSE(fs::exists(bad_config.pidFile));
}
TEST_CASE_METHOD(DaemonFixture, "Daemon stop when not started is safe", "[daemon][lifecycle]") {
SKIP_ON_WINDOWS();
daemon_ = std::make_unique<YamsDaemon>(config_);
REQUIRE_NOTHROW(daemon_->stop());
REQUIRE_NOTHROW(daemon_->stop());
}
TEST_CASE_METHOD(DaemonFixture, "Daemon double stop is idempotent", "[daemon][lifecycle]") {
SKIP_ON_WINDOWS();
daemon_ = std::make_unique<YamsDaemon>(config_);
auto result = daemon_->start();
if (!result && isSocketPermissionDenied(result.error())) {
SKIP("UNIX domain sockets not permitted");
}
REQUIRE(result);
daemon_->stop();
REQUIRE_NOTHROW(daemon_->stop());
}
TEST_CASE_METHOD(DaemonFixture, "Daemon fails with missing socket directory", "[daemon][config]") {
SKIP_ON_WINDOWS();
DaemonConfig bad_config = config_;
bad_config.socketPath = "/nonexistent_dir/subdir/yams.sock";
daemon_ = std::make_unique<YamsDaemon>(bad_config);
auto result = daemon_->start();
REQUIRE_FALSE(result);
}
TEST_CASE_METHOD(DaemonFixture, "Daemon socket cleanup after stop", "[daemon][lifecycle]") {
SKIP_ON_WINDOWS();
daemon_ = std::make_unique<YamsDaemon>(config_);
auto result = daemon_->start();
if (!result && isSocketPermissionDenied(result.error())) {
SKIP("UNIX domain sockets not permitted");
}
REQUIRE(result);
REQUIRE(fs::exists(config_.socketPath));
daemon_->stop();
REQUIRE_FALSE(fs::exists(config_.socketPath));
}
TEST_CASE_METHOD(DaemonFixture, "Daemon restart with existing socket", "[daemon][lifecycle]") {
SKIP_ON_WINDOWS();
daemon_ = std::make_unique<YamsDaemon>(config_);
auto result1 = daemon_->start();
if (!result1 && isSocketPermissionDenied(result1.error())) {
SKIP("UNIX domain sockets not permitted");
}
REQUIRE(result1);
daemon_->stop();
auto result2 = daemon_->start();
REQUIRE(result2);
daemon_->stop();
}
TEST_CASE_METHOD(DaemonFixture, "Daemon empty data directory handling", "[daemon][config]") {
SKIP_ON_WINDOWS();
DaemonConfig bad_config = config_;
bad_config.dataDir = "";
daemon_ = std::make_unique<YamsDaemon>(bad_config);
auto result = daemon_->start();
if (result) {
daemon_->stop();
} else {
REQUIRE_FALSE(result.error().message.empty());
}
}
TEST_CASE_METHOD(DaemonFixture, "Daemon status query on stopped daemon", "[daemon][state]") {
SKIP_ON_WINDOWS();
daemon_ = std::make_unique<YamsDaemon>(config_);
auto result = daemon_->start();
if (!result && isSocketPermissionDenied(result.error())) {
SKIP("UNIX domain sockets not permitted");
}
REQUIRE(result);
const auto& state_running = daemon_->getState();
REQUIRE(state_running.readiness.ipcServerReady.load());
daemon_->stop();
const auto& state_stopped = daemon_->getState();
REQUIRE_FALSE(state_stopped.readiness.ipcServerReady.load());
}
TEST_CASE_METHOD(DaemonFixture, "Daemon async init barrier prevents race condition",
"[daemon][concurrency]") {
SKIP_ON_WINDOWS();
if (const char* disable_vectors = std::getenv("YAMS_DISABLE_VECTORS")) {
if (std::string_view(disable_vectors) == "1") {
SKIP("Vector DB disabled via YAMS_DISABLE_VECTORS=1");
}
}
daemon_ = std::make_unique<YamsDaemon>(config_);
auto result = daemon_->start();
if (!result && isSocketPermissionDenied(result.error())) {
SKIP("UNIX domain sockets not permitted");
}
REQUIRE(result);
REQUIRE(daemon_->isRunning());
std::atomic<bool> runLoopEntered{false};
std::atomic<bool> runLoopExited{false};
std::thread runLoopThread([this, &runLoopEntered, &runLoopExited]() {
runLoopEntered.store(true);
daemon_->runLoop();
runLoopExited.store(true);
});
std::this_thread::sleep_for(100ms);
REQUIRE(runLoopEntered.load());
daemon_->requestStop();
if (runLoopThread.joinable()) {
runLoopThread.join();
}
REQUIRE(runLoopExited.load());
daemon_->stop();
}
TEST_CASE_METHOD(DaemonFixture, "Daemon async init barrier stress test",
"[daemon][concurrency][.slow]") {
SKIP_ON_WINDOWS();
constexpr int kIterations = 5;
for (int i = 0; i < kIterations; ++i) {
INFO("Iteration " << i);
daemon_ = std::make_unique<YamsDaemon>(config_);
auto result = daemon_->start();
if (!result && isSocketPermissionDenied(result.error())) {
SKIP("UNIX domain sockets not permitted");
}
if (!result) {
std::this_thread::sleep_for(100ms);
result = daemon_->start();
if (!result && isSocketPermissionDenied(result.error())) {
SKIP("UNIX domain sockets not permitted");
}
REQUIRE(result);
}
REQUIRE(daemon_->isRunning());
std::atomic<bool> crashed{false};
std::thread runLoopThread([this, &crashed]() {
try {
daemon_->runLoop();
} catch (...) {
crashed.store(true);
}
});
std::this_thread::sleep_for(50ms);
daemon_->requestStop();
if (runLoopThread.joinable()) {
runLoopThread.join();
}
REQUIRE_FALSE(crashed.load());
daemon_->stop();
daemon_.reset();
std::this_thread::sleep_for(50ms);
}
}
} // namespace yams::daemon::test