feat: preset APIs

This commit is contained in:
peachismomo
2026-10-17 01:44:15 +08:00
parent 3bfe40e17a
commit d80af50f23
15 changed files with 2762 additions and 277 deletions
+99 -86
View File
@@ -57,18 +57,22 @@ tx.set("layer_height", "0.2")
tx.commit()
```
The example expresses the intended API contract. The current Python bridge does not
support this awaitable transaction shape; supporting it requires new coroutine or
continuation handling. Any wait for a UI decision must suspend the plugin operation
without blocking Orca's UI thread.
The example is the API contract, and the Python bridge provides it. `begin_update()`
is awaitable: it suspends the plugin operation until the target selection and any
unsaved-edit dialog settle, without blocking Orca's UI thread. `set()` and `commit()`
run synchronously on the background Script worker once the awaitable resolves.
Awaiting `begin_update()` requires the Script worker's running asyncio loop; the
mechanism is described in [python-plugin-async-bridge.md](python-plugin-async-bridge.md).
### Target resolution
- **Filament:** `slot_number` identifies the slot. If `preset_name` is omitted,
the transaction targets the preset currently assigned to that slot. If supplied,
it requests that preset for the slot and selects it through the normal UI flow;
the name must be among the presets offered by that slot's dropdown. Selecting a
different preset changes the slot-to-preset mapping. In both cases the transaction
the name must be among the presets offered by that slot's dropdown, with one
exception: the preset currently assigned to the slot stays targetable even when the
slot's dropdown hides it (for example an incompatible-but-selected preset). Selecting
a different preset changes the slot-to-preset mapping. In both cases the transaction
edits the shared preset, not an isolated slot override.
- **Process and printer:** omitting `preset_name` targets the currently active
preset. Supplying `preset_name` requests the preset by the name shown in its
@@ -86,21 +90,23 @@ different preset.
## Selection and user interaction
`begin_update()` resolves its target, then delegates any required selection to the
shared underlying `switch_preset` operation. It calls `switch_preset` only when the
requested target differs from the active target; otherwise it returns a transaction
without switching. `switch_preset` is responsible for using Orca's normal preset
selection flow, including updating a filament slot assignment when requested. If
the normal selection flow requires user input, it opens the relevant existing
dialog(s), including unsaved-edit choices such as save, discard, or transfer. The
operation is awaitable and remains pending until those interactions finish; it
resolves with the switch result or cancellation, without applying staged settings.
A canceled switch does not change the requested target or filament slot assignment,
but it does not roll back decisions the user already accepted in an earlier dialog.
`switch_preset` is a proposed implementation-level coordinator, not an existing
function or a separate plugin API. It must use the normal type-specific selection
paths and confirm that the requested preset actually became active; a successful
selection request alone is not sufficient.
`begin_update()` resolves its target through shared binding helpers
(`bind_preset_target` for process/printer, `bind_filament_target` for filament) and
performs the selection only when the requested target differs from the active target;
otherwise it returns a transaction without switching. The helpers use Orca's normal
preset selection flow, `Tab::select_preset()`, including updating a filament slot
assignment when requested. If the normal selection flow requires user input, it opens
the relevant existing dialog(s), including unsaved-edit choices such as save, discard,
or transfer. The operation is awaitable and remains pending until those interactions
finish; it resolves with the bound transaction, or with a cancellation, without
applying staged settings. A canceled selection does not change the requested target or
filament slot assignment, but it does not roll back decisions the user already accepted
in an earlier dialog. Cancellation of the awaiting coroutine is separate from a user
cancel: a selection the user already accepted is not reversed, while the remaining slot
assignment and the transaction creation are skipped when the cancellation is observed
before the slot is reassigned (see [python-plugin-async-bridge.md](python-plugin-async-bridge.md)). The helpers then confirm that the requested preset actually
became the edited preset; a successful selection request alone is not sufficient. There
is no separate `switch_preset` plugin API.
If the requested target is already active, `begin_update()` returns without a
selection prompt, apart from any UI-thread dispatch needed by the bridge. If a
@@ -120,22 +126,30 @@ the active preset is the target.
`set(key, value)` stages a serialized config value and does not mutate the preset.
The transaction is the boundary for a related group of changes, avoiding
intermediate partially-updated states. At commit, the API validates keys and values
against the target preset type and applies the batch through the existing tab and
settings-group update path. Existing setting-specific validation, correction, and
callbacks remain authoritative; plugin updates must not create a parallel setter
with different behavior.
intermediate partially-updated states. `commit()` runs synchronously on the background
Script worker. At commit, the API validates keys and values against the target preset
type and applies the batch through `Tab::apply_preset_config_values`, the existing tab
and settings-group update path. Existing setting-specific validation, correction, and
callbacks remain authoritative; plugin updates do not create a parallel setter with
different behavior.
A successful commit updates dirty state and downstream configuration/Plater state
once for the batch where possible. The edited preset remains unsaved; persistence
continues through Orca's normal save action. A validation or commit failure must
not leave only some staged target settings applied. This all-or-nothing batch
guarantee requires a batch-aware commit path; the current setter applies one option
at a time and cannot provide it by simply looping over existing calls. A filament slot assignment
made as part of target selection is not a staged setting: after the user accepts
the selection flow, it may remain changed even if a later commit fails. The API
does not automatically switch back or reverse the user's selection or any accepted
transfer/discard decision for the previous preset.
once for the batch. The edited preset remains unsaved; persistence continues through
Orca's normal save action. A validation or commit failure restores the edited preset's
config to its pre-batch state and recomputes the state derived from it (compatibility
marks, the preset selector, dependent tabs, the Plater and the printer agent), so no
staged setting is left partially applied. That guarantee is limited to the edited
preset and its config-derived state: an `on_change` callback is a GUI method, not a pure
function of the config, so the transaction does not reverse a preset or slot selection
made while resolving the target, a dialog decision the user already accepted, an undo
snapshot, a dependent preset's rewritten config, or anything already sent to a device.
Exact all-or-nothing across those external effects is not achievable while the
authoritative GUI callbacks run; the boundary is documented on
`Tab::apply_preset_config_values`. A filament slot assignment made as part of target
selection is likewise not a staged setting: after the user accepts the selection flow it
may remain changed even if a later commit fails, and the API does not switch back or
reverse the user's selection or any accepted transfer/discard decision for the previous
preset.
## Outcomes and consistency
@@ -153,9 +167,10 @@ duplicate writes to one setting in a transaction are rejected rather than resolv
by incidental call order.
The existing one-setting filament call,
`orca.host.preset.filament.update(slot_number, key, value)`, can remain as a
`orca.host.preset.filament.update(slot_number, key, value)`, remains available as a
compatibility shorthand. It shares the staged API's target resolution, selection,
validation, and commit path.
validation, and commit path, and it is synchronous (it blocks the background Script
worker) rather than awaitable.
## Design principles
@@ -180,63 +195,61 @@ validation, and commit path.
filament share transaction and error semantics while respecting their existing
selection models.
## Feasibility against the current codebase
## Implementation in the current codebase
The design builds on existing preset and tab behavior, but the transaction API and
some target-selection behavior are new. The current plugin surface exposes
per-setting `update()` calls. For filament, `update_filament_slot()` resolves the
preset already assigned to a slot, selects it through `Tab::select_preset()`, and
applies one setting through `Tab::apply_preset_config_value()`. It does not change
the slot assignment. There is no current `begin_update()`, transaction type, or
`switch_preset()` helper.
The transaction API is implemented. The current plugin surface exposes `begin_update()`,
`PresetTransaction` with `set()`/`commit()`, and the per-setting `update()` compatibility
shorthand. For filament, `update_filament_slot()` resolves the preset already assigned
to a slot, selects it through `Tab::select_preset()`, and applies one setting through
`Tab::apply_preset_config_values()`; it does not change the slot assignment.
`Tab::select_preset()` is the existing basis for switching to process, printer, or
filament presets and for invoking the normal unsaved-edit flow. The flow is
type-specific: a process selection can trigger compatibility handling for
filaments, and a printer selection can involve technology changes and dependent
preset compatibility. It may therefore show more than one dialog. Cancellation of
a later step does not necessarily undo decisions the user already accepted in an
earlier dialog. The plugin operation must preserve those side effects and verify
the resulting edited target.
`begin_update()` is awaitable through a Future-backed UI continuation
(`begin_preset_update_async` / `begin_filament_update_async` plus `AsyncFuture`; see
[python-plugin-async-bridge.md](python-plugin-async-bridge.md)). It posts the target
binding to the UI thread and resolves once the selection settles, so the wait does not
block the UI thread. The `update()` shorthand keeps the synchronous, blocking form,
which is valid only on the background Script worker.
The filament `preset_name` behavior is an additional integration, not a capability
of `Tab::select_preset()`: selecting a different name for a slot must also update
the slot-to-preset mapping through the Plater's normal selection behavior. That
path updates more than the vector entry, including dependent UI/configuration state.
The API should reuse or factor that path rather than call
`PresetBundle::set_filament_preset()` alone. It must also define which UI-visible
slots are valid, including whether mixed-color virtual slots are targetable.
`Tab::select_preset()` is the basis for switching to process, printer, or filament
presets and for invoking the normal unsaved-edit flow. The flow is type-specific: a
process selection can trigger compatibility handling for filaments, and a printer
selection can involve technology changes and dependent preset compatibility. It may
therefore show more than one dialog. Cancellation of a later step does not necessarily
undo decisions the user already accepted in an earlier dialog. Target resolution
preserves those side effects and then verifies the resulting edited target.
Process and printer dropdown choices are constrained by visibility and
compatibility, and compatibility may depend on the active context. `Tab::select_preset()`
performs its own compatibility and technology checks; merely finding a name in the
collection does not prove it is an allowed dropdown choice. Target validation must
follow the same rules as the UI.
Filament `preset_name` is an additional integration on top of `Tab::select_preset()`:
selecting a different name for a slot also updates the slot-to-preset mapping and its
dependent UI/configuration state, mirroring the filament branch of the Plater's normal
selection path (`PresetBundle::set_filament_preset()` plus the project/selection refresh,
the dynamic filament list, the conditional support-filament flushing recalc, the slot
combo refresh and its AMS sync badge). A mixed-color virtual slot is not targetable.
The current `PluginHostUi::run_on_ui_thread()` path marshals work synchronously and
waits for completion; modal selection dialogs block that operation until answered.
It is not an asyncio/coroutine bridge. Thus the user-facing wait exists today, but
the proposed Python `await` behavior does not. An awaitable `switch_preset()` needs
new plugin scheduling/continuation support and must avoid blocking the UI thread or
deadlocking callers running on threads that the UI itself waits for.
Process and printer dropdown choices are constrained by visibility and compatibility,
and compatibility may depend on the active context. Target resolution checks the tab
dropdown's actual items before the selection flow, so a target the user cannot pick
fails without raising that flow's unsaved-edit prompt; a name two entries carry is
rejected as ambiguous. A filament `preset_name` resolves against the requested slot's
own sidebar selector rather than the tab's, so that slot's compatibility filter is the
one that applies. A name the slot's dropdown does not show is rejected unless it names
that slot's current assignment (a hidden or incompatible-but-selected preset), which
stays targetable.
Finally, `Tab::apply_preset_config_value()` validates and commits one setting at a
time. It backs up one option and invokes callbacks that can adjust related settings.
A transaction that promises all-or-nothing updates and one downstream refresh needs
a batch-aware prepare/commit mechanism that accounts for those callback effects;
repeating the current single-setting method would allow partial updates and repeated
refreshes. Since `begin_update()` performs target selection before the caller stages
values, invalid staged values can still follow a user-visible selection prompt. That
is a consequence of this API shape and should remain explicit in its behavior.
`commit()` applies its batch through `Tab::apply_preset_config_values`, which parses and
validates the whole batch before touching the edited preset and restores the edited
preset and its config-derived state on failure (see "Staging and commit semantics" for
the limit of that rollback). Since `begin_update()` performs target selection before the
caller stages values, invalid staged values can still follow a user-visible selection
prompt; that is a consequence of this API shape and remains explicit in its behavior.
## Integration boundary
The plugin API coordinates target resolution, preset selection, staged values, and
the settings commit path. It does not own or duplicate preset dirty-state logic,
selection prompts, setting validation, or persistence. Target changes must flow
through the same UI selection path so dependent behavior—such as selecting the
printer agent—still runs. Setting changes must flow through the same UI change path
so `on_change` and related validation/correction behavior still runs. The
integration must bridge potential UI prompts to the Python operation
asynchronously, reuse the normal Plater slot-selection behavior for explicit
filament targets, then commit a batch without losing existing per-setting behavior.
selection prompts, setting validation, or persistence. Target changes flow through
the same UI selection path so dependent behavior—such as selecting the printer
agent—still runs. Setting changes flow through the same UI change path so `on_change`
and related validation/correction behavior still runs. The integration bridges UI
prompts to the Python operation asynchronously (the awaitable `begin_update()`), reuses
the normal Plater slot-selection behavior for explicit filament targets, and commits a
batch without losing existing per-setting behavior.
+398
View File
@@ -0,0 +1,398 @@
# Python plugin async bridge — High Level Design
## Purpose and scope
The embedded Python plugin runtime runs capability calls synchronously: a C++ call site acquires the
GIL and the capability's Python method runs to completion on the calling thread. That model cannot
express an operation that has to wait for the user — most visibly the staged preset update
(`begin_update()` in [preset-plugin-api.md](preset-plugin-api.md)), whose target selection can open
unsaved-edit dialogs and must not block Orca's UI thread while it waits.
This document describes the bridge that lets a Python plugin suspend and resume such an operation
without blocking the UI thread. The bridge is deliberately narrow: only the Script capability gains
an awaitable execution path. Every other capability — including the SlicingPipeline hooks — stays
synchronous.
## 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:
1. constructs a `PluginCapabilityInterface::RefCounter`, which `increment()`s the capability's
reference count for the call's duration;
2. constructs a `PythonGILState`, which first acquires a `PythonRuntimeLease` and then
`PyGILState_Ensure()`;
3. opens a `ScopedPluginAuditContext(audit_plugin_key(), name())`;
4. 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 execute` runs to completion in `invoke_script_execute` and yields
`ScriptExecuteInvocation::result`;
- an `async def execute` (or any override that returns a coroutine, detected with
`inspect.iscoroutine`) yields the coroutine object in `ScriptExecuteInvocation::awaitable` without
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 with
`GUI::wxGetApp().CallAfter`, and blocks on `future.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_requested` before anything (a cancellation that arrived before the run
started is honored without running the plugin);
- it calls `invoke_script_execute`; a plain `def execute` result is used directly, and an `async def`
coroutine 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()`, then `asyncio.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`, exposed to host awaitables through
`ScriptAsyncRuntime::current_loop()` (GIL held).
`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::RefCounter` per invocation and a `shared_ptr` to the capability in the
`Run`, 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_acquire` on 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.
### Awaitable UI continuation
`AsyncFuture` (`src/slic3r/plugin/host/PluginHostUi.hpp`, implemented in `PluginHostUi.cpp`) is the
suspension primitive for a host call that must show a dialog before it can resolve. It carries no GUI
or preset state — only the loop, the Future created on it, and a Python-side resolver:
- `AsyncFuture::create()` requires the GIL and a running asyncio loop. It binds
`asyncio.get_running_loop()`, creates a Future with `create_future()`, and installs the resolver
`make_future_resolver` and the cancellation watcher `make_cancellation_watcher` on the Future.
- `complete(value)`/`fail(exc)` marshal the outcome with `loop.call_soon_threadsafe(m_bind, …)`, so
the Future is resolved on its own loop thread, never from the UI thread. The resolver drops an
outcome whose Future is already cancelled (`set_result`/`set_exception` on a cancelled Future would
raise `InvalidStateError`).
- Cancellation is observed two ways, so the UI thread can decide without touching the loop. The
loop-thread done-callback `make_cancellation_watcher` sets an atomic (`abandon()`); `is_cancelled()`
additionally reads the Python Future's own `cancelled()` under the GIL, so a cancellation that
`Future.cancel()` applied synchronously is seen even before the loop runs the done-callback.
`is_cancelled()` never mutates the Future or the loop and is safe from the UI thread. A queued
selection therefore never starts, and one inside a modal stops when the modal returns.
`~AsyncFuture` releases its Python references under the GIL, or intentionally leaks them if the
interpreter is already gone (same rule as the UI callable holders).
`PluginHostUi::post_to_ui_thread(fn)` posts work to the UI thread without blocking (returns false when
the application is not initialized); `require_ui_application()` fails synchronously so an awaitable
entry point can reject before touching asyncio.
### Awaitable `begin_update()`
`begin_update()` is a real awaitable. `begin_preset_update_async` and `begin_filament_update_async`
(`src/slic3r/plugin/host/PluginHostPresets.cpp`) run on the plugin worker with the GIL held and a
running loop:
1. validate the application synchronously (`require_ui_application()`), so a call without a wx app
fails exactly like `update()`;
2. create an `AsyncFuture` on the running loop and register it with
`PluginHostUi::track_pending_async(handle)`;
3. post the selection to the UI thread with `PluginHostUi::post_to_ui_thread`;
4. return the Future at once.
The UI callback first skips the selection entirely when `handle->is_cancelled()`, then runs the
target binding with the GIL released — `bind_preset_target` for process/printer, `bind_filament_target`
for filament — passing a `CancelProbe` that also reads `handle->is_cancelled()`. It reuses the normal
selection flow `Tab::select_preset()`, including the unsaved-edit prompt. On success it resolves the
Future with the name-based `PresetTransaction`; on failure it fails the Future with the mapped Python
exception. Nothing is staged until the caller's later `set()`/`commit()`. An unavailable target is
rejected before selection and changes no preset. If the user declines selection, decisions already
accepted in an earlier dialog are not rolled back. Coroutine cancellation does not undo an accepted
preset selection; for filament, slot reassignment is skipped when cancellation is observed before the
mapping check, but may remain if cancellation arrives after that check, even when no transaction is
delivered. No GUI or preset pointer crosses the suspension — the awaitable 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 (they call `PluginHostUi::run_on_ui_thread`,
which blocks the worker, or runs inline if entered from the UI thread).
### Cancellation and accepted-selection boundary
Two distinct cancellations exist:
- **User declined the selection** — `Tab::select_preset()` returned false. `bind_*_target` throws
`SelectionCancelled`, which the awaitable path maps to `orca.host.preset.PresetSelectionCancelled`
(a `RuntimeError` subclass). The synchronous `update()` path keeps raising plain `RuntimeError`.
- **The awaiting coroutine was cancelled** — task cancellation or plugin unload. `AsyncFuture`
observes this two ways: the loop-thread done-callback sets an atomic, and `is_cancelled()` also
reads the Future's own `cancelled()` under the GIL (see above). `bind_*_target` checks its
`CancelProbe` after `select_preset()` returns and after the modal-free paths, throwing
`SelectionAbandoned`; the outcome is dropped because the handle is cancelled/abandoned, so the
coroutine sees `asyncio.CancelledError`. A queued selection never starts.
`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).
The accepted-selection boundary is deliberate and cannot be fully enforced once the UI flow has
begun:
- A selection the user already accepted — including any unsaved-edit dialog decision and the switch
itself — is a UI decision and is **not** reversed. A cancellation that lands after the modal has
been answered leaves that accepted selection in place.
- Cancellation only skips the remaining work. In the filament path, the slot assignment and the
transaction creation are skipped only when the cancellation is observed **before** the slot mapping
(`bind_filament_target` checks the `CancelProbe` immediately before it reassigns the slot); the
transaction is likewise skipped when cancellation is observed before it is created.
- A narrow window remains where a cancellation and the operation's start race: if the cancel lands
just as the UI callback begins, the selection may still start. Whichever side wins, the coroutine
observes `asyncio.CancelledError`, never a plugin error.
A finished `commit()` is unaffected by a later cancellation.
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 posted UI selection cannot mutate presets after teardown.
- `PluginManager::unload_plugin` and `unload_all_plugins` call
`PluginHostUi::abandon_pending_async(plugin_key)` first, so the earliest possible UI callback
cannot resume the plugin's coroutine into a run that is being cancelled.
- `PluginManager::cancel_and_wait_for_capabilities` calls `on_cancelled()` first (so the plugin can
observe state while the run is intact), then `ScriptAsyncRuntime::cancel_plugin`, then drains.
The drain is **unbounded while a Script invocation worker or a posted host UI callback for the
plugin is outstanding** — its loop-exit condition is `all_released && !invocation_active &&
!ui_pending`. `has_active_run()` distinguishes a run that was cancelled before it started from one
whose worker is still live; `pending_async_count()` counts posted UI callbacks whose weak
registration has not expired. On the GUI thread the drain pumps `wxYield()` so a posted callback
(possibly a modal) can settle instead of being starved by the blocking wait. Only the non-Script
path keeps the historical bounded 2-second grace: when neither an invocation nor a UI callback is
pending, the drain stops waiting for capability references at the grace deadline and logs any
remaining ones.
- `PluginManager::shutdown` detaches the libslic3r hooks (`plugin_hooks::uninstall`), stops new
loads, waits for in-flight loads, unloads every plugin (draining the async runs above), then calls
`ScriptAsyncRuntime::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 later `initialize()` 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-dial `ActionRegistry` action 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.
- **Resolve an awaitable's Future only with `loop.call_soon_threadsafe`.** `AsyncFuture::complete`/
`fail` do this; the UI thread never touches the loop or the Future directly.
- **Create host awaitables only on the worker with a running loop.** `AsyncFuture::create()` binds
`asyncio.get_running_loop()`; `begin_update()`'s binding requires this.
- **No pointer crosses a suspension.** Awaitables carry copied values; the `SlicingPipelineContext`
graph is valid only within one synchronous `execute(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 suspension and lifecycle tracking:
- `src/slic3r/plugin/host/PluginHostUi.hpp` / `.cpp` — `AsyncFuture` (`create`, `complete`, `fail`,
`abandon`, `is_cancelled`), `make_future_resolver`, `make_cancellation_watcher`,
`PluginHostUi::post_to_ui_thread`, `require_ui_application`, `track_pending_async`,
`abandon_pending_async`, `pending_async_count`, `run_on_ui_thread`/`run_on_ui_blocking`.
- `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_async`,
`begin_filament_update_async`, `PresetSelectionCancelled`, `SelectionCancelled`,
`SelectionAbandoned`, `CancelProbe`, `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 awaits a preset update. `execute` runs on the background Script worker
with its own asyncio loop; `begin_update()` suspends the coroutine until any selection / unsaved-edit
dialog is answered, without blocking the UI thread; `set()`/`commit()` run synchronously on the
worker. This is the shape used by `sandboxes/orca_preset_transaction_demo.py`:
```python
import asyncio
import orca
class AutoTune(orca.script.ScriptPluginCapabilityBase):
def get_name(self):
return "Auto tune"
async def execute(self):
# begin_update() is awaitable: it resolves once the user answers any
# selection / unsaved-edit dialog. The UI thread stays responsive.
tx = await 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 awaits `begin_update()` per command.
Continuation sequence for one `await begin_update(...)`:
```text
Script worker (owned, per invocation) UI thread
loop.run_until_complete(task)
await begin_update() ─────────────────► post_to_ui_thread
(suspended) bind_*_target →
Tab::select_preset() … dialog
settles
loop.call_soon_threadsafe(fut.set_result) ◄──── AsyncFuture::complete
coroutine resumes with PresetTransaction
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.
+300
View File
@@ -0,0 +1,300 @@
# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# id = "preset-transaction-demo"
# name = "Preset Transaction Demo"
# description = "Manual test for the staged begin_update()/set()/commit() preset API."
# author = "OrcaSlicer contributors"
# version = "0.1.0"
# type = "script"
# ///
"""Preset Transaction Demo — a manual test harness for the staged preset API.
Install this file in the local ``orca_plugins`` directory, enable the plugin, then run
"Preset Transaction Demo" from the Plugins dialog. The window drives
``orca.host.preset.<category>.begin_update(...)`` and shows the returned
``PresetUpdateResult``.
This is an asynchronous Script capability: ``execute`` is ``async def`` and runs on the
background Script worker's own asyncio event loop. ``begin_update()`` is awaitable — it
suspends the coroutine until the normal preset-selection / unsaved-edit dialog is answered,
without blocking Orca's UI thread. ``set()`` and ``commit()`` remain synchronous and are
only valid on that worker.
The window's own messages arrive on the UI thread, so ``on_message`` cannot await directly.
Instead the handler enqueues each message onto the worker loop (``call_soon_threadsafe``) and
``execute`` consumes them, one coroutine at a time, so every command can ``await
begin_update``. Closing the window enqueues a sentinel that ends ``execute``.
Categories
* process / printer: target is the active preset, or the name typed into the target
field, resolved through the normal preset-selection flow;
* filament: target is the 1-based slot number, with an optional preset name that
selects (and assigns) that preset for the slot.
Buttons exercise the documented outcomes:
* "Commit" stages every key/value row and commits the batch;
* "Duplicate key" stages the same key twice and shows the rejected conflict;
* "Invalid key" stages a key that belongs to no preset and shows the commit error.
Nothing here saves to disk: an applied change leaves the edited preset dirty until the
user saves it through Orca's normal preset workflow. Values use Orca's serialized config
syntax (numbers plain, percentages with ``%``, booleans ``1``/``0``, lists ``;``-separated).
"""
import asyncio
import orca
CATEGORIES = ("process", "printer", "filament")
PAGE = """<!doctype html>
<html><head><meta charset="utf-8">
<style>
body { font: 13px/1.4 system-ui, sans-serif; margin: 16px; color: #222; }
fieldset { border: 1px solid #ccc; border-radius: 6px; margin: 0 0 12px; padding: 10px 12px; }
legend { font-weight: 600; padding: 0 4px; }
label { display: inline-block; min-width: 90px; }
input, select { font: inherit; padding: 3px 5px; margin: 2px 0; }
input[type=text] { width: 260px; }
.row { margin: 3px 0; }
button { font: inherit; padding: 5px 10px; margin: 2px 6px 2px 0; cursor: pointer; }
#out { white-space: pre-wrap; background: #f6f6f6; border: 1px solid #ddd; border-radius: 6px;
padding: 10px; min-height: 70px; }
.err { color: #b00020; }
.ok { color: #0a7a2f; }
</style></head>
<body>
<fieldset><legend>Target</legend>
<div class="row">
<label>Category</label>
<select id="category">
<option value="process">process</option>
<option value="printer">printer</option>
<option value="filament">filament</option>
</select>
</div>
<div class="row" id="slotRow" style="display:none">
<label>Slot (1-based)</label><input id="slot" type="text" value="1" style="width:80px">
</div>
<div class="row">
<label>Preset name</label>
<input id="preset" type="text" placeholder="(blank = current / slot assignment)">
</div>
<div class="row" style="color:#666">Edited now: <span id="current">?</span></div>
</fieldset>
<fieldset><legend>Staged settings (serialized values)</legend>
<div class="row"><input id="k1" type="text" placeholder="key, e.g. layer_height"> =
<input id="v1" type="text" placeholder="value, e.g. 0.25"></div>
<div class="row"><input id="k2" type="text" placeholder="key (optional)"> =
<input id="v2" type="text" placeholder="value"></div>
</fieldset>
<div>
<button onclick="send('commit')">Commit</button>
<button onclick="send('duplicate')">Duplicate key</button>
<button onclick="send('invalid')">Invalid key</button>
<button onclick="send('refresh')">Refresh</button>
</div>
<p id="out">Ready.</p>
<script>
const $ = (id) => document.getElementById(id);
function pairs() {
const out = [];
for (const n of [1, 2]) {
const k = $('k' + n).value.trim();
if (k) out.push({ key: k, value: $('v' + n).value });
}
return out;
}
function send(command) {
const category = $('category').value;
$('slotRow').style.display = category === 'filament' ? '' : 'none';
const message = {
command,
category,
preset_name: $('preset').value.trim(),
slot: parseInt($('slot').value, 10) || 1,
pairs: pairs(),
};
orca.postMessage(message);
}
function show(payload) {
if (payload.error) { $('out').className = 'err'; $('out').textContent = payload.error; return; }
if (payload.message) { $('out').className = 'ok'; $('out').textContent = payload.message; }
if (payload.current !== undefined) $('current').textContent = payload.current;
}
orca.onMessage(show);
send('refresh');
</script>
</body></html>"""
async def _begin(category, preset_name, slot):
"""Create a transaction for one category, following the two begin_update arities.
begin_update() is awaitable: it suspends until any selection / unsaved-edit dialog is
answered, without blocking Orca's UI thread.
"""
editor = getattr(orca.host.preset, category)
# The binding takes a plain string, so an empty field means "" (keep the current / slot target).
if category == "filament":
return await editor.begin_update(slot, preset_name)
return await editor.begin_update(preset_name)
def _result(payload, category):
return {
"category": category,
"target_name": payload.target_name,
"selection_changed": payload.selection_changed,
"slot_number": payload.slot_number,
"slot_assignment_changed": payload.slot_assignment_changed,
"shared_slots": list(payload.shared_slots),
"keys": list(payload.keys),
"dirty": payload.dirty,
}
def _current(bundle, category, slot):
if category == "filament":
presets = list(bundle.current_filament_presets())
if 1 <= slot <= len(presets) and presets[slot - 1] is not None:
return "%s (slot %d)" % (presets[slot - 1].name, slot)
return "<slot %d has no preset>" % slot
if category == "printer":
return bundle.current_printer_preset().name
return bundle.current_process_preset().name
class PresetTransactionDemo(orca.script.ScriptPluginCapabilityBase):
window = None
category = "process"
slot = 1
loop = None
def get_name(self):
return "Preset transaction demo"
async def execute(self):
"""Open the window and drive its messages, awaiting begin_update per command.
Runs on the background Script worker's asyncio loop. The window's on_message fires on
the UI thread, so it only enqueues; execute consumes the queue here, where awaits are
legal, and keeps the loop alive until the window closes.
"""
try:
self.loop = asyncio.get_running_loop()
self._messages = asyncio.Queue()
self.category = "process"
self.slot = 1
self.window = orca.host.ui.create_window(
title="Preset transaction demo", html=PAGE, width=760, height=520,
on_message=self._enqueue, on_close=self._on_close,
)
try:
while True:
message = await self._messages.get()
if message is None: # sentinel: the window closed
break
await self._handle(message)
finally:
self.window = None
return orca.ExecutionResult.success("Preset transaction demo closed.")
except Exception as error:
text = "Preset transaction demo failed: %s" % error
orca.host.ui.message(text, title="Preset transaction demo", buttons="ok", icon="error")
return orca.ExecutionResult.failure(orca.PluginResult.RecoverableError, text)
# --- UI thread -> worker loop hand-off ------------------------------------------------
def _enqueue(self, message):
"""on_message: runs on the UI thread. Hand the message to the worker loop."""
try:
self.loop.call_soon_threadsafe(self._messages.put_nowait, message)
except Exception:
# The loop is already gone (plugin unloading); drop the message.
pass
def _on_close(self):
"""on_close: runs on the UI thread. Wake execute so it returns and the loop closes."""
try:
self.loop.call_soon_threadsafe(self._messages.put_nowait, None)
except Exception:
pass
# --- worker loop ----------------------------------------------------------------------
async def _handle(self, message):
message = message or {}
category = message.get("category", "process")
if category not in CATEGORIES:
self._post(error="Unknown category %r" % (category,))
return
self.category = category
self.slot = message.get("slot", 1) if isinstance(message.get("slot"), int) else 1
preset_name = message.get("preset_name") or ""
try:
command = message.get("command")
if command == "refresh":
self._post()
return
if command == "duplicate":
transaction = await _begin(category, preset_name, self.slot)
transaction.set("layer_height", "0.2")
transaction.set("layer_height", "0.25")
self._post(error="Duplicate key was not rejected (unexpected)")
return
if command == "invalid":
transaction = await _begin(category, preset_name, self.slot)
transaction.set("this_is_not_a_preset_setting", "1")
transaction.commit()
self._post(error="Invalid key was not rejected (unexpected)")
return
if command == "commit":
pairs = message.get("pairs") or []
if not pairs:
self._post(error="Stage at least one key/value row.")
return
transaction = await _begin(category, preset_name, self.slot)
for entry in pairs:
key = entry.get("key")
if not isinstance(key, str) or not key:
raise RuntimeError("A setting key is required in every row")
value = entry.get("value")
transaction.set(key, value if isinstance(value, str) else str(value))
result = transaction.commit()
self._post(
payload=_result(result, category),
message="Committed %d setting(s) to %s \"%s\"."
% (len(result.keys), category, result.target_name),
)
return
self._post(error="Unknown command %r" % (command,))
except Exception as error:
self._post(error="%s: %s" % (type(error).__name__, error))
def _post(self, payload=None, message="", error=""):
payload = dict(payload or {})
if message:
payload["message"] = message
if error:
payload["error"] = error
try:
payload["current"] = _current(orca.host.preset_bundle(), self.category, self.slot)
except Exception as state_error:
payload["current"] = "unavailable: %s" % state_error
if self.window is not None and self.window.is_open():
self.window.post(payload)
@orca.plugin
class PresetTransactionDemoPackage(orca.base):
def register_capabilities(self):
orca.register_capability(PresetTransactionDemo)
+70 -8
View File
@@ -3,9 +3,11 @@
#include "GUI.hpp"
#include "GUI_App.hpp"
#include "I18N.hpp"
#include "ImGuiWrapper.hpp"
#include "MainFrame.hpp"
#include "NativeCommands.hpp"
#include "Notebook.hpp"
#include "NotificationManager.hpp"
#include "OptionsGroup.hpp"
#include "Plater.hpp"
#include "SettingsIndex.hpp"
@@ -99,6 +101,45 @@ std::string find_loaded_source_name(PluginManager& manager, const std::string& p
return plugin_key;
}
// Translates a Script capability outcome into the action layer's result vocabulary. Shared by
// the synchronous run path and the async completion notification.
AppActionRunResult script_outcome_to_action_result(const ScriptCompletion& completion)
{
// Cancelled by PluginManager (unload/shutdown): expected termination, nothing to show.
if (completion.cancelled)
return {};
if (!completion.error.empty())
return {AppActionRunResult::Level::Error, from_u8(completion.error)};
const ExecutionResult& result = completion.result;
const bool skipped = result.status == PluginResult::Skipped;
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
return {skipped ? AppActionRunResult::Level::Info : AppActionRunResult::Level::Success,
result.message.empty() ? fallback : from_u8(result.message)};
}
// Surfaces the eventual outcome of an async script launched from the speed dial. That launch
// already returned (the UI was not blocked), so the finish is reported as a notification. A
// cancellation is silent.
void notify_script_outcome(const ScriptCompletion& completion)
{
if (completion.cancelled)
return;
if (wxTheApp == nullptr || wxGetApp().is_closing())
return;
Plater* plater = wxGetApp().plater();
if (plater == nullptr || wxGetApp().imgui() == nullptr || !wxGetApp().imgui()->display_initialized())
return;
const AppActionRunResult action_result = script_outcome_to_action_result(completion);
const auto level = action_result.level == AppActionRunResult::Level::Error
? NotificationManager::NotificationLevel::ErrorNotificationLevel
: NotificationManager::NotificationLevel::RegularNotificationLevel;
plater->get_notification_manager()->push_notification(NotificationType::CustomNotification, level, into_u8(action_result.message));
}
// A runnable script capability exposed as a speed-dial action. source_key = plugin_key
// (identity), so a plugin display-name change does not re-key the action.
struct PluginScriptAction : AppAction
@@ -124,15 +165,36 @@ struct PluginScriptAction : AppAction
AppActionRunResult run(const std::string& /*param*/) const override
{
std::string error;
const ExecutionResult result = PluginManager::instance().run_script_capability(plugin_key, capability, error);
if (!error.empty())
return {AppActionRunResult::Level::Error, from_u8(error)};
struct Outcome
{
bool async = false;
bool completed = false;
AppActionRunResult result;
};
auto outcome = std::make_shared<Outcome>();
const bool skipped = result.status == PluginResult::Skipped;
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
return {skipped ? AppActionRunResult::Level::Info : AppActionRunResult::Level::Success,
result.message.empty() ? fallback : from_u8(result.message)};
auto on_done = [outcome](const ScriptCompletion& completion) {
outcome->result = script_outcome_to_action_result(completion);
outcome->completed = true;
if (outcome->async)
notify_script_outcome(completion);
};
const PluginManager::ScriptLaunchResult launch = PluginManager::instance().launch_script_capability(plugin_key, capability, on_done);
if (launch.launched_async) {
// The completion is delivered on this (GUI) thread once the (sync or async) invocation
// finishes, so marking `async` here cannot race with on_done. The speed dial has already
// shown a result by then; the final outcome arrives as a notification (a cancellation is
// silent).
outcome->async = true;
if (outcome->completed)
return outcome->result;
return {AppActionRunResult::Level::Info, _L("Script plugin started.")};
}
// The launch failed inline; on_done produced the (error) result.
on_done(launch.outcome);
return outcome->result;
}
};
+44 -26
View File
@@ -1158,10 +1158,7 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
{
PluginManager& manager = PluginManager::instance();
std::string error;
ExecutionResult result;
auto complete_with_error = [this, &manager, &plugin_key](const std::string& plugin_error, const wxString& status_message) {
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() ? _u8L("Script plugin failed.") : plugin_error;
if (!manager.set_plugin_error(plugin_key, normalized_error))
BOOST_LOG_TRIVIAL(warning) << "Failed to record plugin error. plugin_key=" << plugin_key;
@@ -1177,32 +1174,53 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
show_status(message, "error");
};
// Always runs on the GUI thread. For a started invocation (sync `def execute` or `async def`)
// the runtime delivers it later; it is called inline here only when the launch itself failed.
auto finish = [this, alive = m_alive, plugin_key, &manager, complete_with_error](const ScriptCompletion& completion) {
if (!alive->load())
return;
// Cancelled by PluginManager (unload/shutdown): expected termination. Report neither an
// error nor a normal success, and do not touch the plugin's error/state.
if (completion.cancelled)
return;
if (!completion.error.empty()) {
complete_with_error(completion.error, wxString());
return;
}
const ExecutionResult& result = completion.result;
BOOST_LOG_TRIVIAL(info) << "Script plugin execution completed. plugin_key=" << plugin_key
<< " status=" << static_cast<int>(result.status) << " message=" << result.message
<< " data=" << result.data;
const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError;
if (failed) {
complete_with_error(result.message, wxString());
return;
}
manager.clear_plugin_error(plugin_key);
send_plugins();
const bool skipped = result.status == PluginResult::Skipped;
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
const wxString message = result.message.empty() ? fallback : from_u8(result.message);
show_status(message, skipped ? "info" : "success");
};
PluginManager::ScriptLaunchResult launch;
{
wxBusyCursor busy;
result = manager.run_script_capability(plugin_key, capability_name, error);
launch = manager.launch_script_capability(plugin_key, capability_name, finish);
}
if (!error.empty()) {
complete_with_error(error, wxString());
return;
}
BOOST_LOG_TRIVIAL(info) << "Script plugin execution completed. plugin_key=" << plugin_key
<< " status=" << static_cast<int>(result.status) << " message=" << result.message << " data=" << result.data;
const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError || !error.empty();
if (failed) {
complete_with_error(result.message, wxString());
return;
}
manager.clear_plugin_error(plugin_key);
send_plugins();
const bool skipped = result.status == PluginResult::Skipped;
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
const wxString message = result.message.empty() ? fallback : from_u8(result.message);
show_status(message, skipped ? "info" : "success");
// Every started invocation — a synchronous `def execute` included — completes later through
// `finish`; only a launch failure is reported inline. Return without blocking.
if (!launch.launched_async)
finish(launch.outcome);
}
void PluginsDialog::update_plugin(const std::string& plugin_key)
+87 -50
View File
@@ -1671,74 +1671,111 @@ static const char* preset_type_name(Preset::Type type)
}
}
// Commit one option whose new value already sits in m_config through the same path a hand edit
// runs. The key-specific behaviour (ConfigOptionsGroup::on_change_OG, including the printer_agent
// canonicalization, the group's pre-checks and Tab::on_value_change()) runs exactly as for a hand
// edit. A built field supplies the field's typed/clamped value; otherwise the owning
// ConfigOptionsGroup builds the value from the config it edits and dispatches the same
// on_change_OG(). The option's page is reloaded from the config just committed first, so a built
// field reports the committed value rather than a stale one. No tab-wide refresh happens here: the
// caller does it once for the whole batch.
bool Tab::commit_preset_config_value(const std::string& opt_key)
{
if (Page* field_page = nullptr; Field* field = get_field(opt_key, &field_page)) {
field_page->reload_config();
field->field_changed();
return true;
}
for (const PageShp& page : m_pages)
for (const ConfigOptionsGroupShp& group : page->m_optgroups)
if (group->commit_config_value(opt_key))
return true;
return false;
}
// Set one option of the currently edited preset from its serialized string form, then run the
// same update path a hand edit runs. The edited preset is not saved: it only becomes dirty,
// exactly as if the field had been edited by hand.
void Tab::apply_preset_config_value(const std::string& opt_key, const std::string& value)
{
apply_preset_config_values({{opt_key, value}});
}
// Apply a batch of settings as one transaction: parse and validate the whole batch first, then
// commit it through the same per-setting path apply_preset_config_value() uses, restoring the edited
// preset and the state derived from it if a setting fails. See the header for the contract and its
// limits.
void Tab::apply_preset_config_values(const std::vector<std::pair<std::string, std::string>>& values)
{
if (m_config == nullptr)
throw std::runtime_error("The preset configuration is not available");
if (wxGetApp().plater() == nullptr)
throw std::runtime_error("OrcaSlicer main window is not available");
if (values.empty())
return;
const std::vector<std::string>& options = preset_type_options(m_type);
if (std::find(options.begin(), options.end(), opt_key) == options.end())
throw std::invalid_argument(format("Unknown %1% preset setting \"%2%\"", preset_type_name(m_type), opt_key));
// Parse strictly, keeping a backup: the strict deserializer can already have mutated the option
// when it rejects a bad value, so a failed update has to restore the preset it was given.
const ConfigOption* previous_option = m_config->option(opt_key);
std::unique_ptr<ConfigOption> previous(previous_option != nullptr ? previous_option->clone() : nullptr);
try {
m_config->set_deserialize_strict(opt_key, value);
} catch (const ConfigurationError& err) {
if (previous)
m_config->set_key_value(opt_key, previous.release());
else
m_config->erase(opt_key);
throw std::invalid_argument(err.what());
}
// Run the same commit path the GUI runs for this setting, so its key-specific behaviour
// (ConfigOptionsGroup::on_change_OG, including the printer_agent canonicalization, the group's
// pre-checks and Tab::on_value_change()) runs exactly as for a hand edit. A built field supplies
// the field's typed/clamped value; otherwise the owning ConfigOptionsGroup builds the value from
// the config it edits and dispatches the same on_change_OG(). The field is refreshed from the
// config just committed first, so it reports the committed value rather than a stale one.
bool committed = false;
if (Page* field_page = nullptr; Field* field = get_field(opt_key, &field_page)) {
field_page->reload_config();
field->field_changed();
committed = true;
} else {
for (const PageShp& page : m_pages) {
for (const ConfigOptionsGroupShp& group : page->m_optgroups) {
if (group->commit_config_value(opt_key)) {
committed = true;
break;
}
}
if (committed)
break;
// Preparation: reject every batch that cannot be applied before touching the edited preset, so a
// bad key or value fails without a partial write. The key must belong to this preset type, it must
// not repeat, and each value is strict-parsed into a scratch copy (the strict deserializer can
// already have mutated an option when it rejects a bad value, so the check has to run somewhere
// the edited preset is not affected). Callback failures cannot be prepared this way: a setting's
// on_change callback is a GUI method, not a pure function of the config, so whether it throws is
// only known by running it. That residual case is handled by the rollback below.
std::set<std::string> seen;
DynamicPrintConfig scratch(*m_config);
for (const auto& kv : values) {
const std::string& opt_key = kv.first;
if (std::find(options.begin(), options.end(), opt_key) == options.end())
throw std::invalid_argument(format("Unknown %1% preset setting \"%2%\"", preset_type_name(m_type), opt_key));
if (!seen.insert(opt_key).second)
throw std::invalid_argument(format("%1% preset setting \"%2%\" is set more than once in the same update",
preset_type_name(m_type), opt_key));
try {
scratch.set_deserialize_strict(opt_key, kv.second);
} catch (const ConfigurationError& err) {
throw std::invalid_argument(err.what());
}
}
if (committed) {
// The commit path already ran update_dirty() (dirty flag, preset combo and, on the printer
// tab, the printer-agent switch); refresh the visible page and push the config to the plater.
// Rollback boundary: snapshot the whole edited config, not just the keys the batch names, because a
// key-specific callback can adjust related settings of the edited preset (mutually exclusive
// options, compatibility, and so on). A failure restores this config and then recomputes the state
// derived from it.
DynamicPrintConfig before(*m_config);
try {
for (const auto& kv : values) {
m_config->set_deserialize_strict(kv.first, kv.second);
if (!commit_preset_config_value(kv.first))
// The GUI edits this setting through a custom widget rather than a group (e.g.
// compatible_printers), so take the same shared no-field commit path the widget uses.
// The value was written above, so saved mode skips the write and keeps the path's
// compatibility recompute, dirty flag, preset-combo and plater updates.
load_key_value(kv.first, boost::any(), /*saved_value=*/true);
}
} catch (...) {
*m_config = before;
// A callback can rewrite state derived from the config (compatibility marks, the preset
// selector, dependent tabs, the Plater and, on the printer tab, the printer agent) before it
// throws. Recompute that derived state from the restored config so none of it is left
// reflecting the failed batch. Effects that are not a function of the config (a dialog the
// user already accepted, an undo snapshot, a command already sent to a device, the target
// selection) cannot be reversed here; see the header.
m_preset_bundle->update_compatible(PresetSelectCompatibleType::Never);
reload_config();
update_dirty();
update();
} else {
// The GUI edits this setting through a custom widget rather than a group (e.g.
// compatible_printers), so take the same shared no-field commit path the widget uses. Saved
// mode skips the value write (the strict parse above already did it) and keeps the path's
// compatibility recompute, dirty flag, preset-combo and plater updates.
load_key_value(opt_key, boost::any(), /*saved_value=*/true);
// The widget itself normally re-reads its value after load_key_value(); with no widget to do
// that, sync the visible page so a custom widget (the compatible-printers row) reflects the
// new value.
reload_config();
throw;
}
// Apply the batch's final state once. The commit paths above already maintained the dirty flag,
// preset combo and, on the printer tab, the printer-agent switch; this reloads the visible page
// and pushes the config to the Plater.
reload_config();
update_dirty();
update();
}
// Reload current $self->{config} (aka $self->{presets}->edited_preset->config) into the UI fields.
+29
View File
@@ -435,6 +435,29 @@ public:
// so a not-yet-built field still gets it. A key the GUI edits through a custom widget instead
// takes the shared no-field commit path, Tab::load_key_value() in saved mode.
void apply_preset_config_value(const std::string& opt_key, const std::string& value);
// Host/plugin API: apply a batch of settings to this tab's currently edited preset as one
// transaction. Each pair is a setting of this tab's preset type and its serialized string value.
// The whole batch is parsed and validated before the edited preset is touched; each setting then
// runs the same committed-change path apply_preset_config_value() uses, so setting-specific
// validation, correction and on_change callbacks stay authoritative. Duplicate keys are rejected,
// because the transaction must not resolve an incidental write order, and the batch order is
// preserved. If applying the batch fails, the edited preset's config is restored to its pre-batch
// state and the state derived from it (compatibility marks, the preset selector, dependent tabs,
// the Plater and the printer agent) is recomputed, so the edited preset never keeps a subset of
// the staged settings.
//
// Only the edited preset is restored. A setting's on_change callback is a GUI method, not a pure
// function of the config, so effects outside the edited preset cannot be prepared or undone: the
// transaction does not reverse preset/slot selection made while resolving the target, a dialog
// decision the user already accepted, an undo snapshot, a dependent preset's rewritten config, or
// anything already sent to a device. Exact all-or-nothing across those is not achievable while the
// authoritative GUI callbacks run; this method guarantees the edited preset's own settings, not
// those external effects.
//
// Throws std::invalid_argument for an unknown key, a value the option cannot parse, or a duplicate
// key; std::runtime_error when the preset or main window is unavailable.
void apply_preset_config_values(const std::vector<std::pair<std::string, std::string>>& values);
virtual void reload_config();
void update_mode();
void update_visibility();
@@ -518,6 +541,12 @@ protected:
wxSizer* compatible_widget_create(wxWindow* parent, PresetDependencies &deps);
void compatible_widget_reload(PresetDependencies &deps);
void load_key_value(const std::string& opt_key, const boost::any& value, bool saved_value = false);
// Commit one option whose new value already sits in m_config through the same path a field edit
// runs (throwing field, otherwise the owning group's on_change_OG()). Reloads the option's page
// so a built field reports the committed value, but performs no tab-wide refresh. Returns true
// when a built field or a group owns the option, false when the GUI edits it through a custom
// widget with no group entry.
bool commit_preset_config_value(const std::string& opt_key);
//BBS: GUI refactor
// return true if cancelled
+6
View File
@@ -102,6 +102,12 @@ void install_slicing_pipeline_hook()
ctx.step = step;
ctx.print = &print;
ctx.object = object;
// Slicing hooks are synchronous by design and never await: the context
// borrows Print/PrintObject that are only valid during this call, and the
// UI thread may itself be blocked waiting on this slicing worker. An async
// override is rejected here rather than scheduled -- cap->execute() casts
// its Python result to ExecutionResult and a coroutine object cannot be
// cast, so it surfaces as the SlicingError below.
r = cap->execute(ctx);
} catch (const CanceledException&) {
throw; // cancellation must reach process(), never become a slicing error
+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) {
+100
View File
@@ -98,6 +98,19 @@ struct Plugin
void release_module();
};
// Outcome of one Script invocation, delivered to a script completion callback.
//
// `cancelled` marks a run terminated by PluginManager's own cancellation (plugin unload / shutdown):
// expected termination, not a failure. Callers must not surface it as an error nor as a normal
// success. `error` carries only genuine user/plugin exceptions; `result` is meaningful when neither
// `cancelled` nor a non-empty `error` is set.
struct ScriptCompletion
{
ExecutionResult result;
std::string error;
bool cancelled = false;
};
class PluginManager
{
public:
@@ -214,6 +227,25 @@ public:
ExecutionResult run_script_capability(const std::string& plugin_key, const std::string& capability_name, std::string& error);
// Outcome of launching a Script capability.
struct ScriptLaunchResult
{
bool launched_async = false; // true => a run started off-thread; wait for on_done
ScriptCompletion outcome; // meaningful when !launched_async (start failure only)
};
// Invoked on the GUI thread when a Script invocation finishes (or inline, on the calling thread,
// when the run could not be started at all).
using ScriptCompletionFn = std::function<void(const ScriptCompletion&)>;
// Starts a Script capability off the caller's thread. Both a synchronous `def execute` and an
// `async def execute` run on a ScriptAsyncRuntime worker, so this never blocks the UI thread and
// no invocation runs before returning. `launched_async` is true exactly when a run started; the
// caller then waits for on_done instead of a returned result. When it is false, `outcome.error`
// explains why (the caller's guarded UI normally prevents this).
ScriptLaunchResult launch_script_capability(const std::string& plugin_key,
const std::string& capability_name,
ScriptCompletionFn on_done = {});
void dispatch_lifecycle_event(LifecycleEvent evt, const LifecycleEventContext& ctx);
private:
@@ -309,6 +341,74 @@ private:
std::atomic<bool> m_shutting_down{false};
};
// ── Async Script execution ──────────────────────────────────────────────────────────────────
//
// A Script capability whose Python `execute` is an `async def` (or otherwise returns a
// coroutine) must not run on the UI thread, and cannot use the synchronous trampoline path,
// which casts the return value to ExecutionResult and would reject a coroutine.
//
// ScriptAsyncRuntime drives such executions off the calling (UI) thread. Each invocation gets its
// own worker thread and, for an `async def`, its own asyncio event loop. Every invocation body —
// detection, a synchronous `def execute`, or an `async def` coroutine — runs on that worker, so no
// Script invocation ever blocks the UI thread and no invocation is queued behind another plugin's.
// For the whole invocation the worker holds:
// * a shared_ptr to the capability, so the capability and its module stay alive across awaits;
// * a PythonRuntimeLease plus the GIL, so the interpreter cannot finalize underneath it;
// * the capability's audit scope (ScopedPluginAuditContext),
// which keeps plugin unload and interpreter shutdown from tearing the module out from under a
// suspended run. Unload cancels the run and drains it (it never force-unloads after a timeout).
//
// Interface for host awaitables (see plugin/host/): a host awaitable is constructed inside the
// Script coroutine, i.e. on the worker thread with that invocation's loop running, so it can
// read that loop with asyncio.get_running_loop() or ScriptAsyncRuntime::current_loop(). To
// resume from another thread (typically the UI thread) it must resolve its future with
// loop.call_soon_threadsafe(...) and must never touch plugin Python objects, or the loop,
// directly from that other thread. The loop only runs while the coroutine is suspended inside
// run_until_complete; once Script execute returns the loop is closed and the module may unload.
class ScriptAsyncRuntime
{
public:
static ScriptAsyncRuntime& instance();
~ScriptAsyncRuntime();
ScriptAsyncRuntime(const ScriptAsyncRuntime&) = delete;
ScriptAsyncRuntime& operator=(const ScriptAsyncRuntime&) = delete;
// Starts one Script invocation on its own worker thread. The invocation body (Python `execute`,
// whether `def` or `async def`) and its detection both run on that worker, never on the calling
// thread. `on_done` is invoked on the GUI thread (or inline when there is no wx app) when the
// invocation finishes; a `cancelled` completion is expected termination from cancel_plugin().
// Returns false when the runtime is shutting down.
bool start(const std::string& plugin_key,
const std::string& capability_name,
std::shared_ptr<PluginCapabilityInterface> capability,
std::function<void(const ScriptCompletion&)> on_done);
// Requests cancellation of every active run owned by plugin_key: marks the run and cancels
// its asyncio task via loop.call_soon_threadsafe (a synchronous invocation has no loop).
void cancel_plugin(const std::string& plugin_key);
// True while a run for `capability` is active.
bool has_active_run(const PluginCapabilityInterface* capability) const;
// Stops accepting runs and waits for every in-flight invocation worker. Nothing is torn down
// by force.
void shutdown();
// Re-arms the runtime after a shutdown() so a later initialize() in the same process can
// start runs again.
void reset();
// The asyncio loop of the Script invocation running on the calling thread, or an empty
// object when the calling thread is not running one. Must be called with the GIL held.
static pybind11::object current_loop();
private:
ScriptAsyncRuntime();
struct Impl;
std::unique_ptr<Impl> m_impl;
};
// Resolve each configured capability reference to a loaded capability of type T and run `execute`.
template<typename T>
void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities,
+75
View File
@@ -6,6 +6,7 @@
#include <boost/log/trivial.hpp>
#include <memory>
#include <optional>
#include <stdexcept>
#include <string>
@@ -61,6 +62,80 @@
} while (0)
namespace Slic3r {
// True when `object` is a coroutine object (the value produced by calling an `async def`).
// Must be called with the GIL held.
inline bool py_is_coroutine(const pybind11::handle& object)
{
if (!object)
return false;
pybind11::object inspect = pybind11::module_::import("inspect");
return inspect.attr("iscoroutine")(object).cast<bool>();
}
// Result of invoking a Script capability's Python `execute` exactly once.
//
// A synchronous override (`def execute`) runs to completion here and yields `result`.
// An asynchronous override (`async def execute`, or any override that returns a coroutine)
// yields the coroutine object in `awaitable` WITHOUT running it: the cast to ExecutionResult
// that the trampoline would otherwise perform is impossible for a coroutine, so the caller
// chooses how to drive it. Whoever drives it must keep the capability referenced and re-open
// this call's GIL + runtime lease + audit scope for the coroutine's whole lifetime (see
// ScriptAsyncRuntime in PluginManager.hpp).
struct ScriptExecuteInvocation
{
bool is_async = false;
pybind11::object awaitable; // valid iff is_async; a coroutine object created under the GIL
ExecutionResult result; // valid iff !is_async
std::string error; // non-empty when the call or coroutine creation raised
};
// Invokes ScriptPluginCapability::execute() under the GIL, replicating the audited trampoline
// call (capability ref counter, audit scope, traceback-preserving rethrow) but returning a
// coroutine object instead of forcing a cast when the plugin's `execute` is `async def`.
//
// `capability` must be the concrete, Python-bound capability instance (obtainable from
// get_plugin_capability()); it is resolved back to its Python object through pybind's instance
// map. Never throws: a Python exception is logged (full traceback) and reported in `error`.
inline ScriptExecuteInvocation invoke_script_execute(const std::shared_ptr<PluginCapabilityInterface>& capability)
{
ScriptExecuteInvocation invocation;
if (!capability)
return invocation;
try {
pybind11::gil_scoped_acquire gil;
PluginCapabilityInterface::RefCounter ref_counter(*capability);
std::optional<ScopedPluginAuditContext> audit_scope;
if (const std::string& audit_key = capability->audit_plugin_key(); !audit_key.empty())
audit_scope.emplace(audit_key, capability->name());
pybind11::object py_capability = pybind11::cast(capability);
pybind11::object returned = py_capability.attr("execute")();
if (py_is_coroutine(returned)) {
invocation.is_async = true;
invocation.awaitable = std::move(returned);
return invocation;
}
invocation.result = returned.cast<ExecutionResult>();
} catch (pybind11::error_already_set& err) {
const bool audit_denial = PluginAuditManager::instance().audit_denial_pending();
if (audit_denial)
PluginAuditManager::instance().clear_current_plugin();
invocation.error = err.what();
log_python_exception_keep(err);
if (audit_denial)
PluginAuditManager::instance().clear_audit_denial();
} catch (const std::exception& ex) {
invocation.error = ex.what();
}
return invocation;
}
template<class Base> class PyPluginCommonTrampoline : public Base
{
public:
+794 -77
View File
@@ -1,6 +1,7 @@
#include "PluginHostBindings.hpp"
#include "PluginHostUi.hpp"
#include "slic3r/plugin/PluginBindingUtils.hpp"
#include "slic3r/plugin/PythonInterpreter.hpp" // PythonGILState
#include <pybind11/pytypes.h>
#include <pybind11/cast.h>
@@ -22,8 +23,13 @@
#include <pybind11/stl.h>
#include <wx/string.h>
#include <functional>
#include <set>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace py = pybind11;
@@ -66,91 +72,690 @@ const char* preset_tab_name(Preset::Type type)
}
}
void update_edited_preset(Preset::Type type, const std::string& key, const std::string& value)
PresetCollection& collection_for(PresetBundle& bundle, Preset::Type type)
{
PluginHostUi::run_on_ui_thread([&] {
GUI::GUI_App& app = GUI::wxGetApp();
if (app.is_closing())
throw std::runtime_error("OrcaSlicer is closing");
if (app.preset_bundle == nullptr)
throw std::runtime_error("Preset bundle is not available");
if (app.plater() == nullptr)
throw std::runtime_error("OrcaSlicer main window is not available");
GUI::Tab* tab = app.get_tab(type);
if (tab == nullptr)
throw std::runtime_error(std::string("The ") + preset_tab_name(type) + " preset tab is not available");
// The tab for this preset type exists, but the selected printer's technology may not use it
// (the print tab no-ops under SLA); refuse instead of writing a value the UI cannot apply.
if (!tab->supports_printer_technology(app.preset_bundle->printers.get_selected_preset().printer_technology()))
throw std::runtime_error(std::string("The ") + preset_tab_name(type)
+ " preset is not available for the selected printer technology");
tab->apply_preset_config_value(key, value);
});
switch (type) {
case Preset::TYPE_PRINT: return bundle.prints;
case Preset::TYPE_FILAMENT: return bundle.filaments;
case Preset::TYPE_PRINTER: return printer_presets(bundle);
default: throw std::invalid_argument("Unsupported preset category");
}
}
// Slot-addressed filament update. `slot_number` is 1-based, matching the GUI's filament slots and
// the values PresetBundle::filament_presets holds. The slot's assigned preset is resolved to its
// internal name; when that is not the preset the filament tab edits, the tab is switched through
// Tab::select_preset() - the normal UI preselection flow, so a dirty edited preset still prompts
// the user and a declined prompt cancels the call with a clear error and no change.
void update_filament_slot(int slot_number, const std::string& key, const std::string& value)
// Marker for a user-declined selection: Tab::select_preset() returned false. It derives from
// std::runtime_error so the synchronous update() path keeps raising RuntimeError (pybind11 maps the
// base), while the awaitable begin_update() path raises the distinct, documented
// orca.host.preset.PresetSelectionCancelled.
struct SelectionCancelled : std::runtime_error
{
using std::runtime_error::runtime_error;
};
// Marker for a selection the awaiting coroutine's cancellation overtook: the Future was cancelled
// (task cancellation, or plugin unload) while the selection dialog was open. The user's already
// accepted decision is not reversed; only the remaining slot assignment and the transaction
// creation are skipped. Distinct from SelectionCancelled, which is the user declining.
struct SelectionAbandoned : std::runtime_error
{
using std::runtime_error::runtime_error;
};
// True once the awaiting coroutine's Future has been cancelled. AsyncFuture sets this from its
// loop-thread done-callback, so this reads only an atomic and never touches Python or the loop from
// the UI thread. Empty for the synchronous path, which cannot be cancelled.
using CancelProbe = std::function<bool()>;
// One PresetSelectionCancelled carrying `message`. The type is looked up live from the bound module
// rather than cached in a static PyObject*: a cached reference would dangle across an interpreter
// reset (plugin-system shutdown then re-init in one process). GIL held by the caller. Falls back to
// RuntimeError when the binding is not present.
py::object selection_cancelled_error(const std::string& message)
{
try {
return py::module_::import("orca").attr("host").attr("preset").attr("PresetSelectionCancelled")(message);
} catch (py::error_already_set&) {
PyErr_Clear();
return py::module_::import("builtins").attr("RuntimeError")(message);
}
}
// --------------------------------------------------------------------------
// The staged preset update lifecycle described in docs/HLSD/preset-plugin-api.md.
//
// begin_update() resolves and binds one target (selecting it through the normal UI flow when
// the caller asked for a preset that is not already active), returns a PresetTransaction that
// stages changes with set(), and applies them only on commit(). The transaction holds names,
// never preset pointers, so it stays valid across reloads; a target or filament slot that changed
// while the operation was pending is rejected instead of silently editing the new mapping.
//
// begin_update() is awaitable: called from the background Script worker (a running asyncio loop),
// it posts the target selection to the UI thread and returns a real Future immediately, resolving
// it thread-safely (through PluginHostUi::post_to_ui_thread / loop.call_soon_threadsafe) with a
// name-based PresetTransaction once the selection and any unsaved-edit dialog settle, or failing
// it with the resolution error. A user-declined selection fails it with the distinct
// orca.host.preset.PresetSelectionCancelled rather than a generic RuntimeError; that is separate
// from asyncio task cancellation, which cancels the Future and is dropped by the resolver, so the
// coroutine sees asyncio.CancelledError instead. No GUI or preset pointer crosses the suspension.
// The synchronous update() shorthand keeps the original blocking path (a cancel still raises
// RuntimeError there) and shares the same target resolution; it is safe only from that worker,
// never from the UI thread or a slicing pipeline hook.
//
// Known limits of this implementation, kept explicit rather than hidden:
// * A requested preset_name is resolved by the exact name its dropdown entry shows: the item's
// display label or its stored item alias (the internal preset name). A name two entries carry
// is rejected as ambiguous instead of being resolved by position. Process and printer resolve
// against the tab dropdown; a filament preset_name resolves against the requested slot's own
// sidebar selector, not the tab's, so its compatibility filter is the one that applies. A
// target the selector does not offer fails before the flow's unsaved-edit prompt. The flow
// itself is unchanged and the target is still confirmed as the edited preset afterwards.
// * A filament preset_name that names a different preset for a slot selects it through
// Tab::select_preset() (the normal flow, honouring unsaved-edit prompts) and then updates the
// slot mapping with PresetBundle::set_filament_preset() plus every dependent side effect the
// sidebar selection path performs: the project-dirty/selection refresh, the dynamic filament
// list, the slot's own combo (re-run so it lists and selects the assigned target), the
// conditional support-filament flushing recalc and the slot's AMS sync badge (read from the
// slot combo's own flag). The private Plater::priv::on_select_preset() path cannot be reused
// from here, so this mirrors its filament branch rather than calling it.
// * Cancellation boundary: AsyncFuture observes a cancellation two ways. Its loop-thread
// done-callback sets an atomic; is_cancelled() also reads the Future's own cancelled() under the
// GIL, so a cancel that Future.cancel() already applied is seen even before the loop schedules
// the done-callback. That closes the pre-start window and the after-modal window (checked before
// any slot mapping). A selection still queued therefore never starts, and one already inside the
// modal stops after the modal returns: the remaining slot assignment and the transaction are
// skipped. A late cancel that lands after the user already accepted a selection, or after the
// transaction was created, is NOT rolled back: accepted UI decisions stand. The narrow window
// where a cancel and the operation's start race cannot be fully eliminated, but whichever side
// wins the coroutine still observes asyncio.CancelledError, not a plugin error.
// --------------------------------------------------------------------------
// Outcome of one committed transaction. Identifies the target, whether selecting it switched
// anything, and for filament (whose preset may be shared) which slots share the edited preset.
struct PresetUpdateResult
{
Preset::Type type = Preset::TYPE_INVALID;
std::string target_name;
bool selection_changed = false;
int slot_number = -1; // 1-based for filament, -1 otherwise
bool slot_assignment_changed = false;
std::vector<int> shared_slots;
std::vector<std::string> keys;
bool dirty = false;
};
struct PresetTransaction
{
Preset::Type type = Preset::TYPE_INVALID;
std::string target_name;
bool filament = false;
int slot_number = 0; // 1-based, filament only
std::string slot_assignment_snapshot; // slot's assigned preset as of begin_update()
bool selection_changed = false;
bool slot_assignment_changed = false;
std::vector<std::pair<std::string, std::string>> staged;
std::set<std::string> staged_keys;
bool committed = false;
void set(const std::string& key, const std::string& value);
PresetUpdateResult commit();
};
GUI::GUI_App& require_gui_app()
{
GUI::GUI_App& app = GUI::wxGetApp();
if (app.is_closing())
throw std::runtime_error("OrcaSlicer is closing");
if (app.preset_bundle == nullptr)
throw std::runtime_error("Preset bundle is not available");
if (app.plater() == nullptr)
throw std::runtime_error("OrcaSlicer main window is not available");
return app;
}
GUI::Tab* require_tab(GUI::GUI_App& app, Preset::Type type)
{
GUI::Tab* tab = app.get_tab(type);
if (tab == nullptr)
throw std::runtime_error(std::string("The ") + preset_tab_name(type) + " preset tab is not available");
// The tab for this preset type exists, but the selected printer's technology may not use it
// (the print tab no-ops under SLA); refuse instead of writing a value the UI cannot apply.
if (!tab->supports_printer_technology(app.preset_bundle->printers.get_selected_preset().printer_technology()))
throw std::runtime_error(std::string("The ") + preset_tab_name(type)
+ " preset is not available for the selected printer technology");
return tab;
}
// True for a combo row that is a group header, a marker or a disabled row: it carries a marker in
// its client data and is not a selectable preset.
bool combo_item_is_marker(GUI::PresetComboBox& combo, int item)
{
const auto marker = reinterpret_cast<std::size_t>(combo.GetClientData(item));
return marker >= GUI::PresetComboBox::LABEL_ITEM_DISABLED && marker < GUI::PresetComboBox::LABEL_ITEM_MAX;
}
// How a caller-supplied name resolved against a combo's actual items.
struct ComboPresetMatch
{
int item = -1; // first matching item, or -1
bool ambiguous = false; // two distinct items carry this name
std::string internal_name; // internal preset name of the match (empty when none)
};
// Resolve `requested` by the name each combo item shows: its display label, or its stored item
// alias (the internal preset name, or the printer model for a system printer preset). Names are
// compared with the modified-dirty suffix removed, i.e. exactly what the dropdown shows. Reading
// the combo's own items means this respects the combo's real selectable model (visibility, its
// show-incompatible state and, for a slot combo, that slot's compatibility). A name that two
// distinct items carry is reported ambiguous instead of being resolved by position.
ComboPresetMatch resolve_combo_preset(GUI::PresetComboBox& combo, const std::string& requested)
{
ComboPresetMatch match;
const std::string want = Preset::remove_suffix_modified(requested);
const int item_count = combo.GetCount();
for (int item = 0; item < item_count; ++item) {
if (combo_item_is_marker(combo, item))
continue;
const wxString alias = combo.GetItemAlias(item);
const std::string alias_name = alias.empty() ? std::string()
: Preset::remove_suffix_modified(alias.ToUTF8().data());
const std::string label_name = Preset::remove_suffix_modified(combo.GetString(item).ToUTF8().data());
if ((alias_name.empty() || alias_name != want) && label_name != want)
continue;
if (match.item >= 0 && match.item != item) {
match.ambiguous = true;
return match;
}
match.item = item;
match.internal_name = alias_name.empty() ? label_name : alias_name;
}
return match;
}
// Index of the item whose internal name is `preset_name`, or -1. Used for per-item flags (the AMS
// sync badge); the internal name is unique, so no ambiguity handling is needed here.
int combo_item_for_preset(GUI::PresetComboBox& combo, const std::string& preset_name)
{
const std::string want = Preset::remove_suffix_modified(preset_name);
const int item_count = combo.GetCount();
for (int item = 0; item < item_count; ++item) {
if (combo_item_is_marker(combo, item))
continue;
const wxString alias = combo.GetItemAlias(item);
const std::string alias_name = alias.empty() ? std::string()
: Preset::remove_suffix_modified(alias.ToUTF8().data());
if (!alias_name.empty() && alias_name == want)
return item;
}
return -1;
}
// Resolve `requested` to the internal preset name its dropdown entry names, or throw. `category`
// and `selector` are used only for the error text. A name the selector does not offer, or that two
// entries share, raises std::invalid_argument.
std::string resolve_preset_in_combo(GUI::PresetComboBox& combo, const std::string& requested,
const std::string& category, const std::string& selector)
{
const ComboPresetMatch match = resolve_combo_preset(combo, requested);
if (match.ambiguous)
throw std::invalid_argument("The " + category + " name \"" + requested + "\" is ambiguous in the " + selector
+ "; more than one entry shows it, so name the preset by its own entry");
if (match.item < 0)
throw std::invalid_argument("The " + category + " preset \"" + requested + "\" is not offered by the " + selector);
return match.internal_name;
}
// The preset a filament slot currently resolves to, by its internal name. A slot whose stored
// name is empty or does not resolve yields an error identifying the slot.
std::string resolved_slot_preset(PresetBundle& bundle, int slot_number)
{
if (slot_number < 1 || static_cast<size_t>(slot_number) > bundle.filament_presets.size())
throw std::out_of_range("Filament slot " + std::to_string(slot_number)
+ " is out of range; slot numbers are 1-based and this printer has "
+ std::to_string(bundle.filament_presets.size()) + " filament slot(s)");
const std::string& assigned_name = bundle.filament_presets[static_cast<size_t>(slot_number) - 1];
if (assigned_name.empty())
throw std::runtime_error("Filament slot " + std::to_string(slot_number) + " has no assigned filament preset");
Preset* preset = bundle.filaments.find_preset(assigned_name);
if (preset == nullptr)
throw std::runtime_error("Filament slot " + std::to_string(slot_number) + " is assigned \"" + assigned_name
+ "\", which does not resolve to a filament preset");
return preset->name;
}
void PresetTransaction::set(const std::string& key, const std::string& value)
{
if (committed)
throw std::runtime_error("This preset transaction has already been committed; start a new begin_update()");
if (key.empty())
throw std::invalid_argument("A setting key is required");
// Conflicting duplicate writes to one setting are rejected rather than resolved by order.
if (!staged_keys.insert(key).second)
throw std::invalid_argument("Setting \"" + key + "\" is already staged in this transaction; "
"conflicting duplicate writes to one setting are rejected");
staged.emplace_back(key, value);
}
PresetUpdateResult PresetTransaction::commit()
{
if (committed)
throw std::runtime_error("This preset transaction has already been committed; start a new begin_update()");
if (type == Preset::TYPE_INVALID)
throw std::runtime_error("This preset transaction has no target");
PresetUpdateResult result;
PluginHostUi::run_on_ui_thread([&] {
GUI::GUI_App& app = GUI::wxGetApp();
if (app.is_closing())
throw std::runtime_error("OrcaSlicer is closing");
if (app.preset_bundle == nullptr)
throw std::runtime_error("Preset bundle is not available");
if (app.plater() == nullptr)
throw std::runtime_error("OrcaSlicer main window is not available");
GUI::GUI_App& app = require_gui_app();
GUI::Tab* tab = require_tab(app, type);
PresetBundle& bundle = *app.preset_bundle;
PresetCollection& collection = collection_for(bundle, type);
PresetBundle& bundle = *app.preset_bundle;
if (slot_number < 1 || static_cast<size_t>(slot_number) > bundle.filament_presets.size())
throw std::out_of_range("Filament slot " + std::to_string(slot_number) + " is out of range; slot numbers are 1-based and this printer has "
+ std::to_string(bundle.filament_presets.size()) + " filament slot(s)");
// The target must still be the edited preset: the user may have switched away while the
// transaction was staged, and editing a different preset would break "explicit target".
const std::string edited = collection.get_edited_preset().name;
if (edited != target_name)
throw std::runtime_error("The edited " + std::string(preset_tab_name(type)) + " preset is \"" + edited
+ "\", not this transaction's target \"" + target_name
+ "\"; nothing was applied");
const std::string& assigned_name = bundle.filament_presets[static_cast<size_t>(slot_number) - 1];
if (assigned_name.empty())
throw std::runtime_error("Filament slot " + std::to_string(slot_number) + " has no assigned filament preset");
// Resolve the assigned name the way the app does, so a renamed/aliased entry maps to the
// internal preset it now identifies. Copy the name out before selecting anything: the
// collection can be reallocated while switching.
Preset* slot_preset = bundle.filaments.find_preset(assigned_name);
if (slot_preset == nullptr)
throw std::runtime_error("Filament slot " + std::to_string(slot_number) + " is assigned \"" + assigned_name
+ "\", which does not resolve to a filament preset");
const std::string target_name = slot_preset->name;
GUI::Tab* tab = app.get_tab(Preset::TYPE_FILAMENT);
if (tab == nullptr)
throw std::runtime_error("The filament preset tab is not available");
if (!tab->supports_printer_technology(bundle.printers.get_selected_preset().printer_technology()))
throw std::runtime_error("The filament preset is not available for the selected printer technology");
// Switch to the slot's preset through the normal preset selection, never a forced one, so the
// user's unsaved-changes prompt is honoured. A declined prompt returns false here.
if (bundle.filaments.get_edited_preset().name != target_name) {
if (!tab->select_preset(target_name))
throw std::runtime_error("Switching the edited filament preset to \"" + target_name + "\" for filament slot "
+ std::to_string(slot_number) + " was canceled; the setting was not changed");
if (filament) {
// Reject a stale slot mapping instead of editing whatever the slot now points at.
const std::string now_assigned = resolved_slot_preset(bundle, slot_number);
if (now_assigned != slot_assignment_snapshot)
throw std::runtime_error("Filament slot " + std::to_string(slot_number) + " now holds \"" + now_assigned
+ "\", not \"" + slot_assignment_snapshot
+ "\" as when this transaction was created; the slot mapping changed, so nothing was applied");
}
// select_preset() can still leave a different preset edited (e.g. the target disappeared), so
// re-check before writing anything.
if (bundle.filaments.get_edited_preset().name != target_name)
throw std::runtime_error("The edited filament preset is \"" + bundle.filaments.get_edited_preset().name
+ "\", not \"" + target_name + "\"; the setting was not changed");
// Apply the whole batch through the tab's batch path: it validates every key and value before
// touching the preset, rejects duplicate keys, runs the same per-setting commit path a hand
// edit uses (validation, on_change, dirty state) and refreshes once for the batch. A failure
// restores the edited preset, so no staged setting survives a failed commit.
tab->apply_preset_config_values(staged);
// Match PlaterPresetComboBox::switch_to_tab(): record which 0-based filament slot this tab
// combobox now edits, so the GUI shows the slot the value was written for.
if (GUI::TabPresetComboBox* combo = tab->get_combo_box())
combo->set_filament_idx(slot_number - 1);
result.type = type;
result.target_name = target_name;
result.selection_changed = selection_changed;
result.slot_number = filament ? slot_number : -1;
result.slot_assignment_changed = slot_assignment_changed;
result.keys.reserve(staged.size());
for (const auto& entry : staged)
result.keys.push_back(entry.first);
result.dirty = collection.get_edited_preset().is_dirty;
tab->apply_preset_config_value(key, value);
if (filament) {
// Every slot whose assigned preset resolves to the edited one is affected: the preset
// is shared, not a per-slot override.
for (std::size_t index = 0; index < bundle.filament_presets.size(); ++index) {
Preset* preset = bundle.filaments.find_preset(bundle.filament_presets[index]);
if (preset != nullptr && preset->name == target_name)
result.shared_slots.push_back(static_cast<int>(index) + 1);
}
}
});
committed = true;
return result;
}
// Process/printer target resolution: the active preset when no name is given, otherwise the named
// preset selected through the normal tab-selection flow (which enforces the dropdown/compatibility
// rules and the unsaved-edit prompt). Binds only after confirming the requested preset is edited.
// Runs on the UI thread with the GIL released; both the blocking and the awaitable entry points
// share it (the awaitable one invokes it directly from its UI callback, never through
// run_on_ui_thread()).
void bind_preset_target(Preset::Type type, const std::string& preset_name, PresetTransaction& transaction,
const CancelProbe& cancelled = {})
{
GUI::GUI_App& app = require_gui_app();
GUI::Tab* tab = require_tab(app, type);
PresetCollection& collection = collection_for(*app.preset_bundle, type);
const std::string current = collection.get_edited_preset().name;
std::string target = current;
bool selection_changed = false;
if (!preset_name.empty()) {
// Resolve by the name the tab's dropdown actually shows (display label or item alias), so a
// plugin can pass exactly what the user sees. With no populated dropdown there is nothing
// to resolve against, so fall back to collection internal names.
GUI::TabPresetComboBox* combo = tab->get_combo_box();
std::string resolved;
if (combo != nullptr && combo->GetCount() > 0)
resolved = resolve_preset_in_combo(*combo, preset_name, preset_tab_name(type),
"preset selector for the selected printer");
else
resolved = preset_name;
Preset* requested = collection.find_preset(resolved);
if (requested == nullptr)
throw std::invalid_argument("No " + std::string(preset_tab_name(type)) + " preset named \"" + resolved
+ "\" is available to select");
target = requested->name;
if (target != current) {
// Normal UI selection: honours the unsaved-edit prompt; a declined prompt returns false
// and is a user cancellation, distinct from any other resolution failure.
if (!tab->select_preset(target))
throw SelectionCancelled("Switching the edited " + std::string(preset_tab_name(type)) + " preset to \""
+ target + "\" was canceled; nothing was staged");
// A cancelled task must not go on to bind a transaction. The accepted selection is the
// user's decision and is left in place; only the remaining work stops.
if (cancelled && cancelled())
throw SelectionAbandoned("The edited " + std::string(preset_tab_name(type)) + " preset was switched to \""
+ target + "\", but the plugin operation was cancelled; the transaction was not created");
selection_changed = true;
}
}
// select_preset() can still leave a different preset edited (e.g. the target is not an
// allowed dropdown choice), so confirm before binding.
if (collection.get_edited_preset().name != target)
throw std::runtime_error("The edited " + std::string(preset_tab_name(type)) + " preset is \""
+ collection.get_edited_preset().name + "\", not \"" + target
+ "\"; the transaction was not created");
transaction.type = type;
transaction.target_name = target;
transaction.selection_changed = selection_changed;
}
// Blocking form, used by update() and by any caller that cannot suspend.
PresetTransaction begin_preset_update(Preset::Type type, const std::string& preset_name)
{
PresetTransaction transaction;
PluginHostUi::run_on_ui_thread([&] { bind_preset_target(type, preset_name, transaction); });
return transaction;
}
// Filament target resolution: slot_number identifies the slot; an omitted preset_name keeps the
// preset assigned to it. A supplied preset_name selects that preset for the slot through the normal
// tab-selection flow, then records the slot assignment and its dependent Plater side effects (the
// ones the sidebar selection path performs) only after that flow succeeds, so a canceled prompt
// changes neither. A mixed-color virtual slot is rejected before any of this, with or without a
// preset_name. A requested preset the requested slot's own dropdown does not offer is rejected
// before the selection flow, so no dialog is raised for it; the one exception is the slot's own
// current assignment, which stays targetable when the dropdown hides it. A different preset the
// tab happens to edit is a globally edited target, not an offered choice for this slot, and is
// rejected.
// Runs on the UI thread with the GIL released; shared by the blocking and awaitable entry points.
void bind_filament_target(int slot_number, const std::string& preset_name, PresetTransaction& transaction,
const CancelProbe& cancelled = {})
{
GUI::GUI_App& app = require_gui_app();
PresetBundle& bundle = *app.preset_bundle;
GUI::Plater* plater = app.plater();
// A mixed-color virtual slot has no single filament preset behind it, so every form of this
// call is rejected: with or without a preset_name there is no one preset to edit or assign.
// Range-guarded so an out-of-range slot still gets resolved_slot_preset()'s clear error.
if (slot_number >= 1 && static_cast<size_t>(slot_number) <= bundle.filament_presets.size()
&& bundle.is_mixed_filament(static_cast<size_t>(slot_number) - 1))
throw std::invalid_argument("Filament slot " + std::to_string(slot_number)
+ " is a mixed-color virtual slot; it has no single filament preset to edit or assign");
// Resolve and validate before any selection, so a bad slot fails without a prompt.
const std::string assigned_internal = resolved_slot_preset(bundle, slot_number);
GUI::Tab* tab = require_tab(app, Preset::TYPE_FILAMENT);
const size_t slot_index = static_cast<size_t>(slot_number) - 1;
std::string target = assigned_internal;
if (!preset_name.empty()) {
// Resolve against the requested sidebar slot's own selector, not the tab's: that is the
// model the user picks this slot's filament from, including its compatibility filter. The
// selector matches the exact name shown (display label or item alias).
std::vector<GUI::PlaterPresetComboBox*>& slot_combos = plater->sidebar().combos_filament();
GUI::PlaterPresetComboBox* slot_combo = slot_index < slot_combos.size() ? slot_combos[slot_index] : nullptr;
std::string resolved;
if (slot_combo != nullptr && slot_combo->GetCount() > 0) {
const ComboPresetMatch match = resolve_combo_preset(*slot_combo, preset_name);
if (match.ambiguous)
throw std::invalid_argument("The filament name \"" + preset_name
+ "\" is ambiguous in the filament preset selector for filament slot "
+ std::to_string(slot_number)
+ "; more than one entry shows it, so name the preset by its own entry");
if (match.item >= 0) {
resolved = match.internal_name;
} else {
// Not shown by this slot's selector. Only the slot's current assignment stays
// targetable (a hidden or incompatible-but-selected preset the dropdown omits);
// resolve it by internal name or by display alias and accept only that preset. A
// different preset the tab happens to edit is a globally edited target, not an
// offered choice for this slot, and is rejected.
Preset* by_name = bundle.filaments.find_preset(preset_name);
if (by_name == nullptr) {
const std::string& alias_name = bundle.filaments.get_preset_name_by_alias(preset_name);
by_name = bundle.filaments.find_preset(alias_name);
}
if (by_name == nullptr || by_name->name != assigned_internal)
throw std::invalid_argument("Filament preset \"" + preset_name
+ "\" is not offered by the filament preset selector for filament slot "
+ std::to_string(slot_number));
resolved = by_name->name;
}
} else {
// The slot selector is not populated; fall back to the tab selector, then to collection
// internal names, so the call still works when the sidebar has not been laid out.
GUI::TabPresetComboBox* tab_combo = tab->get_combo_box();
if (tab_combo != nullptr && tab_combo->GetCount() > 0)
resolved = resolve_preset_in_combo(*tab_combo, preset_name, "filament",
"filament preset selector for the selected printer");
else
resolved = preset_name;
}
Preset* requested = bundle.filaments.find_preset(resolved);
if (requested == nullptr)
throw std::invalid_argument("No filament preset named \"" + resolved + "\" is available to select");
target = requested->name;
}
bool selection_changed = false;
if (bundle.filaments.get_edited_preset().name != target) {
// A declined prompt is a user cancellation: it is thrown before any slot-assignment change,
// so neither the mapping nor a setting is touched.
if (!tab->select_preset(target))
throw SelectionCancelled("Switching the edited filament preset to \"" + target + "\" for filament slot "
+ std::to_string(slot_number)
+ " was canceled; neither the slot assignment nor any setting was changed");
// The modal above may have been open when the awaiting task was cancelled. The accepted
// selection stands (we do not reverse a user decision), but the remaining slot assignment
// and the transaction are skipped.
if (cancelled && cancelled())
throw SelectionAbandoned("The edited filament preset was switched to \"" + target
+ "\", but the plugin operation was cancelled before filament slot "
+ std::to_string(slot_number)
+ " was reassigned; neither the slot assignment nor the transaction was created");
if (bundle.filaments.get_edited_preset().name != target)
throw std::runtime_error("The edited filament preset is \"" + bundle.filaments.get_edited_preset().name
+ "\", not \"" + target + "\"; the transaction was not created");
selection_changed = true;
}
bool assignment_changed = false;
if (!preset_name.empty() && target != assigned_internal) {
// Cancellation may also arrive on the modal-free path (the tab already edited the target).
if (cancelled && cancelled())
throw SelectionAbandoned("The plugin operation was cancelled before filament slot " + std::to_string(slot_number)
+ " was reassigned; neither the slot assignment nor the transaction was created");
if (app.app_config == nullptr)
throw std::runtime_error("Application configuration is not available");
// Mirror the filament branch of Plater::priv::on_select_preset() (the sidebar's normal
// preset selection): update the mapping, then every dependent project/UI side effect,
// rather than the vector entry alone.
const bool was_support = GUI::is_support_filament(static_cast<int>(slot_index));
bundle.set_filament_preset(slot_index, target);
plater->update_project_dirty_from_presets();
bundle.export_selections(*app.app_config);
plater->sidebar().update_dynamic_filament_list();
// Assigning a support filament (or moving away from one) changes which filaments the
// flush rules apply to, so the normal flow recalculates the flushing volumes when
// auto-calc covers every filament.
if (GUI::is_support_filament(static_cast<int>(slot_index)) != was_support
&& app.app_config->get("auto_calculate_flush") == "all")
plater->sidebar().auto_calc_flushing_volumes(static_cast<int>(slot_index));
// Refresh the requested slot's own sidebar combo so its list and current selection show the
// newly assigned target (the list may not have contained it), matching the sidebar
// selection path. Fetch the pointer after update_dynamic_filament_list(), which may
// rebuild the combo vector.
std::vector<GUI::PlaterPresetComboBox*>& slot_combos = plater->sidebar().combos_filament();
if (slot_index < slot_combos.size() && slot_combos[slot_index] != nullptr) {
GUI::PlaterPresetComboBox* slot_combo = slot_combos[slot_index];
slot_combo->update();
// Keep the slot's AMS sync badge truthful, reading the item's own flag, the same source
// on_select_preset() reads, so no UI state is invented here.
const int item = combo_item_for_preset(*slot_combo, target);
if (item >= 0)
slot_combo->set_sync_badge(slot_combo->GetFlag(item)
== static_cast<int>(GUI::PresetComboBox::FilamentAMSType::FROM_AMS));
}
plater->on_filament_change(slot_index);
assignment_changed = true;
}
// Match PlaterPresetComboBox::switch_to_tab(): record which 0-based filament slot this tab
// combobox now edits, so the GUI shows the slot the value was written for.
if (GUI::TabPresetComboBox* combo = tab->get_combo_box())
combo->set_filament_idx(slot_number - 1);
transaction.type = Preset::TYPE_FILAMENT;
transaction.filament = true;
transaction.slot_number = slot_number;
transaction.target_name = target;
transaction.selection_changed = selection_changed;
transaction.slot_assignment_changed = assignment_changed;
// Snapshot the mapping after the explicit assignment, as the slot's resolved internal name,
// so commit() compares like with like (a stored alias resolves to the same internal preset).
transaction.slot_assignment_snapshot = resolved_slot_preset(bundle, slot_number);
}
// Blocking form, used by the update() shorthand.
PresetTransaction begin_filament_update(int slot_number, const std::string& preset_name)
{
PresetTransaction transaction;
PluginHostUi::run_on_ui_thread([&] { bind_filament_target(slot_number, preset_name, transaction); });
return transaction;
}
// Compatibility shorthand: one staged setting, committed immediately through the same
// begin_update()/set()/commit() path, so targeting, selection, validation and commit are shared.
void update_edited_preset(Preset::Type type, const std::string& key, const std::string& value)
{
PresetTransaction transaction = begin_preset_update(type, std::string());
transaction.set(key, value);
transaction.commit();
}
void update_filament_slot(int slot_number, const std::string& key, const std::string& value)
{
PresetTransaction transaction = begin_filament_update(slot_number, std::string());
transaction.set(key, value);
transaction.commit();
}
// --------------------------------------------------------------------------
// Awaitable begin_update(): see the lifecycle comment above.
//
// Each entry point runs on the plugin worker with the GIL held and an asyncio loop running. It
// validates the application synchronously (so a call without a wx app fails exactly like update()),
// creates a Future on the running loop, posts the selection to the UI thread, and returns the
// Future at once. The UI callback runs bind_*_target() with the GIL released, then -- with the GIL
// re-acquired -- resolves the Future with the resulting PresetTransaction, or fails it with the
// resolution error. Nothing is staged until the caller's later set()/commit(); a canceled or
// unavailable target changes no preset and maps no slot.
// --------------------------------------------------------------------------
// Map the failures bind_*_target() raises to the Python exceptions the awaitable path should
// surface. A user cancellation becomes the distinct PresetSelectionCancelled (not a plain
// RuntimeError, and not asyncio.CancelledError, which is what a cancelled task raises and is
// dropped by the future resolver before it ever reaches the coroutine). The rest mirror what
// pybind11 would raise for the blocking path (std::invalid_argument -> ValueError,
// std::out_of_range -> IndexError).
py::object preset_update_failure(const std::exception& error)
{
if (dynamic_cast<const SelectionCancelled*>(&error) != nullptr)
return selection_cancelled_error(error.what());
if (dynamic_cast<const std::invalid_argument*>(&error) != nullptr)
return py::module_::import("builtins").attr("ValueError")(error.what());
if (dynamic_cast<const std::out_of_range*>(&error) != nullptr)
return py::module_::import("builtins").attr("IndexError")(error.what());
return py::module_::import("builtins").attr("RuntimeError")(error.what());
}
// Fail an awaitable whose UI work could not be posted at all (the application went away between the
// synchronous check and post_to_ui_thread()). Called with the GIL held.
void fail_unpostable(const std::shared_ptr<AsyncFuture>& handle, const char* message)
{
handle->fail(py::module_::import("builtins").attr("RuntimeError")(message));
}
py::object begin_preset_update_async(Preset::Type type, const std::string& preset_name)
{
PluginHostUi::require_ui_application();
std::shared_ptr<AsyncFuture> handle = AsyncFuture::create();
PluginHostUi::track_pending_async(handle);
py::object future = handle->future();
const bool posted = PluginHostUi::post_to_ui_thread([type, preset_name, handle]() {
// The plugin was unloaded, or the task was cancelled, before this ran: do not select. This
// reads the Future's own cancelled() (not only the atomic), so a cancel Future.cancel()
// already applied is seen even before the loop runs its done-callback.
if (handle->is_cancelled())
return;
PresetTransaction transaction;
try {
bind_preset_target(type, preset_name, transaction, [handle]() { return handle->is_cancelled(); });
} catch (const std::exception& error) {
PythonGILState gil;
if (gil)
handle->fail(preset_update_failure(error));
return;
}
// Cancelled after the bind but before delivery: the resolver would drop the result anyway.
if (handle->is_cancelled())
return;
PythonGILState gil;
if (gil)
handle->complete(py::cast(transaction));
});
if (!posted)
fail_unpostable(handle, "OrcaSlicer application is not initialized");
return future;
}
py::object begin_filament_update_async(int slot_number, const std::string& preset_name)
{
PluginHostUi::require_ui_application();
std::shared_ptr<AsyncFuture> handle = AsyncFuture::create();
PluginHostUi::track_pending_async(handle);
py::object future = handle->future();
const bool posted = PluginHostUi::post_to_ui_thread([slot_number, preset_name, handle]() {
// The plugin was unloaded, or the task was cancelled, before this ran: do not select, so a
// queued UI selection never starts. This reads the Future's own cancelled() (not only the
// atomic), so a cancel Future.cancel() already applied is seen even before the loop runs its
// done-callback.
if (handle->is_cancelled())
return;
PresetTransaction transaction;
try {
bind_filament_target(slot_number, preset_name, transaction, [handle]() { return handle->is_cancelled(); });
} catch (const std::exception& error) {
PythonGILState gil;
if (gil)
handle->fail(preset_update_failure(error));
return;
}
// Cancelled after the bind but before delivery: the resolver would drop the result anyway.
if (handle->is_cancelled())
return;
PythonGILState gil;
if (gil)
handle->complete(py::cast(transaction));
});
if (!posted)
fail_unpostable(handle, "OrcaSlicer application is not initialized");
return future;
}
void reload_local_bundle(const std::string& bundle_id)
@@ -205,7 +810,7 @@ std::vector<std::string> list_bundle_ids()
void host_bindings::register_presets(py::module_& host)
{
auto preset = host.def_submodule("preset", "Preset operations");
auto preset = host.def_submodule("preset", "Preset operations");
auto bundles = preset.def_submodule("bundles", "Local preset bundle operations");
bundles.def("reload_local", &reload_local_bundle, py::arg("bundle_id"),
"Reload one local preset bundle from the active user's _local directory.");
@@ -213,6 +818,19 @@ void host_bindings::register_presets(py::module_& host)
"IDs of local preset bundles in the active user's _local directory that reload_local() accepts, "
"including bundles added after startup.");
// The outcome of a user-declined selection in the awaitable begin_update(), told apart from an
// invalid/unavailable target (ValueError) and from asyncio task cancellation
// (asyncio.CancelledError). It subclasses RuntimeError, so a plugin catching RuntimeError still
// handles it, and the synchronous update() path is unchanged (it raises RuntimeError). The type
// is owned by the module (and looked up live when raised), not cached in a static reference, so
// a later interpreter reset cannot leave a stale pointer behind.
py::dict selection_cancelled_ns;
selection_cancelled_ns["__module__"] = py::str("orca.host.preset");
preset.attr("PresetSelectionCancelled") = py::module_::import("builtins").attr("type")(
"PresetSelectionCancelled",
py::make_tuple(py::reinterpret_borrow<py::object>(PyExc_RuntimeError)),
selection_cancelled_ns);
py::enum_<Preset::Type>(host, "PresetType")
.value("Invalid", Preset::TYPE_INVALID)
.value("Print", Preset::TYPE_PRINT)
@@ -224,11 +842,105 @@ void host_bindings::register_presets(py::module_& host)
.value("Plate", Preset::TYPE_PLATE)
.value("Model", Preset::TYPE_MODEL);
py::class_<PresetUpdateResult>(preset, "PresetUpdateResult",
"Outcome of one committed preset transaction. target_name is the preset that was "
"edited; selection_changed says whether begin_update() switched the active preset; "
"for filament, slot_number is the 1-based target slot, slot_assignment_changed "
"says whether selecting the requested preset reassigned that slot, and shared_slots "
"lists every 1-based slot whose assigned preset is the edited (shared) one. keys "
"lists the settings applied, in order, and dirty says whether the edited preset "
"now differs from its saved state.")
.def_readonly("type", &PresetUpdateResult::type)
.def_readonly("target_name", &PresetUpdateResult::target_name)
.def_readonly("selection_changed", &PresetUpdateResult::selection_changed)
.def_readonly("slot_number", &PresetUpdateResult::slot_number)
.def_readonly("slot_assignment_changed", &PresetUpdateResult::slot_assignment_changed)
.def_readonly("shared_slots", &PresetUpdateResult::shared_slots)
.def_readonly("keys", &PresetUpdateResult::keys)
.def_readonly("dirty", &PresetUpdateResult::dirty)
.def_property_readonly("category", [](const PresetUpdateResult& result) { return preset_tab_name(result.type); })
.def("__repr__", [](const PresetUpdateResult& result) {
return std::string("<PresetUpdateResult ") + preset_tab_name(result.type) + " \"" + result.target_name
+ "\" keys=" + std::to_string(result.keys.size()) + ">";
});
py::class_<PresetTransaction>(preset, "PresetTransaction",
"A staged, single-target preset edit returned by awaiting begin_update(). set() stages "
"a serialized value without mutating the preset; commit() validates and applies the "
"whole batch through the normal setting-change path. begin_update() itself suspends "
"until any selection or unsaved-edit dialog is answered, without blocking the UI "
"thread; set() and commit() run synchronously on the plugin worker. Nothing is saved "
"to disk: the edited preset stays dirty until the user saves it.")
.def_readonly("type", &PresetTransaction::type)
.def_readonly("target_name", &PresetTransaction::target_name)
.def_readonly("selection_changed", &PresetTransaction::selection_changed)
.def_property_readonly("slot_number", [](const PresetTransaction& transaction) {
return transaction.filament ? transaction.slot_number : -1;
})
.def_property_readonly("category", [](const PresetTransaction& transaction) { return preset_tab_name(transaction.type); })
.def_property_readonly("staged_keys", [](const PresetTransaction& transaction) {
std::vector<std::string> keys;
keys.reserve(transaction.staged.size());
for (const auto& entry : transaction.staged)
keys.push_back(entry.first);
return keys;
})
.def("set", &PresetTransaction::set, py::arg("key"), py::arg("value"),
"Stage one setting change. key must be a setting of the target preset category and value its "
"serialized string form; staging validates neither, so commit() is where an invalid key or value "
"fails. Staging the same key twice in one transaction raises ValueError (conflicting duplicate "
"writes are rejected). After commit() the transaction is closed.")
.def("commit", &PresetTransaction::commit,
"Validate and apply every staged setting through the same path as editing it in the GUI, then return "
"a PresetUpdateResult. An invalid key or value raises ValueError; a changed target or filament slot "
"mapping raises RuntimeError; a failure leaves none of the staged settings applied. The edited preset "
"becomes dirty until the user saves it.");
py::class_<PresetEditor>(preset, "PresetEditor",
"Handle to a category of currently edited preset. It is not a snapshot: it always "
"resolves the preset the matching tab edits now, so it stays usable across preset "
"switches. update() edits the preset in place; the change is not saved to disk.")
.def_readonly("type", &PresetEditor::type)
.def(
"begin_update",
[](const PresetEditor& editor, const std::string& preset_name) {
if (editor.type == Preset::TYPE_FILAMENT)
throw std::invalid_argument("begin_update on the filament editor takes "
"(slot_number, preset_name=\"\"); use orca.host.preset.filament.begin_update(slot_number, ...)");
return begin_preset_update_async(editor.type, preset_name);
},
py::arg("preset_name") = "",
"Awaitable. Resolve and bind this category's target preset and resolve to a PresetTransaction. With no "
"preset_name the currently active preset is targeted; a preset_name must be a preset this category can "
"select, and is selected through the normal preset-selection flow (which prompts about unsaved edits). "
"The returned awaitable is pending until any such dialog is answered. A selection the user cancels raises "
"orca.host.preset.PresetSelectionCancelled (a RuntimeError subclass; distinct from asyncio task "
"cancellation, which raises asyncio.CancelledError); an unavailable or invalid target raises ValueError. "
"Either failure creates no transaction and stages no setting. Must be awaited from the background Script "
"worker's running event loop. This form is for process and printer; filament takes "
"begin_update(slot_number, preset_name=\"\").")
.def(
"begin_update",
[](const PresetEditor& editor, int slot_number, const std::string& preset_name) {
if (editor.type != Preset::TYPE_FILAMENT)
throw std::invalid_argument("The slot_number form of begin_update() is only available on "
"orca.host.preset.filament; call begin_update(preset_name=\"\") on this editor");
return begin_filament_update_async(slot_number, preset_name);
},
py::arg("slot_number"), py::arg("preset_name") = "",
"Awaitable. Resolve and bind a filament preset by 1-based slot and resolve to a PresetTransaction. "
"Omitting preset_name targets the preset assigned to the slot; supplying a different name selects it for "
"the slot through the normal flow (prompting about unsaved edits) and, once that succeeds, records the new "
"slot assignment with the same Plater side effects the sidebar selection performs (project/selection "
"refresh, dynamic filament list, conditional support-filament flushing recalc and the slot's AMS sync "
"badge). A requested preset this slot's dropdown does not offer is rejected before that flow, so it "
"raises no prompt; the slot's own current assignment stays targetable even when the dropdown hides it. A "
"selection the user cancels raises orca.host.preset.PresetSelectionCancelled (a "
"RuntimeError subclass; distinct from asyncio task cancellation, which raises asyncio.CancelledError) and "
"changes neither the slot assignment nor any setting. An out-of-range, empty or unresolved slot is "
"rejected, and a mixed-color virtual slot is rejected whether or not preset_name is given. The "
"transaction edits the shared preset, so every slot assigned to it is affected. Must be awaited from the "
"background Script worker's running event loop.")
.def(
"update",
[](const PresetEditor& editor, const std::string& key, const std::string& value) {
@@ -240,7 +952,9 @@ void host_bindings::register_presets(py::module_& host)
"key must be a setting of this preset category and value its serialized string form, "
"otherwise ValueError is raised and nothing is written. A setting edited through a field "
"runs that field's validation even before its page has been opened; one the GUI edits "
"outside a field (such as compatible printers) also recomputes preset compatibility.")
"outside a field (such as compatible printers) also recomputes preset compatibility. "
"Equivalent to awaiting begin_update() with the active preset as target, then "
"set(key, value) and commit(); unlike begin_update() this call blocks the plugin worker.")
.def(
"update",
[](const PresetEditor& editor, int slot_number, const std::string& key, const std::string& value) {
@@ -254,8 +968,11 @@ void host_bindings::register_presets(py::module_& host)
"is not the one currently edited the call switches to it through the normal preset-selection "
"flow, so an unsaved edited preset still prompts the user and a declined prompt cancels the "
"call with an error and no change. An out-of-range, empty or unresolved slot is rejected with "
"a clear error. Other preset editors accept only update(key, value); this form raises "
"ValueError on them.");
"a clear error, as is a mixed-color virtual slot, which has no single preset to edit. Other "
"preset editors accept only update(key, value); this form raises "
"ValueError on them. Equivalent to awaiting begin_update(slot_number) with an omitted "
"preset_name (the slot's current assignment is kept), then set(key, value) and commit(); "
"unlike begin_update() this call blocks the plugin worker.");
// The three preset categories edited by the main tabs. `process` is the print/process preset,
// `printer` the machine preset and `filament` the currently edited filament preset (its
+195
View File
@@ -143,6 +143,38 @@ std::function<void()> make_close_adapter(const CallablePtr& holder)
};
}
// The Python-side resolver scheduled onto a plugin loop by AsyncFuture. It runs on the loop's own
// thread with the GIL held. The cancelled() check drops the outcome of a coroutine whose task was
// cancelled while the UI dialog was open; set_result/set_exception on a cancelled Future would
// otherwise raise InvalidStateError on the loop.
py::object make_future_resolver()
{
return py::cpp_function([](py::object future, py::object value, bool is_error) {
if (future.attr("cancelled")().cast<bool>())
return;
if (is_error)
future.attr("set_exception")(value);
else
future.attr("set_result")(value);
});
}
// A done-callback on the awaitable's Future, run by the loop. If the Future was cancelled (the
// awaiting task was cancelled, e.g. ScriptAsyncRuntime::cancel_plugin, or the coroutine cancelled
// itself), it marks the AsyncFuture abandoned. That is what lets the UI thread -- which must never
// touch the loop or the Future -- notice cancellation atomically, so a queued selection does not
// start and one already inside a modal stops when the modal returns. The weak reference keeps the
// callback from owning the AsyncFuture.
py::object make_cancellation_watcher(const std::weak_ptr<AsyncFuture>& weak_handle)
{
return py::cpp_function([weak_handle](py::object future) {
if (future.attr("cancelled")().cast<bool>()) {
if (auto handle = weak_handle.lock())
handle->abandon();
}
});
}
// --------------------------------------------------------------------------
// Registry of live plugin UI resources. Keyed by an opaque id; tracks the
// owning plugin so all of a plugin's UI can be torn down on unload.
@@ -229,11 +261,66 @@ public:
return callbacks;
}
// Awaitables posted to the UI thread from a plugin worker, keyed by the plugin. Held weakly so
// the registration never keeps a finished operation (or its Python loop/Future) alive.
void track_async(const std::shared_ptr<AsyncFuture>& handle, const std::string& plugin_key)
{
if (!handle)
return;
std::lock_guard<std::mutex> lk(m_mtx);
auto& weaks = m_async[plugin_key];
// Prune registrations whose UI callback already returned, so the list stays bounded.
weaks.erase(std::remove_if(weaks.begin(), weaks.end(),
[](const std::weak_ptr<AsyncFuture>& weak) { return weak.expired(); }),
weaks.end());
weaks.push_back(handle);
}
void abandon_async(const std::string& plugin_key)
{
std::vector<std::shared_ptr<AsyncFuture>> pending;
{
std::lock_guard<std::mutex> lk(m_mtx);
auto it = m_async.find(plugin_key);
if (it == m_async.end())
return;
for (const std::weak_ptr<AsyncFuture>& weak : it->second) {
if (auto handle = weak.lock())
pending.push_back(std::move(handle));
}
// The registrations are intentionally kept: pending_async_count() must still see the
// in-flight UI callback so PluginManager can drain it. They are pruned once the weak
// reference expires (the callback returned).
}
// abandon() is GIL-free and thread-safe: it only stops a later completion from touching the
// unloaded plugin's loop.
for (const std::shared_ptr<AsyncFuture>& handle : pending)
handle->abandon();
}
// Live registrations for plugin_key: their UI callback has been posted but has not returned.
// Expired weak references are pruned so the count reaches 0 when the last callback finishes.
std::size_t pending_async_count(const std::string& plugin_key)
{
std::lock_guard<std::mutex> lk(m_mtx);
auto it = m_async.find(plugin_key);
if (it == m_async.end())
return 0;
auto& weaks = it->second;
weaks.erase(std::remove_if(weaks.begin(), weaks.end(),
[](const std::weak_ptr<AsyncFuture>& weak) { return weak.expired(); }),
weaks.end());
const std::size_t count = weaks.size();
if (count == 0)
m_async.erase(it);
return count;
}
private:
std::mutex m_mtx;
std::unordered_map<int, wxWindow*> m_resources;
std::unordered_map<int, std::string> m_owners;
std::unordered_map<std::string, std::vector<std::weak_ptr<GilSafeCallable>>> m_callbacks;
std::unordered_map<std::string, std::vector<std::weak_ptr<AsyncFuture>>> m_async;
int m_next_id{1};
};
@@ -646,11 +733,115 @@ void plater_notification(GUI::NotificationManager::NotificationLevel notificatio
} // namespace
// --------------------------------------------------------------------------
// AsyncFuture: the awaitable suspension primitive (see PluginHostUi.hpp).
// --------------------------------------------------------------------------
std::shared_ptr<AsyncFuture> AsyncFuture::create()
{
auto handle = std::shared_ptr<AsyncFuture>(new AsyncFuture());
py::object loop = py::module_::import("asyncio").attr("get_running_loop")();
handle->m_loop = loop;
handle->m_future = loop.attr("create_future")();
handle->m_bind = make_future_resolver();
// Watch for task/Future cancellation on the loop, so the host UI thread sees it as an atomic.
handle->m_future.attr("add_done_callback")(make_cancellation_watcher(handle));
return handle;
}
void AsyncFuture::complete(py::object value)
{
if (m_abandoned.load(std::memory_order_acquire))
return;
try {
m_loop.attr("call_soon_threadsafe")(m_bind, m_future, std::move(value), py::bool_(false));
} catch (py::error_already_set& error) {
// The loop closed before delivery (plugin teardown or shutdown). The awaiting task is gone
// with it, so the outcome is dropped.
BOOST_LOG_TRIVIAL(debug) << "orca.host awaitable result was not delivered: " << error.what();
PyErr_Clear();
}
}
void AsyncFuture::fail(py::object exc)
{
if (m_abandoned.load(std::memory_order_acquire))
return;
try {
m_loop.attr("call_soon_threadsafe")(m_bind, m_future, std::move(exc), py::bool_(true));
} catch (py::error_already_set& error) {
BOOST_LOG_TRIVIAL(debug) << "orca.host awaitable error was not delivered: " << error.what();
PyErr_Clear();
}
}
bool AsyncFuture::is_cancelled() const
{
if (m_abandoned.load(std::memory_order_acquire))
return true;
// Future.cancel() flips the Future's state synchronously on the loop thread, while the
// done-callback that sets the atomic is only scheduled afterwards; reading the Future's own
// state under the GIL closes that window for a caller that must decide before it starts or
// before it maps a slot. cancelled() is a plain flag read and never mutates the Future.
PythonGILState gil;
if (!gil || !m_future)
return false;
try {
return m_future.attr("cancelled")().cast<bool>();
} catch (py::error_already_set&) {
PyErr_Clear();
return false;
}
}
AsyncFuture::~AsyncFuture()
{
PythonGILState gil;
if (gil) {
m_bind = py::object();
m_future = py::object();
m_loop = py::object();
} else {
// Interpreter is gone; leaking is the only safe option (mirrors the UI callable holders).
(void) m_bind.release();
(void) m_future.release();
(void) m_loop.release();
}
}
void PluginHostUi::run_on_ui_thread(std::function<void()> fn)
{
run_on_ui_blocking(std::move(fn));
}
void PluginHostUi::require_ui_application()
{
if (wxTheApp == nullptr)
throw std::runtime_error("OrcaSlicer application is not initialized");
}
bool PluginHostUi::post_to_ui_thread(std::function<void()> fn)
{
if (wxTheApp == nullptr)
return false;
GUI::wxGetApp().CallAfter(std::move(fn));
return true;
}
void PluginHostUi::track_pending_async(const std::shared_ptr<AsyncFuture>& handle)
{
UiRegistry::instance().track_async(handle, PluginAuditManager::instance().current_plugin());
}
void PluginHostUi::abandon_pending_async(const std::string& plugin_key)
{
UiRegistry::instance().abandon_async(plugin_key);
}
std::size_t PluginHostUi::pending_async_count(const std::string& plugin_key)
{
return UiRegistry::instance().pending_async_count(plugin_key);
}
void PluginHostUi::RegisterBindings(pybind11::module_& host)
{
auto ui = host.def_submodule(
@@ -781,6 +972,10 @@ void PluginHostUi::close_windows_for_plugin(const std::string& plugin_key)
return;
auto teardown = [plugin_key]() {
// Stop resolving any awaitable still pending for this plugin before its windows go: a UI
// callback that runs after unload must not touch the unloaded plugin's loop.
UiRegistry::instance().abandon_async(plugin_key);
for (auto& callback : UiRegistry::instance().take_callbacks_for_plugin(plugin_key))
callback->disable();
+83
View File
@@ -2,11 +2,70 @@
#include <pybind11/pybind11.h>
#include <atomic>
#include <cstddef>
#include <functional>
#include <memory>
#include <string>
namespace Slic3r {
// One awaitable pending on a plugin worker's running asyncio loop, completed from the UI thread.
//
// This is the suspension primitive for host calls that must show a dialog before they can resolve
// (the awaitable preset begin_update(); see docs/HLSD/preset-plugin-api.md and the async-bridge
// architecture gate). It carries no GUI or preset state: only the Python loop, the Future created
// on it, and a Python-side resolver.
//
// Threading contract:
// * create() runs on the plugin worker with the GIL held and an asyncio loop running; it binds
// that loop and creates the Future, which is the awaitable handed back to Python.
// * The host posts the UI work with PluginHostUi::post_to_ui_thread(); the UI callback then calls
// complete()/fail() with the GIL held. Both marshal the outcome through
// loop.call_soon_threadsafe, so the Future is resolved on its own loop thread, never from the
// UI thread.
// * A Future that a cancelled task or a closed loop already discarded is dropped silently, so a
// late UI result cannot corrupt or resurrect a cancelled coroutine.
// * abandon() stops delivery without touching Python, for lifecycle teardown that must not wait.
// Python references are always released under the GIL, from whichever thread destroys the
// handle; if the interpreter is already gone the references are intentionally leaked rather
// than decref'd unsafely (same rule as the UI callable holders).
class AsyncFuture
{
public:
// Requires the GIL and a running asyncio loop (pybind11::error_already_set otherwise).
static std::shared_ptr<AsyncFuture> create();
// The Python Future to return as the awaitable. Requires the GIL.
pybind11::object future() const { return m_future; }
// Resolve the awaitable. Requires the GIL. complete() sets the result, fail() raises `exc`.
void complete(pybind11::object value);
void fail(pybind11::object exc);
// Stop delivery (thread-safe, GIL-free) and let a host UI callback notice it must not start or
// continue. Set when the owning plugin unloads, and by the loop-thread done-callback when the
// Future is cancelled. Idempotent.
void abandon() { m_abandoned.store(true, std::memory_order_release); }
bool abandoned() const { return m_abandoned.load(std::memory_order_acquire); }
// True once the awaitable has been cancelled or abandoned. Unlike abandoned(), this also reads
// the Python Future's own cancelled() under the GIL, so it observes a cancellation that
// Future.cancel() applied synchronously even before the loop runs the done-callback that sets
// the atomic. It never mutates the Future or the loop. Safe to call from the UI thread.
bool is_cancelled() const;
~AsyncFuture();
private:
AsyncFuture() = default;
pybind11::object m_loop;
pybind11::object m_future;
pybind11::object m_bind;
std::atomic<bool> m_abandoned{false};
};
// Binds the `orca.host.ui` submodule: native message boxes, progress dialogs,
// and interactive HTML windows/dialogs for plugins. All calls run on the main/UI
// thread (marshaled from the plugin worker thread) and the host owns every window.
@@ -22,6 +81,30 @@ public:
static void run_on_ui_thread(std::function<void()> fn);
// Fail synchronously (std::runtime_error) when there is no wx application, matching
// run_on_ui_thread()'s first check, so an awaitable entry point can reject before it touches
// asyncio.
static void require_ui_application();
// Post `fn` to run on the main/UI thread without blocking the caller. `fn` runs with the GIL
// released and must touch only C++/wx state and host-owned handles. Returns false (and drops
// `fn`) when the application is not initialized.
static bool post_to_ui_thread(std::function<void()> fn);
// Lifecycle tracking for awaitables posted from a plugin worker. track_pending_async() keys the
// handle by the current plugin; the registration holds only a weak reference and expires when
// the UI operation finishes. abandon_pending_async() (plugin unload / draining) stops every
// still-pending awaitable for the plugin from resolving into an unloaded loop.
static void track_pending_async(const std::shared_ptr<AsyncFuture>& handle);
static void abandon_pending_async(const std::string& plugin_key);
// Number of plugin-worker awaitables for plugin_key whose UI work has been posted but has not
// finished yet. Expired registrations are pruned, so this reaches 0 once every posted UI
// callback has returned. PluginManager drains on this before unloading a plugin's module: a
// Tab::select_preset dialog accepted after that would otherwise mutate presets after unload.
// GIL-free; safe from any thread.
static std::size_t pending_async_count(const std::string& plugin_key);
// 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);
@@ -222,6 +222,46 @@ TEST_CASE("Plugin host API exposes the filament slot form of preset update and g
}
}
TEST_CASE("Plugin host API exposes staged preset update transactions", "[PluginHost][Python]")
{
py::object preset = import_orca_module().attr("host").attr("preset");
py::object transaction_type = preset.attr("PresetTransaction");
py::object result_type = preset.attr("PresetUpdateResult");
py::object selection_cancelled = preset.attr("PresetSelectionCancelled");
py::object runtime_error = py::module_::import("builtins").attr("RuntimeError");
CHECK(has_attr(transaction_type, "set"));
CHECK(has_attr(transaction_type, "commit"));
CHECK(py::module_::import("builtins").attr("issubclass")(selection_cancelled, runtime_error).cast<bool>());
CHECK(has_attr(result_type, "target_name"));
CHECK(has_attr(result_type, "selection_changed"));
CHECK(has_attr(result_type, "slot_assignment_changed"));
CHECK(has_attr(result_type, "shared_slots"));
for (const char* name : { "process", "printer" }) {
CAPTURE(name);
py::object editor = preset.attr(name);
CHECK(has_attr(editor, "begin_update"));
try {
editor.attr("begin_update")();
FAIL("begin_update unexpectedly succeeded without a wx application");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_RuntimeError));
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
}
}
py::object filament = preset.attr("filament");
CHECK(has_attr(filament, "begin_update"));
try {
filament.attr("begin_update")(1);
FAIL("filament begin_update unexpectedly succeeded without a wx application");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_RuntimeError));
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
}
}
TEST_CASE("A settings group resolves an option's id from its option map", "[PluginHost]")
{
using Slic3r::GUI::option_id_in_map;