yams/tests/integration/daemon/test_daemon_harness.h
Trevon 4e97b31fef
Some checks failed
Fuzz Smoke / libFuzzer smoke (push) Has been cancelled
chore(warnings): clean up compiler warnings on GCC, Clang and Apple Clang (#279)
* chore(warnings): default-initialize aggregate members left out of brace-init

GCC -Wmissing-field-initializers fired about 1300 times where tests and
src build structs with a short positional brace list. Give the omitted
members an empty default member initializer. Value-initialization from
{} is what aggregate init already did for them, so behavior is
unchanged.

Signed-off-by: trvon <me@trevon.dev>

* chore(warnings): remove or annotate unused parameters, functions and variables

- init_command, s3_signer test, request_handler_lifetime test: comment out
  the unused parameter name.
- video_content_handler: path is only read under YAMS_HAVE_FFPROBE.
- vector_memory_scaling_bench: main() takes no arguments.
- daemon_status_render: delete unused neutralText helper.
- DaemonMetrics: mallocStackLoggingNoCompactEnabled is only called in the
  __APPLE__ block, so define it there.
- SocketServer: einval_streak only matters in the __APPLE__ EINVAL path.
- onnx_plugin_harness test, vector_backend_engine_compare bench,
  work_coordinator test: mark platform-conditional or busy-loop
  variables [[maybe_unused]].

Signed-off-by: trvon <git@trevon.dev>

* chore(warnings): fix sign-compare, range-loop, nodiscard and dangling-ref in tests

- search_admission_controller: count started workers in std::uint32_t so
  the comparison with the uint32 cap is same-signed.
- p2p_manager: iterate the invalid-host list by const reference.
- integrity_verifier: discard the nodiscard results the tests do not
  inspect with static_cast<void>.
- post_ingest_queue: findNode/findEdge take std::string_view so no
  temporary std::string is created for the call (GCC
  -Wdangling-reference false positive on the returned reference).
- topology_ablation_bench: the doc comment ended a line with a backslash,
  which continues the comment (-Wcomment); show the env export on its own
  line.

Signed-off-by: trvon <git@trevon.dev>

* chore(warnings): drop redundant std::move of a const captured json

The coroutine lambda is not mutable, so the captured optional<json> is
const and std::move(*progressToken) already selected the copy assignment.
Drop the std::move so the code states what actually happens.

Signed-off-by: trvon <git@trevon.dev>

* chore(warnings): mark __int128 use in db_integrity_stamp as an extension

-Wpedantic flags __int128. Use a __extension__ typedef, the GCC/Clang
idiom for an intentional extension, instead of the bare keyword.

Signed-off-by: trvon <git@trevon.dev>

* chore(warnings): remove always-true unsigned >= 0 comparisons

GCC -Wtype-limits flagged 51 test assertions of the form
CHECK(unsignedValue >= 0) that can never fail, plus a dead
`totalCount.value() < 0` clause in the graph listByType handler (the
count is std::size_t). In the tests keep the evaluation as a
static_cast<void> so the accessors are still exercised; in the handler
drop the dead clause and keep the range check.

Signed-off-by: trvon <me@trevon.dev>

---------

Signed-off-by: trvon <me@trevon.dev>
Signed-off-by: trvon <git@trevon.dev>
2026-10-08 14:30:28 -06:00

714 lines
31 KiB
C++

// Lightweight RAII harness to start/stop a YamsDaemon for integration tests
#pragma once
#include <spdlog/sinks/stdout_color_sinks.h>
#include <spdlog/spdlog.h>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <functional>
#include <map>
#include <optional>
#include <random>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#include <yams/compat/unistd.h>
#include <yams/daemon/client/asio_connection_pool.h>
#include <yams/daemon/client/daemon_client.h>
#include <yams/daemon/client/global_io_context.h>
#include <yams/daemon/daemon.h>
#include <yams/daemon/ipc/socket_utils.h>
#include <boost/asio/io_context.hpp>
#include <boost/asio/local/stream_protocol.hpp>
#include "../../common/test_helpers_catch2.h"
// Windows daemon IPC tests are currently unstable due to socket shutdown race conditions
// The daemon's connection handler coroutines crash during cleanup when sockets are forcibly closed
// Windows daemon IPC does not yet support this path.
#ifdef _WIN32
#define SKIP_DAEMON_TEST_ON_WINDOWS() \
SKIP("Daemon IPC tests unstable on Windows - see windows-daemon-ipc-plan.md")
#else
#define SKIP_DAEMON_TEST_ON_WINDOWS() ((void)0)
#endif
namespace yams::test {
// Options struct must be defined outside DaemonHarness to avoid Clang issue with
// default member initializers in nested structs used as default function arguments.
// See: https://bugs.llvm.org/show_bug.cgi?id=36684
struct DaemonHarnessOptions {
bool enableModelProvider = true;
bool useMockModelProvider = true;
bool autoLoadPlugins = false;
bool pluginDirStrict = false;
bool enableAutoRepair = true;
bool isolateState = false;
bool isolateConfig = false;
// Full process isolation for benchmarks and tests that must never observe the developer's
// machine: implies isolateState and isolateConfig, points HOME and every XDG_* base dir at
// the run's temp root (XDG_RUNTIME_DIR on POSIX; APPDATA/LOCALAPPDATA/USERPROFILE on
// Windows), and scrubs ambient YAMS_CONFIG/YAMS_CONFIG_PATH/YAMS_DATA_DIR/YAMS_STORAGE/
// YAMS_DAEMON_SOCKET[_PATH] plus the model/backend overrides that ConfigResolver ranks above
// TOML (YAMS_EMBED_BACKEND, YAMS_PREFERRED_MODEL, YAMS_RERANKER_MODEL, YAMS_EMBED_DIM) for
// the lifetime of the daemon. Everything is restored when the daemon stops. An explicit
// `configPath` still wins over the generated minimal config.
bool isolateEnvironment = false;
std::string isolatedConfigContents{};
bool requireReadyLifecycle = false;
bool skipSocketVerificationOnReady = false;
bool configureModelPool = false;
bool modelPoolLazyLoading = true;
std::vector<std::string> preloadModels = {};
std::optional<std::filesystem::path> pluginDir = std::nullopt;
// Per-plugin configuration: plugin name -> JSON config string
std::map<std::string, std::string> pluginConfigs = {};
// Override harness paths when a benchmark needs every artifact under one fixture root.
// `preserveRoot` keeps caller-owned fixture roots while generated roots remain RAII-owned.
std::optional<std::filesystem::path> rootDir = std::nullopt;
bool preserveRoot = false;
std::optional<std::filesystem::path> dataDir = std::nullopt;
std::optional<std::filesystem::path> socketPath = std::nullopt;
std::optional<std::filesystem::path> pidFile = std::nullopt;
std::optional<std::filesystem::path> logFile = std::nullopt;
// Optional fixture content to seed `dataDir` with. When both are set
// and `dataDir` exists but is empty (or doesn't exist), the harness
// recursively copies the fixture's contents into `dataDir` before
// starting the daemon. If `dataDir` is non-empty, no copy is performed
// (lets subsequent spawns reuse the seeded scratch dir, preserving
// tuner state / topology artifacts across runs). Useful for benches
// that want a writable scratch seeded from a frozen fixture.
std::optional<std::filesystem::path> cloneFixtureFrom = std::nullopt;
// Optional explicit config.toml path for daemon startup.
// When set, the harness points daemon config resolution at this file.
std::optional<std::filesystem::path> configPath = std::nullopt;
// Additional trusted plugin search paths (appended to DaemonConfig::trustedPluginPaths)
std::vector<std::filesystem::path> trustedPluginPaths = {};
// Optional test hook to mutate the daemon config before construction.
std::function<void(yams::daemon::DaemonConfig&)> configureDaemon{};
};
class DaemonHarness {
public:
using Options = DaemonHarnessOptions;
// Options preset for benchmarks: full environment isolation with caller-owned overrides
// layered on top.
static Options isolatedOptions() {
Options options;
options.isolateEnvironment = true;
return options;
}
explicit DaemonHarness(Options options = Options()) : options_(std::move(options)) {
namespace fs = std::filesystem;
// Ensure mock provider and disable ABI plugins for stability
// Create temp working dir
auto id = random_id();
root_ =
options_.rootDir.value_or(fs::temp_directory_path() / (std::string("yams_it_") + id));
ownsRoot_ = !options_.preserveRoot;
fs::create_directories(root_);
// Use caller-provided dataDir if set (e.g. existing corpus), else create temp.
// Root ownership is tracked independently, so external data remains untouched.
if (options_.dataDir) {
data_ = *options_.dataDir;
fs::create_directories(data_);
} else {
data_ = root_ / "data";
fs::create_directories(data_);
}
// Seed scratch from a fixture when requested AND the scratch is empty.
// Subsequent spawns reuse the existing content (preserves tuner state,
// topology artifacts, etc. across reps).
if (options_.cloneFixtureFrom) {
std::error_code ec;
const auto& src = *options_.cloneFixtureFrom;
bool dataDirEmpty = true;
if (fs::exists(data_)) {
for (auto it = fs::directory_iterator(data_, ec);
!ec && it != fs::directory_iterator(); ++it) {
dataDirEmpty = false;
break;
}
}
if (dataDirEmpty && fs::exists(src, ec)) {
fs::copy(src, data_,
fs::copy_options::recursive | fs::copy_options::copy_symlinks |
fs::copy_options::skip_existing,
ec);
if (ec) {
spdlog::warn("[DaemonHarness] cloneFixtureFrom copy failed: {} ({} -> {})",
ec.message(), src.string(), data_.string());
} else {
spdlog::info("[DaemonHarness] seeded {} from fixture {}", data_.string(),
src.string());
}
}
}
// Use platform-appropriate temp path for socket
// On Windows, AF_UNIX sockets work but need a valid Windows path
// On Unix, use /tmp for short paths to avoid AF_UNIX length limits
#ifdef _WIN32
const auto defaultSocket = fs::temp_directory_path() / ("daemon_" + id + ".sock");
#else
const auto defaultSocket = std::filesystem::path("/tmp") / ("daemon_" + id + ".sock");
#endif
sock_ = options_.socketPath.value_or(defaultSocket);
proxySock_ = yams::daemon::socket_utils::derive_proxy_socket_path(sock_);
pid_ = options_.pidFile.value_or(root_ / ("daemon_" + id + ".pid"));
log_ = options_.logFile.value_or(root_ / ("daemon_" + id + ".log"));
ownershipMarker_ = sock_;
ownershipMarker_ += ".harness-owner";
// NOTE: Do NOT create daemon instance in constructor!
// Creating YamsDaemon initializes ServiceManager which spawns worker threads.
// If start() is never called but destructor runs, thread cleanup can crash.
// Defer daemon creation until start() is explicitly called.
}
~DaemonHarness() {
// Explicitly stop daemon before cleanup to avoid crashes
stop();
cleanup();
}
// Pre-runLoop callback type: called after daemon->start() succeeds but before runLoop thread
// starts. Use this to set signal check hooks or external shutdown flags BEFORE the runLoop
// begins reading them, avoiding race conditions.
using PreRunLoopCallback = std::function<void(yams::daemon::YamsDaemon*)>;
bool start(std::chrono::milliseconds timeout = std::chrono::seconds(10),
PreRunLoopCallback preRunLoopCallback = nullptr) {
try {
return startUnchecked(timeout, std::move(preRunLoopCallback));
} catch (const std::exception& error) {
spdlog::error("[DaemonHarness] Startup threw: {}", error.what());
} catch (...) {
spdlog::error("[DaemonHarness] Startup threw an unknown exception");
}
rollbackFailedStart();
return false;
}
private:
bool startUnchecked(std::chrono::milliseconds timeout, PreRunLoopCallback preRunLoopCallback) {
ensureDefaultLogger();
if (daemon_ || runLoopThread_.joinable()) {
spdlog::error("[DaemonHarness] start called while a prior start is active");
return false;
}
if (!claimEndpointNamespace()) {
return false;
}
shutdownSucceeded_ = false;
startAttempted_ = true;
// Always create new daemon instance on start()
// (daemon cannot be restarted after stop() - must create new instance)
spdlog::info("[DaemonHarness] Creating new daemon instance...");
const bool isolateConfig = options_.isolateConfig || options_.isolateEnvironment;
if (options_.isolateEnvironment) {
isolateAmbientEnvironment();
}
if (options_.isolateState || options_.isolateEnvironment) {
originalCwd_ = std::filesystem::current_path();
std::filesystem::current_path(root_);
xdgStateEnvironment_.emplace("XDG_STATE_HOME", (root_ / "state").string());
std::filesystem::create_directories(root_ / "state" / "yams" / "sessions");
isolateStateActive_ = true;
}
auto effectiveConfigPath = options_.configPath;
if (!effectiveConfigPath && isolateConfig) {
const auto configDirectory = root_ / "config" / "yams";
std::filesystem::create_directories(configDirectory);
effectiveConfigPath = configDirectory / "config.toml";
std::ofstream configFile(*effectiveConfigPath, std::ios::trunc);
if (!configFile) {
spdlog::error("[DaemonHarness] Failed to create isolated config: {}",
effectiveConfigPath->string());
rollbackFailedStart();
return false;
}
configFile << "config_version = 3\n";
if (!options_.isolatedConfigContents.empty()) {
configFile << options_.isolatedConfigContents;
if (options_.isolatedConfigContents.back() != '\n') {
configFile << '\n';
}
}
configFile.close();
xdgConfigEnvironment_.emplace("XDG_CONFIG_HOME", (root_ / "config").string());
}
yams::daemon::DaemonConfig cfg;
if (effectiveConfigPath) {
configEnvironment_.emplace("YAMS_CONFIG", effectiveConfigPath->string());
// ConfigResolver policies use YAMS_CONFIG_PATH as their highest-priority
// source. Keep it aligned with the explicit harness config so an ambient
// developer config cannot silently change a benchmark/test arm.
configPathEnvironment_.emplace("YAMS_CONFIG_PATH", effectiveConfigPath->string());
cfg.configFilePath = *effectiveConfigPath;
}
cfg.dataDir = data_;
cfg.socketPath = sock_;
cfg.pidFile = pid_;
cfg.logFile = log_;
cfg.enableModelProvider = options_.enableModelProvider;
cfg.autoLoadPlugins = options_.autoLoadPlugins;
cfg.pluginDirStrict = options_.pluginDirStrict;
cfg.useMockModelProvider = options_.useMockModelProvider;
cfg.enableAutoRepair = options_.enableAutoRepair;
if (options_.pluginDir) {
cfg.pluginDir = *options_.pluginDir;
}
if (options_.configureModelPool) {
cfg.modelPoolConfig.lazyLoading = options_.modelPoolLazyLoading;
cfg.modelPoolConfig.preloadModels = options_.preloadModels;
}
if (options_.configureDaemon) {
options_.configureDaemon(cfg);
}
// Pass plugin-specific configurations
cfg.pluginConfigs = options_.pluginConfigs;
// Pass trusted plugin paths
cfg.trustedPluginPaths = options_.trustedPluginPaths;
daemon_ = std::make_unique<yams::daemon::YamsDaemon>(cfg);
spdlog::info("[DaemonHarness] Daemon instance created");
spdlog::info("[DaemonHarness] Calling daemon_->start()...");
auto s = daemon_->start();
if (!s) {
spdlog::error("[DaemonHarness] daemon_->start() failed: {}", s.error().message);
stop();
return false;
}
spdlog::info("[DaemonHarness] daemon_->start() succeeded, starting runLoop thread...");
// Call pre-runLoop callback if provided (for setting up signal hooks, etc.)
// This runs BEFORE the runLoop thread starts, avoiding race conditions.
if (preRunLoopCallback) {
spdlog::info("[DaemonHarness] Invoking pre-runLoop callback...");
try {
preRunLoopCallback(daemon_.get());
} catch (const std::exception& error) {
spdlog::error("[DaemonHarness] pre-runLoop callback failed: {}", error.what());
stop();
return false;
} catch (...) {
spdlog::error("[DaemonHarness] pre-runLoop callback failed");
stop();
return false;
}
}
#ifdef YAMS_TESTING
if (!preRunLoopCallback && !options_.enableAutoRepair) {
daemon_->testingStartAsyncInitWithoutRunLoop();
spdlog::info("[DaemonHarness] Async init triggered without runLoop thread, polling for "
"daemon Ready state...");
} else
#endif
{
// Start runLoop in background thread - this triggers async initialization
// Without runLoop(), ServiceManager::startAsyncInit() is never called
runLoopThread_ = std::thread([this]() {
daemon_->runLoop();
auto stopResult = daemon_->stop();
if (!stopResult && daemon_->isRunning()) {
spdlog::warn("[DaemonHarness] Background stop after runLoop returned error: {}",
stopResult.error().message);
}
});
spdlog::info(
"[DaemonHarness] runLoop thread started, polling for daemon Ready state...");
}
if (!waitForUsableLifecycle(timeout)) {
stop();
return false;
}
// Verify socket connectivity as final sanity check
// Use simple socket connection instead of DaemonClient to avoid polluting
// the shared connection pool (DaemonClient destructor shuts down shared pools)
if (options_.skipSocketVerificationOnReady) {
spdlog::info(
"[DaemonHarness] Skipping socket verification after Ready (test override)");
return true;
}
spdlog::info("[DaemonHarness] Verifying socket connectivity at: {}", sock_.string());
bool socketOk = verifySocketConnectivity(sock_, std::chrono::seconds(5));
if (socketOk) {
spdlog::info("[DaemonHarness] Socket connection verified, daemon fully ready at: {}",
sock_.string());
return true;
}
spdlog::error("[DaemonHarness] Daemon Ready but socket connection failed at: {}",
sock_.string());
stop();
return false;
}
public:
[[nodiscard]] bool startWithRetry(std::chrono::milliseconds timeout, std::size_t maxAttempts,
PreRunLoopCallback preRunLoopCallback = nullptr) {
for (std::size_t attempt = 0; attempt < maxAttempts; ++attempt) {
if (start(timeout, preRunLoopCallback)) {
return true;
}
stop();
}
return false;
}
void stop() {
if (yams::config::getenv_optional("YAMS_TRACE_HARNESS_LIFETIME")) {
std::fprintf(stderr, "[DaemonHarness::stop] enter daemon=%p runLoopJoinable=%d\n",
static_cast<void*>(daemon_.get()), runLoopThread_.joinable() ? 1 : 0);
std::fflush(stderr);
}
if (daemon_) {
bool stopCallSucceeded = true;
spdlog::info("[DaemonHarness] Stopping daemon (running={})...", daemon_->isRunning());
if (runLoopThread_.joinable()) {
spdlog::info("[DaemonHarness] Requesting runLoop stop before join...");
daemon_->requestStop();
spdlog::info("[DaemonHarness] Joining runLoop thread...");
runLoopThread_.join();
spdlog::info("[DaemonHarness] runLoop thread joined");
}
// Wait for daemon to fully stop - the background runLoop thread mirrors
// daemon_main by calling stop() after runLoop() returns on remote shutdown.
if (daemon_->isRunning() ||
!daemon_->waitForStopCompletion(std::chrono::milliseconds(0))) {
auto stopResult = daemon_->stop();
stopCallSucceeded = static_cast<bool>(stopResult);
if (!stopResult) {
spdlog::warn("[DaemonHarness] Daemon stop returned error: {}",
stopResult.error().message);
}
}
const bool stopped =
wait_for_condition(std::chrono::seconds(2), std::chrono::milliseconds(10),
[this] { return !daemon_->isRunning(); });
spdlog::info("[DaemonHarness] Daemon stopped (running={}), resetting instance...",
daemon_->isRunning());
// Reset GlobalIOContext to clean up threads and io_context state
// This is critical for test isolation - without it, threads accumulate across test
// cases. Temporarily unset test sentinels through the shared environment boundary so
// GlobalIOContext::reset() actually performs cleanup, then restore their exact prior
// values when the guards leave scope.
{
ScopedEnvVar testingEnvironment{"YAMS_TESTING", std::nullopt};
ScopedEnvVar safeEnvironment{"YAMS_TEST_SAFE_SINGLE_INSTANCE", std::nullopt};
// Reset the global client IO state BEFORE restarting threads.
// This closes ConnectionRegistry sockets and clears pools while the
// existing io_context is still alive, preventing stale client FDs
// from accumulating across harness cycles.
yams::daemon::GlobalIOContext::reset();
spdlog::info("[DaemonHarness] GlobalIOContext reset complete");
}
// Windows needs additional time before GlobalIOContext::reset()
// Windows thread cleanup is slower than Unix
#ifdef _WIN32
// Skip GlobalIOContext operations on Windows for now - causes SIGSEGV.
spdlog::info("[DaemonHarness] Skipping GlobalIOContext::restart() on Windows");
#else
spdlog::info("[DaemonHarness] GlobalIOContext restart deferred");
#endif
// Destroy environment-owning daemon components before restoring the harness guards.
// Otherwise a nested component destructor can restore the isolated value over the
// caller's original process environment.
daemon_.reset();
restoreProcessState();
if (yams::config::getenv_optional("YAMS_TRACE_HARNESS_LIFETIME")) {
std::fprintf(stderr, "[DaemonHarness::stop] exit daemon=%p runLoopJoinable=%d\n",
static_cast<void*>(daemon_.get()), runLoopThread_.joinable() ? 1 : 0);
std::fflush(stderr);
}
const bool artifactsRemoved = waitForOwnedArtifactsRemoved(std::chrono::seconds(2));
shutdownSucceeded_ = stopCallSucceeded && stopped && artifactsRemoved;
if (!artifactsRemoved) {
spdlog::warn("[DaemonHarness] Owned endpoint artifacts remained after shutdown; "
"removing the harness-owned namespace");
removeOwnedArtifacts();
}
releaseEndpointNamespace();
startAttempted_ = false;
spdlog::info("[DaemonHarness] Stop complete (stopped={}, artifactsRemoved={})", stopped,
artifactsRemoved);
}
}
const std::filesystem::path& socketPath() const { return sock_; }
const std::filesystem::path& proxySocketPath() const { return proxySock_; }
const std::filesystem::path& pidPath() const { return pid_; }
const std::filesystem::path& dataDir() const { return data_; }
const std::filesystem::path& rootDir() const { return root_; }
bool shutdownSucceeded() const { return shutdownSucceeded_; }
yams::daemon::YamsDaemon* daemon() const { return daemon_.get(); }
private:
bool waitForUsableLifecycle(std::chrono::milliseconds timeout) const {
const bool reachedTerminal =
wait_for_condition(timeout, std::chrono::milliseconds(20), [this] {
const auto state = daemon_->getLifecycle().snapshot().state;
return state == yams::daemon::LifecycleState::Ready ||
(!options_.requireReadyLifecycle &&
state == yams::daemon::LifecycleState::Degraded) ||
state == yams::daemon::LifecycleState::Failed;
});
const auto lifecycle = daemon_->getLifecycle().snapshot();
const bool usable = lifecycle.state == yams::daemon::LifecycleState::Ready ||
(!options_.requireReadyLifecycle &&
lifecycle.state == yams::daemon::LifecycleState::Degraded);
if (usable) {
spdlog::info("[DaemonHarness] Daemon lifecycle reached usable state: {}",
static_cast<int>(lifecycle.state));
return true;
}
if (lifecycle.state == yams::daemon::LifecycleState::Failed) {
spdlog::error("[DaemonHarness] Daemon lifecycle reached Failed state: {}",
lifecycle.lastError);
return false;
}
spdlog::error("[DaemonHarness] {} waiting for usable lifecycle after {}ms "
"(current state: {})",
reachedTerminal ? "Rejected lifecycle state while" : "Timeout",
timeout.count(), static_cast<int>(lifecycle.state));
return false;
}
// Install a default spdlog logger once per process if none exists, so plugins
// and harness code that call spdlog::info(...) don't dereference a null default
// logger. Mirrors daemon_main's init order (set_default_logger before plugin load).
static void ensureDefaultLogger() {
static const bool once = [] {
if (!spdlog::default_logger()) {
try {
auto sink = std::make_shared<spdlog::sinks::stdout_color_sink_mt>();
auto logger = std::make_shared<spdlog::logger>("yams-test", sink);
spdlog::set_default_logger(logger);
} catch (const std::exception& error) {
std::fprintf(stderr, "DaemonHarness logger setup failed: %s\n", error.what());
} catch (...) {
std::fputs("DaemonHarness logger setup failed with an unknown error\n", stderr);
}
}
return true;
}();
(void)once;
}
static std::string random_id() {
static const char* cs = "abcdefghijklmnopqrstuvwxyz0123456789";
thread_local std::mt19937_64 rng{std::random_device{}()};
std::uniform_int_distribution<size_t> dist(0, 35);
std::string out;
out.reserve(8);
for (int i = 0; i < 8; ++i)
out.push_back(cs[dist(rng)]);
return out;
}
// Simple socket connectivity check that doesn't use the shared connection pool
// This avoids DaemonClient's destructor from shutting down the pool before
// the actual test fixture's client can use it
static bool verifySocketConnectivity(const std::filesystem::path& socketPath,
std::chrono::milliseconds timeout) {
auto deadline = std::chrono::steady_clock::now() + timeout;
while (std::chrono::steady_clock::now() < deadline) {
try {
// Create fresh socket for each attempt (can't reuse after failed connect)
boost::asio::io_context io;
boost::asio::local::stream_protocol::socket sock(io);
boost::system::error_code ec;
ec = sock.connect(
boost::asio::local::stream_protocol::endpoint(socketPath.string()), ec);
if (!ec) {
sock.close();
return true;
}
} catch (const std::exception& e) {
spdlog::debug("[DaemonHarness] Socket verification attempt failed: {}", e.what());
}
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
spdlog::error("[DaemonHarness] Socket verification timed out after {}ms", timeout.count());
return false;
}
bool claimEndpointNamespace() {
std::error_code ec;
if (!std::filesystem::create_directory(ownershipMarker_, ec)) {
spdlog::error("[DaemonHarness] Socket namespace is already claimed: {} ({})",
sock_.string(), ec ? ec.message() : "ownership marker exists");
return false;
}
endpointClaimed_ = true;
if (std::filesystem::exists(sock_) || std::filesystem::exists(proxySock_) ||
std::filesystem::exists(pid_)) {
spdlog::error("[DaemonHarness] Refusing pre-existing daemon artifacts in claimed "
"namespace: {}",
sock_.string());
releaseEndpointNamespace();
return false;
}
return true;
}
void releaseEndpointNamespace() noexcept {
if (!endpointClaimed_) {
return;
}
std::error_code ec;
std::filesystem::remove(ownershipMarker_, ec);
endpointClaimed_ = false;
}
bool waitForOwnedArtifactsRemoved(std::chrono::milliseconds timeout) const {
if (!endpointClaimed_) {
return true;
}
return wait_for_condition(timeout, std::chrono::milliseconds(20), [this] {
return !std::filesystem::exists(sock_) && !std::filesystem::exists(proxySock_) &&
!std::filesystem::exists(pid_);
});
}
void removeOwnedArtifacts() noexcept {
if (!endpointClaimed_) {
return;
}
std::error_code ec;
std::filesystem::remove(sock_, ec);
ec.clear();
std::filesystem::remove(proxySock_, ec);
ec.clear();
std::filesystem::remove(pid_, ec);
}
void rollbackFailedStart() noexcept {
try {
stop();
} catch (const std::exception& error) {
std::fprintf(stderr, "DaemonHarness rollback stop failed: %s\n", error.what());
} catch (...) {
std::fputs("DaemonHarness rollback stop failed with an unknown exception\n", stderr);
}
restoreProcessState();
removeOwnedArtifacts();
releaseEndpointNamespace();
startAttempted_ = false;
}
// Give the run a private HOME/XDG tree under root_ and hide ambient path overrides. The
// config/data/socket variables are scrubbed here; the daemon re-seeds them from its explicit
// DaemonConfig paths.
void isolateAmbientEnvironment() {
namespace fs = std::filesystem;
static constexpr const char* kScrubbed[] = {
"YAMS_CONFIG",
"YAMS_CONFIG_PATH",
"YAMS_DATA_DIR",
"YAMS_STORAGE",
"YAMS_DAEMON_SOCKET",
"YAMS_DAEMON_SOCKET_PATH",
// ConfigResolver gives these precedence over TOML, so the generated config would not
// be deterministic if the developer's shell exported them.
"YAMS_EMBED_BACKEND",
"YAMS_PREFERRED_MODEL",
"YAMS_RERANKER_MODEL",
"YAMS_EMBED_DIM",
};
for (const char* name : kScrubbed) {
isolationEnvironment_.emplace_back(name, std::nullopt);
}
const std::pair<const char*, fs::path> relocated[] = {
{"HOME", root_ / "home"},
{"XDG_CONFIG_HOME", root_ / "config"},
{"XDG_STATE_HOME", root_ / "state"},
{"XDG_DATA_HOME", root_ / "xdg_data"},
{"XDG_CACHE_HOME", root_ / "cache"},
#ifdef _WIN32
{"APPDATA", root_ / "appdata" / "roaming"},
{"LOCALAPPDATA", root_ / "appdata" / "local"},
{"USERPROFILE", root_ / "home"},
#else
{"XDG_RUNTIME_DIR", root_ / "runtime"},
#endif
};
for (const auto& [name, dir] : relocated) {
std::error_code ec;
fs::create_directories(dir, ec);
isolationEnvironment_.emplace_back(name, dir.string());
}
}
void restoreProcessState() noexcept {
if (isolateStateActive_) {
std::error_code ec;
if (!originalCwd_.empty()) {
std::filesystem::current_path(originalCwd_, ec);
}
xdgStateEnvironment_.reset();
isolateStateActive_ = false;
}
configPathEnvironment_.reset();
configEnvironment_.reset();
xdgConfigEnvironment_.reset();
// Reverse order so each guard restores the value it captured.
while (!isolationEnvironment_.empty()) {
isolationEnvironment_.pop_back();
}
}
void cleanup() {
removeOwnedArtifacts();
releaseEndpointNamespace();
if (ownsRoot_ && !root_.empty()) {
std::error_code ec;
std::filesystem::remove_all(root_, ec);
}
}
std::unique_ptr<yams::daemon::YamsDaemon> daemon_;
std::thread runLoopThread_; // Background thread for runLoop
std::filesystem::path root_, data_, sock_, proxySock_, pid_, log_;
std::filesystem::path ownershipMarker_;
std::filesystem::path originalCwd_;
bool ownsRoot_ = true;
bool endpointClaimed_ = false;
bool shutdownSucceeded_ = false;
bool startAttempted_ = false;
std::optional<ScopedEnvVar> xdgStateEnvironment_;
std::optional<ScopedEnvVar> xdgConfigEnvironment_;
std::optional<ScopedEnvVar> configEnvironment_;
std::optional<ScopedEnvVar> configPathEnvironment_;
std::vector<ScopedEnvVar> isolationEnvironment_;
bool isolateStateActive_ = false;
Options options_;
};
} // namespace yams::test