feat: preset APIs

This commit is contained in:
peachismomo committed 2026-10-17 01:44:15 +08:00
1 parent 3bfe40e17a
commit d80af50f23
15 files changed
+2762 -277

No files matched your search

+442 -30
View File
@@ -12,8 +12,11 @@
#include "PluginFsUtils.hpp"
#include "PluginHooks.hpp"
#include "PluginAuditManager.hpp"
#include "PyPluginTrampoline.hpp"
#include "PythonInterpreter.hpp"
#include "PythonPluginBridge.hpp"
#include "host/PluginHostUi.hpp"
#include "OrcaCloudServiceAgent.hpp"
#include "libslic3r/Semver.hpp"
@@ -41,6 +44,7 @@
#include <vector>
#include <wx/app.h>
#include <wx/thread.h>
#include <wx/utils.h>
#include "slic3r/GUI/ImGuiWrapper.hpp"
#include "slic3r/GUI/Plater.hpp"
@@ -68,8 +72,10 @@ void Plugin::release_module()
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.
// release module, sys.modules, and sys.path ownership safely. Touch the async Script runtime
// next, so ~PluginManager can still drain it during its own shutdown().
PythonInterpreter::instance();
ScriptAsyncRuntime::instance();
static PluginManager inst;
return inst;
}
@@ -101,6 +107,9 @@ bool PluginManager::initialize()
m_initialized = true;
}
// Re-arm the async Script runtime in case shutdown() ran earlier in this process.
ScriptAsyncRuntime::instance().reset();
// Bring every capability's stored config into memory. Deliberately unconditional and
// independent of which plugins are installed: an entry outlives uninstall/unsubscribe, so a
// plugin that comes back later finds its settings intact. A missing or malformed file just
@@ -167,6 +176,10 @@ void PluginManager::shutdown()
unload_all_plugins();
PythonPluginBridge::instance().clear_pending_captures();
// Every async Script run has been drained by unload_all_plugins above; wait for every
// invocation worker before the interpreter is allowed to finalize.
ScriptAsyncRuntime::instance().shutdown();
// Every config write already goes to disk as it happens (store_capability_config), so this
// only catches an in-memory-only mutation. Note we flush rather than clear: unloading the
// plugins above must never discard their stored config.
@@ -754,6 +767,10 @@ void PluginManager::release_load_slot(const std::string& plugin_key)
void PluginManager::cancel_and_wait_for_capabilities(
const std::vector<std::shared_ptr<PluginCapabilityInterface>>& capabilities)
{
// Ask the plugin to cancel first, then schedule the async cancellation: on_cancelled() can
// observe/record state while the run is still intact, and only afterwards is its asyncio task
// cancelled. Draining of capability references and posted host UI callbacks still follows, so
// the module is not unloaded until both have settled.
for (const auto& capability : capabilities) {
if (!capability || !capability->is_enabled())
continue;
@@ -769,15 +786,55 @@ void PluginManager::cancel_and_wait_for_capabilities(
}
}
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
while (std::chrono::steady_clock::now() < deadline) {
for (const auto& capability : capabilities) {
if (capability)
ScriptAsyncRuntime::instance().cancel_plugin(capability->audit_plugin_key());
}
// Distinct plugins whose posted host UI work must settle before module unload. A Tab::select_preset
// dialog accepted while the plugin is being unloaded would otherwise mutate presets after unload,
// so the drain below waits for every posted UI callback to return, not just for the run's ref.
std::vector<std::string> plugin_keys;
for (const auto& capability : capabilities) {
if (!capability)
continue;
const std::string& key = capability->audit_plugin_key();
if (!key.empty() && std::find(plugin_keys.begin(), plugin_keys.end(), key) == plugin_keys.end())
plugin_keys.push_back(key);
}
const auto host_pending = [&plugin_keys]() {
return std::any_of(plugin_keys.begin(), plugin_keys.end(),
[](const std::string& key) { return PluginHostUi::pending_async_count(key) > 0; });
};
// Script rows are bounded only by their own worker: a cancelled or stalled invocation of another
// plugin can no longer couple to this one (each invocation owns a thread). A Script worker or a
// posted host UI callback is drained without a deadline — forcing module unload while either is
// outstanding would leave Python executing against a freed module, or apply a preset selection
// after the plugin is gone. Non-Script synchronous calls keep the historical bounded grace. On
// the GUI thread the event loop is pumped so the UI callback (possibly a modal) can settle
// instead of being starved by a blocking wait.
const auto grace_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
for (;;) {
const bool all_released = std::all_of(capabilities.begin(), capabilities.end(), [](const auto& capability) {
return !capability || capability->ref_count() == 0;
});
if (all_released)
// A cancellation that arrived before the invocation ran skips the ref counter, so an active
// worker must also be gone: never unload the module while any Script thread is still live.
const bool invocation_active = std::any_of(capabilities.begin(), capabilities.end(), [](const auto& capability) {
return capability != nullptr && ScriptAsyncRuntime::instance().has_active_run(capability.get());
});
const bool ui_pending = host_pending();
if (all_released && !invocation_active && !ui_pending)
return;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
if (!invocation_active && !ui_pending && std::chrono::steady_clock::now() >= grace_deadline)
break;
if (wxTheApp != nullptr && wxIsMainThread())
wxYield();
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
for (const auto& capability : capabilities) {
@@ -1006,6 +1063,12 @@ void PluginManager::load_plugin_impl(const std::string& plugin_key, bool skip_de
bool PluginManager::unload_plugin(const std::string& plugin_key)
{
// Stop delivery from any posted host UI awaitable for this plugin before anything is torn down,
// so the earliest possible UI callback cannot resume the plugin's coroutine into a run that is
// already being cancelled. Registrations stay live so cancel_and_wait_for_capabilities() drains
// them; a Tab::select_preset dialog accepted after unload must not mutate presets post-teardown.
PluginHostUi::abandon_pending_async(plugin_key);
auto notify_unload_complete = [this, &plugin_key]() {
if (!m_shutting_down.load(std::memory_order_acquire))
run_on_unload_callbacks(plugin_key);
@@ -1080,6 +1143,11 @@ void PluginManager::unload_all_plugins()
// 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.
// Abandon posted host awaitables first, so a late UI callback cannot resolve into a plugin
// whose module is about to go; they stay tracked for cancel_and_wait_for_capabilities() below.
for (const std::string& key : removed_keys)
PluginHostUi::abandon_pending_async(key);
for (const std::string& key : removed_keys)
run_on_unload_callbacks(key);
@@ -2070,41 +2138,385 @@ bool PluginManager::delete_and_unsubscribe_cloud_plugin(const std::string& plugi
return finalize_cloud_plugin_removal(descriptor, false, error);
}
// ── Async Script runtime ────────────────────────────────────────────────────────────────────
namespace {
// The asyncio loop of the Script invocation running on the calling thread. Owned +1 while set;
// always cleared under the GIL before the worker thread exits.
thread_local PyObject* tl_script_loop = nullptr;
} // namespace
struct ScriptAsyncRuntime::Impl
{
struct Run
{
std::string plugin_key;
std::string capability_name;
std::shared_ptr<PluginCapabilityInterface> capability;
std::function<void(const ScriptCompletion&)> on_done;
std::atomic<bool> cancel_requested{false};
// True while an asyncio loop/task is live for this run. Read without the GIL by
// cancel_plugin() to avoid taking the GIL for a purely synchronous invocation.
std::atomic<bool> has_loop{false};
// Live asyncio loop/task for cancellation. Guarded by handle_mutex and only touched
// with the GIL held.
std::mutex handle_mutex;
pybind11::object loop;
pybind11::object task;
};
std::mutex mutex;
std::condition_variable cv;
std::vector<std::shared_ptr<Run>> active;
bool stopping = false;
// Runs one invocation to completion on the calling (worker) thread.
void run_invocation(const std::shared_ptr<Run>& run);
};
void ScriptAsyncRuntime::Impl::run_invocation(const std::shared_ptr<Run>& run)
{
ScriptCompletion completion;
{
PythonRuntimeLease lease = PythonInterpreter::instance().acquire_runtime_lease();
if (!lease) {
completion.error = _u8L("Python interpreter is shutting down");
} else {
pybind11::gil_scoped_acquire gil;
if (run->cancel_requested.load(std::memory_order_acquire)) {
// Cancelled before it ran: expected termination, never an error.
completion.cancelled = true;
} else {
// Hold the capability reference, the audit scope and the GIL for the whole
// invocation, so unload/shutdown cannot tear the module out from under it.
PluginCapabilityInterface::RefCounter ref_counter(*run->capability);
std::optional<ScopedPluginAuditContext> audit_scope;
if (!run->capability->audit_plugin_key().empty())
audit_scope.emplace(run->capability->audit_plugin_key(), run->capability->name());
// Detection and execution both happen here, on the worker: a synchronous
// `def execute` runs to completion off the UI thread, exactly like an `async def`.
ScriptExecuteInvocation invocation = invoke_script_execute(run->capability);
if (!invocation.error.empty()) {
completion.error = invocation.error;
} else if (!invocation.is_async) {
completion.result = invocation.result;
} else if (run->cancel_requested.load(std::memory_order_acquire)) {
completion.cancelled = true;
} else {
try {
pybind11::module_ asyncio = pybind11::module_::import("asyncio");
pybind11::object loop = asyncio.attr("new_event_loop")();
asyncio.attr("set_event_loop")(loop);
tl_script_loop = loop.ptr();
Py_INCREF(tl_script_loop);
pybind11::object task = loop.attr("create_task")(std::move(invocation.awaitable));
{
std::lock_guard<std::mutex> lock(run->handle_mutex);
run->loop = loop;
run->task = task;
}
run->has_loop.store(true, std::memory_order_release);
// A cancellation that arrived before the loop came up must still land.
if (run->cancel_requested.load(std::memory_order_acquire))
loop.attr("call_soon_threadsafe")(task.attr("cancel"));
try {
pybind11::object returned = loop.attr("run_until_complete")(task);
completion.result = returned.cast<ExecutionResult>();
} catch (pybind11::error_already_set& err) {
// Only our own cancel_plugin() cancellation is expected termination; a
// CancelledError the plugin raised on its own stays a real error.
if (err.matches(asyncio.attr("CancelledError")) &&
run->cancel_requested.load(std::memory_order_acquire)) {
completion.cancelled = true;
} else {
log_python_exception_keep(err);
completion.error = err.what();
}
}
run->has_loop.store(false, std::memory_order_release);
{
std::lock_guard<std::mutex> lock(run->handle_mutex);
run->task = pybind11::object();
run->loop = pybind11::object();
}
Py_CLEAR(tl_script_loop);
loop.attr("close")();
asyncio.attr("set_event_loop")(pybind11::none());
} catch (pybind11::error_already_set& err) {
log_python_exception_keep(err);
completion.error = err.what();
} catch (const std::exception& ex) {
completion.error = ex.what();
}
// Covers an exception thrown after the loop came up but before the clear above.
if (run->has_loop.load(std::memory_order_acquire)) {
run->has_loop.store(false, std::memory_order_release);
std::lock_guard<std::mutex> lock(run->handle_mutex);
run->task = pybind11::object();
run->loop = pybind11::object();
}
if (tl_script_loop != nullptr)
Py_CLEAR(tl_script_loop);
}
}
// Drop the capability reference while the GIL is held: releasing a pybind holder from
// a non-Python thread would DECREF the capability without the GIL. Runs on every
// lease-valid path, including an invocation cancelled before it ran.
run->capability.reset();
}
}
// A cancellation requested by cancel_plugin() (unload/shutdown) is expected termination and is
// authoritative: whatever the body did — finished, swallowed CancelledError, or failed — nothing
// is surfaced as an error or as a normal success.
if (run->cancel_requested.load(std::memory_order_acquire)) {
completion.cancelled = true;
completion.error.clear();
}
auto on_done = std::move(run->on_done);
auto deliver = [on_done, completion]() {
if (on_done)
on_done(completion);
};
if (wxTheApp == nullptr || wxIsMainThread())
deliver();
else
GUI::wxGetApp().CallAfter(deliver);
// Leave `active` last, so a drain cannot observe the run as finished before its completion was
// queued/delivered and its capability reference released.
{
std::lock_guard<std::mutex> lock(mutex);
active.erase(std::remove(active.begin(), active.end(), run), active.end());
}
cv.notify_all();
}
ScriptAsyncRuntime& ScriptAsyncRuntime::instance()
{
static ScriptAsyncRuntime inst;
return inst;
}
ScriptAsyncRuntime::ScriptAsyncRuntime() : m_impl(std::make_unique<Impl>()) {}
ScriptAsyncRuntime::~ScriptAsyncRuntime() { shutdown(); }
bool ScriptAsyncRuntime::start(const std::string& plugin_key,
const std::string& capability_name,
std::shared_ptr<PluginCapabilityInterface> capability,
std::function<void(const ScriptCompletion&)> on_done)
{
if (!PythonInterpreter::instance().is_initialized())
return false;
auto run = std::make_shared<Impl::Run>();
run->plugin_key = plugin_key;
run->capability_name = capability_name;
run->capability = std::move(capability);
run->on_done = std::move(on_done);
{
std::lock_guard<std::mutex> lock(m_impl->mutex);
if (m_impl->stopping)
return false;
m_impl->active.push_back(run);
}
// One worker thread per invocation. A stalled invocation of one plugin can never delay another
// plugin's unload or another invocation, and no invocation body ever runs on the caller (UI)
// thread. The thread holds `run` (and so the capability) until it finishes; the runtime tracks
// it in `active` and waits for it on shutdown.
Impl* impl = m_impl.get();
try {
std::thread([impl, run] { impl->run_invocation(run); }).detach();
} catch (...) {
std::lock_guard<std::mutex> lock(m_impl->mutex);
m_impl->active.erase(std::remove(m_impl->active.begin(), m_impl->active.end(), run), m_impl->active.end());
return false;
}
return true;
}
void ScriptAsyncRuntime::cancel_plugin(const std::string& plugin_key)
{
std::vector<std::shared_ptr<Impl::Run>> runs;
{
std::lock_guard<std::mutex> lock(m_impl->mutex);
for (const auto& run : m_impl->active)
if (run->plugin_key == plugin_key)
runs.push_back(run);
}
for (const auto& run : runs)
run->cancel_requested.store(true, std::memory_order_release);
for (const auto& run : runs) {
// A synchronous invocation has no loop to cancel; skip it so the caller never blocks on
// the GIL while that invocation is (uninterruptibly) running.
if (!run->has_loop.load(std::memory_order_acquire))
continue;
PythonGILState gil;
if (!gil)
continue;
pybind11::object loop;
pybind11::object task;
{
std::lock_guard<std::mutex> lock(run->handle_mutex);
loop = run->loop;
task = run->task;
}
if (!loop || !task)
continue;
try {
loop.attr("call_soon_threadsafe")(task.attr("cancel"));
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(warning) << "Script async cancel failed for plugin '" << plugin_key << "': " << ex.what();
}
}
}
bool ScriptAsyncRuntime::has_active_run(const PluginCapabilityInterface* capability) const
{
std::lock_guard<std::mutex> lock(m_impl->mutex);
for (const auto& run : m_impl->active)
if (run->capability.get() == capability)
return true;
return false;
}
void ScriptAsyncRuntime::shutdown()
{
{
std::lock_guard<std::mutex> lock(m_impl->mutex);
m_impl->stopping = true;
}
std::unique_lock<std::mutex> lock(m_impl->mutex);
m_impl->cv.wait(lock, [this] { return m_impl->active.empty(); });
}
void ScriptAsyncRuntime::reset()
{
shutdown();
std::lock_guard<std::mutex> lock(m_impl->mutex);
m_impl->stopping = false;
}
pybind11::object ScriptAsyncRuntime::current_loop()
{
if (tl_script_loop == nullptr)
return pybind11::object();
return pybind11::reinterpret_borrow<pybind11::object>(pybind11::handle(tl_script_loop));
}
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 {};
ExecutionResult result;
std::mutex mutex;
std::condition_variable cv;
bool done = false;
ScriptLaunchResult launch = launch_script_capability(
plugin_key, capability_name, [&](const ScriptCompletion& completion) {
std::lock_guard<std::mutex> lock(mutex);
result = completion.result;
// A cancel_plugin() cancellation is expected termination, not an error to report.
error = completion.cancelled ? std::string() : completion.error;
done = true;
cv.notify_all();
});
if (launch.launched_async) {
// Async run: the completion is delivered on the GUI thread. When this call is itself on
// the GUI thread it must pump the event loop, or the completion can never be delivered.
std::unique_lock<std::mutex> lock(mutex);
if (wxTheApp != nullptr && wxIsMainThread()) {
while (!done) {
cv.wait_for(lock, std::chrono::milliseconds(10));
lock.unlock();
wxYield();
lock.lock();
}
} else {
cv.wait(lock, [&] { return done; });
}
} else {
result = launch.outcome.result;
error = launch.outcome.error;
}
return result;
}
PluginManager::ScriptLaunchResult PluginManager::launch_script_capability(const std::string& plugin_key,
const std::string& capability_name,
ScriptCompletionFn on_done)
{
ScriptLaunchResult launch;
// Mirror the historical synchronous behavior for a dangling reference: nothing to run and
// no error (the caller's guarded UI normally prevents this).
if (plugin_key.empty() || capability_name.empty())
return launch;
if (m_shutting_down.load(std::memory_order_acquire)) {
launch.outcome.error = _u8L("Plugin system is shutting down.");
return launch;
}
auto cap = get_plugin_capability({PluginCapabilityType::Script, capability_name, plugin_key});
if (!cap)
return {};
return launch;
auto cap_interface = std::dynamic_pointer_cast<ScriptPluginCapability>(cap);
if (!cap_interface)
return {};
if (!std::dynamic_pointer_cast<ScriptPluginCapability>(cap))
return launch;
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 = _u8L("Unknown error");
BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw unknown exception. plugin_key=" << plugin_key;
// Every Script body runs off the caller's thread now: the invocation — including detection and
// a synchronous `def execute` — is handed to the runtime, which runs it on its own worker and
// reports the outcome through the completion callback. Nothing executes before returning here,
// so the UI thread is never blocked by a Script invocation.
const bool started = ScriptAsyncRuntime::instance().start(
plugin_key, capability_name, cap,
[this, plugin_key, on_done](const ScriptCompletion& completion) {
// A cancellation requested by cancel_plugin() (unload/shutdown) is expected termination:
// record no plugin error and surface nothing. Genuine user/plugin errors still record.
if (!completion.cancelled && !completion.error.empty())
set_plugin_error(plugin_key, completion.error);
if (on_done)
on_done(completion);
});
if (!started) {
launch.outcome.error = _u8L("Plugin system is shutting down.");
set_plugin_error(plugin_key, launch.outcome.error);
BOOST_LOG_TRIVIAL(error) << "Script plugin execution failed. plugin_key=" << plugin_key
<< " error=" << launch.outcome.error;
return launch;
}
if (!error.empty()) {
set_plugin_error(plugin_key, error);
}
return result;
launch.launched_async = true;
return launch;
}
void PluginManager::dispatch_lifecycle_event(LifecycleEvent evt, const LifecycleEventContext& ctx) {