v0.20 removed the code-symbol graph, but installs still carry its leftovers: - <data_dir>/grammars holds tree-sitter grammar libraries (~44 MB on the audited machine) that `yams init` / `yams config grammar` built and nothing loads any more. - A yams_symbol_extractor plugin under the install prefix is refused on every start with two warnings (the ABI host's skip plus the plugin manager's "load failed"), and the refusal flipped the plugin host FSM through Failed. Remove the grammars once through metadata migration 43, so the cleanup is recorded in schema_migrations and never repeats. It only acts on a data directory's yams.db, only deletes libtree-sitter-* / tree-sitter-* files directly in grammars/, removes the directory only when nothing else is left in it, and leaves a symlinked grammars/ (and its target) alone, so nothing outside the data directory can be touched. The install prefix is not ours to clean, so the stale plugin is only reported: one info line per process naming the file that can be deleted. PluginManager recognises that refusal and skips it quietly instead of warning and dispatching a load failure. Signed-off-by: trvon <me@trevon.dev>
642 lines
23 KiB
C++
642 lines
23 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>
|
|
#include <spdlog/sinks/ostream_sink.h>
|
|
#include <spdlog/spdlog.h>
|
|
|
|
#include <sstream>
|
|
|
|
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;
|
|
};
|
|
|
|
// A stale plugin built against the interface removed in v0.20: discovery finds it on every
|
|
// start, and the host refuses to adopt it.
|
|
class RemovedInterfaceAbiPluginHost final : public AbiPluginHost {
|
|
public:
|
|
yams::Result<std::vector<PluginDescriptor>>
|
|
scanDirectory(const std::filesystem::path& path) override {
|
|
PluginDescriptor stale{.name = "yams_symbol_extractor",
|
|
.path = path / "libyams_symbol_extractor.so",
|
|
.interfaces = {std::string(kRemovedSymbolExtractorInterface)}};
|
|
return std::vector<PluginDescriptor>{stale};
|
|
}
|
|
yams::Result<PluginDescriptor> load(const std::filesystem::path& path,
|
|
const std::string&) override {
|
|
++loads;
|
|
return yams::Error{yams::ErrorCode::NotSupported,
|
|
std::string(kRemovedSymbolExtractorRefusal) + path.string()};
|
|
}
|
|
std::vector<PluginDescriptor> listLoaded() const override { return {}; }
|
|
std::vector<std::filesystem::path> trustList() const override { return trustedPaths; }
|
|
|
|
std::vector<std::filesystem::path> trustedPaths;
|
|
std::size_t loads{0};
|
|
};
|
|
|
|
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("Removed-interface refusals are recognised", "[daemon][components][plugin][catch2]") {
|
|
CHECK(isRemovedInterfaceRefusal(
|
|
yams::Error{yams::ErrorCode::NotSupported,
|
|
std::string(kRemovedSymbolExtractorRefusal) + "/opt/yams/lib/x.so"}));
|
|
CHECK_FALSE(isRemovedInterfaceRefusal(
|
|
yams::Error{yams::ErrorCode::InvalidState,
|
|
std::string(kRemovedSymbolExtractorRefusal) + "/opt/yams/lib/x.so"}));
|
|
CHECK_FALSE(isRemovedInterfaceRefusal(
|
|
yams::Error{yams::ErrorCode::NotSupported, "Plugin init failed"}));
|
|
}
|
|
|
|
TEST_CASE_METHOD(PluginManagerFixture,
|
|
"PluginManager autoload skips a removed-interface plugin without a warning",
|
|
"[daemon][components][plugin][autoload][catch2]") {
|
|
yams::test::ScopedEnvVar mockProvider("YAMS_USE_MOCK_PROVIDER", std::nullopt);
|
|
yams::test::ScopedEnvVar abiPlugins("YAMS_DISABLE_ABI_PLUGINS", std::nullopt);
|
|
config.pluginDirStrict = true;
|
|
std::filesystem::create_directories(tempDir / "plugins");
|
|
RemovedInterfaceAbiPluginHost abiHost;
|
|
abiHost.trustedPaths = {tempDir / "plugins"};
|
|
|
|
auto deps = makeDeps();
|
|
deps.sharedPluginHost = &abiHost;
|
|
PluginManager mgr(deps);
|
|
REQUIRE(mgr.initialize());
|
|
|
|
std::ostringstream captured;
|
|
auto previous = spdlog::default_logger();
|
|
auto logger = std::make_shared<spdlog::logger>(
|
|
"plugin_skip_capture", std::make_shared<spdlog::sinks::ostream_sink_mt>(captured));
|
|
logger->set_level(spdlog::level::info);
|
|
spdlog::set_default_logger(logger);
|
|
auto loaded = mgr.autoloadPlugins();
|
|
spdlog::set_default_logger(previous);
|
|
|
|
REQUIRE(loaded);
|
|
CHECK((loaded.value() == 0));
|
|
CHECK((abiHost.loads == 1));
|
|
INFO(captured.str());
|
|
std::istringstream lines(captured.str());
|
|
for (std::string line; std::getline(lines, line);) {
|
|
const bool aboutStalePlugin = line.find("symbol_extractor") != std::string::npos;
|
|
CHECK_FALSE((aboutStalePlugin && line.find("[warning]") != std::string::npos));
|
|
}
|
|
CHECK((captured.str().find("load failed") == std::string::npos));
|
|
CHECK((mgr.getPluginHostFsmSnapshot().state != PluginHostState::Failed));
|
|
}
|
|
|
|
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));
|
|
}
|