yams/tests/unit/daemon/plugin_infrastructure_test.cpp
Trevon 211db15606
fix(daemon): bound semantic neighbor scores and report startup phase (#334)
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>
2026-10-22 16:48:49 -06:00

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