diff --git a/tests/slic3rutils/test_plugin_capability_config.cpp b/tests/slic3rutils/test_plugin_capability_config.cpp index 40034d2591..f224f92308 100644 --- a/tests/slic3rutils/test_plugin_capability_config.cpp +++ b/tests/slic3rutils/test_plugin_capability_config.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -21,19 +22,33 @@ using json = nlohmann::json; namespace { -void ensure_python_initialized() +// Brings the plugin system up for the duration of one test and tears it down at the end — same +// idiom as ScopedPluginManager in test_plugin_lifecycle.cpp, and for the same reason: shutdown() +// logs via boost::log, so it must run before boost::log tears down its thread-local storage, not be +// left to a static destructor at process exit. +// +// Needed here (unlike a bare pybind11::scoped_interpreter): has_config_ui()/get_config_ui()/ +// get_name()/etc. all cross PyPluginCommonTrampoline's ORCA_PY_OVERRIDE_AUDITED, which refuses to +// call into Python unless PythonInterpreter::instance() itself reports initialized (see +// PythonGILState). A bare interpreter never sets that flag, so every trampoline call would report +// "Python interpreter is shutting down" even though Python was perfectly alive. +// +// initialize() leaves the GIL released (production code re-acquires it per call via PythonGILState); +// every TEST_CASE below pairs this with a py::gil_scoped_acquire, declared second so it releases +// before this destructor's shutdown() runs. +struct ScopedPluginManager { - // As in test_plugin_host_api.cpp: `orca` is compiled into this binary, so a bare interpreter is - // enough and does not need the bundled Python home. - if (!Py_IsInitialized()) { - static py::scoped_interpreter interpreter; - (void) interpreter; + bool initialized = PluginManager::instance().initialize(); + + ~ScopedPluginManager() + { + PluginManager::instance().shutdown(); + PythonInterpreter::instance().shutdown(); } -} +}; py::module_ import_orca_module() { - ensure_python_initialized(); (void) PythonPluginBridge::instance(); // force the embedded module registration into the binary return py::module_::import("orca"); } @@ -86,6 +101,11 @@ PluginCapabilityId capability_id(PluginCapabilityType type, const char* name, co TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]") { + ScopedPluginManager plugin_system; // declared first: destroyed last + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down + py::module_ orca = import_orca_module(); REQUIRE(py::hasattr(orca, "PythonPluginBase")); @@ -107,6 +127,11 @@ TEST_CASE("Capability config API is exposed on every Python capability", "[Plugi TEST_CASE("get_config returns only cap_config and save_config persists it", "[PluginConfig][Python]") { + ScopedPluginManager plugin_system; // declared first: destroyed last + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down + ScopedDataDir data_dir_guard("plugin-config-py-roundtrip"); host_config().load(); // reset the singleton's in-memory store against the empty temp dir @@ -138,6 +163,11 @@ TEST_CASE("get_config returns only cap_config and save_config persists it", "[Pl TEST_CASE("save_config rejects a string that is not valid JSON", "[PluginConfig][Python]") { + ScopedPluginManager plugin_system; // declared first: destroyed last + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down + ScopedDataDir data_dir_guard("plugin-config-py-badjson"); host_config().load(); @@ -152,6 +182,11 @@ TEST_CASE("save_config rejects a string that is not valid JSON", "[PluginConfig] TEST_CASE("Saving one capability's config does not touch another's", "[PluginConfig][Python]") { + ScopedPluginManager plugin_system; // declared first: destroyed last + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down + ScopedDataDir data_dir_guard("plugin-config-py-isolation"); host_config().load(); @@ -188,6 +223,11 @@ TEST_CASE("Saving one capability's config does not touch another's", "[PluginCon TEST_CASE("Config API refuses a capability the host never materialized", "[PluginConfig][Python]") { + ScopedPluginManager plugin_system; // declared first: destroyed last + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down + ScopedDataDir data_dir_guard("plugin-config-py-unowned"); host_config().load(); @@ -202,6 +242,11 @@ TEST_CASE("Config API refuses a capability the host never materialized", "[Plugi TEST_CASE("Custom config UI hooks dispatch to the Python override", "[PluginConfig][Python]") { + ScopedPluginManager plugin_system; // declared first: destroyed last + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down + py::object cap = make_capability("CustomUiCap", " def get_name(self): return 'cap_a'\n" " def has_config_ui(self): return True\n" @@ -216,6 +261,11 @@ TEST_CASE("Custom config UI hooks dispatch to the Python override", "[PluginConf TEST_CASE("A capability that omits the config UI hooks gets the default editor", "[PluginConfig][Python]") { + ScopedPluginManager plugin_system; // declared first: destroyed last + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down + ScopedDataDir data_dir_guard("plugin-config-py-bare"); host_config().load(); @@ -234,6 +284,11 @@ TEST_CASE("A capability that omits the config UI hooks gets the default editor", TEST_CASE("get_default_config supplies the value Restore defaults writes back", "[PluginConfig][Python]") { + ScopedPluginManager plugin_system; // declared first: destroyed last + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down + SECTION("not overridden -> an empty config") { // Already "restore defaults" for a capability that keeps its stored config sparse and applies @@ -291,6 +346,11 @@ TEST_CASE("get_default_config supplies the value Restore defaults writes back", TEST_CASE("Restoring defaults overwrites only the target capability", "[PluginConfig][Python]") { + ScopedPluginManager plugin_system; // declared first: destroyed last + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down + ScopedDataDir data_dir_guard("plugin-config-py-restore"); host_config().load(); @@ -313,6 +373,11 @@ TEST_CASE("Restoring defaults overwrites only the target capability", "[PluginCo TEST_CASE("A raising get_default_config leaves the stored config untouched", "[PluginConfig][Python]") { + ScopedPluginManager plugin_system; // declared first: destroyed last + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down + ScopedDataDir data_dir_guard("plugin-config-py-restore-raise"); host_config().load(); @@ -333,6 +398,11 @@ TEST_CASE("A raising get_default_config leaves the stored config untouched", "[P TEST_CASE("A raising config UI hook surfaces as an exception the host can catch", "[PluginConfig][Python]") { + ScopedPluginManager plugin_system; // declared first: destroyed last + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down + py::object cap = make_capability("RaisingCap", " def get_name(self): return 'cap_a'\n" " def has_config_ui(self): return True\n" @@ -351,6 +421,11 @@ TEST_CASE("A raising config UI hook surfaces as an exception the host can catch" TEST_CASE("A config UI hook returning the wrong type does not crash the host", "[PluginConfig][Python]") { + ScopedPluginManager plugin_system; // declared first: destroyed last + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down + // has_config_ui() is plugin-authored, so it can return anything; the host must survive the call. py::object cap = make_capability("BadTypeCap", " def get_name(self): return 'cap_a'\n" diff --git a/tests/slic3rutils/test_plugin_cloud_metadata.cpp b/tests/slic3rutils/test_plugin_cloud_metadata.cpp index af503afa69..6f438541ff 100644 --- a/tests/slic3rutils/test_plugin_cloud_metadata.cpp +++ b/tests/slic3rutils/test_plugin_cloud_metadata.cpp @@ -22,13 +22,16 @@ using json = nlohmann::json; namespace { -// Shut both singletons down while boost::log is still alive; left to their static -// destructors, shutdown()'s logging runs after boost::log tears down its thread-local -// storage and crashes the process on exit (same reason ScopedPluginManager exists in -// test_plugin_lifecycle.cpp). No initialize() needed: discovery and the cloud-metadata -// merge never touch Python, but manager shutdown instantiates the interpreter singleton. +// Brings the plugin manager up and shuts both singletons down while boost::log is still alive; +// left to their static destructors, shutdown()'s logging runs after boost::log tears down its +// thread-local storage and crashes the process on exit (same reason ScopedPluginManager exists in +// test_plugin_lifecycle.cpp). initialize() IS needed here: the plugin under test is a +// slicing-pipeline script, so discover_plugins() brings up the Python interpreter to parse it, +// same as any other plugin. struct ScopedManagerShutdown { + bool initialized = PluginManager::instance().initialize(); + ~ScopedManagerShutdown() { PluginManager::instance().shutdown(); @@ -63,7 +66,9 @@ print('ok') TEST_CASE("cloud metadata refresh preserves a plugin's stored config", "[PluginCloudMetadata]") { ScopedManagerShutdown manager_shutdown_guard; // declared first: destroyed last - ScopedDataDir data_dir_guard("cloud-meta-config"); + if (!manager_shutdown_guard.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + ScopedDataDir data_dir_guard("cloud-meta-config"); // A locally-installed cloud plugin: package .py plus an install-state sidecar carrying the // cloud identity. Discovery derives plugin_key from the cloud UUID.