Files
OrcaSlicer/tests/slic3rutils/test_plugin_lifecycle.cpp
Ian Chua 62afea225a Fix/impl refactor (#14776)
* fix: impl refactor

* fix: unload/load python module, race conditions, freezes

* remove dead code

* remove extra hook

* remove more dead code

* fix gil run script
2026-07-15 20:12:51 +08:00

761 lines
29 KiB
C++

#include <catch2/catch_all.hpp>
#include <libslic3r/Utils.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PythonFileUtils.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include <boost/filesystem.hpp>
#include <algorithm>
#include <chrono>
#include <fstream>
#include <string>
#include <vector>
using namespace Slic3r;
namespace fs = boost::filesystem;
// Plugin load/unload lifecycle: discovery -> load -> capability materialization -> enable/disable
// -> unload.
//
// Each Catch2 test case runs in its own process (catch_discover_tests), so the PluginManager and
// interpreter singletons are brought up at most once per test.
namespace {
// Point data_dir() at a throwaway directory for the lifetime of a test and restore the previous
// value afterwards, so discovery scans a disposable {data_dir}/orca_plugins tree and tests don't
// leak state into each other.
struct ScopedDataDir
{
std::string previous;
fs::path dir;
explicit ScopedDataDir(const std::string& tag)
{
previous = data_dir();
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
fs::create_directories(dir);
set_data_dir(dir.string());
}
~ScopedDataDir()
{
set_data_dir(previous);
boost::system::error_code ec;
fs::remove_all(dir, ec);
}
fs::path plugins_dir() const { return dir / "orca_plugins"; }
};
// Brings the plugin system up, and tears it down explicitly at the end of the test.
//
// Shutting the interpreter down here, rather than leaving it to PythonInterpreter's static
// destructor, mirrors what the app does (GUI_App finalizes it before exit). Left to static
// destruction, shutdown()'s logging runs after boost::log has torn down its thread-local storage
// and throws, aborting the process after the tests have already passed.
//
// Declare this FIRST in a test so it is destroyed last.
struct ScopedPluginManager
{
bool initialized = false;
ScopedPluginManager() { initialized = PluginManager::instance().initialize(); }
~ScopedPluginManager()
{
PluginManager::instance().shutdown();
PythonInterpreter::instance().shutdown();
}
};
// A minimal script plugin exposing exactly one capability, "Echo".
const char* const ECHO_PLUGIN_SOURCE = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Echo Plugin"
# description = "Plugin lifecycle characterization fixture"
# author = "OrcaSlicer"
# version = "1.0"
# type = "script"
# ///
import orca
class Echo(orca.script.ScriptPluginCapabilityBase):
def get_name(self):
return "Echo"
def execute(self, ctx):
return orca.ExecutionResult.success()
@orca.plugin
class EchoPackage(orca.base):
def register_capabilities(self):
orca.register_capability(Echo)
)PY";
// Writes {data_dir}/orca_plugins/<stem>/<stem>.py and returns the plugin directory.
fs::path write_plugin(const ScopedDataDir& data_dir_guard, const std::string& stem, const std::string& source)
{
const fs::path plugin_dir = data_dir_guard.plugins_dir() / stem;
fs::create_directories(plugin_dir);
std::ofstream out((plugin_dir / (stem + ".py")).string(), std::ios::binary);
out << source;
out.close();
return plugin_dir;
}
// Loads a plugin and blocks until the detached worker thread is done with it.
bool load_and_wait(PluginManager& manager,
const std::string& plugin_key,
std::string& error,
std::vector<std::string> capabilities_to_enable = {})
{
manager.load_plugin(plugin_key, /*skip_deps=*/true, std::move(capabilities_to_enable));
return manager.wait_for_plugin_load(plugin_key, std::chrono::seconds(120), error);
}
std::shared_ptr<PluginCapabilityInterface> find_capability(PluginManager& manager, const std::string& plugin_key,
const std::string& name)
{
return manager.get_plugin_capability(plugin_key, name, PluginCapabilityType::Unknown, /*only_enabled=*/false);
}
std::vector<std::shared_ptr<PluginCapabilityInterface>> capabilities_of(PluginManager& manager, const std::string& plugin_key)
{
return manager.get_plugin_capabilities(plugin_key, PluginCapabilityType::Unknown, /*only_enabled=*/false);
}
PluginDescriptor descriptor_of(PluginManager& manager, const std::string& plugin_key)
{
PluginDescriptor descriptor;
manager.try_get_plugin_descriptor(plugin_key, descriptor);
return descriptor;
}
} // namespace
TEST_CASE("A discovered script plugin loads and materializes its capability", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("lifecycle-load");
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
PluginDescriptor descriptor;
REQUIRE(manager.try_get_valid_plugin_descriptor("Echo_Plugin", descriptor));
CHECK(descriptor.name == "Echo Plugin");
std::string error;
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
INFO("load error: " << error);
CHECK(error.empty());
CHECK(manager.is_plugin_loaded("Echo_Plugin"));
CHECK(manager.get_plugin_load_error("Echo_Plugin").empty());
const auto capabilities = capabilities_of(manager, "Echo_Plugin");
REQUIRE(capabilities.size() == 1);
const auto& echo = capabilities.front();
CHECK(echo->name() == "Echo");
CHECK(echo->type() == PluginCapabilityType::Script);
CHECK(echo->is_enabled());
CHECK(echo->audit_plugin_key() == "Echo_Plugin");
CHECK(manager.get_plugin_capability("Echo_Plugin", "Echo", PluginCapabilityType::Script) == echo);
manager.unload_plugin("Echo_Plugin");
}
TEST_CASE("Plugin manager can initialize again after shutdown", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("lifecycle-reinitialize");
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
manager.shutdown();
REQUIRE(manager.initialize());
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
CHECK(manager.is_plugin_loaded("Echo_Plugin"));
manager.unload_plugin("Echo_Plugin");
}
TEST_CASE("Duplicate discovered plugin keys are reported", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("lifecycle-duplicate-key");
for (const char* directory_name : {"first", "second"}) {
const fs::path plugin_dir = data_dir_guard.plugins_dir() / directory_name;
fs::create_directories(plugin_dir);
std::ofstream out((plugin_dir / "Shared.py").string(), std::ios::binary);
out << ECHO_PLUGIN_SOURCE;
}
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
PluginDescriptor descriptor;
REQUIRE(manager.try_get_plugin_descriptor("Shared", descriptor));
CHECK(descriptor.has_error());
CHECK(descriptor.normalized_error().find("Duplicate plugin key") != std::string::npos);
}
TEST_CASE("Unloading a plugin drops the package and its capabilities", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("lifecycle-unload");
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
REQUIRE(manager.is_plugin_loaded("Echo_Plugin"));
CHECK(manager.unload_plugin("Echo_Plugin"));
CHECK_FALSE(manager.is_plugin_loaded("Echo_Plugin"));
CHECK(manager.get_plugin_capabilities("Echo_Plugin").empty());
CHECK(manager.get_plugin_capability("Echo_Plugin", "Echo", PluginCapabilityType::Script) == nullptr);
// The package stays discovered, but nothing capability-shaped survives the unload.
const PluginDescriptor descriptor = descriptor_of(manager, "Echo_Plugin");
CHECK(descriptor.plugin_key == "Echo_Plugin");
CHECK(capabilities_of(manager, "Echo_Plugin").empty());
}
TEST_CASE("Python module release removes package submodules and owned sys.path", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("module-release");
const fs::path package_root = data_dir_guard.dir / "reload_package";
fs::create_directories(package_root);
auto write_package = [&](const std::string& value) {
std::ofstream init((package_root / "__init__.py").string());
init << "import reload_helper\nfrom . import sub\nVALUE = sub.VALUE\n";
std::ofstream sub((package_root / "sub.py").string());
sub << "VALUE = " << value << "\n";
std::ofstream helper((package_root.parent_path() / "reload_helper.py").string());
helper << "VALUE = 'helper'\n";
};
write_package("'old'");
PythonInterpreter& interpreter = PythonInterpreter::instance();
std::vector<std::string> paths;
std::vector<std::string> modules;
std::string error;
PyObject* module = interpreter.load_module_from_directory(
package_root.parent_path().string(), "reload_package", error, &paths, &modules);
REQUIRE(module != nullptr);
INFO("module load error: " << error);
REQUIRE(error.empty());
REQUIRE(paths.size() == 1);
{
PythonGILState gil;
REQUIRE(gil);
PyObject* modules = PyImport_GetModuleDict();
REQUIRE(modules != nullptr);
CHECK(PyDict_GetItemString(modules, "reload_package") != nullptr);
CHECK(PyDict_GetItemString(modules, "reload_package.sub") != nullptr);
CHECK(PyDict_GetItemString(modules, "reload_helper") != nullptr);
}
Plugin loaded;
loaded.module = module;
loaded.module_name = "reload_package";
loaded.plugin_sys_paths = paths;
loaded.plugin_modules = modules;
loaded.release_module();
{
PythonGILState gil;
REQUIRE(gil);
PyObject* modules = PyImport_GetModuleDict();
REQUIRE(modules != nullptr);
CHECK(PyDict_GetItemString(modules, "reload_package") == nullptr);
CHECK(PyDict_GetItemString(modules, "reload_package.sub") == nullptr);
CHECK(PyDict_GetItemString(modules, "reload_helper") == nullptr);
PyObject* sys_path = PySys_GetObject("path");
REQUIRE(sys_path != nullptr);
PyObjectPtr path(PyUnicode_DecodeFSDefault(paths.front().c_str()));
REQUIRE(path);
CHECK(PySequence_Contains(sys_path, path.get()) == 0);
}
// Ensure the next import executes the new submodule rather than reusing a stale package child.
write_package("'new'");
boost::system::error_code ec;
fs::remove_all(package_root / "__pycache__", ec);
paths.clear();
modules.clear();
module = interpreter.load_module_from_directory(
package_root.parent_path().string(), "reload_package", error, &paths, &modules);
REQUIRE(module != nullptr);
REQUIRE(error.empty());
{
PythonGILState gil;
REQUIRE(gil);
PyObjectPtr value(PyObject_GetAttrString(module, "VALUE"));
REQUIRE(value);
CHECK(std::string(PyUnicode_AsUTF8(value.get())) == "new");
}
loaded.module = module;
loaded.module_name = "reload_package";
loaded.plugin_sys_paths = paths;
loaded.plugin_modules = modules;
loaded.release_module();
}
TEST_CASE("A capability disabled in the sidecar loads disabled", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("lifecycle-disabled");
const fs::path plugin_dir = write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
// Pre-seed the sidecar with the capability disabled, as a previous session would have.
PluginInstallState state;
state.installed_from = "local";
state.installed_version = "1.0";
state.plugin_name = "Echo Plugin";
state.enabled = true;
state.capabilities = {{"Echo", false}};
REQUIRE(write_install_state(plugin_dir, state));
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
REQUIRE(manager.is_plugin_loaded("Echo_Plugin"));
// The capability still materializes — it is loaded, but logically disabled, so consumers skip it.
const auto echo = find_capability(manager, "Echo_Plugin", "Echo");
REQUIRE(echo != nullptr);
CHECK_FALSE(echo->is_enabled());
CHECK(manager.get_plugin_capabilities("Echo_Plugin", PluginCapabilityType::Unknown, /*only_enabled=*/true).empty());
CHECK(manager.get_plugin_capabilities("Echo_Plugin", PluginCapabilityType::Unknown, /*only_enabled=*/false).size() == 1);
// An empty load request must preserve the persisted disabled state even when the package is
// already loaded.
std::string no_request_error;
REQUIRE(load_and_wait(manager, "Echo_Plugin", no_request_error));
CHECK_FALSE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled());
manager.unload_plugin("Echo_Plugin");
}
TEST_CASE("Disabling a capability round-trips through the sidecar and survives a reload", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("lifecycle-roundtrip");
const fs::path plugin_dir = write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
REQUIRE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled());
// Disabling writes the choice through to .install_state.json.
manager.set_capability_enabled("Echo_Plugin", "Echo", false);
CHECK_FALSE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled());
PluginInstallState persisted;
REQUIRE(read_install_state(plugin_dir, persisted));
REQUIRE(persisted.capabilities.size() == 1);
CHECK(persisted.capabilities.front().first == "Echo");
CHECK_FALSE(persisted.capabilities.front().second);
// Unload and reload: the user's choice must survive.
REQUIRE(manager.unload_plugin("Echo_Plugin"));
std::string reload_error;
REQUIRE(load_and_wait(manager, "Echo_Plugin", reload_error));
const auto echo = find_capability(manager, "Echo_Plugin", "Echo");
REQUIRE(echo != nullptr);
CHECK_FALSE(echo->is_enabled());
manager.unload_plugin("Echo_Plugin");
}
TEST_CASE("A capability disabled after load stays disabled when rediscovered and reloaded", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("lifecycle-reload-live");
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
manager.set_capability_enabled("Echo_Plugin", "Echo", false);
REQUIRE(manager.unload_plugin("Echo_Plugin"));
// Rediscover, as the app does when a plugin is toggled off and back on. The enable flags the
// loader seeds from must come from the sidecar just written, not from a stale cache.
manager.discover_plugins(/*async=*/false, /*clear=*/false);
std::string reload_error;
REQUIRE(load_and_wait(manager, "Echo_Plugin", reload_error));
const auto echo = find_capability(manager, "Echo_Plugin", "Echo");
REQUIRE(echo != nullptr);
CHECK_FALSE(echo->is_enabled());
manager.unload_plugin("Echo_Plugin");
}
TEST_CASE("Re-enabling a disabled capability writes the sidecar back", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("lifecycle-reenable");
const fs::path plugin_dir = write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
PluginInstallState state;
state.installed_from = "local";
state.plugin_name = "Echo Plugin";
state.enabled = true;
state.capabilities = {{"Echo", false}};
REQUIRE(write_install_state(plugin_dir, state));
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
// An explicit request overrides the persisted disabled state, including on a fresh load.
REQUIRE(manager.unload_plugin("Echo_Plugin"));
std::string enable_error;
REQUIRE(load_and_wait(manager, "Echo_Plugin", enable_error, {"Echo"}));
const auto echo = find_capability(manager, "Echo_Plugin", "Echo");
REQUIRE(echo != nullptr);
CHECK(echo->is_enabled());
PluginInstallState persisted;
REQUIRE(read_install_state(plugin_dir, persisted));
REQUIRE(persisted.capabilities.size() == 1);
CHECK(persisted.capabilities.front().first == "Echo");
CHECK(persisted.capabilities.front().second);
manager.unload_plugin("Echo_Plugin");
}
TEST_CASE("Overwriting a local plugin unloads its live module", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("lifecycle-overwrite");
const fs::path package_dir = data_dir_guard.dir / "packages";
fs::create_directories(package_dir);
const fs::path package = package_dir / "Echo_Plugin.py";
{
std::ofstream out(package.string(), std::ios::binary);
out << ECHO_PLUGIN_SOURCE;
}
PluginManager& manager = PluginManager::instance();
std::string error;
REQUIRE(manager.install_plugin(package, error));
manager.discover_plugins(/*async=*/false, /*clear=*/true);
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
REQUIRE(manager.is_plugin_loaded("Echo_Plugin"));
REQUIRE(manager.install_plugin(package, error));
CHECK_FALSE(manager.is_plugin_loaded("Echo_Plugin"));
}
TEST_CASE("capabilities_to_enable selects which capabilities come up enabled", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
// Two capabilities in one package; only the second is requested.
const char* const two_cap_source = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Duo Plugin"
# version = "1.0"
# type = "script"
# ///
import orca
class Alpha(orca.script.ScriptPluginCapabilityBase):
def get_name(self):
return "Alpha"
def execute(self, ctx):
return orca.ExecutionResult.success()
class Beta(orca.script.ScriptPluginCapabilityBase):
def get_name(self):
return "Beta"
def execute(self, ctx):
return orca.ExecutionResult.success()
@orca.plugin
class DuoPackage(orca.base):
def register_capabilities(self):
orca.register_capability(Alpha)
orca.register_capability(Beta)
)PY";
ScopedDataDir data_dir_guard("lifecycle-select");
write_plugin(data_dir_guard, "Duo_Plugin", two_cap_source);
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, "Duo_Plugin", error, {"Beta"}));
REQUIRE(capabilities_of(manager, "Duo_Plugin").size() == 2);
const auto alpha = find_capability(manager, "Duo_Plugin", "Alpha");
const auto beta = find_capability(manager, "Duo_Plugin", "Beta");
REQUIRE(alpha != nullptr);
REQUIRE(beta != nullptr);
CHECK_FALSE(alpha->is_enabled());
CHECK(beta->is_enabled());
manager.unload_plugin("Duo_Plugin");
}
TEST_CASE("A cancelled load keeps blocking wait_for_all_plugin_loads until the worker exits", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
// Stalls inside the module import, so the detached load worker is still executing Python while
// the test cancels it.
const char* const slow_source = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Slow Load Plugin"
# version = "1.0"
# type = "script"
# ///
import time
import orca
time.sleep(2)
class Slow(orca.script.ScriptPluginCapabilityBase):
def get_name(self):
return "Slow"
def execute(self, ctx):
return orca.ExecutionResult.success()
@orca.plugin
class SlowPackage(orca.base):
def register_capabilities(self):
orca.register_capability(Slow)
)PY";
ScopedDataDir data_dir_guard("lifecycle-cancel");
write_plugin(data_dir_guard, "Slow_Load_Plugin", slow_source);
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
manager.load_plugin("Slow_Load_Plugin", /*skip_deps=*/true);
// The key is registered before the worker is spawned, so this is not a race.
REQUIRE(manager.is_plugin_load_in_progress("Slow_Load_Plugin"));
REQUIRE(manager.cancel_plugin_load("Slow_Load_Plugin"));
// Cancelling must not release the worker's slot. shutdown() unloads everything and GUI_App
// finalizes the interpreter as soon as this wait returns, so reporting "no loads in progress"
// while the worker is still inside Python is how the app crashes on exit.
CHECK(manager.is_plugin_load_in_progress("Slow_Load_Plugin"));
CHECK_FALSE(manager.wait_for_all_plugin_loads(std::chrono::milliseconds(0)));
// The worker releases the slot itself, once it has unwound.
CHECK(manager.wait_for_all_plugin_loads(std::chrono::seconds(60)));
CHECK_FALSE(manager.is_plugin_load_in_progress("Slow_Load_Plugin"));
CHECK_FALSE(manager.is_plugin_loaded("Slow_Load_Plugin"));
}
TEST_CASE("Loading an unknown plugin key records an error instead of crashing", "[PluginLifecycle][Python]")
{
// discover_plugins() initializes the plugin system (and with it the interpreter), so this
// needs the same explicit teardown as the load tests.
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("lifecycle-missing");
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
// Rejected synchronously: no worker thread is spawned for an unknown key.
manager.load_plugin("No_Such_Plugin", /*skip_deps=*/true);
CHECK_FALSE(manager.is_plugin_loaded("No_Such_Plugin"));
CHECK(manager.get_plugin_load_error("No_Such_Plugin") == "Plugin not found: No_Such_Plugin");
CHECK(manager.get_plugin_capabilities("No_Such_Plugin").empty());
}
TEST_CASE("The startup auto-load list only contains packages whose sidecar enables them", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("lifecycle-autoload");
// No sidecar at all: never installed through Orca, so it carries no auto-load intent.
write_plugin(data_dir_guard, "Bare_Plugin", ECHO_PLUGIN_SOURCE);
// Sidecar with enabled = true: auto-loads.
const fs::path on_dir = write_plugin(data_dir_guard, "Enabled_Plugin", ECHO_PLUGIN_SOURCE);
PluginInstallState on_state;
on_state.installed_from = "local";
on_state.enabled = true;
REQUIRE(write_install_state(on_dir, on_state));
// Sidecar with enabled = false: the user turned it off.
const fs::path off_dir = write_plugin(data_dir_guard, "Disabled_Plugin", ECHO_PLUGIN_SOURCE);
PluginInstallState off_state;
off_state.installed_from = "local";
off_state.enabled = false;
REQUIRE(write_install_state(off_dir, off_state));
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
const std::vector<std::string> keys = manager.get_enabled_plugin_keys();
CHECK(std::find(keys.begin(), keys.end(), "Enabled_Plugin") != keys.end());
CHECK(std::find(keys.begin(), keys.end(), "Disabled_Plugin") == keys.end());
CHECK(std::find(keys.begin(), keys.end(), "Bare_Plugin") == keys.end());
}
TEST_CASE("Signing out drops every cloud plugin row, installed or not", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("lifecycle-signout");
// A local package, which must survive sign-out.
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
// Two cloud rows: one merely available (nothing installed), one with a local package behind it —
// the case that used to linger, unloaded but still listed, until the user hit refresh.
PluginDescriptor available;
available.plugin_key = "11111111-1111-1111-1111-111111111111";
available.name = "Available Cloud Plugin";
available.cloud = CloudPluginState{available.plugin_key, /*installed=*/false, false, false, false};
PluginDescriptor installed;
installed.plugin_key = "22222222-2222-2222-2222-222222222222";
installed.name = "Installed Cloud Plugin";
installed.plugin_root = (data_dir_guard.plugins_dir() / "_subscribed" / "user" / installed.plugin_key).string();
installed.cloud = CloudPluginState{installed.plugin_key, /*installed=*/true, false, false, false};
manager.update_cloud_catalog({available, installed});
const auto has_key = [&manager](const std::string& key) {
PluginDescriptor descriptor;
return manager.try_get_plugin_descriptor(key, descriptor);
};
REQUIRE(has_key(available.plugin_key));
REQUIRE(has_key(installed.plugin_key));
REQUIRE(has_key("Echo_Plugin"));
// Sign out. The per-user _subscribed directory stops being scanned, so both cloud rows are now
// stale and must go — not just the one with nothing installed behind it.
manager.unload_cloud_plugins();
manager.clear_cloud_plugin_catalog();
manager.set_cloud_user("");
CHECK_FALSE(has_key(available.plugin_key));
CHECK_FALSE(has_key(installed.plugin_key));
CHECK(has_key("Echo_Plugin"));
}
TEST_CASE("Unloading a plugin that is not loaded is a no-op", "[PluginLifecycle]")
{
ScopedDataDir data_dir_guard("lifecycle-noop-unload");
PluginManager& manager = PluginManager::instance();
// Current behavior: unloading an unknown key succeeds (it fires the unload callbacks and
// reports success) rather than reporting "nothing to unload".
CHECK(manager.unload_plugin("No_Such_Plugin"));
CHECK_FALSE(manager.is_plugin_loaded("No_Such_Plugin"));
}