643 lines
28 KiB
C++
643 lines
28 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 <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;
|
|
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;
|
|
|
|
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...");
|
|
|
|
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<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;
|
|
}
|
|
|
|
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<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_;
|
|
bool isolateStateActive_ = false;
|
|
Options options_;
|
|
};
|
|
|
|
} // namespace yams::test
|