From 62afea225af3f78ae1618212ccfe818e7d63e716 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Wed, 15 Jul 2026 20:12:51 +0800 Subject: [PATCH] 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 --- src/slic3r/CMakeLists.txt | 2 - src/slic3r/GUI/GUI_App.cpp | 49 +- src/slic3r/GUI/OptionsGroup.cpp | 17 +- src/slic3r/GUI/PluginPickerDialog.hpp | 2 - src/slic3r/GUI/PluginsDialog.cpp | 340 ++- src/slic3r/GUI/PluginsDialog.hpp | 45 +- src/slic3r/GUI/PostProcessor.cpp | 2 +- src/slic3r/Utils/NetworkAgentFactory.cpp | 51 +- src/slic3r/plugin/PluginAuditManager.cpp | 11 +- src/slic3r/plugin/PluginCatalog.cpp | 714 ------ src/slic3r/plugin/PluginCatalog.hpp | 76 - src/slic3r/plugin/PluginDescriptor.hpp | 30 +- src/slic3r/plugin/PluginFsUtils.cpp | 147 ++ src/slic3r/plugin/PluginFsUtils.hpp | 16 + src/slic3r/plugin/PluginHooks.cpp | 18 +- src/slic3r/plugin/PluginLoader.cpp | 1460 +++--------- src/slic3r/plugin/PluginLoader.hpp | 226 +- src/slic3r/plugin/PluginManager.cpp | 2119 +++++++++++++---- src/slic3r/plugin/PluginManager.hpp | 286 ++- src/slic3r/plugin/PluginResolver.cpp | 57 +- src/slic3r/plugin/PluginResolver.hpp | 2 - src/slic3r/plugin/PyPluginTrampoline.hpp | 71 +- src/slic3r/plugin/PythonFileUtils.cpp | 46 +- src/slic3r/plugin/PythonInterpreter.cpp | 679 +++--- src/slic3r/plugin/PythonInterpreter.hpp | 120 +- src/slic3r/plugin/PythonPluginBridge.cpp | 47 +- src/slic3r/plugin/PythonPluginInterface.hpp | 66 +- src/slic3r/plugin/host/PluginHostUi.cpp | 16 +- src/slic3r/plugin/host/PluginHostUi.hpp | 11 +- ...PrinterAgentPluginCapabilityTrampoline.hpp | 6 +- .../SlicingPipelinePluginCapability.hpp | 2 + tests/slic3rutils/CMakeLists.txt | 2 +- .../test_plugin_capability_identifier.cpp | 25 - tests/slic3rutils/test_plugin_install.cpp | 14 +- tests/slic3rutils/test_plugin_lifecycle.cpp | 760 ++++++ 35 files changed, 4162 insertions(+), 3373 deletions(-) delete mode 100644 src/slic3r/plugin/PluginCatalog.cpp delete mode 100644 src/slic3r/plugin/PluginCatalog.hpp delete mode 100644 tests/slic3rutils/test_plugin_capability_identifier.cpp create mode 100644 tests/slic3rutils/test_plugin_lifecycle.cpp diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 637554f859..d0c2360eee 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -617,8 +617,6 @@ set(SLIC3R_GUI_SOURCES plugin/CloudPluginService.hpp plugin/PluginFsUtils.cpp plugin/PluginFsUtils.hpp - plugin/PluginCatalog.cpp - plugin/PluginCatalog.hpp plugin/PluginLoader.cpp plugin/PluginLoader.hpp plugin/PluginDescriptor.hpp diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 06da4d4b8b..bb9e9010af 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -1121,7 +1121,7 @@ void GUI_App::shutdown() if (m_is_recreating_gui) return; stop_http_server(); set_closing(true); - Slic3r::PluginManager::instance().get_loader().set_shutting_down(); + Slic3r::PluginManager::instance().set_shutting_down(); if (m_agent) m_agent->set_printer_agent(nullptr); @@ -2723,12 +2723,13 @@ void GUI_App::init_plugin_gui_wiring() }); }; - plugin_mgr.get_loader().subscribe_on_load_callback([refresh_plugins_dialog](const std::string&) { refresh_plugins_dialog(); }); - plugin_mgr.get_loader().subscribe_on_unload_callback([refresh_plugins_dialog](const std::string&) { refresh_plugins_dialog(); }); - plugin_mgr.get_loader().subscribe_on_load_callback(NetworkAgentFactory::register_python_plugin); - plugin_mgr.get_loader().subscribe_on_unload_callback(NetworkAgentFactory::deregister_python_plugin); - plugin_mgr.get_loader().subscribe_on_capability_load_callback( - [refresh_plugins_dialog](const PluginCapabilityIdentifier& capability) { + plugin_mgr.subscribe_on_unload_callback(PluginHostUi::close_windows_for_plugin); + plugin_mgr.subscribe_on_load_callback([refresh_plugins_dialog](const std::string&) { refresh_plugins_dialog(); }); + plugin_mgr.subscribe_on_unload_callback([refresh_plugins_dialog](const std::string&) { refresh_plugins_dialog(); }); + plugin_mgr.subscribe_on_load_callback(NetworkAgentFactory::register_python_plugin); + plugin_mgr.subscribe_on_unload_callback(NetworkAgentFactory::deregister_python_plugin); + plugin_mgr.subscribe_on_capability_load_callback( + [refresh_plugins_dialog](const PluginCapabilityId& capability) { if (capability.type == PluginCapabilityType::PrinterConnection) NetworkAgentFactory::register_python_printer_agent(capability.plugin_key, capability.name); refresh_plugins_dialog(); @@ -2740,8 +2741,8 @@ void GUI_App::init_plugin_gui_wiring() plater->revalidate_current_plate_if_plugins_missing(); }); }); - plugin_mgr.get_loader().subscribe_on_capability_unload_callback( - [refresh_plugins_dialog](const PluginCapabilityIdentifier& capability) { + plugin_mgr.subscribe_on_capability_unload_callback( + [refresh_plugins_dialog](const PluginCapabilityId& capability) { if (capability.type == PluginCapabilityType::PrinterConnection) NetworkAgentFactory::deregister_python_printer_agent(capability.plugin_key, capability.name); refresh_plugins_dialog(); @@ -3162,17 +3163,16 @@ bool GUI_App::on_init_inner() // plugins are discovered even before the network agent is ready. const std::string preset_folder = app_config->get("preset_folder"); if (!preset_folder.empty()) { - plugin_mgr.get_catalog().set_cloud_plugin_dir(preset_folder); - plugin_mgr.get_loader().set_cloud_user_id(preset_folder); + plugin_mgr.set_cloud_user(preset_folder); } plugin_mgr.discover_plugins(false, true); init_plugin_gui_wiring(); - for (const std::string& plugin_key : plugin_mgr.get_catalog().get_enabled_plugin_keys()) { - if (!plugin_mgr.get_loader().is_plugin_loaded(plugin_key)) { - plugin_mgr.get_loader().load_plugin(plugin_mgr.get_catalog(), plugin_key, false); + for (const std::string& plugin_key : plugin_mgr.get_enabled_plugin_keys()) { + if (!plugin_mgr.is_plugin_loaded(plugin_key)) { + plugin_mgr.load_plugin(plugin_key, false); BOOST_LOG_TRIVIAL(info) << "Auto-loading plugin on startup: " << plugin_key; } } @@ -3182,8 +3182,7 @@ bool GUI_App::on_init_inner() if (m_agent && m_agent->is_user_login()) { enable_user_preset_folder(true); - plugin_mgr.get_catalog().set_cloud_plugin_dir(m_agent->get_user_id()); - plugin_mgr.get_loader().set_cloud_user_id(m_agent->get_user_id()); + plugin_mgr.set_cloud_user(m_agent->get_user_id()); // If there is a user logged in we do an immediate sync. std::vector not_found, unauthorized; plugin_mgr.fetch_plugins_from_cloud(¬_found, &unauthorized); @@ -3203,8 +3202,7 @@ bool GUI_App::on_init_inner() } } else { enable_user_preset_folder(false); - plugin_mgr.get_catalog().set_cloud_plugin_dir(""); - plugin_mgr.get_loader().set_cloud_user_id(""); + plugin_mgr.set_cloud_user(""); } // BBS if load user preset failed @@ -4776,10 +4774,9 @@ void GUI_App::request_user_logout(const std::string& provider/* = ORCA_CLOUD_PRO remove_user_presets(); enable_user_preset_folder(false); - Slic3r::PluginManager::instance().get_loader().unload_cloud_plugins(); - Slic3r::PluginManager::instance().get_catalog().clear_cloud_plugin_catalog(); - Slic3r::PluginManager::instance().get_catalog().set_cloud_plugin_dir(""); - Slic3r::PluginManager::instance().get_loader().set_cloud_user_id(""); + Slic3r::PluginManager::instance().unload_cloud_plugins(); + Slic3r::PluginManager::instance().clear_cloud_plugin_catalog(); + Slic3r::PluginManager::instance().set_cloud_user(""); preset_bundle->load_user_presets(DEFAULT_USER_FOLDER_NAME, ForwardCompatibilitySubstitutionRule::Enable); mainframe->update_side_preset_ui(); @@ -5325,14 +5322,12 @@ void GUI_App::enable_user_preset_folder(bool enable) std::string user_id = m_agent->get_user_id(); app_config->set("preset_folder", user_id); GUI::wxGetApp().preset_bundle->update_user_presets_directory(user_id); - PluginManager::instance().get_catalog().set_cloud_plugin_dir(user_id); - PluginManager::instance().get_loader().set_cloud_user_id(user_id); + PluginManager::instance().set_cloud_user(user_id); } else { BOOST_LOG_TRIVIAL(info) << "preset_folder: set to empty"; app_config->set("preset_folder", ""); GUI::wxGetApp().preset_bundle->update_user_presets_directory(DEFAULT_USER_FOLDER_NAME); - PluginManager::instance().get_catalog().set_cloud_plugin_dir(""); - PluginManager::instance().get_loader().set_cloud_user_id(""); + PluginManager::instance().set_cloud_user(""); } } @@ -8230,7 +8225,7 @@ void GUI_App::open_plugins_dialog(size_t open_on_tab, const std::string& highlig } try { - m_plugins_dlg = new PluginsDialog(mainframe, open_on_tab, highlight_option); + m_plugins_dlg = new PluginsDialog(mainframe, wxID_ANY, _L("Plugins")); m_plugins_dlg->set_open_terminal_dlg_fn(); m_plugins_dlg->Bind(wxEVT_DESTROY, [this](wxWindowDestroyEvent& event) { if (event.GetEventObject() == m_plugins_dlg) diff --git a/src/slic3r/GUI/OptionsGroup.cpp b/src/slic3r/GUI/OptionsGroup.cpp index a570a521e8..a0b744488c 100644 --- a/src/slic3r/GUI/OptionsGroup.cpp +++ b/src/slic3r/GUI/OptionsGroup.cpp @@ -683,8 +683,13 @@ std::string OptionsGroup::pick_plugin(const ConfigOptionDef& opt) return {}; } - auto caps = manager.get_loader().get_plugin_capabilities_by_type(plugin_type); - caps.erase(std::remove_if(caps.begin(), caps.end(), [](const auto& cap) { return !cap || !cap->enabled; }), caps.end()); + struct CapabilityRef { std::string plugin_key; std::string name; }; + std::vector caps; + for (const auto& capability : manager.get_plugin_capabilities(/*plugin_key=*/"", plugin_type, /*only_enabled=*/true)) + caps.push_back({capability->audit_plugin_key(), capability->name()}); + std::sort(caps.begin(), caps.end(), [](const CapabilityRef& lhs, const CapabilityRef& rhs) { + return lhs.name == rhs.name ? lhs.plugin_key < rhs.plugin_key : lhs.name < rhs.name; + }); if (caps.empty()) { wxMessageBox(_L("No plugins capabilities available for this type.\nEnable or install some to use."), _L("Plugin Selection"), wxOK | wxICON_INFORMATION, m_parent); @@ -695,10 +700,10 @@ std::string OptionsGroup::pick_plugin(const ConfigOptionDef& opt) entries.reserve(caps.size()); for (const auto& cap : caps) { Slic3r::PluginDescriptor descriptor; - wxString package_name = manager.get_catalog().try_get_plugin_descriptor(cap->plugin_key, descriptor) - ? from_u8(descriptor.name) : from_u8(cap->plugin_key); + wxString package_name = manager.try_get_plugin_descriptor(cap.plugin_key, descriptor) + ? from_u8(descriptor.name) : from_u8(cap.plugin_key); entries.push_back({ - cap->plugin_key, cap->name, from_u8(cap->name) + from_u8(" \xE2\x80\x94 ") + package_name, package_name + cap.plugin_key, cap.name, from_u8(cap.name) + from_u8(" \xE2\x80\x94 ") + package_name, package_name }); } @@ -713,7 +718,7 @@ std::string OptionsGroup::pick_plugin(const ConfigOptionDef& opt) // name is stored in the option; the full "name;uuid;capability" reference is derived lazily // when the preset is serialized (see ConfigBase::save_plugin_collection). Slic3r::PluginDescriptor descriptor; - if (!manager.get_catalog().try_get_plugin_descriptor(selection.plugin_key, descriptor) || descriptor.name.empty()) + if (!manager.try_get_plugin_descriptor(selection.plugin_key, descriptor) || descriptor.name.empty()) return {}; BOOST_LOG_TRIVIAL(info) << "Picked plugin capability: " << selection.name; diff --git a/src/slic3r/GUI/PluginPickerDialog.hpp b/src/slic3r/GUI/PluginPickerDialog.hpp index cbc46d1db3..0d676eb04d 100644 --- a/src/slic3r/GUI/PluginPickerDialog.hpp +++ b/src/slic3r/GUI/PluginPickerDialog.hpp @@ -40,8 +40,6 @@ public: // Returns the {plugin_key, name} of the selected capability (capability path). CapabilityEntry selected_capability() const; - void update_plugin_list(const std::vector& updated_plugins) { m_plugins = updated_plugins; } - private: void build_ui(const wxString& plugin_type_label); void build_capability_ui(const wxString& plugin_type_label); diff --git a/src/slic3r/GUI/PluginsDialog.cpp b/src/slic3r/GUI/PluginsDialog.cpp index a2006de579..b0af572f20 100644 --- a/src/slic3r/GUI/PluginsDialog.cpp +++ b/src/slic3r/GUI/PluginsDialog.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include @@ -123,19 +124,19 @@ struct PluginDialogItem constexpr bool kFetchCloudCatalog = true; constexpr bool kUseCurrentCloudCatalog = false; +// The type of a package's first loaded capability. A package that is not loaded has no type. +PluginCapabilityType primary_capability_type_of(PluginManager& manager, const std::string& plugin_key) +{ + const auto capabilities = manager.get_plugin_capabilities(plugin_key, PluginCapabilityType::Unknown, /*only_enabled=*/false); + return capabilities.empty() ? PluginCapabilityType::Unknown : capabilities.front()->type(); +} + std::vector current_cloud_catalog_snapshot() { - const auto& catalog = PluginManager::instance().get_catalog(); - std::vector cloud_entries; - auto append_cloud_entries = [&cloud_entries](const std::vector& entries) { - for (const PluginDescriptor& entry : entries) - if (entry.is_cloud_plugin()) - cloud_entries.push_back(entry); - }; - - append_cloud_entries(catalog.get_all_plugin_descriptors()); - append_cloud_entries(catalog.get_invalid_plugins()); + for (const PluginDescriptor& entry : PluginManager::instance().get_plugin_descriptors(/*include_invalid=*/true)) + if (entry.is_cloud_plugin()) + cloud_entries.push_back(entry); return cloud_entries; } @@ -165,26 +166,30 @@ void refresh_plugin_catalog_blocking(bool fetch_cloud) manager.rescan_plugins(); if (!fetch_cloud) { - manager.get_catalog().update_cloud_catalog(current_cloud_catalog); + manager.update_cloud_catalog(current_cloud_catalog); return; } manager.fetch_plugins_from_cloud(¬_found, &unauthorized); - auto plater = wxGetApp().plater(); + wxGetApp().CallAfter([not_found = std::move(not_found), unauthorized = std::move(unauthorized)]() { + if (wxGetApp().is_closing()) + return; + Plater* plater = wxGetApp().plater(); + if (plater == nullptr) + return; - if (plater) { - for (const auto& uuid : not_found) { - plater->get_notification_manager()->push_notification(NotificationType::CustomNotification, - NotificationManager::NotificationLevel::RegularNotificationLevel, - format(_L("Plugin %s is no longer available."), uuid)); - } - for (const auto& uuid : unauthorized) { - plater->get_notification_manager()->push_notification(NotificationType::CustomNotification, - NotificationManager::NotificationLevel::RegularNotificationLevel, - format(_L("Plugin %s access is unauthorized."), uuid)); - } - } + for (const auto& uuid : not_found) + plater->get_notification_manager()->push_notification( + NotificationType::CustomNotification, + NotificationManager::NotificationLevel::RegularNotificationLevel, + format(_L("Plugin %s is no longer available."), uuid)); + for (const auto& uuid : unauthorized) + plater->get_notification_manager()->push_notification( + NotificationType::CustomNotification, + NotificationManager::NotificationLevel::RegularNotificationLevel, + format(_L("Plugin %s access is unauthorized."), uuid)); + }); } std::string to_string(PluginUpdateStatus status); @@ -305,7 +310,7 @@ PluginAvailableActions evaluate_action_policy(const PluginDialogItem& item) PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor) { PluginDialogItem item; - const auto& loader = PluginManager::instance().get_loader(); + PluginManager& manager = PluginManager::instance(); item.plugin_key = descriptor.plugin_key; item.display_name = descriptor.name; @@ -323,18 +328,25 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor) // why: sort by the same version the row displays (GetDisplayVersion in index.js) - installed when // installed, otherwise latest - so the Version sort matches what the user sees. item.sort_version = item.installed_version.empty() ? item.latest_version : item.installed_version; - item.type_label = descriptor.type_label(); - item.type_key = plugin_capability_type_to_string(descriptor.primary_capability_type()); + // A package that is not loaded has no capabilities, so its row contributes none and does not + // expand. + const std::vector> capabilities = + manager.get_plugin_capabilities(descriptor.plugin_key, PluginCapabilityType::Unknown, /*only_enabled=*/false); + + const PluginCapabilityType primary_type = capabilities.empty() ? PluginCapabilityType::Unknown : + capabilities.front()->type(); + item.type_label = plugin_capability_type_to_string(primary_type); + item.type_key = plugin_capability_type_to_string(primary_type); // "types" is the display-only compatibility list. Cloud plugins show the raw labels the // service returned (which may not map to real capability types); local plugins derive them - // from the capabilities actually discovered/loaded. + // from the capabilities actually loaded. if (descriptor.is_cloud_plugin() && !descriptor.display_types.empty()) { for (const std::string& label : descriptor.display_types) if (std::find(item.type_labels.begin(), item.type_labels.end(), label) == item.type_labels.end()) item.type_labels.push_back(label); } else { - for (PluginCapabilityType type : descriptor.capability_types) { - const std::string label = plugin_capability_type_display_name(type); + for (const auto& capability : capabilities) { + const std::string label = plugin_capability_type_display_name(capability->type()); if (std::find(item.type_labels.begin(), item.type_labels.end(), label) == item.type_labels.end()) item.type_labels.push_back(label); } @@ -348,30 +360,14 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor) item.is_cloud_plugin = descriptor.is_cloud_plugin(); item.has_local_package = descriptor.has_local_package(); item.unauthorized = descriptor.is_unauthorized(); - item.has_script_capability = descriptor.has_capability_type(Slic3r::PluginCapabilityType::Script); - item.is_loaded = loader.is_plugin_loaded(descriptor.plugin_key); - item.loading = loader.is_plugin_load_in_progress(descriptor.plugin_key); - if (item.is_loaded) { - for (const auto& cap : loader.get_loaded_plugin_capabilities(descriptor.plugin_key)) { - if (cap) { - item.capabilities.push_back({cap->name, plugin_capability_type_display_name(cap->type), - plugin_capability_type_to_string(cap->type), cap->enabled, true, false}); - if (cap->type == PluginCapabilityType::Script) - item.has_script_capability = true; - } - } - } - if (item.capabilities.empty()) { - for (PluginCapabilityType type : descriptor.capability_types) { - const std::string type_key = plugin_capability_type_to_string(type); - const auto existing = std::find_if(item.capabilities.begin(), item.capabilities.end(), - [&type_key](const PluginCapabilityView& capability) { - return capability.type_key == type_key; - }); - if (existing != item.capabilities.end()) - continue; - item.capabilities.push_back({std::string{}, plugin_capability_type_display_name(type), type_key, false, false, false}); - } + item.is_loaded = manager.is_plugin_loaded(descriptor.plugin_key); + item.loading = manager.is_plugin_load_in_progress(descriptor.plugin_key); + item.has_script_capability = false; + for (const auto& capability : capabilities) { + item.capabilities.push_back({capability->name(), plugin_capability_type_display_name(capability->type()), + plugin_capability_type_to_string(capability->type()), capability->is_enabled(), true, false}); + if (capability->type() == PluginCapabilityType::Script) + item.has_script_capability = true; } item.sharing_token = descriptor.sharing_token; item.thumbnail_url = descriptor.thumbnail_url; @@ -447,7 +443,7 @@ PluginsDialog::PluginsDialog(wxWindow* parent, wxWindowID id, const wxString&, c : WebViewHostDialog(parent, id, _L("Plugins"), pos, size, style) { create_webview("web/dialog/PluginsDialog/index.html", _L("Plugins"), wxSize(900, 820), wxSize(760, 715)); } -PluginsDialog::~PluginsDialog() = default; +PluginsDialog::~PluginsDialog() { m_alive->store(false, std::memory_order_release); } void PluginsDialog::set_open_terminal_dlg_fn() { @@ -467,8 +463,8 @@ void PluginsDialog::resolve_pending_activation() if (m_activating_plugin_key.empty()) return; - PluginLoader& loader = PluginManager::instance().get_loader(); - if (loader.is_plugin_load_in_progress(m_activating_plugin_key)) + PluginManager& manager = PluginManager::instance(); + if (manager.is_plugin_load_in_progress(m_activating_plugin_key)) return; // Still loading: keep the "Activating..." message until the load resolves. const std::string plugin_key = m_activating_plugin_key; @@ -478,7 +474,7 @@ void PluginsDialog::resolve_pending_activation() PluginDescriptor descriptor; if (get_descriptor(plugin_key, descriptor) && descriptor.has_error()) show_status(wxString::Format(_L("Failed to activate \"%s\"."), plugin_display_name(plugin_key)), "error"); - else if (loader.is_plugin_loaded(plugin_key)) + else if (manager.is_plugin_loaded(plugin_key)) show_status(wxString::Format(_L("Activated \"%s\"."), plugin_display_name(plugin_key)), "success"); // Otherwise it ended up inactive (toggled off or cancelled mid-load): stay silent. } @@ -543,18 +539,13 @@ nlohmann::json PluginsDialog::build_plugins_payload() const response["sort_order"] = to_string(m_plugin_sort_order); response["data"] = nlohmann::json::array(); - const auto& catalog = PluginManager::instance().get_catalog(); - const auto valid = catalog.get_all_plugin_descriptors(); - const auto invalid = catalog.get_invalid_plugins(); - BOOST_LOG_TRIVIAL(info) << "Prepared " << valid.size() + invalid.size() << " plugin rows for Plugins dialog"; + const auto rows = PluginManager::instance().get_plugin_descriptors(/*include_invalid=*/true); + BOOST_LOG_TRIVIAL(info) << "Prepared " << rows.size() << " plugin rows for Plugins dialog"; std::vector items; - items.reserve(valid.size() + invalid.size()); + items.reserve(rows.size()); - for (const PluginDescriptor& row : valid) - items.push_back(build_plugin_dialog_item(row)); - - for (const PluginDescriptor& row : invalid) + for (const PluginDescriptor& row : rows) items.push_back(build_plugin_dialog_item(row)); // In-place sort @@ -568,19 +559,19 @@ nlohmann::json PluginsDialog::build_plugins_payload() const bool PluginsDialog::get_descriptor(const std::string& plugin_key, PluginDescriptor& descriptor) const { - const auto& catalog = PluginManager::instance().get_catalog(); - if (const PluginDescriptor* valid_descriptor = catalog.find_valid_plugin_descriptor(plugin_key)) { - descriptor = *valid_descriptor; + PluginManager& manager = PluginManager::instance(); + if (manager.try_get_valid_plugin_descriptor(plugin_key, descriptor)) return true; - } - return catalog.try_get_invalid_plugin_descriptor(plugin_key, descriptor); + return manager.try_get_plugin_descriptor(plugin_key, descriptor) && descriptor.is_invalid_package(); } -std::shared_ptr PluginsDialog::get_capability(const std::string& plugin_key, - PluginCapabilityType type, - const std::string& capability_name) const +std::shared_ptr PluginsDialog::get_capability(const std::string& plugin_key, + PluginCapabilityType type, + const std::string& capability_name) const { - return PluginManager::instance().get_loader().get_plugin_capability_by_name(plugin_key, type, capability_name); + // only_enabled=false: this is an existence check used to gate both enabling and disabling a + // capability from the dialog, so a currently-disabled capability must still resolve. + return PluginManager::instance().get_plugin_capability(plugin_key, capability_name, type, /*only_enabled=*/false); } void PluginsDialog::refresh_plugin_catalog_async(const wxString& title, const wxString& message, bool fetch_cloud) @@ -616,7 +607,7 @@ void PluginsDialog::toggle_plugin(const std::string& plugin_key, bool enabled) PluginManager& manager = PluginManager::instance(); if (!enabled) { - if (!manager.get_loader().unload_plugin(plugin_key)) { + if (!manager.unload_plugin(plugin_key)) { BOOST_LOG_TRIVIAL(error) << "Failed to unload plugin from Plugins dialog: " << plugin_key; show_status(_L("Failed to unload plugin."), "warn"); send_plugins(); @@ -632,9 +623,6 @@ void PluginsDialog::toggle_plugin(const std::string& plugin_key, bool enabled) return; } - // Check for capabilities that are currently in use - auto loaded_capabilities = manager.get_loader().get_loaded_plugin_capabilities(plugin_key); - if (!available_actions.can_toggle) { if (dialog_item.unauthorized && available_actions.toggle_installs_cloud_plugin == false && row_data.has_local_package() == false) { const std::string install_error = "Unauthorized cloud plugins cannot be installed."; @@ -666,25 +654,25 @@ void PluginsDialog::toggle_plugin(const std::string& plugin_key, bool enabled) } } - if (manager.get_loader().is_plugin_loaded(plugin_key)) { + if (manager.is_plugin_loaded(plugin_key)) { send_plugins(); return; } - const PluginDescriptor* descriptor_ptr = manager.get_catalog().find_valid_plugin_descriptor(plugin_key); - if (!descriptor_ptr) { + PluginDescriptor valid_descriptor; + if (!manager.try_get_valid_plugin_descriptor(plugin_key, valid_descriptor)) { show_status(_L("Plugin manifest was not found."), "warn"); send_plugins(); return; } - if (manager.get_loader().is_plugin_load_in_progress(plugin_key)) { + if (manager.is_plugin_load_in_progress(plugin_key)) { send_plugins(); return; } BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Starting plugin load from web dialog: " << plugin_key; - manager.get_loader().load_plugin(manager.get_catalog(), plugin_key); + manager.load_plugin(plugin_key); send_plugins(); // Loading is asynchronous: show a pending message now; resolve_pending_activation() turns it into // Activated/Failed once the loader's completion callback runs update_plugin_dialog_ui(). @@ -713,14 +701,14 @@ void PluginsDialog::toggle_plugin_capability(const std::string& plugin_key, Plug return; } - PluginLoader& loader = PluginManager::instance().get_loader(); + PluginManager& manager = PluginManager::instance(); if (enabled) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Enabling plugin capability: " << plugin_key << " | " << capability_name; - loader.enable_capability(plugin_key, capability_name, type); + manager.set_capability_enabled(plugin_key, capability_name, true); } else { // check if the capability is currently in use here. BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Disabling plugin capability: " << plugin_key << " | " << capability_name; - loader.disable_capability(plugin_key, capability_name, type); + manager.set_capability_enabled(plugin_key, capability_name, false); } send_plugins(); @@ -795,8 +783,7 @@ bool PluginsDialog::install_plugin_package(const std::string& package_path) }; try { - if (!PluginManager::instance().get_loader().inspect_local_plugin_package(package_file, plugin_descriptor, existing_installation, - error)) + if (!PluginManager::instance().inspect_local_plugin_package(package_file, plugin_descriptor, existing_installation, error)) return report_inspection_failure(); } catch (const std::exception& ex) { error = ex.what(); @@ -898,46 +885,18 @@ wxString PluginsDialog::plugin_display_name(const std::string& plugin_key) const void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std::string& capability_name) { - if (plugin_key.empty() || capability_name.empty()) { - BOOST_LOG_TRIVIAL(warning) << "Ignoring run_script_plugin with an empty plugin key or capability name"; - send_plugins(); - return; - } - + PluginManager& manager = PluginManager::instance(); - auto cap = manager.get_loader().get_plugin_capability_by_name( - plugin_key, Slic3r::PluginCapabilityType::Script, capability_name); - if (!cap) { - BOOST_LOG_TRIVIAL(warning) << "Ignoring stale run request for missing script capability. plugin_key=" << plugin_key - << " capability_name=" << capability_name; - send_plugins(); - return; - } - if (!cap->enabled) { - BOOST_LOG_TRIVIAL(warning) << "Ignoring stale run request for disabled script capability. plugin_key=" << plugin_key - << " capability_name=" << capability_name; - send_plugins(); - return; - } - // A plugin's modal orca.host.ui dialog or the result message box pumps a nested event - // loop; the WebView could re-dispatch this command mid-run. Refuse the overlapping run. - if (m_script_running) { - BOOST_LOG_TRIVIAL(info) << "Ignoring run_script_plugin; a plugin is already running. plugin_key=" << plugin_key; - return; - } - m_script_running = true; - ScopeGuard running_guard([this]() { m_script_running = false; }); - - BOOST_LOG_TRIVIAL(info) << "Run script plugin requested from Plugins dialog. plugin_key=" << plugin_key - << " capability_name=" << capability_name; + std::string error; + ExecutionResult result; auto complete_with_error = [this, &manager, &plugin_key](const std::string& plugin_error, const wxString& status_message) { const std::string normalized_error = plugin_error.empty() ? "Script plugin failed." : plugin_error; - if (!manager.get_catalog().set_plugin_error(plugin_key, normalized_error)) + if (!manager.set_plugin_error(plugin_key, normalized_error)) BOOST_LOG_TRIVIAL(warning) << "Failed to record plugin error. plugin_key=" << plugin_key; - if (!manager.get_loader().unload_plugin(plugin_key)) + if (!manager.unload_plugin(plugin_key)) BOOST_LOG_TRIVIAL(error) << "Failed to unload plugin after script error. plugin_key=" << plugin_key; send_plugins(); @@ -948,74 +907,12 @@ void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std:: show_status(message, "error"); }; - PluginDescriptor descriptor; - if (!get_descriptor(plugin_key, descriptor)) { - BOOST_LOG_TRIVIAL(error) << "Cannot run script plugin because manifest was not found. plugin_key=" << plugin_key; - complete_with_error("Plugin manifest was not found.", _L("Plugin manifest was not found.")); - return; - } - - if (descriptor.has_error()) { - complete_with_error(descriptor.normalized_error(), wxString()); - return; - } - - // Should not reach here, handle for extra safety - if (!descriptor.is_metadata_valid()) { - std::string plugin_type_str = plugin_capability_type_to_string(descriptor.primary_capability_type()); - std::string metadata_valid = descriptor.is_metadata_valid() ? "true" : "false"; - const std::string plugin_error = "Cannot run plugin because its metadata is invalid:\n\tplugin type: " + plugin_type_str + - "\n\tmetadata_valid: " + metadata_valid; - BOOST_LOG_TRIVIAL(error) << "Cannot run plugin because its metadata is invalid. plugin_key=" << plugin_key - << " is_metadata_valid=" << descriptor.is_metadata_valid() - << " type=" << plugin_capability_type_to_string(descriptor.primary_capability_type()); - complete_with_error(plugin_error, _L("Only plugins with valid metadata can be run from this dialog.")); - return; - } - - // Should not reach here as non-loaded plugins have disabled run buttons, handle for extra safety - if (!manager.get_loader().is_plugin_loaded(plugin_key)) { - BOOST_LOG_TRIVIAL(warning) << "Cannot run script plugin because it is not loaded. plugin_key=" << plugin_key; - complete_with_error("Load the script plugin before running it: Cannot run script plugin because it is not loaded.", - _L("Load the script plugin before running it.")); - return; - } - - auto plugin = std::dynamic_pointer_cast(cap->instance); - if (!plugin) { - BOOST_LOG_TRIVIAL(error) << "Loaded plugin does not implement ScriptPluginCapability. plugin_key=" << plugin_key; - complete_with_error("The selected plugin is not a runnable script plugin: Loaded plugin does not implement ScriptPluginCapability.", - _L("The selected plugin is not a runnable script plugin.")); - return; - } - - std::string error; - ExecutionResult result; - - // Script plugins run on the main/UI thread (not a worker). They hold live, non-owning - // ModelObject*/ModelVolume*/ModelInstance* aliases into host data and can mint ObjectIDs, - // which libslic3r requires on the main thread (ObjectID.hpp's non-atomic s_last_id). Running - // here makes those reads/instantiations legal and means nothing mutates the model underneath - // a run. The trade-off is that a slow execute() freezes the UI, so the contract is to keep - // execute() quick and offload heavy work to the plugin's own threading.Thread. orca.host.ui - // calls already no-op their main-thread marshaling here. { wxBusyCursor busy; - try { - PythonGILState gil; - result = plugin->execute(); - } catch (const std::exception& ex) { - error = ex.what(); - BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw exception. plugin_key=" << plugin_key << " error=" << error; - } catch (...) { - error = "Unknown error"; - BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw unknown exception. plugin_key=" << plugin_key; - } + result = manager.run_script_capability(plugin_key, capability_name, error); } if (!error.empty()) { - plugin.reset(); - cap.reset(); complete_with_error(error, wxString()); return; } @@ -1023,11 +920,8 @@ void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std:: BOOST_LOG_TRIVIAL(info) << "Script plugin execution completed. plugin_key=" << plugin_key << " status=" << static_cast(result.status) << " message=" << result.message << " data=" << result.data; - const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError; + const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError || !error.empty(); if (failed) { - plugin.reset(); - cap.reset(); - // complete_with_error normalizes an empty message to "Script plugin failed." and reports via the status bar. complete_with_error(result.message, wxString()); return; } @@ -1189,18 +1083,35 @@ void PluginsDialog::reinstall_local_plugin(const std::string& plugin_key) if (plugin_key.empty()) return; - PluginManager& manager = PluginManager::instance(); - const bool was_loaded = manager.get_loader().is_plugin_loaded(plugin_key); - if (!manager.get_loader().unload_plugin(plugin_key)) { - show_status(_L("Failed to unload plugin."), "warn"); - send_plugins(); - return; + const bool was_loaded = PluginManager::instance().is_plugin_loaded(plugin_key); + std::pair reload_result{false, ""}; + try { + reload_result = run_with_dialog_wait( + [plugin_key, was_loaded]() -> std::pair { + PluginManager& manager = PluginManager::instance(); + if (!manager.unload_plugin(plugin_key)) + return {false, "Failed to unload plugin."}; + + manager.load_plugin(plugin_key, false); + std::string error; + if (!manager.wait_for_plugin_load(plugin_key, std::chrono::minutes(5), error) || + !manager.is_plugin_loaded(plugin_key)) + return {false, error.empty() ? "Plugin failed to load." : error}; + + if (!was_loaded && !manager.unload_plugin(plugin_key)) + return {false, "Plugin reloaded, but failed to restore the inactive state."}; + + return {true, {}}; + }, + _L("Reloading plugin"), _L("Reloading plugin")); + } catch (const std::exception& ex) { + reload_result = {false, ex.what()}; + } catch (...) { + reload_result = {false, "Unknown plugin reload error."}; } - manager.get_loader().load_plugin(manager.get_catalog(), plugin_key, false); - - if (!was_loaded && !manager.get_loader().unload_plugin(plugin_key)) { - show_status(_L("Plugin reloaded, but failed to restore the inactive state."), "warn"); + if (!reload_result.first) { + show_status(reload_result.second.empty() ? _L("Failed to reload plugin.") : from_u8(reload_result.second), "warn"); send_plugins(); return; } @@ -1216,7 +1127,7 @@ void PluginsDialog::reinstall_cloud_plugin(const PluginDescriptor& plugin) return; PluginManager& manager = PluginManager::instance(); - const bool was_loaded = manager.get_loader().is_plugin_loaded(plugin_key); + const bool was_loaded = PluginManager::instance().is_plugin_loaded(plugin_key); std::string error; if (plugin.has_local_package()) { @@ -1226,7 +1137,7 @@ void PluginsDialog::reinstall_cloud_plugin(const PluginDescriptor& plugin) return; } - manager.get_catalog().update_cloud_catalog(std::vector{as_cloud_only_descriptor(plugin)}); + manager.update_cloud_catalog(std::vector{as_cloud_only_descriptor(plugin)}); } if (!install_cloud_plugin(plugin_key, plugin.version, from_u8(plugin.name))) { @@ -1235,7 +1146,30 @@ void PluginsDialog::reinstall_cloud_plugin(const PluginDescriptor& plugin) } if (was_loaded) { - manager.get_loader().load_plugin(manager.get_catalog(), plugin_key); + std::pair reload_result{false, ""}; + try { + reload_result = run_with_dialog_wait( + [plugin_key]() -> std::pair { + PluginManager& manager = PluginManager::instance(); + manager.load_plugin(plugin_key); + std::string error; + if (!manager.wait_for_plugin_load(plugin_key, std::chrono::minutes(5), error) || + !manager.is_plugin_loaded(plugin_key)) + return {false, error.empty() ? "Plugin failed to load." : error}; + return {true, {}}; + }, + _L("Reloading plugin"), _L("Reloading plugin")); + } catch (const std::exception& ex) { + reload_result = {false, ex.what()}; + } catch (...) { + reload_result = {false, "Unknown plugin reload error."}; + } + + if (!reload_result.first) { + show_status(reload_result.second.empty() ? _L("Failed to reload plugin.") : from_u8(reload_result.second), "warn"); + send_plugins(); + return; + } } send_plugins(); diff --git a/src/slic3r/GUI/PluginsDialog.hpp b/src/slic3r/GUI/PluginsDialog.hpp index 55885262a5..49049d6982 100644 --- a/src/slic3r/GUI/PluginsDialog.hpp +++ b/src/slic3r/GUI/PluginsDialog.hpp @@ -7,6 +7,8 @@ #include "PluginSort.hpp" #include "slic3r/plugin/PluginDescriptor.hpp" +#include +#include #include #include #include @@ -17,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -25,7 +28,7 @@ class wxTimer; namespace Slic3r { -struct LoadedPluginCapability; +class PluginCapabilityInterface; enum class PluginCapabilityType; namespace GUI { @@ -55,7 +58,7 @@ private: nlohmann::json build_plugins_payload() const; bool get_descriptor(const std::string& plugin_key, Slic3r::PluginDescriptor& descriptor) const; - std::shared_ptr get_capability(const std::string& plugin_key, PluginCapabilityType type, const std::string& capability_name) const; + std::shared_ptr get_capability(const std::string& plugin_key, PluginCapabilityType type, const std::string& capability_name) const; void refresh_plugin_catalog_async(const wxString& title, const wxString& message, bool fetch_cloud); void refresh_plugins(); @@ -91,34 +94,51 @@ private: const wxString& title, const wxString& message, int maximum = 100, - int style = wxPD_APP_MODAL | wxPD_AUTO_HIDE) + int style = wxPD_APP_MODAL | wxPD_AUTO_HIDE, + bool finish_after_dialog_destroyed = false) { + const auto alive = m_alive; wxProgressDialog* progress = new wxProgressDialog(title, message, maximum, this, style); wxTimer* timer = new wxTimer(); - timer->Bind(wxEVT_TIMER, [progress, message](wxTimerEvent&) { - if (progress) + timer->Bind(wxEVT_TIMER, [alive, progress, message](wxTimerEvent&) { + if (alive->load(std::memory_order_acquire) && progress) progress->Pulse(message); }); timer->Start(100); - std::thread([this, + std::thread([alive, progress, timer, run = std::forward(run), - on_finish = std::forward(on_finish)]() mutable { + on_finish = std::forward(on_finish), + finish_after_dialog_destroyed]() mutable { try { run(); + } catch (const std::exception& ex) { + BOOST_LOG_TRIVIAL(error) << "Plugin dialog worker failed: " << ex.what(); } catch (...) { + BOOST_LOG_TRIVIAL(error) << "Plugin dialog worker failed with an unknown exception"; } - CallAfter([progress, timer, on_finish = std::move(on_finish)]() mutable { + if (wxTheApp == nullptr) + return; + + wxTheApp->CallAfter([alive, + progress, + timer, + on_finish = std::move(on_finish), + finish_after_dialog_destroyed]() mutable { timer->Stop(); delete timer; - progress->Destroy(); - on_finish(); + if (alive->load(std::memory_order_acquire)) { + progress->Destroy(); + on_finish(); + } else if (finish_after_dialog_destroyed) { + on_finish(); + } }); }).detach(); } @@ -157,7 +177,7 @@ private: state->exception = std::current_exception(); } }, - on_finish, title, message, maximum, style); + on_finish, title, message, maximum, style, true); if (!finished) loop.Run(); @@ -190,7 +210,7 @@ private: state->exception = std::current_exception(); } }, - on_finish, title, message, maximum, style); + on_finish, title, message, maximum, style, true); if (!finished) loop.Run(); @@ -210,6 +230,7 @@ private: } std::function m_open_terminal_dlg_fn; + std::shared_ptr> m_alive = std::make_shared>(true); PluginSortKey m_plugin_sort_key = PluginSortKey::None; PluginSortOrder m_plugin_sort_order = PluginSortOrder::Asc; diff --git a/src/slic3r/GUI/PostProcessor.cpp b/src/slic3r/GUI/PostProcessor.cpp index afb7932eb3..bce7ea1814 100644 --- a/src/slic3r/GUI/PostProcessor.cpp +++ b/src/slic3r/GUI/PostProcessor.cpp @@ -273,7 +273,7 @@ static void run_post_process_plugins(const ConfigOptionStrings& capabilities, // Hand the plugin its own [tool.orcaslicer.plugin.settings] as ctx.params (same plugin_key the // capability was resolved by), mirroring the in-pipeline dispatcher in GUI_App.cpp. const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid; - ctx.params = PluginManager::instance().get_loader().get_plugin_settings(plugin_key); + ctx.params = PluginManager::instance().get_plugin_settings(plugin_key); ExecutionResult exec_result; try { diff --git a/src/slic3r/Utils/NetworkAgentFactory.cpp b/src/slic3r/Utils/NetworkAgentFactory.cpp index 51b74e45d2..2763435519 100644 --- a/src/slic3r/Utils/NetworkAgentFactory.cpp +++ b/src/slic3r/Utils/NetworkAgentFactory.cpp @@ -229,35 +229,33 @@ std::unique_ptr create_agent_from_config(const std::string& log_di void NetworkAgentFactory::register_python_plugin(const std::string& plugin_key) { - auto capabilities = PluginManager::instance().get_loader().get_plugin_capabilities_by_type( - plugin_key, PluginCapabilityType::PrinterConnection); - for (const auto& capability : capabilities) - if (capability && capability->enabled) - register_python_printer_agent(plugin_key, capability->name); + for (const auto& capability : PluginManager::instance().get_plugin_capabilities(plugin_key, PluginCapabilityType::PrinterConnection, + /*only_enabled=*/true)) + register_python_printer_agent(plugin_key, capability->name()); } void NetworkAgentFactory::register_python_printer_agent(const std::string& plugin_key, const std::string& capability_name) { PluginManager& plugin_manager = PluginManager::instance(); - auto cap = plugin_manager.get_loader().get_plugin_capability_by_name(plugin_key, PluginCapabilityType::PrinterConnection, - capability_name); + auto cap = plugin_manager.get_plugin_capability(plugin_key, capability_name, PluginCapabilityType::PrinterConnection, + /*only_enabled=*/false); if (!cap) { BOOST_LOG_TRIVIAL(warning) << "Printer-agent capability '" << capability_name << "' not found for plugin '" << plugin_key << "'"; return; } - if (!cap->enabled) { + + PluginDescriptor descriptor; + if (!plugin_manager.try_get_plugin_descriptor(plugin_key, descriptor)) { + BOOST_LOG_TRIVIAL(warning) << "Could not find descriptor for plugin key: '" << plugin_key; + return; + } + if (!cap->is_enabled()) { BOOST_LOG_TRIVIAL(warning) << "Printer-agent capability '" << capability_name << "' is disabled for plugin '" << plugin_key << "'"; return; } - PluginDescriptor descriptor; - if (!plugin_manager.get_catalog().try_get_plugin_descriptor(plugin_key, descriptor)) { - BOOST_LOG_TRIVIAL(warning) << "Could not find descriptor for plugin key: '" << plugin_key; - return; - } - - auto plugin = std::dynamic_pointer_cast(cap->instance); + auto plugin = std::dynamic_pointer_cast(cap); if (!plugin) { BOOST_LOG_TRIVIAL(warning) << "Loaded plugin capability '" << capability_name << "' is not a printer-agent plugin"; @@ -305,9 +303,10 @@ void NetworkAgentFactory::register_python_printer_agent(const std::string& plugi // Python work above may allow disable/unload/reload to replace this capability. // Re-resolve without holding the registry mutex to avoid lock inversion and reentrancy. - auto current_cap = plugin_manager.get_loader().get_plugin_capability_by_name(plugin_key, PluginCapabilityType::PrinterConnection, - capability_name); - if (current_cap != cap || !current_cap->enabled) { + // only_enabled defaults to true here, so a capability that changed identity (reload) or was + // merely disabled both surface the same way: current_cap no longer equals cap. + auto current_cap = plugin_manager.get_plugin_capability(plugin_key, capability_name, PluginCapabilityType::PrinterConnection); + if (current_cap != cap) { BOOST_LOG_TRIVIAL(debug) << "Printer-agent capability '" << capability_name << "' from plugin '" << plugin_key << "' changed or was disabled during registration"; return; @@ -322,7 +321,9 @@ void NetworkAgentFactory::register_python_printer_agent(const std::string& plugi [&plugin_key, &capability_name](const auto& entry) { return entry.first.first == plugin_key && entry.first.second == capability_name; }); - if (token_it == s_python_printer_agent_registration_tokens.end() || token_it->second != registration_token || !cap->enabled) { + const bool cap_still_enabled = cap->is_enabled(); + if (token_it == s_python_printer_agent_registration_tokens.end() || token_it->second != registration_token || + !cap_still_enabled) { BOOST_LOG_TRIVIAL(debug) << "Printer-agent capability '" << capability_name << "' from plugin '" << plugin_key << "' was disabled before registry commit"; return; @@ -448,6 +449,18 @@ void NetworkAgentFactory::deregister_python_printer_agent(const std::string& plu if (cached_agent) cached_agent->disconnect_printer(); + // The GUI may still own the active capability separately from the factory cache. Clear that + // handle before the plugin module is torn down, otherwise NetworkAgent can retain a Python + // object whose implementation is about to be unloaded. + if (!agent_id.empty() && wxTheApp) { + NetworkAgent* network_agent = GUI::wxGetApp().getAgent(); + if (network_agent) { + const auto active_agent = network_agent->get_printer_agent(); + if (active_agent && active_agent->get_agent_info().id == agent_id) + network_agent->set_printer_agent(nullptr); + } + } + BOOST_LOG_TRIVIAL(info) << "Deregistered Python printer-agent capability '" << capability_name << "' from plugin '" << plugin_key << "' with agent ID '" << agent_id << "'"; } diff --git a/src/slic3r/plugin/PluginAuditManager.cpp b/src/slic3r/plugin/PluginAuditManager.cpp index 1b49cc0cc6..40c16a4807 100644 --- a/src/slic3r/plugin/PluginAuditManager.cpp +++ b/src/slic3r/plugin/PluginAuditManager.cpp @@ -2,6 +2,7 @@ #include "libslic3r/Utils.hpp" +#include #include #include @@ -44,8 +45,16 @@ bool is_inside_allowed_root(const std::filesystem::path& candidate, const std::f auto root_it = canon_root.begin(); auto root_end = canon_root.end(); + const auto same_component = [](const fs::path& lhs, const fs::path& rhs) { +#ifdef _WIN32 + return boost::algorithm::iequals(lhs.native(), rhs.native()); +#else + return lhs == rhs; +#endif + }; + // Consume matching components - while (root_it != root_end && cand_it != cand_end && *root_it == *cand_it) { + while (root_it != root_end && cand_it != cand_end && same_component(*root_it, *cand_it)) { ++root_it; ++cand_it; } diff --git a/src/slic3r/plugin/PluginCatalog.cpp b/src/slic3r/plugin/PluginCatalog.cpp deleted file mode 100644 index e0c0a010f5..0000000000 --- a/src/slic3r/plugin/PluginCatalog.cpp +++ /dev/null @@ -1,714 +0,0 @@ -#include "PluginCatalog.hpp" - -#include "PluginFsUtils.hpp" -#include "libslic3r/Semver.hpp" -#include "libslic3r/Utils.hpp" -#include "PythonFileUtils.hpp" - -#include -#include -#include - -#include -#include -#include -#include - -namespace Slic3r { -namespace { - -const char* kCloudPluginNotFoundError = "Plugin was not found in the cloud."; - -bool is_cloud_version_newer(const std::string& cloud_version, const std::string& local_version) -{ - auto cloud_parsed = Semver::parse(cloud_version); - auto local_parsed = Semver::parse(local_version); - if (cloud_parsed && local_parsed) - return *cloud_parsed > *local_parsed; - // Fall back to string comparison if semver parsing fails for either version. - return cloud_version != local_version; -} - -void remove_plugin_from_entries(std::vector& entries, const std::string& plugin_key) -{ - entries.erase(std::remove_if(entries.begin(), entries.end(), [&plugin_key](const PluginDescriptor& entry) { - return entry.plugin_key == plugin_key; - }), entries.end()); -} - -void clear_plugin_cloud_state_in_entries(std::vector& entries, const std::string& plugin_key) -{ - for (auto& entry : entries) { - if (entry.plugin_key == plugin_key) { - entry.cloud.reset(); - if (entry.normalized_error() == kCloudPluginNotFoundError) - entry.clear_error(); - return; - } - } -} - -bool set_plugin_error_in_entries(std::vector& entries, const std::string& plugin_key, const std::string& error) -{ - for (auto& entry : entries) { - if (entry.plugin_key == plugin_key) { - entry.set_error(error); - return true; - } - } - - return false; -} - -void clear_plugin_errors_in_entries(std::vector& entries) -{ - for (auto& entry : entries) - entry.clear_error(); -} - -// Derive a discovered descriptor's operational plugin_key: ":" for cloud -// entries, otherwise the (escaped) stem of name_source (the entry file when one -// exists, or the plugin directory when it does not). plugin_key is always derived, -// never read back from the install-state sidecar. -void assign_discovered_plugin_key(PluginDescriptor& descriptor, const boost::filesystem::path& name_source) -{ - if (descriptor.is_cloud_plugin()) - descriptor.plugin_key = descriptor.cloud_uuid(); - else - descriptor.plugin_key = make_local_plugin_key(name_source.stem().string()); -} - -} // namespace - -void PluginCatalog::discover_plugins(bool async, bool clear) -{ - { - std::lock_guard lock(m_mutex); - if (m_discovery_in_progress) { - BOOST_LOG_TRIVIAL(debug) << "Plugin discovery already running"; - return; - } - if (clear) { - m_plugin_catalog.clear(); - m_invalid_plugins.clear(); - m_install_states.clear(); - } - m_discovery_in_progress = true; - m_discovery_complete = false; - m_discovery_error.clear(); - } - - run_discovery(async); -} - -bool PluginCatalog::is_discovery_complete() const -{ - std::lock_guard lock(m_mutex); - return m_discovery_complete; -} - -bool PluginCatalog::is_discovery_in_progress() const -{ - std::lock_guard lock(m_mutex); - return m_discovery_in_progress; -} - -std::string PluginCatalog::get_discovery_error() const -{ - std::lock_guard lock(m_mutex); - return m_discovery_error; -} - -bool PluginCatalog::wait_for_discovery(std::chrono::milliseconds timeout, std::string& error) const -{ - std::unique_lock lock(m_mutex); - if (!m_discovery_in_progress) - return true; - - if (timeout == std::chrono::milliseconds::max()) { - m_discovery_cv.wait(lock, [this]() { return !m_discovery_in_progress; }); - return true; - } - - if (!m_discovery_cv.wait_for(lock, timeout, [this]() { return !m_discovery_in_progress; })) { - error = "Plugin discovery is still running"; - return false; - } - - return true; -} - -const std::vector& PluginCatalog::get_plugin_catalog() const -{ - std::lock_guard lock(m_mutex); - return m_plugin_catalog; -} - -std::vector PluginCatalog::get_all_plugin_descriptors() const -{ - std::lock_guard lock(m_mutex); - return m_plugin_catalog; -} - -std::vector PluginCatalog::get_invalid_plugins() const -{ - std::lock_guard lock(m_mutex); - return m_invalid_plugins; -} - -std::vector PluginCatalog::get_plugin_descriptors_by_type(const std::string& type) const -{ - return get_plugin_descriptors_by_type(plugin_capability_type_from_string(type)); -} - -std::vector PluginCatalog::get_plugin_descriptors_by_type(PluginCapabilityType type) const -{ - std::lock_guard lock(m_mutex); - - std::vector result; - for (const auto& entry : m_plugin_catalog) { - if (entry.has_capability_type(type)) - result.push_back(entry); - } - - return result; -} - -const PluginDescriptor* PluginCatalog::find_valid_plugin_descriptor(const std::string& plugin_key) const -{ - std::lock_guard lock(m_mutex); - - for (const auto& entry : m_plugin_catalog) { - if (entry.plugin_key == plugin_key) - return &entry; - } - - return nullptr; -} - -bool PluginCatalog::try_get_valid_plugin_descriptor(const std::string& plugin_key, PluginDescriptor& descriptor) const -{ - std::lock_guard lock(m_mutex); - - const auto it = std::find_if(m_plugin_catalog.begin(), m_plugin_catalog.end(), [&plugin_key](const PluginDescriptor& entry) { - return entry.plugin_key == plugin_key; - }); - - if (it == m_plugin_catalog.end()) - return false; - - descriptor = *it; - return true; -} - -bool PluginCatalog::try_get_plugin_descriptor(const std::string& plugin_key, PluginDescriptor& descriptor) const -{ - std::lock_guard lock(m_mutex); - - const auto find_by_key = [&plugin_key](const PluginDescriptor& entry) { - return entry.plugin_key == plugin_key; - }; - - auto catalog_it = std::find_if(m_plugin_catalog.begin(), m_plugin_catalog.end(), find_by_key); - if (catalog_it != m_plugin_catalog.end()) { - descriptor = *catalog_it; - return true; - } - - auto invalid_it = std::find_if(m_invalid_plugins.begin(), m_invalid_plugins.end(), find_by_key); - if (invalid_it != m_invalid_plugins.end()) { - descriptor = *invalid_it; - return true; - } - - return false; -} - -bool PluginCatalog::try_get_invalid_plugin_descriptor(const std::string& plugin_key, PluginDescriptor& descriptor) const -{ - std::lock_guard lock(m_mutex); - - const auto it = std::find_if(m_invalid_plugins.begin(), m_invalid_plugins.end(), [&plugin_key](const PluginDescriptor& entry) { - return entry.plugin_key == plugin_key; - }); - - if (it == m_invalid_plugins.end()) - return false; - - descriptor = *it; - return true; -} - -bool PluginCatalog::has_valid_plugin_descriptor(const std::string& plugin_key) const -{ - std::lock_guard lock(m_mutex); - return std::any_of(m_plugin_catalog.begin(), m_plugin_catalog.end(), [&plugin_key](const PluginDescriptor& entry) { - return entry.plugin_key == plugin_key; - }); -} - -void PluginCatalog::set_cloud_plugin_dir(const std::string& dir) -{ - std::lock_guard lock(m_mutex); - m_cloud_plugin_dir = dir; -} - -std::vector PluginCatalog::get_plugin_directories() const -{ - namespace fs = boost::filesystem; - std::vector dirs; - std::string cloud_plugin_dir_name; - - { - std::lock_guard lock(m_mutex); - cloud_plugin_dir_name = m_cloud_plugin_dir; - } - - auto add_or_create_dir = [&dirs](const fs::path& path) { - if (fs::exists(path) && fs::is_directory(path)) { - dirs.push_back(path.string()); - } else { - try { - fs::create_directories(path); - dirs.push_back(path.string()); - BOOST_LOG_TRIVIAL(info) << "Created plugin directory: " << path.string(); - } catch (const std::exception& ex) { - BOOST_LOG_TRIVIAL(warning) << "Failed to create plugin directory: " << ex.what(); - } - } - }; - - // Local plugins: {data_dir}/orca_plugins/ - add_or_create_dir(fs::path(data_dir()) / "orca_plugins"); - - // Cloud plugins: {data_dir}/orca_plugins/_subscribed/{user_id}/ - if (!cloud_plugin_dir_name.empty()) - add_or_create_dir(fs::path(get_cloud_plugin_dir(cloud_plugin_dir_name))); - - return dirs; -} - -void PluginCatalog::update_cloud_catalog(const std::vector& cloud_list) -{ - std::lock_guard lock(m_mutex); - - for (const auto& cloud_entry : cloud_list) { - std::string cloud_uuid = cloud_entry.cloud_uuid(); - const std::string cloud_key = cloud_entry.plugin_key; - if (cloud_uuid.empty()) { - BOOST_LOG_TRIVIAL(warning) << "Skipping cloud plugin record without UUID"; - continue; - } - - auto matches_cloud_descriptor = [&cloud_key, &cloud_uuid](const PluginDescriptor& entry) { - if (!cloud_key.empty() && entry.plugin_key == cloud_key) - return true; - return entry.is_cloud_plugin() && entry.cloud_uuid() == cloud_uuid; - }; - - auto apply_cloud_state = [&cloud_entry, &cloud_uuid](PluginDescriptor& entry) { - const PluginDescriptor local_entry = entry; - const std::string installed_version = entry.installed_version; - const std::string latest_version = cloud_entry.latest_available_version(); - const std::string local_plugin_root = entry.plugin_root; - const std::string local_entry_path = entry.entry_path; - const bool local_metadata_valid = entry.is_metadata_valid(); - const bool has_local_package = entry.has_local_package(); - const std::string previous_error = entry.error; - - entry = cloud_entry; - entry.plugin_root = local_plugin_root; - entry.entry_path = local_entry_path; - if (has_local_package) - apply_plugin_metadata_fallbacks(entry, local_entry); - if (entry.plugin_key.empty()) - entry.plugin_key = cloud_uuid; - entry.metadata_valid = has_local_package ? local_metadata_valid : cloud_entry.metadata_valid; - entry.error = previous_error; - if (!entry.cloud.has_value()) - entry.cloud = CloudPluginState{cloud_uuid, has_local_package, false, false}; - else if (entry.cloud->uuid.empty()) - entry.cloud->uuid = cloud_uuid; - - entry.cloud->installed = has_local_package; - // The installed version is the source of truth read back from the install-state - // sidecar (the version fetched from the cloud at install time), not the local - // manifest/PEP723 header. The header may be stale — the cloud can bump the version - // without the header changing — which would otherwise make an already-updated - // plugin appear perpetually out of date. - entry.installed_version = has_local_package ? installed_version : std::string{}; - entry.cloud->update_available = has_local_package && local_metadata_valid && !installed_version.empty() && - !latest_version.empty() && is_cloud_version_newer(latest_version, installed_version); - if (entry.normalized_error() == kCloudPluginNotFoundError) - entry.clear_error(); - }; - - auto catalog_it = std::find_if(m_plugin_catalog.begin(), m_plugin_catalog.end(), matches_cloud_descriptor); - if (catalog_it != m_plugin_catalog.end()) { - apply_cloud_state(*catalog_it); - continue; - } - - auto invalid_it = std::find_if(m_invalid_plugins.begin(), m_invalid_plugins.end(), matches_cloud_descriptor); - if (invalid_it != m_invalid_plugins.end()) { - apply_cloud_state(*invalid_it); - continue; - } - - PluginDescriptor normalized_entry = cloud_entry; - if (normalized_entry.plugin_key.empty()) - normalized_entry.plugin_key = cloud_uuid; - if (!normalized_entry.cloud.has_value()) - normalized_entry.cloud = CloudPluginState{cloud_uuid, false, false, false}; - else if (normalized_entry.cloud->uuid.empty()) - normalized_entry.cloud->uuid = cloud_uuid; - m_plugin_catalog.push_back(std::move(normalized_entry)); - } -} - -void PluginCatalog::mark_cloud_plugin_unauthorized(const std::string& cloud_uuid) -{ - std::lock_guard lock(m_mutex); - - auto mark_unauthorized = [&cloud_uuid](std::vector& entries) { - for (auto& entry : entries) { - if (entry.is_cloud_plugin() && entry.cloud_uuid() == cloud_uuid) { - entry.set_unauthorized(true); - if (entry.cloud.has_value()) - entry.cloud->update_available = false; - return true; - } - } - return false; - }; - - if (mark_unauthorized(m_plugin_catalog)) - return; - - mark_unauthorized(m_invalid_plugins); -} - -void PluginCatalog::mark_cloud_plugin_not_found(const std::string& cloud_uuid) -{ - std::lock_guard lock(m_mutex); - - auto mark_not_found = [&cloud_uuid](std::vector& entries) { - for (auto& entry : entries) { - if (entry.is_cloud_plugin() && entry.cloud_uuid() == cloud_uuid) { - if (!entry.has_local_package()) - entry.set_error(kCloudPluginNotFoundError); - return true; - } - } - return false; - }; - - if (mark_not_found(m_plugin_catalog)) - return; - - mark_not_found(m_invalid_plugins); -} - -void PluginCatalog::clear_cloud_plugin_unauthorized() -{ - std::lock_guard lock(m_mutex); - - auto clear_unauthorized = [](std::vector& entries) { - for (auto& entry : entries) { - if (entry.is_cloud_plugin()) - entry.set_unauthorized(false); - } - }; - - clear_unauthorized(m_plugin_catalog); - clear_unauthorized(m_invalid_plugins); -} - -void PluginCatalog::clear_cloud_plugin_not_found_errors() -{ - std::lock_guard lock(m_mutex); - - auto clear_not_found_errors = [](std::vector& entries) { - for (auto& entry : entries) { - if (entry.is_cloud_plugin() && entry.normalized_error() == kCloudPluginNotFoundError) - entry.clear_error(); - } - }; - - clear_not_found_errors(m_plugin_catalog); - clear_not_found_errors(m_invalid_plugins); -} - -void PluginCatalog::clear_cloud_plugin_catalog() -{ - std::lock_guard lock(m_mutex); - - auto clear_entries = [](std::vector& entries) { - entries.erase(std::remove_if(entries.begin(), entries.end(), [](const PluginDescriptor& entry) { - return entry.is_cloud_plugin() && !entry.has_local_package(); - }), entries.end()); - - for (auto& entry : entries) { - if (entry.is_cloud_plugin()) { - entry.cloud->update_available = false; - entry.cloud->unauthorized = false; - entry.cloud->is_mine = false; - if (!entry.plugin_root.empty() || !entry.entry_path.empty()) - entry.cloud->installed = true; - } - if (entry.normalized_error() == kCloudPluginNotFoundError) - entry.clear_error(); - } - }; - - clear_entries(m_plugin_catalog); - clear_entries(m_invalid_plugins); - - BOOST_LOG_TRIVIAL(info) << "Cleared cloud plugin catalog entries"; -} - -void PluginCatalog::remove_plugin(const std::string& plugin_key) -{ - std::lock_guard lock(m_mutex); - remove_plugin_from_entries(m_plugin_catalog, plugin_key); - remove_plugin_from_entries(m_invalid_plugins, plugin_key); -} - -void PluginCatalog::clear_plugin_cloud_state(const std::string& plugin_key) -{ - std::lock_guard lock(m_mutex); - clear_plugin_cloud_state_in_entries(m_plugin_catalog, plugin_key); - clear_plugin_cloud_state_in_entries(m_invalid_plugins, plugin_key); -} - -bool PluginCatalog::set_plugin_error(const std::string& plugin_key, const std::string& error) -{ - std::lock_guard lock(m_mutex); - if (set_plugin_error_in_entries(m_plugin_catalog, plugin_key, error)) - return true; - - return set_plugin_error_in_entries(m_invalid_plugins, plugin_key, error); -} - -bool PluginCatalog::clear_plugin_error(const std::string& plugin_key) -{ - return set_plugin_error(plugin_key, ""); -} - -void PluginCatalog::clear_all_plugin_errors() -{ - std::lock_guard lock(m_mutex); - clear_plugin_errors_in_entries(m_plugin_catalog); - clear_plugin_errors_in_entries(m_invalid_plugins); -} - -void PluginCatalog::discover_plugins_impl() -{ - const auto start_time = std::chrono::steady_clock::now(); - - try { - const std::vector plugin_dirs = get_plugin_directories(); - - BOOST_LOG_TRIVIAL(info) << "Scanning " << plugin_dirs.size() << " plugin directories..."; - - for (const auto& dir : plugin_dirs) - scan_directory(dir); - - std::size_t plugin_count = 0; - { - std::lock_guard lock(m_mutex); - m_discovery_complete = true; - plugin_count = m_plugin_catalog.size(); - } - - const auto end_time = std::chrono::steady_clock::now(); - const auto duration = std::chrono::duration_cast(end_time - start_time); - BOOST_LOG_TRIVIAL(info) << "Plugin discovery completed in " << duration.count() << "ms. Found " << plugin_count - << " plugin manifests"; - } catch (const std::exception& ex) { - std::lock_guard lock(m_mutex); - m_discovery_error = std::string("Plugin discovery failed: ") + ex.what(); - m_discovery_complete = true; - BOOST_LOG_TRIVIAL(error) << m_discovery_error; - } -} - -void PluginCatalog::scan_directory(const std::string& dir_path) -{ - namespace fs = boost::filesystem; - - if (!fs::exists(dir_path) || !fs::is_directory(dir_path)) - return; - - BOOST_LOG_TRIVIAL(debug) << "Scanning plugin directory: " << dir_path; - - try { - for (fs::directory_iterator it(dir_path); it != fs::directory_iterator(); ++it) { - if (!fs::is_directory(it->status())) - continue; - - const fs::path plugin_dir = it->path(); - if (is_ignored_plugin_directory(plugin_dir)) - continue; - - PluginDescriptor descriptor; - descriptor.plugin_root = plugin_dir.string(); - - std::string entry_error; - const fs::path entry_path = find_installed_plugin_entry(plugin_dir, entry_error); - - if (entry_path.empty()) { - descriptor.set_error(entry_error); - read_install_state(plugin_dir, descriptor); - assign_discovered_plugin_key(descriptor, plugin_dir); - PluginInstallState install_state; - const bool have_install_state = read_install_state(plugin_dir, install_state); - std::lock_guard lock(m_mutex); - if (have_install_state) - m_install_states[descriptor.plugin_key] = std::move(install_state); - m_invalid_plugins.push_back(std::move(descriptor)); - BOOST_LOG_TRIVIAL(warning) << "Invalid plugin package: " << plugin_dir.string() << " - " << m_invalid_plugins.back().error; - continue; - } - - // Parse local file metadata for dependencies and loading details. - std::string meta_error; - if (entry_path.extension() == ".whl") { - if (!read_wheel_plugin_metadata(entry_path, descriptor, meta_error)) { - descriptor.set_error(meta_error); - read_install_state(plugin_dir, descriptor); - assign_discovered_plugin_key(descriptor, entry_path); - PluginInstallState install_state; - const bool have_install_state = read_install_state(plugin_dir, install_state); - std::lock_guard lock(m_mutex); - if (have_install_state) - m_install_states[descriptor.plugin_key] = std::move(install_state); - m_invalid_plugins.push_back(std::move(descriptor)); - BOOST_LOG_TRIVIAL(warning) << "Invalid wheel plugin: " << plugin_dir.string() << " - " - << m_invalid_plugins.back().error; - continue; - } - descriptor.entry_path = entry_path.string(); - } else { - if (!read_python_plugin_metadata(entry_path, descriptor, meta_error)) { - descriptor.set_error(meta_error); - read_install_state(plugin_dir, descriptor); - assign_discovered_plugin_key(descriptor, entry_path); - PluginInstallState install_state; - const bool have_install_state = read_install_state(plugin_dir, install_state); - std::lock_guard lock(m_mutex); - if (have_install_state) - m_install_states[descriptor.plugin_key] = std::move(install_state); - m_invalid_plugins.push_back(std::move(descriptor)); - BOOST_LOG_TRIVIAL(warning) << "Invalid .py plugin: " << plugin_dir.string() << " - " << m_invalid_plugins.back().error; - continue; - } - descriptor.entry_path = entry_path.string(); - } - - descriptor.set_metadata_valid(true); - descriptor.clear_error(); - - // Read cloud identity (uuid) from sidecar; plugin_key is always derived. - read_install_state(plugin_dir, descriptor); - assign_discovered_plugin_key(descriptor, entry_path); - - PluginInstallState install_state; - const bool have_install_state = read_install_state(plugin_dir, install_state); - - std::lock_guard lock(m_mutex); - - if (have_install_state) - m_install_states[descriptor.plugin_key] = std::move(install_state); - m_plugin_catalog.push_back(std::move(descriptor)); - BOOST_LOG_TRIVIAL(info) << "Discovered plugin: " << m_plugin_catalog.back().name - << " (type: " << m_plugin_catalog.back().type_label() << ", version: " << m_plugin_catalog.back().version - << ")"; - } - } catch (const std::exception& ex) { - BOOST_LOG_TRIVIAL(error) << "Error scanning directory " << dir_path << ": " << ex.what(); - } -} - -void PluginCatalog::run_discovery(bool async) -{ - auto task = [this]() { run_discovery_task(); }; - - if (async) - std::thread(std::move(task)).detach(); - else - task(); -} - -void PluginCatalog::run_discovery_task() -{ - try { - discover_plugins_impl(); - } catch (const std::exception& ex) { - { - std::lock_guard lock(m_mutex); - m_discovery_error = std::string("Plugin discovery failed: ") + ex.what(); - m_discovery_complete = true; - } - BOOST_LOG_TRIVIAL(error) << m_discovery_error; - } catch (...) { - { - std::lock_guard lock(m_mutex); - m_discovery_error = "Plugin discovery failed: unknown error"; - m_discovery_complete = true; - } - BOOST_LOG_TRIVIAL(error) << m_discovery_error; - } - - { - std::lock_guard lock(m_mutex); - m_discovery_in_progress = false; - } - m_discovery_cv.notify_all(); -} - -bool PluginCatalog::update_plugin_descriptor(const std::string& plugin_key, const PluginDescriptor& descriptor) -{ - std::lock_guard lock(m_mutex); - - const auto find_by_key = [&plugin_key](const PluginDescriptor& entry) { - return entry.plugin_key == plugin_key; - }; - - auto catalog_it = std::find_if(m_plugin_catalog.begin(), m_plugin_catalog.end(), find_by_key); - if (catalog_it != m_plugin_catalog.end()) { - *catalog_it = std::move(descriptor); - return true; - } - - auto invalid_it = std::find_if(m_invalid_plugins.begin(), m_invalid_plugins.end(), find_by_key); - if (invalid_it != m_invalid_plugins.end()) { - *invalid_it = std::move(descriptor); - return true; - } - - return false; -} - -bool PluginCatalog::try_get_install_state(const std::string& plugin_key, PluginInstallState& out) const -{ - std::lock_guard lock(m_mutex); - const auto it = m_install_states.find(plugin_key); - if (it == m_install_states.end()) - return false; - out = it->second; - return true; -} - -std::vector PluginCatalog::get_enabled_plugin_keys() const -{ - std::lock_guard lock(m_mutex); - std::vector keys; - for (const auto& [plugin_key, state] : m_install_states) { - if (state.enabled) - keys.push_back(plugin_key); - } - return keys; -} - -} // namespace Slic3r diff --git a/src/slic3r/plugin/PluginCatalog.hpp b/src/slic3r/plugin/PluginCatalog.hpp deleted file mode 100644 index bddad82ec3..0000000000 --- a/src/slic3r/plugin/PluginCatalog.hpp +++ /dev/null @@ -1,76 +0,0 @@ -#pragma once - -#include "PluginDescriptor.hpp" -#include "PythonFileUtils.hpp" - -#include -#include -#include -#include -#include -#include - -namespace Slic3r { - -class PluginCatalog -{ -public: - void discover_plugins(bool async = false, bool clear = false); - - bool is_discovery_complete() const; - bool is_discovery_in_progress() const; - std::string get_discovery_error() const; - bool wait_for_discovery(std::chrono::milliseconds timeout, std::string& error) const; - - const std::vector& get_plugin_catalog() const; - std::vector get_all_plugin_descriptors() const; - std::vector get_invalid_plugins() const; - std::vector get_plugin_descriptors_by_type(const std::string& type) const; - std::vector get_plugin_descriptors_by_type(PluginCapabilityType type) const; - const PluginDescriptor* find_valid_plugin_descriptor(const std::string& plugin_key) const; - bool try_get_valid_plugin_descriptor(const std::string& plugin_key, PluginDescriptor& descriptor) const; - bool try_get_plugin_descriptor(const std::string& plugin_key, PluginDescriptor& descriptor) const; - bool try_get_invalid_plugin_descriptor(const std::string& plugin_key, PluginDescriptor& descriptor) const; - bool has_valid_plugin_descriptor(const std::string& plugin_key) const; - - void set_cloud_plugin_dir(const std::string& dir); - std::vector get_plugin_directories() const; - - void update_cloud_catalog(const std::vector& cloud_list); - void mark_cloud_plugin_unauthorized(const std::string& cloud_uuid); - void mark_cloud_plugin_not_found(const std::string& cloud_uuid); - void clear_cloud_plugin_unauthorized(); - void clear_cloud_plugin_not_found_errors(); - void clear_cloud_plugin_catalog(); - void remove_plugin(const std::string& plugin_key); - void clear_plugin_cloud_state(const std::string& plugin_key); - bool set_plugin_error(const std::string& plugin_key, const std::string& error); - bool clear_plugin_error(const std::string& plugin_key); - void clear_all_plugin_errors(); - - bool update_plugin_descriptor(const std::string& plugin_key, const PluginDescriptor& descriptor); - - // Cached install state, populated from each plugin's .install_state.json during discovery. - bool try_get_install_state(const std::string& plugin_key, PluginInstallState& out) const; - std::vector get_enabled_plugin_keys() const; - -private: - void discover_plugins_impl(); - void scan_directory(const std::string& dir_path); - void run_discovery(bool async); - void run_discovery_task(); - - bool m_discovery_complete = false; - std::string m_discovery_error; - bool m_discovery_in_progress = false; - - std::vector m_plugin_catalog; - std::vector m_invalid_plugins; - std::unordered_map m_install_states; - std::string m_cloud_plugin_dir; - - mutable std::mutex m_mutex; - mutable std::condition_variable m_discovery_cv; -}; - -} // namespace Slic3r diff --git a/src/slic3r/plugin/PluginDescriptor.hpp b/src/slic3r/plugin/PluginDescriptor.hpp index 79b967f693..e15dda2666 100644 --- a/src/slic3r/plugin/PluginDescriptor.hpp +++ b/src/slic3r/plugin/PluginDescriptor.hpp @@ -56,7 +56,6 @@ struct PluginDescriptor std::string version; // Selected plugin version std::string latest_version; // Latest available cloud version fallback when changelog is unavailable. std::string installed_version; // Locally installed package version. Preserved across cloud merges, which overwrite `version` with the latest cloud version. Empty when not installed. - std::vector capability_types; // Capability types this package materializes (one package → N capabilities) std::vector display_types; // Display-only "compatibility" labels (cloud: raw service labels; local: from real capabilities). Never used for dispatch. std::string plugin_root; // Installed plugin directory, even when entry_path is invalid or ambiguous. std::string entry_path; // Full path to the installed plugin entry file @@ -68,6 +67,10 @@ struct PluginDescriptor std::string error; // Blocking error message. Non-empty means the plugin is in an error state. std::optional cloud; // Extra cloud state layered on top of a normal plugin descriptor. bool metadata_valid = false; // Manifest/package validity stays separate from the user-facing error field. + // Package auto-load flag, read from .install_state.json. Defaults to FALSE: a package with no + // sidecar has never been installed through Orca and carries no auto-load intent, so it must not + // be loaded at startup. Installing a package writes the sidecar with enabled = true. + bool enabled = false; std::string sharing_token; // Use BASE_URL/p/SHARING_TOKEN to open relevant plugin in browser. std::string thumbnail_url; // Cloud main_image pre-signed (access_url) thumbnail; empty for local plugins. Display-only. @@ -76,22 +79,13 @@ struct PluginDescriptor bool has_local_package() const { return !is_cloud_plugin() || cloud->installed || !plugin_root.empty() || !entry_path.empty(); } bool is_metadata_valid() const { return metadata_valid; } - // Capability-type helpers. A package may materialize several capability types; - // these accessors give callers that still reason about a single "type" (catalog - // display, cloud overlay, dispatch — finalized in later tasks) a stable view. - bool has_capability_type(PluginCapabilityType t) const - { - return std::find(capability_types.begin(), capability_types.end(), t) != capability_types.end(); - } - PluginCapabilityType primary_capability_type() const - { - return capability_types.empty() ? PluginCapabilityType::Unknown : capability_types.front(); - } - // Set the package to a single capability type (metadata/cloud sources currently - // declare one type; multi-type discovery is finalized in later tasks). - void set_capability_type(PluginCapabilityType t) { capability_types.assign(1, t); } - // Canonical label derived from the primary type for UI / config matching. - std::string type_label() const { return plugin_capability_type_to_string(primary_capability_type()); } + // A package that exists on disk but whose manifest could not be parsed. + // + // Deliberately not just !is_metadata_valid(): a cloud plugin that is merely available + // (subscribed but not installed) also has metadata_valid == false — the cloud service never + // sets it — yet it is a valid catalog row, not a broken package. What separates the two is + // whether there is a local package behind the descriptor at all. + bool is_invalid_package() const { return !metadata_valid && has_local_package(); } std::string normalized_error() const { @@ -159,8 +153,6 @@ inline void apply_plugin_metadata_fallbacks(PluginDescriptor& target, const Plug target.author = fallback.author; if (target.version.empty()) target.version = fallback.version; - if (target.capability_types.empty()) - target.capability_types = fallback.capability_types; if (target.entry_package.empty()) target.entry_package = fallback.entry_package; if (target.dependencies.empty()) diff --git a/src/slic3r/plugin/PluginFsUtils.cpp b/src/slic3r/plugin/PluginFsUtils.cpp index f62c3ee3f5..b9685a4764 100644 --- a/src/slic3r/plugin/PluginFsUtils.cpp +++ b/src/slic3r/plugin/PluginFsUtils.cpp @@ -1,11 +1,14 @@ #include "PluginFsUtils.hpp" +#include "PythonFileUtils.hpp" #include "libslic3r/Utils.hpp" #include #include +#include #include +#include #include "PluginAuditManager.hpp" @@ -107,4 +110,148 @@ bool delete_plugin_root(const boost::filesystem::path& resolved_root, return true; } + +// ── Discovery ─────────────────────────────────────────────────────────────────────────────── + +namespace { + +// Derive a discovered descriptor's operational plugin_key: the cloud UUID for cloud entries, +// otherwise the (escaped) stem of name_source (the entry file when one exists, or the plugin +// directory when it does not). plugin_key is always derived, never read back from the sidecar. +void assign_discovered_plugin_key(PluginDescriptor& descriptor, const boost::filesystem::path& name_source) +{ + if (descriptor.is_cloud_plugin()) + descriptor.plugin_key = descriptor.cloud_uuid(); + else + descriptor.plugin_key = make_local_plugin_key(name_source.stem().string()); +} + +void scan_plugin_directory(const std::string& dir_path, std::vector& out) +{ + namespace fs = boost::filesystem; + + if (!fs::exists(dir_path) || !fs::is_directory(dir_path)) + return; + + BOOST_LOG_TRIVIAL(debug) << "Scanning plugin directory: " << dir_path; + + try { + for (fs::directory_iterator it(dir_path); it != fs::directory_iterator(); ++it) { + if (!fs::is_directory(it->status())) + continue; + + const fs::path plugin_dir = it->path(); + if (is_ignored_plugin_directory(plugin_dir)) + continue; + + PluginDescriptor descriptor; + descriptor.plugin_root = plugin_dir.string(); + + std::string entry_error; + const fs::path entry_path = find_installed_plugin_entry(plugin_dir, entry_error); + + // No usable entry file: keep the package as an invalid row so the UI can show it and + // its error, rather than dropping it silently. + if (entry_path.empty()) { + descriptor.set_error(entry_error); + read_install_state(plugin_dir, descriptor); + assign_discovered_plugin_key(descriptor, plugin_dir); + out.push_back(std::move(descriptor)); + BOOST_LOG_TRIVIAL(warning) << "Invalid plugin package: " << plugin_dir.string() << " - " << out.back().error; + continue; + } + + std::string meta_error; + const bool is_wheel = entry_path.extension() == ".whl"; + const bool parsed = is_wheel ? read_wheel_plugin_metadata(entry_path, descriptor, meta_error) : + read_python_plugin_metadata(entry_path, descriptor, meta_error); + if (!parsed) { + descriptor.set_error(meta_error); + read_install_state(plugin_dir, descriptor); + assign_discovered_plugin_key(descriptor, entry_path); + out.push_back(std::move(descriptor)); + BOOST_LOG_TRIVIAL(warning) << (is_wheel ? "Invalid wheel plugin: " : "Invalid .py plugin: ") + << plugin_dir.string() << " - " << out.back().error; + continue; + } + + descriptor.entry_path = entry_path.string(); + descriptor.set_metadata_valid(true); + descriptor.clear_error(); + + // Cloud identity and the package-level auto-load flag. plugin_key is always derived + // below, never read from the sidecar. + read_install_state(plugin_dir, descriptor); + assign_discovered_plugin_key(descriptor, entry_path); + + out.push_back(std::move(descriptor)); + BOOST_LOG_TRIVIAL(info) << "Discovered plugin: " << out.back().name << " (version: " << out.back().version << ")"; + } + } catch (const std::exception& ex) { + BOOST_LOG_TRIVIAL(error) << "Error scanning directory " << dir_path << ": " << ex.what(); + } +} + + +} // namespace + +std::vector get_plugin_directories(const std::string& cloud_user_id) +{ + namespace fs = boost::filesystem; + + std::vector dirs; + + // Creates the directory when missing — callers (notably the install path) rely on it existing. + auto add_or_create_dir = [&dirs](const fs::path& path) { + if (fs::exists(path) && fs::is_directory(path)) { + dirs.push_back(path.string()); + return; + } + try { + fs::create_directories(path); + dirs.push_back(path.string()); + BOOST_LOG_TRIVIAL(info) << "Created plugin directory: " << path.string(); + } catch (const std::exception& ex) { + BOOST_LOG_TRIVIAL(warning) << "Failed to create plugin directory: " << ex.what(); + } + }; + + // Local plugins: {data_dir}/orca_plugins/ + add_or_create_dir(fs::path(data_dir()) / "orca_plugins"); + + // Cloud plugins: {data_dir}/orca_plugins/_subscribed/{user_id}/ + if (!cloud_user_id.empty()) + add_or_create_dir(fs::path(get_cloud_plugin_dir(cloud_user_id))); + + return dirs; +} + +std::vector discover_plugin_packages(const std::vector& dirs, std::string& error) +{ + error.clear(); + + const auto start_time = std::chrono::steady_clock::now(); + std::vector discovered; + + try { + BOOST_LOG_TRIVIAL(info) << "Scanning " << dirs.size() << " plugin directories..."; + + for (const std::string& dir : dirs) + scan_plugin_directory(dir, discovered); + + const auto duration = std::chrono::duration_cast(std::chrono::steady_clock::now() - start_time); + BOOST_LOG_TRIVIAL(info) << "Plugin discovery completed in " << duration.count() << "ms. Found " << discovered.size() + << " plugin manifests"; + } catch (const std::exception& ex) { + error = std::string("Plugin discovery failed: ") + ex.what(); + BOOST_LOG_TRIVIAL(error) << error; + } catch (...) { + error = "Plugin discovery failed: unknown error"; + BOOST_LOG_TRIVIAL(error) << error; + } + + return discovered; +} + + } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginFsUtils.hpp b/src/slic3r/plugin/PluginFsUtils.hpp index 4e95a7aa4d..a47f21c65f 100644 --- a/src/slic3r/plugin/PluginFsUtils.hpp +++ b/src/slic3r/plugin/PluginFsUtils.hpp @@ -32,4 +32,20 @@ bool delete_plugin_root(const boost::filesystem::path& resolved_root, const std::string& plugin_id, std::string& error); +// The directories plugins are discovered from: {data_dir}/orca_plugins, plus the per-user cloud +// directory when cloud_user_id is non-empty. +// +// NOTE: this CREATES the directories if they do not exist. Callers rely on that side effect — the +// install path writes into them without creating them itself. +std::vector get_plugin_directories(const std::string& cloud_user_id); + +// Scan the given directories for plugin packages (manifest-only; no Python is loaded, no state is +// kept). Pure: directories in, descriptors out. +// +// Returns every package found, valid and invalid alike: a package whose manifest could not be +// parsed comes back with metadata_valid == false and its error set (descriptor.is_invalid_package()). +// The package-level auto-load flag is read from each package's .install_state.json into +// descriptor.enabled. Capabilities are NOT discovered here — a package has none until it is loaded. +std::vector discover_plugin_packages(const std::vector& dirs, std::string& error); + } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginHooks.cpp b/src/slic3r/plugin/PluginHooks.cpp index 428c79223e..6e3a5f9b73 100644 --- a/src/slic3r/plugin/PluginHooks.cpp +++ b/src/slic3r/plugin/PluginHooks.cpp @@ -14,6 +14,7 @@ #include #include +#include namespace Slic3r::plugin_hooks { namespace { @@ -25,12 +26,16 @@ void install_capability_resolver() { ConfigBase::set_resolve_capability_fn([](const std::string& cap_name, const std::string& cap_type) { PluginManager& plugin_mgr = PluginManager::instance(); - auto plugin_cap = plugin_mgr.get_loader().try_get_plugin_capability_by_name_and_type(cap_name, plugin_capability_type_from_string(cap_type)); + const PluginCapabilityType type = plugin_capability_type_from_string(cap_type); + // only_enabled = false: this resolves the reference a preset STORES, which must stay + // resolvable whether or not the user currently has the capability enabled. Filtering on + // the enable flag here would quietly drop the reference out of the preset's manifest. + auto plugin_cap = plugin_mgr.get_plugin_capability(cap_name, type, /*only_enabled=*/false); if (!plugin_cap) return std::string(); PluginDescriptor descriptor; - if (!plugin_mgr.get_catalog().try_get_plugin_descriptor(plugin_cap->plugin_key, descriptor)) + if (!plugin_mgr.try_get_plugin_descriptor_for_capability(cap_name, type, descriptor)) return std::string(); // Cloud plugins are resolved at runtime via the UUID in the middle field, so the first @@ -62,11 +67,17 @@ void install_slicing_pipeline_hook() execute_capabilities_from_refs( *caps, plugs, PluginCapabilityType::SlicingPipeline, [&](std::shared_ptr cap, const PluginCapabilityRef& ref) { + const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid; ExecutionResult r; try { + // Read manager state before acquiring the GIL so this path does not take + // m_mutex in the opposite order to plugin teardown. + const auto plugin_settings = PluginManager::instance().get_plugin_settings(plugin_key); // GIL is acquired per capability (not once for the whole dispatch) so it // is released between capabilities. PythonGILState gil; + if (!gil) + throw std::runtime_error("Python interpreter is shutting down"); // throw_if_canceled() is protected on PrintBase; canceled() is the public // equivalent check (same cancel flag), so honor cancellation via it. if (print.canceled()) @@ -78,8 +89,7 @@ void install_slicing_pipeline_hook() ctx.object = object; // hand the plugin its own [tool.orcaslicer.plugin.settings] as ctx.params // (same plugin_key the capability was resolved by, so it always matches). - const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid; - ctx.params = PluginManager::instance().get_loader().get_plugin_settings(plugin_key); + ctx.params = plugin_settings; r = cap->execute(ctx); } catch (const CanceledException&) { throw; // cancellation must reach process(), never become a slicing error diff --git a/src/slic3r/plugin/PluginLoader.cpp b/src/slic3r/plugin/PluginLoader.cpp index 3f0257d6c0..e8140ecc48 100644 --- a/src/slic3r/plugin/PluginLoader.cpp +++ b/src/slic3r/plugin/PluginLoader.cpp @@ -1,15 +1,15 @@ #include "PluginLoader.hpp" -#include "PluginCatalog.hpp" +// plugin_loader fills and tears down a Plugin, so it needs the complete type. The +// declaration lives with the registry that owns it; the dependency is .cpp-only, so the +// service header stays free of the manager. +#include "PluginManager.hpp" + #include "PluginFsUtils.hpp" #include "PythonFileUtils.hpp" -#include "PythonPluginBridge.hpp" #include "PythonInterpreter.hpp" +#include "PythonPluginBridge.hpp" #include "libslic3r/Utils.hpp" -#include "slic3r/Utils/NetworkAgentFactory.hpp" - -#include -namespace py = pybind11; #include #include @@ -20,19 +20,19 @@ namespace py = pybind11; #endif #include -#include +#include #include -#include -#include -#include +#include +#include #include #include +#include +#include #include -#include "PluginAuditManager.hpp" - -namespace Slic3r { +namespace Slic3r::plugin_loader { namespace { + boost::filesystem::path canonical_or_absolute(const boost::filesystem::path& path) { namespace fs = boost::filesystem; @@ -75,8 +75,8 @@ void assign_local_plugin_key(PluginDescriptor& plugin_descriptor, const boost::f } bool read_local_plugin_package_metadata(const boost::filesystem::path& source_path, - PluginDescriptor& plugin_descriptor, - std::string& error) + PluginDescriptor& plugin_descriptor, + std::string& error) { error.clear(); @@ -99,246 +99,10 @@ bool read_local_plugin_package_metadata(const boost::filesystem::path& source_pa return true; } -} // namespace - -bool PluginLoader::is_idle_and_empty() const -{ - std::lock_guard lock(m_mutex); - return m_plugin_load_in_progress.empty() && m_plugins.empty(); -} - -bool PluginLoader::is_plugin_loaded(const std::string& plugin_key) const -{ - std::lock_guard lock(m_mutex); - - return m_plugins.find(plugin_key) != m_plugins.end(); -} - -bool PluginLoader::is_plugin_load_in_progress(const std::string& plugin_key) const -{ - std::lock_guard lock(m_mutex); - return m_plugin_load_in_progress.count(plugin_key) > 0; -} - -void PluginLoader::wait_for_all_plugin_loads() const -{ - std::unique_lock lock(m_mutex); - m_plugin_load_cv.wait(lock, [this]() { return m_plugin_load_in_progress.empty(); }); -} - -bool PluginLoader::wait_for_all_plugin_loads(std::chrono::milliseconds timeout) const -{ - std::unique_lock lock(m_mutex); - return m_plugin_load_cv.wait_for(lock, timeout, [this]() { return m_plugin_load_in_progress.empty(); }); -} - -bool PluginLoader::wait_for_plugin_load(const std::string& plugin_key, std::chrono::milliseconds timeout, std::string& error) const -{ - std::unique_lock lock(m_mutex); - - auto done = [this, &plugin_key]() { - return m_plugin_load_in_progress.count(plugin_key) == 0; - }; - - if (timeout == std::chrono::milliseconds::max()) { - m_plugin_load_cv.wait(lock, done); - } else if (!m_plugin_load_cv.wait_for(lock, timeout, done)) { - error = "Plugin load is still in progress"; - return false; - } - - auto it = m_plugin_load_errors.find(plugin_key); - if (it != m_plugin_load_errors.end()) { - error = it->second; - return false; - } - - return true; -} - -std::vector PluginLoader::get_all_loaded_plugin_descriptors() const -{ - std::lock_guard lock(m_mutex); - - std::vector result; - result.reserve(m_plugins.size()); - - for (const auto& [key, loaded] : m_plugins) { - (void) key; - result.push_back(loaded.descriptor); - } - - return result; -} - -std::shared_ptr PluginLoader::try_get_plugin_capability_by_name_and_type(const std::string& capability_name, PluginCapabilityType type) const -{ - std::lock_guard lock(m_mutex); - - auto find_by_name = [&capability_name](const PluginCapabilityMap& capabilities) -> std::shared_ptr { - for (const auto& [id, capability] : capabilities) { - if (capability && capability->name == capability_name) - return capability; - } - return nullptr; - }; - - if (type != PluginCapabilityType::Unknown) { - const auto type_it = m_plugin_capabilities.find(type); - return type_it == m_plugin_capabilities.end() ? nullptr : find_by_name(type_it->second); - } - - for (const auto& [capability_type, capabilities] : m_plugin_capabilities) { - (void) capability_type; - if (auto capability = find_by_name(capabilities)) - return capability; - } - return nullptr; -} - -std::vector> PluginLoader::get_plugin_capabilities_by_type(const std::string& plugin_type) const -{ return get_plugin_capabilities_by_type(plugin_capability_type_from_string(plugin_type)); } - -std::vector> PluginLoader::get_plugin_capabilities_by_type(PluginCapabilityType type) const -{ - std::lock_guard lock(m_mutex); - - auto type_it = m_plugin_capabilities.find(type); - if (type_it == m_plugin_capabilities.end()) - return {}; - - std::vector> result; - result.reserve(type_it->second.size()); - for (const auto& [id, capability] : type_it->second) { - (void) id; - result.push_back(capability); - } - std::sort(result.begin(), result.end(), [](const auto& lhs, const auto& rhs) { - if (!lhs || !rhs) - return static_cast(lhs); - return lhs->name == rhs->name ? lhs->plugin_key < rhs->plugin_key : lhs->name < rhs->name; - }); - return result; -} - -std::vector> PluginLoader::get_plugin_capabilities_by_type(const std::string& plugin_key, - PluginCapabilityType type) const -{ - std::lock_guard lock(m_mutex); - - std::vector> result; - - if (m_plugin_capabilities.find(type) == m_plugin_capabilities.end()) - return result; - - for (auto& [key, val] : m_plugin_capabilities.at(type)) { - if (val->plugin_key != plugin_key) - continue; - - result.push_back(val); - } - - return result; -} - -std::shared_ptr PluginLoader::get_plugin_capability_by_name( - const std::string& plugin_key, PluginCapabilityType type, const std::string& name) const -{ - return get_plugin_capability_by_name(PluginCapabilityIdentifier{type, name, plugin_key}); -} - -std::shared_ptr PluginLoader::get_plugin_capability_by_name(const PluginCapabilityIdentifier& identifier) const -{ - std::lock_guard lock(m_mutex); - - auto type_it = m_plugin_capabilities.find(identifier.type); - if (type_it == m_plugin_capabilities.end()) - return nullptr; - - if (type_it->second.find(identifier) != type_it->second.end()) { - return type_it->second.at(identifier); - } - return nullptr; -} - -std::map PluginLoader::get_plugin_settings(const std::string& plugin_key) const -{ - std::lock_guard lock(m_mutex); - const auto it = m_plugins.find(plugin_key); - return it != m_plugins.end() ? it->second.descriptor.settings : std::map{}; -} - -std::vector> PluginLoader::get_loaded_plugin_capabilities(const std::string& plugin_key) const -{ - std::lock_guard lock(m_mutex); - const auto plugin_it = m_plugins.find(plugin_key); - if (plugin_it == m_plugins.end()) - return {}; - - const LoadedPlugin& loaded = plugin_it->second; - std::vector> result; - result.reserve(loaded.capabilities.size()); - for (const PluginCapabilityIdentifier& id : loaded.capabilities) { - auto type_it = m_plugin_capabilities.find(id.type); - if (type_it == m_plugin_capabilities.end()) - continue; - auto cap_it = type_it->second.find(id); - if (cap_it != type_it->second.end()) - result.push_back(cap_it->second); - } - return result; -} - -void PluginLoader::write_loaded_plugin_install_state(const std::string& plugin_key) -{ - PluginDescriptor descriptor; - std::vector> caps; - bool found = false; - { - std::lock_guard lock(m_mutex); - auto it = m_plugins.find(plugin_key); - if (it == m_plugins.end()) - return; - descriptor = it->second.descriptor; - for (const PluginCapabilityIdentifier& id : it->second.capabilities) { - auto type_it = m_plugin_capabilities.find(id.type); - if (type_it == m_plugin_capabilities.end()) - continue; - auto cap_it = type_it->second.find(id); - if (cap_it != type_it->second.end() && cap_it->second) - caps.emplace_back(cap_it->second->name, cap_it->second->enabled.load()); - } - found = true; - } - - if (!found || descriptor.plugin_root.empty()) - return; - - write_install_state(boost::filesystem::path(descriptor.plugin_root), descriptor, /*enabled=*/true, caps); -} - -std::vector> PluginLoader::extract_plugin_capabilities_locked(const LoadedPlugin& plugin) -{ - std::vector> result; - result.reserve(plugin.capabilities.size()); - - for (const PluginCapabilityIdentifier& id : plugin.capabilities) { - auto type_it = m_plugin_capabilities.find(id.type); - if (type_it == m_plugin_capabilities.end()) - continue; - - auto node = type_it->second.extract(id); - if (!node.empty()) - result.push_back(std::move(node.mapped())); - if (type_it->second.empty()) - m_plugin_capabilities.erase(type_it); - } - - return result; -} - -void PluginLoader::teardown_capabilities(std::vector>& capabilities, - std::size_t lifecycle_count) const +// on_unload() the first `lifecycle_count` capabilities — the ones that actually got on_load() — +// then drop them all. +// +void teardown_capabilities(std::vector>& capabilities, std::size_t lifecycle_count) { if (capabilities.empty()) return; @@ -349,13 +113,18 @@ void PluginLoader::teardown_capabilities(std::vectorinstance) + if (!capability) continue; try { - capability->instance->on_unload(); + capability->on_unload(); } catch (const std::exception& ex) { BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": Plugin on_unload failed: " << ex.what(); } catch (...) { @@ -365,58 +134,282 @@ void PluginLoader::teardown_capabilities(std::vector& plugin_paths, + std::string& error) { - std::lock_guard lock(m_mutex); - const auto it = m_plugin_load_errors.find(plugin_key); - if (it != m_plugin_load_errors.end()) - return it->second; + namespace fs = boost::filesystem; - return ""; + const fs::path entry_path(descriptor.entry_path); + const fs::path plugin_dir = entry_path.has_extension() ? entry_path.parent_path() : entry_path; + if (!fs::exists(plugin_dir) || !fs::is_directory(plugin_dir)) + return true; + + PythonInterpreter& interpreter = PythonInterpreter::instance(); + for (fs::directory_iterator it(plugin_dir); it != fs::directory_iterator(); ++it) { + if (!fs::is_regular_file(it->status()) || it->path().extension() != ".whl") + continue; + // Skip the entry file itself — it is loaded by its own path. + if (it->path() == entry_path) + continue; + + const fs::path dep_dir = plugin_dir / "__whl_extracted__" / it->path().stem().string(); + if (!fs::exists(dep_dir)) { + std::string extract_error; + if (!extract_zip_to_directory(it->path(), dep_dir, extract_error)) { + error = "Failed to extract plugin .whl dependency " + it->path().string() + ": " + extract_error; + return false; + } + } + + std::string syspath_error; + if (!interpreter.add_plugin_sys_path(dep_dir.string(), syspath_error)) { + error = "Failed to add .whl dependency to sys.path: " + syspath_error; + return false; + } + plugin_paths.push_back(dep_dir.string()); + } + + return true; } -bool PluginLoader::cancel_plugin_load(const std::string& plugin_key) +PyObject* import_plugin_module(const PluginDescriptor& descriptor, + std::vector& plugin_paths, + std::vector& plugin_modules, + std::string& error) { - bool cancelled = false; + PythonInterpreter& interpreter = PythonInterpreter::instance(); + + if (descriptor.entry_package.empty()) + return interpreter.load_module_from_file(descriptor.entry_path, error, &plugin_paths, &plugin_modules); + + if (boost::filesystem::path(descriptor.entry_path).extension() == ".whl") + return interpreter.load_module_from_whl( + descriptor.entry_path, descriptor.entry_package, error, &plugin_paths, &plugin_modules); + + return interpreter.load_module_from_directory( + descriptor.entry_path, descriptor.entry_package, error, &plugin_paths, &plugin_modules); +} + +std::string plugin_module_name(const PluginDescriptor& descriptor) +{ + if (!descriptor.entry_package.empty()) + return descriptor.entry_package; + return boost::filesystem::path(descriptor.entry_path).stem().string(); +} + +} // namespace + +void unload(Plugin& plugin) +{ + // Dropping the capabilities drops their state with them; the user's enable choices survive in + // the .install_state.json sidecar, which the next load seeds from. + teardown_capabilities(plugin.capabilities, plugin.capabilities.size()); + plugin.release_module(); +} + +bool load(const PluginDescriptor& descriptor, + bool skip_deps, + const std::vector& capabilities_to_enable, + const std::function& registry_precheck, + Plugin& out, + std::string& error) +{ + error.clear(); + out = Plugin{}; + + BOOST_LOG_TRIVIAL(info) << "[plugin_loader::load] START plugin=" << descriptor.plugin_key + << " thread=" << std::this_thread::get_id(); + + PythonInterpreter& interpreter = PythonInterpreter::instance(); + if (!interpreter.is_initialized()) { + error = "Python interpreter not initialized: " + interpreter.last_error(); + return false; + } + + if (!descriptor.is_metadata_valid()) { + error = "Plugin manifest is invalid: " + descriptor.plugin_key; + if (descriptor.has_error()) + error += " - " + descriptor.normalized_error(); + return false; + } + + // Serialize the entire load sequence — only one thread may run it at a time. This prevents + // sys.path / sys.modules races when two plugins share the same entry-point filename (e.g. + // plugin.py), and keeps the caller's check-then-commit of the registry free of an interleaved + // load (which could otherwise force a wasted on_load/on_unload rollback). install_packages() + // has a 120s timeout so this cannot block indefinitely. + static std::mutex load_serializer; + std::lock_guard load_lock(load_serializer); + + Plugin plugin; + plugin.descriptor = descriptor; + plugin.module_name = plugin_module_name(descriptor); + + if (!skip_deps) { + std::string pkg_install_error; + if (!install_packages(descriptor.dependencies, pkg_install_error)) { + error = "Failed to install plugin dependencies: " + pkg_install_error; + return false; + } + } + + PythonPluginBridge& bridge = PythonPluginBridge::instance(); + bridge.begin_plugin_capture(descriptor.entry_path); + + std::string wheel_error; + if (!add_wheel_dependencies_to_sys_path(descriptor, plugin.plugin_sys_paths, wheel_error)) { + bridge.cancel_plugin_capture(descriptor.entry_path); + error = std::move(wheel_error); + return false; + } + + std::string load_error; + PyObject* module = import_plugin_module(descriptor, plugin.plugin_sys_paths, plugin.plugin_modules, load_error); + if (module == nullptr) { + bridge.cancel_plugin_capture(descriptor.entry_path); + error = "Failed to load plugin module: " + load_error; + return false; + } + + // From here on the module reference is owned by `plugin`: every failure path below returns and + // lets ~Plugin release it. + plugin.module = module; + + // finalize_plugin_capture runs the module's @orca.plugin package class register_capabilities() + // (while the active plugin key is set), then instantiates each registered capability and caches + // its get_name(). Returns one entry per capability. + std::string bridge_error; + auto capabilities_found = bridge.finalize_plugin_capture(descriptor.entry_path, bridge_error); + if (!bridge_error.empty()) { + capabilities_found.clear(); + error = "Plugin registration failed: " + bridge_error; + return false; + } + if (capabilities_found.empty()) { + error = "Plugin module did not register any capabilities"; + return false; + } + + plugin.descriptor.clear_error(); + + // The user's per-capability enable choices live in the sidecar — the only durable record, since + // a capability has no state (and no existence) while the package is not loaded. + PluginInstallState install_state; + const bool have_install_state = !descriptor.plugin_root.empty() && + read_install_state(boost::filesystem::path(descriptor.plugin_root), install_state); + + // get_name()/get_type() are read exactly once — here, under the GIL this block already holds — + // and cached on the capability, which is the only place capability state lives. + std::unordered_map> seen_capabilities; + std::vector> capabilities; + capabilities.reserve(capabilities_found.size()); + std::string materialization_error; + { - std::lock_guard lock(m_mutex); - cancelled = cancel_plugin_load_locked(plugin_key); + PythonGILState gil; + if (!gil) { + materialization_error = "Python interpreter is shutting down"; + } else { + try { + for (auto& found : capabilities_found) { + if (!found.instance) { + materialization_error = "Plugin capability instance is null"; + break; + } + + std::shared_ptr instance = std::move(found.instance); + const PluginCapabilityType type = instance->get_type(); + + // An empty request restores the sidecar state (or enables capabilities by + // default). An explicit request overrides the sidecar for that capability; + // all other capabilities retain their persisted state. + const bool explicitly_requested = + std::find(capabilities_to_enable.begin(), capabilities_to_enable.end(), found.name) != + capabilities_to_enable.end(); + bool enabled = capabilities_to_enable.empty() || explicitly_requested; + if (have_install_state && !explicitly_requested) { + for (const auto& [cap_name, cap_enabled] : install_state.capabilities) { + if (cap_name == found.name) { + enabled = cap_enabled; + break; + } + } + } + + if (!seen_capabilities[type].insert(found.name).second) { + materialization_error = "Plugin declares duplicate capability '" + found.name + "' for type " + + plugin_capability_type_to_string(type); + break; + } + + instance->set_audit_plugin_key(descriptor.plugin_key); + instance->set_resolved_identity(found.name, type); + instance->set_enabled(enabled); + + capabilities.push_back(std::move(instance)); + } + } catch (const std::exception& ex) { + materialization_error = std::string("Plugin capability materialization failed: ") + ex.what(); + } catch (...) { + materialization_error = "Plugin capability materialization failed"; + } + } + + if (!materialization_error.empty()) { + capabilities.clear(); + capabilities_found.clear(); + } + } + if (!materialization_error.empty()) { + error = std::move(materialization_error); + return false; + } + capabilities_found.clear(); + + plugin.capabilities = std::move(capabilities); + + // Let the caller reject the load against its registry BEFORE on_load() runs, so a duplicate + // package or a capability-name collision costs no on_load/on_unload cycle. + if (registry_precheck) { + std::string precheck_error = registry_precheck(plugin); + if (!precheck_error.empty()) { + teardown_capabilities(plugin.capabilities, 0); + error = std::move(precheck_error); + return false; + } } - notify_plugin_load_state_changed(cancelled); - return cancelled; -} - -bool PluginLoader::cancel_plugin_unload(const std::string& plugin_key) -{ - BOOST_LOG_TRIVIAL(debug) << "Plugin unload cancellation requested but unload is synchronous: " << plugin_key; - return false; -} - -bool PluginLoader::cancel_plugin_load_locked(const std::string& plugin_key) -{ - const auto in_progress_it = m_plugin_load_in_progress.find(plugin_key); - const bool cancelled = in_progress_it != m_plugin_load_in_progress.end(); - if (cancelled) { - m_plugin_load_in_progress.erase(in_progress_it); - m_plugin_load_errors.erase(plugin_key); + // lifecycle_count is incremented BEFORE on_load(), so a capability whose on_load() throws + // still gets its on_unload(). + std::size_t lifecycle_count = 0; + try { + PythonGILState gil; + if (!gil) + throw std::runtime_error("Python interpreter is shutting down"); + for (const auto& capability : plugin.capabilities) { + ++lifecycle_count; + capability->on_load(); + } + } catch (const std::exception& ex) { + teardown_capabilities(plugin.capabilities, lifecycle_count); + error = std::string("Plugin on_load failed: ") + ex.what(); + return false; + } catch (...) { + teardown_capabilities(plugin.capabilities, lifecycle_count); + error = "Plugin on_load failed"; + return false; } - return cancelled; + out = std::move(plugin); + + BOOST_LOG_TRIVIAL(info) << "[plugin_loader::load] SUCCESS plugin=" << descriptor.plugin_key + << " thread=" << std::this_thread::get_id(); + return true; } -bool PluginLoader::is_plugin_load_cancelled_locked(const std::string& plugin_key) const -{ - return m_plugin_load_in_progress.count(plugin_key) == 0; -} - -void PluginLoader::notify_plugin_load_state_changed(bool changed) -{ - if (changed) - m_plugin_load_cv.notify_all(); -} - -bool PluginLoader::install_packages(const std::vector& pkgs, std::string& error) const +bool install_packages(const std::vector& pkgs, std::string& error) { if (pkgs.empty()) return true; @@ -446,21 +439,22 @@ bool PluginLoader::install_packages(const std::vector& pkgs, std::s std::vector args = {"pip", "install", "--python", python_executable, "--no-python-downloads", "--target", target_dir}; args.insert(args.end(), pkgs.begin(), pkgs.end()); - BOOST_LOG_TRIVIAL(info) << "Installing Python packages via uv for " << python_executable << ": " << boost::algorithm::join(pkgs, ", "); + BOOST_LOG_TRIVIAL(info) << "Installing Python packages via uv for " << python_executable << ": " + << boost::algorithm::join(pkgs, ", "); try { namespace process = boost::process; process::ipstream std_err; - process::child child(uv_path, process::args(args), + process::child child(uv_path, process::args(args), #ifdef _WIN32 - // uv.exe (and the python.exe it spawns) are console-subsystem programs. - // OrcaSlicer is a GUI app with no console of its own, so without this flag - // Windows allocates a fresh console window for the child that flashes on - // screen during startup plugin loading. Matches ProcessRunner/MediaPlayCtrl. - process::windows::create_no_window, + // uv.exe (and the python.exe it spawns) are console-subsystem programs. + // OrcaSlicer is a GUI app with no console of its own, so without this flag + // Windows allocates a fresh console window for the child that flashes on + // screen during startup plugin loading. Matches ProcessRunner/MediaPlayCtrl. + process::windows::create_no_window, #endif - process::std_err > std_err); + process::std_err > std_err); std::string err_output; std::thread stderr_reader([&std_err, &err_output]() { @@ -525,641 +519,10 @@ bool PluginLoader::install_packages(const std::vector& pkgs, std::s } } -void PluginLoader::unload_all_plugins() -{ - std::vector>>> removed; - std::vector> orphaned_capabilities; - { - std::lock_guard lock(m_mutex); - removed.reserve(m_plugins.size()); - for (auto& [key, loaded] : m_plugins) { - (void) key; - auto capabilities = extract_plugin_capabilities_locked(loaded); - removed.emplace_back(std::move(loaded), std::move(capabilities)); - } - m_plugins.clear(); - - // Preserve teardown safety even if a prior invariant violation left an - // unreferenced registry entry behind. - for (auto& [capability_type, capabilities] : m_plugin_capabilities) { - (void) capability_type; - for (auto& [id, capability] : capabilities) { - (void) id; - orphaned_capabilities.push_back(std::move(capability)); - } - } - m_plugin_capabilities.clear(); - } - - for (auto& [loaded, capabilities] : removed) { - (void) loaded; - teardown_capabilities(capabilities, capabilities.size()); - } - teardown_capabilities(orphaned_capabilities, orphaned_capabilities.size()); -} - -bool PluginLoader::unload_plugin(const std::string& plugin_key, PluginCapabilityType type) -{ - auto notify_unload_complete = [this, &plugin_key]() { - if (!m_shutting_down.load(std::memory_order_acquire)) - run_on_unload_callbacks(plugin_key); - }; - - std::optional removed; - std::vector> removed_capabilities; - bool cancelled = false; - - { - std::lock_guard lock(m_mutex); - - // Cancel in-progress load for this plugin so load_plugin_impl discards the result. - cancelled = cancel_plugin_load_locked(plugin_key); - - auto map_it = m_plugins.find(plugin_key); - if (map_it != m_plugins.end()) { - removed_capabilities = extract_plugin_capabilities_locked(map_it->second); - removed.emplace(std::move(map_it->second)); - m_plugins.erase(map_it); - } - } - - if (!removed) { - notify_plugin_load_state_changed(cancelled); - notify_unload_complete(); - return true; - } - - std::vector teardown_types; - teardown_types.reserve(removed->capabilities.size()); - for (const PluginCapabilityIdentifier& id : removed->capabilities) - teardown_types.push_back(id.type); - if (teardown_types.empty()) - teardown_types.push_back(type); - - teardown_capabilities(removed_capabilities, removed_capabilities.size()); - removed.reset(); - notify_plugin_load_state_changed(cancelled); - - // The .install_state.json sidecar is flipped to enabled=false by the on-unload - // callback (skipped during shutdown), so the auto-load list survives app exit. - - BOOST_LOG_TRIVIAL(info) << "Unloaded plugin: " << plugin_key; - - std::unordered_set torn_down_types; - for (const PluginCapabilityType cap_type : teardown_types) { - if (!torn_down_types.insert(cap_type).second) - continue; - switch (cap_type) { - case PluginCapabilityType::PrinterConnection: NetworkAgentFactory::deregister_python_plugin(plugin_key); break; - default: break; - } - } - - notify_unload_complete(); - - return true; -} - -bool PluginLoader::unload_plugin(const std::string& plugin_key) -{ - PluginCapabilityType type = PluginCapabilityType::Unknown; - bool found = false; - bool cancelled = false; - { - std::lock_guard lock(m_mutex); - cancelled = cancel_plugin_load_locked(plugin_key); - - const auto map_it = m_plugins.find(plugin_key); - if (map_it != m_plugins.end()) { - type = map_it->second.descriptor.primary_capability_type(); - found = true; - } - } - - notify_plugin_load_state_changed(cancelled); - - if (found) - return unload_plugin(plugin_key, type); - - if (!m_shutting_down.load(std::memory_order_acquire)) - run_on_unload_callbacks(plugin_key); - - return true; -} - -void PluginLoader::load_plugin(PluginCatalog& catalog, const std::string& plugin_key, bool skip_deps, std::vector capabilities_to_enable) -{ - std::string plugin_id = plugin_key; - PluginDescriptor resolved_descriptor; - if (catalog.try_get_valid_plugin_descriptor(plugin_key, resolved_descriptor)) - plugin_id = resolved_descriptor.plugin_key; - - bool already_loaded = false; - bool load_in_progress = false; - - { - std::lock_guard lock(m_mutex); - - already_loaded = m_plugins.count(plugin_id) > 0; - - if (m_plugin_load_in_progress.count(plugin_id)) - load_in_progress = true; - } - - if (already_loaded) { - // The plugin is already loaded, but the caller may be asking us to enable capabilities that - // are currently disabled (e.g. resolving an inactive-plugin reference). The load path below - // is skipped for an already-loaded plugin, so honor the request here. Runs outside m_mutex - // (released above); enable_capability takes the lock itself. - for (const auto& cap : get_loaded_plugin_capabilities(plugin_id)) { - if (!cap) - continue; - - const bool enable_all = capabilities_to_enable.empty(); - const bool enable_requested = std::find(capabilities_to_enable.begin(), capabilities_to_enable.end(), cap->name) != - capabilities_to_enable.end(); - - if (enable_all || enable_requested) - enable_capability(plugin_id, cap->name, cap->type); - } - - run_on_load_callbacks(plugin_id); - return; - } - - if (load_in_progress) - return; - - if (m_shutting_down.load(std::memory_order_acquire)) { - BOOST_LOG_TRIVIAL(info) << "Plugin load rejected — shutting down: " << plugin_id; - run_on_load_callbacks(plugin_id); - return; - } - - if (!catalog.has_valid_plugin_descriptor(plugin_id)) { - PluginDescriptor invalid; - std::string message; - if (catalog.try_get_invalid_plugin_descriptor(plugin_id, invalid)) { - message = "Plugin is invalid: " + plugin_id; - if (invalid.has_error()) - message += " - " + invalid.normalized_error(); - } else { - message = "Plugin not found: " + plugin_id; - } - { - std::lock_guard lock(m_mutex); - m_plugin_load_errors[plugin_id] = message; - BOOST_LOG_TRIVIAL(error) << message; - } - if (!invalid.plugin_key.empty()) - catalog.set_plugin_error(plugin_id, message); - run_on_load_callbacks(plugin_id); - return; - } - - { - std::lock_guard lock(m_mutex); - m_plugin_load_in_progress.insert(plugin_id); - m_plugin_load_errors.erase(plugin_id); - } - - catalog.clear_plugin_error(plugin_id); - - std::thread([this, &catalog, plugin_id, skip_deps, capabilities_to_enable]() { - auto fail_unexpected = [this, &catalog, &plugin_id](std::string message) { - BOOST_LOG_TRIVIAL(error) << "[load_plugin] Unexpected worker failure plugin=" << plugin_id << " error=" << message; - catalog.set_plugin_error(plugin_id, message); - bool load_state_changed = false; - { - std::lock_guard lock(m_mutex); - m_plugin_load_errors[plugin_id] = std::move(message); - load_state_changed = m_plugin_load_in_progress.erase(plugin_id) > 0; - } - notify_plugin_load_state_changed(load_state_changed); - }; - - try { - load_plugin_impl(catalog, plugin_id, skip_deps, capabilities_to_enable); - } catch (const std::exception& ex) { - fail_unexpected(std::string("Unexpected plugin load failure: ") + ex.what()); - } catch (...) { - fail_unexpected("Unexpected plugin load failure"); - } - - if (!m_shutting_down.load(std::memory_order_acquire)) - run_on_load_callbacks(plugin_id); - }).detach(); -} - -void PluginLoader::enable_capability(const std::string& plugin_key, const std::string& capability_name, PluginCapabilityType type) -{ - std::optional changed; - { - std::lock_guard lock(m_mutex); - auto type_it = m_plugin_capabilities.find(type); - if (type_it != m_plugin_capabilities.end()) { - const PluginCapabilityIdentifier id{type, capability_name, plugin_key}; - auto cap_it = type_it->second.find(id); - if (cap_it != type_it->second.end() && cap_it->second && !cap_it->second->enabled) { - cap_it->second->enabled = true; - changed = id; - } - } - } - - if (!changed) - return; - - write_loaded_plugin_install_state(plugin_key); - run_on_capability_load_callbacks(*changed); -} - -void PluginLoader::disable_capability(const std::string& plugin_key, const std::string& capability_name, PluginCapabilityType type) -{ - std::optional changed; - { - std::lock_guard lock(m_mutex); - auto type_it = m_plugin_capabilities.find(type); - if (type_it != m_plugin_capabilities.end()) { - const PluginCapabilityIdentifier id{type, capability_name, plugin_key}; - auto cap_it = type_it->second.find(id); - if (cap_it != type_it->second.end() && cap_it->second && cap_it->second->enabled) { - cap_it->second->enabled = false; - changed = id; - } - } - } - - if (!changed) - return; - - write_loaded_plugin_install_state(plugin_key); - run_on_capability_unload_callbacks(*changed); -} - -void PluginLoader::load_plugin_impl(PluginCatalog& catalog, const std::string& plugin_key, bool skip_deps, std::vector capabilities_to_enable) -{ - BOOST_LOG_TRIVIAL(info) << "[load_plugin_impl] START plugin=" << plugin_key << " thread=" << std::this_thread::get_id(); - - auto fail = [this, &catalog, &plugin_key](std::string message) { - BOOST_LOG_TRIVIAL(error) << "[load_plugin_impl] FAIL plugin=" << plugin_key << " error=" << message; - catalog.set_plugin_error(plugin_key, message); - bool load_state_changed = false; - { - std::lock_guard lock(m_mutex); - m_plugin_load_errors[plugin_key] = std::move(message); - load_state_changed = m_plugin_load_in_progress.erase(plugin_key) > 0; - BOOST_LOG_TRIVIAL(error) << m_plugin_load_errors[plugin_key]; - } - notify_plugin_load_state_changed(load_state_changed); - }; - - PythonInterpreter& interpreter = PythonInterpreter::instance(); - if (!interpreter.is_initialized()) { - fail("Python interpreter not initialized: " + interpreter.last_error()); - return; - } - - PluginDescriptor descriptor; - if (!catalog.try_get_plugin_descriptor(plugin_key, descriptor)) { - fail("Plugin manifest not found: " + plugin_key); - return; - } - if (!descriptor.is_metadata_valid()) { - std::string error = "Plugin manifest is invalid: " + plugin_key; - if (descriptor.has_error()) - error += " - " + descriptor.normalized_error(); - fail(std::move(error)); - return; - } - - // Serialize the entire load sequence — only one thread may run it at a time. This - // prevents sys.path / sys.modules races when two plugins share the same entry-point - // filename (e.g. plugin.py), and keeps the capability-registry check-then-commit below - // free of an interleaved load (which could otherwise force a wasted on_load/on_unload - // rollback). install_packages() has a 120s timeout so this cannot block indefinitely. - // Held until the load completes; released by load_lock's destructor. - static std::mutex load_serializer; - std::lock_guard load_lock(load_serializer); - - if (!skip_deps) { - std::string pkg_install_error; - if (!install_packages(descriptor.dependencies, pkg_install_error)) { - fail("Failed to install plugin dependencies: " + pkg_install_error); - return; - } - } - - PythonPluginBridge& bridge = PythonPluginBridge::instance(); - bridge.begin_plugin_capture(descriptor.entry_path); - - // Extract any .whl dependency files in the plugin directory. - namespace fs = boost::filesystem; - { - fs::path entry_path(descriptor.entry_path); - fs::path plugin_dir = entry_path.has_extension() ? entry_path.parent_path() : entry_path; - if (fs::exists(plugin_dir) && fs::is_directory(plugin_dir)) { - PythonInterpreter& interp = PythonInterpreter::instance(); - for (fs::directory_iterator it(plugin_dir); it != fs::directory_iterator(); ++it) { - if (!fs::is_regular_file(it->status()) || it->path().extension() != ".whl") - continue; - // Skip the entry file itself - it's loaded by its own path below. - if (it->path() == fs::path(descriptor.entry_path)) - continue; - - std::string dep_error; - fs::path dep_dir = plugin_dir / "__whl_extracted__" / it->path().stem().string(); - if (!fs::exists(dep_dir)) { - if (!extract_zip_to_directory(it->path(), dep_dir, dep_error)) { - fail("Failed to extract plugin .whl dependency " + it->path().string() + ": " + dep_error); - bridge.cancel_plugin_capture(descriptor.entry_path); - return; - } - } - - std::string syspath_error; - if (!interp.add_sys_path(dep_dir.string(), syspath_error)) { - fail("Failed to add .whl dependency to sys.path: " + syspath_error); - bridge.cancel_plugin_capture(descriptor.entry_path); - return; - } - } - } - } - - std::string load_error; - PyObject* module = nullptr; - { - PythonInterpreter& interp = PythonInterpreter::instance(); - if (!descriptor.entry_package.empty()) { - fs::path entry_path(descriptor.entry_path); - if (entry_path.extension() == ".whl") { - module = interp.load_module_from_whl(descriptor.entry_path, descriptor.entry_package, load_error); - } else { - module = interp.load_module_from_directory(descriptor.entry_path, descriptor.entry_package, load_error); - } - } else { - module = interp.load_module_from_file(descriptor.entry_path, load_error); - } - } - if (!module) { - fail("Failed to load plugin module: " + load_error); - bridge.cancel_plugin_capture(descriptor.entry_path); - return; - } - - LoadedPlugin loaded; - loaded.module = module; - - std::string bridge_error; - // finalize_plugin_capture runs the module's @orca.plugin package class register_capabilities() - // (while g_active_plugin_key is set), then instantiates each registered capability and - // caches its get_name(). Returns one entry per capability. - auto capabilities_found = bridge.finalize_plugin_capture(descriptor.entry_path, bridge_error); - if (!bridge_error.empty()) { - PythonGILState gil; - capabilities_found.clear(); - fail("Plugin registration failed: " + bridge_error); - return; - } - - if (capabilities_found.empty()) { - fail("Plugin module did not register any capabilities"); - return; - } - - descriptor.clear_error(); - loaded.capabilities.reserve(capabilities_found.size()); - - // Per-capability enabled state comes from the plugin's cached install-state sidecar. - // Capabilities not listed default to enabled. Matched by name within the plugin. - PluginInstallState install_state; - const bool have_install_state = catalog.try_get_install_state(plugin_key, install_state); - - std::unordered_map> seen_capabilities; - std::vector> capabilities; - capabilities.reserve(capabilities_found.size()); - std::vector capability_types; - capability_types.reserve(capabilities_found.size()); - std::string materialization_error; - - { - PythonGILState gil; - try { - for (auto& cap : capabilities_found) { - if (!cap.instance) { - materialization_error = "Plugin capability instance is null"; - break; - } - - auto loaded_cap = std::make_shared(); - loaded_cap->instance = std::move(cap.instance); - loaded_cap->name = cap.name; - loaded_cap->plugin_key = descriptor.plugin_key; - loaded_cap->type = loaded_cap->instance->get_type(); - - const PluginCapabilityIdentifier capability_id{ - loaded_cap->type, loaded_cap->name, loaded_cap->plugin_key}; - bool cap_enabled = capabilities_to_enable.empty() ? true : std::find_if(capabilities_to_enable.begin(), capabilities_to_enable.end(), - [cap](const std::string& val) { return val == cap.name; }) != capabilities_to_enable.end(); - if (have_install_state) { - for (const auto& [cap_name, cap_state] : install_state.capabilities) { - if (cap_name == loaded_cap->name) { - cap_enabled = cap_state; - break; - } - } - } - loaded_cap->enabled = cap_enabled; - - if (!seen_capabilities[loaded_cap->type].insert(loaded_cap->name).second) { - materialization_error = "Plugin declares duplicate capability '" + loaded_cap->name + - "' for type " + plugin_capability_type_to_string(loaded_cap->type); - break; - } - - loaded_cap->instance->set_audit_plugin_key(descriptor.plugin_key); - capability_types.push_back(loaded_cap->type); - loaded.capabilities.push_back(capability_id); - capabilities.emplace_back(std::move(loaded_cap)); - } - } catch (const std::exception& ex) { - materialization_error = std::string("Plugin capability materialization failed: ") + ex.what(); - } catch (...) { - materialization_error = "Plugin capability materialization failed"; - } - - if (!materialization_error.empty()) { - capabilities.clear(); - capabilities_found.clear(); - } - } - if (!materialization_error.empty()) { - fail(std::move(materialization_error)); - return; - } - capabilities_found.clear(); - - descriptor.capability_types = capability_types; - loaded.descriptor = descriptor; - - bool cancelled = false; - std::string registry_error; - { - std::lock_guard lock(m_mutex); - cancelled = is_plugin_load_cancelled_locked(plugin_key); - if (!cancelled) { - const auto package_it = m_plugins.find(descriptor.plugin_key); - if (package_it != m_plugins.end()) - registry_error = "Plugin package is already loaded: " + descriptor.plugin_key; - } - if (!cancelled && registry_error.empty()) { - for (const auto& capability : capabilities) { - auto type_it = m_plugin_capabilities.find(capability->type); - const PluginCapabilityIdentifier cap_id{capability->type, capability->name, capability->plugin_key}; - if (type_it != m_plugin_capabilities.end() && type_it->second.count(cap_id) > 0) { - registry_error = "Capability collision for type " + plugin_capability_type_to_string(capability->type) + - " and name '" + capability->name + "'"; - break; - } - } - } - } - if (cancelled || !registry_error.empty()) { - teardown_capabilities(capabilities, 0); - if (!registry_error.empty()) - fail(std::move(registry_error)); - return; - } - - std::size_t lifecycle_count = 0; - try { - PythonGILState gil; - for (const auto& capability : capabilities) { - ++lifecycle_count; - capability->instance->on_load(); - } - } catch (const std::exception& ex) { - teardown_capabilities(capabilities, lifecycle_count); - fail(std::string("Plugin on_load failed: ") + ex.what()); - return; - } catch (...) { - teardown_capabilities(capabilities, lifecycle_count); - fail("Plugin on_load failed"); - return; - } - - bool committed = false; - cancelled = false; - std::size_t inserted_capability_count = 0; - { - std::lock_guard lock(m_mutex); - - cancelled = is_plugin_load_cancelled_locked(plugin_key); - if (!cancelled) { - const auto package_it = m_plugins.find(descriptor.plugin_key); - if (package_it != m_plugins.end()) - registry_error = "Plugin package is already loaded: " + descriptor.plugin_key; - } - - if (!cancelled && registry_error.empty()) { - for (const auto& capability : capabilities) { - auto type_it = m_plugin_capabilities.find(capability->type); - const PluginCapabilityIdentifier cap_id{capability->type, capability->name, capability->plugin_key}; - if (type_it != m_plugin_capabilities.end() && type_it->second.count(cap_id) > 0) { - registry_error = "Capability collision for type " + plugin_capability_type_to_string(capability->type) + - " and name '" + capability->name + "'"; - break; - } - } - } - - if (!cancelled && registry_error.empty()) { - try { - for (const auto& capability : capabilities) { - auto [type_it, type_inserted] = m_plugin_capabilities.try_emplace(capability->type); - (void) type_inserted; - auto [capability_it, capability_inserted] = - type_it->second.try_emplace( - PluginCapabilityIdentifier{capability->type, capability->name, capability->plugin_key}, - capability); - (void) capability_it; - if (!capability_inserted) { - registry_error = "Capability collision for type " + plugin_capability_type_to_string(capability->type) + - " and name '" + capability->name + "'"; - break; - } - ++inserted_capability_count; - } - - if (registry_error.empty()) { - auto [plugin_it, plugin_inserted] = m_plugins.try_emplace(descriptor.plugin_key, std::move(loaded)); - (void) plugin_it; - if (!plugin_inserted) - registry_error = "Plugin package is already loaded: " + descriptor.plugin_key; - else - committed = true; - } - } catch (const std::exception& ex) { - registry_error = std::string("Failed to register plugin capabilities: ") + ex.what(); - } catch (...) { - registry_error = "Failed to register plugin capabilities"; - } - } - - if (!committed) { - for (std::size_t index = 0; index < inserted_capability_count; ++index) { - const auto& capability = capabilities[index]; - auto type_it = m_plugin_capabilities.find(capability->type); - if (type_it == m_plugin_capabilities.end()) - continue; - type_it->second.erase( - PluginCapabilityIdentifier{capability->type, capability->name, capability->plugin_key}); - if (type_it->second.empty()) - m_plugin_capabilities.erase(type_it); - } - for (auto type_it = m_plugin_capabilities.begin(); type_it != m_plugin_capabilities.end();) { - if (type_it->second.empty()) - type_it = m_plugin_capabilities.erase(type_it); - else - ++type_it; - } - } - } - - if (!committed) { - teardown_capabilities(capabilities, lifecycle_count); - if (!cancelled) - fail(std::move(registry_error)); - return; - } - - catalog.clear_plugin_error(plugin_key); - - bool load_state_changed = false; - - // The enabled plugin is persisted to its .install_state.json sidecar by the on-load - // callback (subscribe_on_load_callback → write_loaded_plugin_install_state). - { - std::lock_guard lock(m_mutex); - - // unload_plugin() may cancel the load after the plugin was materialized - // but before this worker reaches the registry/bookkeeping update. - if (is_plugin_load_cancelled_locked(plugin_key)) - return; - - load_state_changed = m_plugin_load_in_progress.erase(plugin_key) > 0; - m_plugin_load_errors.erase(plugin_key); - } - notify_plugin_load_state_changed(load_state_changed); - - BOOST_LOG_TRIVIAL(info) << "[load_plugin_impl] SUCCESS plugin=" << plugin_key << " thread=" << std::this_thread::get_id(); -} - -bool PluginLoader::inspect_local_plugin_package(const boost::filesystem::path& filepath, - PluginDescriptor& plugin_descriptor, - bool& existing_installation, - std::string& error) const +bool inspect_local_plugin_package(const boost::filesystem::path& filepath, + PluginDescriptor& plugin_descriptor, + bool& existing_installation, + std::string& error) { namespace fs = boost::filesystem; @@ -1173,7 +536,7 @@ bool PluginLoader::inspect_local_plugin_package(const boost::filesystem::path& f }; boost::system::error_code ec; - const fs::path source_path = canonical_or_absolute(filepath); + const fs::path source_path = canonical_or_absolute(filepath); if (!fs::exists(source_path, ec) || !fs::is_regular_file(source_path, ec)) return fail("Plugin package is not a file: " + filepath.string()); @@ -1196,21 +559,24 @@ bool PluginLoader::inspect_local_plugin_package(const boost::filesystem::path& f return true; } -bool PluginLoader::install_plugin(const boost::filesystem::path& filepath, std::string& error) +bool install_plugin(const boost::filesystem::path& filepath, const std::string& cloud_user_id, std::string& error) { PluginDescriptor descriptor{}; - return install_plugin(filepath, descriptor, error); + return install_plugin(filepath, cloud_user_id, descriptor, error); } -bool PluginLoader::install_plugin(const boost::filesystem::path& filepath, PluginDescriptor& plugin_descriptor, std::string& error) +bool install_plugin(const boost::filesystem::path& filepath, + const std::string& cloud_user_id, + PluginDescriptor& plugin_descriptor, + std::string& error) { namespace fs = boost::filesystem; - const std::string cloud_uuid = plugin_descriptor.cloud_uuid(); + const std::string cloud_uuid = plugin_descriptor.cloud_uuid(); const bool is_cloud_install = !cloud_uuid.empty(); fs::path backup_dir; - bool backup_created = false; + bool backup_created = false; auto fail = [&error](const std::string& message) { error = "Plugin installation failed: " + message; @@ -1218,7 +584,7 @@ bool PluginLoader::install_plugin(const boost::filesystem::path& filepath, Plugi }; boost::system::error_code ec; - const fs::path source_path = canonical_or_absolute(filepath); + const fs::path source_path = canonical_or_absolute(filepath); if (!fs::exists(source_path, ec) || !fs::is_regular_file(source_path, ec)) return fail("Plugin package is not a file: " + filepath.string()); @@ -1226,16 +592,14 @@ bool PluginLoader::install_plugin(const boost::filesystem::path& filepath, Plugi const std::string ext = plugin_package_extension(source_path); if (ext != ".py" && ext != ".whl") return fail("Plugin package must be a .py or .whl file, got: " + ext); - if (is_cloud_install && cloud_uuid.empty()) - return fail("Cloud plugin descriptor is missing UUID"); // The cloud UUID is concatenated into the install path (final_dir = plugin_root / cloud_uuid). // It comes verbatim from the server's catalog JSON, so reject anything that could escape the // per-user plugin root (traversal, leading dots, separators) before touching the filesystem. if (is_cloud_install && !is_valid_plugin_id(cloud_uuid)) return fail("Cloud plugin UUID is not a valid identifier: " + cloud_uuid); - const fs::path plugin_root = is_cloud_install && !m_cloud_user_id.empty() ? fs::path(get_cloud_plugin_dir(m_cloud_user_id)) : - local_plugin_root(); + const fs::path plugin_root = is_cloud_install && !cloud_user_id.empty() ? fs::path(get_cloud_plugin_dir(cloud_user_id)) : + local_plugin_root(); fs::create_directories(plugin_root, ec); if (ec) return fail("Failed to create plugin directory " + plugin_root.string() + ": " + ec.message()); @@ -1259,23 +623,21 @@ bool PluginLoader::install_plugin(const boost::filesystem::path& filepath, Plugi return fail(meta_error); // A side-loaded .py with no (or incomplete) PEP 723 block parses as success but has no // usable identity: an empty name collides in local_plugin_install_dir() (every nameless - // plugin maps to orca_plugins/"path"), so - // it installs but never loads. Wheels are exempt: read_wheel_plugin_metadata() already - // requires a Name and may legitimately leave type == Unknown. + // plugin maps to orca_plugins/"path"), so it installs but never loads. Wheels are exempt: + // read_wheel_plugin_metadata() already requires a Name and may legitimately leave the type + // Unknown. if (ext == ".py" && plugin_descriptor.name.empty()) return fail("Side-loaded .py plugin is missing required PEP 723 metadata: 'name' is required"); } - const fs::path final_dir = is_cloud_install ? plugin_root / cloud_uuid : - local_plugin_install_dir(source_path); + const fs::path final_dir = is_cloud_install ? plugin_root / cloud_uuid : local_plugin_install_dir(source_path); const fs::path dest_file = final_dir / source_path.filename(); // Local key is the plugin file stem; cloud key is the cloud UUID. - if (is_cloud_install) { + if (is_cloud_install) plugin_descriptor.plugin_key = cloud_uuid; - } else { + else assign_local_plugin_key(plugin_descriptor, source_path); - } // Backup existing installation if present. if (fs::exists(final_dir, ec)) { @@ -1308,7 +670,7 @@ bool PluginLoader::install_plugin(const boost::filesystem::path& filepath, Plugi // Update entry_path to the installed location. plugin_descriptor.plugin_root = final_dir.string(); - plugin_descriptor.entry_path = dest_file.string(); + plugin_descriptor.entry_path = dest_file.string(); plugin_descriptor.set_metadata_valid(true); plugin_descriptor.clear_error(); @@ -1319,11 +681,10 @@ bool PluginLoader::install_plugin(const boost::filesystem::path& filepath, Plugi installed_entry.cloud = CloudPluginState{cloud_uuid, true, false, false, plugin_descriptor.cloud->is_mine}; if (!write_install_state(final_dir, installed_entry)) { // Roll back: remove new files and restore backup. - boost::system::error_code ec; - fs::remove_all(final_dir, ec); - if (backup_created) { - fs::rename(backup_dir, final_dir, ec); - } + boost::system::error_code rollback_ec; + fs::remove_all(final_dir, rollback_ec); + if (backup_created) + fs::rename(backup_dir, final_dir, rollback_ec); return fail("Failed to write plugin install state: " + (final_dir / INSTALL_STATE_FILE).string()); } } @@ -1339,174 +700,9 @@ bool PluginLoader::install_plugin(const boost::filesystem::path& filepath, Plugi if (is_cloud_install) { boost::system::error_code remove_ec; - boost::filesystem::remove(source_path, remove_ec); + fs::remove(source_path, remove_ec); } return true; } -void PluginLoader::clear_loaded_plugin_cloud_state(const std::string& plugin_key) -{ - std::lock_guard lock(m_mutex); - const auto it = m_plugins.find(plugin_key); - if (it != m_plugins.end()) - it->second.descriptor.cloud.reset(); -} - -void PluginLoader::update_loaded_plugin_key(const std::string& old_key, const std::string& new_key) -{ - std::vector unloaded_capability_ids; - std::vector loaded_capability_ids; - { - std::lock_guard lock(m_mutex); - - auto it = m_plugins.find(old_key); - if (it == m_plugins.end()) - return; - - const auto collision = m_plugins.find(new_key); - if (collision != m_plugins.end() && collision != it) { - BOOST_LOG_TRIVIAL(warning) << "Cannot update loaded plugin key to existing package key: " << new_key; - return; - } - - auto node = m_plugins.extract(it); - const std::string original_map_key = node.key(); - node.key() = new_key; - auto insertion = m_plugins.insert(std::move(node)); - if (!insertion.inserted) { - insertion.node.key() = original_map_key; - m_plugins.insert(std::move(insertion.node)); - BOOST_LOG_TRIVIAL(warning) << "Cannot update loaded plugin key to existing package key: " << new_key; - return; - } - - LoadedPlugin& loaded = insertion.position->second; - loaded.descriptor.plugin_key = new_key; - loaded.descriptor.cloud.reset(); - for (PluginCapabilityIdentifier& id : loaded.capabilities) { - const PluginCapabilityIdentifier old_id = id; - const PluginCapabilityIdentifier new_id{id.type, id.name, new_key}; - auto type_it = m_plugin_capabilities.find(id.type); - if (type_it != m_plugin_capabilities.end()) { - auto capability_node = type_it->second.extract(id); - if (!capability_node.empty() && capability_node.mapped()) { - const bool enabled = capability_node.mapped()->enabled; - capability_node.mapped()->plugin_key = new_key; - if (capability_node.mapped()->instance) - capability_node.mapped()->instance->set_audit_plugin_key(new_key); - capability_node.key() = new_id; - type_it->second.insert(std::move(capability_node)); - if (enabled) { - unloaded_capability_ids.push_back(old_id); - loaded_capability_ids.push_back(new_id); - } - } - } - id = new_id; - } - } - - // Subscribers may key their own state by plugin_key. Publish the identity transition - // after the registry update and outside m_mutex so they can safely query the loader. - for (const PluginCapabilityIdentifier& id : unloaded_capability_ids) - run_on_capability_unload_callbacks(id); - for (const PluginCapabilityIdentifier& id : loaded_capability_ids) - run_on_capability_load_callbacks(id); -} - -void PluginLoader::unload_cloud_plugins() -{ - std::vector> plugins_to_unload; - - { - std::lock_guard lock(m_mutex); - for (auto& [key, loaded] : m_plugins) { - if (loaded.descriptor.is_cloud_plugin()) - plugins_to_unload.emplace_back(loaded.descriptor.primary_capability_type(), loaded.descriptor.plugin_key); - } - } - - // Release m_mutex before unloading: unload_plugin() re-acquires it, and runs - // plugin teardown + unload callbacks that can re-enter the loader. - for (auto& [type, key] : plugins_to_unload) { - unload_plugin(key, type); - } -} - -void PluginLoader::run_on_load_callbacks(const std::string& plugin_key) -{ - for (auto& fn : m_callbacks[CallbackType::Load]) { - try { - fn(plugin_key); - } catch (const std::exception& ex) { - BOOST_LOG_TRIVIAL(error) << "Plugin load completion callback failed for " << plugin_key << ": " << ex.what(); - } catch (...) { - BOOST_LOG_TRIVIAL(error) << "Plugin load completion callback failed for " << plugin_key; - } - } -} - -void PluginLoader::run_on_unload_callbacks(const std::string& plugin_key) -{ - for (auto& fn : m_callbacks[CallbackType::Unload]) { - try { - fn(plugin_key); - } catch (const std::exception& ex) { - BOOST_LOG_TRIVIAL(error) << "Plugin unload completion callback failed for " << plugin_key << ": " << ex.what(); - } catch (...) { - BOOST_LOG_TRIVIAL(error) << "Plugin unload completion callback failed for " << plugin_key << ": unknown error"; - } - } -} - -void PluginLoader::run_on_capability_load_callbacks(const PluginCapabilityIdentifier& id) -{ - for (auto& fn : m_capability_callbacks[CallbackType::Load]) { - try { - fn(id); - } catch (const std::exception& ex) { - BOOST_LOG_TRIVIAL(error) << "Plugin capability load callback failed for " << id.plugin_key << "/" - << id.name << ": " << ex.what(); - } catch (...) { - BOOST_LOG_TRIVIAL(error) << "Plugin capability load callback failed for " << id.plugin_key << "/" - << id.name << ": unknown error"; - } - } -} - -void PluginLoader::run_on_capability_unload_callbacks(const PluginCapabilityIdentifier& id) -{ - for (auto& fn : m_capability_callbacks[CallbackType::Unload]) { - try { - fn(id); - } catch (const std::exception& ex) { - BOOST_LOG_TRIVIAL(error) << "Plugin capability unload callback failed for " << id.plugin_key << "/" - << id.name << ": " << ex.what(); - } catch (...) { - BOOST_LOG_TRIVIAL(error) << "Plugin capability unload callback failed for " << id.plugin_key << "/" - << id.name << ": unknown error"; - } - } -} - -void PluginLoader::subscribe_on_load_callback(PluginLifecycleCompleteFn fn) -{ - m_callbacks[CallbackType::Load].push_back(std::move(fn)); -} - -void PluginLoader::subscribe_on_unload_callback(PluginLifecycleCompleteFn fn) -{ - m_callbacks[CallbackType::Unload].push_back(std::move(fn)); -} - -void PluginLoader::subscribe_on_capability_load_callback(CapabilityLifecycleFn fn) -{ - m_capability_callbacks[CallbackType::Load].push_back(std::move(fn)); -} - -void PluginLoader::subscribe_on_capability_unload_callback(CapabilityLifecycleFn fn) -{ - m_capability_callbacks[CallbackType::Unload].push_back(std::move(fn)); -} - -} // namespace Slic3r +} // namespace Slic3r::plugin_loader diff --git a/src/slic3r/plugin/PluginLoader.hpp b/src/slic3r/plugin/PluginLoader.hpp index 77bb0b4747..f571bb89b7 100644 --- a/src/slic3r/plugin/PluginLoader.hpp +++ b/src/slic3r/plugin/PluginLoader.hpp @@ -4,211 +4,45 @@ #include -#include #include -#include -#include -#include #include -#include -#include #include -#include -#include -#include - -#include namespace Slic3r { - -class PluginCatalog; - -// A single capability materialized from a plugin package. The owning LoadedPlugin -// holds the descriptor and the Python module; capabilities only back-reference the -// package by plugin_key and cache their resolved name. -struct LoadedPluginCapability -{ - std::shared_ptr instance; // Materialized capability instance - std::string name; // Cached from instance->get_name() at load time - std::string plugin_key; // Owning package - PluginCapabilityType type = PluginCapabilityType::Unknown; // cached from instance->get_type() at load (GUI reads this without the GIL) - std::atomic enabled{true}; // logical enable/disable; disabled capabilities are skipped by consumers but stay loaded -}; - -struct PluginCapabilityIdentifier -{ - PluginCapabilityType type = PluginCapabilityType::Unknown; - std::string name; - std::string plugin_key; // owning package — makes the identity globally unique - - bool operator==(const PluginCapabilityIdentifier& o) const - { return type == o.type && name == o.name && plugin_key == o.plugin_key; } -}; - -// A loaded plugin package: one .py/.whl file → one descriptor + one module + N capabilities. -struct LoadedPlugin -{ - PluginDescriptor descriptor; - PyObject* module = nullptr; // Python module object, shared by all capabilities - std::vector capabilities; - - LoadedPlugin() = default; - LoadedPlugin(const LoadedPlugin&) = delete; - LoadedPlugin& operator=(const LoadedPlugin&) = delete; - // Move transfers module ownership; the moved-from package must not Py_DECREF it. - LoadedPlugin(LoadedPlugin&& other) noexcept - : descriptor(std::move(other.descriptor)), module(other.module), capabilities(std::move(other.capabilities)) - { other.module = nullptr; } - LoadedPlugin& operator=(LoadedPlugin&& other) noexcept = delete; - - ~LoadedPlugin(); -}; - +struct Plugin; } // namespace Slic3r -template<> struct std::hash -{ - std::size_t operator()(const Slic3r::PluginCapabilityIdentifier& id) const noexcept - { - std::size_t h = std::hash{}(static_cast(id.type)); - auto mix = [&h](std::size_t v) { h ^= v + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2); }; - mix(std::hash{}(id.name)); - mix(std::hash{}(id.plugin_key)); - return h; - } -}; +namespace Slic3r::plugin_loader { +bool load(const PluginDescriptor& descriptor, + bool skip_deps, + const std::vector& capabilities_to_enable, + const std::function& registry_precheck, + Plugin& out, + std::string& error); -namespace Slic3r { +// Run on_unload() on every capability, drop them, and release the module. Safe to call on a +// not-loaded Plugin, and safe after the interpreter has been finalized (in which case the module +// reference is deliberately leaked rather than DECREF'd). +void unload(Plugin& plugin); -class PluginLoader -{ -public: - enum CallbackType { - Load, - Unload, - }; +// Install Python dependencies into the shared packages directory via the bundled uv. Blocks, with +// a 120 s cap. +bool install_packages(const std::vector& pkgs, std::string& error); - using PluginLoadInProgress = std::unordered_set; - using PluginLoadErrors = std::unordered_map; +// Read a local .py/.whl package's metadata without installing it, and report whether a package is +// already installed under the same directory. +bool inspect_local_plugin_package(const boost::filesystem::path& filepath, + PluginDescriptor& plugin_descriptor, + bool& existing_installation, + std::string& error); - using PluginLifecycleCompleteFn = std::function; +// Copy a .py/.whl package into the plugin directory (the per-user cloud directory when the +// descriptor carries a cloud UUID and cloud_user_id is non-empty) and write its +// .install_state.json sidecar, backing up and restoring any existing installation on failure. +bool install_plugin(const boost::filesystem::path& filepath, + const std::string& cloud_user_id, + PluginDescriptor& plugin_descriptor, + std::string& error); +bool install_plugin(const boost::filesystem::path& filepath, const std::string& cloud_user_id, std::string& error); - using PluginChangedCallbacks = std::unordered_map&)>>>; - - bool is_idle_and_empty() const; - bool is_plugin_loaded(const std::string& plugin_key) const; - bool is_plugin_load_in_progress(const std::string& plugin_key) const; - void wait_for_all_plugin_loads() const; - bool wait_for_all_plugin_loads(std::chrono::milliseconds timeout) const; // bounded; true if all finished, false on timeout - bool wait_for_plugin_load(const std::string& plugin_key, - std::chrono::milliseconds timeout, - std::string& error) const; - std::vector get_all_loaded_plugin_descriptors() const; - // the plugin's [tool.orcaslicer.plugin.settings] table (empty if the plugin is unknown). - std::map get_plugin_settings(const std::string& plugin_key) const; - - - // Package descriptor accessor; returns nullptr when the package is not loaded. - std::vector> get_plugin_capabilities_by_type(const std::string& plugin_type) const; - std::vector> get_plugin_capabilities_by_type(PluginCapabilityType type) const; - std::vector> get_plugin_capabilities_by_type( - const std::string& plugin_key, PluginCapabilityType type) const; - // Resolve a capability by its owning package + type + name. plugin_key is matched by equality. - std::shared_ptr get_plugin_capability_by_name( - const std::string& plugin_key, PluginCapabilityType type, const std::string& name) const; - std::shared_ptr try_get_plugin_capability_by_name_and_type(const std::string& capability_name, PluginCapabilityType type) const; - std::shared_ptr get_plugin_capability_by_name(const PluginCapabilityIdentifier& identifier) const; - std::vector> get_loaded_plugin_capabilities(const std::string& plugin_key) const; - - std::string get_plugin_load_error(const std::string& plugin_key) const; - bool cancel_plugin_load(const std::string& plugin_key); - bool cancel_plugin_unload(const std::string& plugin_key); - - bool install_packages(const std::vector& pkgs, std::string& error) const; - void unload_all_plugins(); - bool unload_plugin(const std::string& plugin_key, - PluginCapabilityType type); - bool unload_plugin(const std::string& plugin_key); - - void load_plugin(PluginCatalog& catalog, - const std::string& plugin_key, - bool skip_deps = false, - std::vector capabilities_to_enable = std::vector()); - - void enable_capability(const std::string& plugin_key, const std::string& capability_name, PluginCapabilityType type); - void disable_capability(const std::string& plugin_key, const std::string& capability_name, PluginCapabilityType type); - - // Writes the .install_state.json sidecar for a currently-loaded plugin (enabled=true plus - // the current per-capability enabled flags). Source of truth for auto-load on next startup. - void write_loaded_plugin_install_state(const std::string& plugin_key); - - bool inspect_local_plugin_package(const boost::filesystem::path& filepath, - PluginDescriptor& plugin_descriptor, - bool& existing_installation, - std::string& error) const; - bool install_plugin(const boost::filesystem::path& filepath, std::string& error); - bool install_plugin(const boost::filesystem::path& filepath, - PluginDescriptor& plugin_descriptor, std::string& error); - void clear_loaded_plugin_cloud_state(const std::string& plugin_key); - void update_loaded_plugin_key(const std::string& old_key, const std::string& new_key); - - void set_cloud_user_id(const std::string& user_id) { m_cloud_user_id = user_id; } - void set_shutting_down() { m_shutting_down.store(true, std::memory_order_release); } - - void unload_cloud_plugins(); - - void subscribe_on_load_callback(PluginLifecycleCompleteFn fn); - void subscribe_on_unload_callback(PluginLifecycleCompleteFn fn); - - // Capability-level lifecycle callbacks, mirroring the package-level load/unload callbacks - // above but carrying the full capability identity. Fired for logical enable/disable and - // loaded-capability key migration; no Python module interaction. - using CapabilityLifecycleFn = std::function; - void subscribe_on_capability_load_callback(CapabilityLifecycleFn fn); - void subscribe_on_capability_unload_callback(CapabilityLifecycleFn fn); - -private: - void load_plugin_impl(PluginCatalog& catalog, - const std::string& plugin_key, - bool skip_deps, - std::vector capabilities_to_enable = std::vector()); - - // Caller holds m_mutex. Removes only the exact typed identifiers owned by plugin. - std::vector> extract_plugin_capabilities_locked(const LoadedPlugin& plugin); - void teardown_capabilities(std::vector>& capabilities, - std::size_t lifecycle_count) const; - - bool cancel_plugin_load_locked(const std::string& plugin_key); - bool is_plugin_load_cancelled_locked(const std::string& plugin_key) const; - void notify_plugin_load_state_changed(bool changed); - void run_on_load_callbacks(const std::string& plugin_key); - void run_on_unload_callbacks(const std::string& plugin_key); - void run_on_capability_load_callbacks(const PluginCapabilityIdentifier& id); - void run_on_capability_unload_callbacks(const PluginCapabilityIdentifier& id); - - // Package store keyed by plugin_key. Capability wrappers live in the typed registry; - // packages retain their registration order through exact typed identifiers. - std::unordered_map m_plugins; - using PluginCapabilityMap = std::unordered_map>; - std::unordered_map m_plugin_capabilities; - - PluginLoadInProgress m_plugin_load_in_progress; - PluginLoadErrors m_plugin_load_errors; - std::string m_cloud_user_id; - std::atomic m_shutting_down{false}; - mutable std::mutex m_mutex; - mutable std::condition_variable m_plugin_load_cv; - - std::unordered_map> m_callbacks{}; - std::unordered_map> m_capability_callbacks{}; - - /* - callbacks: - on plugin load/unload - plugin discovery should always be blocking (with dialog) - all executions should be blocking (with dialog) - Currently, only script plugins should be cancellable. - */ -}; - -} // namespace Slic3r +} // namespace Slic3r::plugin_loader diff --git a/src/slic3r/plugin/PluginManager.cpp b/src/slic3r/plugin/PluginManager.cpp index 33b692c2df..6a306b9aec 100644 --- a/src/slic3r/plugin/PluginManager.cpp +++ b/src/slic3r/plugin/PluginManager.cpp @@ -1,100 +1,57 @@ #include "PluginManager.hpp" -#include "slic3r/GUI/GUI_App.hpp" +#include #include -#include "PythonPluginBridge.hpp" #include "PluginFsUtils.hpp" #include "PluginHooks.hpp" #include "PythonFileUtils.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - #include "PythonInterpreter.hpp" -#include "slic3r/GUI/GUI_App.hpp" +#include "PythonPluginBridge.hpp" #include "OrcaCloudServiceAgent.hpp" +#include "libslic3r/Semver.hpp" +#include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/NotificationManager.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include namespace Slic3r { namespace { -bool wait_for_plugin_catalog(const PluginCatalog& catalog, std::string& error) -{ - error.clear(); - if (!catalog.wait_for_discovery(std::chrono::milliseconds::max(), error)) { - if (error.empty()) - error = "Plugin discovery is still running"; - return false; - } - - return true; -} - -bool find_plugin_descriptor_by_key(const std::vector& catalog, - const std::vector& invalid_plugins, - const std::string& plugin_key, - PluginDescriptor& descriptor) -{ - auto find_by_key = [&plugin_key](const PluginDescriptor& entry) { return entry.plugin_key == plugin_key; }; - - auto catalog_it = std::find_if(catalog.begin(), catalog.end(), find_by_key); - if (catalog_it != catalog.end()) { - descriptor = *catalog_it; - return true; - } - - auto invalid_it = std::find_if(invalid_plugins.begin(), invalid_plugins.end(), find_by_key); - if (invalid_it != invalid_plugins.end()) { - descriptor = *invalid_it; - return true; - } - - return false; -} - -void remove_plugin_from_entries(std::vector& entries, const std::string& plugin_key) -{ - entries.erase(std::remove_if(entries.begin(), entries.end(), - [&plugin_key](const PluginDescriptor& entry) { return entry.plugin_key == plugin_key; }), - entries.end()); -} - -void clear_plugin_cloud_state(std::vector& entries, const std::string& plugin_key) -{ - for (auto& entry : entries) { - if (entry.plugin_key == plugin_key) { - entry.cloud.reset(); - return; - } - } -} +// Sentinel error returned by the registry pre-check when the load was cancelled while it ran. +// A cancelled load records no error and fires no failure path — it just unwinds. +const char* const LOAD_CANCELLED = "__plugin_load_cancelled__"; } // namespace -LoadedPlugin::~LoadedPlugin() +void Plugin::release_module() { - if (!PythonInterpreter::instance().is_initialized()) { - module = nullptr; + if (module == nullptr && plugin_sys_paths.empty() && plugin_modules.empty()) return; - } - PythonGILState gil; - if (module != nullptr) { - Py_DECREF(module); - module = nullptr; - } + PythonInterpreter::instance().unload_module(module, module_name, plugin_sys_paths, plugin_modules); + module = nullptr; + module_name.clear(); + plugin_sys_paths.clear(); + plugin_modules.clear(); } PluginManager& PluginManager::instance() { + // Touch the interpreter first so its static outlives this one: ~Plugin needs it alive to + // release module, sys.modules, and sys.path ownership safely. PythonInterpreter::instance(); static PluginManager inst; return inst; @@ -104,14 +61,15 @@ PluginManager::~PluginManager() { shutdown(); } bool PluginManager::initialize() { - std::lock_guard lock(m_mutex); + { + std::lock_guard lock(m_mutex); + if (m_initialized) + return true; + m_shutting_down.store(false, std::memory_order_release); + } - if (m_initialized) - return true; - - // Initialize the Python interpreter eagerly on the main thread. - // CPython must be initialized from the main thread; calling - // Py_InitializeFromConfig from a background thread (e.g. the + // Initialize the Python interpreter eagerly on the main thread. CPython must be initialized + // from the main thread; calling Py_InitializeFromConfig from a background thread (e.g. the // load_plugin worker) causes heap corruption in CPython internals. PythonInterpreter& interpreter = PythonInterpreter::instance(); if (!interpreter.is_initialized() && !interpreter.initialize()) { @@ -119,28 +77,32 @@ bool PluginManager::initialize() return false; } - m_initialized = true; + { + std::lock_guard lock(m_mutex); + if (m_initialized) + return true; + m_initialized = true; + } - // Install the libslic3r hooks (capability resolver, slicing-pipeline - // dispatcher). Uninstalled in shutdown() before the interpreter finalizes. + // Install the libslic3r hooks (capability resolver, slicing-pipeline dispatcher). + // Uninstalled in shutdown() before the interpreter finalizes. plugin_hooks::install(); // Persist auto-load / capability state to each plugin's .install_state.json sidecar. - // On load: write enabled=true plus current capability flags. On unload: flip enabled=false. - // The on-unload callback is skipped during shutdown (run_on_unload_callbacks is gated by - // !m_shutting_down), so app exit does not wipe the auto-load list. - m_loader.subscribe_on_load_callback([this](const std::string& key) { - m_loader.write_loaded_plugin_install_state(key); - }); - m_loader.subscribe_on_unload_callback([this](const std::string& key) { + // On load: write enabled=true plus the current capability flags. On unload: flip enabled=false. + // The on-unload callback is skipped during shutdown, so app exit does not wipe the auto-load list. + subscribe_on_load_callback([this](const std::string& key) { write_loaded_plugin_install_state(key); }); + subscribe_on_unload_callback([this](const std::string& key) { + if (m_shutting_down.load(std::memory_order_acquire)) + return; PluginDescriptor descriptor; - if (!m_catalog.try_get_plugin_descriptor(key, descriptor) || descriptor.plugin_root.empty()) + if (!try_get_plugin_descriptor(key, descriptor) || descriptor.plugin_root.empty()) return; const boost::filesystem::path root(descriptor.plugin_root); - PluginInstallState st; - if (read_install_state(root, st)) { - st.enabled = false; - write_install_state(root, st); + PluginInstallState state; + if (read_install_state(root, state)) { + state.enabled = false; + write_install_state(root, state); } }); @@ -149,33 +111,43 @@ bool PluginManager::initialize() return true; } +void PluginManager::set_shutting_down() { m_shutting_down.store(true, std::memory_order_release); } + void PluginManager::shutdown() { { std::lock_guard lock(m_mutex); - if (!m_initialized && !m_catalog.is_discovery_in_progress() && m_loader.is_idle_and_empty()) + const bool idle_and_empty = m_load_in_progress.empty() && std::none_of(m_plugins.begin(), m_plugins.end(), + [](const Plugin& plugin) { return plugin.is_loaded(); }); + if (!m_initialized && !m_discovery_in_progress && idle_and_empty) return; } BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": PluginManager shutdown enter"; - // Detach the libslic3r hooks first so nothing dispatches into Python while - // (or after) plugins unload. Callers stop background slicing before this. + // Detach the libslic3r hooks first so nothing dispatches into Python while (or after) plugins + // unload. Callers stop background slicing before this. plugin_hooks::uninstall(); - // Signal the loader to reject new plugin loads before we drain. - m_loader.set_shutting_down(); + // Reject new plugin loads before we drain. + set_shutting_down(); std::string wait_error; - if (!m_catalog.wait_for_discovery(std::chrono::milliseconds::max(), wait_error) && !wait_error.empty()) + if (!wait_for_discovery(std::chrono::milliseconds::max(), wait_error) && !wait_error.empty()) BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": Plugin discovery did not finish cleanly during shutdown: " << wait_error; - // Wait for any in-progress plugin loads. - m_loader.wait_for_all_plugin_loads(); + // Wait for any in-progress plugin loads. Cancelled loads keep their slot until their worker + // has actually unwound, so this cannot return while one is still executing Python. + wait_for_all_plugin_loads(); - m_loader.unload_all_plugins(); + unload_all_plugins(); PythonPluginBridge::instance().clear_pending_captures(); + // Drop the lifecycle subscriptions taken out during initialize(). Without this a second + // initialize() in the same process re-subscribes the same callbacks on top of the old ones, + // and every load would then write the install-state sidecar once per duplicate. + clear_callbacks(); + { std::lock_guard lock(m_mutex); m_initialized = false; @@ -184,15 +156,69 @@ void PluginManager::shutdown() BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": PluginManager shutdown exit"; } +// ── Registry helpers ──────────────────────────────────────────────────────────────────────── + +Plugin* PluginManager::find_plugin_locked(const std::string& plugin_key) +{ + const auto it = std::find_if(m_plugins.begin(), m_plugins.end(), + [&plugin_key](const Plugin& plugin) { return plugin.descriptor.plugin_key == plugin_key; }); + return it == m_plugins.end() ? nullptr : &*it; +} + +const Plugin* PluginManager::find_plugin_locked(const std::string& plugin_key) const +{ + const auto it = std::find_if(m_plugins.begin(), m_plugins.end(), + [&plugin_key](const Plugin& plugin) { return plugin.descriptor.plugin_key == plugin_key; }); + return it == m_plugins.end() ? nullptr : &*it; +} + +std::string PluginManager::check_registry_locked(const std::string& plugin_key, const Plugin& candidate) const +{ + const Plugin* entry = find_plugin_locked(plugin_key); + if (entry != nullptr && entry->is_loaded()) + return "Plugin package is already loaded: " + plugin_key; + + // A capability name must be unique per type across every loaded package. + for (const auto& capability : candidate.capabilities) { + if (!capability) + continue; + for (const Plugin& other : m_plugins) { + if (!other.is_loaded() || other.descriptor.plugin_key == plugin_key) + continue; + for (const auto& existing : other.capabilities) { + if (existing && existing->type() == capability->type() && existing->name() == capability->name()) + return "Capability collision for type " + plugin_capability_type_to_string(capability->type()) + " and name '" + + capability->name() + "'"; + } + } + } + + return {}; +} + +// ── Discovery ─────────────────────────────────────────────────────────────────────────────── + void PluginManager::discover_plugins(bool async, bool clear) { if (!initialize()) return; - if (clear) - m_catalog.clear_all_plugin_errors(); + { + std::lock_guard lock(m_mutex); + if (m_discovery_in_progress) { + BOOST_LOG_TRIVIAL(debug) << "Plugin discovery already running"; + return; + } + if (clear) { + for (Plugin& plugin : m_plugins) + plugin.descriptor.clear_error(); + } + m_discovery_in_progress = true; + m_discovery_complete = false; + m_discovery_error.clear(); + } - m_catalog.discover_plugins(async, clear); + run_discovery(async, clear); } void PluginManager::rescan_plugins() @@ -200,239 +226,1150 @@ void PluginManager::rescan_plugins() BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Rescanning plugins..."; std::string wait_error; - m_catalog.wait_for_discovery(std::chrono::milliseconds::max(), wait_error); + wait_for_discovery(std::chrono::milliseconds::max(), wait_error); - if (!initialize()) - return; - - m_catalog.clear_all_plugin_errors(); - m_catalog.discover_plugins(false, true); + discover_plugins(/*async=*/false, /*clear=*/true); } -bool PluginManager::install_plugin(const boost::filesystem::path& filepath, std::string& error) +void PluginManager::run_discovery(bool async, bool clear) { - error.clear(); + auto task = [this, clear]() { run_discovery_task(clear); }; - std::string wait_error; - if (!wait_for_plugin_catalog(m_catalog, wait_error)) { - error = wait_error; - BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": Plugin installation failed while waiting for discovery: " << wait_error; + if (async) + std::thread(std::move(task)).detach(); + else + task(); +} + +void PluginManager::run_discovery_task(bool clear) +{ + std::string error; + + try { + std::string cloud_user_id; + { + std::lock_guard lock(m_mutex); + cloud_user_id = m_cloud_user_id; + } + + const std::vector dirs = get_plugin_directories(cloud_user_id); + std::vector discovered = discover_plugin_packages(dirs, error); + merge_discovered_plugins(std::move(discovered), clear); + } catch (const std::exception& ex) { + error = std::string("Plugin discovery failed: ") + ex.what(); + BOOST_LOG_TRIVIAL(error) << error; + } catch (...) { + error = "Plugin discovery failed: unknown error"; + BOOST_LOG_TRIVIAL(error) << error; + } + + { + std::lock_guard lock(m_mutex); + m_discovery_error = std::move(error); + m_discovery_complete = true; + m_discovery_in_progress = false; + } + m_discovery_cv.notify_all(); +} + +void PluginManager::merge_discovered_plugins(std::vector discovered, bool clear) +{ + std::vector seen; + seen.reserve(discovered.size()); + + { + std::lock_guard lock(m_mutex); + + for (PluginDescriptor& descriptor : discovered) { + if (descriptor.plugin_key.empty()) + continue; + + if (std::find(seen.begin(), seen.end(), descriptor.plugin_key) != seen.end()) { + const std::string duplicate_error = "Duplicate plugin key discovered: " + descriptor.plugin_key; + if (Plugin* existing = find_plugin_locked(descriptor.plugin_key)) { + existing->descriptor.set_error(duplicate_error); + BOOST_LOG_TRIVIAL(error) << duplicate_error << " (" << existing->descriptor.entry_path << ", " + << descriptor.entry_path << ")"; + } else { + BOOST_LOG_TRIVIAL(error) << duplicate_error; + } + continue; + } + + seen.push_back(descriptor.plugin_key); + + Plugin* existing = find_plugin_locked(descriptor.plugin_key); + if (existing == nullptr) { + Plugin plugin; + plugin.descriptor = std::move(descriptor); + m_plugins.push_back(std::move(plugin)); + continue; + } + + // A manifest-only rescan has nothing to say about capabilities, so the live module and + // instances are left alone. + existing->descriptor = std::move(descriptor); + } + + if (!clear) + return; + } + + // Unloading may call Python and lifecycle subscribers may re-enter the manager, so never do it + // while holding m_mutex. unload_and_erase_if() retries until no matching entry is loaded at the + // moment of erase, in case another caller starts a load between the initial snapshot and the + // teardown. + unload_and_erase_if( + [&seen](const Plugin& plugin) { return std::find(seen.begin(), seen.end(), plugin.descriptor.plugin_key) == seen.end(); }); +} + +void PluginManager::unload_and_erase_if(const std::function& should_remove, + const std::function& after_erase_locked) +{ + for (;;) { + std::vector unload_keys; + { + std::lock_guard lock(m_mutex); + for (const Plugin& plugin : m_plugins) { + if (should_remove(plugin) && plugin.is_loaded()) + unload_keys.push_back(plugin.descriptor.plugin_key); + } + } + + if (unload_keys.empty()) { + std::lock_guard lock(m_mutex); + // Keep the check and erase in one critical section so a concurrent load cannot make a + // live entry reach vector compaction and move-assignment. + bool loaded_entry_found = false; + for (const Plugin& plugin : m_plugins) { + if (should_remove(plugin) && plugin.is_loaded()) { + loaded_entry_found = true; + break; + } + } + if (loaded_entry_found) + continue; + + m_plugins.erase(std::remove_if(m_plugins.begin(), m_plugins.end(), should_remove), m_plugins.end()); + if (after_erase_locked) + after_erase_locked(); + break; + } + for (const std::string& key : unload_keys) + unload_plugin(key); + } +} + +bool PluginManager::is_discovery_complete() const +{ + std::lock_guard lock(m_mutex); + return m_discovery_complete; +} + +bool PluginManager::is_discovery_in_progress() const +{ + std::lock_guard lock(m_mutex); + return m_discovery_in_progress; +} + +std::string PluginManager::get_discovery_error() const +{ + std::lock_guard lock(m_mutex); + return m_discovery_error; +} + +bool PluginManager::wait_for_discovery(std::chrono::milliseconds timeout, std::string& error) const +{ + std::unique_lock lock(m_mutex); + if (!m_discovery_in_progress) + return true; + + const auto done = [this]() { return !m_discovery_in_progress; }; + + if (timeout == std::chrono::milliseconds::max()) { + m_discovery_cv.wait(lock, done); + return true; + } + + if (!m_discovery_cv.wait_for(lock, timeout, done)) { + error = "Plugin discovery is still running"; return false; } - if (!m_loader.install_plugin(filepath, error)) + return true; +} + +// ── Descriptors ───────────────────────────────────────────────────────────────────────────── + +std::vector PluginManager::get_plugin_descriptors(bool include_invalid) const +{ + std::lock_guard lock(m_mutex); + + std::vector result; + result.reserve(m_plugins.size()); + for (const Plugin& plugin : m_plugins) { + if (!include_invalid && plugin.descriptor.is_invalid_package()) + continue; + result.push_back(plugin.descriptor); + } + return result; +} + +bool PluginManager::try_get_plugin_descriptor(const std::string& plugin_key, PluginDescriptor& out) const +{ + std::lock_guard lock(m_mutex); + + const Plugin* plugin = find_plugin_locked(plugin_key); + if (plugin == nullptr) return false; + out = plugin->descriptor; return true; } +bool PluginManager::try_get_valid_plugin_descriptor(const std::string& plugin_key, PluginDescriptor& out) const +{ + std::lock_guard lock(m_mutex); + + const Plugin* plugin = find_plugin_locked(plugin_key); + if (plugin == nullptr || plugin->descriptor.is_invalid_package()) + return false; + + out = plugin->descriptor; + return true; +} + +std::vector PluginManager::get_enabled_plugin_keys() const +{ + std::lock_guard lock(m_mutex); + + std::vector keys; + for (const Plugin& plugin : m_plugins) { + if (plugin.descriptor.enabled && plugin.descriptor.has_local_package()) + keys.push_back(plugin.descriptor.plugin_key); + } + return keys; +} + +bool PluginManager::try_get_plugin_descriptor_for_capability(const std::string& capability_name, + PluginCapabilityType type, + PluginDescriptor& out) const +{ + std::lock_guard lock(m_mutex); + + for (const Plugin& plugin : m_plugins) { + for (const auto& capability : plugin.capabilities) { + if (!capability || capability->name() != capability_name) + continue; + if (type != PluginCapabilityType::Unknown && capability->type() != type) + continue; + out = plugin.descriptor; + return true; + } + } + + return false; +} + +// ── Capability instances ──────────────────────────────────────────────────────────────────── + +std::vector> PluginManager::get_plugin_capabilities(const std::string& plugin_key, + PluginCapabilityType type, + bool only_enabled) const +{ + std::lock_guard lock(m_mutex); + + std::vector> result; + for (const Plugin& plugin : m_plugins) { + if (!plugin_key.empty() && plugin.descriptor.plugin_key != plugin_key) + continue; + + for (const auto& capability : plugin.capabilities) { + if (!capability) + continue; + if (type != PluginCapabilityType::Unknown && capability->type() != type) + continue; + if (only_enabled && !capability->is_enabled()) + continue; + result.push_back(capability); + } + } + return result; +} + +std::shared_ptr PluginManager::get_plugin_capability(const std::string& plugin_key, + const std::string& capability_name, + PluginCapabilityType type, + bool only_enabled) const +{ + std::lock_guard lock(m_mutex); + + const Plugin* plugin = find_plugin_locked(plugin_key); + if (plugin == nullptr) + return nullptr; + + for (const auto& capability : plugin->capabilities) { + if (!capability || capability->name() != capability_name) + continue; + if (type != PluginCapabilityType::Unknown && capability->type() != type) + continue; + if (only_enabled && !capability->is_enabled()) + continue; + return capability; + } + + return nullptr; +} + +std::shared_ptr PluginManager::get_plugin_capability(const std::string& capability_name, + PluginCapabilityType type, + bool only_enabled) const +{ + std::lock_guard lock(m_mutex); + + for (const Plugin& plugin : m_plugins) { + for (const auto& capability : plugin.capabilities) { + if (!capability || capability->name() != capability_name) + continue; + if (type != PluginCapabilityType::Unknown && capability->type() != type) + continue; + if (only_enabled && !capability->is_enabled()) + continue; + return capability; + } + } + + return nullptr; +} + +std::map PluginManager::get_plugin_settings(const std::string& plugin_key) const +{ + std::lock_guard lock(m_mutex); + + const Plugin* plugin = find_plugin_locked(plugin_key); + if (plugin == nullptr || !plugin->is_loaded()) + return {}; + + return plugin->descriptor.settings; +} + +// ── Lifecycle ─────────────────────────────────────────────────────────────────────────────── + +bool PluginManager::is_plugin_loaded(const std::string& plugin_key) const +{ + std::lock_guard lock(m_mutex); + const Plugin* plugin = find_plugin_locked(plugin_key); + return plugin != nullptr && plugin->is_loaded(); +} + +bool PluginManager::is_plugin_load_in_progress(const std::string& plugin_key) const +{ + std::lock_guard lock(m_mutex); + return m_load_in_progress.count(plugin_key) > 0; +} + +void PluginManager::wait_for_all_plugin_loads() const +{ + std::unique_lock lock(m_mutex); + m_load_cv.wait(lock, [this]() { return m_load_in_progress.empty(); }); +} + +bool PluginManager::wait_for_all_plugin_loads(std::chrono::milliseconds timeout) const +{ + std::unique_lock lock(m_mutex); + return m_load_cv.wait_for(lock, timeout, [this]() { return m_load_in_progress.empty(); }); +} + +bool PluginManager::wait_for_plugin_load(const std::string& plugin_key, std::chrono::milliseconds timeout, std::string& error) const +{ + std::unique_lock lock(m_mutex); + + const auto done = [this, &plugin_key]() { return m_load_in_progress.count(plugin_key) == 0; }; + + if (timeout == std::chrono::milliseconds::max()) { + m_load_cv.wait(lock, done); + } else if (!m_load_cv.wait_for(lock, timeout, done)) { + error = "Plugin load is still in progress"; + return false; + } + + const auto it = m_load_errors.find(plugin_key); + if (it != m_load_errors.end()) { + error = it->second; + return false; + } + + return true; +} + +std::string PluginManager::get_plugin_load_error(const std::string& plugin_key) const +{ + std::lock_guard lock(m_mutex); + const auto it = m_load_errors.find(plugin_key); + return it == m_load_errors.end() ? std::string{} : it->second; +} + +bool PluginManager::cancel_plugin_load(const std::string& plugin_key) +{ + bool cancelled = false; + { + std::lock_guard lock(m_mutex); + cancelled = cancel_plugin_load_locked(plugin_key); + } + + notify_plugin_load_state_changed(cancelled); + return cancelled; +} + +bool PluginManager::cancel_plugin_load_locked(const std::string& plugin_key) +{ + if (m_load_in_progress.count(plugin_key) == 0) + return false; + + m_cancelled.insert(plugin_key); + m_load_errors.erase(plugin_key); + return true; +} + +bool PluginManager::is_plugin_load_cancelled_locked(const std::string& plugin_key) const { return m_cancelled.count(plugin_key) > 0; } + +void PluginManager::notify_plugin_load_state_changed(bool changed) +{ + if (changed) + m_load_cv.notify_all(); +} + +void PluginManager::release_load_slot(const std::string& plugin_key) +{ + bool changed = false; + { + std::lock_guard lock(m_mutex); + changed = m_load_in_progress.erase(plugin_key) > 0; + m_cancelled.erase(plugin_key); + } + notify_plugin_load_state_changed(changed); +} + +void PluginManager::cancel_and_wait_for_capabilities( + const std::vector>& capabilities) +{ + for (const auto& capability : capabilities) { + if (!capability || !capability->is_enabled()) + continue; + + try { + capability->on_cancelled(); + } catch (const std::exception& ex) { + BOOST_LOG_TRIVIAL(warning) << "Plugin on_cancelled failed for '" << capability->audit_plugin_key() << "/" + << capability->name() << "': " << ex.what(); + } catch (...) { + BOOST_LOG_TRIVIAL(warning) << "Plugin on_cancelled failed for '" << capability->audit_plugin_key() << "/" + << capability->name() << "'"; + } + } + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (std::chrono::steady_clock::now() < deadline) { + const bool all_released = std::all_of(capabilities.begin(), capabilities.end(), [](const auto& capability) { + return !capability || capability->ref_count() == 0; + }); + if (all_released) + return; + + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + for (const auto& capability : capabilities) { + if (!capability) + continue; + + const int refs = capability->ref_count(); + if (refs > 0) { + BOOST_LOG_TRIVIAL(warning) << "Forcing unload of plugin capability with " << refs + << " active call(s): " << capability->audit_plugin_key() << "/" << capability->name(); + } + } +} + +void PluginManager::load_plugin(const std::string& plugin_key, bool skip_deps, std::vector capabilities_to_enable) +{ + std::string plugin_id = plugin_key; + + bool exists = false; + bool invalid = false; + bool already_loaded = false; + bool load_in_progress = false; + bool load_slot_claimed = false; + bool shutting_down = false; + std::string invalid_error; + std::vector loaded_capability_names; + + { + std::lock_guard lock(m_mutex); + + const Plugin* plugin = find_plugin_locked(plugin_key); + if (plugin != nullptr) { + exists = true; + plugin_id = plugin->descriptor.plugin_key; + invalid = plugin->descriptor.is_invalid_package(); + already_loaded = plugin->is_loaded(); + invalid_error = plugin->descriptor.normalized_error(); + if (already_loaded) { + for (const auto& capability : plugin->capabilities) + if (capability) + loaded_capability_names.push_back(capability->name()); + } + } + + load_in_progress = m_load_in_progress.count(plugin_id) > 0; + shutting_down = m_shutting_down.load(std::memory_order_acquire); + if (exists && !invalid && !already_loaded && !load_in_progress && !shutting_down) { + m_load_in_progress.insert(plugin_id); + m_load_errors.erase(plugin_id); + load_slot_claimed = true; + } + } + + if (already_loaded) { + // An empty request means "restore the persisted capability state". Only an explicit + // capability request may change an already-loaded capability, e.g. when resolving an + // inactive-plugin reference. Runs outside m_mutex. + for (const std::string& capability_name : capabilities_to_enable) { + if (std::find(loaded_capability_names.begin(), loaded_capability_names.end(), capability_name) != + loaded_capability_names.end()) + set_capability_enabled(plugin_id, capability_name, true); + } + + run_on_load_callbacks(plugin_id); + return; + } + + if (load_in_progress) + return; + + if (shutting_down) { + BOOST_LOG_TRIVIAL(info) << "Plugin load rejected — shutting down: " << plugin_id; + run_on_load_callbacks(plugin_id); + return; + } + + if (!exists || invalid) { + std::string message; + if (invalid) { + message = "Plugin is invalid: " + plugin_id; + if (!invalid_error.empty()) + message += " - " + invalid_error; + } else { + message = "Plugin not found: " + plugin_id; + } + + { + std::lock_guard lock(m_mutex); + m_load_errors[plugin_id] = message; + BOOST_LOG_TRIVIAL(error) << message; + } + + if (invalid) + set_plugin_error(plugin_id, message); + + run_on_load_callbacks(plugin_id); + return; + } + + if (!load_slot_claimed) + return; + + clear_plugin_error(plugin_id); + + std::thread([this, plugin_id, skip_deps, capabilities_to_enable]() { + auto fail_unexpected = [this, &plugin_id](std::string message) { + BOOST_LOG_TRIVIAL(error) << "[load_plugin] Unexpected worker failure plugin=" << plugin_id << " error=" << message; + set_plugin_error(plugin_id, message); + { + std::lock_guard lock(m_mutex); + m_load_errors[plugin_id] = std::move(message); + } + }; + + try { + load_plugin_impl(plugin_id, skip_deps, capabilities_to_enable); + } catch (const std::exception& ex) { + fail_unexpected(std::string("Unexpected plugin load failure: ") + ex.what()); + } catch (...) { + fail_unexpected("Unexpected plugin load failure"); + } + + if (!m_shutting_down.load(std::memory_order_acquire)) + run_on_load_callbacks(plugin_id); + + // The worker owns its slot and releases it here, once it is done with Python for good — + // including a load that was cancelled mid-flight, which is what keeps + // wait_for_all_plugin_loads() blocking until the worker is really gone. + release_load_slot(plugin_id); + }).detach(); +} + +void PluginManager::load_plugin_impl(const std::string& plugin_key, bool skip_deps, const std::vector& capabilities_to_enable) +{ + auto fail = [this, &plugin_key](std::string message) { + BOOST_LOG_TRIVIAL(error) << "[load_plugin_impl] FAIL plugin=" << plugin_key << " error=" << message; + set_plugin_error(plugin_key, message); + { + std::lock_guard lock(m_mutex); + m_load_errors[plugin_key] = std::move(message); + } + }; + + PluginDescriptor descriptor; + if (!try_get_plugin_descriptor(plugin_key, descriptor)) { + fail("Plugin manifest not found: " + plugin_key); + return; + } + + // Reject a duplicate package or a capability-name collision BEFORE on_load() runs, so a + // rejected load costs no on_load/on_unload cycle. Also the point at which a cancellation that + // arrived while the module was importing is noticed. + auto registry_precheck = [this, &plugin_key](const Plugin& candidate) -> std::string { + std::lock_guard lock(m_mutex); + if (is_plugin_load_cancelled_locked(plugin_key)) + return LOAD_CANCELLED; + return check_registry_locked(plugin_key, candidate); + }; + + Plugin plugin; + std::string error; + if (!plugin_loader::load(descriptor, skip_deps, capabilities_to_enable, registry_precheck, plugin, error)) { + if (error == LOAD_CANCELLED) + return; // cancelled: nothing materialized survives, and no error is recorded + fail(std::move(error)); + return; + } + + bool committed = false; + bool cancelled = false; + std::string registry_error; + { + std::lock_guard lock(m_mutex); + + cancelled = is_plugin_load_cancelled_locked(plugin_key); + if (!cancelled) { + registry_error = check_registry_locked(plugin_key, plugin); + if (registry_error.empty()) { + Plugin* entry = find_plugin_locked(plugin_key); + if (entry == nullptr) { + registry_error = "Plugin manifest not found: " + plugin_key; + } else { + // Move everything via Plugin's own move assignment, but the package's + // descriptor (identity/metadata) belongs to the registry entry, not to the + // freshly-loaded `plugin` -- preserve it across the move. + PluginDescriptor entry_descriptor = std::move(entry->descriptor); + *entry = std::move(plugin); + entry->descriptor = std::move(entry_descriptor); + committed = true; + } + } + } + } + + if (!committed) { + // Outside the lock: this runs on_unload() and DECREFs the module. + plugin_loader::unload(plugin); + if (!cancelled) + fail(std::move(registry_error)); + return; + } + + clear_plugin_error(plugin_key); + + { + std::lock_guard lock(m_mutex); + m_load_errors.erase(plugin_key); + } + + BOOST_LOG_TRIVIAL(info) << "[load_plugin_impl] SUCCESS plugin=" << plugin_key; +} + +bool PluginManager::unload_plugin(const std::string& plugin_key) +{ + auto notify_unload_complete = [this, &plugin_key]() { + if (!m_shutting_down.load(std::memory_order_acquire)) + run_on_unload_callbacks(plugin_key); + }; + + Plugin removed; + bool cancelled = false; + bool found = false; + + { + std::lock_guard lock(m_mutex); + + // Cancel any in-progress load for this plugin so its worker discards the result. + cancelled = cancel_plugin_load_locked(plugin_key); + + Plugin* plugin = find_plugin_locked(plugin_key); + if (plugin != nullptr && plugin->is_loaded()) { + found = true; + + // Take the live parts out of the entry via Plugin's own move assignment; the + // descriptor stays behind, so the package remains discovered. Everything + // capability-shaped is discarded with the capabilities — the user's enable choices + // live on in the .install_state.json sidecar. + PluginDescriptor kept_descriptor = std::move(plugin->descriptor); + removed = std::move(*plugin); + plugin->descriptor = std::move(kept_descriptor); + } + } + + if (!found) { + notify_plugin_load_state_changed(cancelled); + return true; + } + + notify_unload_complete(); + + cancel_and_wait_for_capabilities(removed.capabilities); + + notify_plugin_load_state_changed(cancelled); + + // Teardown runs outside m_mutex: it calls into Python and must not hold the registry lock. + plugin_loader::unload(removed); + + // The .install_state.json sidecar is flipped to enabled=false by the on-unload callback + // (skipped during shutdown), so the auto-load list survives app exit. + BOOST_LOG_TRIVIAL(info) << "Unloaded plugin: " << plugin_key; + + return true; +} + +void PluginManager::unload_all_plugins() +{ + std::vector removed; + std::vector removed_keys; + std::vector> capabilities; + { + std::lock_guard lock(m_mutex); + for (Plugin& plugin : m_plugins) { + if (!plugin.is_loaded()) + continue; + + // Take the live parts out via Plugin's own move; the descriptor stays behind, as in + // unload_plugin() above. + PluginDescriptor kept_descriptor = std::move(plugin.descriptor); + Plugin taken = std::move(plugin); + plugin.descriptor = std::move(kept_descriptor); + removed.push_back(std::move(taken)); + removed_keys.push_back(plugin.descriptor.plugin_key); + capabilities.insert(capabilities.end(), removed.back().capabilities.begin(), removed.back().capabilities.end()); + } + } + + // Bulk shutdown intentionally skips the normal unload notification path, but resources owned + // by GUI subscribers still need to be torn down before the interpreter is finalized. + for (const std::string& key : removed_keys) + run_on_unload_callbacks(key); + + // Broadcast cancellation to every plugin before waiting. This keeps bulk unload from + // starving a later plugin while an earlier plugin uses the full cancellation grace period. + cancel_and_wait_for_capabilities(capabilities); + + for (Plugin& plugin : removed) + plugin_loader::unload(plugin); +} + +void PluginManager::unload_cloud_plugins() +{ + std::vector keys; + { + std::lock_guard lock(m_mutex); + for (const Plugin& plugin : m_plugins) { + if (plugin.is_loaded() && plugin.descriptor.is_cloud_plugin()) + keys.push_back(plugin.descriptor.plugin_key); + } + } + + // Release m_mutex before unloading: unload_plugin() re-acquires it and runs plugin teardown + // plus unload callbacks, which can re-enter the manager. + for (const std::string& key : keys) + unload_plugin(key); +} + +// ── Enable state ──────────────────────────────────────────────────────────────────────────── + +void PluginManager::set_capability_enabled(const std::string& plugin_key, const std::string& capability_name, bool enabled) +{ + PluginCapabilityId changed; + bool did_change = false; + + { + std::lock_guard lock(m_mutex); + + Plugin* plugin = find_plugin_locked(plugin_key); + if (plugin == nullptr || !plugin->is_loaded()) + return; + + for (const auto& capability : plugin->capabilities) { + if (!capability || capability->name() != capability_name || capability->is_enabled() == enabled) + continue; + capability->set_enabled(enabled); + changed = PluginCapabilityId{capability->type(), capability->name(), plugin_key}; + did_change = true; + break; + } + } + + if (!did_change) + return; + + write_loaded_plugin_install_state(plugin_key); + + if (enabled) + run_on_capability_load_callbacks(changed); + else + run_on_capability_unload_callbacks(changed); +} + +void PluginManager::write_loaded_plugin_install_state(const std::string& plugin_key) +{ + PluginDescriptor descriptor; + std::vector> capabilities; + { + std::lock_guard lock(m_mutex); + + const Plugin* plugin = find_plugin_locked(plugin_key); + if (plugin == nullptr || !plugin->is_loaded()) + return; + + descriptor = plugin->descriptor; + for (const auto& capability : plugin->capabilities) + if (capability) + capabilities.emplace_back(capability->name(), capability->is_enabled()); + } + + if (descriptor.plugin_root.empty()) + return; + + write_install_state(boost::filesystem::path(descriptor.plugin_root), descriptor, /*enabled=*/true, capabilities); +} + +// ── Callbacks ─────────────────────────────────────────────────────────────────────────────── + +void PluginManager::subscribe_on_load_callback(PluginLifecycleCompleteFn fn) +{ + std::lock_guard lock(m_mutex); + m_callbacks[CallbackType::Load].push_back(std::move(fn)); +} + +void PluginManager::subscribe_on_unload_callback(PluginLifecycleCompleteFn fn) +{ + std::lock_guard lock(m_mutex); + m_callbacks[CallbackType::Unload].push_back(std::move(fn)); +} + +void PluginManager::subscribe_on_capability_load_callback(CapabilityLifecycleFn fn) +{ + std::lock_guard lock(m_mutex); + m_capability_callbacks[CallbackType::Load].push_back(std::move(fn)); +} + +void PluginManager::subscribe_on_capability_unload_callback(CapabilityLifecycleFn fn) +{ + std::lock_guard lock(m_mutex); + m_capability_callbacks[CallbackType::Unload].push_back(std::move(fn)); +} + +void PluginManager::clear_callbacks() +{ + std::lock_guard lock(m_mutex); + m_callbacks.clear(); + m_capability_callbacks.clear(); +} + +std::vector PluginManager::copy_callbacks(CallbackType type) const +{ + std::lock_guard lock(m_mutex); + const auto it = m_callbacks.find(type); + return it == m_callbacks.end() ? std::vector{} : it->second; +} + +std::vector PluginManager::copy_capability_callbacks(CallbackType type) const +{ + std::lock_guard lock(m_mutex); + const auto it = m_capability_callbacks.find(type); + return it == m_capability_callbacks.end() ? std::vector{} : it->second; +} + +// The run_on_*_callbacks below are called from detached load/unload workers. They copy the +// subscriber list under m_mutex and invoke it OUTSIDE the lock: subscribers re-enter the manager, +// and no callback may run while the registry lock is held. +void PluginManager::run_on_load_callbacks(const std::string& plugin_key) +{ + for (auto& fn : copy_callbacks(CallbackType::Load)) { + try { + fn(plugin_key); + } catch (const std::exception& ex) { + BOOST_LOG_TRIVIAL(error) << "Plugin load completion callback failed for " << plugin_key << ": " << ex.what(); + } catch (...) { + BOOST_LOG_TRIVIAL(error) << "Plugin load completion callback failed for " << plugin_key; + } + } +} + +void PluginManager::run_on_unload_callbacks(const std::string& plugin_key) +{ + for (auto& fn : copy_callbacks(CallbackType::Unload)) { + try { + fn(plugin_key); + } catch (const std::exception& ex) { + BOOST_LOG_TRIVIAL(error) << "Plugin unload completion callback failed for " << plugin_key << ": " << ex.what(); + } catch (...) { + BOOST_LOG_TRIVIAL(error) << "Plugin unload completion callback failed for " << plugin_key << ": unknown error"; + } + } +} + +void PluginManager::run_on_capability_load_callbacks(const PluginCapabilityId& id) +{ + for (auto& fn : copy_capability_callbacks(CallbackType::Load)) { + try { + fn(id); + } catch (const std::exception& ex) { + BOOST_LOG_TRIVIAL(error) << "Plugin capability load callback failed for " << id.plugin_key << "/" << id.name << ": " + << ex.what(); + } catch (...) { + BOOST_LOG_TRIVIAL(error) << "Plugin capability load callback failed for " << id.plugin_key << "/" << id.name; + } + } +} + +void PluginManager::run_on_capability_unload_callbacks(const PluginCapabilityId& id) +{ + for (auto& fn : copy_capability_callbacks(CallbackType::Unload)) { + try { + fn(id); + } catch (const std::exception& ex) { + BOOST_LOG_TRIVIAL(error) << "Plugin capability unload callback failed for " << id.plugin_key << "/" << id.name << ": " + << ex.what(); + } catch (...) { + BOOST_LOG_TRIVIAL(error) << "Plugin capability unload callback failed for " << id.plugin_key << "/" << id.name; + } + } +} + +// ── Cloud user ────────────────────────────────────────────────────────────────────────────── + +void PluginManager::set_cloud_user(const std::string& user_id) +{ + std::lock_guard lock(m_mutex); + m_cloud_user_id = user_id; +} + +// ── Errors ────────────────────────────────────────────────────────────────────────────────── + +bool PluginManager::set_plugin_error(const std::string& plugin_key, std::string error) +{ + std::string wait_error; + if (!wait_for_discovery(std::chrono::milliseconds::max(), wait_error)) + return false; + + std::lock_guard lock(m_mutex); + Plugin* plugin = find_plugin_locked(plugin_key); + if (plugin == nullptr) + return false; + + plugin->descriptor.set_error(std::move(error)); + return true; +} + +bool PluginManager::clear_plugin_error(const std::string& plugin_key) +{ + std::string wait_error; + if (!wait_for_discovery(std::chrono::milliseconds::max(), wait_error)) + return false; + + std::lock_guard lock(m_mutex); + Plugin* plugin = find_plugin_locked(plugin_key); + if (plugin == nullptr || !plugin->descriptor.is_metadata_valid()) + return false; + + plugin->descriptor.clear_error(); + return true; +} + +// ── Install / inspect ─────────────────────────────────────────────────────────────────────── + +bool PluginManager::inspect_local_plugin_package(const boost::filesystem::path& filepath, + PluginDescriptor& plugin_descriptor, + bool& existing_installation, + std::string& error) const +{ return plugin_loader::inspect_local_plugin_package(filepath, plugin_descriptor, existing_installation, error); } + +bool PluginManager::install_plugin(const boost::filesystem::path& filepath, std::string& error) +{ + PluginDescriptor descriptor{}; + return install_plugin(filepath, descriptor, error); +} + bool PluginManager::install_plugin(const boost::filesystem::path& filepath, PluginDescriptor& plugin_descriptor, std::string& error) { error.clear(); std::string wait_error; - if (!wait_for_plugin_catalog(m_catalog, wait_error)) { - error = wait_error; + if (!wait_for_discovery(std::chrono::milliseconds::max(), wait_error)) { + error = wait_error.empty() ? "Plugin discovery is still running" : wait_error; if (!plugin_descriptor.plugin_key.empty()) - m_catalog.set_plugin_error(plugin_descriptor.plugin_key, error); - BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": Plugin installation failed while waiting for discovery: " << wait_error; + set_plugin_error(plugin_descriptor.plugin_key, error); + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": Plugin installation failed while waiting for discovery: " << error; return false; } - if (!m_loader.install_plugin(filepath, plugin_descriptor, error)) { + std::string cloud_user_id; + { + std::lock_guard lock(m_mutex); + cloud_user_id = m_cloud_user_id; + } + + // A local overwrite must not leave the old module holding the package file or its Python + // namespace while the installer replaces the package on disk. + if (!plugin_descriptor.is_cloud_plugin()) { + const std::string local_plugin_key = make_local_plugin_key(filepath.stem().string()); + if (is_plugin_loaded(local_plugin_key)) + unload_plugin(local_plugin_key); + } + + if (!plugin_loader::install_plugin(filepath, cloud_user_id, plugin_descriptor, error)) { if (!plugin_descriptor.plugin_key.empty()) - m_catalog.set_plugin_error(plugin_descriptor.plugin_key, error); + set_plugin_error(plugin_descriptor.plugin_key, error); BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": " << error; return false; } if (!plugin_descriptor.plugin_key.empty()) - m_catalog.clear_plugin_error(plugin_descriptor.plugin_key); + clear_plugin_error(plugin_descriptor.plugin_key); return true; } -bool PluginManager::set_plugin_error(const std::string& plugin_key, std::string error) +// ── Cloud overlay ─────────────────────────────────────────────────────────────────────────── + +namespace { + +bool is_cloud_version_newer(const std::string& cloud_version, const std::string& local_version) { - std::string wait_error; - if (!wait_for_plugin_catalog(m_catalog, wait_error)) - return false; - - PluginDescriptor descriptor; - if (!m_catalog.try_get_plugin_descriptor(plugin_key, descriptor)) - return false; - - descriptor.set_error(std::move(error)); - return m_catalog.update_plugin_descriptor(plugin_key, descriptor); + auto cloud_parsed = Semver::parse(cloud_version); + auto local_parsed = Semver::parse(local_version); + if (cloud_parsed && local_parsed) + return *cloud_parsed > *local_parsed; + // Fall back to string comparison if semver parsing fails for either version. + return cloud_version != local_version; } -bool PluginManager::clear_plugin_error(const std::string& plugin_key) +const char* const CLOUD_PLUGIN_NOT_FOUND_ERROR = "Plugin was not found in the cloud."; + +} // namespace + +void PluginManager::update_cloud_catalog(const std::vector& cloud_list) { - std::string wait_error; - if (!wait_for_plugin_catalog(m_catalog, wait_error)) - return false; + std::lock_guard lock(m_mutex); - PluginDescriptor descriptor; - if (!m_catalog.try_get_plugin_descriptor(plugin_key, descriptor)) - return false; - if (!descriptor.is_metadata_valid()) - return false; + // Collected and appended after the scan: push_back into m_plugins would invalidate the + // pointers the loop is holding. + std::vector new_entries; - descriptor.clear_error(); - return m_catalog.update_plugin_descriptor(plugin_key, descriptor); + for (const PluginDescriptor& cloud_entry : cloud_list) { + const std::string cloud_uuid = cloud_entry.cloud_uuid(); + const std::string cloud_key = cloud_entry.plugin_key; + if (cloud_uuid.empty()) { + BOOST_LOG_TRIVIAL(warning) << "Skipping cloud plugin record without UUID"; + continue; + } + + const auto matches_cloud_descriptor = [&cloud_key, &cloud_uuid](const PluginDescriptor& entry) { + if (!cloud_key.empty() && entry.plugin_key == cloud_key) + return true; + return entry.is_cloud_plugin() && entry.cloud_uuid() == cloud_uuid; + }; + + const auto existing = std::find_if(m_plugins.begin(), m_plugins.end(), [&matches_cloud_descriptor](const Plugin& plugin) { + return matches_cloud_descriptor(plugin.descriptor); + }); + + if (existing == m_plugins.end()) { + PluginDescriptor normalized_entry = cloud_entry; + if (normalized_entry.plugin_key.empty()) + normalized_entry.plugin_key = cloud_uuid; + if (!normalized_entry.cloud.has_value()) + normalized_entry.cloud = CloudPluginState{cloud_uuid, false, false, false}; + else if (normalized_entry.cloud->uuid.empty()) + normalized_entry.cloud->uuid = cloud_uuid; + new_entries.push_back(std::move(normalized_entry)); + continue; + } + + PluginDescriptor& entry = existing->descriptor; + + const PluginDescriptor local_entry = entry; + const std::string installed_version = entry.installed_version; + const std::string latest_version = cloud_entry.latest_available_version(); + const std::string local_plugin_root = entry.plugin_root; + const std::string local_entry_path = entry.entry_path; + const bool local_metadata_ok = entry.is_metadata_valid(); + const bool has_local_package = entry.has_local_package(); + const std::string previous_error = entry.error; + // The cloud does not know about the local auto-load flag, which comes from the sidecar. + const bool enabled = entry.enabled; + + entry = cloud_entry; + entry.plugin_root = local_plugin_root; + entry.entry_path = local_entry_path; + if (has_local_package) + apply_plugin_metadata_fallbacks(entry, local_entry); + if (entry.plugin_key.empty()) + entry.plugin_key = cloud_uuid; + entry.metadata_valid = has_local_package ? local_metadata_ok : cloud_entry.metadata_valid; + entry.error = previous_error; + entry.enabled = enabled; + if (!entry.cloud.has_value()) + entry.cloud = CloudPluginState{cloud_uuid, has_local_package, false, false}; + else if (entry.cloud->uuid.empty()) + entry.cloud->uuid = cloud_uuid; + + entry.cloud->installed = has_local_package; + // The installed version is the source of truth read back from the install-state sidecar + // (the version fetched from the cloud at install time), not the local manifest/PEP723 + // header. The header may be stale — the cloud can bump the version without the header + // changing — which would otherwise make an already-updated plugin appear perpetually out + // of date. + entry.installed_version = has_local_package ? installed_version : std::string{}; + entry.cloud->update_available = has_local_package && local_metadata_ok && !installed_version.empty() && !latest_version.empty() && + is_cloud_version_newer(latest_version, installed_version); + if (entry.normalized_error() == CLOUD_PLUGIN_NOT_FOUND_ERROR) + entry.clear_error(); + } + + for (PluginDescriptor& descriptor : new_entries) { + Plugin plugin; + plugin.descriptor = std::move(descriptor); + m_plugins.push_back(std::move(plugin)); + } } -bool PluginManager::delete_plugin(const std::string& plugin_key, std::string& error) +void PluginManager::clear_cloud_plugin_catalog() { - if (!wait_for_plugin_catalog(m_catalog, error)) - return false; + // Cloud entries may own live Python modules. Unload them before erasing their vector entries, + // and do so outside m_mutex because both Python teardown and lifecycle callbacks can re-enter + // the manager. + unload_and_erase_if( + [](const Plugin& plugin) { return plugin.descriptor.is_cloud_plugin(); }, + [this]() { + // A local plugin can still be carrying a stale "not found in the cloud" error from + // when it was cloud-tracked. + for (Plugin& plugin : m_plugins) { + if (plugin.descriptor.normalized_error() == CLOUD_PLUGIN_NOT_FOUND_ERROR) + plugin.descriptor.clear_error(); + } + }); - error.clear(); - - PluginDescriptor descriptor; - if (!m_catalog.try_get_plugin_descriptor(plugin_key, descriptor)) { - error = "Plugin not found: " + plugin_key; - return false; - } - - if (!delete_installed_plugin_package(descriptor, error)) { - m_catalog.set_plugin_error(plugin_key, error); - return false; - } - - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Deleted plugin: " << plugin_key; - return true; -} - -bool PluginManager::unsubscribe_cloud_plugin(const std::string& plugin_key, std::string& error) -{ - if (!wait_for_plugin_catalog(m_catalog, error)) - return false; - - error.clear(); - - PluginDescriptor descriptor; - if (!m_catalog.try_get_plugin_descriptor(plugin_key, descriptor)) { - error = "Plugin not found: " + plugin_key; - return false; - } - - if (!descriptor.is_cloud_plugin()) { - error = "Only cloud plugins can be unsubscribed."; - m_catalog.set_plugin_error(plugin_key, error); - return false; - } - - if (descriptor.cloud && descriptor.cloud->is_mine) { - error = "Cannot unsubscribe your own plugins. Use Delete from Cloud instead."; - m_catalog.set_plugin_error(plugin_key, error); - return false; - } - - if (!m_cloud_service.request_cloud_unsubscribe(descriptor, error)) { - m_catalog.set_plugin_error(plugin_key, error); - return false; - } - - return finalize_cloud_plugin_removal(descriptor, true, error); -} - -bool PluginManager::delete_and_unsubscribe_cloud_plugin(const std::string& plugin_key, std::string& error) -{ - if (!wait_for_plugin_catalog(m_catalog, error)) - return false; - - error.clear(); - - PluginDescriptor descriptor; - if (!m_catalog.try_get_plugin_descriptor(plugin_key, descriptor)) { - error = "Plugin not found: " + plugin_key; - return false; - } - - if (!descriptor.is_cloud_plugin()) { - error = "Only cloud plugins can be deleted and unsubscribed."; - m_catalog.set_plugin_error(plugin_key, error); - return false; - } - - if (descriptor.cloud->is_mine) { - error = "Use Delete local and cloud for owned plugins."; - m_catalog.set_plugin_error(plugin_key, error); - return false; - } - - if (!m_cloud_service.request_cloud_unsubscribe(descriptor, error)) { - m_catalog.set_plugin_error(plugin_key, error); - return false; - } - - return finalize_cloud_plugin_removal(descriptor, false, error); -} - -bool PluginManager::delete_mine_plugin_from_cloud(const std::string& plugin_key, std::string& error) -{ - if (!wait_for_plugin_catalog(m_catalog, error)) - return false; - - error.clear(); - - PluginDescriptor descriptor; - if (!m_catalog.try_get_plugin_descriptor(plugin_key, descriptor)) { - error = "Plugin not found: " + plugin_key; - return false; - } - - if (!descriptor.is_cloud_plugin()) { - error = "Only owned cloud plugins can be deleted from the cloud."; - m_catalog.set_plugin_error(plugin_key, error); - return false; - } - - if (!descriptor.cloud->is_mine) { - error = "Only your own plugins can be deleted from the cloud."; - m_catalog.set_plugin_error(plugin_key, error); - return false; - } - - if (!m_cloud_service.request_cloud_delete(descriptor, error)) { - m_catalog.set_plugin_error(plugin_key, error); - return false; - } - - return finalize_cloud_plugin_removal(descriptor, true, error); -} - -bool PluginManager::delete_mine_local_and_cloud_plugin(const std::string& plugin_key, std::string& error) -{ - if (!wait_for_plugin_catalog(m_catalog, error)) - return false; - - error.clear(); - - PluginDescriptor descriptor; - if (!m_catalog.try_get_plugin_descriptor(plugin_key, descriptor)) { - error = "Plugin not found: " + plugin_key; - return false; - } - - if (!descriptor.is_cloud_plugin()) { - error = "Only owned cloud plugins can be deleted from local and cloud."; - m_catalog.set_plugin_error(plugin_key, error); - return false; - } - - if (!descriptor.cloud->is_mine) { - error = "Only your own plugins can be deleted from local and cloud."; - m_catalog.set_plugin_error(plugin_key, error); - return false; - } - - if (!m_cloud_service.request_cloud_delete(descriptor, error)) { - m_catalog.set_plugin_error(plugin_key, error); - return false; - } - return finalize_cloud_plugin_removal(descriptor, false, error); + BOOST_LOG_TRIVIAL(info) << "Cleared cloud plugin catalog entries"; } void PluginManager::fetch_plugins_from_cloud(std::vector* out_not_found, std::vector* out_unauthorized) @@ -442,29 +1379,60 @@ void PluginManager::fetch_plugins_from_cloud(std::vector* out_not_f std::vector cloud_list{}; std::vector not_found{}, unauthorized{}; - bool result = m_cloud_service.fetch_manifests_into_descriptors(cloud_list, not_found, unauthorized); - if (!result) { - if (GUI::wxGetApp().plater() != nullptr && GUI::wxGetApp().imgui()->display_initialized()) { - GUI::wxGetApp() - .plater() - ->get_notification_manager() - ->push_notification(GUI::NotificationType::CustomNotification, - GUI::NotificationManager::NotificationLevel::WarningNotificationLevel, - "Failed to fetch plugins from the cloud. See logs for details."); + if (!m_cloud_service.fetch_manifests_into_descriptors(cloud_list, not_found, unauthorized)) { + if (wxTheApp != nullptr) { + GUI::wxGetApp().CallAfter([] { + if (GUI::wxGetApp().is_closing()) + return; + GUI::Plater* plater = GUI::wxGetApp().plater(); + if (plater == nullptr || GUI::wxGetApp().imgui() == nullptr || !GUI::wxGetApp().imgui()->display_initialized()) + return; + plater->get_notification_manager()->push_notification(GUI::NotificationType::CustomNotification, + GUI::NotificationManager::NotificationLevel::WarningNotificationLevel, + "Failed to fetch plugins from the cloud. See logs for details."); + }); } } - m_catalog.update_cloud_catalog(cloud_list); - m_catalog.clear_cloud_plugin_unauthorized(); - m_catalog.clear_cloud_plugin_not_found_errors(); + update_cloud_catalog(cloud_list); - // Mark cloud issues in the catalog so app UIs can reflect their shared state. - for (const auto& uuid : not_found) - m_catalog.mark_cloud_plugin_not_found(uuid); - for (const auto& uuid : unauthorized) - m_catalog.mark_cloud_plugin_unauthorized(uuid); + { + std::lock_guard lock(m_mutex); + + // Clear the previous cloud verdicts before re-applying the fresh ones. + for (Plugin& plugin : m_plugins) { + PluginDescriptor& entry = plugin.descriptor; + if (!entry.is_cloud_plugin()) + continue; + entry.set_unauthorized(false); + if (entry.normalized_error() == CLOUD_PLUGIN_NOT_FOUND_ERROR) + entry.clear_error(); + } + + for (const std::string& uuid : not_found) { + for (Plugin& plugin : m_plugins) { + PluginDescriptor& entry = plugin.descriptor; + if (!entry.is_cloud_plugin() || entry.cloud_uuid() != uuid) + continue; + if (!entry.has_local_package()) + entry.set_error(CLOUD_PLUGIN_NOT_FOUND_ERROR); + break; + } + } + + for (const std::string& uuid : unauthorized) { + for (Plugin& plugin : m_plugins) { + PluginDescriptor& entry = plugin.descriptor; + if (!entry.is_cloud_plugin() || entry.cloud_uuid() != uuid) + continue; + entry.set_unauthorized(true); + if (entry.cloud.has_value()) + entry.cloud->update_available = false; + break; + } + } + } - // Return the vectors to callers that need them (e.g. for notifications). if (out_not_found) *out_not_found = std::move(not_found); if (out_unauthorized) @@ -488,11 +1456,11 @@ bool PluginManager::subscribe_and_install_cloud_plugin(const std::string& plugin } PluginDescriptor descriptor; - bool found = m_catalog.try_get_plugin_descriptor(plugin_key, descriptor) && descriptor.is_cloud_plugin(); + bool found = try_get_plugin_descriptor(plugin_key, descriptor) && descriptor.is_cloud_plugin(); if (!found) { // The plugin may already be subscribed or owned while the local catalog is stale. fetch_plugins_from_cloud(); - found = m_catalog.try_get_plugin_descriptor(plugin_key, descriptor) && descriptor.is_cloud_plugin(); + found = try_get_plugin_descriptor(plugin_key, descriptor) && descriptor.is_cloud_plugin(); } if (!found) { @@ -500,7 +1468,7 @@ bool PluginManager::subscribe_and_install_cloud_plugin(const std::string& plugin return false; fetch_plugins_from_cloud(); - found = m_catalog.try_get_plugin_descriptor(plugin_key, descriptor) && descriptor.is_cloud_plugin(); + found = try_get_plugin_descriptor(plugin_key, descriptor) && descriptor.is_cloud_plugin(); if (!found) { error = "Subscribed cloud plugin was not returned by OrcaCloud."; return false; @@ -519,19 +1487,19 @@ bool PluginManager::download_and_install_cloud_plugin(const std::string& plugin_ CloudPluginDownload download; PluginDescriptor descriptor; - if (!m_catalog.try_get_plugin_descriptor(plugin_key, descriptor)) { + if (!try_get_plugin_descriptor(plugin_key, descriptor)) { error = "Plugin not found: " + plugin_key; BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": Failed to find plugin key " << plugin_key; return false; } - m_catalog.clear_plugin_error(plugin_key); + clear_plugin_error(plugin_key); const std::string requested_version = version.empty() ? descriptor.version : version; if (!m_cloud_service.download_cloud_plugin(descriptor, requested_version, download, error)) { if (error.empty()) error = "Failed to download cloud plugin."; - m_catalog.set_plugin_error(plugin_key, error); + set_plugin_error(plugin_key, error); BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": " << error; return false; } @@ -540,18 +1508,18 @@ bool PluginManager::download_and_install_cloud_plugin(const std::string& plugin_ if (auto agent = m_cloud_service.get_cloud_agent()) { const std::string user_id = agent->get_user_id(); if (!user_id.empty()) - m_loader.set_cloud_user_id(user_id); + set_cloud_user(user_id); } // Record the version we just fetched from the cloud so install_plugin persists it to the - // install-state sidecar (the source of truth for the installed cloud version) instead of - // the local manifest/PEP723 header version. + // install-state sidecar (the source of truth for the installed cloud version) instead of the + // local manifest/PEP723 header version. descriptor.installed_version = requested_version; if (!install_plugin(download.package_path, descriptor, error)) { if (error.empty()) error = "Failed to install cloud plugin."; - m_catalog.set_plugin_error(plugin_key, error); + set_plugin_error(plugin_key, error); boost::system::error_code ec; boost::filesystem::remove(download.package_path, ec); BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": " << error; @@ -563,6 +1531,7 @@ bool PluginManager::download_and_install_cloud_plugin(const std::string& plugin_ updated_descriptor.version = requested_version; // The version just downloaded and installed is now the locally installed version. updated_descriptor.installed_version = requested_version.empty() ? descriptor.version : requested_version; + updated_descriptor.enabled = true; if (updated_descriptor.cloud.has_value()) { updated_descriptor.cloud->installed = true; updated_descriptor.cloud->update_available = false; @@ -571,11 +1540,19 @@ bool PluginManager::download_and_install_cloud_plugin(const std::string& plugin_ updated_descriptor.clear_error(); updated_descriptor.set_unauthorized(false); - if (!m_catalog.update_plugin_descriptor(plugin_key, updated_descriptor)) { - error = "Plugin Manifest not found."; - m_catalog.set_plugin_error(plugin_key, error); - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": Cloud plugin " << plugin_key - << " downloaded successfully but failed to update plugin manifest. Manifest not found in catalog."; + { + std::lock_guard lock(m_mutex); + Plugin* entry = find_plugin_locked(plugin_key); + if (entry == nullptr) { + error = "Plugin Manifest not found."; + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": Cloud plugin " << plugin_key + << " downloaded successfully but failed to update plugin manifest. Manifest not found in catalog."; + } else { + entry->descriptor = std::move(updated_descriptor); + } + } + if (!error.empty()) { + set_plugin_error(plugin_key, error); return false; } @@ -583,11 +1560,196 @@ bool PluginManager::download_and_install_cloud_plugin(const std::string& plugin_ return true; } +bool PluginManager::update_cloud_plugin(const std::string& plugin_key, std::string& error, std::string version) +{ + error.clear(); + + PluginDescriptor descriptor; + if (!try_get_plugin_descriptor(plugin_key, descriptor)) { + error = "Plugin not found: " + plugin_key; + return false; + } + + if (!descriptor.is_cloud_plugin()) { + error = "Only cloud plugins can be updated."; + set_plugin_error(plugin_key, error); + return false; + } + + // An empty version means "update the current plugin". If there is no update available, the + // intention is unclear. + if (version.empty()) { + if (descriptor.cloud->update_available) { + version = descriptor.latest_available_version(); + } else { + error = "Trying to update plugin with no available update. Version is empty."; + set_plugin_error(plugin_key, error); + return false; + } + } + + clear_plugin_error(plugin_key); + + if (descriptor.has_local_package()) { + boost::filesystem::path resolved_root; + bool resolved_allowed_plugin_root = false; + { + std::lock_guard lock(m_mutex); + resolved_allowed_plugin_root = resolve_allowed_plugin_root(descriptor, get_plugin_directories(m_cloud_user_id), + "Refusing to delete a plugin outside the known plugin directories.", + resolved_root, error); + } + + if (!resolved_allowed_plugin_root) { + set_plugin_error(plugin_key, error); + return false; + } + + unload_plugin(plugin_key); + + if (!delete_plugin_root(resolved_root, plugin_key, error)) { + set_plugin_error(plugin_key, error); + return false; + } + } + + if (!download_and_install_cloud_plugin(plugin_key, version, error)) { + set_plugin_error(plugin_key, error); + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": " << error; + return false; + } + + clear_plugin_error(plugin_key); + return true; +} + +// ── Delete / unsubscribe ──────────────────────────────────────────────────────────────────── + +bool PluginManager::delete_installed_plugin_package(const PluginDescriptor& plugin, std::string& error) +{ + boost::filesystem::path resolved_root; + bool resolved_allowed_plugin_root = false; + { + std::lock_guard lock(m_mutex); + resolved_allowed_plugin_root = resolve_allowed_plugin_root(plugin, get_plugin_directories(m_cloud_user_id), + "Refusing to delete a plugin outside the known plugin directories.", + resolved_root, error); + } + if (!resolved_allowed_plugin_root) + return false; + + unload_plugin(plugin.plugin_key); + + if (!delete_plugin_root(resolved_root, plugin.plugin_key, error)) + return false; + + { + std::lock_guard lock(m_mutex); + m_plugins.erase(std::remove_if(m_plugins.begin(), m_plugins.end(), + [&plugin](const Plugin& entry) { return entry.descriptor.plugin_key == plugin.plugin_key; }), + m_plugins.end()); + } + + return true; +} + +bool PluginManager::keep_installed_plugin_as_local(const PluginDescriptor& plugin_descriptor, std::string& error) +{ + namespace fs = boost::filesystem; + + boost::filesystem::path resolved_root; + + bool resolved_allowed_plugin_root = false; + { + std::lock_guard lock(m_mutex); + resolved_allowed_plugin_root = resolve_allowed_plugin_root(plugin_descriptor, get_plugin_directories(m_cloud_user_id), + "Refusing to update a plugin outside the known plugin directories.", + resolved_root, error); + } + + if (!resolved_allowed_plugin_root) + return false; + + const std::string old_key = plugin_descriptor.plugin_key; + + // Generate a new local key from the entry file stem (cloud -> local conversion). + const std::string entry_stem = fs::path(plugin_descriptor.entry_path).stem().string(); + const std::string new_key = make_local_plugin_key(!entry_stem.empty() ? entry_stem : resolved_root.stem().string()); + + PluginDescriptor local_descriptor = plugin_descriptor; + local_descriptor.plugin_key = new_key; + local_descriptor.cloud = std::nullopt; + local_descriptor.clear_error(); + + // Re-key the entry in place, carrying the live module/capabilities (if any) with it. The + // capabilities' audit key must follow, since it is what identifies their owning package. + std::vector rekeyed; + { + std::lock_guard lock(m_mutex); + + Plugin* entry = find_plugin_locked(old_key); + if (entry == nullptr) { + error = "Plugin manifest not found: " + old_key; + return false; + } + + if (find_plugin_locked(new_key) != nullptr && new_key != old_key) { + error = "Cannot keep plugin local: local plugin key already exists: " + new_key; + BOOST_LOG_TRIVIAL(warning) << error; + return false; + } + + // Preserve the sidecar's package and capability choices while changing only its origin. + // Re-keying an installed plugin must not silently re-enable capabilities. + PluginInstallState install_state; + if (!read_install_state(resolved_root, install_state)) { + install_state.installed_version = !local_descriptor.installed_version.empty() + ? local_descriptor.installed_version + : local_descriptor.version; + install_state.enabled = local_descriptor.enabled; + } + install_state.installed_from = "local"; + install_state.plugin_name = local_descriptor.name; + install_state.cloud_uuid.clear(); + if (install_state.installed_version.empty()) + install_state.installed_version = local_descriptor.version; + if (!write_install_state(resolved_root, install_state)) { + error = "Failed to update plugin install state: " + (resolved_root / INSTALL_STATE_FILE).string(); + return false; + } + + entry->descriptor.plugin_key = new_key; + entry->descriptor.cloud.reset(); + entry->descriptor.clear_error(); + + for (const auto& capability : entry->capabilities) { + if (capability) + capability->set_audit_plugin_key(new_key); + } + + for (const auto& capability : entry->capabilities) { + if (capability && capability->is_enabled()) + rekeyed.push_back(PluginCapabilityId{capability->type(), capability->name(), new_key}); + } + } + + // Subscribers key their own state by plugin_key. Publish the identity transition after the + // registry update and outside m_mutex so they can safely query the manager. + for (const PluginCapabilityId& id : rekeyed) { + run_on_capability_unload_callbacks(PluginCapabilityId{id.type, id.name, old_key}); + run_on_capability_load_callbacks(id); + } + + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Transitioned plugin from " << old_key << " to " << new_key; + + return true; +} + bool PluginManager::finalize_cloud_plugin_removal(const PluginDescriptor& plugin, bool keep_local, std::string& error) { - // Shared by all four cloud-removal entrypoints after the cloud-side request - // succeeds. Handles the common local follow-up of keeping a detached local - // copy, deleting local files, or dropping a cloud-only row from the catalog. + // Shared by all four cloud-removal entrypoints after the cloud-side request succeeds. Handles + // the common local follow-up of keeping a detached local copy, deleting local files, or + // dropping a cloud-only row from the catalog. if (keep_local && plugin.has_local_package()) { if (!keep_installed_plugin_as_local(plugin, error)) return false; @@ -598,141 +1760,212 @@ bool PluginManager::finalize_cloud_plugin_removal(const PluginDescriptor& plugin if (plugin.has_local_package()) { if (!delete_installed_plugin_package(plugin, error)) return false; - // Re-sync the cloud catalog so observers/UI see the updated cloud list - // after the local package has been removed. + // Re-sync the cloud catalog so observers/UI see the updated cloud list after the local + // package has been removed. fetch_plugins_from_cloud(); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Deleted local package after cloud removal: " << plugin.plugin_key; return true; } - m_catalog.remove_plugin(plugin.plugin_key); + { + std::lock_guard lock(m_mutex); + m_plugins.erase(std::remove_if(m_plugins.begin(), m_plugins.end(), + [&plugin](const Plugin& entry) { return entry.descriptor.plugin_key == plugin.plugin_key; }), + m_plugins.end()); + } BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Removed cloud-only plugin from catalog: " << plugin.plugin_key; return true; } -bool PluginManager::delete_installed_plugin_package(const PluginDescriptor& plugin, std::string& error) +bool PluginManager::delete_plugin(const std::string& plugin_key, std::string& error) { - boost::filesystem::path resolved_root; - if (!resolve_allowed_plugin_root(plugin, m_catalog.get_plugin_directories(), - "Refusing to delete a plugin outside the known plugin directories.", resolved_root, error)) + if (!wait_for_discovery(std::chrono::milliseconds::max(), error)) return false; - m_loader.unload_plugin(plugin.plugin_key, plugin.primary_capability_type()); - - if (!delete_plugin_root(resolved_root, plugin.plugin_key, error)) - return false; - - m_catalog.remove_plugin(plugin.plugin_key); - return true; -} - -bool PluginManager::keep_installed_plugin_as_local(const PluginDescriptor& plugin_descriptor, std::string& error) -{ - namespace fs = boost::filesystem; - - boost::filesystem::path resolved_root; - if (!resolve_allowed_plugin_root(plugin_descriptor, m_catalog.get_plugin_directories(), - "Refusing to update a plugin outside the known plugin directories.", resolved_root, error)) - return false; - - const std::string old_key = plugin_descriptor.plugin_key; - - // Generate new local key from the entry file stem (cloud → local conversion). - const std::string entry_stem = fs::path(plugin_descriptor.entry_path).stem().string(); - const std::string new_key = make_local_plugin_key( - !entry_stem.empty() ? entry_stem : resolved_root.stem().string()); - - // Update metadata. - PluginDescriptor local_descriptor = plugin_descriptor; - local_descriptor.plugin_key = new_key; - local_descriptor.cloud = std::nullopt; - local_descriptor.clear_error(); - - if (!write_install_state(resolved_root, local_descriptor)) { - error = "Failed to update plugin install state: " + (resolved_root / INSTALL_STATE_FILE).string(); - return false; - } - - // Update catalog entry key in-memory. - { - std::lock_guard lock(m_mutex); - auto& catalog_entries = const_cast&>(m_catalog.get_plugin_catalog()); - for (auto& entry : catalog_entries) { - if (entry.plugin_key == old_key) { - entry.plugin_key = new_key; - entry.cloud.reset(); - entry.clear_error(); - break; - } - } - } - - // Update loaded plugin manifest key if currently loaded. - m_loader.update_loaded_plugin_key(old_key, new_key); - - // Auto-load state for the new local key is carried by the .install_state.json sidecar - // written above with the new local descriptor. - - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Transitioned plugin from " << old_key << " to " << new_key; - - return true; -} - -bool PluginManager::update_cloud_plugin(const std::string& plugin_key, std::string& error, std::string version) -{ error.clear(); - // delete local plugin file and download newer version PluginDescriptor descriptor; - if (!m_catalog.try_get_plugin_descriptor(plugin_key, descriptor)) { + if (!try_get_plugin_descriptor(plugin_key, descriptor)) { + error = "Plugin not found: " + plugin_key; + return false; + } + + if (!delete_installed_plugin_package(descriptor, error)) { + set_plugin_error(plugin_key, error); + return false; + } + + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Deleted plugin: " << plugin_key; + return true; +} + +bool PluginManager::unsubscribe_cloud_plugin(const std::string& plugin_key, std::string& error) +{ + if (!wait_for_discovery(std::chrono::milliseconds::max(), error)) + return false; + + error.clear(); + + PluginDescriptor descriptor; + if (!try_get_plugin_descriptor(plugin_key, descriptor)) { error = "Plugin not found: " + plugin_key; return false; } if (!descriptor.is_cloud_plugin()) { - error = "Only cloud plugins can be updated."; - m_catalog.set_plugin_error(plugin_key, error); + error = "Only cloud plugins can be unsubscribed."; + set_plugin_error(plugin_key, error); return false; } - // Empty version should represent that we are trying to update the current plugin. - // So if the version is empty but there isn't an update available, the intention is unclear. - if (version.empty()) { - if (descriptor.cloud->update_available) - version = descriptor.latest_available_version(); - else { - error = "Trying to update plugin with no available update. Version is empty."; - m_catalog.set_plugin_error(plugin_key, error); - return false; - } - } - - m_catalog.clear_plugin_error(plugin_key); - - if (descriptor.has_local_package()) { - boost::filesystem::path resolved_root; - if (!resolve_allowed_plugin_root(descriptor, m_catalog.get_plugin_directories(), - "Refusing to delete a plugin outside the known plugin directories.", resolved_root, error)) { - m_catalog.set_plugin_error(plugin_key, error); - return false; - } - - m_loader.unload_plugin(plugin_key, descriptor.primary_capability_type()); - - if (!delete_plugin_root(resolved_root, plugin_key, error)) { - m_catalog.set_plugin_error(plugin_key, error); - return false; - } - } - - if (!download_and_install_cloud_plugin(plugin_key, version, error)) { - m_catalog.set_plugin_error(plugin_key, error); - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": " << error; + if (descriptor.cloud && descriptor.cloud->is_mine) { + error = "Cannot unsubscribe your own plugins. Use Delete from Cloud instead."; + set_plugin_error(plugin_key, error); return false; } - m_catalog.clear_plugin_error(plugin_key); - return true; + if (!m_cloud_service.request_cloud_unsubscribe(descriptor, error)) { + set_plugin_error(plugin_key, error); + return false; + } + + return finalize_cloud_plugin_removal(descriptor, true, error); +} + +bool PluginManager::delete_and_unsubscribe_cloud_plugin(const std::string& plugin_key, std::string& error) +{ + if (!wait_for_discovery(std::chrono::milliseconds::max(), error)) + return false; + + error.clear(); + + PluginDescriptor descriptor; + if (!try_get_plugin_descriptor(plugin_key, descriptor)) { + error = "Plugin not found: " + plugin_key; + return false; + } + + if (!descriptor.is_cloud_plugin()) { + error = "Only cloud plugins can be deleted and unsubscribed."; + set_plugin_error(plugin_key, error); + return false; + } + + if (descriptor.cloud->is_mine) { + error = "Use Delete local and cloud for owned plugins."; + set_plugin_error(plugin_key, error); + return false; + } + + if (!m_cloud_service.request_cloud_unsubscribe(descriptor, error)) { + set_plugin_error(plugin_key, error); + return false; + } + + return finalize_cloud_plugin_removal(descriptor, false, error); +} + +bool PluginManager::delete_mine_plugin_from_cloud(const std::string& plugin_key, std::string& error) +{ + if (!wait_for_discovery(std::chrono::milliseconds::max(), error)) + return false; + + error.clear(); + + PluginDescriptor descriptor; + if (!try_get_plugin_descriptor(plugin_key, descriptor)) { + error = "Plugin not found: " + plugin_key; + return false; + } + + if (!descriptor.is_cloud_plugin()) { + error = "Only owned cloud plugins can be deleted from the cloud."; + set_plugin_error(plugin_key, error); + return false; + } + + if (!descriptor.cloud->is_mine) { + error = "Only your own plugins can be deleted from the cloud."; + set_plugin_error(plugin_key, error); + return false; + } + + if (!m_cloud_service.request_cloud_delete(descriptor, error)) { + set_plugin_error(plugin_key, error); + return false; + } + + return finalize_cloud_plugin_removal(descriptor, true, error); +} + +bool PluginManager::delete_mine_local_and_cloud_plugin(const std::string& plugin_key, std::string& error) +{ + if (!wait_for_discovery(std::chrono::milliseconds::max(), error)) + return false; + + error.clear(); + + PluginDescriptor descriptor; + if (!try_get_plugin_descriptor(plugin_key, descriptor)) { + error = "Plugin not found: " + plugin_key; + return false; + } + + if (!descriptor.is_cloud_plugin()) { + error = "Only owned cloud plugins can be deleted from local and cloud."; + set_plugin_error(plugin_key, error); + return false; + } + + if (!descriptor.cloud->is_mine) { + error = "Only your own plugins can be deleted from local and cloud."; + set_plugin_error(plugin_key, error); + return false; + } + + if (!m_cloud_service.request_cloud_delete(descriptor, error)) { + set_plugin_error(plugin_key, error); + return false; + } + + return finalize_cloud_plugin_removal(descriptor, false, error); +} + +ExecutionResult PluginManager::run_script_capability(const std::string& plugin_key, const std::string& capability_name, std::string& error) +{ + if (plugin_key.empty() || capability_name.empty()) { + return {}; + } + + auto cap = get_plugin_capability(plugin_key, capability_name, PluginCapabilityType::Script); + if (!cap) + return {}; + + auto cap_interface = std::dynamic_pointer_cast(cap); + if (!cap_interface) + return {}; + + ExecutionResult result; + try { + PythonGILState gil; + if (!gil) { + error = "Python interpreter is shutting down"; + return {}; + } + result = cap_interface->execute(); + } catch (const std::exception& ex) { + error = ex.what(); + BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw exception. plugin_key=" << plugin_key << " error=" << error; + } catch (...) { + error = "Unknown error"; + BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw unknown exception. plugin_key=" << plugin_key; + } + + if (!error.empty()) { + set_plugin_error(plugin_key, error); + } + + return result; } } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginManager.hpp b/src/slic3r/plugin/PluginManager.hpp index 6d45b5b577..08f2659c16 100644 --- a/src/slic3r/plugin/PluginManager.hpp +++ b/src/slic3r/plugin/PluginManager.hpp @@ -5,28 +5,110 @@ #include #include +#include #include #include +#include #include #include #include #include #include -#include +#include #include +#include + #include "CloudPluginService.hpp" -#include "PluginCatalog.hpp" -#include "PluginLoader.hpp" +#include "PluginFsUtils.hpp" #include "PluginDescriptor.hpp" +#include "PluginLoader.hpp" namespace Slic3r { class OrcaCloudServiceAgent; +// Identity of a single capability, as published to lifecycle subscribers. Purely a message +// payload — capabilities are looked up by (plugin_key, name) linear scan, so unlike the registry +// key this replaces, it needs no hash and no equality. +struct PluginCapabilityId +{ + PluginCapabilityType type = PluginCapabilityType::Unknown; + std::string name; + std::string plugin_key; // owning package +}; + +// One discovered plugin package: one .py/.whl file -> one descriptor + one Python module + +// N materialized capabilities. +// +// PluginManager holds every discovered plugin in a single std::vector, loaded or not; +// module == nullptr means the package was discovered but is not loaded. "What exists" and +// "what is live" are therefore the same vector, distinguished by a null check. +// +// A capability carries its own name, type and enable flag (PluginCapabilityInterface), cached by +// the loader at materialization, and its owning package via audit_plugin_key(). Capability state +// therefore lives exactly as long as the capability: a package that is not loaded has none. The +// only durable record is the .install_state.json sidecar, which the loader seeds enable flags from. +// The descriptor holds package state only. +struct Plugin +{ + PluginDescriptor descriptor; // All plugin state and metadata lives here. + PyObject* module = nullptr; // Python module object, shared by all capabilities. nullptr => not loaded. + std::string module_name; // Root name used in sys.modules, including package submodules. + std::vector plugin_sys_paths; // Paths added for this plugin load. + std::vector plugin_modules; // Plugin-originated sys.modules entries. + + // Materialized capability instances. Empty unless loaded. + std::vector> capabilities; + + Plugin() = default; + Plugin(const Plugin&) = delete; + Plugin& operator=(const Plugin&) = delete; + + // Move transfers module ownership; the moved-from package must not Py_DECREF it. Move + // assignment is required: the manager's vector is erased from and reordered. + Plugin(Plugin&& other) noexcept + : descriptor(std::move(other.descriptor)), + module(other.module), + module_name(std::move(other.module_name)), + plugin_sys_paths(std::move(other.plugin_sys_paths)), + plugin_modules(std::move(other.plugin_modules)), + capabilities(std::move(other.capabilities)) + { + other.module = nullptr; + } + + Plugin& operator=(Plugin&& other) noexcept + { + if (this != &other) { + release_module(); + descriptor = std::move(other.descriptor); + module = other.module; + module_name = std::move(other.module_name); + plugin_sys_paths = std::move(other.plugin_sys_paths); + plugin_modules = std::move(other.plugin_modules); + capabilities = std::move(other.capabilities); + other.module = nullptr; + } + return *this; + } + + ~Plugin() { release_module(); } + + bool is_loaded() const { return module != nullptr; } + + // Removes the module namespace and plugin-owned sys.path entries before dropping the module + // reference. If the interpreter is already finalized, PythonInterpreter deliberately leaves + // the raw reference untouched because there is no safe way to DECREF it. + void release_module(); +}; + class PluginManager { public: + using PluginLifecycleCompleteFn = std::function; + using CapabilityLifecycleFn = std::function; + static PluginManager& instance(); ~PluginManager(); @@ -37,35 +119,86 @@ public: // Stop discovery and unload Python plugin objects before Python finalizes. void shutdown(); - // Discover and scan plugins from standard directories (manifest-only, no Python loading). - // Runs on a worker thread when async=true. + // Reject new plugin loads. Called early in app teardown, before shutdown() drains. + void set_shutting_down(); + void discover_plugins(bool async = false, bool clear = false); - - // fetches plugins from the cloud - void fetch_plugins_from_cloud(std::vector* out_not_found = nullptr, - std::vector* out_unauthorized = nullptr); - - // Download and install a cloud plugin from its download_url. - // Returns an error string on failure (empty URL, network down, etc.). - // On success returns an empty string. - bool download_and_install_cloud_plugin(const std::string& plugin_key, const std::string& version, std::string& error); - bool subscribe_and_install_cloud_plugin(const std::string& plugin_key, std::string& error); - - // Manually trigger manifest-only rescan of plugins. Blocks until discovery is complete. + // Manually trigger a manifest-only rescan. Blocks until discovery is complete. void rescan_plugins(); - PluginCatalog& get_catalog() { return m_catalog; } - const PluginCatalog& get_catalog() const { return m_catalog; } - PluginLoader& get_loader() { return m_loader; } - const PluginLoader& get_loader() const { return m_loader; } + bool is_discovery_complete() const; + bool is_discovery_in_progress() const; + std::string get_discovery_error() const; + bool wait_for_discovery(std::chrono::milliseconds timeout, std::string& error) const; + + + std::vector get_plugin_descriptors(bool include_invalid = false) const; + // Packages that materialize at least one capability of this type. A package's capability types + // are only known once it has been loaded, so a never-loaded package never matches. + bool try_get_plugin_descriptor(const std::string& plugin_key, PluginDescriptor& out) const; + // Same, but only for packages that are loadable (i.e. not an invalid package). + bool try_get_valid_plugin_descriptor(const std::string& plugin_key, PluginDescriptor& out) const; + // Packages whose .install_state.json marks them for auto-load. + std::vector get_enabled_plugin_keys() const; + // The package owning a loaded capability, for the by-name dispatch path. + bool try_get_plugin_descriptor_for_capability(const std::string& capability_name, + PluginCapabilityType type, + PluginDescriptor& out) const; + + std::vector> get_plugin_capabilities( + const std::string& plugin_key = "", // "" => all plugins + PluginCapabilityType type = PluginCapabilityType::Unknown, // Unknown => all types + bool only_enabled = true) const; + std::shared_ptr get_plugin_capability(const std::string& plugin_key, + const std::string& capability_name, + PluginCapabilityType type = PluginCapabilityType::Unknown, + bool only_enabled = true) const; + std::shared_ptr get_plugin_capability(const std::string& capability_name, + PluginCapabilityType type = PluginCapabilityType::Unknown, + bool only_enabled = true) const; + + void load_plugin(const std::string& plugin_key, bool skip_deps = false, std::vector capabilities_to_enable = {}); + bool unload_plugin(const std::string& plugin_key); + void unload_all_plugins(); + void unload_cloud_plugins(); + bool is_plugin_loaded(const std::string& plugin_key) const; + bool is_plugin_load_in_progress(const std::string& plugin_key) const; + void wait_for_all_plugin_loads() const; + bool wait_for_all_plugin_loads(std::chrono::milliseconds timeout) const; // false on timeout + bool wait_for_plugin_load(const std::string& plugin_key, std::chrono::milliseconds timeout, std::string& error) const; + bool cancel_plugin_load(const std::string& plugin_key); + std::string get_plugin_load_error(const std::string& plugin_key) const; + + void set_capability_enabled(const std::string& plugin_key, const std::string& capability_name, bool enabled); + + // The plugin's [tool.orcaslicer.plugin.settings] table (empty if the plugin is unknown). This will be replaced once the config is merged in. + std::map get_plugin_settings(const std::string& plugin_key) const; + // Sets the cloud user whose _subscribed/{user_id} directory is scanned and installed into. + void set_cloud_user(const std::string& user_id); + + void subscribe_on_load_callback(PluginLifecycleCompleteFn fn); + void subscribe_on_unload_callback(PluginLifecycleCompleteFn fn); + void subscribe_on_capability_load_callback(CapabilityLifecycleFn fn); + void subscribe_on_capability_unload_callback(CapabilityLifecycleFn fn); void set_cloud_agent(std::shared_ptr agent) { m_cloud_service.set_cloud_agent(std::move(agent)); } bool install_plugin(const boost::filesystem::path& filepath, std::string& error); bool install_plugin(const boost::filesystem::path& filepath, PluginDescriptor& plugin_descriptor, std::string& error); + bool inspect_local_plugin_package(const boost::filesystem::path& filepath, + PluginDescriptor& plugin_descriptor, + bool& existing_installation, + std::string& error) const; + bool set_plugin_error(const std::string& plugin_key, std::string error); bool clear_plugin_error(const std::string& plugin_key); + void fetch_plugins_from_cloud(std::vector* out_not_found = nullptr, std::vector* out_unauthorized = nullptr); + void update_cloud_catalog(const std::vector& cloud_list); + void clear_cloud_plugin_catalog(); + + bool download_and_install_cloud_plugin(const std::string& plugin_key, const std::string& version, std::string& error); + bool subscribe_and_install_cloud_plugin(const std::string& plugin_key, std::string& error); // If the version is empty, take the latest version. bool update_cloud_plugin(const std::string& plugin_key, std::string& error, std::string version = ""); @@ -75,32 +208,101 @@ public: bool delete_mine_plugin_from_cloud(const std::string& plugin_key, std::string& error); bool delete_mine_local_and_cloud_plugin(const std::string& plugin_key, std::string& error); + ExecutionResult run_script_capability(const std::string& plugin_key, const std::string& capability_name, std::string& error); + private: PluginManager() = default; PluginManager(const PluginManager&) = delete; PluginManager& operator=(const PluginManager&) = delete; + enum class CallbackType { Load, Unload }; + + // Caller holds m_mutex. The returned pointer is invalidated by any push_back into m_plugins, + // so it must never escape the lock. + Plugin* find_plugin_locked(const std::string& plugin_key); + const Plugin* find_plugin_locked(const std::string& plugin_key) const; + + void load_plugin_impl(const std::string& plugin_key, bool skip_deps, const std::vector& capabilities_to_enable); + // Caller holds m_mutex. Non-empty return means the load must be abandoned. + std::string check_registry_locked(const std::string& plugin_key, const Plugin& candidate) const; + + void run_discovery(bool async, bool clear); + void run_discovery_task(bool clear); + void merge_discovered_plugins(std::vector discovered, bool clear); + + // Unload every loaded Plugin matching should_remove, then erase every matching entry (loaded or + // not). Unloading runs outside m_mutex (Python teardown and lifecycle callbacks can re-enter the + // manager), so the snapshot-unload cycle retries until a pass finds nothing left to unload; the + // final check and the erase itself run in one critical section so a concurrent load cannot make a + // live entry reach vector compaction and move-assignment. after_erase_locked, if given, runs right + // after the erase while m_mutex is still held. + void unload_and_erase_if(const std::function& should_remove, + const std::function& after_erase_locked = {}); + + bool cancel_plugin_load_locked(const std::string& plugin_key); + bool is_plugin_load_cancelled_locked(const std::string& plugin_key) const; + void notify_plugin_load_state_changed(bool changed); + void release_load_slot(const std::string& plugin_key); + void cancel_and_wait_for_capabilities( + const std::vector>& capabilities); + + // Snapshot subscribers under m_mutex so they can be invoked without holding it. + std::vector copy_callbacks(CallbackType type) const; + std::vector copy_capability_callbacks(CallbackType type) const; + void run_on_load_callbacks(const std::string& plugin_key); + void run_on_unload_callbacks(const std::string& plugin_key); + void run_on_capability_load_callbacks(const PluginCapabilityId& id); + void run_on_capability_unload_callbacks(const PluginCapabilityId& id); + void clear_callbacks(); + + // Writes the sidecar for a loaded plugin (enabled=true plus the current per-capability flags). + void write_loaded_plugin_install_state(const std::string& plugin_key); + bool finalize_cloud_plugin_removal(const PluginDescriptor& plugin, bool keep_local, std::string& error); bool delete_installed_plugin_package(const PluginDescriptor& plugin, std::string& error); bool keep_installed_plugin_as_local(const PluginDescriptor& plugin_descriptor, std::string& error); bool m_initialized = false; CloudPluginService m_cloud_service; - PluginCatalog m_catalog; - PluginLoader m_loader; + // Leaf lock: code holding m_mutex must not call Python, acquire the GIL, invoke lifecycle + // callbacks, or re-enter the manager. Live plugin payloads are detached and torn down after + // releasing this lock. mutable std::mutex m_mutex; - std::unordered_map&)>>> m_loaded_plugin_changed_callbacks; + // Every discovered plugin, loaded or not. module == nullptr => not loaded. + std::vector m_plugins; + + std::unordered_set m_load_in_progress; + // Keys whose in-flight load has been cancelled. Cancellation does NOT remove the key from + // m_load_in_progress: the detached worker is still inside the loader touching Python, and it + // releases its own slot only once it has unwound. Dropping the key here would let + // wait_for_all_plugin_loads() return early, after which shutdown() unloads everything and + // GUI_App finalizes the interpreter — leaving the live worker to run Python against it. + std::unordered_set m_cancelled; + std::map m_load_errors; + mutable std::condition_variable m_load_cv; + + std::map> m_callbacks; + std::map> m_capability_callbacks; + + bool m_discovery_complete = false; + bool m_discovery_in_progress = false; + std::string m_discovery_error; + mutable std::condition_variable m_discovery_cv; + + std::string m_cloud_user_id; + std::atomic m_shutting_down{false}; }; -template +// Resolve each configured capability reference to a loaded capability of type T and run `execute`. +template void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities, const ConfigOptionStrings* plugins, PluginCapabilityType type, std::function, const PluginCapabilityRef&)> execute) { - PluginManager& plugin_mgr = PluginManager::instance(); + PluginManager& plugin_mgr = PluginManager::instance(); // Log prefix derived from the capability type so each capability family (Printer connection, // Slicing Pipeline, ...) tags its dispatch diagnostics with its own display name. @@ -108,7 +310,7 @@ void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities, const bool has_any = std::any_of(capabilities.values.begin(), capabilities.values.end(), [](const std::string& s) { return !s.empty(); }); - if (has_any && !plugin_mgr.get_loader().wait_for_all_plugin_loads(std::chrono::seconds(10))) { + if (has_any && !plugin_mgr.wait_for_all_plugin_loads(std::chrono::seconds(10))) { BOOST_LOG_TRIVIAL(warning) << tag << ": timed out waiting for plugin loads; unresolved capabilities will be skipped"; } @@ -116,9 +318,6 @@ void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities, if (capability.empty()) continue; - std::shared_ptr cap; - std::string cap_name, plugin_key; - std::optional ref; if (plugins != nullptr) { for (const std::string& plugin_ref : plugins->values) { @@ -135,25 +334,26 @@ void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities, continue; } - cap_name = ref->capability_name; - plugin_key = ref->uuid.empty() ? ref->name : ref->uuid; - cap = plugin_mgr.get_loader().get_plugin_capability_by_name(plugin_key, type, cap_name); + const std::string cap_name = ref->capability_name; + const std::string plugin_key = ref->uuid.empty() ? ref->name : ref->uuid; + // only_enabled = false so that "not loaded" and "loaded but disabled" stay distinguishable + // and each keeps its own diagnostic. + auto cap = plugin_mgr.get_plugin_capability(plugin_key, cap_name, type, /*only_enabled=*/false); if (!cap) { - BOOST_LOG_TRIVIAL(warning) << tag << ": no loaded capability '" << cap_name - << "' for plugin '" << plugin_key << "'; skipping"; - continue; - } - if (!cap->enabled) { - BOOST_LOG_TRIVIAL(warning) << tag << ": capability '" << cap_name - << "' for plugin '" << plugin_key << "' is disabled; skipping"; + BOOST_LOG_TRIVIAL(warning) << tag << ": no loaded capability '" << cap_name << "' for plugin '" << plugin_key << "'; skipping"; continue; } - auto plugin_capability = std::dynamic_pointer_cast(cap->instance); + if (!cap->is_enabled()) { + BOOST_LOG_TRIVIAL(warning) << tag << ": capability '" << cap_name << "' for plugin '" << plugin_key + << "' is disabled; skipping"; + continue; + } + + auto plugin_capability = std::dynamic_pointer_cast(cap); if (!plugin_capability) { - BOOST_LOG_TRIVIAL(warning) << tag << ": capability '" << cap_name - << "' (plugin_key=" << cap->plugin_key + BOOST_LOG_TRIVIAL(warning) << tag << ": capability '" << cap_name << "' (plugin_key=" << cap->audit_plugin_key() << ") is not a " << plugin_capability_type_to_string(type) << "; skipping"; continue; } diff --git a/src/slic3r/plugin/PluginResolver.cpp b/src/slic3r/plugin/PluginResolver.cpp index d7808b6a5b..222ea8a3d5 100644 --- a/src/slic3r/plugin/PluginResolver.cpp +++ b/src/slic3r/plugin/PluginResolver.cpp @@ -116,15 +116,19 @@ std::string resolve_recovery_url(const PluginCapabilityRef& ref) // loaded or does not provide the capability. static std::pair loaded_capability_state(const std::string& plugin_key, const PluginCapabilityRef& ref) { - bool present = false, enabled = false; - PluginLoader& loader = PluginManager::instance().get_loader(); - for (const auto& capability : loader.get_loaded_plugin_capabilities(plugin_key)) - if (capability && capability->name == ref.capability_name) { - present = true; - if (capability->enabled) - enabled = true; - } - return {present, enabled}; + PluginManager& mgr = PluginManager::instance(); + + // Only a loaded package exposes capabilities; a discovered one carries their names in its + // descriptor but has materialized nothing. + if (!mgr.is_plugin_loaded(plugin_key)) + return {false, false}; + + const auto capability = mgr.get_plugin_capability(plugin_key, ref.capability_name, PluginCapabilityType::Unknown, + /*only_enabled=*/false); + if (!capability) + return {false, false}; + + return {true, capability->is_enabled()}; } void refresh_missing_plugins(const PresetBundle& preset_bundle) @@ -162,8 +166,7 @@ void refresh_missing_plugins(Preset::Type type, const ConfigOptionStrings* manif if (manifest == nullptr) return; - PluginLoader& loader = PluginManager::instance().get_loader(); - const PluginCatalog& catalog = PluginManager::instance().get_catalog(); + PluginManager& mgr = PluginManager::instance(); for (const std::string& entry : manifest->values) { const auto ref = parse_capability_ref(entry); if (!ref) @@ -175,9 +178,9 @@ void refresh_missing_plugins(Preset::Type type, const ConfigOptionStrings* manif continue; PluginDescriptor descriptor; - const bool in_catalog = catalog.try_get_plugin_descriptor(key, descriptor); + const bool in_catalog = mgr.try_get_plugin_descriptor(key, descriptor); const bool installed = in_catalog && descriptor.has_local_package(); - const bool loaded = installed && loader.is_plugin_loaded(descriptor.plugin_key); + const bool loaded = installed && mgr.is_plugin_loaded(descriptor.plugin_key); const auto cap_state = installed ? loaded_capability_state(descriptor.plugin_key, *ref) : std::pair{false, false}; const bool cap_present = cap_state.first; @@ -311,7 +314,7 @@ void resolve_missing_plugins(const std::vector& refs, PluginInstall // Use a friendly name for the progress message when the catalog already knows it. std::string display_name = uuid; PluginDescriptor known; - if (mgr.get_catalog().try_get_plugin_descriptor(uuid, known) && !known.name.empty()) + if (mgr.try_get_plugin_descriptor(uuid, known) && !known.name.empty()) display_name = known.name; if (progress.on_plugin_begin) progress.on_plugin_begin(display_name, i, total); @@ -322,14 +325,13 @@ void resolve_missing_plugins(const std::vector& refs, PluginInstall continue; } PluginDescriptor descriptor; - if (!mgr.get_catalog().try_get_plugin_descriptor(uuid, descriptor)) { + if (!mgr.try_get_plugin_descriptor(uuid, descriptor)) { report_install_failure(uuid + ": installed plugin was not found in the catalog."); continue; } - PluginLoader& loader = mgr.get_loader(); - loader.load_plugin(mgr.get_catalog(), descriptor.plugin_key, false); - if (!loader.wait_for_plugin_load(descriptor.plugin_key, std::chrono::minutes(5), error) || - !loader.is_plugin_loaded(descriptor.plugin_key)) { + mgr.load_plugin(descriptor.plugin_key, false); + if (!mgr.wait_for_plugin_load(descriptor.plugin_key, std::chrono::minutes(5), error) || + !mgr.is_plugin_loaded(descriptor.plugin_key)) { report_install_failure(descriptor.name + ": " + (error.empty() ? "plugin failed to load." : error)); } } @@ -341,8 +343,7 @@ void resolve_missing_plugins(const std::vector& refs, PluginInstall void resolve_inactive_plugins(const std::vector& refs) { - PluginManager& mgr = PluginManager::instance(); - PluginCatalog& catalog = mgr.get_catalog(); + PluginManager& mgr = PluginManager::instance(); // Group the requested capabilities by owning plugin so each plugin is loaded once with the full // set to enable. @@ -353,7 +354,7 @@ void resolve_inactive_plugins(const std::vector& refs) continue; const std::string key = ref->uuid.empty() ? ref->name : ref->uuid; PluginDescriptor descriptor; - if (!catalog.try_get_plugin_descriptor(key, descriptor) || !descriptor.has_local_package()) + if (!mgr.try_get_plugin_descriptor(key, descriptor) || !descriptor.has_local_package()) continue; by_plugin[descriptor.plugin_key].push_back(ref->capability_name); } @@ -367,13 +368,11 @@ void resolve_inactive_plugins(const std::vector& refs) // if the loaded plugin turns out not to provide the capability. std::vector>> work(by_plugin.begin(), by_plugin.end()); std::thread([work = std::move(work)]() { - PluginManager& mgr = PluginManager::instance(); - PluginLoader& loader = mgr.get_loader(); - PluginCatalog& catalog = mgr.get_catalog(); + PluginManager& mgr = PluginManager::instance(); for (auto& [plugin_key, capabilities] : work) { - loader.load_plugin(catalog, plugin_key, /*skip_deps=*/false, capabilities); + mgr.load_plugin(plugin_key, /*skip_deps=*/false, capabilities); std::string error; - loader.wait_for_plugin_load(plugin_key, std::chrono::minutes(5), error); + mgr.wait_for_plugin_load(plugin_key, std::chrono::minutes(5), error); } GUI::wxGetApp().CallAfter([]() { if (GUI::Plater* plater = GUI::wxGetApp().plater()) @@ -394,8 +393,4 @@ void open_missing_plugins_on_cloud(const std::vector& local_refs) wxLaunchDefaultBrowser(GUI::from_u8(resolve_cloud_base_url() + "/app/plugins/plugin-hub"), wxBROWSER_NEW_WINDOW); } -bool check_capability_in_use(const std::string &capability_refs) { - return false; -} - } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginResolver.hpp b/src/slic3r/plugin/PluginResolver.hpp index 11b1999a37..10544abd90 100644 --- a/src/slic3r/plugin/PluginResolver.hpp +++ b/src/slic3r/plugin/PluginResolver.hpp @@ -81,8 +81,6 @@ void open_missing_plugins_on_cloud(const std::vector& local_refs); std::string create_full_ref(const PluginCapabilityRef& ref); std::string resolve_recovery_url(const PluginCapabilityRef& ref); -bool check_capability_in_use(const std::string& capability_refs); - } // namespace Slic3r #endif diff --git a/src/slic3r/plugin/PyPluginTrampoline.hpp b/src/slic3r/plugin/PyPluginTrampoline.hpp index 5ff4514c57..0864954bde 100644 --- a/src/slic3r/plugin/PyPluginTrampoline.hpp +++ b/src/slic3r/plugin/PyPluginTrampoline.hpp @@ -4,6 +4,7 @@ #include #include +#include #include "PythonPluginInterface.hpp" #include "PythonInterpreter.hpp" @@ -20,29 +21,32 @@ // Logs (and rethrows) a Python exception from a pybind11 override call, preserving the // traceback. Internal helper shared by the public macros below and by trampolines that // manage their own audit scope (e.g. the G-code plugin). -#define ORCA_PY_LOGGED_OVERRIDE_BODY(override_call) \ - try { \ - override_call; \ - } catch (pybind11::error_already_set & err) { \ - ::Slic3r::log_python_exception_keep(err); \ - throw; \ +#define ORCA_PY_LOGGED_OVERRIDE_BODY(override_call) \ + try { \ + override_call; \ + } catch (pybind11::error_already_set & err) { \ + ::Slic3r::log_python_exception_keep(err); \ + throw; \ } // Opens the plugin's filesystem audit scope for the duration of a C++ -> Python call // when this trampoline instance carries a non-empty audit plugin key. Declares a local // `_orca_audit_scope`. -#define ORCA_PY_AUDIT_SCOPE(mode) \ - std::optional<::Slic3r::ScopedPluginAuditContext> _orca_audit_scope; \ - if (const std::string& _orca_audit_key = this->audit_plugin_key(); \ - !_orca_audit_key.empty()) \ - _orca_audit_scope.emplace(_orca_audit_key, mode) +#define ORCA_PY_AUDIT_SCOPE(mode) \ + std::optional<::Slic3r::ScopedPluginAuditContext> _orca_audit_scope; \ + if (const std::string& _orca_audit_key = this->audit_plugin_key(); !_orca_audit_key.empty()) \ + _orca_audit_scope.emplace(_orca_audit_key, mode) #define ORCA_PY_OVERRIDE_AUDITED(mode, audit_setup, override_macro, ret, base, name, ...) \ - do { \ - ORCA_PY_AUDIT_SCOPE(mode); \ - if (_orca_audit_scope) \ - audit_setup(); \ - ORCA_PY_LOGGED_OVERRIDE_BODY(override_macro(ret, base, name, ##__VA_ARGS__)); \ + do { \ + ::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this); \ + ::Slic3r::PythonGILState _orca_python_gil; \ + if (!_orca_python_gil) \ + throw std::runtime_error("Python interpreter is shutting down"); \ + ORCA_PY_AUDIT_SCOPE(mode); \ + if (_orca_audit_scope) \ + audit_setup(); \ + ORCA_PY_LOGGED_OVERRIDE_BODY(override_macro(ret, base, name, ##__VA_ARGS__)); \ } while (0) namespace Slic3r { @@ -54,36 +58,23 @@ public: // get_name is required on all capabilities — Python subclass must implement it. std::string get_name() const override { - ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, - [] {}, - PYBIND11_OVERRIDE_PURE, - std::string, - Base, - get_name); + ORCA_PY_OVERRIDE_AUDITED(::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, std::string, Base, get_name); } // All plugins may define their own on_load/unload functions. void on_load() override { - ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, - [] {}, - PYBIND11_OVERRIDE, - void, - Base, - on_load); + ORCA_PY_OVERRIDE_AUDITED(::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, void, Base, on_load); } void on_unload() override { - ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, - [] {}, - PYBIND11_OVERRIDE, - void, - Base, - on_unload); + ORCA_PY_OVERRIDE_AUDITED(::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, void, Base, on_unload); + } + + void on_cancelled() override + { + ORCA_PY_OVERRIDE_AUDITED(::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, void, Base, on_cancelled); } }; @@ -97,11 +88,7 @@ public: PluginCapabilityType get_type() const override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, - [] {}, - PYBIND11_OVERRIDE, - PluginCapabilityType, - PluginCapabilityInterface, + ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, PluginCapabilityType, PluginCapabilityInterface, get_type); } }; diff --git a/src/slic3r/plugin/PythonFileUtils.cpp b/src/slic3r/plugin/PythonFileUtils.cpp index 9fec2ee13f..9a9c117f8f 100644 --- a/src/slic3r/plugin/PythonFileUtils.cpp +++ b/src/slic3r/plugin/PythonFileUtils.cpp @@ -492,35 +492,25 @@ bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boo void read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry) { - namespace fs = boost::filesystem; - const fs::path sidecar_path = plugin_dir / ".install_state.json"; - - if (!fs::exists(sidecar_path) || !fs::is_regular_file(sidecar_path)) + PluginInstallState state; + if (!read_install_state(plugin_dir, state)) return; - boost::nowide::ifstream f(sidecar_path.string()); - if (!f) - return; + // The cloud identity and the persisted installed version are read back. plugin_key + // is always derived by the catalog scan (filename for local, the cloud uuid for + // cloud), so it is not read from the sidecar. installed_version is the source of + // truth for a cloud plugin's installed version: it records the version fetched from + // the cloud at install time, independent of the (possibly stale) manifest/PEP723 + // header that scan_directory parses into entry.version. + if (!state.installed_version.empty()) + entry.installed_version = state.installed_version; + if (!state.cloud_uuid.empty()) + entry.cloud = CloudPluginState{state.cloud_uuid, true, false, false}; - try { - nlohmann::json state = nlohmann::json::parse(f, nullptr, false, true); - if (state.is_discarded() || !state.is_object()) - return; - - // The cloud identity and the persisted installed version are read back. plugin_key - // is always derived by the catalog scan (filename for local, the cloud uuid for - // cloud), so it is not read from the sidecar. installed_version is the source of - // truth for a cloud plugin's installed version: it records the version fetched from - // the cloud at install time, independent of the (possibly stale) manifest/PEP723 - // header that scan_directory parses into entry.version. - if (state.contains("installed_version") && state["installed_version"].is_string()) - entry.installed_version = state["installed_version"].get(); - if (state.contains("cloud_uuid") && state["cloud_uuid"].is_string()) { - const std::string cloud_uuid = state["cloud_uuid"].get(); - if (!cloud_uuid.empty()) - entry.cloud = CloudPluginState{cloud_uuid, true, false, false}; - } - } catch (...) {} + // Package-level auto-load flag only. The per-capability enable flags stay in the sidecar: a + // capability has no existence — and so no state — until it is materialized, at which point the + // loader seeds the flag onto the capability itself. + entry.enabled = state.enabled; } bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstallState& out) @@ -616,6 +606,10 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry) { + // Install-time writer: the package is not loaded, so its capabilities are not known yet and the + // sidecar is (re)initialized to "auto-load, nothing disabled". PluginManager writes the real + // per-capability flags once the package is loaded, via the (dir, entry, enabled, capabilities) + // overload. return write_install_state(plugin_dir, entry, true, {}); } diff --git a/src/slic3r/plugin/PythonInterpreter.cpp b/src/slic3r/plugin/PythonInterpreter.cpp index be65f0467e..2ff275ccb3 100644 --- a/src/slic3r/plugin/PythonInterpreter.cpp +++ b/src/slic3r/plugin/PythonInterpreter.cpp @@ -15,17 +15,77 @@ #include #include #include +#include #include #include namespace Slic3r { +thread_local PythonInterpreter* PythonRuntimeLease::s_owner = nullptr; +thread_local unsigned int PythonRuntimeLease::s_depth = 0; + +PythonRuntimeLease::PythonRuntimeLease(PythonInterpreter& interpreter) +{ + if (s_owner == &interpreter) { + m_interpreter = &interpreter; + ++s_depth; + return; + } + + m_lock = std::shared_lock(interpreter.m_runtime_mutex); + if (!interpreter.m_initialized.load(std::memory_order_acquire)) { + m_lock.unlock(); + return; + } + + m_interpreter = &interpreter; + s_owner = &interpreter; + s_depth = 1; +} + +PythonRuntimeLease::PythonRuntimeLease(PythonRuntimeLease&& other) noexcept + : m_interpreter(other.m_interpreter), m_lock(std::move(other.m_lock)) +{ + other.m_interpreter = nullptr; +} + +PythonRuntimeLease& PythonRuntimeLease::operator=(PythonRuntimeLease&& other) noexcept +{ + if (this != &other) { + release(); + m_lock = std::move(other.m_lock); + m_interpreter = other.m_interpreter; + other.m_interpreter = nullptr; + } + return *this; +} + +void PythonRuntimeLease::release() +{ + if (!m_interpreter) + return; + + if (s_owner == m_interpreter && s_depth > 0) { + --s_depth; + if (s_depth == 0) + s_owner = nullptr; + } + m_interpreter = nullptr; +} + +PythonRuntimeLease::~PythonRuntimeLease() +{ + release(); +} + void log_python_exception_keep(pybind11::error_already_set& err) { // The GIL may already be released here: the macro's gil_scoped_acquire is a // local destroyed by stack unwinding before this catch runs. Touching Python // state below needs the GIL. PyGILState_Ensure is reentrant — harmless if held. PythonGILState gil; + if (!gil) + return; // Non-destructive: print the traceback to sys.stderr (tee'd to the session log) // WITHOUT consuming err, so the caller can rethrow it intact. For example, downstream C++ @@ -108,8 +168,11 @@ std::string executable_name(const char* base) #endif } -bool add_sys_path_entry(const boost::filesystem::path& path, std::string& error) +bool add_sys_path_entry(const boost::filesystem::path& path, std::string& error, bool* inserted = nullptr) { + if (inserted) + *inserted = false; + PyObject* sys_path = PySys_GetObject("path"); if (!sys_path || !PyList_Check(sys_path)) { error = "Python sys.path is not available"; @@ -136,6 +199,9 @@ bool add_sys_path_entry(const boost::filesystem::path& path, std::string& error) return false; } + if (inserted) + *inserted = true; + return true; } @@ -387,94 +453,23 @@ std::string PythonInterpreter::bundled_uv_path() bool PythonInterpreter::initialize() { - if (m_initialized) { + std::unique_lock runtime_lock(m_runtime_mutex); + if (m_initialized.load(std::memory_order_acquire)) { return true; } m_last_error.clear(); + m_plugin_path_users.clear(); + m_plugin_path_owned.clear(); + m_plugin_module_users.clear(); + m_plugin_module_owned.clear(); try { // Set Python home to the bundled Python installation // This is critical for finding the standard library (encodings module, etc.) namespace fs = boost::filesystem; - std::string python_home; - const auto valid_python_home = [](const fs::path& candidate) { -#ifdef _WIN32 - return fs::exists(candidate / "Lib" / "encodings") && - (fs::exists(candidate / PYTHON_DLL) || fs::exists(candidate / PYTHON_DEBUG_DLL)); -#else - return fs::exists(candidate / "lib" / PYTHON_STDLIB_DIR / "encodings"); -#endif - }; - -// Determine Python home based on application structure -// Python is bundled at different locations depending on platform and build type - -// Strategy 1: Platform-specific bundled locations (highest priority) -#ifdef __APPLE__ - // macOS app bundle: OrcaSlicer.app/Contents/MacOS/python - // (resources_dir is Contents/Resources, so go up and into MacOS) - fs::path bundle_python = fs::path(resources_dir()).parent_path() / "MacOS" / "python"; - if (valid_python_home(bundle_python)) { - python_home = bundle_python.string(); - BOOST_LOG_TRIVIAL(info) << "Found Python in macOS app bundle: " << python_home; - } -#elif defined(_WIN32) - fs::path exe_python = boost::dll::program_location().parent_path() / "python"; - if (valid_python_home(exe_python)) { - python_home = exe_python.string(); - BOOST_LOG_TRIVIAL(info) << "Found Python next to Windows executable: " << python_home; - } -#else - // Linux: typically in ../lib or ../share relative to binary - fs::path linux_python = fs::path(resources_dir()).parent_path() / "lib" / "python"; - if (valid_python_home(linux_python)) { - python_home = linux_python.string(); - BOOST_LOG_TRIVIAL(info) << "Found Python in Linux install: " << python_home; - } -#endif - - // Strategy 2: Configured development dependency directory. - if (python_home.empty()) { - fs::path configured_python = ORCA_BUNDLED_PYTHON_ROOT; - if (!configured_python.empty() && valid_python_home(configured_python)) { - python_home = configured_python.string(); - BOOST_LOG_TRIVIAL(info) << "Found Python in configured bundled path: " << python_home; - } - } - - // Strategy 3: Development build directory from runtime environment. - if (python_home.empty()) { - const char* prefix_path = std::getenv("CMAKE_PREFIX_PATH"); - if (prefix_path && std::strlen(prefix_path) > 0) { - fs::path libpython = fs::path(prefix_path) / "libpython"; - if (valid_python_home(libpython)) { - python_home = libpython.string(); - BOOST_LOG_TRIVIAL(info) << "Found Python in CMAKE_PREFIX_PATH: " << python_home; - } - } - } - - // Strategy 3: Check resources directory (alternate bundling location) - if (python_home.empty()) { - fs::path res_python = fs::path(resources_dir()) / "python"; - if (valid_python_home(res_python)) { - python_home = res_python.string(); - BOOST_LOG_TRIVIAL(info) << "Found Python in resources directory: " << python_home; - } - } - -// Strategy 4: Check data_dir (user configuration directory) -#ifndef _WIN32 - if (python_home.empty()) { - fs::path data_python = fs::path(data_dir()) / "python"; - if (valid_python_home(data_python)) { - python_home = data_python.string(); - BOOST_LOG_TRIVIAL(info) << "Found Python in data directory: " << python_home; - } - } -#endif + const std::string python_home = find_bundled_python_home().string(); if (python_home.empty()) { m_last_error = "Could not locate bundled Python installation"; @@ -493,6 +488,8 @@ bool PythonInterpreter::initialize() return false; } + BOOST_LOG_TRIVIAL(info) << "Found bundled Python home: " << python_home; + // Verify Python standard library directory exists #ifdef _WIN32 fs::path python_lib = fs::path(python_home) / "Lib"; @@ -539,6 +536,10 @@ bool PythonInterpreter::initialize() // Set Python home - this is the prefix where Python libraries are located PyConfig config; PyConfig_InitPythonConfig(&config); + // Do not let the host process's PYTHONPATH or user site-packages override the bundled + // runtime used by plugins. + config.use_environment = 0; + config.user_site_directory = 0; BOOST_LOG_TRIVIAL(debug) << "Calling PyConfig_SetBytesString with home=" << python_home; PyStatus status = PyConfig_SetBytesString(&config, &config.home, python_home.c_str()); @@ -631,11 +632,10 @@ bool PythonInterpreter::initialize() // background-thread exceptions) to /log/python_*.log. install_python_stderr_redirect(); - m_initialized = true; - // Release the GIL so other threads can acquire it via PyGILState_Ensure. // Without this, calls from background threads will block trying to acquire the GIL. m_main_thread_state = PyEval_SaveThread(); + m_initialized.store(true, std::memory_order_release); BOOST_LOG_TRIVIAL(debug) << "Main thread released Python GIL after initialization"; return true; @@ -650,7 +650,8 @@ PythonInterpreter::~PythonInterpreter() { shutdown(); } void PythonInterpreter::shutdown() { - if (!m_initialized) + std::unique_lock runtime_lock(m_runtime_mutex); + if (!m_initialized.load(std::memory_order_acquire)) return; BOOST_LOG_TRIVIAL(info) << "Python interpreter shutdown enter"; @@ -668,80 +669,305 @@ void PythonInterpreter::shutdown() m_interpreter.reset(); BOOST_LOG_TRIVIAL(info) << "Python interpreter finalized"; - m_initialized = false; + m_plugin_path_users.clear(); + m_plugin_path_owned.clear(); + m_plugin_module_users.clear(); + m_plugin_module_owned.clear(); + m_initialized.store(false, std::memory_order_release); } -bool PythonInterpreter::add_sys_path(const std::string& path, std::string& error) +bool PythonInterpreter::add_plugin_sys_path(const std::string& path, std::string& error) { - if (!m_initialized) { + if (!m_initialized.load(std::memory_order_acquire)) { error = "Python interpreter not initialized"; return false; } PythonGILState gil; + if (!gil) { + error = "Python interpreter is shutting down"; + return false; + } + return add_plugin_sys_path_locked(path, error); +} + +bool PythonInterpreter::add_plugin_sys_path_locked(const std::string& path, std::string& error) +{ + bool inserted = false; + if (!add_sys_path_entry(boost::filesystem::path(path), error, &inserted)) + return false; + + auto users = m_plugin_path_users.find(path); + if (users == m_plugin_path_users.end()) { + m_plugin_path_owned[path] = inserted; + m_plugin_path_users.emplace(path, 1); + } else { + ++users->second; + } + + return true; +} + +void PythonInterpreter::remove_plugin_sys_paths_locked(const std::vector& paths) +{ PyObject* sys_path = PySys_GetObject("path"); if (!sys_path || !PyList_Check(sys_path)) { - error = "Python sys.path is not available"; - return false; + PyErr_Clear(); + return; } - PyObjectPtr py_path(PyUnicode_DecodeFSDefault(path.c_str())); - if (!py_path) { - error = "Failed to decode path for Python sys.path: " + path; - PyErr_Clear(); - return false; + for (const std::string& path : paths) { + auto users = m_plugin_path_users.find(path); + if (users == m_plugin_path_users.end()) + continue; + + if (--users->second != 0) + continue; + + const bool owned = m_plugin_path_owned[path]; + m_plugin_path_users.erase(users); + m_plugin_path_owned.erase(path); + if (!owned) + continue; + + PyObjectPtr py_path(PyUnicode_DecodeFSDefault(path.c_str())); + if (!py_path) { + PyErr_Clear(); + continue; + } + + for (Py_ssize_t index = PyList_Size(sys_path) - 1; index >= 0; --index) { + PyObject* entry = PyList_GetItem(sys_path, index); // borrowed reference + int equal = entry ? PyObject_RichCompareBool(entry, py_path.get(), Py_EQ) : 0; + if (equal == 1 && PySequence_DelItem(sys_path, index) != 0) + PyErr_Clear(); + else if (equal < 0) + PyErr_Clear(); + } } - - const int contains = PySequence_Contains(sys_path, py_path.get()); - if (contains == 1) - return true; - if (contains < 0) - PyErr_Clear(); - - if (PyList_Insert(sys_path, 0, py_path.get()) != 0) { - error = "Failed to append path to Python sys.path: " + path; - PyErr_Clear(); - return false; - } - - return true; } -bool PythonInterpreter::execute_string(const std::string& code, std::string& error) +void PythonInterpreter::record_plugin_modules_locked(const std::string& module_name, + const std::vector& plugin_paths, + std::vector* plugin_modules) { - if (!m_initialized) { - error = "Python interpreter not initialized"; - return false; + if (!plugin_modules) + return; + + PyObject* modules = PyImport_GetModuleDict(); + if (!modules) + return; + + PyObjectPtr names(PyDict_Keys(modules)); + if (!names) { + PyErr_Clear(); + return; } + const std::string prefix = module_name + "."; + const auto path_matches = [](const std::string& file, const std::string& root) { + namespace fs = boost::filesystem; + + boost::system::error_code ec; + fs::path file_path = fs::weakly_canonical(fs::path(file), ec); + if (ec) { + ec.clear(); + file_path = fs::absolute(fs::path(file), ec); + } + if (ec) + return false; + + ec.clear(); + fs::path root_path = fs::weakly_canonical(fs::path(root), ec); + if (ec) { + ec.clear(); + root_path = fs::absolute(fs::path(root), ec); + } + if (ec) + return false; + + const std::string file_string = file_path.generic_string(); + std::string root_string = root_path.generic_string(); + if (root_string.empty()) + return false; + if (root_string.back() != '/') + root_string.push_back('/'); + return file_string.compare(0, root_string.size(), root_string) == 0; + }; + + const Py_ssize_t count = PyList_Size(names.get()); + for (Py_ssize_t index = 0; index < count; ++index) { + PyObject* name_obj = PyList_GetItem(names.get(), index); // borrowed reference + if (!name_obj || !PyUnicode_Check(name_obj)) + continue; + + const char* name_utf8 = PyUnicode_AsUTF8(name_obj); + if (!name_utf8) { + PyErr_Clear(); + continue; + } + + const std::string name(name_utf8); + const bool is_plugin_namespace = name == module_name || name.compare(0, prefix.size(), prefix) == 0; + bool is_plugin_path_module = false; + if (!is_plugin_namespace) { + PyObject* module = PyDict_GetItem(modules, name_obj); // borrowed reference + if (!module) + continue; + + PyObjectPtr file(PyObject_GetAttrString(module, "__file__")); + if (file && PyUnicode_Check(file.get())) { + const char* file_utf8 = PyUnicode_AsUTF8(file.get()); + if (file_utf8) { + for (const std::string& path : plugin_paths) { + if (path_matches(file_utf8, path)) { + is_plugin_path_module = true; + break; + } + } + } else { + PyErr_Clear(); + } + } else { + PyErr_Clear(); + } + + if (!is_plugin_path_module) { + PyObjectPtr package_path(PyObject_GetAttrString(module, "__path__")); + if (package_path) { + const Py_ssize_t path_count = PySequence_Size(package_path.get()); + if (path_count < 0) { + PyErr_Clear(); + } else { + for (Py_ssize_t path_index = 0; path_index < path_count && !is_plugin_path_module; ++path_index) { + PyObjectPtr path_entry(PySequence_GetItem(package_path.get(), path_index)); + if (!path_entry) { + PyErr_Clear(); + continue; + } + if (!PyUnicode_Check(path_entry.get())) + continue; + const char* path_utf8 = PyUnicode_AsUTF8(path_entry.get()); + if (!path_utf8) { + PyErr_Clear(); + continue; + } + for (const std::string& path : plugin_paths) { + if (path_matches(path_utf8, path)) { + is_plugin_path_module = true; + break; + } + } + } + } + } else { + PyErr_Clear(); + } + } + } + + if (!is_plugin_namespace && !is_plugin_path_module) + continue; + + auto users = m_plugin_module_users.find(name); + if (users == m_plugin_module_users.end()) { + m_plugin_module_owned[name] = true; + m_plugin_module_users.emplace(name, 1); + } else { + ++users->second; + } + plugin_modules->push_back(name); + } +} + +void PythonInterpreter::remove_plugin_modules_locked(const std::vector& plugin_modules) +{ + PyObject* modules = PyImport_GetModuleDict(); + if (!modules) + return; + + for (const std::string& name : plugin_modules) { + auto users = m_plugin_module_users.find(name); + if (users == m_plugin_module_users.end()) + continue; + + if (--users->second != 0) + continue; + + const bool owned = m_plugin_module_owned[name]; + m_plugin_module_users.erase(users); + m_plugin_module_owned.erase(name); + if (owned && PyDict_DelItemString(modules, name.c_str()) != 0) + PyErr_Clear(); + } +} + +void PythonInterpreter::remove_module_tree_locked(const std::string& module_name) +{ + if (module_name.empty()) + return; + + PyObject* modules = PyImport_GetModuleDict(); + if (!modules) + return; + + PyObjectPtr names(PyDict_Keys(modules)); + if (!names) { + PyErr_Clear(); + return; + } + + const std::string prefix = module_name + "."; + const Py_ssize_t count = PyList_Size(names.get()); + for (Py_ssize_t index = 0; index < count; ++index) { + PyObject* name_obj = PyList_GetItem(names.get(), index); // borrowed reference + if (!name_obj || !PyUnicode_Check(name_obj)) + continue; + + const char* name_utf8 = PyUnicode_AsUTF8(name_obj); + if (!name_utf8) { + PyErr_Clear(); + continue; + } + + const std::string name(name_utf8); + if (name != module_name && name.compare(0, prefix.size(), prefix) != 0) + continue; + + if (PyDict_DelItem(modules, name_obj) != 0) + PyErr_Clear(); + } +} + +void PythonInterpreter::unload_module(PyObject* module, + const std::string& module_name, + const std::vector& plugin_paths, + const std::vector& plugin_modules) +{ + if (!module && plugin_paths.empty() && plugin_modules.empty()) + return; + + if (!m_initialized.load(std::memory_order_acquire)) + return; + PythonGILState gil; + if (!gil) + return; - PyObject* main_module = PyImport_AddModule("__main__"); - if (!main_module) { - error = "Failed to get __main__ module"; - return false; - } - - PyObject* global_dict = PyModule_GetDict(main_module); - PyObjectPtr result(PyRun_String(code.c_str(), Py_file_input, global_dict, global_dict)); - - if (!result) { - PyObject *ptype, *pvalue, *ptraceback; - PyErr_Fetch(&ptype, &pvalue, &ptraceback); - error = format_python_error(ptype, pvalue, ptraceback); - Py_XDECREF(ptype); - Py_XDECREF(pvalue); - Py_XDECREF(ptraceback); - return false; - } - - return true; + remove_plugin_modules_locked(plugin_modules); + if (plugin_modules.empty()) + remove_module_tree_locked(module_name); + remove_plugin_sys_paths_locked(plugin_paths); + Py_XDECREF(module); } -PyObject* PythonInterpreter::load_module_from_file(const std::string& file_path, std::string& error) +PyObject* PythonInterpreter::load_module_from_file(const std::string& file_path, + std::string& error, + std::vector* plugin_paths, + std::vector* plugin_modules) { - if (!m_initialized) { + if (!m_initialized.load(std::memory_order_acquire)) { error = "Python interpreter not initialized"; return nullptr; } @@ -755,49 +981,27 @@ PyObject* PythonInterpreter::load_module_from_file(const std::string& file_path, } PythonGILState gil; + if (!gil) { + error = "Python interpreter is shutting down"; + return nullptr; + } // Add the directory to sys.path fs::path dir_path = path.parent_path(); std::string module_name = path.stem().string(); - PyObjectPtr sys(PyImport_ImportModule("sys")); - if (!sys) { - error = "Failed to import sys module"; + const std::string dir = dir_path.string(); + if (!add_plugin_sys_path_locked(dir, error)) return nullptr; - } - - PyObject* sys_path = PyObject_GetAttrString(sys.get(), "path"); - if (!sys_path) { - error = "Failed to get sys.path"; - return nullptr; - } - - PyObjectPtr dir_str(PyUnicode_FromString(dir_path.string().c_str())); - if (!dir_str) { - Py_DECREF(sys_path); - error = "Failed to create directory string"; - return nullptr; - } - - if (PyList_Insert(sys_path, 0, dir_str.get()) < 0) { - Py_DECREF(sys_path); - error = "Failed to add directory to sys.path"; - return nullptr; - } - - Py_DECREF(sys_path); + if (plugin_paths) + plugin_paths->push_back(dir); // Ensure module is re-imported fresh by removing any cached instance. - if (PyObject* modules = PyImport_GetModuleDict()) { - if (PyDict_GetItemString(modules, module_name.c_str())) { - if (PyDict_DelItemString(modules, module_name.c_str()) != 0) { - PyErr_Clear(); - } - } - } + remove_module_tree_locked(module_name); // Import the module PyObject* module = PyImport_ImportModule(module_name.c_str()); + record_plugin_modules_locked(module_name, plugin_paths ? *plugin_paths : std::vector{}, plugin_modules); if (!module) { PyObject *ptype, *pvalue, *ptraceback; PyErr_Fetch(&ptype, &pvalue, &ptraceback); @@ -811,9 +1015,13 @@ PyObject* PythonInterpreter::load_module_from_file(const std::string& file_path, return module; } -PyObject* PythonInterpreter::load_module_from_directory(const std::string& dir_path, const std::string& pkg_name, std::string& error) +PyObject* PythonInterpreter::load_module_from_directory(const std::string& dir_path, + const std::string& pkg_name, + std::string& error, + std::vector* plugin_paths, + std::vector* plugin_modules) { - if (!m_initialized) { + if (!m_initialized.load(std::memory_order_acquire)) { error = "Python interpreter not initialized"; return nullptr; } @@ -827,43 +1035,21 @@ PyObject* PythonInterpreter::load_module_from_directory(const std::string& dir_p } PythonGILState gil; - - PyObjectPtr sys(PyImport_ImportModule("sys")); - if (!sys) { - error = "Failed to import sys module"; + if (!gil) { + error = "Python interpreter is shutting down"; return nullptr; } - PyObject* sys_path = PyObject_GetAttrString(sys.get(), "path"); - if (!sys_path) { - error = "Failed to get sys.path"; + const std::string directory = dir.string(); + if (!add_plugin_sys_path_locked(directory, error)) return nullptr; - } + if (plugin_paths) + plugin_paths->push_back(directory); - PyObjectPtr dir_str(PyUnicode_FromString(dir.string().c_str())); - if (!dir_str) { - Py_DECREF(sys_path); - error = "Failed to create directory string"; - return nullptr; - } - - if (PyList_Insert(sys_path, 0, dir_str.get()) < 0) { - Py_DECREF(sys_path); - error = "Failed to add directory to sys.path"; - return nullptr; - } - - Py_DECREF(sys_path); - - if (PyObject* modules = PyImport_GetModuleDict()) { - if (PyDict_GetItemString(modules, pkg_name.c_str())) { - if (PyDict_DelItemString(modules, pkg_name.c_str()) != 0) { - PyErr_Clear(); - } - } - } + remove_module_tree_locked(pkg_name); PyObject* module = PyImport_ImportModule(pkg_name.c_str()); + record_plugin_modules_locked(pkg_name, plugin_paths ? *plugin_paths : std::vector{}, plugin_modules); if (!module) { PyObject *ptype, *pvalue, *ptraceback; PyErr_Fetch(&ptype, &pvalue, &ptraceback); @@ -877,9 +1063,13 @@ PyObject* PythonInterpreter::load_module_from_directory(const std::string& dir_p return module; } -PyObject* PythonInterpreter::load_module_from_whl(const std::string& file_path, const std::string& pkg_name, std::string& error) +PyObject* PythonInterpreter::load_module_from_whl(const std::string& file_path, + const std::string& pkg_name, + std::string& error, + std::vector* plugin_paths, + std::vector* plugin_modules) { - if (!m_initialized) { + if (!m_initialized.load(std::memory_order_acquire)) { error = "Python interpreter not initialized"; return nullptr; } @@ -900,98 +1090,7 @@ PyObject* PythonInterpreter::load_module_from_whl(const std::string& file_path, return nullptr; } - return load_module_from_directory(extract_dir.string(), pkg_name, error); -} - -bool PythonInterpreter::call_function( - PyObject* module, const std::string& function_name, const std::string& arg, std::string& result, std::string& error) -{ - if (!m_initialized || !module) { - error = "Python interpreter not initialized or module is null"; - return false; - } - - PythonGILState gil; - - PyObject* func = PyObject_GetAttrString(module, function_name.c_str()); - if (!func || !PyCallable_Check(func)) { - Py_XDECREF(func); - error = "Function '" + function_name + "' not found or not callable"; - return false; - } - - PyObjectPtr args(PyTuple_New(1)); - PyObjectPtr arg_str(PyUnicode_FromString(arg.c_str())); - PyTuple_SetItem(args.get(), 0, arg_str.release()); - - PyObjectPtr py_result(PyObject_CallObject(func, args.get())); - Py_DECREF(func); - - if (!py_result) { - PyObject *ptype, *pvalue, *ptraceback; - PyErr_Fetch(&ptype, &pvalue, &ptraceback); - error = "Function call failed: " + format_python_error(ptype, pvalue, ptraceback); - Py_XDECREF(ptype); - Py_XDECREF(pvalue); - Py_XDECREF(ptraceback); - return false; - } - - result = py_object_to_string(py_result.get()); - return true; -} - -bool PythonInterpreter::call_function_no_args(PyObject* module, const std::string& function_name, std::string& result, std::string& error) -{ - if (!m_initialized || !module) { - error = "Python interpreter not initialized or module is null"; - return false; - } - - PythonGILState gil; - - PyObject* func = PyObject_GetAttrString(module, function_name.c_str()); - if (!func || !PyCallable_Check(func)) { - Py_XDECREF(func); - error = "Function '" + function_name + "' not found or not callable"; - return false; - } - - PyObjectPtr py_result(PyObject_CallObject(func, nullptr)); - Py_DECREF(func); - - if (!py_result) { - PyObject *ptype, *pvalue, *ptraceback; - PyErr_Fetch(&ptype, &pvalue, &ptraceback); - error = "Function call failed: " + format_python_error(ptype, pvalue, ptraceback); - Py_XDECREF(ptype); - Py_XDECREF(pvalue); - Py_XDECREF(ptraceback); - return false; - } - - result = py_object_to_string(py_result.get()); - return true; -} - -std::string PythonInterpreter::py_object_to_string(PyObject* obj) -{ - if (!obj) { - return ""; - } - - if (PyUnicode_Check(obj)) { - const char* str = PyUnicode_AsUTF8(obj); - return str ? std::string(str) : ""; - } - - PyObjectPtr str_obj(PyObject_Str(obj)); - if (str_obj) { - const char* str = PyUnicode_AsUTF8(str_obj.get()); - return str ? std::string(str) : ""; - } - - return ""; + return load_module_from_directory(extract_dir.string(), pkg_name, error, plugin_paths, plugin_modules); } } // namespace Slic3r diff --git a/src/slic3r/plugin/PythonInterpreter.hpp b/src/slic3r/plugin/PythonInterpreter.hpp index 5525763b3b..ff69ce6846 100644 --- a/src/slic3r/plugin/PythonInterpreter.hpp +++ b/src/slic3r/plugin/PythonInterpreter.hpp @@ -3,9 +3,13 @@ #include #include -#include -#include +#include #include +#include +#include +#include +#include +#include #include "libslic3r/libslic3r.h" namespace pybind11 { @@ -15,6 +19,8 @@ class error_already_set; namespace Slic3r { +class PythonRuntimeLease; + // Print a Python exception's full traceback to sys.stderr (tee'd to the session // log) WITHOUT consuming err. // @@ -37,7 +43,11 @@ public: bool initialize(); // Check if interpreter is initialized - bool is_initialized() const { return m_initialized; } + bool is_initialized() const { return m_initialized.load(std::memory_order_acquire); } + + // Acquire a shared lease on the interpreter. Shutdown takes the exclusive side of this + // lock, so a caller that owns a lease can safely acquire the GIL and touch Python objects. + PythonRuntimeLease acquire_runtime_lease(); const std::string& last_error() const { return m_last_error; } @@ -56,56 +66,116 @@ public: // Finalize the Python interpreter. void shutdown(); - // Add a filesystem path to sys.path if not already present. - bool add_sys_path(const std::string& path, std::string& error); - - // Execute a Python string and return result - bool execute_string(const std::string& code, std::string& error); + // Add a path owned by one plugin load. The path is reference-counted across plugins and is + // removed when the final plugin using it is unloaded. + bool add_plugin_sys_path(const std::string& path, std::string& error); // Load a Python module from file path - PyObject* load_module_from_file(const std::string& file_path, std::string& error); - PyObject* load_module_from_whl(const std::string& whl_path, const std::string& pkg_name, std::string& error); - PyObject* load_module_from_directory(const std::string& dir_path, const std::string& pkg_name, std::string& error); + PyObject* load_module_from_file(const std::string& file_path, + std::string& error, + std::vector* plugin_paths = nullptr, + std::vector* plugin_modules = nullptr); + PyObject* load_module_from_whl(const std::string& whl_path, + const std::string& pkg_name, + std::string& error, + std::vector* plugin_paths = nullptr, + std::vector* plugin_modules = nullptr); + PyObject* load_module_from_directory(const std::string& dir_path, + const std::string& pkg_name, + std::string& error, + std::vector* plugin_paths = nullptr, + std::vector* plugin_modules = nullptr); - // Call a Python function with string argument, return string result - bool call_function(PyObject* module, const std::string& function_name, - const std::string& arg, std::string& result, std::string& error); - - // Call a Python function with no arguments, return string result - bool call_function_no_args(PyObject* module, const std::string& function_name, - std::string& result, std::string& error); - - // Helper to get string from Python object - static std::string py_object_to_string(PyObject* obj); + // Remove the complete module namespace and plugin-owned paths, then release the root module + // reference. Safe to call with a null module when a load failed after adding paths. + void unload_module(PyObject* module, + const std::string& module_name, + const std::vector& plugin_paths, + const std::vector& plugin_modules); // Destructor finalizes Python if shutdown() was not called explicitly. ~PythonInterpreter(); private: + friend class PythonRuntimeLease; + PythonInterpreter() = default; PythonInterpreter(const PythonInterpreter&) = delete; PythonInterpreter& operator=(const PythonInterpreter&) = delete; - bool m_initialized = false; + bool add_plugin_sys_path_locked(const std::string& path, std::string& error); + void remove_plugin_sys_paths_locked(const std::vector& paths); + void record_plugin_modules_locked(const std::string& module_name, + const std::vector& plugin_paths, + std::vector* plugin_modules); + void remove_plugin_modules_locked(const std::vector& plugin_modules); + void remove_module_tree_locked(const std::string& module_name); + + std::atomic m_initialized{false}; + mutable std::shared_mutex m_runtime_mutex; PyThreadState* m_main_thread_state = nullptr; // thread state saved after releasing GIL post-initialize std::unique_ptr m_interpreter; std::string m_last_error; + std::unordered_map m_plugin_path_users; + std::unordered_map m_plugin_path_owned; + std::unordered_map m_plugin_module_users; + std::unordered_map m_plugin_module_owned; }; +class PythonRuntimeLease +{ +public: + PythonRuntimeLease() = default; + PythonRuntimeLease(PythonRuntimeLease&& other) noexcept; + PythonRuntimeLease& operator=(PythonRuntimeLease&& other) noexcept; + ~PythonRuntimeLease(); + + PythonRuntimeLease(const PythonRuntimeLease&) = delete; + PythonRuntimeLease& operator=(const PythonRuntimeLease&) = delete; + + explicit operator bool() const { return m_interpreter != nullptr; } + +private: + friend class PythonInterpreter; + + explicit PythonRuntimeLease(PythonInterpreter& interpreter); + void release(); + + static thread_local PythonInterpreter* s_owner; + static thread_local unsigned int s_depth; + + PythonInterpreter* m_interpreter = nullptr; + std::shared_lock m_lock; +}; + +inline PythonRuntimeLease PythonInterpreter::acquire_runtime_lease() +{ + return PythonRuntimeLease(*this); +} + // RAII helper for Python GIL (Global Interpreter Lock) class PythonGILState { public: PythonGILState() { - m_state = PyGILState_Ensure(); + m_runtime_lease = PythonInterpreter::instance().acquire_runtime_lease(); + if (m_runtime_lease) { + m_state = PyGILState_Ensure(); + m_acquired = true; + } } ~PythonGILState() { - PyGILState_Release(m_state); + if (m_acquired) + PyGILState_Release(m_state); } + explicit operator bool() const { return m_acquired; } + private: - PyGILState_STATE m_state; + PythonRuntimeLease m_runtime_lease; + PyGILState_STATE m_state{}; + bool m_acquired = false; }; // RAII helper for Python object references diff --git a/src/slic3r/plugin/PythonPluginBridge.cpp b/src/slic3r/plugin/PythonPluginBridge.cpp index 2711e196bb..37677c6480 100644 --- a/src/slic3r/plugin/PythonPluginBridge.cpp +++ b/src/slic3r/plugin/PythonPluginBridge.cpp @@ -44,19 +44,35 @@ struct PluginInstanceHandle ~PluginInstanceHandle() { if (keep_alive) { - if (Py_IsInitialized()) { - // Dropping a py::object decrefs the Python object, so reacquire the GIL. - PythonGILState gil; + // Dropping a py::object decrefs the Python object, so acquire a runtime lease and + // the GIL. If shutdown has already won the lease, intentionally abandon the wrapper. + PythonGILState gil; + if (gil) { keep_alive = py::object(); } else { - // During interpreter shutdown it is no longer safe to decref Python objects. - // release() forgets the wrapper ownership without touching Python runtime state. (void) keep_alive.release(); } } } }; +void discard_pending_capture_without_python(const std::string& plugin_key) +{ + std::lock_guard lock(g_registry_mutex); + auto capabilities = g_pending_capabilities.find(plugin_key); + if (capabilities != g_pending_capabilities.end()) { + for (py::object& capability : capabilities->second) + (void) capability.release(); + g_pending_capabilities.erase(capabilities); + } + + auto package = g_pending_package.find(plugin_key); + if (package != g_pending_package.end()) { + (void) package->second.release(); + g_pending_package.erase(package); + } +} + } // namespace PythonPluginBridge& PythonPluginBridge::instance() @@ -68,6 +84,10 @@ PythonPluginBridge& PythonPluginBridge::instance() void PythonPluginBridge::begin_plugin_capture(const std::string& plugin_key) { PythonGILState gil; + if (!gil) { + BOOST_LOG_TRIVIAL(warning) << "Cannot begin Python plugin capture while the interpreter is shutting down"; + return; + } BOOST_LOG_TRIVIAL(info) << "Beginning Python plugin capture for key " << plugin_key; { std::lock_guard lock(g_registry_mutex); @@ -84,6 +104,10 @@ void PythonPluginBridge::begin_plugin_capture(const std::string& plugin_key) std::vector PythonPluginBridge::finalize_plugin_capture(const std::string& plugin_key, std::string& error) { PythonGILState gil; + if (!gil) { + error = "Python interpreter is shutting down"; + return {}; + } BOOST_LOG_TRIVIAL(info) << "Finalizing Python plugin capture for key " << plugin_key; // Phase 1: run the package class's register_capabilities() while the active key is @@ -236,6 +260,13 @@ void PythonPluginBridge::cancel_plugin_capture(const std::string& plugin_key) PythonGILState gil; BOOST_LOG_TRIVIAL(warning) << "Cancelling Python plugin capture for key " << plugin_key; + if (!gil) { + discard_pending_capture_without_python(plugin_key); + if (g_active_plugin_key == plugin_key) + g_active_plugin_key.clear(); + return; + } + { std::lock_guard lock(g_registry_mutex); // Import or dependency setup failed before finalization. Drop anything the module @@ -250,7 +281,8 @@ void PythonPluginBridge::cancel_plugin_capture(const std::string& plugin_key) void PythonPluginBridge::clear_pending_captures() { - if (!Py_IsInitialized()) { + PythonGILState gil; + if (!gil) { std::lock_guard lock(g_registry_mutex); BOOST_LOG_TRIVIAL(info) << "Clearing " << g_pending_capabilities.size() << " pending Python plugin capture(s) without Python interpreter"; @@ -271,9 +303,6 @@ void PythonPluginBridge::clear_pending_captures() return; } - // Normal shutdown path: hold the GIL and let py::object destructors decref cleanly. - PythonGILState gil; - std::lock_guard lock(g_registry_mutex); BOOST_LOG_TRIVIAL(info) << "Clearing " << g_pending_capabilities.size() << " pending Python plugin capture(s)"; g_pending_capabilities.clear(); diff --git a/src/slic3r/plugin/PythonPluginInterface.hpp b/src/slic3r/plugin/PythonPluginInterface.hpp index c3232c3b98..a1c82f3e32 100644 --- a/src/slic3r/plugin/PythonPluginInterface.hpp +++ b/src/slic3r/plugin/PythonPluginInterface.hpp @@ -1,6 +1,7 @@ #ifndef slic3r_PythonPluginInterface_hpp_ #define slic3r_PythonPluginInterface_hpp_ +#include #include #include #include @@ -96,22 +97,77 @@ struct ExecutionResult class PluginCapabilityInterface { public: + class RefCounter + { + public: + explicit RefCounter(const PluginCapabilityInterface& iface) : m_iface(&iface) { m_iface->increment(); } + + ~RefCounter() { m_iface->decrement(); } + + RefCounter(const RefCounter&) = delete; + RefCounter& operator=(const RefCounter&) = delete; + + private: + const PluginCapabilityInterface* m_iface; + }; + virtual ~PluginCapabilityInterface() = default; - virtual std::string get_name() const = 0; // required — overridden in Python - virtual PluginCapabilityType get_type() const { return PluginCapabilityType::Unknown; } // optional — typed bases override + // DO NOT CALL THESE OUTSIDE THE LOADER'S MATERIALIZATION BLOCK. get_name() is pure virtual and + // always implemented in Python: the trampoline routes it through PYBIND11_OVERRIDE_PURE, so every + // call acquires the GIL, dispatches into the plugin, and opens a filesystem-enforcement scope. + // Capability lookup is by name and runs under the plugin registry lock, so calling this on a + // lookup would hold that lock while taking the GIL — inverting the lock order against plugin + // dispatch on the slicing threads — and it is undefined after the interpreter is finalized. + // get_type() is virtual with a C++ default that the typed bases override in C++, but the untyped + // trampoline still routes it to Python, so it is only GIL-free by accident of the base chosen. + // + // The loader resolves both exactly once, at materialization, under the GIL it already holds, and + // caches them below. Everything else reads name()/type(). + virtual std::string get_name() const = 0; // required — overridden in Python + virtual PluginCapabilityType get_type() const { return PluginCapabilityType::Unknown; } // optional — typed bases override virtual void on_load() {} virtual void on_unload() {} + virtual void on_cancelled() {} - // C++-only audit identity (never exposed to Python). Set by PluginLoader after - // plugin capture so trampoline calls can scope filesystem enforcement to this - // plugin. This is PluginDescriptor::plugin_key, the canonical runtime id. + // ── C++-only host state, never exposed to Python. Set by the loader at materialization. ── + // + // The capability owns its own identity and enable flag: they are read once under the GIL, live + // exactly as long as the capability does, and are discarded with it on unload. Nothing about a + // capability outlives the capability — the durable record is the .install_state.json sidecar. + + // Cached identity. Plain C++ reads, safe under any lock and after the interpreter is gone. + const std::string& name() const { return m_name; } + PluginCapabilityType type() const { return m_type; } + void set_resolved_identity(std::string name, PluginCapabilityType type) + { + m_name = std::move(name); + m_type = type; + } + + // Logical enable/disable. A disabled capability stays loaded but is skipped by consumers. + // Atomic because dispatch reads it off a shared_ptr handed out by the manager, i.e. without the + // registry lock held. Seeded from the sidecar at load; PluginManager writes it through on change. + bool is_enabled() const { return m_enabled.load(std::memory_order_acquire); } + void set_enabled(bool enabled) { m_enabled.store(enabled, std::memory_order_release); } + + // The owning package (PluginDescriptor::plugin_key), the canonical runtime id. Also scopes + // filesystem enforcement for trampoline calls. void set_audit_plugin_key(std::string key) { m_audit_plugin_key = std::move(key); } const std::string& audit_plugin_key() const { return m_audit_plugin_key; } + void increment() const { m_refs.fetch_add(1, std::memory_order_acq_rel); } + void decrement() const { m_refs.fetch_sub(1, std::memory_order_acq_rel); } + int ref_count() const { return m_refs.load(std::memory_order_acquire); } + private: + std::string m_name; + PluginCapabilityType m_type = PluginCapabilityType::Unknown; + std::atomic m_enabled{true}; std::string m_audit_plugin_key; + + mutable std::atomic m_refs{0}; }; } // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginHostUi.cpp b/src/slic3r/plugin/host/PluginHostUi.cpp index ac54c38337..0c4b82b3d2 100644 --- a/src/slic3r/plugin/host/PluginHostUi.cpp +++ b/src/slic3r/plugin/host/PluginHostUi.cpp @@ -105,7 +105,10 @@ struct GilSafeCallable { if (fn) { PythonGILState gil; - fn = py::object(); + if (gil) + fn = py::object(); + else + (void) fn.release(); } } }; @@ -127,6 +130,8 @@ GUI::PluginWebDialog::MessageHandler make_message_adapter(py::object on_message) return nullptr; return [holder](const json& data) { PythonGILState gil; + if (!gil) + return; try { holder->fn(json_to_py(data)); } catch (py::error_already_set& e) { @@ -343,6 +348,8 @@ py::object ui_create_window(const std::string& html, const std::string& title, i if (close_holder) { on_close = [close_holder]() { PythonGILState gil; + if (!gil) + return; try { close_holder->fn(); } catch (py::error_already_set& e) { @@ -412,7 +419,7 @@ UiProgressHandle* new_progress_dialog(const std::string& title, const std::strin bool progress_is_open(int id) { - return UiRegistry::instance().get_as(id) != nullptr; + return UiRegistry::instance().is_open(id); } bool progress_pulse(int id, const std::string& message) @@ -462,7 +469,10 @@ void PluginHostUi::RegisterBindings(pybind11::module_& host) auto ui = host.def_submodule( "ui", "Host UI: native dialogs and interactive HTML windows. Calls run on the main/UI " - "thread (marshaled from the plugin thread). See the plugin docs for the window.orca bridge."); + "thread (marshaled from the plugin thread). See the plugin docs for the window.orca bridge. " + "Do not call these from a slicing pipeline hook (SlicingPipeline capability): that hook runs " + "on the slicing worker thread, which the UI thread can itself be blocked waiting on, so a " + "marshaled UI call from there can deadlock the application."); ui.def("message", &ui_message, py::arg("text"), py::arg("title") = "OrcaSlicer", py::arg("buttons") = "ok", py::arg("icon") = "info", diff --git a/src/slic3r/plugin/host/PluginHostUi.hpp b/src/slic3r/plugin/host/PluginHostUi.hpp index 224a823876..8badf1dd0a 100644 --- a/src/slic3r/plugin/host/PluginHostUi.hpp +++ b/src/slic3r/plugin/host/PluginHostUi.hpp @@ -9,15 +9,18 @@ namespace Slic3r { // Binds the `orca.host.ui` submodule: native message boxes, progress dialogs, // and interactive HTML windows for plugins. All calls run on the main/UI thread // (marshaled from the plugin worker thread) and the host owns every window. +// +// Not safe to call from a slicing pipeline hook (SlicingPipelinePluginCapability): +// that hook runs on the slicing worker thread, which the UI thread can itself be +// blocked waiting on, so marshaling a UI call from there can deadlock. Plugin +// authors must not call orca.host.ui.* from pipeline hooks. class PluginHostUi { public: static void RegisterBindings(pybind11::module_& host); - // Lifecycle hook: close and tear down every UI window owned by a plugin. - // Registered via PluginLoader::subscribe_on_unload_callback so UI windows - // are destroyed on plugin unload/reload and at app shutdown (before the - // Python interpreter is finalized). Matches PluginLifecycleCompleteFn. + // Lifecycle hook: close and tear down every UI window owned by a plugin. PluginManager invokes + // this after plugin teardown and also for bulk unload during application shutdown. static void close_windows_for_plugin(const std::string& plugin_key); }; diff --git a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp index 901d07a888..f9193a0f03 100644 --- a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp +++ b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp @@ -5,6 +5,7 @@ #include "../../PyPluginTrampoline.hpp" #include "IPrinterAgent.hpp" +#include namespace Slic3r { class PyPrinterAgentPluginCapabilityTrampoline : public PyPluginCommonTrampoline @@ -216,7 +217,10 @@ public: int request_bind_ticket(std::string* ticket) override { ORCA_PY_AUDIT_SCOPE(::Slic3r::PluginAuditManager::AuditMode::Loading); - pybind11::gil_scoped_acquire gil; + ::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this); + ::Slic3r::PythonGILState gil; + if (!gil) + throw std::runtime_error("Python interpreter is shutting down"); pybind11::function override = pybind11::get_override(static_cast(this), "request_bind_ticket"); if (!override) diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp index eda2c70324..7f0f322dce 100644 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp @@ -38,6 +38,8 @@ struct SlicingPipelineContext { class SlicingPipelinePluginCapability : public PluginCapabilityInterface { public: PluginCapabilityType get_type() const override { return PluginCapabilityType::SlicingPipeline; } + // Runs on the slicing worker thread. Do not call orca.host.ui.* here: the UI thread can be + // blocked waiting on the slicing worker, so a marshaled UI call from this thread can deadlock. virtual ExecutionResult execute(SlicingPipelineContext& ctx) = 0; static void RegisterBindings(pybind11::module_& module, pybind11::enum_& pluginTypes); }; diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 86e4b78d79..429d3582df 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -2,8 +2,8 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME) add_executable(${_TEST_NAME}_tests ${_TEST_NAME}_tests_main.cpp test_plugin_host_api.cpp - test_plugin_capability_identifier.cpp test_plugin_install.cpp + test_plugin_lifecycle.cpp test_slicing_pipeline_bindings.cpp test_plugin_sort.cpp ) diff --git a/tests/slic3rutils/test_plugin_capability_identifier.cpp b/tests/slic3rutils/test_plugin_capability_identifier.cpp deleted file mode 100644 index 575ac4c8a5..0000000000 --- a/tests/slic3rutils/test_plugin_capability_identifier.cpp +++ /dev/null @@ -1,25 +0,0 @@ -#include - -#include - -#include - -using Slic3r::PluginCapabilityIdentifier; -using Slic3r::PluginCapabilityType; - -TEST_CASE("PluginCapabilityIdentifier equality includes plugin_key", "[plugin][identifier]") { - PluginCapabilityIdentifier a{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}; - PluginCapabilityIdentifier b{PluginCapabilityType::SlicingPipeline, "Cleanup", "b.py"}; - PluginCapabilityIdentifier a2{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}; - - CHECK(a == a2); - CHECK_FALSE(a == b); // same (type,name), different plugin_key -> distinct -} - -TEST_CASE("PluginCapabilityIdentifier is usable as a hash-map key", "[plugin][identifier]") { - std::unordered_map m; - m[{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}] = 1; - m[{PluginCapabilityType::SlicingPipeline, "Cleanup", "b.py"}] = 2; // no collision - CHECK(m.size() == 2); - CHECK(m.at({PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}) == 1); -} diff --git a/tests/slic3rutils/test_plugin_install.cpp b/tests/slic3rutils/test_plugin_install.cpp index c671943ba4..430564a4fc 100644 --- a/tests/slic3rutils/test_plugin_install.cpp +++ b/tests/slic3rutils/test_plugin_install.cpp @@ -57,15 +57,14 @@ TEST_CASE("install_plugin rejects a cloud UUID containing path traversal", "[Plu // Package contents are irrelevant: the UUID is validated before metadata is read. const fs::path py = write_py_file(data_dir_guard.dir / "src", "evil.py", "print('hi')\n"); - PluginLoader loader; - loader.set_cloud_user_id("test-user"); + PluginDescriptor descriptor; // is_cloud_plugin() -> true; cloud_uuid() -> the traversal string. descriptor.cloud = CloudPluginState{"../../escape", true, false, false, false}; std::string error; - const bool installed = loader.install_plugin(py, descriptor, error); + const bool installed = plugin_loader::install_plugin(py, /*cloud_user_id=*/"test-user", descriptor, error); REQUIRE_FALSE(installed); CHECK_THAT(error, Catch::Matchers::ContainsSubstring("valid identifier")); @@ -78,11 +77,10 @@ TEST_CASE("install_plugin rejects a side-loaded .py with no PEP 723 metadata", " // No `# /// script` block -> name stays empty and type stays Unknown. const fs::path py = write_py_file(data_dir_guard.dir / "src", "nameless.py", "print('no metadata here')\n"); - PluginLoader loader; // non-cloud: no cloud user id, descriptor has no cloud state PluginDescriptor descriptor; std::string error; - const bool installed = loader.install_plugin(py, descriptor, error); + const bool installed = plugin_loader::install_plugin(py, /*cloud_user_id=*/"", descriptor, error); REQUIRE_FALSE(installed); CHECK_THAT(error, Catch::Matchers::ContainsSubstring("PEP 723")); @@ -103,11 +101,10 @@ TEST_CASE("install_plugin accepts a side-loaded .py with complete PEP 723 metada "print('ok')\n"; const fs::path py = write_py_file(data_dir_guard.dir / "src", "good.py", contents); - PluginLoader loader; // non-cloud PluginDescriptor descriptor; std::string error; - const bool installed = loader.install_plugin(py, descriptor, error); + const bool installed = plugin_loader::install_plugin(py, /*cloud_user_id=*/"", descriptor, error); // Positive control: a complete side-loaded .py must still install (guards against over-rejection). REQUIRE(installed); @@ -168,10 +165,9 @@ TEST_CASE("install_plugin parses [tool.orcaslicer.plugin.settings] into descript "print('ok')\n"; const fs::path py = write_py_file(data_dir_guard.dir / "src", "settings.py", contents); - PluginLoader loader; // non-cloud PluginDescriptor descriptor; std::string error; - const bool installed = loader.install_plugin(py, descriptor, error); + const bool installed = plugin_loader::install_plugin(py, /*cloud_user_id=*/"", descriptor, error); REQUIRE(installed); CHECK(error.empty()); diff --git a/tests/slic3rutils/test_plugin_lifecycle.cpp b/tests/slic3rutils/test_plugin_lifecycle.cpp new file mode 100644 index 0000000000..5f8a45452b --- /dev/null +++ b/tests/slic3rutils/test_plugin_lifecycle.cpp @@ -0,0 +1,760 @@ +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +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//.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 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 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> 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 paths; + std::vector 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 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")); +}