// Lightweight RAII harness to start/stop a YamsDaemon for integration tests #pragma once #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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; std::string isolatedConfigContents; bool requireReadyLifecycle = false; bool skipSocketVerificationOnReady = false; bool configureModelPool = false; bool modelPoolLazyLoading = true; std::vector preloadModels = {}; std::optional pluginDir = std::nullopt; // Per-plugin configuration: plugin name -> JSON config string std::map 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 rootDir = std::nullopt; bool preserveRoot = false; std::optional dataDir = std::nullopt; std::optional socketPath = std::nullopt; std::optional pidFile = std::nullopt; std::optional 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 cloneFixtureFrom = std::nullopt; // Optional explicit config.toml path for daemon startup. // When set, the harness points daemon config resolution at this file. std::optional configPath = std::nullopt; // Additional trusted plugin search paths (appended to DaemonConfig::trustedPluginPaths) std::vector trustedPluginPaths = {}; // Optional test hook to mutate the daemon config before construction. std::function configureDaemon; }; class DaemonHarness { public: using Options = DaemonHarnessOptions; 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; 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..."); if (options_.isolateState) { 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 && options_.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(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(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(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(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(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(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(); auto logger = std::make_shared("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 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; } 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(); } void cleanup() { removeOwnedArtifacts(); releaseEndpointNamespace(); if (ownsRoot_ && !root_.empty()) { std::error_code ec; std::filesystem::remove_all(root_, ec); } } std::unique_ptr 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 xdgStateEnvironment_; std::optional xdgConfigEnvironment_; std::optional configEnvironment_; std::optional configPathEnvironment_; bool isolateStateActive_ = false; Options options_; }; } // namespace yams::test