yams/tests/unit/daemon/components/plugin_manager_test.cpp

566 lines
20 KiB
C++

// Copyright (c) 2025 YAMS Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Unit tests for PluginManager component (PBI-090)
//
// Catch2 migration from GTest (yams-3s4 / yams-zns)
#include <catch2/catch_test_macros.hpp>
#include "../../../common/test_helpers_catch2.h"
#include <yams/daemon/components/DaemonLifecycleFsm.h>
#include <yams/daemon/components/PluginManager.h>
#include <yams/daemon/components/StateComponent.h>
#include <yams/daemon/daemon.h>
#include <yams/daemon/resource/model_provider.h>
#include <yams/daemon/resource/plugin_content_extractor_adapter.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <memory>
#include <string>
#include <utility>
#include <nlohmann/json.hpp>
using namespace yams::daemon;
template <typename T>
concept HasIndependentPluginStatusSnapshot =
requires(const T& manager) { manager.getStatusSnapshot(); };
static_assert(!HasIndependentPluginStatusSnapshot<PluginManager>,
"ServiceManager is the sole plugin status snapshot authority");
namespace {
class CountingAbiPluginHost final : public AbiPluginHost {
public:
CountingAbiPluginHost() = default;
std::vector<PluginDescriptor> listLoaded() const override {
++listCalls;
if (unloadCalls == 0) {
return {{.name = "counted_plugin"}};
}
return {};
}
yams::Result<void> unload(const std::string&) override {
++unloadCalls;
return {};
}
mutable std::size_t listCalls{0};
std::size_t unloadCalls{0};
};
class RecordingPluginHost final : public IPluginHost {
public:
yams::Result<PluginDescriptor> scanTarget(const std::filesystem::path&) override {
return descriptor;
}
yams::Result<std::vector<PluginDescriptor>>
scanDirectory(const std::filesystem::path&) override {
return std::vector<PluginDescriptor>{descriptor};
}
yams::Result<PluginDescriptor> load(const std::filesystem::path& path,
const std::string& configJson) override {
loadedPath = path;
loadedConfigJson = configJson;
descriptor.path = path;
return descriptor;
}
yams::Result<void> unload(const std::string&) override { return {}; }
std::vector<PluginDescriptor> listLoaded() const override { return {}; }
std::vector<std::filesystem::path> trustList() const override { return {}; }
yams::Result<void> trustAdd(const std::filesystem::path&) override { return {}; }
yams::Result<void> trustRemove(const std::filesystem::path&) override { return {}; }
yams::Result<std::string> health(const std::string&) override { return std::string("{}"); }
PluginDescriptor descriptor;
std::filesystem::path loadedPath;
std::string loadedConfigJson;
};
class DiscoveryAbiPluginHost final : public AbiPluginHost {
public:
yams::Result<PluginDescriptor> scanTarget(const std::filesystem::path& path) override {
++targetScans;
auto result = descriptor;
result.path = path;
return result;
}
yams::Result<std::vector<PluginDescriptor>>
scanDirectory(const std::filesystem::path& path) override {
++directoryScans;
auto result = descriptor;
result.path = path / descriptor.path.filename();
return std::vector<PluginDescriptor>{result};
}
yams::Result<PluginDescriptor> load(const std::filesystem::path& path,
const std::string& configJson) override {
++loads;
loadedConfigJson = configJson;
auto result = descriptor;
result.path = path;
loadedPlugins.push_back(result);
return result;
}
yams::Result<void> unload(const std::string& name) override {
std::erase_if(loadedPlugins, [&](const auto& loaded) { return loaded.name == name; });
return {};
}
std::vector<PluginDescriptor> listLoaded() const override { return loadedPlugins; }
std::vector<std::filesystem::path> trustList() const override { return trustedPaths; }
PluginDescriptor descriptor{.name = "libyams_glint",
.path = "libyams_glint.so",
.interfaces = {"content_extractor_v1"}};
std::vector<std::filesystem::path> trustedPaths;
std::vector<PluginDescriptor> loadedPlugins;
std::size_t targetScans{0};
std::size_t directoryScans{0};
std::size_t loads{0};
std::string loadedConfigJson;
};
struct PluginManagerFixture {
std::filesystem::path tempDir;
std::unique_ptr<DaemonLifecycleFsm> lifecycleFsm;
std::unique_ptr<StateComponent> stateComponent;
DaemonConfig config;
PluginManagerFixture() {
tempDir = std::filesystem::temp_directory_path() /
("yams_pluginmgr_test_" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tempDir);
lifecycleFsm = std::make_unique<DaemonLifecycleFsm>();
stateComponent = std::make_unique<StateComponent>();
}
~PluginManagerFixture() {
lifecycleFsm.reset();
stateComponent.reset();
std::error_code ec;
std::filesystem::remove_all(tempDir, ec);
}
PluginManager::Dependencies makeDeps() {
PluginManager::Dependencies deps;
deps.config = &config;
deps.state = stateComponent.get();
deps.lifecycleFsm = lifecycleFsm.get();
deps.dataDir = tempDir;
deps.resolvePreferredModel = []() { return std::string("test-model"); };
return deps;
}
};
} // namespace
TEST_CASE("PluginContentExtractorAdapter retains ABI plugin lease",
"[daemon][components][plugin][lifetime][catch2]") {
auto keepAlive = std::make_shared<int>(42);
std::weak_ptr<int> weakKeepAlive = keepAlive;
auto adapter = std::make_shared<PluginContentExtractorAdapter>(
nullptr, std::static_pointer_cast<void>(keepAlive));
keepAlive.reset();
CHECK_FALSE(weakKeepAlive.expired());
adapter.reset();
CHECK(weakKeepAlive.expired());
}
TEST_CASE_METHOD(PluginManagerFixture, "PluginManager construction",
"[daemon][components][plugin][catch2]") {
auto deps = makeDeps();
SECTION("construction succeeds with valid dependencies") {
PluginManager mgr(deps);
CHECK((mgr.getName() == std::string("PluginManager")));
}
}
TEST_CASE_METHOD(PluginManagerFixture, "PluginManager initialize/shutdown lifecycle",
"[daemon][components][plugin][catch2]") {
auto deps = makeDeps();
PluginManager mgr(deps);
SECTION("initialize without plugins succeeds") {
auto result = mgr.initialize();
CHECK(result.has_value());
}
SECTION("shutdown is safe without initialize") {
mgr.shutdown();
// No crash = success
}
SECTION("initialize then shutdown works") {
REQUIRE(mgr.initialize().has_value());
mgr.shutdown();
// No crash = success
}
SECTION("double shutdown is safe") {
REQUIRE(mgr.initialize().has_value());
mgr.shutdown();
mgr.shutdown();
// No crash = success
}
}
TEST_CASE_METHOD(PluginManagerFixture, "PluginManager autoload is synchronous",
"[daemon][components][plugin][autoload][catch2]") {
config.useMockModelProvider = true;
PluginManager mgr(makeDeps());
REQUIRE(mgr.initialize());
const auto result = mgr.autoloadPlugins();
REQUIRE(result);
CHECK((result.value() == 0));
}
TEST_CASE_METHOD(PluginManagerFixture, "PluginManager owns shared host shutdown",
"[daemon][components][plugin][ownership][catch2]") {
CountingAbiPluginHost sharedHost;
auto deps = makeDeps();
deps.sharedPluginHost = &sharedHost;
PluginManager mgr(deps);
REQUIRE(mgr.initialize());
mgr.shutdown();
mgr.shutdown();
CHECK((sharedHost.listCalls == 1));
CHECK((sharedHost.unloadCalls == 1));
}
TEST_CASE_METHOD(PluginManagerFixture, "PluginManager plugin host accessors",
"[daemon][components][plugin][catch2]") {
auto deps = makeDeps();
PluginManager mgr(deps);
SECTION("plugin host is nullptr before init") {
// May or may not be null depending on implementation
auto host = mgr.getPluginHost();
// Just verify accessor doesn't crash
(void)host;
}
SECTION("external plugin host is nullptr before init") {
auto ext = mgr.getExternalPluginHost();
(void)ext;
}
}
TEST_CASE_METHOD(PluginManagerFixture, "PluginManager applies name policy to fallback host",
"[daemon][components][plugin][policy][catch2]") {
yams::test::ScopedEnvVar namePolicyEnv("YAMS_PLUGIN_NAME_POLICY", std::nullopt);
config.pluginNamePolicy = "spec";
const auto nonPlugin = tempDir / "third_party.so";
{
std::ofstream pluginFile(nonPlugin, std::ios::binary);
REQUIRE(pluginFile.good());
pluginFile.put('\0');
}
const auto requireSpecPolicy = [&](AbiPluginHost& host) {
REQUIRE(host.scanDirectory(tempDir));
const auto skips = host.getLastScanSkips();
const auto skip = std::find_if(skips.begin(), skips.end(),
[&](const auto& entry) { return entry.first == nonPlugin; });
REQUIRE((skip != skips.end()));
CHECK((skip->second == "name policy: require libyams_* or yams_*"));
};
SECTION("fallback host") {
PluginManager mgr(makeDeps());
REQUIRE(mgr.initialize());
auto* host = mgr.getPluginHost();
REQUIRE((host != nullptr));
requireSpecPolicy(*host);
}
SECTION("shared host") {
AbiPluginHost sharedHost(tempDir / "shared-plugins.trust");
auto deps = makeDeps();
deps.sharedPluginHost = &sharedHost;
PluginManager mgr(deps);
REQUIRE(mgr.initialize());
requireSpecPolicy(sharedHost);
}
}
TEST_CASE_METHOD(PluginManagerFixture, "PluginManager configured loads use one config path",
"[daemon][components][plugin][config][catch2]") {
PluginManager mgr(makeDeps());
RecordingPluginHost host;
SECTION("short aliases resolve for ABI and external hosts") {
config.pluginConfigs["glint"] = R"({"mode":"fast"})";
host.descriptor.name = "yams_glint";
host.descriptor.path = tempDir / "libyams_glint.so";
REQUIRE(mgr.loadConfiguredPlugin(host, host.descriptor));
CHECK((host.loadedPath == host.descriptor.path));
CHECK((host.loadedConfigJson == R"({"mode":"fast"})"));
}
SECTION("conflicting aliases fail closed") {
config.pluginConfigs["glint"] = R"({"mode":"short"})";
config.pluginConfigs["yams_glint"] = R"({"mode":"prefixed"})";
host.descriptor.name = "yams_glint";
host.descriptor.path = tempDir / "libyams_glint.so";
auto result = mgr.loadConfiguredPlugin(host, host.descriptor);
REQUIRE_FALSE(result);
CHECK((result.error().code == yams::ErrorCode::InvalidData));
CHECK(host.loadedPath.empty());
}
SECTION("ONNX pool constraints are applied after config resolution") {
config.pluginConfigs["onnx"] = R"({"mode":"fast"})";
config.modelPoolConfig.maxLoadedModels = 7;
host.descriptor.name = "yams_onnx";
host.descriptor.path = tempDir / "libyams_onnx.so";
host.descriptor.interfaces = {"model_provider_v1"};
REQUIRE(mgr.loadConfiguredPlugin(host, host.descriptor));
const auto loadedConfig = nlohmann::json::parse(host.loadedConfigJson);
CHECK((loadedConfig["mode"] == "fast"));
CHECK((loadedConfig["max_loaded_models"] == 7));
}
}
TEST_CASE_METHOD(PluginManagerFixture, "PluginManager owns discovery routing and loading",
"[daemon][components][plugin][discovery][catch2]") {
config.pluginDirStrict = true;
DiscoveryAbiPluginHost abiHost;
abiHost.trustedPaths = {tempDir / "plugins", tempDir / "plugins"};
config.pluginConfigs["glint"] = R"({"mode":"canonical"})";
auto deps = makeDeps();
deps.sharedPluginHost = &abiHost;
PluginManager mgr(deps);
REQUIRE(mgr.initialize());
SECTION("target discovery chooses one host and uses canonical configured load") {
abiHost.descriptor.interfaces.clear();
const auto pluginPath = tempDir / "libyams_glint.so";
auto discovered = mgr.scanPluginTarget(pluginPath);
REQUIRE(discovered);
CHECK((discovered.value().host == PluginManager::PluginHostKind::Abi));
CHECK((discovered.value().descriptor.path == pluginPath));
CHECK((abiHost.targetScans == 1));
auto loaded = mgr.loadDiscoveredPlugin(discovered.value());
REQUIRE(loaded);
CHECK((abiHost.loads == 1));
CHECK((abiHost.loadedConfigJson == R"({"mode":"canonical"})"));
auto snapshot = mgr.getPluginHostFsmSnapshot();
CHECK((snapshot.state == PluginHostState::Ready));
CHECK((snapshot.loadedCount == 1));
CHECK((snapshot.loadedPlugins == std::vector<std::string>{"libyams_glint"}));
REQUIRE(mgr.unloadPlugin("libyams_glint"));
snapshot = mgr.getPluginHostFsmSnapshot();
CHECK((snapshot.state == PluginHostState::Ready));
CHECK((snapshot.loadedCount == 0));
CHECK(snapshot.loadedPlugins.empty());
}
SECTION("runtime interfaces reject unsafe hot unload") {
const std::array<const char*, 6> runtimeInterfaces = {
"model_provider_v1", "content_extractor_v1", "symbol_extractor_v1",
"entity_extractor_v2", "kg_entity_provider_v1", "future_runtime_v9"};
for (const auto* interfaceName : runtimeInterfaces) {
INFO("interface=" << interfaceName);
abiHost.descriptor.interfaces = {interfaceName};
abiHost.loadedPlugins = {abiHost.descriptor};
auto result = mgr.unloadPlugin("libyams_glint");
REQUIRE_FALSE(result);
CHECK((result.error().code == yams::ErrorCode::InvalidState));
CHECK((abiHost.loadedPlugins.size() == 1));
}
}
SECTION("configured discovery roots are deduplicated and obey strict mode") {
auto discovered = mgr.scanConfiguredPluginRoots();
REQUIRE(discovered);
REQUIRE((discovered.value().size() == 1));
CHECK((discovered.value().front().host == PluginManager::PluginHostKind::Abi));
CHECK((abiHost.directoryScans == 1));
}
SECTION("loaded child does not suppress its containing directory") {
const auto pluginDirectory = tempDir / "plugins";
const auto loadedPath = pluginDirectory / "libyams_one.so";
abiHost.loadedPlugins = {{.name = "yams_one", .path = loadedPath}};
CHECK(mgr.isPluginLoadedFrom(loadedPath));
CHECK_FALSE(mgr.isPluginLoadedFrom(pluginDirectory));
}
}
TEST_CASE_METHOD(PluginManagerFixture, "PluginManager trust list operations",
"[daemon][components][plugin][catch2]") {
auto deps = makeDeps();
PluginManager mgr(deps);
SECTION("initial trust list may be empty") {
auto list = mgr.trustList();
// Empty or contains defaults - both valid
(void)list;
}
SECTION("trustAdd for valid path returns result") {
auto result = mgr.trustAdd(tempDir / "plugin.so");
// Either success or failure is acceptable without init
(void)result;
}
SECTION("trustRemove for non-existent path returns result") {
auto result = mgr.trustRemove(tempDir / "nonexistent.so");
(void)result;
}
}
TEST_CASE("PluginManager getName returns component name", "[daemon][components][plugin][catch2]") {
DaemonLifecycleFsm fsm;
StateComponent state;
DaemonConfig config;
PluginManager::Dependencies deps;
deps.config = &config;
deps.state = &state;
deps.lifecycleFsm = &fsm;
deps.resolvePreferredModel = []() { return std::string("test"); };
PluginManager mgr(deps);
CHECK((std::string(mgr.getName()) == "PluginManager"));
}
namespace {
struct ScopedEmbedBackend {
yams::test::ScopedEnvVar backend;
yams::test::ScopedEnvVar preferredModel;
explicit ScopedEmbedBackend(const char* selectedBackend)
: backend("YAMS_EMBED_BACKEND", std::string(selectedBackend)),
preferredModel("YAMS_PREFERRED_MODEL", std::nullopt) {}
};
class TaggedInProcessProvider : public IModelProvider {
public:
explicit TaggedInProcessProvider(std::string tag) : tag_(std::move(tag)) {}
yams::Result<std::vector<float>> generateEmbedding(const std::string&) override {
return std::vector<float>{};
}
yams::Result<std::vector<std::vector<float>>>
generateBatchEmbeddings(const std::vector<std::string>&) override {
return std::vector<std::vector<float>>{};
}
yams::Result<std::vector<float>> generateEmbeddingFor(const std::string&,
const std::string&) override {
return std::vector<float>{};
}
yams::Result<std::vector<std::vector<float>>>
generateBatchEmbeddingsFor(const std::string&, const std::vector<std::string>&) override {
return std::vector<std::vector<float>>{};
}
yams::Result<void> loadModel(const std::string&) override { return {}; }
yams::Result<void> unloadModel(const std::string&) override { return {}; }
bool isModelLoaded(const std::string&) const override { return false; }
std::vector<std::string> getLoadedModels() const override { return {}; }
size_t getLoadedModelCount() const override { return 0; }
yams::Result<yams::daemon::ModelInfo> getModelInfo(const std::string&) const override {
return yams::daemon::ModelInfo{};
}
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 tag_; }
std::string getProviderVersion() const override { return "0.0.0-test"; }
bool isAvailable() const override { return true; }
bool isTrainingFree() const override { return true; }
size_t getMemoryUsage() const override { return 0; }
void releaseUnusedResources() override {}
void shutdown() override {}
private:
std::string tag_;
};
std::string makeUniqueProviderName(const char* prefix) {
static std::atomic<unsigned long> counter{0};
return std::string(prefix) + "_" +
std::to_string(counter.fetch_add(1, std::memory_order_relaxed));
}
} // namespace
TEST_CASE_METHOD(PluginManagerFixture,
"adoptModelProvider: prefers in-process backend when selected via env",
"[daemon][components][plugin][catch2][simeon]") {
const std::string backendName = makeUniqueProviderName("pm_test_backend");
const std::string expectedTag = backendName;
ModelProviderFactoryRegistration reg{
backendName, [expectedTag]() -> std::unique_ptr<IModelProvider> {
return std::make_unique<TaggedInProcessProvider>(expectedTag);
}};
ScopedEmbedBackend guard{backendName.c_str()};
auto deps = makeDeps();
PluginManager mgr(deps);
REQUIRE(mgr.initialize().has_value());
auto result = mgr.adoptModelProvider();
REQUIRE(result.has_value());
REQUIRE(result.value());
auto provider = mgr.getModelProvider();
REQUIRE((provider != nullptr));
CHECK((provider->getProviderName() == expectedTag));
CHECK(provider->isTrainingFree());
CHECK((mgr.getEmbeddingModelName() == expectedTag));
}
TEST_CASE_METHOD(
PluginManagerFixture,
"adoptModelProvider: empty preferredName and backend=auto does not force in-process",
"[daemon][components][plugin][catch2][simeon]") {
ScopedEmbedBackend guard{"auto"};
auto deps = makeDeps();
PluginManager mgr(deps);
REQUIRE(mgr.initialize().has_value());
auto result = mgr.adoptModelProvider();
REQUIRE(result.has_value());
CHECK_FALSE(result.value());
CHECK((mgr.getModelProvider() == nullptr));
}