Some checks failed
Fuzz Smoke / libFuzzer smoke (push) Has been cancelled
* 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>
714 lines
31 KiB
C++
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
|