* fix(graph): handle qualified C++ symbols Signed-off-by: trvon <me@trevon.dev> * refactor(vector)!: remove dead transition APIs Split Simeon vector and retrieval linkage so consumers inherit only the runtime surface they use. BREAKING CHANGE: remove unused async/model/config helpers and the undefined vector-backend factory from public headers. Signed-off-by: trvon <me@trevon.dev> * fix(vector): harden PQ snapshot lifecycle Validate and atomically install persisted generations, revalidate after races, and fall back to exact search when a snapshot is stale. Add the exact-scan reference lane and distinguish observed work counters from unavailable backend metrics. Signed-off-by: trvon <me@trevon.dev> * feat(topology): expose route coordinates Persist chart distortion and local-dimension evidence, retain pre-scalarization route axes, and emit detailed trace rows without treating unavailable vector work as zero. Signed-off-by: trvon <me@trevon.dev> * feat(xplan): add vector decision gates Track binary and corpus identity, preserve missing-work semantics, and add repeated PQ routing, crossover, and rerank plans with quality and cost summaries. Signed-off-by: trvon <me@trevon.dev> * feat(topology): formalize coordinate routing Separate chart geometry from route-risk admission and prove candidate-stage recall and strict-focus boundaries. Record the measured atlas limitations and literature basis for the next construction experiment. Signed-off-by: trvon <me@trevon.dev> * fix(search): serialize SymSpell readers SQLite-backed lookups reuse mutable prepared statements, so shared-reader locking is unsafe. Advance the owned SymSpell revision for batched delete insertion and cover concurrent root lookups under TSAN. Signed-off-by: trvon <me@trevon.dev> * fix(vector): make mutations atomic Preserve stored vectors on replacement and commit failures, reject cross-thread writes during explicit transactions, and restore exact reranking for quantized-primary PQ rows. * fix(search)!: surface SymSpell write errors Propagate persistent dictionary failures through metadata ingestion, roll back failed batches, and make clear remove stored terms. BREAKING CHANGE: SymSpellSearch mutations now return Result values. * feat(topology): formalize candidate expansion * feat(vector): add coordinate-aware embeddings * feat(topology): preserve protected relation cover * feat(search): add graph candidate rescue * feat(daemon): expose retrieval hydration telemetry * feat(xplan): add rescue and hydration gates * refactor(content): dedupe media handlers Reuse shared graph presenter helpers and centralize repeated audio/video/archive buffer processing, validation, lifecycle, and stats helper logic. * feat(lifecycle): prune stale corpus data Keep cleanup preview-first while retention stays opt-in and long prune work no longer holds IPC responses open. * feat(search): add topology candidate rescue Preserve high-value routed candidates across vector backends while keeping exact rerank diagnostics explicit. * feat(search): expose evidence paths * docs: define mobile host contract * formal: model bounded candidate rescue * fix(prune): inspect extensionless signatures * fix(prune): tolerate stale extensions --------- Signed-off-by: trvon <me@trevon.dev>
2530 lines
99 KiB
C++
2530 lines
99 KiB
C++
// Comprehensive daemon socket client integration test
|
|
// Covers connection lifecycle, reconnection, timeouts, multiplexing, and error handling
|
|
|
|
#define CATCH_CONFIG_MAIN
|
|
#include <catch2/catch_session.hpp>
|
|
#include <catch2/catch_test_macros.hpp>
|
|
#include <catch2/generators/catch_generators.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <future>
|
|
#include <mutex>
|
|
#include <optional>
|
|
#include <set>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <thread>
|
|
|
|
#include "../../common/test_helpers_catch2.h"
|
|
#include "test_async_helpers.h"
|
|
#include "test_daemon_harness.h"
|
|
#include <boost/asio.hpp>
|
|
#include <yams/app/services/session_service.hpp>
|
|
#include <yams/daemon/client/daemon_client.h>
|
|
#include <yams/daemon/client/global_io_context.h>
|
|
#include <yams/daemon/components/RequestDispatcher.h>
|
|
#include <yams/daemon/components/ServiceManager.h>
|
|
#include <yams/daemon/ipc/ipc_protocol.h>
|
|
#include <yams/daemon/resource/model_provider.h>
|
|
#include <yams/metadata/metadata_repository.h>
|
|
|
|
using namespace yams::daemon;
|
|
using namespace yams::test;
|
|
using namespace std::chrono_literals;
|
|
|
|
namespace {
|
|
class EnvGuard {
|
|
public:
|
|
EnvGuard(const std::string& envName, const std::string& newValue) : name_(envName) {
|
|
if (const char* orig = std::getenv(name_.c_str())) {
|
|
originalValue_ = orig;
|
|
}
|
|
#ifdef _WIN32
|
|
_putenv_s(name_.c_str(), newValue.c_str());
|
|
#else
|
|
setenv(name_.c_str(), newValue.c_str(), 1);
|
|
#endif
|
|
}
|
|
|
|
~EnvGuard() {
|
|
if (originalValue_) {
|
|
#ifdef _WIN32
|
|
_putenv_s(name_.c_str(), originalValue_->c_str());
|
|
#else
|
|
setenv(name_.c_str(), originalValue_->c_str(), 1);
|
|
#endif
|
|
} else {
|
|
#ifdef _WIN32
|
|
_putenv_s(name_.c_str(), "");
|
|
#else
|
|
unsetenv(name_.c_str());
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private:
|
|
std::string name_;
|
|
std::optional<std::string> originalValue_;
|
|
};
|
|
|
|
class StubModelProvider : public yams::daemon::IModelProvider {
|
|
public:
|
|
yams::Result<std::vector<float>> generateEmbedding(const std::string& text) override {
|
|
return std::vector<float>(getEmbeddingDim(""), static_cast<float>(text.size()));
|
|
}
|
|
|
|
yams::Result<std::vector<std::vector<float>>>
|
|
generateBatchEmbeddings(const std::vector<std::string>& texts) override {
|
|
std::vector<std::vector<float>> out;
|
|
out.reserve(texts.size());
|
|
for (const auto& text : texts) {
|
|
auto one = generateEmbedding(text);
|
|
if (!one) {
|
|
return one.error();
|
|
}
|
|
out.push_back(std::move(one.value()));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
yams::Result<std::vector<float>> generateEmbeddingFor(const std::string& modelName,
|
|
const std::string& text) override {
|
|
if (!isModelLoaded(modelName)) {
|
|
return yams::Error{yams::ErrorCode::NotFound,
|
|
"Requested model not loaded: " + modelName};
|
|
}
|
|
return generateEmbedding(text);
|
|
}
|
|
|
|
yams::Result<std::vector<std::vector<float>>>
|
|
generateBatchEmbeddingsFor(const std::string& modelName,
|
|
const std::vector<std::string>& texts) override {
|
|
if (!isModelLoaded(modelName)) {
|
|
return yams::Error{yams::ErrorCode::NotFound,
|
|
"Requested model not loaded: " + modelName};
|
|
}
|
|
return generateBatchEmbeddings(texts);
|
|
}
|
|
|
|
yams::Result<void> loadModel(const std::string& modelName) override {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
lastLoadWithOptionsModel_.clear();
|
|
lastLoadWithOptionsJson_.clear();
|
|
if (!available_) {
|
|
return yams::Error{yams::ErrorCode::InvalidState, "Provider unavailable"};
|
|
}
|
|
if (std::find(loadedModels_.begin(), loadedModels_.end(), modelName) ==
|
|
loadedModels_.end()) {
|
|
loadedModels_.push_back(modelName);
|
|
}
|
|
return yams::Result<void>();
|
|
}
|
|
|
|
yams::Result<void> loadModelWithOptions(const std::string& modelName,
|
|
const std::string& optionsJson) override {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
if (!available_) {
|
|
return yams::Error{yams::ErrorCode::InvalidState, "Provider unavailable"};
|
|
}
|
|
lastLoadWithOptionsModel_ = modelName;
|
|
lastLoadWithOptionsJson_ = optionsJson;
|
|
if (std::find(loadedModels_.begin(), loadedModels_.end(), modelName) ==
|
|
loadedModels_.end()) {
|
|
loadedModels_.push_back(modelName);
|
|
}
|
|
return yams::Result<void>();
|
|
}
|
|
|
|
yams::Result<void> unloadModel(const std::string& modelName) override {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
auto it = std::find(loadedModels_.begin(), loadedModels_.end(), modelName);
|
|
if (it == loadedModels_.end()) {
|
|
return yams::Error{yams::ErrorCode::NotFound, "Model not loaded: " + modelName};
|
|
}
|
|
loadedModels_.erase(it);
|
|
return yams::Result<void>();
|
|
}
|
|
|
|
bool isModelLoaded(const std::string& modelName) const override {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
return std::find(loadedModels_.begin(), loadedModels_.end(), modelName) !=
|
|
loadedModels_.end();
|
|
}
|
|
|
|
std::vector<std::string> getLoadedModels() const override {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
return loadedModels_;
|
|
}
|
|
size_t getLoadedModelCount() const override {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
return loadedModels_.size();
|
|
}
|
|
|
|
yams::Result<yams::daemon::ModelInfo>
|
|
getModelInfo(const std::string& modelName) const override {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
if (std::find(loadedModels_.begin(), loadedModels_.end(), modelName) ==
|
|
loadedModels_.end()) {
|
|
return yams::Error{yams::ErrorCode::NotFound, "Model not loaded: " + modelName};
|
|
}
|
|
yams::daemon::ModelInfo info;
|
|
info.name = modelName;
|
|
info.embeddingDim = 384;
|
|
info.maxSequenceLength = 512;
|
|
info.memoryUsageBytes = 32 * 1024 * 1024;
|
|
return info;
|
|
}
|
|
|
|
size_t getEmbeddingDim(const std::string&) const override { return 384; }
|
|
|
|
std::shared_ptr<yams::vector::EmbeddingGenerator>
|
|
getEmbeddingGenerator(const std::string& = "") override {
|
|
return nullptr;
|
|
}
|
|
|
|
std::string getProviderName() const override { return "StubModelProvider"; }
|
|
std::string getProviderVersion() const override { return "1.0-test"; }
|
|
bool isAvailable() const override {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
return available_;
|
|
}
|
|
size_t getMemoryUsage() const override {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
return loadedModels_.size() * 32 * 1024 * 1024;
|
|
}
|
|
void releaseUnusedResources() override {}
|
|
void shutdown() override {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
available_ = false;
|
|
}
|
|
void setAvailable(bool available) {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
available_ = available;
|
|
}
|
|
std::string lastLoadWithOptionsModel() const {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
return lastLoadWithOptionsModel_;
|
|
}
|
|
std::string lastLoadWithOptionsJson() const {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
return lastLoadWithOptionsJson_;
|
|
}
|
|
|
|
private:
|
|
mutable std::mutex mutex_;
|
|
bool available_{true};
|
|
std::vector<std::string> loadedModels_;
|
|
std::string lastLoadWithOptionsModel_;
|
|
std::string lastLoadWithOptionsJson_;
|
|
};
|
|
|
|
// Helper to create a client with custom config
|
|
// Increased default timeouts to handle thread resource pressure from repeated daemon restarts
|
|
DaemonClient createClient(const std::filesystem::path& socketPath,
|
|
std::chrono::milliseconds connectTimeout = 5s, bool autoStart = false) {
|
|
ClientConfig config;
|
|
config.socketPath = socketPath;
|
|
config.connectTimeout = connectTimeout;
|
|
config.autoStart = autoStart;
|
|
config.requestTimeout = 10s;
|
|
return DaemonClient(config);
|
|
}
|
|
|
|
void startHarnessWithRetry(DaemonHarness& harness, int maxRetries = 3,
|
|
std::chrono::milliseconds retryDelay = 250ms) {
|
|
for (int attempt = 0; attempt < maxRetries; ++attempt) {
|
|
if (harness.start(30s)) {
|
|
return;
|
|
}
|
|
harness.stop();
|
|
std::this_thread::sleep_for(retryDelay);
|
|
}
|
|
|
|
SKIP("Skipping socket integration section due to daemon startup instability");
|
|
}
|
|
|
|
// Helper to retry get request with delay - handles async post-ingest processing
|
|
// streamingAddDocument returns immediately but document may not be available until
|
|
// post-ingest completes (FTS5 indexing, metadata extraction, etc.)
|
|
template <typename Client>
|
|
yams::Result<GetResponse> getWithRetry(Client& client, const std::string& hash, int maxRetries = 10,
|
|
std::chrono::milliseconds retryDelay = 100ms) {
|
|
GetRequest getReq;
|
|
getReq.hash = hash;
|
|
|
|
for (int i = 0; i < maxRetries; ++i) {
|
|
auto getResult = yams::cli::run_sync(client.get(getReq), 5s);
|
|
if (getResult.has_value()) {
|
|
return getResult;
|
|
}
|
|
std::this_thread::sleep_for(retryDelay);
|
|
}
|
|
// Return last attempt's result
|
|
return yams::cli::run_sync(client.get(getReq), 5s);
|
|
}
|
|
|
|
DownloadResponse waitForDownloadJobStatus(DaemonClient& client, const std::string& jobId,
|
|
std::chrono::milliseconds timeout = 5s,
|
|
std::chrono::milliseconds pollInterval = 25ms) {
|
|
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
|
DownloadResponse latest;
|
|
latest.jobId = jobId;
|
|
latest.state = "unknown";
|
|
|
|
while (std::chrono::steady_clock::now() < deadline) {
|
|
auto statusResult =
|
|
yams::cli::run_sync(client.downloadStatus(DownloadStatusRequest{jobId}), 5s);
|
|
if (statusResult.has_value()) {
|
|
latest = statusResult.value();
|
|
if (latest.state != "queued" && latest.state != "running") {
|
|
return latest;
|
|
}
|
|
}
|
|
std::this_thread::sleep_for(pollInterval);
|
|
}
|
|
|
|
return latest;
|
|
}
|
|
|
|
std::filesystem::path createMockExternalPlugin(const std::filesystem::path& baseDir) {
|
|
auto pluginDir = baseDir / "socket_mock_plugin";
|
|
std::filesystem::create_directories(pluginDir);
|
|
|
|
{
|
|
std::ofstream pluginFile(pluginDir / "plugin.py");
|
|
pluginFile << R"PY(#!/usr/bin/env python3
|
|
import json
|
|
import sys
|
|
|
|
|
|
def handle_request(req):
|
|
method = req.get("method", "")
|
|
if method == "handshake.manifest":
|
|
return {
|
|
"name": "socket_mock_plugin",
|
|
"version": "1.0.0",
|
|
"interfaces": ["content_extractor_v1"],
|
|
"capabilities": {
|
|
"content_extraction": {
|
|
"formats": ["text/plain"],
|
|
"extensions": [".txt"]
|
|
}
|
|
}
|
|
}
|
|
if method == "plugin.init":
|
|
return {"status": "initialized"}
|
|
if method == "plugin.health":
|
|
return {"status": "ok"}
|
|
if method == "plugin.shutdown":
|
|
return {"status": "ok"}
|
|
raise ValueError(f"Method not found: {method}")
|
|
|
|
|
|
for line in sys.stdin:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
req_id = None
|
|
try:
|
|
req = json.loads(line)
|
|
req_id = req.get("id")
|
|
response = {"jsonrpc": "2.0", "id": req_id, "result": handle_request(req)}
|
|
except Exception as exc:
|
|
response = {
|
|
"jsonrpc": "2.0",
|
|
"id": req_id,
|
|
"error": {"code": -32603, "message": str(exc)}
|
|
}
|
|
print(json.dumps(response), flush=True)
|
|
)PY";
|
|
}
|
|
|
|
{
|
|
std::ofstream manifestFile(pluginDir / "yams-plugin.json");
|
|
manifestFile << R"JSON({
|
|
"name": "socket_mock_plugin",
|
|
"version": "1.0.0",
|
|
"interfaces": ["content_extractor_v1"],
|
|
"capabilities": {
|
|
"content_extraction": {
|
|
"formats": ["text/plain"],
|
|
"extensions": [".txt"]
|
|
}
|
|
},
|
|
"entry": {
|
|
"fallback_cmd": ["/usr/bin/env", "python3", "-u", "${plugin_dir}/plugin.py"],
|
|
"env": {
|
|
"PYTHONUNBUFFERED": "1"
|
|
}
|
|
}
|
|
})JSON";
|
|
}
|
|
|
|
return pluginDir;
|
|
}
|
|
|
|
bool containsCanonicalPath(const std::vector<std::string>& paths,
|
|
const std::filesystem::path& target) {
|
|
std::error_code ec;
|
|
auto targetCanonical = std::filesystem::weakly_canonical(target, ec);
|
|
if (ec) {
|
|
targetCanonical = target.lexically_normal();
|
|
}
|
|
|
|
for (const auto& path : paths) {
|
|
std::error_code itemEc;
|
|
auto itemCanonical = std::filesystem::weakly_canonical(path, itemEc);
|
|
if (itemEc) {
|
|
itemCanonical = std::filesystem::path(path).lexically_normal();
|
|
}
|
|
if (itemCanonical == targetCanonical) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
class SimpleHttpRedirectServer {
|
|
public:
|
|
explicit SimpleHttpRedirectServer(std::string location)
|
|
: io_(), acceptor_(io_), location_(std::move(location)) {
|
|
using boost::asio::ip::tcp;
|
|
|
|
tcp::endpoint endpoint(boost::asio::ip::make_address("127.0.0.1"), 0);
|
|
acceptor_.open(endpoint.protocol());
|
|
acceptor_.set_option(tcp::acceptor::reuse_address(true));
|
|
acceptor_.bind(endpoint);
|
|
acceptor_.listen();
|
|
port_ = acceptor_.local_endpoint().port();
|
|
|
|
ready_.set_value();
|
|
thread_ = std::thread([this]() { serveOnce(); });
|
|
ready_.get_future().wait();
|
|
}
|
|
|
|
~SimpleHttpRedirectServer() {
|
|
boost::system::error_code ec;
|
|
acceptor_.close(ec);
|
|
io_.stop();
|
|
if (thread_.joinable()) {
|
|
thread_.join();
|
|
}
|
|
}
|
|
|
|
uint16_t port() const { return port_; }
|
|
|
|
private:
|
|
void serveOnce() {
|
|
using boost::asio::ip::tcp;
|
|
|
|
try {
|
|
tcp::socket socket(io_);
|
|
acceptor_.accept(socket);
|
|
|
|
std::array<char, 2048> buffer{};
|
|
boost::system::error_code readEc;
|
|
(void)socket.read_some(boost::asio::buffer(buffer), readEc);
|
|
|
|
std::ostringstream response;
|
|
response << "HTTP/1.1 302 Found\r\n";
|
|
response << "Location: " << location_ << "\r\n";
|
|
response << "Content-Length: 0\r\n";
|
|
response << "Connection: close\r\n\r\n";
|
|
|
|
boost::system::error_code writeEc;
|
|
boost::asio::write(socket, boost::asio::buffer(response.str()), writeEc);
|
|
socket.shutdown(tcp::socket::shutdown_both, writeEc);
|
|
socket.close(writeEc);
|
|
} catch (...) {
|
|
}
|
|
}
|
|
|
|
boost::asio::io_context io_;
|
|
boost::asio::ip::tcp::acceptor acceptor_;
|
|
std::string location_;
|
|
uint16_t port_{0};
|
|
std::promise<void> ready_;
|
|
std::thread thread_;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("Daemon socket connection lifecycle", "[daemon][socket][integration]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
SECTION("connects to running daemon") {
|
|
DaemonHarness harness;
|
|
startHarnessWithRetry(harness);
|
|
|
|
auto client = createClient(harness.socketPath());
|
|
auto result = yams::cli::run_sync(client.connect(), 5s);
|
|
|
|
REQUIRE(result.has_value());
|
|
REQUIRE(client.isConnected());
|
|
}
|
|
|
|
SECTION("fails gracefully when daemon not running") {
|
|
// Use a separate harness that won't start
|
|
DaemonHarness harness;
|
|
auto client = createClient(harness.socketPath(), 500ms);
|
|
auto result = yams::cli::run_sync(client.connect(), 1s);
|
|
|
|
REQUIRE(!result.has_value());
|
|
REQUIRE(!client.isConnected());
|
|
}
|
|
|
|
SECTION("connects after daemon restart") {
|
|
// This test needs its own daemon to restart
|
|
DaemonHarness harness;
|
|
startHarnessWithRetry(harness);
|
|
|
|
auto client = createClient(harness.socketPath());
|
|
auto connectResult = yams::cli::run_sync(client.connect(), 5s);
|
|
REQUIRE(connectResult.has_value());
|
|
REQUIRE(client.isConnected());
|
|
|
|
// Stop daemon - this resets GlobalIOContext, invalidating existing clients
|
|
harness.stop();
|
|
std::this_thread::sleep_for(500ms);
|
|
|
|
// Restart daemon
|
|
startHarnessWithRetry(harness);
|
|
|
|
// Create NEW client after restart (old client's io_context was reset)
|
|
// Allow extra time for GlobalIOContext threads to fully stabilize after restart
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
// Use retry logic to handle potential timing issues with GlobalIOContext restart
|
|
auto client2 = createClient(harness.socketPath());
|
|
yams::Result<void> reconnectResult;
|
|
int maxRetries = 3;
|
|
for (int attempt = 0; attempt < maxRetries; ++attempt) {
|
|
reconnectResult = yams::cli::run_sync(client2.connect(), 5s);
|
|
if (reconnectResult.has_value()) {
|
|
break;
|
|
}
|
|
spdlog::warn("[TEST] Reconnect attempt {} failed: {}, retrying...", attempt + 1,
|
|
reconnectResult.error().message);
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
if (!reconnectResult.has_value()) {
|
|
spdlog::error("[TEST] All reconnect attempts failed: {}",
|
|
reconnectResult.error().message);
|
|
}
|
|
REQUIRE(reconnectResult.has_value());
|
|
REQUIRE(client2.isConnected());
|
|
}
|
|
|
|
SECTION("handles explicit disconnect") {
|
|
DaemonHarness harness;
|
|
startHarnessWithRetry(harness);
|
|
|
|
auto client = createClient(harness.socketPath());
|
|
auto connectResult = yams::cli::run_sync(client.connect(), 5s);
|
|
REQUIRE(connectResult.has_value());
|
|
REQUIRE(client.isConnected());
|
|
|
|
client.disconnect();
|
|
REQUIRE(!client.isConnected());
|
|
}
|
|
|
|
SECTION("respects connection timeout") {
|
|
// Use a separate harness that won't start
|
|
DaemonHarness harness;
|
|
auto client = createClient(harness.socketPath(), 100ms);
|
|
|
|
auto start = std::chrono::steady_clock::now();
|
|
auto result = yams::cli::run_sync(client.connect(), 500ms);
|
|
auto elapsed = std::chrono::steady_clock::now() - start;
|
|
|
|
REQUIRE(!result.has_value());
|
|
REQUIRE(elapsed < 200ms); // Should fail fast with 100ms timeout
|
|
}
|
|
|
|
SECTION("survives repeated restart cycles") {
|
|
DaemonHarness harness;
|
|
constexpr int cycles = 2; // Reduced from 3 to avoid macOS thread limit
|
|
|
|
for (int i = 0; i < cycles; ++i) {
|
|
INFO("restart cycle " << i);
|
|
startHarnessWithRetry(harness);
|
|
|
|
// Allow time for GlobalIOContext to stabilize after restart
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
auto client = createClient(harness.socketPath());
|
|
|
|
// Retry connection to handle timing issues
|
|
yams::Result<void> connectResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
connectResult = yams::cli::run_sync(client.connect(), 5s);
|
|
if (connectResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(connectResult.has_value());
|
|
|
|
auto statusResult = yams::cli::run_sync(client.status(), 5s);
|
|
REQUIRE(statusResult.has_value());
|
|
|
|
harness.stop();
|
|
// Allow GlobalIOContext to fully restart before next cycle
|
|
std::this_thread::sleep_for(500ms);
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Daemon client request execution", "[daemon][socket][requests]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
// Use isolated daemon for tests that modify database state
|
|
DaemonHarness harness;
|
|
startHarnessWithRetry(harness);
|
|
|
|
// Allow time for GlobalIOContext to stabilize
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
auto client = createClient(harness.socketPath());
|
|
|
|
// Retry connection to handle timing issues with GlobalIOContext
|
|
yams::Result<void> connectResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
connectResult = yams::cli::run_sync(client.connect(), 5s);
|
|
if (connectResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(connectResult.has_value());
|
|
|
|
SECTION("status request succeeds") {
|
|
auto statusResult = yams::cli::run_sync(client.status(), 5s);
|
|
|
|
REQUIRE(statusResult.has_value());
|
|
auto& status = statusResult.value();
|
|
REQUIRE(status.readinessStates.at("ipc_server"));
|
|
REQUIRE(status.readinessStates.at("content_store"));
|
|
}
|
|
|
|
SECTION("list request succeeds") {
|
|
ListRequest req;
|
|
req.limit = 10;
|
|
|
|
auto listResult = yams::cli::run_sync(client.list(req), 5s);
|
|
|
|
REQUIRE(listResult.has_value());
|
|
// Database may have documents from other tests - just verify response is valid
|
|
}
|
|
|
|
SECTION("add and retrieve document") {
|
|
// Add a document with unique content
|
|
AddDocumentRequest addReq;
|
|
addReq.name = "test_request_execution.txt";
|
|
addReq.content = "Hello from request execution test!";
|
|
|
|
// Retry document add in case of transient connection issues
|
|
yams::Result<AddDocumentResponse> addResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
addResult = yams::cli::run_sync(client.streamingAddDocument(addReq), 10s);
|
|
if (addResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(addResult.has_value());
|
|
|
|
std::string docHash = addResult.value().hash;
|
|
REQUIRE(!docHash.empty());
|
|
|
|
// Retrieve it (with retry since streamingAddDocument is async)
|
|
auto getResult = getWithRetry(client, docHash);
|
|
REQUIRE(getResult.has_value());
|
|
REQUIRE(getResult.value().content == "Hello from request execution test!");
|
|
REQUIRE(getResult.value().name == "test_request_execution.txt");
|
|
}
|
|
|
|
SECTION("search for unique content") {
|
|
SearchRequest searchReq;
|
|
searchReq.query = "xyzzy_nonexistent_unique_string_12345";
|
|
|
|
// Retry search request in case of transient connection issues
|
|
yams::Result<SearchResponse> searchResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
searchResult = yams::cli::run_sync(client.search(searchReq), 10s);
|
|
if (searchResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
|
|
REQUIRE(searchResult.has_value());
|
|
// Should return empty results for unique non-existent query
|
|
REQUIRE(searchResult.value().results.empty());
|
|
}
|
|
|
|
SECTION("grep with unique pattern") {
|
|
GrepRequest grepReq;
|
|
grepReq.pattern = "xyzzy_nonexistent_pattern_67890";
|
|
|
|
// Retry grep request in case of transient connection issues
|
|
yams::Result<GrepResponse> grepResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
grepResult = yams::cli::run_sync(client.grep(grepReq), 10s);
|
|
if (grepResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
|
|
REQUIRE(grepResult.has_value());
|
|
// Should return empty matches for unique non-existent pattern
|
|
REQUIRE(grepResult.value().matches.empty());
|
|
}
|
|
|
|
SECTION("prepare session request succeeds") {
|
|
PrepareSessionRequest req;
|
|
req.sessionName = "default";
|
|
req.limit = 16;
|
|
req.snippetLen = 48;
|
|
|
|
auto result = yams::cli::run_sync(client.prepareSession(req), 10s);
|
|
|
|
REQUIRE(result.has_value());
|
|
CHECK(result.value().warmedCount == 0);
|
|
CHECK(result.value().message == "OK");
|
|
}
|
|
|
|
SECTION("chunked get protocol succeeds by name") {
|
|
const std::string docName = "test_chunk_protocol.txt";
|
|
const std::string content = "chunked retrieval content for request execution";
|
|
|
|
AddDocumentRequest addReq;
|
|
addReq.name = docName;
|
|
addReq.content = content;
|
|
|
|
auto addResult = yams::cli::run_sync(client.streamingAddDocument(addReq), 10s);
|
|
REQUIRE(addResult.has_value());
|
|
|
|
auto getResult = getWithRetry(client, addResult.value().hash);
|
|
REQUIRE(getResult.has_value());
|
|
|
|
GetInitRequest initReq;
|
|
initReq.name = docName;
|
|
initReq.byName = true;
|
|
initReq.chunkSize = 7;
|
|
|
|
auto initResult = yams::cli::run_sync(client.getInit(initReq), 10s);
|
|
REQUIRE(initResult.has_value());
|
|
REQUIRE(initResult.value().transferId != 0);
|
|
CHECK(initResult.value().totalSize == content.size());
|
|
CHECK(initResult.value().chunkSize == 7);
|
|
CHECK(initResult.value().metadata.contains("hash"));
|
|
CHECK(initResult.value().metadata.at("hash") == addResult.value().hash);
|
|
|
|
std::string assembled;
|
|
uint64_t offset = 0;
|
|
uint64_t remaining = initResult.value().totalSize;
|
|
while (remaining > 0) {
|
|
GetChunkRequest chunkReq{initResult.value().transferId, offset, 7};
|
|
auto chunkResult = yams::cli::run_sync(client.getChunk(chunkReq), 10s);
|
|
REQUIRE(chunkResult.has_value());
|
|
assembled += chunkResult.value().data;
|
|
offset += chunkResult.value().data.size();
|
|
remaining = chunkResult.value().bytesRemaining;
|
|
}
|
|
|
|
CHECK(assembled == content);
|
|
|
|
auto endResult =
|
|
yams::cli::run_sync(client.getEnd(GetEndRequest{initResult.value().transferId}), 10s);
|
|
REQUIRE(endResult.has_value());
|
|
CHECK(endResult.value().message == "OK");
|
|
}
|
|
|
|
SECTION("cat request returns stored content") {
|
|
AddDocumentRequest addReq;
|
|
addReq.name = "test_cat_request.txt";
|
|
addReq.content = "cat request body";
|
|
|
|
auto addResult = yams::cli::run_sync(client.streamingAddDocument(addReq), 10s);
|
|
REQUIRE(addResult.has_value());
|
|
REQUIRE(getWithRetry(client, addResult.value().hash).has_value());
|
|
|
|
CatRequest catReq;
|
|
catReq.hash = addResult.value().hash;
|
|
|
|
auto catResult = yams::cli::run_sync(client.cat(catReq), 10s);
|
|
|
|
REQUIRE(catResult.has_value());
|
|
CHECK(catResult.value().hash == addResult.value().hash);
|
|
CHECK(catResult.value().name == "test_cat_request.txt");
|
|
CHECK(catResult.value().content.find("cat request body") != std::string::npos);
|
|
}
|
|
|
|
SECTION("update document request succeeds") {
|
|
AddDocumentRequest addReq;
|
|
addReq.name = "test_update_request.txt";
|
|
addReq.content = "update request body";
|
|
|
|
auto addResult = yams::cli::run_sync(client.streamingAddDocument(addReq), 10s);
|
|
REQUIRE(addResult.has_value());
|
|
REQUIRE(getWithRetry(client, addResult.value().hash).has_value());
|
|
|
|
UpdateDocumentRequest updateReq;
|
|
updateReq.hash = addResult.value().hash;
|
|
updateReq.metadata["owner"] = "socket-test";
|
|
updateReq.addTags.push_back("request-execution");
|
|
|
|
auto updateResult = yams::cli::run_sync(client.updateDocument(updateReq), 10s);
|
|
|
|
REQUIRE(updateResult.has_value());
|
|
CHECK((updateResult.value().contentUpdated || !updateResult.value().hash.empty() ||
|
|
updateResult.value().metadataUpdated || updateResult.value().tagsUpdated));
|
|
}
|
|
|
|
SECTION("delete request succeeds") {
|
|
AddDocumentRequest addReq;
|
|
addReq.name = "test_delete_request.txt";
|
|
addReq.content = "delete request body";
|
|
|
|
auto addResult = yams::cli::run_sync(client.streamingAddDocument(addReq), 10s);
|
|
REQUIRE(addResult.has_value());
|
|
REQUIRE(getWithRetry(client, addResult.value().hash).has_value());
|
|
|
|
DeleteRequest deleteReq;
|
|
deleteReq.hash = addResult.value().hash;
|
|
|
|
auto deleteResult = yams::cli::run_sync(client.call<DeleteRequest>(deleteReq), 10s);
|
|
|
|
REQUIRE(deleteResult.has_value());
|
|
CHECK_FALSE(deleteResult.value().dryRun);
|
|
}
|
|
|
|
SECTION("download request returns policy reminder") {
|
|
DownloadRequest downloadReq;
|
|
downloadReq.url = "https://example.com/archive.txt";
|
|
|
|
auto downloadResult = yams::cli::run_sync(client.call<DownloadRequest>(downloadReq), 10s);
|
|
|
|
REQUIRE(downloadResult.has_value());
|
|
CHECK(downloadResult.value().url == downloadReq.url);
|
|
CHECK_FALSE(downloadResult.value().success);
|
|
CHECK(downloadResult.value().error.find("Daemon download is disabled") !=
|
|
std::string::npos);
|
|
}
|
|
|
|
SECTION("cancel request reports missing request id") {
|
|
auto cancelResult =
|
|
yams::cli::run_sync(client.executeRequest(Request{CancelRequest{999999999ULL}}), 10s);
|
|
|
|
REQUIRE_FALSE(cancelResult.has_value());
|
|
}
|
|
|
|
SECTION("file history request reports document outside snapshots") {
|
|
const std::string docName = "test_file_history_request.txt";
|
|
|
|
AddDocumentRequest addReq;
|
|
addReq.name = docName;
|
|
addReq.content = "file history request body";
|
|
|
|
auto addResult = yams::cli::run_sync(client.streamingAddDocument(addReq), 10s);
|
|
REQUIRE(addResult.has_value());
|
|
REQUIRE(getWithRetry(client, addResult.value().hash).has_value());
|
|
|
|
FileHistoryRequest historyReq;
|
|
historyReq.filepath = (std::filesystem::current_path() / docName).string();
|
|
|
|
auto historyResult = yams::cli::run_sync(client.fileHistory(historyReq), 10s);
|
|
|
|
REQUIRE(historyResult.has_value());
|
|
CHECK(historyResult.value().filepath ==
|
|
std::filesystem::absolute(std::filesystem::current_path() / docName)
|
|
.lexically_normal()
|
|
.string());
|
|
CHECK(historyResult.value().totalVersions >= 1);
|
|
CHECK_FALSE(historyResult.value().versions.empty());
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Daemon client download job request execution", "[daemon][socket][download-jobs]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
ScopedEnvVar daemonDownloadEnabled("YAMS_ENABLE_DAEMON_DOWNLOAD", std::string("1"));
|
|
|
|
DaemonHarnessOptions options;
|
|
options.configureDaemon = [](yams::daemon::DaemonConfig& cfg) {
|
|
cfg.downloadPolicy.enable = true;
|
|
cfg.downloadPolicy.allowedHosts = {"127.0.0.1", "example.com"};
|
|
cfg.downloadPolicy.allowedSchemes = {"http", "https"};
|
|
cfg.downloadPolicy.requireChecksum = false;
|
|
cfg.downloadPolicy.storeOnly = true;
|
|
cfg.downloadPolicy.timeout = 5s;
|
|
};
|
|
DaemonHarness harness(options);
|
|
startHarnessWithRetry(harness);
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
auto client = createClient(harness.socketPath());
|
|
|
|
yams::Result<void> connectResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
connectResult = yams::cli::run_sync(client.connect(), 5s);
|
|
if (connectResult.has_value()) {
|
|
break;
|
|
}
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(connectResult.has_value());
|
|
|
|
SECTION("download request creates tracked job and reaches completed status") {
|
|
RequestDispatcher::__test_resetDownloadJobs();
|
|
RequestDispatcher::__test_forceDownloadServiceSuccessOnce();
|
|
|
|
DownloadRequest downloadReq;
|
|
downloadReq.url = "https://example.com/archive.txt";
|
|
downloadReq.checksum = "sha256:abcd1234";
|
|
|
|
auto startResult = yams::cli::run_sync(client.call<DownloadRequest>(downloadReq), 10s);
|
|
|
|
REQUIRE(startResult.has_value());
|
|
CHECK(startResult.value().url == downloadReq.url);
|
|
CHECK_FALSE(startResult.value().jobId.empty());
|
|
CHECK(startResult.value().state == "queued");
|
|
CHECK_FALSE(startResult.value().success);
|
|
|
|
const auto finalStatus = waitForDownloadJobStatus(client, startResult.value().jobId);
|
|
CHECK(finalStatus.jobId == startResult.value().jobId);
|
|
CHECK(finalStatus.state == "completed");
|
|
CHECK(finalStatus.success);
|
|
CHECK(finalStatus.hash == "sha256:forced-download-success");
|
|
CHECK(finalStatus.error.empty());
|
|
|
|
auto listResult =
|
|
yams::cli::run_sync(client.listDownloadJobs(ListDownloadJobsRequest{10}), 10s);
|
|
REQUIRE(listResult.has_value());
|
|
REQUIRE_FALSE(listResult.value().jobs.empty());
|
|
CHECK(std::any_of(
|
|
listResult.value().jobs.begin(), listResult.value().jobs.end(), [&](const auto& job) {
|
|
return job.jobId == startResult.value().jobId && job.state == "completed";
|
|
}));
|
|
}
|
|
|
|
SECTION("cancel download job transitions seeded running job to canceled") {
|
|
RequestDispatcher::__test_resetDownloadJobs();
|
|
|
|
DownloadResponse seeded;
|
|
seeded.url = "https://example.com/seeded-archive.txt";
|
|
seeded.jobId = "download-seeded-running";
|
|
seeded.state = "running";
|
|
seeded.success = false;
|
|
seeded.createdAtMs = 111;
|
|
seeded.updatedAtMs = 222;
|
|
RequestDispatcher::__test_seedDownloadJob(seeded);
|
|
|
|
yams::Result<DownloadResponse> cancelResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
cancelResult = yams::cli::run_sync(
|
|
client.cancelDownloadJob(CancelDownloadJobRequest{seeded.jobId}), 10s);
|
|
if (cancelResult.has_value()) {
|
|
break;
|
|
}
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
|
|
INFO("cancel download job error: "
|
|
<< (cancelResult.has_value() ? "" : cancelResult.error().message));
|
|
REQUIRE(cancelResult.has_value());
|
|
CHECK(cancelResult.value().jobId == seeded.jobId);
|
|
CHECK(cancelResult.value().state == "canceled");
|
|
CHECK_FALSE(cancelResult.value().success);
|
|
|
|
auto statusResult =
|
|
yams::cli::run_sync(client.downloadStatus(DownloadStatusRequest{seeded.jobId}), 10s);
|
|
REQUIRE(statusResult.has_value());
|
|
CHECK(statusResult.value().jobId == seeded.jobId);
|
|
CHECK(statusResult.value().state == "canceled");
|
|
|
|
auto listResult =
|
|
yams::cli::run_sync(client.listDownloadJobs(ListDownloadJobsRequest{10}), 10s);
|
|
REQUIRE(listResult.has_value());
|
|
CHECK(std::any_of(
|
|
listResult.value().jobs.begin(), listResult.value().jobs.end(),
|
|
[&](const auto& job) { return job.jobId == seeded.jobId && job.state == "canceled"; }));
|
|
}
|
|
|
|
SECTION("redirecting download is blocked and surfaced to the client") {
|
|
RequestDispatcher::__test_resetDownloadJobs();
|
|
|
|
SimpleHttpRedirectServer redirectServer("http://example.com/escaped.bin");
|
|
|
|
DownloadRequest downloadReq;
|
|
downloadReq.url =
|
|
"http://127.0.0.1:" + std::to_string(redirectServer.port()) + "/redirect.bin";
|
|
|
|
auto startResult = yams::cli::run_sync(client.call<DownloadRequest>(downloadReq), 10s);
|
|
|
|
REQUIRE(startResult.has_value());
|
|
CHECK(startResult.value().url == downloadReq.url);
|
|
CHECK_FALSE(startResult.value().jobId.empty());
|
|
CHECK(startResult.value().state == "queued");
|
|
CHECK_FALSE(startResult.value().success);
|
|
|
|
const auto finalStatus = waitForDownloadJobStatus(client, startResult.value().jobId, 10s);
|
|
CHECK(finalStatus.jobId == startResult.value().jobId);
|
|
CHECK(finalStatus.state == "failed");
|
|
CHECK_FALSE(finalStatus.success);
|
|
CHECK(finalStatus.error ==
|
|
"Redirect blocked by policy during probe (follow_redirects=false)");
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Daemon client model request execution", "[daemon][socket][requests][model]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
EnvGuard backendGuard("YAMS_EMBED_BACKEND", "daemon");
|
|
DaemonHarnessOptions options;
|
|
options.useMockModelProvider = false;
|
|
DaemonHarness harness(options);
|
|
startHarnessWithRetry(harness);
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
auto client = createClient(harness.socketPath());
|
|
|
|
yams::Result<void> connectResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
connectResult = yams::cli::run_sync(client.connect(), 5s);
|
|
if (connectResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(connectResult.has_value());
|
|
|
|
const auto* daemon = harness.daemon();
|
|
REQUIRE(daemon != nullptr);
|
|
auto* serviceManager = const_cast<yams::daemon::ServiceManager*>(daemon->getServiceManager());
|
|
REQUIRE(serviceManager != nullptr);
|
|
|
|
ModelStatusRequest statusReq;
|
|
auto statusResult = yams::cli::run_sync(client.executeRequest(Request{statusReq}), 5s);
|
|
|
|
REQUIRE(statusResult.has_value());
|
|
REQUIRE(std::holds_alternative<ModelStatusResponse>(statusResult.value()));
|
|
CHECK(std::get<ModelStatusResponse>(statusResult.value()).models.empty());
|
|
|
|
LoadModelRequest emptyLoadReq;
|
|
auto emptyLoadResult = yams::cli::run_sync(client.executeRequest(Request{emptyLoadReq}), 10s);
|
|
|
|
REQUIRE(emptyLoadResult.has_value());
|
|
REQUIRE(std::holds_alternative<ErrorResponse>(emptyLoadResult.value()));
|
|
CHECK(std::get<ErrorResponse>(emptyLoadResult.value()).code == yams::ErrorCode::InvalidState);
|
|
|
|
LoadModelRequest loadReq;
|
|
loadReq.modelName = "test-model-that-does-not-exist";
|
|
auto loadResult = yams::cli::run_sync(client.executeRequest(Request{loadReq}), 10s);
|
|
|
|
REQUIRE(loadResult.has_value());
|
|
REQUIRE(std::holds_alternative<ErrorResponse>(loadResult.value()));
|
|
CHECK(std::get<ErrorResponse>(loadResult.value()).code == yams::ErrorCode::InvalidState);
|
|
|
|
auto injectedProvider = std::make_shared<StubModelProvider>();
|
|
REQUIRE(injectedProvider != nullptr);
|
|
serviceManager->__test_setModelProvider(injectedProvider);
|
|
|
|
LoadModelRequest emptyLoadWithProviderReq;
|
|
auto emptyLoadWithProviderResult =
|
|
yams::cli::run_sync(client.executeRequest(Request{emptyLoadWithProviderReq}), 10s);
|
|
|
|
REQUIRE(emptyLoadWithProviderResult.has_value());
|
|
REQUIRE(std::holds_alternative<ErrorResponse>(emptyLoadWithProviderResult.value()));
|
|
CHECK(std::get<ErrorResponse>(emptyLoadWithProviderResult.value()).code ==
|
|
yams::ErrorCode::InvalidData);
|
|
|
|
LoadModelRequest optionsLoadReq;
|
|
optionsLoadReq.modelName = "dispatcher-model-with-options";
|
|
optionsLoadReq.optionsJson = R"({"revision":"test","offline":true})";
|
|
auto optionsLoadResult = yams::cli::run_sync(client.loadModel(optionsLoadReq), 10s);
|
|
|
|
REQUIRE(optionsLoadResult.has_value());
|
|
CHECK(optionsLoadResult.value().success);
|
|
CHECK(optionsLoadResult.value().modelName == "dispatcher-model-with-options");
|
|
CHECK(injectedProvider->isModelLoaded("dispatcher-model-with-options"));
|
|
CHECK(injectedProvider->lastLoadWithOptionsModel() == "dispatcher-model-with-options");
|
|
CHECK(injectedProvider->lastLoadWithOptionsJson() == optionsLoadReq.optionsJson);
|
|
|
|
LoadModelRequest successLoadReq;
|
|
successLoadReq.modelName = "dispatcher-model";
|
|
auto successLoadResult = yams::cli::run_sync(client.loadModel(successLoadReq), 10s);
|
|
|
|
REQUIRE(successLoadResult.has_value());
|
|
CHECK(successLoadResult.value().success);
|
|
CHECK(successLoadResult.value().modelName == "dispatcher-model");
|
|
CHECK(injectedProvider->isModelLoaded("dispatcher-model"));
|
|
|
|
ModelStatusRequest loadedStatusReq;
|
|
loadedStatusReq.modelName = "dispatcher-model";
|
|
auto loadedStatusResult = yams::cli::run_sync(client.getModelStatus(loadedStatusReq), 5s);
|
|
|
|
REQUIRE(loadedStatusResult.has_value());
|
|
REQUIRE(loadedStatusResult.value().models.size() == 1);
|
|
CHECK(loadedStatusResult.value().models.front().name == "dispatcher-model");
|
|
CHECK(loadedStatusResult.value().models.front().loaded);
|
|
|
|
ModelStatusRequest missingStatusReq;
|
|
missingStatusReq.modelName = "missing-dispatcher-model";
|
|
auto missingStatusResult = yams::cli::run_sync(client.getModelStatus(missingStatusReq), 5s);
|
|
|
|
REQUIRE(missingStatusResult.has_value());
|
|
CHECK(missingStatusResult.value().models.empty());
|
|
|
|
UnloadModelRequest unloadReq;
|
|
unloadReq.modelName = "test-model-that-does-not-exist";
|
|
auto unloadResult = yams::cli::run_sync(client.executeRequest(Request{unloadReq}), 5s);
|
|
|
|
REQUIRE(unloadResult.has_value());
|
|
REQUIRE(std::holds_alternative<ErrorResponse>(unloadResult.value()));
|
|
CHECK(std::get<ErrorResponse>(unloadResult.value()).code == yams::ErrorCode::NotFound);
|
|
|
|
UnloadModelRequest successUnloadReq;
|
|
successUnloadReq.modelName = "dispatcher-model";
|
|
auto successUnloadResult = yams::cli::run_sync(client.unloadModel(successUnloadReq), 5s);
|
|
|
|
REQUIRE(successUnloadResult.has_value());
|
|
CHECK(successUnloadResult.value().message == "Model unloaded");
|
|
CHECK_FALSE(injectedProvider->isModelLoaded("dispatcher-model"));
|
|
|
|
UnloadModelRequest successOptionsUnloadReq;
|
|
successOptionsUnloadReq.modelName = "dispatcher-model-with-options";
|
|
auto successOptionsUnloadResult =
|
|
yams::cli::run_sync(client.unloadModel(successOptionsUnloadReq), 5s);
|
|
|
|
REQUIRE(successOptionsUnloadResult.has_value());
|
|
CHECK(successOptionsUnloadResult.value().message == "Model unloaded");
|
|
CHECK_FALSE(injectedProvider->isModelLoaded("dispatcher-model-with-options"));
|
|
|
|
injectedProvider->setAvailable(false);
|
|
|
|
ModelStatusRequest unavailableStatusReq;
|
|
auto unavailableStatusResult =
|
|
yams::cli::run_sync(client.getModelStatus(unavailableStatusReq), 5s);
|
|
|
|
REQUIRE(unavailableStatusResult.has_value());
|
|
CHECK(unavailableStatusResult.value().models.empty());
|
|
CHECK(unavailableStatusResult.value().totalMemoryMb == 0);
|
|
}
|
|
|
|
TEST_CASE("Daemon client plugin request execution", "[daemon][socket][requests][plugin]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
const auto uniqueId =
|
|
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
|
|
const auto pluginRoot =
|
|
std::filesystem::temp_directory_path() / ("yams_socket_plugin_" + uniqueId);
|
|
const auto emptyTrustedDir = pluginRoot / "trusted_empty";
|
|
std::filesystem::create_directories(emptyTrustedDir);
|
|
const auto pluginDir = createMockExternalPlugin(pluginRoot);
|
|
|
|
DaemonHarnessOptions options;
|
|
options.isolateState = true;
|
|
options.trustedPluginPaths = {pluginDir};
|
|
|
|
DaemonHarness harness(options);
|
|
startHarnessWithRetry(harness);
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
auto client = createClient(harness.socketPath());
|
|
|
|
yams::Result<void> connectResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
connectResult = yams::cli::run_sync(client.connect(), 5s);
|
|
if (connectResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(connectResult.has_value());
|
|
|
|
PluginScanRequest scanReq;
|
|
scanReq.target = pluginDir.string();
|
|
auto scanResult = yams::cli::run_sync(client.call<PluginScanRequest>(scanReq), 10s);
|
|
|
|
REQUIRE(scanResult.has_value());
|
|
REQUIRE(scanResult.value().plugins.size() == 1);
|
|
CHECK(scanResult.value().plugins.front().name == "socket_mock_plugin");
|
|
CHECK(scanResult.value().plugins.front().path == pluginDir.string());
|
|
|
|
PluginScanRequest dirScanReq;
|
|
dirScanReq.dir = pluginRoot.string();
|
|
auto dirScanResult = yams::cli::run_sync(client.call<PluginScanRequest>(dirScanReq), 10s);
|
|
|
|
REQUIRE(dirScanResult.has_value());
|
|
REQUIRE(dirScanResult.value().plugins.size() == 1);
|
|
CHECK(dirScanResult.value().plugins.front().name == "socket_mock_plugin");
|
|
|
|
auto initialTrustList =
|
|
yams::cli::run_sync(client.call<PluginTrustListRequest>(PluginTrustListRequest{}), 5s);
|
|
|
|
REQUIRE(initialTrustList.has_value());
|
|
CHECK(containsCanonicalPath(initialTrustList.value().paths, pluginDir));
|
|
|
|
PluginTrustAddRequest trustAddReq;
|
|
trustAddReq.path = emptyTrustedDir.string();
|
|
auto trustAddResult = yams::cli::run_sync(client.executeRequest(Request{trustAddReq}), 5s);
|
|
|
|
REQUIRE(trustAddResult.has_value());
|
|
REQUIRE(std::holds_alternative<SuccessResponse>(trustAddResult.value()));
|
|
CHECK(std::get<SuccessResponse>(trustAddResult.value()).message == "ok");
|
|
|
|
auto trustListAfterAdd =
|
|
yams::cli::run_sync(client.call<PluginTrustListRequest>(PluginTrustListRequest{}), 5s);
|
|
|
|
REQUIRE(trustListAfterAdd.has_value());
|
|
CHECK(containsCanonicalPath(trustListAfterAdd.value().paths, emptyTrustedDir));
|
|
|
|
PluginLoadRequest dryRunReq;
|
|
dryRunReq.pathOrName = pluginDir.string();
|
|
dryRunReq.dryRun = true;
|
|
auto dryRunResult = yams::cli::run_sync(client.call<PluginLoadRequest>(dryRunReq), 10s);
|
|
|
|
REQUIRE(dryRunResult.has_value());
|
|
CHECK_FALSE(dryRunResult.value().loaded);
|
|
CHECK(dryRunResult.value().message == "dry-run");
|
|
CHECK(dryRunResult.value().record.name == "socket_mock_plugin");
|
|
|
|
PluginLoadRequest loadReq;
|
|
loadReq.pathOrName = pluginDir.string();
|
|
auto loadResult = yams::cli::run_sync(client.call<PluginLoadRequest>(loadReq), 10s);
|
|
|
|
REQUIRE(loadResult.has_value());
|
|
CHECK(loadResult.value().loaded);
|
|
CHECK(loadResult.value().message == "loaded");
|
|
CHECK(loadResult.value().record.name == "socket_mock_plugin");
|
|
|
|
PluginUnloadRequest unloadLoadedReq;
|
|
unloadLoadedReq.name = loadResult.value().record.name;
|
|
auto unloadLoadedResult =
|
|
yams::cli::run_sync(client.executeRequest(Request{unloadLoadedReq}), 5s);
|
|
|
|
REQUIRE(unloadLoadedResult.has_value());
|
|
REQUIRE(std::holds_alternative<SuccessResponse>(unloadLoadedResult.value()));
|
|
CHECK(std::get<SuccessResponse>(unloadLoadedResult.value()).message == "unloaded");
|
|
|
|
PluginUnloadRequest unloadReq;
|
|
unloadReq.name = "definitely_missing_plugin_for_dispatcher_coverage";
|
|
auto unloadResult = yams::cli::run_sync(client.executeRequest(Request{unloadReq}), 5s);
|
|
|
|
REQUIRE(unloadResult.has_value());
|
|
REQUIRE(std::holds_alternative<SuccessResponse>(unloadResult.value()));
|
|
CHECK(std::get<SuccessResponse>(unloadResult.value()).message == "unloaded");
|
|
|
|
PluginScanRequest missingScanReq;
|
|
missingScanReq.target = (pluginRoot / "missing_plugin_dir").string();
|
|
auto missingScanResult =
|
|
yams::cli::run_sync(client.executeRequest(Request{missingScanReq}), 10s);
|
|
|
|
REQUIRE(missingScanResult.has_value());
|
|
REQUIRE(std::holds_alternative<ErrorResponse>(missingScanResult.value()));
|
|
CHECK(std::get<ErrorResponse>(missingScanResult.value()).code == yams::ErrorCode::NotFound);
|
|
|
|
PluginTrustRemoveRequest trustRemoveReq;
|
|
trustRemoveReq.path = emptyTrustedDir.string();
|
|
auto trustRemoveResult =
|
|
yams::cli::run_sync(client.executeRequest(Request{trustRemoveReq}), 5s);
|
|
|
|
REQUIRE(trustRemoveResult.has_value());
|
|
REQUIRE(std::holds_alternative<SuccessResponse>(trustRemoveResult.value()));
|
|
CHECK(std::get<SuccessResponse>(trustRemoveResult.value()).message == "ok");
|
|
|
|
auto trustListAfterRemove =
|
|
yams::cli::run_sync(client.call<PluginTrustListRequest>(PluginTrustListRequest{}), 5s);
|
|
|
|
REQUIRE(trustListAfterRemove.has_value());
|
|
CHECK_FALSE(containsCanonicalPath(trustListAfterRemove.value().paths, emptyTrustedDir));
|
|
}
|
|
|
|
TEST_CASE("Daemon client prune request execution", "[daemon][socket][requests][prune]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
DaemonHarness harness;
|
|
startHarnessWithRetry(harness);
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
auto client = createClient(harness.socketPath());
|
|
|
|
yams::Result<void> connectResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
connectResult = yams::cli::run_sync(client.connect(), 5s);
|
|
if (connectResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(connectResult.has_value());
|
|
|
|
const auto* daemon = harness.daemon();
|
|
REQUIRE(daemon != nullptr);
|
|
auto* serviceManager = daemon->getServiceManager();
|
|
REQUIRE(serviceManager != nullptr);
|
|
auto metadataRepo = serviceManager->getMetadataRepo();
|
|
REQUIRE(metadataRepo != nullptr);
|
|
|
|
AddDocumentRequest addReq;
|
|
addReq.name = "dispatcher_coverage.log";
|
|
addReq.content = "prune coverage content\n";
|
|
auto addResult = yams::cli::run_sync(client.streamingAddDocument(addReq), 10s);
|
|
|
|
REQUIRE(addResult.has_value());
|
|
REQUIRE_FALSE(addResult.value().hash.empty());
|
|
|
|
auto getResult = getWithRetry(client, addResult.value().hash);
|
|
REQUIRE(getResult.has_value());
|
|
|
|
AddDocumentRequest largeReq;
|
|
largeReq.name = "dispatcher_coverage_large.log";
|
|
largeReq.content = std::string(4096, 'L');
|
|
auto largeResult = yams::cli::run_sync(client.streamingAddDocument(largeReq), 10s);
|
|
|
|
REQUIRE(largeResult.has_value());
|
|
REQUIRE_FALSE(largeResult.value().hash.empty());
|
|
REQUIRE(getWithRetry(client, largeResult.value().hash, 50, 200ms).has_value());
|
|
|
|
AddDocumentRequest signatureReq;
|
|
signatureReq.name = "dispatcher_coverage_binary";
|
|
signatureReq.content = std::string("\x7F" "ELF", 4);
|
|
auto signatureResult = yams::cli::run_sync(client.streamingAddDocument(signatureReq), 10s);
|
|
|
|
REQUIRE(signatureResult.has_value());
|
|
REQUIRE_FALSE(signatureResult.value().hash.empty());
|
|
REQUIRE(getWithRetry(client, signatureResult.value().hash, 50, 200ms).has_value());
|
|
|
|
auto smallDocResult = metadataRepo->getDocumentByHash(addResult.value().hash);
|
|
REQUIRE(smallDocResult.has_value());
|
|
REQUIRE(smallDocResult.value().has_value());
|
|
const auto smallDoc = *smallDocResult.value();
|
|
|
|
auto largeDocResult = metadataRepo->getDocumentByHash(largeResult.value().hash);
|
|
REQUIRE(largeDocResult.has_value());
|
|
REQUIRE(largeDocResult.value().has_value());
|
|
auto largeDoc = *largeDocResult.value();
|
|
largeDoc.modifiedTime = std::chrono::time_point_cast<std::chrono::seconds>(
|
|
std::chrono::system_clock::now() - std::chrono::hours(48));
|
|
REQUIRE(metadataRepo->updateDocument(largeDoc).has_value());
|
|
|
|
PruneRequest pruneReq;
|
|
pruneReq.extensions = {"log"};
|
|
pruneReq.smallerThan = "1MB";
|
|
pruneReq.dryRun = true;
|
|
pruneReq.verbose = true;
|
|
|
|
auto pruneResult = yams::cli::run_sync(client.call<PruneRequest>(pruneReq), 30s);
|
|
|
|
REQUIRE(pruneResult.has_value());
|
|
CHECK(pruneResult.value().filesDeleted == 0);
|
|
CHECK(pruneResult.value().filesFailed == 0);
|
|
CHECK(pruneResult.value().totalBytesFreed >=
|
|
static_cast<uint64_t>(smallDoc.fileSize + largeDoc.fileSize));
|
|
CHECK_FALSE(pruneResult.value().categoryCounts.empty());
|
|
|
|
PruneRequest signaturePruneReq;
|
|
signaturePruneReq.categories = {"build-artifacts"};
|
|
signaturePruneReq.dryRun = true;
|
|
auto signaturePruneResult =
|
|
yams::cli::run_sync(client.call<PruneRequest>(signaturePruneReq), 30s);
|
|
|
|
REQUIRE(signaturePruneResult.has_value());
|
|
CHECK(signaturePruneResult.value().categoryCounts["build-executable"] == 1);
|
|
|
|
PruneRequest olderThanReq;
|
|
olderThanReq.extensions = {"log"};
|
|
olderThanReq.olderThan = "1d";
|
|
olderThanReq.dryRun = true;
|
|
|
|
auto olderThanResult = yams::cli::run_sync(client.call<PruneRequest>(olderThanReq), 30s);
|
|
|
|
REQUIRE(olderThanResult.has_value());
|
|
CHECK(olderThanResult.value().filesDeleted == 0);
|
|
CHECK(olderThanResult.value().filesFailed == 0);
|
|
CHECK(olderThanResult.value().totalBytesFreed == static_cast<uint64_t>(largeDoc.fileSize));
|
|
|
|
PruneRequest unknownAgeUnitReq;
|
|
unknownAgeUnitReq.extensions = {"log"};
|
|
unknownAgeUnitReq.olderThan = "1q";
|
|
unknownAgeUnitReq.dryRun = true;
|
|
|
|
auto unknownAgeUnitResult =
|
|
yams::cli::run_sync(client.call<PruneRequest>(unknownAgeUnitReq), 30s);
|
|
|
|
REQUIRE(unknownAgeUnitResult.has_value());
|
|
CHECK(unknownAgeUnitResult.value().filesDeleted == 0);
|
|
CHECK(unknownAgeUnitResult.value().filesFailed == 0);
|
|
CHECK(unknownAgeUnitResult.value().totalBytesFreed >=
|
|
static_cast<uint64_t>(smallDoc.fileSize + largeDoc.fileSize));
|
|
|
|
PruneRequest largerThanReq;
|
|
largerThanReq.extensions = {"log"};
|
|
largerThanReq.largerThan = "1KB";
|
|
largerThanReq.dryRun = true;
|
|
|
|
auto largerThanResult = yams::cli::run_sync(client.call<PruneRequest>(largerThanReq), 30s);
|
|
|
|
REQUIRE(largerThanResult.has_value());
|
|
CHECK(largerThanResult.value().filesDeleted == 0);
|
|
CHECK(largerThanResult.value().filesFailed == 0);
|
|
CHECK(largerThanResult.value().totalBytesFreed == static_cast<uint64_t>(largeDoc.fileSize));
|
|
|
|
PruneRequest applyReq;
|
|
applyReq.extensions = {"log"};
|
|
applyReq.olderThan = "1d";
|
|
applyReq.largerThan = "1KB";
|
|
applyReq.dryRun = false;
|
|
|
|
auto applyResult = yams::cli::run_sync(client.call<PruneRequest>(applyReq), 30s);
|
|
|
|
REQUIRE(applyResult.has_value());
|
|
CHECK_FALSE(applyResult.value().statusMessage.empty());
|
|
CHECK(applyResult.value().filesDeleted == 0);
|
|
CHECK(applyResult.value().filesFailed == 0);
|
|
|
|
const auto pruneDeadline = std::chrono::steady_clock::now() + 30s;
|
|
bool largeDocumentPruned = false;
|
|
while (std::chrono::steady_clock::now() < pruneDeadline) {
|
|
auto largeDocAfterPrune = metadataRepo->getDocumentByHash(largeResult.value().hash);
|
|
REQUIRE(largeDocAfterPrune.has_value());
|
|
if (!largeDocAfterPrune.value().has_value()) {
|
|
largeDocumentPruned = true;
|
|
break;
|
|
}
|
|
std::this_thread::sleep_for(25ms);
|
|
}
|
|
CHECK(largeDocumentPruned);
|
|
|
|
auto smallDocAfterPrune = metadataRepo->getDocumentByHash(addResult.value().hash);
|
|
REQUIRE(smallDocAfterPrune.has_value());
|
|
CHECK(smallDocAfterPrune.value().has_value());
|
|
}
|
|
|
|
TEST_CASE("Daemon client graph maintenance request execution",
|
|
"[daemon][socket][requests][graph]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
DaemonHarness harness;
|
|
startHarnessWithRetry(harness);
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
auto client = createClient(harness.socketPath());
|
|
|
|
yams::Result<void> connectResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
connectResult = yams::cli::run_sync(client.connect(), 5s);
|
|
if (connectResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(connectResult.has_value());
|
|
|
|
GraphRepairRequest repairReq;
|
|
repairReq.dryRun = true;
|
|
auto graphRepairResult = yams::cli::run_sync(client.graphRepair(repairReq), 30s);
|
|
|
|
REQUIRE(graphRepairResult.has_value());
|
|
CHECK(graphRepairResult.value().dryRun);
|
|
CHECK(graphRepairResult.value().errors == 0);
|
|
CHECK(std::any_of(
|
|
graphRepairResult.value().issues.begin(), graphRepairResult.value().issues.end(),
|
|
[](const auto& issue) { return issue.find("dry-run") != std::string::npos; }));
|
|
|
|
GraphValidateRequest validateReq;
|
|
auto graphValidateResult = yams::cli::run_sync(client.graphValidate(validateReq), 30s);
|
|
|
|
REQUIRE(graphValidateResult.has_value());
|
|
CHECK(graphValidateResult.value().totalNodes >= 0);
|
|
CHECK(graphValidateResult.value().totalEdges >= 0);
|
|
CHECK(graphValidateResult.value().issues.empty());
|
|
|
|
AddDocumentRequest addReq;
|
|
addReq.name = "graph_dispatcher_coverage.txt";
|
|
addReq.content = "graph maintenance coverage content\n";
|
|
addReq.tags = {"graph-coverage", "dispatcher"};
|
|
auto addResult = yams::cli::run_sync(client.streamingAddDocument(addReq), 10s);
|
|
|
|
REQUIRE(addResult.has_value());
|
|
REQUIRE_FALSE(addResult.value().hash.empty());
|
|
REQUIRE(getWithRetry(client, addResult.value().hash).has_value());
|
|
|
|
GraphRepairRequest applyRepairReq;
|
|
applyRepairReq.dryRun = false;
|
|
auto applyRepairResult = yams::cli::run_sync(client.graphRepair(applyRepairReq), 30s);
|
|
|
|
REQUIRE(applyRepairResult.has_value());
|
|
CHECK_FALSE(applyRepairResult.value().dryRun);
|
|
CHECK(applyRepairResult.value().errors == 0);
|
|
CHECK(applyRepairResult.value().nodesCreated >= 1);
|
|
CHECK(applyRepairResult.value().edgesCreated >= 1);
|
|
|
|
auto postRepairValidateResult = yams::cli::run_sync(client.graphValidate(validateReq), 30s);
|
|
|
|
REQUIRE(postRepairValidateResult.has_value());
|
|
CHECK(postRepairValidateResult.value().totalNodes >= 1);
|
|
CHECK(postRepairValidateResult.value().totalEdges >= 1);
|
|
}
|
|
|
|
TEST_CASE("Daemon client collection request execution", "[daemon][socket][requests][collections]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
DaemonHarness harness;
|
|
startHarnessWithRetry(harness);
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
auto client = createClient(harness.socketPath());
|
|
|
|
yams::Result<void> connectResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
connectResult = yams::cli::run_sync(client.connect(), 5s);
|
|
if (connectResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(connectResult.has_value());
|
|
|
|
const auto* daemon = harness.daemon();
|
|
REQUIRE(daemon != nullptr);
|
|
auto* serviceManager = daemon->getServiceManager();
|
|
REQUIRE(serviceManager != nullptr);
|
|
|
|
const std::string collectionName = "dispatcher-coverage-collection";
|
|
const std::string snapshotId = "dispatcher-coverage-snapshot";
|
|
|
|
AddDocumentRequest addReq;
|
|
addReq.name = "dispatcher_collection_coverage.txt";
|
|
addReq.content = "collection coverage content\n";
|
|
addReq.collection = collectionName;
|
|
addReq.snapshotId = snapshotId;
|
|
addReq.snapshotLabel = "Dispatcher coverage snapshot";
|
|
|
|
auto addResult = yams::cli::run_sync(client.streamingAddDocument(addReq), 10s);
|
|
REQUIRE(addResult.has_value());
|
|
REQUIRE_FALSE(addResult.value().hash.empty());
|
|
|
|
auto getResult = getWithRetry(client, addResult.value().hash);
|
|
REQUIRE(getResult.has_value());
|
|
|
|
ListSnapshotsRequest listReq;
|
|
auto listResult = yams::cli::run_sync(client.call<ListSnapshotsRequest>(listReq), 10s);
|
|
|
|
REQUIRE(listResult.has_value());
|
|
CHECK(listResult.value().totalCount >= 1);
|
|
CHECK(std::any_of(listResult.value().snapshots.begin(), listResult.value().snapshots.end(),
|
|
[&](const SnapshotInfo& info) { return info.id == snapshotId; }));
|
|
|
|
MetadataValueCountsRequest countsReq;
|
|
countsReq.keys = {"collection", "snapshot_id"};
|
|
auto countsResult =
|
|
yams::cli::run_sync(client.call<MetadataValueCountsRequest>(countsReq), 10s);
|
|
|
|
REQUIRE(countsResult.has_value());
|
|
REQUIRE(countsResult.value().valueCounts.count("collection") == 1);
|
|
REQUIRE(countsResult.value().valueCounts.count("snapshot_id") == 1);
|
|
CHECK(std::any_of(
|
|
countsResult.value().valueCounts.at("collection").begin(),
|
|
countsResult.value().valueCounts.at("collection").end(),
|
|
[&](const auto& entry) { return entry.first == collectionName && entry.second >= 1; }));
|
|
CHECK(std::any_of(
|
|
countsResult.value().valueCounts.at("snapshot_id").begin(),
|
|
countsResult.value().valueCounts.at("snapshot_id").end(),
|
|
[&](const auto& entry) { return entry.first == snapshotId && entry.second >= 1; }));
|
|
|
|
MetadataValueCountsRequest emptyCountsReq;
|
|
auto emptyCountsResult =
|
|
yams::cli::run_sync(client.call<MetadataValueCountsRequest>(emptyCountsReq), 10s);
|
|
|
|
REQUIRE(emptyCountsResult.has_value());
|
|
CHECK(emptyCountsResult.value().valueCounts.empty());
|
|
|
|
RestoreCollectionRequest missingCollectionReq;
|
|
missingCollectionReq.collection = "dispatcher-missing-collection";
|
|
missingCollectionReq.outputDirectory = "collection-restore-missing";
|
|
missingCollectionReq.dryRun = true;
|
|
auto missingCollectionResult =
|
|
yams::cli::run_sync(client.call<RestoreCollectionRequest>(missingCollectionReq), 30s);
|
|
|
|
REQUIRE(missingCollectionResult.has_value());
|
|
CHECK(missingCollectionResult.value().dryRun);
|
|
CHECK(missingCollectionResult.value().filesRestored == 0);
|
|
CHECK(missingCollectionResult.value().files.empty());
|
|
|
|
RestoreCollectionRequest restoreCollectionReq;
|
|
restoreCollectionReq.collection = collectionName;
|
|
restoreCollectionReq.outputDirectory = "collection-restore-out";
|
|
restoreCollectionReq.layoutTemplate = "{name}{ext}";
|
|
restoreCollectionReq.dryRun = true;
|
|
auto restoreCollectionResult =
|
|
yams::cli::run_sync(client.call<RestoreCollectionRequest>(restoreCollectionReq), 30s);
|
|
|
|
REQUIRE(restoreCollectionResult.has_value());
|
|
CHECK(restoreCollectionResult.value().dryRun);
|
|
CHECK(restoreCollectionResult.value().filesRestored == 1);
|
|
REQUIRE(restoreCollectionResult.value().files.size() == 1);
|
|
CHECK(restoreCollectionResult.value().files.front().hash == addResult.value().hash);
|
|
CHECK_FALSE(restoreCollectionResult.value().files.front().skipped);
|
|
CHECK(restoreCollectionResult.value().files.front().path.find(
|
|
"dispatcher_collection_coverage") != std::string::npos);
|
|
|
|
const auto existingCollectionOut =
|
|
std::filesystem::temp_directory_path() / "yams_dispatcher_collection_existing.txt";
|
|
{
|
|
std::ofstream existing(existingCollectionOut);
|
|
REQUIRE(existing.good());
|
|
existing << "existing collection content";
|
|
}
|
|
|
|
RestoreCollectionRequest existingCollectionReq;
|
|
existingCollectionReq.collection = collectionName;
|
|
existingCollectionReq.outputDirectory = existingCollectionOut.parent_path().string();
|
|
existingCollectionReq.layoutTemplate = existingCollectionOut.filename().string();
|
|
existingCollectionReq.dryRun = false;
|
|
auto existingCollectionResult =
|
|
yams::cli::run_sync(client.call<RestoreCollectionRequest>(existingCollectionReq), 30s);
|
|
|
|
REQUIRE(existingCollectionResult.has_value());
|
|
REQUIRE(existingCollectionResult.value().files.size() == 1);
|
|
CHECK(existingCollectionResult.value().filesRestored == 0);
|
|
CHECK(existingCollectionResult.value().files.front().skipped);
|
|
CHECK(existingCollectionResult.value().files.front().skipReason ==
|
|
"File exists (overwrite=false)");
|
|
|
|
auto metadataRepo = serviceManager->getMetadataRepo();
|
|
REQUIRE(metadataRepo != nullptr);
|
|
|
|
auto originalCollectionDocResult = metadataRepo->getDocumentByHash(addResult.value().hash);
|
|
REQUIRE(originalCollectionDocResult.has_value());
|
|
REQUIRE(originalCollectionDocResult.value().has_value());
|
|
const auto originalCollectionDoc = *originalCollectionDocResult.value();
|
|
|
|
auto missingCollectionDoc = originalCollectionDoc;
|
|
missingCollectionDoc.sha256Hash = std::string(64, 'a');
|
|
REQUIRE(metadataRepo->updateDocument(missingCollectionDoc).has_value());
|
|
|
|
const auto missingCollectionOut = std::filesystem::temp_directory_path() /
|
|
"yams_dispatcher_missing_collection_root" /
|
|
"missing_collection" / "restored.txt";
|
|
RestoreCollectionRequest missingContentCollectionReq;
|
|
missingContentCollectionReq.collection = collectionName;
|
|
missingContentCollectionReq.outputDirectory =
|
|
(missingCollectionOut.parent_path().parent_path()).string();
|
|
missingContentCollectionReq.layoutTemplate = "missing_collection/restored.txt";
|
|
missingContentCollectionReq.overwrite = true;
|
|
missingContentCollectionReq.dryRun = false;
|
|
auto missingContentCollectionResult = yams::cli::run_sync(
|
|
client.call<RestoreCollectionRequest>(missingContentCollectionReq), 30s);
|
|
|
|
REQUIRE(missingContentCollectionResult.has_value());
|
|
REQUIRE(missingContentCollectionResult.value().files.size() == 1);
|
|
CHECK(missingContentCollectionResult.value().filesRestored == 0);
|
|
CHECK(missingContentCollectionResult.value().files.front().skipped);
|
|
CHECK(missingContentCollectionResult.value().files.front().skipReason.find(
|
|
"Failed to retrieve content") != std::string::npos);
|
|
|
|
REQUIRE(metadataRepo->updateDocument(originalCollectionDoc).has_value());
|
|
|
|
RestoreCollectionRequest filteredCollectionReq;
|
|
filteredCollectionReq.collection = collectionName;
|
|
filteredCollectionReq.outputDirectory = "collection-restore-filtered";
|
|
filteredCollectionReq.includePatterns = {"*.md"};
|
|
filteredCollectionReq.dryRun = true;
|
|
auto filteredCollectionResult =
|
|
yams::cli::run_sync(client.call<RestoreCollectionRequest>(filteredCollectionReq), 30s);
|
|
|
|
REQUIRE(filteredCollectionResult.has_value());
|
|
CHECK(filteredCollectionResult.value().filesRestored == 0);
|
|
CHECK(filteredCollectionResult.value().files.empty());
|
|
|
|
RestoreCollectionRequest invalidCollectionReq;
|
|
invalidCollectionReq.outputDirectory = "collection-restore-invalid";
|
|
invalidCollectionReq.dryRun = true;
|
|
auto invalidCollectionResult =
|
|
yams::cli::run_sync(client.executeRequest(Request{invalidCollectionReq}), 30s);
|
|
|
|
REQUIRE(invalidCollectionResult.has_value());
|
|
REQUIRE(std::holds_alternative<ErrorResponse>(invalidCollectionResult.value()));
|
|
CHECK(std::get<ErrorResponse>(invalidCollectionResult.value()).code ==
|
|
yams::ErrorCode::InvalidArgument);
|
|
|
|
RestoreSnapshotRequest restoreSnapshotReq;
|
|
restoreSnapshotReq.snapshotId = snapshotId;
|
|
restoreSnapshotReq.outputDirectory = "snapshot-restore-out";
|
|
restoreSnapshotReq.layoutTemplate = "{hash}{ext}";
|
|
restoreSnapshotReq.dryRun = true;
|
|
auto restoreSnapshotResult =
|
|
yams::cli::run_sync(client.call<RestoreSnapshotRequest>(restoreSnapshotReq), 30s);
|
|
|
|
REQUIRE(restoreSnapshotResult.has_value());
|
|
CHECK(restoreSnapshotResult.value().dryRun);
|
|
CHECK(restoreSnapshotResult.value().filesRestored == 1);
|
|
REQUIRE(restoreSnapshotResult.value().files.size() == 1);
|
|
CHECK(restoreSnapshotResult.value().files.front().hash == addResult.value().hash);
|
|
CHECK_FALSE(restoreSnapshotResult.value().files.front().skipped);
|
|
CHECK(restoreSnapshotResult.value().files.front().path.find(addResult.value().hash) !=
|
|
std::string::npos);
|
|
|
|
const auto existingSnapshotOut =
|
|
std::filesystem::temp_directory_path() / "yams_dispatcher_snapshot_existing.txt";
|
|
{
|
|
std::ofstream existing(existingSnapshotOut);
|
|
REQUIRE(existing.good());
|
|
existing << "existing snapshot content";
|
|
}
|
|
|
|
RestoreSnapshotRequest existingSnapshotReq;
|
|
existingSnapshotReq.snapshotId = snapshotId;
|
|
existingSnapshotReq.outputDirectory = existingSnapshotOut.parent_path().string();
|
|
existingSnapshotReq.layoutTemplate = existingSnapshotOut.filename().string();
|
|
existingSnapshotReq.dryRun = false;
|
|
auto existingSnapshotResult =
|
|
yams::cli::run_sync(client.call<RestoreSnapshotRequest>(existingSnapshotReq), 30s);
|
|
|
|
REQUIRE(existingSnapshotResult.has_value());
|
|
REQUIRE(existingSnapshotResult.value().files.size() == 1);
|
|
CHECK(existingSnapshotResult.value().filesRestored == 0);
|
|
CHECK(existingSnapshotResult.value().files.front().skipped);
|
|
CHECK(existingSnapshotResult.value().files.front().skipReason ==
|
|
"File exists (overwrite=false)");
|
|
|
|
auto originalSnapshotDocResult = metadataRepo->getDocumentByHash(addResult.value().hash);
|
|
REQUIRE(originalSnapshotDocResult.has_value());
|
|
REQUIRE(originalSnapshotDocResult.value().has_value());
|
|
const auto originalSnapshotDoc = *originalSnapshotDocResult.value();
|
|
|
|
auto missingSnapshotDoc = originalSnapshotDoc;
|
|
missingSnapshotDoc.sha256Hash = std::string(64, 'b');
|
|
REQUIRE(metadataRepo->updateDocument(missingSnapshotDoc).has_value());
|
|
|
|
const auto missingSnapshotOut = std::filesystem::temp_directory_path() /
|
|
"yams_dispatcher_missing_snapshot_root" / "missing_snapshot" /
|
|
"restored.txt";
|
|
RestoreSnapshotRequest missingContentSnapshotReq;
|
|
missingContentSnapshotReq.snapshotId = snapshotId;
|
|
missingContentSnapshotReq.outputDirectory =
|
|
(missingSnapshotOut.parent_path().parent_path()).string();
|
|
missingContentSnapshotReq.layoutTemplate = "missing_snapshot/restored.txt";
|
|
missingContentSnapshotReq.overwrite = true;
|
|
missingContentSnapshotReq.dryRun = false;
|
|
auto missingContentSnapshotResult =
|
|
yams::cli::run_sync(client.call<RestoreSnapshotRequest>(missingContentSnapshotReq), 30s);
|
|
|
|
REQUIRE(missingContentSnapshotResult.has_value());
|
|
REQUIRE(missingContentSnapshotResult.value().files.size() == 1);
|
|
CHECK(missingContentSnapshotResult.value().filesRestored == 0);
|
|
CHECK(missingContentSnapshotResult.value().files.front().skipped);
|
|
CHECK(missingContentSnapshotResult.value().files.front().skipReason.find(
|
|
"Failed to retrieve content") != std::string::npos);
|
|
|
|
REQUIRE(metadataRepo->updateDocument(originalSnapshotDoc).has_value());
|
|
|
|
RestoreSnapshotRequest filteredSnapshotReq;
|
|
filteredSnapshotReq.snapshotId = snapshotId;
|
|
filteredSnapshotReq.outputDirectory = "snapshot-restore-filtered";
|
|
filteredSnapshotReq.excludePatterns = {"*coverage.txt"};
|
|
filteredSnapshotReq.dryRun = true;
|
|
auto filteredSnapshotResult =
|
|
yams::cli::run_sync(client.call<RestoreSnapshotRequest>(filteredSnapshotReq), 30s);
|
|
|
|
REQUIRE(filteredSnapshotResult.has_value());
|
|
CHECK(filteredSnapshotResult.value().filesRestored == 0);
|
|
CHECK(filteredSnapshotResult.value().files.empty());
|
|
|
|
RestoreSnapshotRequest missingSnapshotReq;
|
|
missingSnapshotReq.snapshotId = "dispatcher-missing-snapshot";
|
|
missingSnapshotReq.outputDirectory = "snapshot-restore-missing";
|
|
missingSnapshotReq.dryRun = true;
|
|
auto missingSnapshotResult =
|
|
yams::cli::run_sync(client.call<RestoreSnapshotRequest>(missingSnapshotReq), 30s);
|
|
|
|
REQUIRE(missingSnapshotResult.has_value());
|
|
CHECK(missingSnapshotResult.value().filesRestored == 0);
|
|
CHECK(missingSnapshotResult.value().files.empty());
|
|
|
|
RestoreSnapshotRequest invalidSnapshotReq;
|
|
invalidSnapshotReq.outputDirectory = "snapshot-restore-invalid";
|
|
invalidSnapshotReq.dryRun = true;
|
|
auto invalidSnapshotResult =
|
|
yams::cli::run_sync(client.executeRequest(Request{invalidSnapshotReq}), 30s);
|
|
|
|
REQUIRE(invalidSnapshotResult.has_value());
|
|
REQUIRE(std::holds_alternative<ErrorResponse>(invalidSnapshotResult.value()));
|
|
CHECK(std::get<ErrorResponse>(invalidSnapshotResult.value()).code ==
|
|
yams::ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
TEST_CASE("Daemon client session request execution", "[daemon][socket][requests][session]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
DaemonHarness harness({.isolateState = true});
|
|
startHarnessWithRetry(harness);
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
auto client = createClient(harness.socketPath());
|
|
|
|
yams::Result<void> connectResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
connectResult = yams::cli::run_sync(client.connect(), 5s);
|
|
if (connectResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(connectResult.has_value());
|
|
|
|
const auto* daemon = harness.daemon();
|
|
REQUIRE(daemon != nullptr);
|
|
auto* serviceManager = daemon->getServiceManager();
|
|
REQUIRE(serviceManager != nullptr);
|
|
|
|
auto appContext = serviceManager->getAppContext();
|
|
auto sessionService = yams::app::services::makeSessionService(&appContext);
|
|
REQUIRE(sessionService);
|
|
|
|
const std::string sessionA = "dispatcher-session-a";
|
|
const std::string sessionB = "dispatcher-session-b";
|
|
const std::string selectorPath = "src/**/*.cpp";
|
|
|
|
sessionService->init(sessionA, "dispatcher coverage session A");
|
|
sessionService->create(sessionB, "dispatcher coverage session B");
|
|
|
|
auto listResult =
|
|
yams::cli::run_sync(client.executeRequest(Request{ListSessionsRequest{}}), 10s);
|
|
REQUIRE(listResult.has_value());
|
|
REQUIRE(std::holds_alternative<ListSessionsResponse>(listResult.value()));
|
|
const auto& listResp = std::get<ListSessionsResponse>(listResult.value());
|
|
std::set<std::string> sessionNames(listResp.session_names.begin(),
|
|
listResp.session_names.end());
|
|
CHECK(sessionNames.contains(sessionA));
|
|
CHECK(sessionNames.contains(sessionB));
|
|
CHECK(listResp.current_session == sessionA);
|
|
|
|
UseSessionRequest missingReq;
|
|
missingReq.session_name = "dispatcher-session-missing";
|
|
auto missingResult = yams::cli::run_sync(client.executeRequest(Request{missingReq}), 10s);
|
|
REQUIRE(missingResult.has_value());
|
|
REQUIRE(std::holds_alternative<ErrorResponse>(missingResult.value()));
|
|
CHECK(std::get<ErrorResponse>(missingResult.value()).code == yams::ErrorCode::NotFound);
|
|
|
|
sessionService->close();
|
|
|
|
auto noCurrentListResult =
|
|
yams::cli::run_sync(client.executeRequest(Request{ListSessionsRequest{}}), 10s);
|
|
REQUIRE(noCurrentListResult.has_value());
|
|
REQUIRE(std::holds_alternative<ListSessionsResponse>(noCurrentListResult.value()));
|
|
CHECK(std::get<ListSessionsResponse>(noCurrentListResult.value()).current_session.empty());
|
|
|
|
AddPathSelectorRequest noCurrentReq;
|
|
noCurrentReq.path = selectorPath;
|
|
auto noCurrentResult = yams::cli::run_sync(client.executeRequest(Request{noCurrentReq}), 10s);
|
|
REQUIRE(noCurrentResult.has_value());
|
|
REQUIRE(std::holds_alternative<ErrorResponse>(noCurrentResult.value()));
|
|
CHECK(std::get<ErrorResponse>(noCurrentResult.value()).code == yams::ErrorCode::InvalidState);
|
|
|
|
RemovePathSelectorRequest noCurrentRemoveReq;
|
|
noCurrentRemoveReq.path = selectorPath;
|
|
auto noCurrentRemoveResult =
|
|
yams::cli::run_sync(client.executeRequest(Request{noCurrentRemoveReq}), 10s);
|
|
REQUIRE(noCurrentRemoveResult.has_value());
|
|
REQUIRE(std::holds_alternative<ErrorResponse>(noCurrentRemoveResult.value()));
|
|
CHECK(std::get<ErrorResponse>(noCurrentRemoveResult.value()).code ==
|
|
yams::ErrorCode::InvalidState);
|
|
|
|
UseSessionRequest useReq;
|
|
useReq.session_name = sessionB;
|
|
auto useResult = yams::cli::run_sync(client.executeRequest(Request{useReq}), 10s);
|
|
REQUIRE(useResult.has_value());
|
|
REQUIRE(std::holds_alternative<SuccessResponse>(useResult.value()));
|
|
CHECK(std::get<SuccessResponse>(useResult.value()).message == "OK");
|
|
REQUIRE(sessionService->current().has_value());
|
|
CHECK(sessionService->current().value() == sessionB);
|
|
|
|
AddPathSelectorRequest missingSessionAddReq;
|
|
missingSessionAddReq.session_name = "dispatcher-session-missing";
|
|
missingSessionAddReq.path = "missing/**/*.txt";
|
|
auto missingSessionAddResult =
|
|
yams::cli::run_sync(client.executeRequest(Request{missingSessionAddReq}), 10s);
|
|
REQUIRE(missingSessionAddResult.has_value());
|
|
REQUIRE(std::holds_alternative<ErrorResponse>(missingSessionAddResult.value()));
|
|
CHECK(std::get<ErrorResponse>(missingSessionAddResult.value()).code ==
|
|
yams::ErrorCode::NotFound);
|
|
|
|
AddPathSelectorRequest addReq;
|
|
addReq.path = selectorPath;
|
|
addReq.tags = {"pinned", "coverage"};
|
|
addReq.metadata = {{"team", "daemon"}, {"task", "dispatcher-session"}};
|
|
auto addResult = yams::cli::run_sync(client.executeRequest(Request{addReq}), 10s);
|
|
REQUIRE(addResult.has_value());
|
|
REQUIRE(std::holds_alternative<SuccessResponse>(addResult.value()));
|
|
CHECK(std::get<SuccessResponse>(addResult.value()).message == "OK");
|
|
auto selectors = sessionService->listPathSelectors(sessionB);
|
|
REQUIRE(selectors.size() == 1);
|
|
CHECK(selectors.front() == selectorPath);
|
|
|
|
AddPathSelectorRequest explicitSessionAddReq;
|
|
explicitSessionAddReq.session_name = sessionB;
|
|
explicitSessionAddReq.path = "include/**/*.hpp";
|
|
explicitSessionAddReq.tags = {"explicit"};
|
|
auto explicitSessionAddResult =
|
|
yams::cli::run_sync(client.executeRequest(Request{explicitSessionAddReq}), 10s);
|
|
REQUIRE(explicitSessionAddResult.has_value());
|
|
REQUIRE(std::holds_alternative<SuccessResponse>(explicitSessionAddResult.value()));
|
|
|
|
selectors = sessionService->listPathSelectors(sessionB);
|
|
REQUIRE(selectors.size() == 2);
|
|
CHECK(std::find(selectors.begin(), selectors.end(), "include/**/*.hpp") != selectors.end());
|
|
|
|
RemovePathSelectorRequest missingSessionRemoveReq;
|
|
missingSessionRemoveReq.session_name = "dispatcher-session-missing";
|
|
missingSessionRemoveReq.path = selectorPath;
|
|
auto missingSessionRemoveResult =
|
|
yams::cli::run_sync(client.executeRequest(Request{missingSessionRemoveReq}), 10s);
|
|
REQUIRE(missingSessionRemoveResult.has_value());
|
|
REQUIRE(std::holds_alternative<ErrorResponse>(missingSessionRemoveResult.value()));
|
|
CHECK(std::get<ErrorResponse>(missingSessionRemoveResult.value()).code ==
|
|
yams::ErrorCode::NotFound);
|
|
|
|
RemovePathSelectorRequest explicitRemoveReq;
|
|
explicitRemoveReq.session_name = sessionB;
|
|
explicitRemoveReq.path = "include/**/*.hpp";
|
|
auto explicitRemoveResult =
|
|
yams::cli::run_sync(client.executeRequest(Request{explicitRemoveReq}), 10s);
|
|
REQUIRE(explicitRemoveResult.has_value());
|
|
REQUIRE(std::holds_alternative<SuccessResponse>(explicitRemoveResult.value()));
|
|
|
|
RemovePathSelectorRequest removeReq;
|
|
removeReq.path = selectorPath;
|
|
auto removeResult = yams::cli::run_sync(client.executeRequest(Request{removeReq}), 10s);
|
|
REQUIRE(removeResult.has_value());
|
|
REQUIRE(std::holds_alternative<SuccessResponse>(removeResult.value()));
|
|
CHECK(std::get<SuccessResponse>(removeResult.value()).message == "OK");
|
|
CHECK(sessionService->listPathSelectors(sessionB).empty());
|
|
}
|
|
|
|
TEST_CASE("Daemon client repair request execution", "[daemon][socket][requests][repair]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
DaemonHarness harness({.enableModelProvider = false, .useMockModelProvider = false});
|
|
startHarnessWithRetry(harness);
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
auto client = createClient(harness.socketPath());
|
|
|
|
yams::Result<void> connectResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
connectResult = yams::cli::run_sync(client.connect(), 5s);
|
|
if (connectResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(connectResult.has_value());
|
|
|
|
const auto* daemon = harness.daemon();
|
|
REQUIRE(daemon != nullptr);
|
|
auto* serviceManager = const_cast<yams::daemon::ServiceManager*>(daemon->getServiceManager());
|
|
REQUIRE(serviceManager != nullptr);
|
|
|
|
std::shared_ptr<yams::daemon::RepairService> repairService;
|
|
for (int attempt = 0; attempt < 30; ++attempt) {
|
|
repairService = serviceManager->getRepairServiceShared();
|
|
if (repairService) {
|
|
break;
|
|
}
|
|
std::this_thread::sleep_for(100ms);
|
|
}
|
|
REQUIRE(repairService != nullptr);
|
|
|
|
auto injectedProvider = std::make_shared<StubModelProvider>();
|
|
REQUIRE(injectedProvider != nullptr);
|
|
serviceManager->__test_setModelProvider(injectedProvider);
|
|
REQUIRE(injectedProvider->loadModel("repair-embed-model").has_value());
|
|
|
|
EmbedDocumentsRequest embedReq;
|
|
embedReq.documentHashes = {std::string(64, 'a')};
|
|
embedReq.modelName = "repair-embed-model";
|
|
embedReq.normalize = true;
|
|
embedReq.batchSize = 8;
|
|
embedReq.skipExisting = false;
|
|
|
|
auto embedResult = yams::cli::run_sync(client.call(embedReq), 10s);
|
|
REQUIRE_FALSE(embedResult.has_value());
|
|
CHECK(embedResult.error().message.find(
|
|
"No requested documents were found in the daemon metadata repository") !=
|
|
std::string::npos);
|
|
|
|
RepairRequest repairReq;
|
|
repairReq.repairOrphans = true;
|
|
repairReq.dryRun = true;
|
|
repairReq.verbose = true;
|
|
|
|
std::vector<RepairEvent> events;
|
|
auto repairResult = yams::cli::run_sync(
|
|
client.callRepair(repairReq,
|
|
[&events](const RepairEvent& event) { events.push_back(event); }),
|
|
30s);
|
|
|
|
REQUIRE(repairResult.has_value());
|
|
CHECK(repairResult.value().success);
|
|
CHECK(repairResult.value().totalOperations == 1);
|
|
CHECK(repairResult.value().operationResults.size() == 1);
|
|
CHECK(repairResult.value().operationResults.front().operation == "orphans");
|
|
CHECK_FALSE(events.empty());
|
|
CHECK(std::any_of(events.begin(), events.end(), [](const RepairEvent& event) {
|
|
return event.phase == "repairing" && event.operation == "orphans";
|
|
}));
|
|
CHECK(std::any_of(events.begin(), events.end(), [](const RepairEvent& event) {
|
|
return event.phase == "completed" && event.operation == "orphans";
|
|
}));
|
|
}
|
|
|
|
TEST_CASE("Daemon client tree diff request execution", "[daemon][socket][requests][tree-diff]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
DaemonHarness harness;
|
|
startHarnessWithRetry(harness);
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
auto client = createClient(harness.socketPath());
|
|
|
|
yams::Result<void> connectResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
connectResult = yams::cli::run_sync(client.connect(), 5s);
|
|
if (connectResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(connectResult.has_value());
|
|
const auto* daemon = harness.daemon();
|
|
REQUIRE(daemon != nullptr);
|
|
const auto* serviceManager = daemon->getServiceManager();
|
|
REQUIRE(serviceManager != nullptr);
|
|
auto metadataRepo = serviceManager->getMetadataRepo();
|
|
REQUIRE(metadataRepo != nullptr);
|
|
|
|
const std::string baseSnapshotId = "dispatcher-coverage-base";
|
|
const std::string targetSnapshotId = "dispatcher-coverage-target";
|
|
|
|
const auto nowMicros = std::chrono::duration_cast<std::chrono::microseconds>(
|
|
std::chrono::system_clock::now().time_since_epoch())
|
|
.count();
|
|
|
|
yams::metadata::TreeSnapshotRecord baseSnapshot;
|
|
baseSnapshot.snapshotId = baseSnapshotId;
|
|
baseSnapshot.createdTime = nowMicros;
|
|
baseSnapshot.metadata["directory_path"] = "/tmp/dispatcher-base";
|
|
REQUIRE(metadataRepo->upsertTreeSnapshot(baseSnapshot).has_value());
|
|
|
|
yams::metadata::TreeSnapshotRecord targetSnapshot;
|
|
targetSnapshot.snapshotId = targetSnapshotId;
|
|
targetSnapshot.createdTime = nowMicros + 1;
|
|
targetSnapshot.metadata["directory_path"] = "/tmp/dispatcher-target";
|
|
REQUIRE(metadataRepo->upsertTreeSnapshot(targetSnapshot).has_value());
|
|
|
|
yams::metadata::TreeDiffDescriptor descriptor;
|
|
descriptor.baseSnapshotId = baseSnapshotId;
|
|
descriptor.targetSnapshotId = targetSnapshotId;
|
|
descriptor.computedAt = nowMicros + 2;
|
|
descriptor.status = "pending";
|
|
|
|
auto diffIdResult = metadataRepo->beginTreeDiff(descriptor);
|
|
REQUIRE(diffIdResult.has_value());
|
|
|
|
std::vector<yams::metadata::TreeChangeRecord> changes;
|
|
|
|
yams::metadata::TreeChangeRecord added;
|
|
added.type = yams::metadata::TreeChangeType::Added;
|
|
added.newPath = "src/new_file.cpp";
|
|
added.newHash = "hash-added";
|
|
added.mode = 0644;
|
|
changes.push_back(added);
|
|
|
|
yams::metadata::TreeChangeRecord modified;
|
|
modified.type = yams::metadata::TreeChangeType::Modified;
|
|
modified.oldPath = "src/existing.cpp";
|
|
modified.newPath = "src/existing.cpp";
|
|
modified.oldHash = "hash-old";
|
|
modified.newHash = "hash-new";
|
|
modified.mode = 0644;
|
|
modified.contentDeltaHash = "delta-existing";
|
|
changes.push_back(modified);
|
|
|
|
yams::metadata::TreeChangeRecord renamed;
|
|
renamed.type = yams::metadata::TreeChangeType::Renamed;
|
|
renamed.oldPath = "src/old_name.cpp";
|
|
renamed.newPath = "src/new_name.cpp";
|
|
renamed.oldHash = "hash-rename";
|
|
renamed.newHash = "hash-rename";
|
|
renamed.mode = 0644;
|
|
changes.push_back(renamed);
|
|
|
|
yams::metadata::TreeChangeRecord deleted;
|
|
deleted.type = yams::metadata::TreeChangeType::Deleted;
|
|
deleted.oldPath = "src/removed.cpp";
|
|
deleted.oldHash = "hash-deleted";
|
|
deleted.mode = 0644;
|
|
changes.push_back(deleted);
|
|
|
|
yams::metadata::TreeChangeRecord moved;
|
|
moved.type = yams::metadata::TreeChangeType::Moved;
|
|
moved.oldPath = "include/old_header.hpp";
|
|
moved.newPath = "include/new_header.hpp";
|
|
moved.oldHash = "hash-moved";
|
|
moved.newHash = "hash-moved";
|
|
moved.mode = 0644;
|
|
changes.push_back(moved);
|
|
|
|
auto appendResult = metadataRepo->appendTreeChanges(diffIdResult.value(), changes);
|
|
REQUIRE(appendResult.has_value());
|
|
|
|
auto finalizeResult =
|
|
metadataRepo->finalizeTreeDiff(diffIdResult.value(), changes.size(), "complete");
|
|
REQUIRE(finalizeResult.has_value());
|
|
|
|
yams::metadata::TreeDiffQuery directQuery;
|
|
directQuery.baseSnapshotId = baseSnapshotId;
|
|
directQuery.targetSnapshotId = targetSnapshotId;
|
|
directQuery.limit = 20000;
|
|
auto directListResult = metadataRepo->listTreeChanges(directQuery);
|
|
if (!directListResult.has_value()) {
|
|
INFO("direct listTreeChanges error: " << directListResult.error().message);
|
|
}
|
|
REQUIRE(directListResult.has_value());
|
|
CHECK(directListResult.value().size() == 5);
|
|
|
|
ListTreeDiffRequest listReq;
|
|
listReq.baseSnapshotId = baseSnapshotId;
|
|
listReq.targetSnapshotId = targetSnapshotId;
|
|
listReq.limit = 20000;
|
|
auto listResult = yams::cli::run_sync(client.executeRequest(Request{listReq}), 10s);
|
|
|
|
INFO("listTreeDiff has_value=" << listResult.has_value());
|
|
if (!listResult.has_value()) {
|
|
INFO("listTreeDiff transport error: " << listResult.error().message);
|
|
}
|
|
REQUIRE(listResult.has_value());
|
|
if (std::holds_alternative<ErrorResponse>(listResult.value())) {
|
|
INFO("listTreeDiff daemon error: " << std::get<ErrorResponse>(listResult.value()).message);
|
|
}
|
|
REQUIRE(std::holds_alternative<ListTreeDiffResponse>(listResult.value()));
|
|
const auto& listResp = std::get<ListTreeDiffResponse>(listResult.value());
|
|
CHECK(listResp.totalCount == 5);
|
|
REQUIRE(listResp.changes.size() == 5);
|
|
CHECK(std::any_of(listResp.changes.begin(), listResp.changes.end(), [](const auto& entry) {
|
|
return entry.changeType == "added" && entry.path == "src/new_file.cpp" &&
|
|
entry.oldPath.empty();
|
|
}));
|
|
CHECK(std::any_of(listResp.changes.begin(), listResp.changes.end(), [](const auto& entry) {
|
|
return entry.changeType == "modified" && entry.path == "src/existing.cpp" &&
|
|
entry.oldPath.empty() && entry.contentDeltaHash == "delta-existing";
|
|
}));
|
|
CHECK(std::any_of(listResp.changes.begin(), listResp.changes.end(), [](const auto& entry) {
|
|
return entry.changeType == "renamed" && entry.path == "src/new_name.cpp" &&
|
|
entry.oldPath == "src/old_name.cpp";
|
|
}));
|
|
CHECK(std::any_of(listResp.changes.begin(), listResp.changes.end(), [](const auto& entry) {
|
|
return entry.changeType == "deleted" && entry.path.empty() &&
|
|
entry.oldPath == "src/removed.cpp";
|
|
}));
|
|
CHECK(std::any_of(listResp.changes.begin(), listResp.changes.end(), [](const auto& entry) {
|
|
return entry.changeType == "unknown" && entry.path == "include/new_header.hpp" &&
|
|
entry.oldPath == "include/old_header.hpp";
|
|
}));
|
|
|
|
ListTreeDiffRequest filteredReq;
|
|
filteredReq.baseSnapshotId = baseSnapshotId;
|
|
filteredReq.targetSnapshotId = targetSnapshotId;
|
|
filteredReq.pathPrefix = "src/new";
|
|
filteredReq.typeFilter = "renamed";
|
|
filteredReq.limit = 10;
|
|
auto filteredResult = yams::cli::run_sync(client.executeRequest(Request{filteredReq}), 10s);
|
|
|
|
INFO("filteredTreeDiff has_value=" << filteredResult.has_value());
|
|
if (!filteredResult.has_value()) {
|
|
INFO("filteredTreeDiff transport error: " << filteredResult.error().message);
|
|
}
|
|
REQUIRE(filteredResult.has_value());
|
|
if (std::holds_alternative<ErrorResponse>(filteredResult.value())) {
|
|
INFO("filteredTreeDiff daemon error: "
|
|
<< std::get<ErrorResponse>(filteredResult.value()).message);
|
|
}
|
|
REQUIRE(std::holds_alternative<ListTreeDiffResponse>(filteredResult.value()));
|
|
const auto& filteredResp = std::get<ListTreeDiffResponse>(filteredResult.value());
|
|
CHECK(filteredResp.totalCount == 1);
|
|
REQUIRE(filteredResp.changes.size() == 1);
|
|
CHECK(filteredResp.changes.front().changeType == "renamed");
|
|
CHECK(filteredResp.changes.front().path == "src/new_name.cpp");
|
|
CHECK(filteredResp.changes.front().oldPath == "src/old_name.cpp");
|
|
|
|
ListTreeDiffRequest unknownFilterReq;
|
|
unknownFilterReq.baseSnapshotId = baseSnapshotId;
|
|
unknownFilterReq.targetSnapshotId = targetSnapshotId;
|
|
unknownFilterReq.pathPrefix = "include/new";
|
|
unknownFilterReq.typeFilter = "moved";
|
|
unknownFilterReq.limit = 10;
|
|
auto unknownFilterResult =
|
|
yams::cli::run_sync(client.executeRequest(Request{unknownFilterReq}), 10s);
|
|
|
|
REQUIRE(unknownFilterResult.has_value());
|
|
REQUIRE(std::holds_alternative<ListTreeDiffResponse>(unknownFilterResult.value()));
|
|
const auto& unknownFilterResp = std::get<ListTreeDiffResponse>(unknownFilterResult.value());
|
|
CHECK(unknownFilterResp.totalCount == 1);
|
|
REQUIRE(unknownFilterResp.changes.size() == 1);
|
|
CHECK(unknownFilterResp.changes.front().changeType == "unknown");
|
|
CHECK(unknownFilterResp.changes.front().path == "include/new_header.hpp");
|
|
CHECK(unknownFilterResp.changes.front().oldPath == "include/old_header.hpp");
|
|
|
|
ListTreeDiffRequest pagedReq;
|
|
pagedReq.baseSnapshotId = baseSnapshotId;
|
|
pagedReq.targetSnapshotId = targetSnapshotId;
|
|
pagedReq.limit = 1;
|
|
pagedReq.offset = 1;
|
|
auto pagedResult = yams::cli::run_sync(client.executeRequest(Request{pagedReq}), 10s);
|
|
|
|
REQUIRE(pagedResult.has_value());
|
|
REQUIRE(std::holds_alternative<ListTreeDiffResponse>(pagedResult.value()));
|
|
const auto& pagedResp = std::get<ListTreeDiffResponse>(pagedResult.value());
|
|
CHECK(pagedResp.totalCount == 1);
|
|
REQUIRE(pagedResp.changes.size() == 1);
|
|
CHECK(pagedResp.changes.front().changeType == "modified");
|
|
CHECK(pagedResp.changes.front().path == "src/existing.cpp");
|
|
CHECK(pagedResp.changes.front().contentDeltaHash == "delta-existing");
|
|
|
|
ListTreeDiffRequest invalidReq;
|
|
invalidReq.targetSnapshotId = targetSnapshotId;
|
|
auto invalidResult = yams::cli::run_sync(client.executeRequest(Request{invalidReq}), 10s);
|
|
|
|
REQUIRE(invalidResult.has_value());
|
|
REQUIRE(std::holds_alternative<ErrorResponse>(invalidResult.value()));
|
|
CHECK(std::get<ErrorResponse>(invalidResult.value()).code == yams::ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
TEST_CASE("Daemon client error handling", "[daemon][socket][errors]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
// Use isolated daemon for tests that modify database state
|
|
DaemonHarness harness;
|
|
startHarnessWithRetry(harness);
|
|
|
|
// Allow time for GlobalIOContext to stabilize
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
auto client = createClient(harness.socketPath());
|
|
|
|
// Retry connection to handle timing issues with GlobalIOContext
|
|
yams::Result<void> connectResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
connectResult = yams::cli::run_sync(client.connect(), 5s);
|
|
if (connectResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(connectResult.has_value());
|
|
|
|
SECTION("get nonexistent document fails gracefully") {
|
|
GetRequest req;
|
|
req.hash = "0000000000000000000000000000000000000000000000000000000000000000";
|
|
|
|
auto result = yams::cli::run_sync(client.get(req), 5s);
|
|
|
|
REQUIRE(!result.has_value());
|
|
// Should return error, not crash
|
|
}
|
|
|
|
SECTION("invalid search query handled") {
|
|
SearchRequest req;
|
|
req.query = ""; // Empty query
|
|
|
|
auto result = yams::cli::run_sync(client.search(req), 5s);
|
|
|
|
// Either succeeds with empty results or returns error
|
|
// Both are acceptable error handling
|
|
REQUIRE((!result.has_value() || result.value().results.empty()));
|
|
}
|
|
|
|
SECTION("malformed grep pattern handled") {
|
|
GrepRequest req;
|
|
req.pattern = "[[[invalid regex"; // Invalid regex
|
|
|
|
auto result = yams::cli::run_sync(client.grep(req), 5s);
|
|
|
|
// Should return error, not crash
|
|
REQUIRE(!result.has_value());
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Daemon client concurrent requests", "[daemon][socket][concurrency]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
// Use isolated daemon for tests that modify database state
|
|
DaemonHarness harness;
|
|
startHarnessWithRetry(harness);
|
|
|
|
// Allow time for GlobalIOContext to stabilize
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
auto client = createClient(harness.socketPath());
|
|
|
|
// Retry connection to handle timing issues with GlobalIOContext
|
|
yams::Result<void> connectResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
connectResult = yams::cli::run_sync(client.connect(), 5s);
|
|
if (connectResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(connectResult.has_value());
|
|
|
|
SECTION("sequential status requests") {
|
|
for (int i = 0; i < 5; ++i) {
|
|
auto result = yams::cli::run_sync(client.status(), 5s);
|
|
REQUIRE(result.has_value());
|
|
}
|
|
}
|
|
|
|
SECTION("add multiple documents") {
|
|
std::vector<std::string> hashes;
|
|
|
|
for (int i = 0; i < 3; ++i) {
|
|
AddDocumentRequest req;
|
|
req.name = "doc" + std::to_string(i) + ".txt";
|
|
req.content = "Content " + std::to_string(i);
|
|
|
|
auto result = yams::cli::run_sync(client.streamingAddDocument(req), 5s);
|
|
REQUIRE(result.has_value());
|
|
hashes.push_back(result.value().hash);
|
|
}
|
|
|
|
REQUIRE(hashes.size() == 3);
|
|
// Verify all hashes are unique
|
|
std::set<std::string> uniqueHashes(hashes.begin(), hashes.end());
|
|
REQUIRE(uniqueHashes.size() == 3);
|
|
}
|
|
|
|
SECTION("list after adding documents") {
|
|
// Add documents with delay to avoid database lock contention from async post-ingest
|
|
std::vector<std::string> hashes;
|
|
for (int i = 0; i < 3; ++i) {
|
|
AddDocumentRequest req;
|
|
req.name = "multi_doc" + std::to_string(i) + ".txt";
|
|
req.content = "Content " + std::to_string(i);
|
|
|
|
// Retry document add in case of transient connection issues
|
|
yams::Result<AddDocumentResponse> result;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
result = yams::cli::run_sync(client.streamingAddDocument(req), 10s);
|
|
if (result.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(result.has_value());
|
|
hashes.push_back(result.value().hash);
|
|
// Small delay to let post-ingest process and release database lock
|
|
std::this_thread::sleep_for(50ms);
|
|
}
|
|
|
|
// Wait for all documents to be available (async post-ingest processing)
|
|
for (const auto& hash : hashes) {
|
|
auto getResult = getWithRetry(client, hash);
|
|
REQUIRE(getResult.has_value());
|
|
}
|
|
|
|
// List should show all documents
|
|
ListRequest listReq;
|
|
listReq.limit = 100;
|
|
|
|
auto listResult = yams::cli::run_sync(client.list(listReq), 5s);
|
|
REQUIRE(listResult.has_value());
|
|
REQUIRE(listResult.value().items.size() >= 3);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Daemon client timeout behavior", "[daemon][socket][timeout]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
// Use isolated daemon
|
|
DaemonHarness harness;
|
|
startHarnessWithRetry(harness);
|
|
|
|
SECTION("short header timeout") {
|
|
ClientConfig config;
|
|
config.socketPath = harness.socketPath();
|
|
config.connectTimeout = 2s;
|
|
config.headerTimeout =
|
|
500ms; // Reasonable short timeout (increased from 100ms for stability)
|
|
config.autoStart = false;
|
|
|
|
// Allow time for GlobalIOContext to stabilize
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
DaemonClient client(config);
|
|
|
|
// Retry connection to handle timing issues with GlobalIOContext
|
|
yams::Result<void> connectResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
connectResult = yams::cli::run_sync(client.connect(), 5s);
|
|
if (connectResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(connectResult.has_value());
|
|
|
|
// Fast operation should still succeed even with short timeout
|
|
auto statusResult = yams::cli::run_sync(client.status(), 5s);
|
|
REQUIRE(statusResult.has_value());
|
|
}
|
|
|
|
SECTION("request timeout handling") {
|
|
// Allow time for GlobalIOContext to stabilize
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
auto client = createClient(harness.socketPath());
|
|
|
|
// Retry connection to handle timing issues with GlobalIOContext
|
|
yams::Result<void> connectResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
connectResult = yams::cli::run_sync(client.connect(), 5s);
|
|
if (connectResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(connectResult.has_value());
|
|
|
|
// Use a short timeout for the operation (50ms is short but avoids race conditions)
|
|
// Note: 1ms caused SIGABRT due to mutex race conditions during timeout handling
|
|
auto statusResult = yams::cli::run_sync(client.status(), 50ms);
|
|
|
|
// May timeout or succeed depending on timing
|
|
// Just verify it doesn't crash
|
|
REQUIRE(true);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Daemon client move semantics", "[daemon][socket][move]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
// Use isolated daemon
|
|
DaemonHarness harness;
|
|
startHarnessWithRetry(harness);
|
|
|
|
SECTION("move construction") {
|
|
// Allow time for GlobalIOContext to stabilize
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
auto client1 = createClient(harness.socketPath());
|
|
|
|
// Retry connection to handle timing issues with GlobalIOContext
|
|
yams::Result<void> connectResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
connectResult = yams::cli::run_sync(client1.connect(), 5s);
|
|
if (connectResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(connectResult.has_value());
|
|
REQUIRE(client1.isConnected());
|
|
|
|
// Move construct
|
|
DaemonClient client2(std::move(client1));
|
|
REQUIRE(client2.isConnected());
|
|
|
|
// Original should be in valid but unspecified state
|
|
// Just verify we can destroy it
|
|
}
|
|
|
|
SECTION("move assignment") {
|
|
// Allow time for GlobalIOContext to stabilize
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
auto client1 = createClient(harness.socketPath());
|
|
|
|
// Retry connection to handle timing issues with GlobalIOContext
|
|
yams::Result<void> connectResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
connectResult = yams::cli::run_sync(client1.connect(), 5s);
|
|
if (connectResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(connectResult.has_value());
|
|
REQUIRE(client1.isConnected());
|
|
|
|
// Move assign
|
|
DaemonClient client2(std::move(client1));
|
|
REQUIRE(client2.isConnected());
|
|
|
|
// Can make request with moved-to client
|
|
auto statusResult = yams::cli::run_sync(client2.status(), 5s);
|
|
REQUIRE(statusResult.has_value());
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Daemon socket file lifecycle", "[daemon][socket][filesystem]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
SECTION("socket file created on start") {
|
|
// Use its own daemon
|
|
DaemonHarness harness;
|
|
startHarnessWithRetry(harness);
|
|
REQUIRE(std::filesystem::exists(harness.socketPath()));
|
|
harness.stop();
|
|
}
|
|
|
|
SECTION("daemon stops on shutdown request") {
|
|
// This test needs its own daemon to shutdown
|
|
DaemonHarness harness;
|
|
startHarnessWithRetry(harness);
|
|
REQUIRE(std::filesystem::exists(harness.socketPath()));
|
|
|
|
// Allow time for GlobalIOContext to stabilize
|
|
std::this_thread::sleep_for(200ms);
|
|
|
|
auto client = createClient(harness.socketPath());
|
|
|
|
// Retry connection to handle timing issues with GlobalIOContext
|
|
yams::Result<void> connectResult;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
connectResult = yams::cli::run_sync(client.connect(), 5s);
|
|
if (connectResult.has_value())
|
|
break;
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
REQUIRE(connectResult.has_value());
|
|
|
|
// Send shutdown request - the daemon may close the connection before responding,
|
|
// so we don't require the IPC response to succeed. The important thing is that
|
|
// the daemon eventually stops.
|
|
(void)yams::cli::run_sync(client.shutdown(true), 10s);
|
|
REQUIRE(harness.daemon() != nullptr);
|
|
REQUIRE(harness.daemon()->waitForStopCompletion(10s));
|
|
}
|
|
|
|
SECTION("socket file removed on stop") {
|
|
// This test needs its own daemon to stop
|
|
DaemonHarness harness;
|
|
startHarnessWithRetry(harness);
|
|
REQUIRE(std::filesystem::exists(harness.socketPath()));
|
|
|
|
harness.stop();
|
|
std::this_thread::sleep_for(500ms);
|
|
|
|
// Socket may be cleaned up
|
|
// Just verify we don't crash
|
|
REQUIRE(true);
|
|
}
|
|
|
|
SECTION("multiple clients can connect to same socket") {
|
|
DaemonHarness harness;
|
|
startHarnessWithRetry(harness);
|
|
|
|
auto client1 = createClient(harness.socketPath());
|
|
auto client2 = createClient(harness.socketPath());
|
|
|
|
auto connect1 = yams::cli::run_sync(client1.connect(), 5s);
|
|
auto connect2 = yams::cli::run_sync(client2.connect(), 5s);
|
|
|
|
REQUIRE(connect1.has_value());
|
|
REQUIRE(connect2.has_value());
|
|
REQUIRE(client1.isConnected());
|
|
REQUIRE(client2.isConnected());
|
|
}
|
|
}
|