Semantic-neighbor maintenance aborted the daemon during startup: std::clamp<float> asserted '!(__hi < __lo)' because the adaptive cosine threshold could exceed 1.0 for a legal pair of identical vectors. The same work was also admitted while the daemon was still initializing, so the 300-second starvation guard forced a pass before readiness. + semanticInverseNorm/semanticCosineSimilarity/semanticExplicitThreshold in the existing embed policy header, shared by the streamed and corpus branches; non-finite vectors or scores produce no neighbor and legal scores are bounded before ranking and threshold selection + startupMaintenanceEligible() requires ServiceManagerState::Ready, not stopping and idle, and is evaluated before ResourceGovernor::admitDeferrable(), so an elapsed deferral deadline can no longer manufacture missing readiness + StartupPhaseTracker (two atomics, no mutex) plus a warn-level init-waiter line reporting state, phase and phase_elapsed_ms instead of a debug-level state number; a 53-minute startup previously logged nothing actionable + regression through the public backfill and corpus-rebuild APIs using a 64-D vector whose old float inverse norm rounds its self-cosine above 1 - removed the duplicated inverse-norm and cosine lambdas from EmbeddingService - renamed __test_setModelProvider/__test_setKgStore to testing_* because they used reserved identifiers, across 11 files - re-anchored 13 pre-existing raw-environment allowlist entries to their new line numbers; no new exception was added Startup is still blocked by corpus-sized memory-sync scans; this change stops the abort and names the blocking phase, it does not make startup fast. Signed-off-by: trvon <git@trevon.dev>
991 lines
33 KiB
C++
991 lines
33 KiB
C++
#include <chrono>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <future>
|
|
#include "../../common/test_helpers_catch2.h"
|
|
#include <catch2/catch_test_macros.hpp>
|
|
#include <yams/compat/unistd.h>
|
|
#include <yams/daemon/components/DaemonLifecycleFsm.h>
|
|
#include <yams/daemon/components/RequestDispatcher.h>
|
|
#include <yams/daemon/components/ServiceManager.h>
|
|
#include <yams/daemon/components/StateComponent.h>
|
|
#include <yams/daemon/ipc/ipc_protocol.h>
|
|
#include <yams/daemon/resource/abi_plugin_loader.h>
|
|
#include <yams/daemon/resource/plugin_host.h>
|
|
#include <yams/plugins/model_provider_v1.h>
|
|
|
|
namespace yams::daemon {
|
|
namespace fs = std::filesystem;
|
|
|
|
using ScopedEnv = yams::test::ScopedEnvVar;
|
|
|
|
// Generate a unique suffix for test directories to avoid collisions
|
|
inline std::string uniqueSuffix() {
|
|
static std::atomic<int> counter{0};
|
|
auto now = std::chrono::steady_clock::now().time_since_epoch().count();
|
|
return std::to_string(::getpid()) + "_" + std::to_string(now) + "_" + std::to_string(counter++);
|
|
}
|
|
|
|
struct PluginHostFixture {
|
|
// RAII env var management - declared first so they're destroyed last
|
|
ScopedEnv xdgConfigEnv_;
|
|
ScopedEnv testingEnv_;
|
|
ScopedEnv pluginPolicyEnv_;
|
|
ScopedEnv configEnv_;
|
|
ScopedEnv dataEnv_;
|
|
|
|
fs::path tempDir_;
|
|
fs::path trustFile_;
|
|
|
|
PluginHostFixture()
|
|
: xdgConfigEnv_("XDG_CONFIG_HOME", ""), // placeholder, updated below
|
|
testingEnv_("YAMS_TESTING", "1"), pluginPolicyEnv_("YAMS_PLUGIN_NAME_POLICY", "spec"),
|
|
configEnv_("YAMS_CONFIG", ""), dataEnv_("YAMS_DATA_DIR", ""),
|
|
tempDir_(fs::temp_directory_path() / ("yams_ph_" + uniqueSuffix())),
|
|
trustFile_(tempDir_ / "plugins_trust.txt") {
|
|
fs::create_directories(tempDir_);
|
|
// Update env vars with actual temp dir paths
|
|
xdgConfigEnv_ = ScopedEnv("XDG_CONFIG_HOME", tempDir_.string().c_str());
|
|
configEnv_ = ScopedEnv("YAMS_CONFIG", (tempDir_ / "config.toml").string().c_str());
|
|
dataEnv_ = ScopedEnv("YAMS_DATA_DIR", tempDir_.string().c_str());
|
|
}
|
|
|
|
~PluginHostFixture() {
|
|
std::error_code ec;
|
|
fs::remove_all(tempDir_, ec);
|
|
}
|
|
|
|
fs::path makeFile(std::string_view name, std::string_view content = "") {
|
|
auto p = tempDir_ / name;
|
|
fs::create_directories(p.parent_path());
|
|
std::ofstream{p, std::ios::binary} << content;
|
|
return p;
|
|
}
|
|
};
|
|
|
|
struct RequestDispatcherFixture {
|
|
// RAII env var management - declared first so they're destroyed last
|
|
// (after resources that may depend on them during shutdown)
|
|
ScopedEnv xdgConfigEnv_;
|
|
ScopedEnv testingEnv_;
|
|
ScopedEnv configEnv_;
|
|
ScopedEnv dataEnv_;
|
|
|
|
fs::path tempDir_;
|
|
std::unique_ptr<StateComponent> state_;
|
|
std::unique_ptr<DaemonLifecycleFsm> lifecycleFsm_;
|
|
std::unique_ptr<ServiceManager> svc_;
|
|
std::unique_ptr<RequestDispatcher> dispatcher_;
|
|
|
|
RequestDispatcherFixture()
|
|
: xdgConfigEnv_("XDG_CONFIG_HOME", ""), // placeholder, set after tempDir_ known
|
|
testingEnv_("YAMS_TESTING", "1"), configEnv_("YAMS_CONFIG", ""),
|
|
dataEnv_("YAMS_DATA_DIR", ""),
|
|
tempDir_(fs::temp_directory_path() / ("yams_rdph_" + uniqueSuffix())) {
|
|
// Now update env vars with actual temp dir path
|
|
fs::create_directories(tempDir_);
|
|
xdgConfigEnv_ = ScopedEnv("XDG_CONFIG_HOME", tempDir_.string().c_str());
|
|
configEnv_ = ScopedEnv("YAMS_CONFIG", (tempDir_ / "config.toml").string().c_str());
|
|
dataEnv_ = ScopedEnv("YAMS_DATA_DIR", tempDir_.string().c_str());
|
|
|
|
state_ = std::make_unique<StateComponent>();
|
|
lifecycleFsm_ = std::make_unique<DaemonLifecycleFsm>();
|
|
|
|
DaemonConfig cfg;
|
|
cfg.dataDir = tempDir_;
|
|
cfg.autoLoadPlugins = false;
|
|
cfg.enableModelProvider = true;
|
|
|
|
svc_ = std::make_unique<ServiceManager>(cfg, *state_, *lifecycleFsm_);
|
|
dispatcher_ = std::make_unique<RequestDispatcher>(nullptr, svc_.get(), state_.get());
|
|
}
|
|
|
|
~RequestDispatcherFixture() {
|
|
// Reset in reverse dependency order before env vars are restored
|
|
dispatcher_.reset();
|
|
svc_.reset();
|
|
lifecycleFsm_.reset();
|
|
state_.reset();
|
|
|
|
std::error_code ec;
|
|
fs::remove_all(tempDir_, ec);
|
|
}
|
|
};
|
|
|
|
TEST_CASE("Plugin discovery ignores bundled zpdf runtime library",
|
|
"[plugin][discovery][runtime-dependency]") {
|
|
PluginHostFixture fixture;
|
|
AbiPluginLoader loader;
|
|
loader.setNamePolicy(AbiPluginLoader::NamePolicy::Spec);
|
|
fixture.makeFile("libzpdf.so", "runtime dependency");
|
|
fixture.makeFile("libzpdf.dylib", "runtime dependency");
|
|
fixture.makeFile("zpdf.dll", "runtime dependency");
|
|
|
|
auto result = loader.scanDirectory(fixture.tempDir_);
|
|
|
|
REQUIRE(result);
|
|
CHECK(result.value().empty());
|
|
CHECK(loader.getLastSkips().empty());
|
|
}
|
|
|
|
TEST_CASE("PluginHost - Trust Management", "[plugin][host][trust]") {
|
|
PluginHostFixture fixture;
|
|
|
|
SECTION("Trust policy add and remove") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
auto initial = host.trustList().size();
|
|
auto dir = fixture.tempDir_ / "trusted_abi";
|
|
fs::create_directories(dir);
|
|
|
|
REQUIRE(host.trustAdd(dir));
|
|
auto tl = host.trustList();
|
|
CHECK((tl.size() == initial + 1));
|
|
|
|
auto canon_dir = fs::weakly_canonical(dir);
|
|
bool found = false;
|
|
for (const auto& p : tl) {
|
|
if (fs::weakly_canonical(p) == canon_dir) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
CHECK(found);
|
|
|
|
REQUIRE(host.trustRemove(dir));
|
|
auto after = host.trustList();
|
|
CHECK((after.size() == initial));
|
|
|
|
bool still_present = false;
|
|
for (const auto& p : after) {
|
|
if (fs::weakly_canonical(p) == canon_dir) {
|
|
still_present = true;
|
|
break;
|
|
}
|
|
}
|
|
CHECK_FALSE(still_present);
|
|
}
|
|
|
|
SECTION("Load untrusted plugin returns unauthorized") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
auto fake = fixture.makeFile("fake_plugin.so", "not a real so");
|
|
auto res = host.load(fake, "{}");
|
|
|
|
REQUIRE_FALSE(res);
|
|
CHECK((res.error().code == ErrorCode::Unauthorized));
|
|
}
|
|
|
|
SECTION("Plugin discovery - scan non-existent target") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
auto fake = fixture.tempDir_ / "nonexistent.so";
|
|
auto res = host.scanTarget(fake);
|
|
|
|
REQUIRE_FALSE(res);
|
|
}
|
|
|
|
SECTION("Plugin discovery - scan empty directory") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
auto emptyDir = fixture.tempDir_ / "empty_plugins";
|
|
fs::create_directories(emptyDir);
|
|
|
|
auto res = host.scanDirectory(emptyDir);
|
|
REQUIRE(res);
|
|
CHECK(res.value().empty());
|
|
}
|
|
|
|
SECTION("Trust file corruption - invalid JSON") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
{
|
|
std::ofstream out(fixture.trustFile_);
|
|
out << "{ invalid json syntax ][";
|
|
}
|
|
|
|
auto tl = host.trustList();
|
|
CHECK(tl.empty());
|
|
}
|
|
|
|
SECTION("Trust operations with same trust file path") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
auto dir1 = fixture.tempDir_ / "trusted_dir1";
|
|
auto dir2 = fixture.tempDir_ / "trusted_dir2";
|
|
fs::create_directories(dir1);
|
|
fs::create_directories(dir2);
|
|
|
|
REQUIRE(host.trustAdd(dir1));
|
|
REQUIRE(host.trustAdd(dir2));
|
|
|
|
auto tl = host.trustList();
|
|
CHECK((tl.size() >= 2));
|
|
|
|
REQUIRE(host.trustRemove(dir1));
|
|
auto after_remove = host.trustList();
|
|
CHECK((after_remove.size() == tl.size() - 1));
|
|
}
|
|
|
|
#if !defined(_WIN32)
|
|
SECTION("Trust file permissions are private") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
auto dir = fixture.tempDir_ / "trusted_perms_dir";
|
|
fs::create_directories(dir);
|
|
|
|
REQUIRE(host.trustAdd(dir));
|
|
REQUIRE(fs::exists(fixture.trustFile_));
|
|
|
|
std::error_code ec;
|
|
auto perms = fs::status(fixture.trustFile_, ec).permissions();
|
|
REQUIRE_FALSE(ec);
|
|
CHECK(((perms & (fs::perms::group_all | fs::perms::others_all)) == fs::perms::none));
|
|
}
|
|
#endif
|
|
|
|
SECTION("Trust remove persists after host reload") {
|
|
auto dir1 = fixture.tempDir_ / "persist_remove_dir1";
|
|
auto dir2 = fixture.tempDir_ / "persist_remove_dir2";
|
|
fs::create_directories(dir1);
|
|
fs::create_directories(dir2);
|
|
|
|
{
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
REQUIRE(host.trustAdd(dir1));
|
|
REQUIRE(host.trustAdd(dir2));
|
|
REQUIRE(host.trustRemove(dir1));
|
|
}
|
|
|
|
{
|
|
AbiPluginHost reloaded;
|
|
reloaded.setTrustFile(fixture.trustFile_);
|
|
auto tl = reloaded.trustList();
|
|
auto c1 = fs::weakly_canonical(dir1);
|
|
auto c2 = fs::weakly_canonical(dir2);
|
|
bool hasDir1 = false;
|
|
bool hasDir2 = false;
|
|
for (const auto& p : tl) {
|
|
auto cp = fs::weakly_canonical(p);
|
|
hasDir1 = hasDir1 || (cp == c1);
|
|
hasDir2 = hasDir2 || (cp == c2);
|
|
}
|
|
CHECK_FALSE(hasDir1);
|
|
CHECK(hasDir2);
|
|
}
|
|
}
|
|
|
|
SECTION("List loaded plugins when empty") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
auto loaded = host.listLoaded();
|
|
CHECK(loaded.empty());
|
|
}
|
|
|
|
#ifdef TEST_ABI_PLUGIN_FILE
|
|
SECTION("Load mock model plugin and get interface") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
fs::path pluginPath(TEST_ABI_PLUGIN_FILE);
|
|
auto dir = pluginPath.parent_path();
|
|
REQUIRE(host.trustAdd(dir));
|
|
|
|
auto lr = host.load(pluginPath, "{}");
|
|
REQUIRE(lr);
|
|
auto ldesc = lr.value();
|
|
CHECK(ldesc.name == "mock_model");
|
|
|
|
auto ifaceRes = host.getInterface("mock_model", "model_provider_v1", 1);
|
|
REQUIRE(ifaceRes);
|
|
auto* table = reinterpret_cast<yams_model_provider_v1*>(ifaceRes.value());
|
|
REQUIRE(table != nullptr);
|
|
|
|
CHECK(table->abi_version == YAMS_IFACE_MODEL_PROVIDER_V1_VERSION);
|
|
|
|
bool loaded = false;
|
|
CHECK(table->load_model(table->self, "test_model", nullptr, nullptr) == 0);
|
|
CHECK(table->is_model_loaded(table->self, "test_model", &loaded) == 0);
|
|
CHECK(loaded);
|
|
|
|
const char** ids = nullptr;
|
|
size_t count = 0;
|
|
CHECK(table->get_loaded_models(table->self, &ids, &count) == 0);
|
|
CHECK(count >= 1u);
|
|
table->free_model_list(table->self, ids, count);
|
|
CHECK(table->unload_model(table->self, "test_model") == 0);
|
|
|
|
auto ur = host.unload("mock_model");
|
|
CHECK(ur);
|
|
}
|
|
|
|
SECTION("Plugin unloading - unload non-existent plugin") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
auto res = host.unload("nonexistent_plugin");
|
|
REQUIRE_FALSE(res);
|
|
}
|
|
|
|
SECTION("Plugin unloading - list loaded after unload") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
fs::path pluginPath(TEST_ABI_PLUGIN_FILE);
|
|
auto dir = pluginPath.parent_path();
|
|
REQUIRE(host.trustAdd(dir));
|
|
|
|
auto lr = host.load(pluginPath, "{}");
|
|
REQUIRE(lr);
|
|
|
|
auto duplicate = host.load(pluginPath, R"({"duplicate":true})");
|
|
REQUIRE(duplicate);
|
|
CHECK((duplicate.value().name == lr.value().name));
|
|
|
|
auto loaded_before = host.listLoaded();
|
|
CHECK(loaded_before.size() == 1);
|
|
|
|
auto ur = host.unload(lr.value().name);
|
|
REQUIRE(ur);
|
|
|
|
auto loaded_after = host.listLoaded();
|
|
CHECK(loaded_after.empty());
|
|
|
|
auto first = std::async(std::launch::async, [&] { return host.load(pluginPath, "{}"); });
|
|
auto second = std::async(std::launch::async, [&] { return host.load(pluginPath, "{}"); });
|
|
auto firstResult = first.get();
|
|
auto secondResult = second.get();
|
|
REQUIRE(firstResult);
|
|
REQUIRE(secondResult);
|
|
CHECK((firstResult.value().name == secondResult.value().name));
|
|
CHECK((host.listLoaded().size() == 1));
|
|
REQUIRE(host.unload(firstResult.value().name));
|
|
}
|
|
|
|
SECTION("Plugin keepalive defers physical unload until adapters release") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
fs::path pluginPath(TEST_ABI_PLUGIN_FILE);
|
|
auto dir = pluginPath.parent_path();
|
|
REQUIRE(host.trustAdd(dir));
|
|
|
|
auto lr = host.load(pluginPath, "{}");
|
|
REQUIRE(lr);
|
|
const auto pluginName = lr.value().name;
|
|
|
|
auto lease = host.acquireInterface(pluginName, "model_provider_v1", 1);
|
|
REQUIRE(lease);
|
|
REQUIRE(lease.value().keepAlive != nullptr);
|
|
|
|
auto* table = reinterpret_cast<yams_model_provider_v1*>(lease.value().interface);
|
|
REQUIRE(table != nullptr);
|
|
|
|
REQUIRE(host.unload(pluginName));
|
|
CHECK(host.listLoaded().empty());
|
|
|
|
// Interface remains callable while keepalive token is held.
|
|
bool loaded = false;
|
|
CHECK(table->load_model(table->self, "test_model", nullptr, nullptr) == 0);
|
|
CHECK(table->is_model_loaded(table->self, "test_model", &loaded) == 0);
|
|
CHECK(loaded);
|
|
CHECK(table->unload_model(table->self, "test_model") == 0);
|
|
|
|
lease.value().keepAlive.reset();
|
|
|
|
// After releasing keepalive, plugin can be loaded again normally.
|
|
auto lr2 = host.load(pluginPath, "{}");
|
|
REQUIRE(lr2);
|
|
CHECK(lr2.value().name == pluginName);
|
|
CHECK(host.unload(pluginName));
|
|
}
|
|
|
|
SECTION("Plugin health check - healthy plugin") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
fs::path pluginPath(TEST_ABI_PLUGIN_FILE);
|
|
auto dir = pluginPath.parent_path();
|
|
REQUIRE(host.trustAdd(dir));
|
|
|
|
auto lr = host.load(pluginPath, "{}");
|
|
REQUIRE(lr);
|
|
|
|
auto health = host.health(lr.value().name);
|
|
REQUIRE(health);
|
|
CHECK_FALSE(health.value().empty());
|
|
}
|
|
|
|
SECTION("Plugin health check - non-existent plugin") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
auto health = host.health("nonexistent_plugin");
|
|
REQUIRE_FALSE(health);
|
|
}
|
|
|
|
SECTION("Plugin discovery - scan target finds plugin") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
fs::path pluginPath(TEST_ABI_PLUGIN_FILE);
|
|
auto res = host.scanTarget(pluginPath);
|
|
|
|
REQUIRE(res);
|
|
auto desc = res.value();
|
|
CHECK(desc.name == "mock_model");
|
|
CHECK(desc.abiVersion > 0);
|
|
CHECK_FALSE(desc.path.empty());
|
|
}
|
|
|
|
SECTION("Plugin discovery - scan directory finds plugins") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
fs::path pluginPath(TEST_ABI_PLUGIN_FILE);
|
|
auto dir = pluginPath.parent_path();
|
|
|
|
auto res = host.scanDirectory(dir);
|
|
REQUIRE(res);
|
|
|
|
auto plugins = res.value();
|
|
CHECK_FALSE(plugins.empty());
|
|
|
|
bool found_mock = false;
|
|
for (const auto& p : plugins) {
|
|
if (p.name == "mock_model") {
|
|
found_mock = true;
|
|
CHECK(p.abiVersion > 0);
|
|
CHECK_FALSE(p.interfaces.empty());
|
|
}
|
|
}
|
|
CHECK(found_mock);
|
|
}
|
|
|
|
SECTION("Plugin discovery - scan directory finds plugins in nested plugin folders") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
fs::path pluginPath(TEST_ABI_PLUGIN_FILE);
|
|
auto rootDir = fixture.tempDir_ / "nested_plugins";
|
|
auto nestedDir = rootDir / "mock_model";
|
|
fs::create_directories(nestedDir);
|
|
|
|
auto nestedPluginPath = nestedDir / pluginPath.filename();
|
|
std::error_code ec;
|
|
fs::copy_file(pluginPath, nestedPluginPath, fs::copy_options::overwrite_existing, ec);
|
|
REQUIRE_FALSE(ec);
|
|
|
|
auto res = host.scanDirectory(rootDir);
|
|
REQUIRE(res);
|
|
|
|
auto plugins = res.value();
|
|
bool found_mock = false;
|
|
for (const auto& p : plugins) {
|
|
if (p.name == "mock_model") {
|
|
found_mock = true;
|
|
CHECK(p.path == nestedPluginPath);
|
|
CHECK(p.abiVersion > 0);
|
|
}
|
|
}
|
|
CHECK(found_mock);
|
|
}
|
|
|
|
SECTION("Plugin discovery - getLastScanSkips after scan") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
fs::path pluginPath(TEST_ABI_PLUGIN_FILE);
|
|
auto dir = pluginPath.parent_path();
|
|
|
|
host.scanDirectory(dir);
|
|
auto skips = host.getLastScanSkips();
|
|
}
|
|
|
|
SECTION("Plugin reload - unload and reload same plugin") {
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
fs::path pluginPath(TEST_ABI_PLUGIN_FILE);
|
|
auto dir = pluginPath.parent_path();
|
|
REQUIRE(host.trustAdd(dir));
|
|
|
|
auto lr1 = host.load(pluginPath, "{}");
|
|
REQUIRE(lr1);
|
|
auto name = lr1.value().name;
|
|
|
|
auto ur = host.unload(name);
|
|
REQUIRE(ur);
|
|
|
|
auto lr2 = host.load(pluginPath, "{}");
|
|
REQUIRE(lr2);
|
|
CHECK(lr2.value().name == name);
|
|
|
|
host.unload(name);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
TEST_CASE("RequestDispatcher - Plugin Integration", "[plugin][dispatcher][integration]") {
|
|
RequestDispatcherFixture fixture;
|
|
|
|
#ifdef TEST_ABI_PLUGIN_FILE
|
|
SECTION("Load ABI mock plugin and adopt provider") {
|
|
fs::path plugin(TEST_ABI_PLUGIN_FILE);
|
|
|
|
PluginTrustAddRequest tadd;
|
|
tadd.path = plugin.parent_path().string();
|
|
auto r1 = fixture.dispatcher_->handlePluginTrustAddRequest(tadd);
|
|
REQUIRE(std::holds_alternative<SuccessResponse>(r1));
|
|
|
|
PluginLoadRequest lreq;
|
|
lreq.pathOrName = plugin.string();
|
|
lreq.dryRun = true;
|
|
auto dr = fixture.dispatcher_->handlePluginLoadRequest(lreq);
|
|
REQUIRE(std::holds_alternative<PluginLoadResponse>(dr));
|
|
auto drr = std::get<PluginLoadResponse>(dr);
|
|
CHECK_FALSE(drr.loaded);
|
|
CHECK(drr.message == "dry-run");
|
|
|
|
lreq.dryRun = false;
|
|
auto lr = fixture.dispatcher_->handlePluginLoadRequest(lreq);
|
|
REQUIRE(std::holds_alternative<PluginLoadResponse>(lr));
|
|
auto lrv = std::get<PluginLoadResponse>(lr);
|
|
CHECK(lrv.loaded);
|
|
|
|
auto mp = fixture.svc_->getModelProvider();
|
|
REQUIRE(mp != nullptr);
|
|
CHECK(mp->getProviderName() == "MOCK_ABI");
|
|
|
|
PluginTrustListRequest tlr;
|
|
auto tl = fixture.dispatcher_->handlePluginTrustListRequest(tlr);
|
|
REQUIRE(std::holds_alternative<PluginTrustListResponse>(tl));
|
|
auto tlv = std::get<PluginTrustListResponse>(tl);
|
|
|
|
bool found = false;
|
|
for (const auto& p : tlv.paths) {
|
|
if (fs::weakly_canonical(p) == fs::weakly_canonical(plugin.parent_path())) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
CHECK(found);
|
|
|
|
PluginUnloadRequest ureq;
|
|
ureq.name = lrv.record.name;
|
|
auto ur = fixture.dispatcher_->handlePluginUnloadRequest(ureq);
|
|
REQUIRE(std::holds_alternative<SuccessResponse>(ur));
|
|
}
|
|
|
|
SECTION("Plugin state transitions - degraded to healthy") {
|
|
fs::path plugin(TEST_ABI_PLUGIN_FILE);
|
|
|
|
PluginTrustAddRequest tadd;
|
|
tadd.path = plugin.parent_path().string();
|
|
fixture.dispatcher_->handlePluginTrustAddRequest(tadd);
|
|
|
|
PluginLoadRequest lreq;
|
|
lreq.pathOrName = plugin.string();
|
|
auto lr = fixture.dispatcher_->handlePluginLoadRequest(lreq);
|
|
REQUIRE(std::holds_alternative<PluginLoadResponse>(lr));
|
|
auto lrv = std::get<PluginLoadResponse>(lr);
|
|
REQUIRE(lrv.loaded);
|
|
|
|
auto mp = fixture.svc_->getModelProvider();
|
|
REQUIRE(mp != nullptr);
|
|
|
|
fixture.svc_->testing_setModelProviderDegraded(true, "simulated error");
|
|
auto degraded_subsystems = fixture.lifecycleFsm_->degradedSubsystems();
|
|
CHECK(degraded_subsystems.find("ModelProvider") != degraded_subsystems.end());
|
|
|
|
fixture.svc_->testing_setModelProviderDegraded(false);
|
|
degraded_subsystems = fixture.lifecycleFsm_->degradedSubsystems();
|
|
CHECK(degraded_subsystems.find("ModelProvider") == degraded_subsystems.end());
|
|
}
|
|
|
|
SECTION("Plugin state transitions - healthy to degraded and back") {
|
|
fs::path plugin(TEST_ABI_PLUGIN_FILE);
|
|
|
|
PluginTrustAddRequest tadd;
|
|
tadd.path = plugin.parent_path().string();
|
|
fixture.dispatcher_->handlePluginTrustAddRequest(tadd);
|
|
|
|
PluginLoadRequest lreq;
|
|
lreq.pathOrName = plugin.string();
|
|
auto lr = fixture.dispatcher_->handlePluginLoadRequest(lreq);
|
|
REQUIRE(std::holds_alternative<PluginLoadResponse>(lr));
|
|
REQUIRE(std::get<PluginLoadResponse>(lr).loaded);
|
|
|
|
auto mp = fixture.svc_->getModelProvider();
|
|
REQUIRE(mp != nullptr);
|
|
|
|
auto initial_degraded = fixture.lifecycleFsm_->degradedSubsystems();
|
|
CHECK(initial_degraded.find("ModelProvider") == initial_degraded.end());
|
|
|
|
fixture.svc_->testing_setModelProviderDegraded(true, "test error 1");
|
|
auto after_degrade = fixture.lifecycleFsm_->degradedSubsystems();
|
|
CHECK(after_degrade.find("ModelProvider") != after_degrade.end());
|
|
|
|
fixture.svc_->testing_setModelProviderDegraded(false);
|
|
auto after_recover = fixture.lifecycleFsm_->degradedSubsystems();
|
|
CHECK(after_recover.find("ModelProvider") == after_recover.end());
|
|
|
|
fixture.svc_->testing_setModelProviderDegraded(true, "test error 2");
|
|
auto after_degrade_again = fixture.lifecycleFsm_->degradedSubsystems();
|
|
CHECK(after_degrade_again.find("ModelProvider") != after_degrade_again.end());
|
|
|
|
fixture.svc_->testing_setModelProviderDegraded(false);
|
|
auto final_state = fixture.lifecycleFsm_->degradedSubsystems();
|
|
CHECK(final_state.find("ModelProvider") == final_state.end());
|
|
}
|
|
|
|
SECTION("ServiceManager status snapshot is authoritative after plugin load") {
|
|
fs::path plugin(TEST_ABI_PLUGIN_FILE);
|
|
|
|
PluginTrustAddRequest tadd;
|
|
tadd.path = plugin.parent_path().string();
|
|
fixture.dispatcher_->handlePluginTrustAddRequest(tadd);
|
|
|
|
PluginLoadRequest lreq;
|
|
lreq.pathOrName = plugin.string();
|
|
auto lr = fixture.dispatcher_->handlePluginLoadRequest(lreq);
|
|
REQUIRE(std::holds_alternative<PluginLoadResponse>(lr));
|
|
const auto& loaded = std::get<PluginLoadResponse>(lr);
|
|
|
|
const auto snapshot = fixture.svc_->getPluginStatusSnapshot();
|
|
const auto hostSnapshot = fixture.svc_->getPluginHostFsmSnapshot();
|
|
CHECK((snapshot.host.state == hostSnapshot.state));
|
|
CHECK((snapshot.host.loadedPlugins == hostSnapshot.loadedPlugins));
|
|
CHECK_FALSE(snapshot.records.empty());
|
|
|
|
auto provider = fixture.svc_->getModelProvider();
|
|
REQUIRE(provider);
|
|
bool foundProvider = false;
|
|
for (const auto& rec : snapshot.records) {
|
|
if (rec.name == loaded.record.name && rec.isProvider) {
|
|
foundProvider = true;
|
|
CHECK(rec.ready);
|
|
CHECK_FALSE(rec.degraded);
|
|
CHECK((rec.modelsLoaded == provider->getLoadedModelCount()));
|
|
}
|
|
}
|
|
CHECK(foundProvider);
|
|
}
|
|
|
|
SECTION("Plugin unload through dispatcher") {
|
|
fs::path plugin(TEST_ABI_PLUGIN_FILE);
|
|
|
|
PluginTrustAddRequest tadd;
|
|
tadd.path = plugin.parent_path().string();
|
|
fixture.dispatcher_->handlePluginTrustAddRequest(tadd);
|
|
|
|
PluginLoadRequest lreq;
|
|
lreq.pathOrName = plugin.string();
|
|
auto lr = fixture.dispatcher_->handlePluginLoadRequest(lreq);
|
|
REQUIRE(std::holds_alternative<PluginLoadResponse>(lr));
|
|
auto lrv = std::get<PluginLoadResponse>(lr);
|
|
|
|
PluginUnloadRequest ureq;
|
|
ureq.name = lrv.record.name;
|
|
auto ur = fixture.dispatcher_->handlePluginUnloadRequest(ureq);
|
|
REQUIRE(std::holds_alternative<SuccessResponse>(ur));
|
|
|
|
auto mp = fixture.svc_->getModelProvider();
|
|
CHECK(mp == nullptr);
|
|
}
|
|
|
|
SECTION("Plugin list request after load") {
|
|
fs::path plugin(TEST_ABI_PLUGIN_FILE);
|
|
|
|
PluginTrustAddRequest tadd;
|
|
tadd.path = plugin.parent_path().string();
|
|
fixture.dispatcher_->handlePluginTrustAddRequest(tadd);
|
|
|
|
PluginLoadRequest lreq;
|
|
lreq.pathOrName = plugin.string();
|
|
auto lr = fixture.dispatcher_->handlePluginLoadRequest(lreq);
|
|
REQUIRE(std::holds_alternative<PluginLoadResponse>(lr));
|
|
|
|
PluginListRequest listreq;
|
|
auto list_resp = fixture.dispatcher_->handlePluginListRequest(listreq);
|
|
REQUIRE(std::holds_alternative<PluginListResponse>(list_resp));
|
|
auto records = std::get<PluginListResponse>(list_resp).records;
|
|
|
|
CHECK_FALSE(records.empty());
|
|
CHECK(records[0].name == "mock_model");
|
|
}
|
|
#endif
|
|
}
|
|
|
|
TEST_CASE("Plugin Advanced Scenarios", "[plugin][advanced][stress][slow]") {
|
|
#ifdef TEST_ABI_PLUGIN_FILE
|
|
SECTION("Concurrent trust operations") {
|
|
PluginHostFixture fixture;
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
std::vector<std::future<Result<void>>> futures;
|
|
std::vector<fs::path> dirs;
|
|
|
|
for (int i = 0; i < 5; ++i) {
|
|
auto dir = fixture.tempDir_ / ("concurrent_trust_" + std::to_string(i));
|
|
fs::create_directories(dir);
|
|
dirs.push_back(dir);
|
|
}
|
|
|
|
for (const auto& dir : dirs) {
|
|
futures.push_back(
|
|
std::async(std::launch::async, [&host, dir]() { return host.trustAdd(dir); }));
|
|
}
|
|
|
|
int success_count = 0;
|
|
for (auto& f : futures) {
|
|
auto result = f.get();
|
|
if (result) {
|
|
++success_count;
|
|
}
|
|
}
|
|
|
|
CHECK(success_count >= 3);
|
|
auto final_list = host.trustList();
|
|
CHECK(final_list.size() >= 3);
|
|
}
|
|
|
|
SECTION("Rapid load and unload cycles") {
|
|
PluginHostFixture fixture;
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
fs::path pluginPath(TEST_ABI_PLUGIN_FILE);
|
|
auto dir = pluginPath.parent_path();
|
|
REQUIRE(host.trustAdd(dir));
|
|
|
|
constexpr int cycles = 5;
|
|
for (int i = 0; i < cycles; ++i) {
|
|
auto lr = host.load(pluginPath, "{}");
|
|
REQUIRE(lr);
|
|
|
|
auto loaded = host.listLoaded();
|
|
CHECK(loaded.size() == 1);
|
|
|
|
auto ur = host.unload(lr.value().name);
|
|
REQUIRE(ur);
|
|
|
|
auto after_unload = host.listLoaded();
|
|
CHECK(after_unload.empty());
|
|
}
|
|
}
|
|
|
|
SECTION("Plugin scan with invalid files in directory") {
|
|
PluginHostFixture fixture;
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
auto testDir = fixture.tempDir_ / "mixed_files";
|
|
fs::create_directories(testDir);
|
|
|
|
fixture.makeFile("mixed_files/not_a_plugin.txt", "just text");
|
|
fixture.makeFile("mixed_files/fake.so", "not a real shared object");
|
|
fixture.makeFile("mixed_files/empty.so", "");
|
|
|
|
auto res = host.scanDirectory(testDir);
|
|
REQUIRE(res);
|
|
CHECK(res.value().empty());
|
|
|
|
auto skips = host.getLastScanSkips();
|
|
CHECK(skips.size() >= 1);
|
|
}
|
|
|
|
SECTION("Load plugin with custom configuration JSON") {
|
|
PluginHostFixture fixture;
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
fs::path pluginPath(TEST_ABI_PLUGIN_FILE);
|
|
auto dir = pluginPath.parent_path();
|
|
REQUIRE(host.trustAdd(dir));
|
|
|
|
std::string customConfig = R"({"model_path": "/custom/path", "threads": 4})";
|
|
auto lr = host.load(pluginPath, customConfig);
|
|
REQUIRE(lr);
|
|
|
|
auto loaded = host.listLoaded();
|
|
CHECK(loaded.size() == 1);
|
|
|
|
host.unload(lr.value().name);
|
|
}
|
|
|
|
SECTION("Plugin interface retrieval with wrong version") {
|
|
PluginHostFixture fixture;
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
fs::path pluginPath(TEST_ABI_PLUGIN_FILE);
|
|
auto dir = pluginPath.parent_path();
|
|
REQUIRE(host.trustAdd(dir));
|
|
|
|
auto lr = host.load(pluginPath, "{}");
|
|
REQUIRE(lr);
|
|
|
|
auto ifaceRes = host.getInterface("mock_model", "model_provider_v1", 999);
|
|
REQUIRE_FALSE(ifaceRes);
|
|
|
|
host.unload(lr.value().name);
|
|
}
|
|
|
|
SECTION("Plugin interface retrieval with wrong interface ID") {
|
|
PluginHostFixture fixture;
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
fs::path pluginPath(TEST_ABI_PLUGIN_FILE);
|
|
auto dir = pluginPath.parent_path();
|
|
REQUIRE(host.trustAdd(dir));
|
|
|
|
auto lr = host.load(pluginPath, "{}");
|
|
REQUIRE(lr);
|
|
|
|
auto ifaceRes = host.getInterface("mock_model", "nonexistent_interface", 1);
|
|
REQUIRE_FALSE(ifaceRes);
|
|
|
|
host.unload(lr.value().name);
|
|
}
|
|
|
|
SECTION("RequestDispatcher - multiple state transitions with status checks") {
|
|
RequestDispatcherFixture fixture;
|
|
fs::path plugin(TEST_ABI_PLUGIN_FILE);
|
|
|
|
PluginTrustAddRequest tadd;
|
|
tadd.path = plugin.parent_path().string();
|
|
fixture.dispatcher_->handlePluginTrustAddRequest(tadd);
|
|
|
|
PluginLoadRequest lreq;
|
|
lreq.pathOrName = plugin.string();
|
|
auto lr = fixture.dispatcher_->handlePluginLoadRequest(lreq);
|
|
REQUIRE(std::holds_alternative<PluginLoadResponse>(lr));
|
|
|
|
for (int cycle = 0; cycle < 3; ++cycle) {
|
|
fixture.svc_->testing_setModelProviderDegraded(true, "cycle " + std::to_string(cycle));
|
|
|
|
auto snapshot = fixture.svc_->getPluginStatusSnapshot();
|
|
bool found_degraded = false;
|
|
for (const auto& rec : snapshot.records) {
|
|
if (rec.isProvider && rec.degraded) {
|
|
found_degraded = true;
|
|
CHECK_FALSE(rec.error.empty());
|
|
}
|
|
}
|
|
CHECK(found_degraded);
|
|
|
|
fixture.svc_->testing_setModelProviderDegraded(false);
|
|
|
|
snapshot = fixture.svc_->getPluginStatusSnapshot();
|
|
for (const auto& rec : snapshot.records) {
|
|
if (rec.isProvider) {
|
|
CHECK_FALSE(rec.degraded);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SECTION("Plugin load with empty configuration") {
|
|
PluginHostFixture fixture;
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
fs::path pluginPath(TEST_ABI_PLUGIN_FILE);
|
|
auto dir = pluginPath.parent_path();
|
|
REQUIRE(host.trustAdd(dir));
|
|
|
|
auto lr1 = host.load(pluginPath, "");
|
|
REQUIRE(lr1);
|
|
|
|
auto ur = host.unload(lr1.value().name);
|
|
REQUIRE(ur);
|
|
|
|
auto lr2 = host.load(pluginPath, "{}");
|
|
REQUIRE(lr2);
|
|
|
|
host.unload(lr2.value().name);
|
|
}
|
|
|
|
SECTION("Trust list after multiple add/remove operations") {
|
|
PluginHostFixture fixture;
|
|
AbiPluginHost host;
|
|
host.setTrustFile(fixture.trustFile_);
|
|
|
|
std::vector<fs::path> dirs;
|
|
for (int i = 0; i < 10; ++i) {
|
|
auto dir = fixture.tempDir_ / ("trust_test_" + std::to_string(i));
|
|
fs::create_directories(dir);
|
|
dirs.push_back(dir);
|
|
REQUIRE(host.trustAdd(dir));
|
|
}
|
|
|
|
auto tl = host.trustList();
|
|
CHECK(tl.size() >= 10);
|
|
|
|
for (int i = 0; i < 5; ++i) {
|
|
REQUIRE(host.trustRemove(dirs[i]));
|
|
}
|
|
|
|
auto after_remove = host.trustList();
|
|
CHECK(after_remove.size() == tl.size() - 5);
|
|
|
|
for (int i = 5; i < 10; ++i) {
|
|
REQUIRE(host.trustRemove(dirs[i]));
|
|
}
|
|
|
|
auto final_list = host.trustList();
|
|
CHECK(final_list.size() == tl.size() - 10);
|
|
}
|
|
|
|
SECTION("Plugin health status during state transitions") {
|
|
RequestDispatcherFixture fixture;
|
|
fs::path plugin(TEST_ABI_PLUGIN_FILE);
|
|
|
|
PluginTrustAddRequest tadd;
|
|
tadd.path = plugin.parent_path().string();
|
|
fixture.dispatcher_->handlePluginTrustAddRequest(tadd);
|
|
|
|
PluginLoadRequest lreq;
|
|
lreq.pathOrName = plugin.string();
|
|
auto lr = fixture.dispatcher_->handlePluginLoadRequest(lreq);
|
|
REQUIRE(std::holds_alternative<PluginLoadResponse>(lr));
|
|
|
|
fixture.svc_->testing_setModelProviderDegraded(true,
|
|
"testing health during degraded state");
|
|
|
|
auto snapshot_degraded = fixture.svc_->getPluginStatusSnapshot();
|
|
bool found_degraded_with_error = false;
|
|
for (const auto& rec : snapshot_degraded.records) {
|
|
if (rec.isProvider && rec.degraded) {
|
|
found_degraded_with_error = true;
|
|
CHECK(rec.error.find("testing health") != std::string::npos);
|
|
}
|
|
}
|
|
CHECK(found_degraded_with_error);
|
|
|
|
fixture.svc_->testing_setModelProviderDegraded(false);
|
|
|
|
auto snapshot_healthy = fixture.svc_->getPluginStatusSnapshot();
|
|
for (const auto& rec : snapshot_healthy.records) {
|
|
if (rec.isProvider) {
|
|
CHECK_FALSE(rec.degraded);
|
|
CHECK(rec.ready);
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
} // namespace yams::daemon
|