From d80af50f2355639cf15048f6458a201e67c70da4 Mon Sep 17 00:00:00 2001 From: peachismomo Date: Sat, 17 Oct 2026 01:44:15 +0800 Subject: [PATCH] feat: preset APIs --- docs/HLSD/preset-plugin-api.md | 185 ++-- docs/HLSD/python-plugin-async-bridge.md | 398 +++++++++ sandboxes/orca_preset_transaction_demo.py | 300 +++++++ src/slic3r/GUI/ActionRegistry.cpp | 78 +- src/slic3r/GUI/PluginsDialog.cpp | 70 +- src/slic3r/GUI/Tab.cpp | 137 +-- src/slic3r/GUI/Tab.hpp | 29 + src/slic3r/plugin/PluginHooks.cpp | 6 + src/slic3r/plugin/PluginManager.cpp | 472 +++++++++- src/slic3r/plugin/PluginManager.hpp | 100 +++ src/slic3r/plugin/PyPluginTrampoline.hpp | 75 ++ src/slic3r/plugin/host/PluginHostPresets.cpp | 871 +++++++++++++++++-- src/slic3r/plugin/host/PluginHostUi.cpp | 195 +++++ src/slic3r/plugin/host/PluginHostUi.hpp | 83 ++ tests/slic3rutils/test_plugin_host_api.cpp | 40 + 15 files changed, 2762 insertions(+), 277 deletions(-) create mode 100644 docs/HLSD/python-plugin-async-bridge.md create mode 100644 sandboxes/orca_preset_transaction_demo.py diff --git a/docs/HLSD/preset-plugin-api.md b/docs/HLSD/preset-plugin-api.md index aafbfea4ba..46dd69f58b 100644 --- a/docs/HLSD/preset-plugin-api.md +++ b/docs/HLSD/preset-plugin-api.md @@ -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. diff --git a/docs/HLSD/python-plugin-async-bridge.md b/docs/HLSD/python-plugin-async-bridge.md new file mode 100644 index 0000000000..6e02925d5b --- /dev/null +++ b/docs/HLSD/python-plugin-async-bridge.md @@ -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`. 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. diff --git a/sandboxes/orca_preset_transaction_demo.py b/sandboxes/orca_preset_transaction_demo.py new file mode 100644 index 0000000000..8882225310 --- /dev/null +++ b/sandboxes/orca_preset_transaction_demo.py @@ -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..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 = """ + + + +
Target +
+ + +
+ +
+ + +
+
Edited now: ?
+
+ +
Staged settings (serialized values) +
= +
+
= +
+
+ +
+ + + + +
+

Ready.

+ + +""" + + +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 + 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) diff --git a/src/slic3r/GUI/ActionRegistry.cpp b/src/slic3r/GUI/ActionRegistry.cpp index 6956423bc6..011381b33e 100644 --- a/src/slic3r/GUI/ActionRegistry.cpp +++ b/src/slic3r/GUI/ActionRegistry.cpp @@ -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(); - 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; } }; diff --git a/src/slic3r/GUI/PluginsDialog.cpp b/src/slic3r/GUI/PluginsDialog.cpp index 86ca31ba0f..0a7338e6b3 100644 --- a/src/slic3r/GUI/PluginsDialog.cpp +++ b/src/slic3r/GUI/PluginsDialog.cpp @@ -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(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(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) diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 36c12611d9..89012727f5 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -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>& 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& 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 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 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. diff --git a/src/slic3r/GUI/Tab.hpp b/src/slic3r/GUI/Tab.hpp index 4aee8fa73c..89e3ce0960 100644 --- a/src/slic3r/GUI/Tab.hpp +++ b/src/slic3r/GUI/Tab.hpp @@ -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>& 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 diff --git a/src/slic3r/plugin/PluginHooks.cpp b/src/slic3r/plugin/PluginHooks.cpp index dec7d261c6..0088bc1477 100644 --- a/src/slic3r/plugin/PluginHooks.cpp +++ b/src/slic3r/plugin/PluginHooks.cpp @@ -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 diff --git a/src/slic3r/plugin/PluginManager.cpp b/src/slic3r/plugin/PluginManager.cpp index 98bf0b6ad6..d4d6ddf0c1 100644 --- a/src/slic3r/plugin/PluginManager.cpp +++ b/src/slic3r/plugin/PluginManager.cpp @@ -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 #include #include +#include #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>& 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 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 capability; + std::function on_done; + std::atomic 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 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> active; + bool stopping = false; + + // Runs one invocation to completion on the calling (worker) thread. + void run_invocation(const std::shared_ptr& run); +}; + +void ScriptAsyncRuntime::Impl::run_invocation(const std::shared_ptr& 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 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 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(); + } 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 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 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 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()) {} + +ScriptAsyncRuntime::~ScriptAsyncRuntime() { shutdown(); } + +bool ScriptAsyncRuntime::start(const std::string& plugin_key, + const std::string& capability_name, + std::shared_ptr capability, + std::function on_done) +{ + if (!PythonInterpreter::instance().is_initialized()) + return false; + + auto run = std::make_shared(); + 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 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 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> runs; + { + std::lock_guard 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 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 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 lock(m_impl->mutex); + m_impl->stopping = true; + } + + std::unique_lock lock(m_impl->mutex); + m_impl->cv.wait(lock, [this] { return m_impl->active.empty(); }); +} + +void ScriptAsyncRuntime::reset() +{ + shutdown(); + + std::lock_guard 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::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 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 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(cap); - if (!cap_interface) - return {}; + if (!std::dynamic_pointer_cast(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) { diff --git a/src/slic3r/plugin/PluginManager.hpp b/src/slic3r/plugin/PluginManager.hpp index bd7a495a67..c5cddda201 100644 --- a/src/slic3r/plugin/PluginManager.hpp +++ b/src/slic3r/plugin/PluginManager.hpp @@ -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; + + // 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 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 capability, + std::function 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 m_impl; +}; + // Resolve each configured capability reference to a loaded capability of type T and run `execute`. template void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities, diff --git a/src/slic3r/plugin/PyPluginTrampoline.hpp b/src/slic3r/plugin/PyPluginTrampoline.hpp index 915c578353..1700c54d4b 100644 --- a/src/slic3r/plugin/PyPluginTrampoline.hpp +++ b/src/slic3r/plugin/PyPluginTrampoline.hpp @@ -6,6 +6,7 @@ #include +#include #include #include #include @@ -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(); +} + +// 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& capability) +{ + ScriptExecuteInvocation invocation; + if (!capability) + return invocation; + + try { + pybind11::gil_scoped_acquire gil; + PluginCapabilityInterface::RefCounter ref_counter(*capability); + + std::optional 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(); + } 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 PyPluginCommonTrampoline : public Base { public: diff --git a/src/slic3r/plugin/host/PluginHostPresets.cpp b/src/slic3r/plugin/host/PluginHostPresets.cpp index 48069cdb45..e36163ec69 100644 --- a/src/slic3r/plugin/host/PluginHostPresets.cpp +++ b/src/slic3r/plugin/host/PluginHostPresets.cpp @@ -1,6 +1,7 @@ #include "PluginHostBindings.hpp" #include "PluginHostUi.hpp" #include "slic3r/plugin/PluginBindingUtils.hpp" +#include "slic3r/plugin/PythonInterpreter.hpp" // PythonGILState #include #include @@ -22,8 +23,13 @@ #include +#include + +#include +#include #include #include +#include #include 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; + +// 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 shared_slots; + std::vector 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> staged; + std::set 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(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(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(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(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(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(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(slot_number) <= bundle.filament_presets.size() + && bundle.is_mixed_filament(static_cast(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(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& 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(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(slot_index)) != was_support + && app.app_config->get("auto_calculate_flush") == "all") + plater->sidebar().auto_calc_flushing_volumes(static_cast(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& 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(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(&error) != nullptr) + return selection_cancelled_error(error.what()); + if (dynamic_cast(&error) != nullptr) + return py::module_::import("builtins").attr("ValueError")(error.what()); + if (dynamic_cast(&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& 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 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 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 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(PyExc_RuntimeError)), + selection_cancelled_ns); + py::enum_(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_(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(""; + }); + + py::class_(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 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_(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 diff --git a/src/slic3r/plugin/host/PluginHostUi.cpp b/src/slic3r/plugin/host/PluginHostUi.cpp index 4a87d5b26d..12c1b19b57 100644 --- a/src/slic3r/plugin/host/PluginHostUi.cpp +++ b/src/slic3r/plugin/host/PluginHostUi.cpp @@ -143,6 +143,38 @@ std::function 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()) + 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& weak_handle) +{ + return py::cpp_function([weak_handle](py::object future) { + if (future.attr("cancelled")().cast()) { + 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& handle, const std::string& plugin_key) + { + if (!handle) + return; + std::lock_guard 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& weak) { return weak.expired(); }), + weaks.end()); + weaks.push_back(handle); + } + void abandon_async(const std::string& plugin_key) + { + std::vector> pending; + { + std::lock_guard lk(m_mtx); + auto it = m_async.find(plugin_key); + if (it == m_async.end()) + return; + for (const std::weak_ptr& 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& 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 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& 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 m_resources; std::unordered_map m_owners; std::unordered_map>> m_callbacks; + std::unordered_map>> 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::create() +{ + auto handle = std::shared_ptr(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(); + } 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 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 fn) +{ + if (wxTheApp == nullptr) + return false; + GUI::wxGetApp().CallAfter(std::move(fn)); + return true; +} + +void PluginHostUi::track_pending_async(const std::shared_ptr& 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(); diff --git a/src/slic3r/plugin/host/PluginHostUi.hpp b/src/slic3r/plugin/host/PluginHostUi.hpp index cb47416b71..0bc66d1cde 100644 --- a/src/slic3r/plugin/host/PluginHostUi.hpp +++ b/src/slic3r/plugin/host/PluginHostUi.hpp @@ -2,11 +2,70 @@ #include +#include +#include #include +#include #include 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 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 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 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 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& 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); diff --git a/tests/slic3rutils/test_plugin_host_api.cpp b/tests/slic3rutils/test_plugin_host_api.cpp index 902269839f..3b974af142 100644 --- a/tests/slic3rutils/test_plugin_host_api.cpp +++ b/tests/slic3rutils/test_plugin_host_api.cpp @@ -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()); + 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;