20 KiB
Python plugin async bridge — High Level Design
Purpose and scope
The embedded Python plugin runtime runs most capability calls synchronously: a C++ call site acquires
the GIL and the capability's Python method runs to completion on the calling thread. The staged preset
update (begin_update() in preset-plugin-api.md) is also synchronous, but
its target selection can open unsaved-edit dialogs. It therefore blocks the Script worker, not the UI
thread, and returns only when the selection settles.
This document describes the Script capability's background execution path, including the optional
per-invocation asyncio loop for an async def execute, and the blocking UI marshalling that host calls
use from that worker. The bridge is deliberately narrow: only the Script capability gains a
background/async execution path. Every other capability — including the SlicingPipeline hooks — stays
synchronous on its caller's thread.
Execution baseline
How a capability call runs
A capability instance is a Python object owned through Slic3r::PluginInstanceHandle
(src/slic3r/plugin/PythonPluginBridge.cpp) and handed to the rest of the program as a
std::shared_ptr<Slic3r::PluginCapabilityInterface>. Its identity (name(), type(),
audit_plugin_key()) is resolved once at materialization and cached in C++
(PluginCapabilityInterface in src/slic3r/plugin/PythonPluginInterface.hpp).
Every C++→Python call crosses a trampoline method. PyPluginCommonTrampoline and
PyPluginInterfaceTrampoline (src/slic3r/plugin/PyPluginTrampoline.hpp), and the per-type subclass
PyScriptPluginCapabilityTrampoline
(src/slic3r/plugin/pluginTypes/script/ScriptPluginCapabilityTrampoline.hpp), wrap the call in the
ORCA_PY_OVERRIDE_AUDITED macro. For one synchronous call that macro, in order:
- constructs a
PluginCapabilityInterface::RefCounter, whichincrement()s the capability's reference count for the call's duration; - constructs a
PythonGILState, which first acquires aPythonRuntimeLeaseand thenPyGILState_Ensure(); - opens a
ScopedPluginAuditContext(audit_plugin_key(), name()); - dispatches into Python via
PYBIND11_OVERRIDE/PYBIND11_OVERRIDE_PURE;
and unwinds those guards when the call returns.
PythonGILState and PythonRuntimeLease live in src/slic3r/plugin/PythonInterpreter.hpp. A lease is
a shared_lock on PythonInterpreter::m_runtime_mutex; PythonInterpreter::shutdown() takes the
exclusive side, so a thread that holds a lease can safely touch Python objects, and interpreter
finalization cannot run underneath it. ScopedPluginAuditContext
(src/slic3r/plugin/PluginAuditManager.hpp) stores the previous thread-local plugin/capability pair
and scoped filesystem roots, sets this call's, and restores them on destruction; the audit hook then
attributes every filesystem/socket/process event to the plugin currently executing on that thread.
The Script capability
Slic3r::ScriptPluginCapability (src/slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp)
declares one pure virtual method, ExecutionResult execute(). The async runtime invokes it through
invoke_script_execute (src/slic3r/plugin/PyPluginTrampoline.hpp), which replicates the audited
trampoline call (ref counter, audit scope, traceback-preserving rethrow) but returns a
ScriptExecuteInvocation instead of forcing a cast:
- a plain
def executeruns to completion ininvoke_script_executeand yieldsScriptExecuteInvocation::result; - an
async def execute(or any override that returns a coroutine, detected withinspect.iscoroutine) yields the coroutine object inScriptExecuteInvocation::awaitablewithout running it, so the runtime can drive it.
ScriptExecuteInvocation is defined in PyPluginTrampoline.hpp; py_is_coroutine is the detection
helper.
UI marshalling
PluginHostUi::run_on_ui_thread (src/slic3r/plugin/host/PluginHostUi.cpp) forwards to the
file-local run_on_ui_blocking:
- if the caller is already on the wx main thread, it runs the callable inline after a
py::gil_scoped_release; - otherwise it sets up a
std::promise/std::future, releases the GIL, posts the callable withGUI::wxGetApp().CallAfter, and blocks onfuture.get().
That is a blocking handoff: the caller's stack is parked on a thread while the UI thread runs the
callable. It is the correct primitive for the synchronous update()/commit() path and for
reload_local_bundle/list_bundle_ids, but it cannot be used by an operation that must suspend.
Slicing hooks
Print::set_slicing_pipeline_hook_fn is installed by src/slic3r/plugin/PluginHooks.cpp. The hook
runs on the slicing worker thread and dispatches to SlicingPipelinePluginCapability::execute(ctx)
per capability, acquiring a PythonGILState per capability. SlicingPipelineContext
(SlicingPipelinePluginCapability.hpp) holds raw pointers into the live slicing graph
(ctx.print, ctx.object) that are valid only for the duration of execute(ctx). The UI thread can
be blocked waiting on the slicing worker (BackgroundSlicingProcess::stop() parks it), so a
marshalled UI call from a hook can deadlock; PluginHostSlicing.cpp, SlicingPipelinePluginCapability.hpp
and PluginHostUi.hpp all document this.
Script-only async execution
Per-invocation worker and loop
ScriptAsyncRuntime (declared in src/slic3r/plugin/PluginManager.hpp, implemented in
PluginManager.cpp) drives every Script invocation off the caller's thread. Its start() creates a
Run and launches a detached worker thread that runs Impl::run_invocation. There is one worker
thread per invocation, so no invocation ever blocks the UI thread and no invocation is queued behind
another plugin's.
run_invocation runs detection and execution on the worker:
- it re-checks
cancel_requestedbefore anything (a cancellation that arrived before the run started is honored without running the plugin); - it calls
invoke_script_execute; a plaindef executeresult is used directly, and anasync defcoroutine is driven on a loop created for that invocation:asyncio.new_event_loop()→asyncio.set_event_loop(loop)→loop.create_task(coroutine)→loop.run_until_complete(task)→loop.close(), thenasyncio.set_event_loop(None).
The loop is per invocation, not global, and no loop outlives its invocation. The loop for the
calling thread is published in the thread-local tl_script_loop and exposed through
ScriptAsyncRuntime::current_loop() (GIL held). A host call such as begin_update() does not suspend
the loop: it blocks the worker thread while the UI settles, then returns a value the coroutine uses
directly.
PluginManager::launch_script_capability is the entry point: it resolves the capability, hands it to
ScriptAsyncRuntime::start, and sets ScriptLaunchResult::launched_async. The completion callback
runs on the GUI thread (or inline when there is no wx app), and launch_script_capability records a
run error through set_plugin_error before invoking the caller's ScriptCompletionFn.
PluginManager::run_script_capability is kept as a compatibility wrapper: it waits for the
completion callback, pumping wxYield() when it is itself on the GUI thread, and returns the
ExecutionResult.
Capability, runtime, GIL and audit lifetime across a suspension
For the whole invocation — including an async def suspended at an await — run_invocation holds:
- a
PluginCapabilityInterface::RefCounterper invocation and ashared_ptrto the capability in theRun, so the capability and its module stay alive across awaits and unload cannot drop them; - a
PythonRuntimeLease(acquired before the GIL).PythonInterpreter::shutdown()cannot finalize the interpreter while any invocation worker holds a lease; - the GIL, acquired with
pybind11::gil_scoped_acquireon the worker; - a
ScopedPluginAuditContext(audit_plugin_key(), name()), so filesystem/socket/process events raised by the coroutine are attributed to the plugin.
The capability reference is released inside the GIL scope (run->capability.reset()), because
dropping the pybind holder from a non-Python thread would DECREF without the GIL. The loop is
closed and tl_script_loop cleared before the worker exits.
A consequence of running Script on a non-main thread: the audit permission prompt is fail-closed off
the main thread. request_permission in src/slic3r/plugin/PluginAuditManager.cpp posts the dialog
with CallAfter and denies the current event, persisting the grant when the user accepts, so a
Script sees a denial on the first attempt and can succeed on a later one. This matches the existing
behavior for the plugin-load worker and the slicing worker.
Blocking UI continuation
PluginHostUi::run_on_ui_thread (in src/slic3r/plugin/host/PluginHostUi.cpp) is the primitive host
calls use from the Script worker. It forwards to run_on_ui_blocking:
- if the caller is already on the wx main thread, it runs the callable inline after a
py::gil_scoped_release; - otherwise it sets up a
std::promise/std::future, releases the GIL, posts the callable withGUI::wxGetApp().CallAfter, and blocks onfuture.get().
Because the Script capability owns a worker thread, blocking there does not freeze the UI. The same
primitive is used by begin_update(), set()/commit(), reload_local_bundle() and
list_bundle_ids().
Synchronous begin_update()
begin_preset_update and begin_filament_update (src/slic3r/plugin/host/PluginHostPresets.cpp) run
on the Script worker with the GIL held and block it:
- the binding enters through
PluginHostUi::run_on_ui_thread, which fails with aRuntimeErrorbefore posting when there is no wx application; - the UI thread runs the target binding with the GIL released —
bind_preset_targetfor process/printer,bind_filament_targetfor filament — includingTab::select_preset()and any unsaved-edit prompt; - the worker resumes with the name-based
PresetTransaction, and nothing is staged until the caller's laterset()/commit().
An unavailable target is rejected before selection and changes no preset. A selection the user
declines raises RuntimeError and creates no transaction; decisions already accepted in an earlier
dialog are not rolled back. No GUI or preset pointer outlives the call: the transaction holds only
copied category/name/slot values.
set() stages a serialized value without mutating the preset, and commit() applies the batch
through Tab::apply_preset_config_values on the UI thread. Both are synchronous and, per their
bindings, valid only on the background Script worker.
Cancellation
ScriptAsyncRuntime::cancel_plugin(plugin_key) sets cancel_requested on every active run for the
plugin and, when a loop is live (has_loop), cancels the task with
loop.call_soon_threadsafe(task.cancel) while holding the GIL. A synchronous def execute
invocation has no loop, so only the flag is set (checked before the run starts).
A task cancellation cannot interrupt a blocking begin_update() native call: the worker is inside
that call, so the loop does not run the scheduled task.cancel() until the selection settles and the
call returns. The transaction is therefore delivered, and any accepted UI selection stands; the
cancellation is observed at the coroutine's next suspension point. A user-declined selection is an
ordinary RuntimeError, not asyncio.CancelledError.
A plugin's own asyncio.CancelledError is treated as a cancellation only when cancel_requested was
set; otherwise it stays a genuine error (see run_invocation), so a plugin that cancels its own task
does not masquerade as an unload cancellation.
Unload and shutdown drain
Unloading or shutting down must guarantee that no plugin Python code runs after its module is unloaded, and that a blocking UI selection cannot mutate presets after teardown.
PluginManager::cancel_and_wait_for_capabilitiescallson_cancelled()first (so the plugin can observe state while the run is intact), thenScriptAsyncRuntime::cancel_plugin, then drains. The drain is unbounded while a Script invocation worker is outstanding — its loop-exit condition isall_released && !invocation_active.has_active_run()distinguishes a run that was cancelled before it started from one whose worker is still live, and a worker blocked inbegin_update()keeps the run active until its UI callback returns. On the GUI thread the drain pumpswxYield()so a modal selection can settle instead of being starved by the blocking wait. Only the non-Script path keeps the historical bounded 2-second grace: when no invocation is active, the drain stops waiting for capability references at the grace deadline and logs any remaining ones.PluginManager::shutdowndetaches the libslic3r hooks (plugin_hooks::uninstall), stops new loads, waits for in-flight loads, unloads every plugin (draining the async runs above), then callsScriptAsyncRuntime::instance().shutdown(), which refuses new runs and waits without a deadline for every invocation worker to finish — nothing is torn down by force.ScriptAsyncRuntime::reset()re-arms the runtime so a laterinitialize()in the same process can start runs again.
Cancellation triggered by unload or shutdown is a separate, expected completion, not a plugin
failure. run_invocation marks a run whose cancel_requested was set as completion.cancelled and
clears any error, whatever the coroutine body did. launch_script_capability records a plugin error
only when !completion.cancelled && !completion.error.empty(); run_script_capability reports an
empty error for a cancelled completion; and the two GUI launchers treat cancelled as neither error
nor success (the speed dial stays silent, the Plugins dialog updates no status). A plugin's own
CancelledError is not counted as this expected termination unless cancel_requested is set, so a
self-cancelled plugin still records its failure.
Supported and unsupported capabilities
| Capability | Async support | Reason |
|---|---|---|
| Script | def execute and async def execute both run on the per-invocation worker |
The runtime owns the worker and, for a coroutine, a per-invocation loop; nothing about a Script invocation borrows C++ state. |
| SlicingPipeline | Synchronous only | execute(ctx) borrows Print/PrintObject for the callback's duration, and the UI thread can be blocked on the slicing worker. PluginHooks.cpp rejects an async override: cap->execute() casts its Python result to ExecutionResult, and a coroutine object cannot be cast, so it surfaces as a SlicingError rather than being scheduled. |
| PrinterConnection, Pages, Analysis, Importer, Exporter, Visualization, ResourceTexture | Unchanged | Not part of this bridge; they keep their existing synchronous contract. |
Deadlock constraints
- Never block the UI thread on plugin work. Every Script launch path goes through
launch_script_capability; the speed-dialActionRegistryaction and the Plugins dialog launcher return immediately and report the outcome later (see Examples). - Never call
orca.host.ui.*from a slicing hook. The slicing worker runs while the UI thread can be parked on it. - Marshal host UI work from the Script worker only.
PluginHostUi::run_on_ui_threadblocks the calling worker while the UI thread runs the callback; calling it from the UI thread itself would deadlock the marshalling path. - No pointer crosses a call boundary.
begin_update()returns copied category/name/slot values; theSlicingPipelineContextgraph is valid only within one synchronousexecute(ctx).
File and symbol map
Script execution runtime:
src/slic3r/plugin/PluginManager.hpp/.cpp—ScriptAsyncRuntime(instance,start,cancel_plugin,has_active_run,shutdown,reset,current_loop),ScriptCompletion,ScriptCompletionFn,ScriptLaunchResult,launch_script_capability,run_script_capability.src/slic3r/plugin/PyPluginTrampoline.hpp—invoke_script_execute,ScriptExecuteInvocation,py_is_coroutine.src/slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp/ScriptPluginCapabilityTrampoline.hpp—ScriptPluginCapability::execute,PyScriptPluginCapabilityTrampoline.
Lifetime and GIL primitives:
src/slic3r/plugin/PythonInterpreter.hpp—PythonInterpreter,PythonRuntimeLease,PythonGILState.src/slic3r/plugin/PythonPluginInterface.hpp—PluginCapabilityInterface,RefCounter,increment/decrement/ref_count.src/slic3r/plugin/PluginAuditManager.hpp/.cpp—ScopedPluginAuditContext,request_filesystem_read_permissions,request_permission(fail-closed off the main thread).
UI marshalling:
src/slic3r/plugin/host/PluginHostUi.hpp/.cpp—PluginHostUi::run_on_ui_thread, the file-localrun_on_ui_blocking, andclose_windows_for_plugin.GUI::wxGetApp().CallAfter— the post-to-UI-thread primitive.
Preset targets:
src/slic3r/plugin/host/PluginHostPresets.cpp—PresetEditor,PresetTransaction,bind_preset_target,bind_filament_target,begin_preset_update,begin_filament_update,SelectionCancelled,resolve_combo_preset/resolve_preset_in_combo.src/slic3r/GUI/Tab.hpp/.cpp—Tab::select_preset,Tab::apply_preset_config_values,Tab::commit_preset_config_value.
Launch paths:
src/slic3r/GUI/ActionRegistry.cpp—PluginScriptAction::run,notify_script_outcome.src/slic3r/GUI/PluginsDialog.cpp—PluginsDialog::run_script_plugin_capability.
Slicing (synchronous only):
src/slic3r/plugin/PluginHooks.cpp—install_slicing_pipeline_hook.src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp—SlicingPipelineContext,execute.
Unload/shutdown:
src/slic3r/plugin/PluginManager.cpp—unload_plugin,unload_all_plugins,cancel_and_wait_for_capabilities,shutdown.
Examples
An async def execute that updates a preset. execute runs on the background Script worker with its
own asyncio loop; begin_update() blocks that worker until any selection / unsaved-edit dialog is
answered, without blocking the UI thread; set()/commit() then run synchronously on the same
worker. This is the shape used by sandboxes/orca_preset_transaction_demo.py:
import asyncio
import orca
class AutoTune(orca.script.ScriptPluginCapabilityBase):
def get_name(self):
return "Auto tune"
async def execute(self):
# begin_update() blocks this worker until the user answers any
# selection / unsaved-edit dialog. The UI thread stays responsive.
tx = orca.host.preset.process.begin_update(preset_name="0.20mm Standard")
tx.set("layer_height", "0.2")
tx.commit()
return orca.ExecutionResult.success("process preset updated")
The window-driven demo shows the same pattern where the plugin's own UI must hand work to the worker:
on_message fires on the UI thread and only enqueues onto the worker loop with
loop.call_soon_threadsafe; execute consumes that queue and calls begin_update() per command.
Sequence for one begin_update(...):
Script worker (owned, per invocation) UI thread
loop.run_until_complete(task)
begin_update() ───────────────────────► CallAfter(run_on_ui_blocking)
(blocked; not suspended) bind_*_target →
Tab::select_preset() … dialog
settles
promise set; worker resumes with PresetTransaction
set(); commit() (UI thread for the batch)
coroutine resumes at its next await
execute() returns ExecutionResult
A plain def execute needs no loop and runs to completion on the same worker; both kinds are
launched with launch_script_capability, so the caller (UI thread) never blocks. A launch failure is
reported inline; a started run reports its outcome through the completion callback — the speed dial
shows "started" and pushes a completion notification, and the Plugins dialog updates its status when
finish runs.