fix: remove async behavior and clang-tidy

This commit is contained in:
peachismomo committed 2026-10-17 15:09:06 +08:00
1 parent d80af50f23
commit 0b478a1d0b
9 files changed
+176 -715

No files matched your search

+25 -28
View File
@@ -40,7 +40,7 @@ applies them only on explicit `commit()`.
# Filament is targeted by its 1-based slot. Omitting preset_name keeps the
# preset currently assigned to the slot; providing it selects that preset for
# the slot before staging updates.
tx = await orca.host.preset.filament.begin_update(
tx = orca.host.preset.filament.begin_update(
slot_number=2,
preset_name="Generic PLA"
)
@@ -50,7 +50,7 @@ result = tx.commit()
# Process and printer default to their active presets. A caller can request a
# different preset that is selectable in the corresponding UI dropdown.
tx = await orca.host.preset.process.begin_update(
tx = orca.host.preset.process.begin_update(
preset_name="0.20mm Standard"
)
tx.set("layer_height", "0.2")
@@ -58,11 +58,12 @@ tx.commit()
```
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).
is synchronous: it blocks the background Script worker until the target selection and
any unsaved-edit dialog settle, then returns the bound transaction. Orca's UI thread
stays responsive because the Script capability runs on its own worker. `set()` and
`commit()` run synchronously on that same worker. The Script worker and its optional
coroutine loop are described in
[python-plugin-async-bridge.md](python-plugin-async-bridge.md).
### Target resolution
@@ -97,24 +98,21 @@ otherwise it returns a transaction without switching. The helpers use Orca's nor
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
or transfer. The call remains blocked on the Script worker until those interactions
finish; it returns the bound transaction, or raises on 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
in an earlier dialog. 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
different preset is requested, Orca selects it through the normal UI flow. For
selection prompt, apart from the UI-thread dispatch needed to bind the transaction.
If a different preset is requested, Orca selects it through the normal UI flow. For
filament, this also updates the requested slot's assignment when `preset_name`
differs from its current assignment. When the current preset has unsaved changes,
the plugin operation remains pending until the user chooses among Orca's existing
options, such as transfer, discard, or cancel.
the Script worker stays blocked until the user chooses among Orca's existing options,
such as transfer, discard, or cancel.
The API never suppresses that warning, chooses an option for the user, or switches
away from dirty edits silently. Cancel aborts the operation before any staged target
@@ -160,8 +158,8 @@ slot assignment changed and which slots share the edited preset. A canceled
selection applies neither the requested slot assignment nor staged target changes;
a failed commit applies none of the staged target settings.
Filament slot assignments can change while a plugin operation is pending. The API
must detect that and reject a stale operation instead of silently editing the new
Filament slot assignments can change between `begin_update()` and `commit()`. The API
detects that and rejects a stale operation instead of silently editing the new
slot assignment. It must not hold raw preset pointers across reloads. Conflicting
duplicate writes to one setting in a transaction are rejected rather than resolved
by incidental call order.
@@ -169,8 +167,8 @@ by incidental call order.
The existing one-setting filament call,
`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, and it is synchronous (it blocks the background Script
worker) rather than awaitable.
validation, and commit path, and it is synchronous like `begin_update()`: both block
the background Script worker while any selection dialog settles.
## Design principles
@@ -203,12 +201,11 @@ shorthand. For filament, `update_filament_slot()` resolves the preset already as
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.
`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.
`begin_update()` and `update()` are synchronous blocking calls
(`begin_preset_update` / `begin_filament_update`). They marshal the target binding to
the UI thread and block the background Script worker until the selection settles, so
the wait never blocks the UI thread. They are valid only on that worker, never from the
UI thread or a slicing pipeline hook.
`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
@@ -250,6 +247,6 @@ selection prompts, setting validation, or persistence. Target changes flow throu
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
prompts to the Python operation through the blocking `begin_update()` and reuses
the normal Plater slot-selection behavior for explicit filament targets, and commits a
batch without losing existing per-setting behavior.
+79 -126
View File
@@ -2,16 +2,17 @@
## 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.
The embedded Python plugin runtime runs most capability calls synchronously: a C++ call site acquires
the GIL and the capability's Python method runs to completion on the calling thread. The staged preset
update (`begin_update()` in [preset-plugin-api.md](preset-plugin-api.md)) is also synchronous, but
its target selection can open unsaved-edit dialogs. It therefore blocks the Script worker, not the UI
thread, and returns only when the selection settles.
This document describes the 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.
This document describes the Script capability's background execution path, including the optional
per-invocation asyncio loop for an `async def execute`, and the blocking UI marshalling that host calls
use from that worker. The bridge is deliberately narrow: only the Script capability gains a
background/async execution path. Every other capability — including the SlicingPipeline hooks — stays
synchronous on its caller's thread.
## Execution baseline
@@ -108,8 +109,10 @@ another plugin's.
`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).
calling thread is published in the thread-local `tl_script_loop` and exposed through
`ScriptAsyncRuntime::current_loop()` (GIL held). A host call such as `begin_update()` does not suspend
the loop: it blocks the worker thread while the UI settles, then returns a value the coroutine uses
directly.
`PluginManager::launch_script_capability` is the entry point: it resolves the capability, hands it to
`ScriptAsyncRuntime::start`, and sets `ScriptLaunchResult::launched_async`. The completion callback
@@ -141,97 +144,54 @@ with `CallAfter` and denies the current event, persisting the grant when the use
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
### Blocking 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:
`PluginHostUi::run_on_ui_thread` (in `src/slic3r/plugin/host/PluginHostUi.cpp`) is the primitive host
calls use from the Script worker. It forwards to `run_on_ui_blocking`:
- `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).
- 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()`.
`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.
Because the Script capability owns a worker thread, blocking there does not freeze the UI. The same
primitive is used by `begin_update()`, `set()`/`commit()`, `reload_local_bundle()` and
`list_bundle_ids()`.
### Awaitable `begin_update()`
### Synchronous `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:
`begin_preset_update` and `begin_filament_update` (`src/slic3r/plugin/host/PluginHostPresets.cpp`) run
on the Script worker with the GIL held and block it:
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.
1. the binding enters through `PluginHostUi::run_on_ui_thread`, which fails with a `RuntimeError`
before posting when there is no wx application;
2. the UI thread runs the target binding with the GIL released — `bind_preset_target` for
process/printer, `bind_filament_target` for filament — including `Tab::select_preset()` and any
unsaved-edit prompt;
3. the worker resumes with the name-based `PresetTransaction`, and nothing is staged until the
caller's later `set()`/`commit()`.
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.
An unavailable target is rejected before selection and changes no preset. A selection the user
declines raises `RuntimeError` and creates no transaction; decisions already accepted in an earlier
dialog are not rolled back. No GUI or preset pointer outlives the call: the transaction holds only
copied category/name/slot values.
`set()` stages a serialized value without mutating the preset, and `commit()` applies the batch
through `Tab::apply_preset_config_values` on the UI thread; both are synchronous and, per their
bindings, valid only on the background Script worker (they call `PluginHostUi::run_on_ui_thread`,
which blocks the worker, or runs inline if entered from the UI thread).
through `Tab::apply_preset_config_values` on the UI thread. Both are synchronous and, per their
bindings, valid only on the background Script worker.
### Cancellation 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.
### Cancellation
`ScriptAsyncRuntime::cancel_plugin(plugin_key)` sets `cancel_requested` on every active run for the
plugin and, when a loop is live (`has_loop`), cancels the task with
`loop.call_soon_threadsafe(task.cancel)` while holding the GIL. A synchronous `def execute`
invocation has no loop, so only the flag is set (checked before the run starts).
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 task cancellation cannot interrupt a blocking `begin_update()` native call: the worker is inside
that call, so the loop does not run the scheduled `task.cancel()` until the selection settles and the
call returns. The transaction is therefore delivered, and any accepted UI selection stands; the
cancellation is observed at the coroutine's next suspension point. A user-declined selection is an
ordinary `RuntimeError`, not `asyncio.CancelledError`.
A plugin's own `asyncio.CancelledError` is treated as a cancellation only when `cancel_requested` was
set; otherwise it stays a genuine error (see `run_invocation`), so a plugin that cancels its own task
@@ -240,22 +200,18 @@ 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.
unloaded, and that a blocking 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.
The drain is **unbounded while a Script invocation worker is outstanding** — its loop-exit
condition is `all_released && !invocation_active`. `has_active_run()` distinguishes a run that was
cancelled before it started from one whose worker is still live, and a worker blocked in
`begin_update()` keeps the run active until its UI callback returns. On the GUI thread the drain
pumps `wxYield()` so a modal selection can settle instead of being starved by the blocking wait.
Only the non-Script path keeps the historical bounded 2-second grace: when no invocation is active,
the drain stops waiting for capability references at the grace deadline and logs any remaining
ones.
- `PluginManager::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
@@ -286,12 +242,11 @@ self-cancelled plugin still records its failure.
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)`.
- **Marshal host UI work from the Script worker only.** `PluginHostUi::run_on_ui_thread` blocks the
calling worker while the UI thread runs the callback; calling it from the UI thread itself would
deadlock the marshalling path.
- **No pointer crosses a call boundary.** `begin_update()` returns copied category/name/slot values;
the `SlicingPipelineContext` graph is valid only within one synchronous `execute(ctx)`.
## File and symbol map
@@ -314,20 +269,17 @@ Lifetime and GIL primitives:
- `src/slic3r/plugin/PluginAuditManager.hpp` / `.cpp` — `ScopedPluginAuditContext`,
`request_filesystem_read_permissions`, `request_permission` (fail-closed off the main thread).
UI suspension and lifecycle tracking:
UI marshalling:
- `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`.
- `src/slic3r/plugin/host/PluginHostUi.hpp` / `.cpp` — `PluginHostUi::run_on_ui_thread`, the
file-local `run_on_ui_blocking`, and `close_windows_for_plugin`.
- `GUI::wxGetApp().CallAfter` — the post-to-UI-thread primitive.
Preset targets:
- `src/slic3r/plugin/host/PluginHostPresets.cpp` — `PresetEditor`, `PresetTransaction`,
`bind_preset_target`, `bind_filament_target`, `begin_preset_update_async`,
`begin_filament_update_async`, `PresetSelectionCancelled`, `SelectionCancelled`,
`SelectionAbandoned`, `CancelProbe`, `resolve_combo_preset`/`resolve_preset_in_combo`.
`bind_preset_target`, `bind_filament_target`, `begin_preset_update`, `begin_filament_update`,
`SelectionCancelled`, `resolve_combo_preset`/`resolve_preset_in_combo`.
- `src/slic3r/GUI/Tab.hpp` / `.cpp` — `Tab::select_preset`, `Tab::apply_preset_config_values`,
`Tab::commit_preset_config_value`.
@@ -349,9 +301,9 @@ Unload/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
An `async def execute` that updates a preset. `execute` runs on the background Script worker with its
own asyncio loop; `begin_update()` blocks that worker until any selection / unsaved-edit dialog is
answered, without blocking the UI thread; `set()`/`commit()` then run synchronously on the same
worker. This is the shape used by `sandboxes/orca_preset_transaction_demo.py`:
```python
@@ -365,9 +317,9 @@ class AutoTune(orca.script.ScriptPluginCapabilityBase):
return "Auto tune"
async def execute(self):
# begin_update() is awaitable: it resolves once the user answers any
# begin_update() blocks this worker until 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 = 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")
@@ -375,19 +327,20 @@ class AutoTune(orca.script.ScriptPluginCapabilityBase):
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.
`loop.call_soon_threadsafe`; `execute` consumes that queue and calls `begin_update()` per command.
Continuation sequence for one `await begin_update(...)`:
Sequence for one `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
begin_update() ───────────────────────► CallAfter(run_on_ui_blocking)
(blocked; not suspended) bind_*_target →
Tab::select_preset() … dialog
settles
promise set; worker resumes with PresetTransaction
set(); commit() (UI thread for the batch)
coroutine resumes at its next await
execute() returns ExecutionResult
```
+13 -40
View File
@@ -16,10 +16,12 @@
#include "PyPluginTrampoline.hpp"
#include "PythonInterpreter.hpp"
#include "PythonPluginBridge.hpp"
#include "host/PluginHostUi.hpp"
#include "OrcaCloudServiceAgent.hpp"
#include "libslic3r/Semver.hpp"
#include "pybind11/pytypes.h"
#include "pybind11/gil.h"
#include "pybind11/pybind11.h"
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/NotificationManager.hpp"
@@ -769,8 +771,8 @@ void PluginManager::cancel_and_wait_for_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.
// cancelled. Draining of capability references still follows, so the module is not unloaded
// until the run has settled.
for (const auto& capability : capabilities) {
if (!capability || !capability->is_enabled())
continue;
@@ -791,30 +793,13 @@ void PluginManager::cancel_and_wait_for_capabilities(
ScriptAsyncRuntime::instance().cancel_plugin(capability->audit_plugin_key());
}
// Distinct plugins whose posted host UI work must settle before module unload. A Tab::select_preset
// dialog accepted while the plugin is being unloaded would otherwise mutate presets after unload,
// so the drain below waits for every posted UI callback to return, not just for the run's ref.
std::vector<std::string> plugin_keys;
for (const auto& capability : capabilities) {
if (!capability)
continue;
const std::string& key = capability->audit_plugin_key();
if (!key.empty() && std::find(plugin_keys.begin(), plugin_keys.end(), key) == plugin_keys.end())
plugin_keys.push_back(key);
}
const auto host_pending = [&plugin_keys]() {
return std::any_of(plugin_keys.begin(), plugin_keys.end(),
[](const std::string& key) { return PluginHostUi::pending_async_count(key) > 0; });
};
// Script rows are bounded only by their own worker: a cancelled or stalled invocation of another
// plugin can no longer couple to this one (each invocation owns a thread). A Script worker or a
// posted host UI callback is drained without a deadline — forcing module unload while either is
// outstanding would leave Python executing against a freed module, or apply a preset selection
// after the plugin is gone. Non-Script synchronous calls keep the historical bounded grace. On
// the GUI thread the event loop is pumped so the UI callback (possibly a modal) can settle
// instead of being starved by a blocking wait.
// plugin can no longer couple to this one (each invocation owns a thread). A Script worker is
// drained without a deadline — forcing module unload while one is outstanding would leave Python
// executing against a freed module, or let a blocking preset selection mutate presets after the
// plugin is gone. Non-Script synchronous calls keep the historical bounded grace. On the GUI
// thread the event loop is pumped so a modal selection 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) {
@@ -825,11 +810,10 @@ void PluginManager::cancel_and_wait_for_capabilities(
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)
if (all_released && !invocation_active)
return;
if (!invocation_active && !ui_pending && std::chrono::steady_clock::now() >= grace_deadline)
if (!invocation_active && std::chrono::steady_clock::now() >= grace_deadline)
break;
if (wxTheApp != nullptr && wxIsMainThread())
@@ -1063,12 +1047,6 @@ 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);
@@ -1143,11 +1121,6 @@ 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);
+1
View File
@@ -15,6 +15,7 @@
#include <memory>
#include <mutex>
#include <optional>
#include "pybind11/pytypes.h"
#include "slic3r/plugin/PluginFsUtils.hpp"
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <string>
+5 -4
View File
@@ -2,6 +2,7 @@
#define slic3r_PyPluginTrampoline_hpp_
#include "libslic3r/LifecycleEvents.hpp"
#include <exception>
#include <pybind11/embed.h>
#include <boost/log/trivial.hpp>
@@ -15,9 +16,11 @@
#include "PythonInterpreter.hpp"
#include "PluginFsUtils.hpp"
#include "PluginAuditManager.hpp"
#include "pybind11/cast.h"
#include <pybind11/pybind11.h>
#include <pybind11/gil.h>
#include <pybind11/pytypes.h>
#include <utility>
// Trampoline variants of pybind11's override macros. Every C++->Python plugin call crosses a
// trampoline method, so this single boundary is where we (1) log the full Python traceback and
@@ -64,13 +67,11 @@
namespace Slic3r {
// True when `object` is a coroutine object (the value produced by calling an `async def`).
// Match inspect.iscoroutine() without importing Python modules inside the plugin audit scope.
// Must be called with the GIL held.
inline bool py_is_coroutine(const pybind11::handle& object)
{
if (!object)
return false;
pybind11::object inspect = pybind11::module_::import("inspect");
return inspect.attr("iscoroutine")(object).cast<bool>();
return object && PyCoro_CheckExact(object.ptr());
}
// Result of invoking a Script capability's Python `execute` exactly once.
+53 -236
View File
@@ -1,8 +1,8 @@
#include "PluginHostBindings.hpp"
#include "PluginHostUi.hpp"
#include "slic3r/plugin/PluginBindingUtils.hpp"
#include "slic3r/plugin/PythonInterpreter.hpp" // PythonGILState
#include <memory>
#include <pybind11/pytypes.h>
#include <pybind11/cast.h>
#include <pybind11/pybind11.h>
@@ -12,8 +12,6 @@
#include <libslic3r/Preset.hpp>
#include <libslic3r/PresetBundle.hpp>
#include <memory>
#include <slic3r/GUI/GUI.hpp>
#include <slic3r/GUI/GUI_App.hpp>
#include <slic3r/GUI/MainFrame.hpp>
@@ -25,7 +23,6 @@
#include <wx/string.h>
#include <functional>
#include <set>
#include <stdexcept>
#include <string>
@@ -83,42 +80,13 @@ PresetCollection& collection_for(PresetBundle& bundle, Preset::Type type)
}
// 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.
// std::runtime_error (pybind11 maps the base to RuntimeError), so callers see it as an ordinary
// failed transaction and no staged setting or slot assignment is created.
struct SelectionCancelled : std::runtime_error
{
using std::runtime_error::runtime_error;
};
// Marker for a selection the awaiting coroutine's cancellation overtook: the Future was cancelled
// (task cancellation, or plugin unload) while the selection dialog was open. The user's already
// accepted decision is not reversed; only the remaining slot assignment and the transaction
// creation are skipped. Distinct from SelectionCancelled, which is the user declining.
struct SelectionAbandoned : std::runtime_error
{
using std::runtime_error::runtime_error;
};
// True once the awaiting coroutine's Future has been cancelled. AsyncFuture sets this from its
// loop-thread done-callback, so this reads only an atomic and never touches Python or the loop from
// the UI thread. Empty for the synchronous path, which cannot be cancelled.
using CancelProbe = std::function<bool()>;
// One PresetSelectionCancelled carrying `message`. The type is looked up live from the bound module
// rather than cached in a static PyObject*: a cached reference would dangle across an interpreter
// reset (plugin-system shutdown then re-init in one process). GIL held by the caller. Falls back to
// RuntimeError when the binding is not present.
py::object selection_cancelled_error(const std::string& message)
{
try {
return py::module_::import("orca").attr("host").attr("preset").attr("PresetSelectionCancelled")(message);
} catch (py::error_already_set&) {
PyErr_Clear();
return py::module_::import("builtins").attr("RuntimeError")(message);
}
}
// --------------------------------------------------------------------------
// The staged preset update lifecycle described in docs/HLSD/preset-plugin-api.md.
//
@@ -126,19 +94,15 @@ py::object selection_cancelled_error(const std::string& message)
// 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.
// between begin_update() and commit() 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.
// begin_update() is synchronous: it runs on the background Script worker, marshals the target
// selection to the UI thread with PluginHostUi::run_on_ui_thread(), and blocks that worker until
// the selection and any unsaved-edit dialog settle. The UI thread stays responsive because the
// Script capability owns its worker thread. No GUI or preset pointer outlives the call; the
// returned transaction holds copied category/name/slot values. The update() shorthand shares the
// same target resolution and 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
@@ -156,16 +120,9 @@ py::object selection_cancelled_error(const std::string& message)
// 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.
// * A user-declined selection does not undo decisions the user already accepted in an earlier
// dialog. It aborts before any slot assignment or transaction is created, so no staged
// setting is applied.
// --------------------------------------------------------------------------
// Outcome of one committed transaction. Identifies the target, whether selecting it switched
@@ -400,11 +357,8 @@ PresetUpdateResult PresetTransaction::commit()
// 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 = {})
// Runs on the UI thread with the GIL released, called from begin_preset_update().
void bind_preset_target(Preset::Type type, const std::string& preset_name, PresetTransaction& transaction)
{
GUI::GUI_App& app = require_gui_app();
GUI::Tab* tab = require_tab(app, type);
@@ -437,11 +391,6 @@ void bind_preset_target(Preset::Type type, const std::string& preset_name, Prese
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;
}
}
@@ -458,7 +407,7 @@ void bind_preset_target(Preset::Type type, const std::string& preset_name, Prese
transaction.selection_changed = selection_changed;
}
// Blocking form, used by update() and by any caller that cannot suspend.
// Blocking form used by begin_update() and update().
PresetTransaction begin_preset_update(Preset::Type type, const std::string& preset_name)
{
PresetTransaction transaction;
@@ -476,9 +425,8 @@ PresetTransaction begin_preset_update(Preset::Type type, const std::string& pres
// 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 = {})
// Runs on the UI thread with the GIL released, called from begin_filament_update().
void bind_filament_target(int slot_number, const std::string& preset_name, PresetTransaction& transaction)
{
GUI::GUI_App& app = require_gui_app();
PresetBundle& bundle = *app.preset_bundle;
@@ -558,14 +506,6 @@ void bind_filament_target(int slot_number, const std::string& preset_name, Prese
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");
@@ -574,10 +514,6 @@ void bind_filament_target(int slot_number, const std::string& preset_name, Prese
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
@@ -629,7 +565,7 @@ void bind_filament_target(int slot_number, const std::string& preset_name, Prese
transaction.slot_assignment_snapshot = resolved_slot_preset(bundle, slot_number);
}
// Blocking form, used by the update() shorthand.
// Blocking form used by begin_update() and update().
PresetTransaction begin_filament_update(int slot_number, const std::string& preset_name)
{
PresetTransaction transaction;
@@ -653,111 +589,6 @@ void update_filament_slot(int slot_number, const std::string& key, const std::st
transaction.commit();
}
// --------------------------------------------------------------------------
// Awaitable begin_update(): see the lifecycle comment above.
//
// Each entry point runs on the plugin worker with the GIL held and an asyncio loop running. It
// validates the application synchronously (so a call without a wx app fails exactly like update()),
// creates a Future on the running loop, posts the selection to the UI thread, and returns the
// Future at once. The UI callback runs bind_*_target() with the GIL released, then -- with the GIL
// re-acquired -- resolves the Future with the resulting PresetTransaction, or fails it with the
// resolution error. Nothing is staged until the caller's later set()/commit(); a canceled or
// unavailable target changes no preset and maps no slot.
// --------------------------------------------------------------------------
// Map the failures bind_*_target() raises to the Python exceptions the awaitable path should
// surface. A user cancellation becomes the distinct PresetSelectionCancelled (not a plain
// RuntimeError, and not asyncio.CancelledError, which is what a cancelled task raises and is
// dropped by the future resolver before it ever reaches the coroutine). The rest mirror what
// pybind11 would raise for the blocking path (std::invalid_argument -> ValueError,
// std::out_of_range -> IndexError).
py::object preset_update_failure(const std::exception& error)
{
if (dynamic_cast<const SelectionCancelled*>(&error) != nullptr)
return selection_cancelled_error(error.what());
if (dynamic_cast<const std::invalid_argument*>(&error) != nullptr)
return py::module_::import("builtins").attr("ValueError")(error.what());
if (dynamic_cast<const std::out_of_range*>(&error) != nullptr)
return py::module_::import("builtins").attr("IndexError")(error.what());
return py::module_::import("builtins").attr("RuntimeError")(error.what());
}
// Fail an awaitable whose UI work could not be posted at all (the application went away between the
// synchronous check and post_to_ui_thread()). Called with the GIL held.
void fail_unpostable(const std::shared_ptr<AsyncFuture>& handle, const char* message)
{
handle->fail(py::module_::import("builtins").attr("RuntimeError")(message));
}
py::object begin_preset_update_async(Preset::Type type, const std::string& preset_name)
{
PluginHostUi::require_ui_application();
std::shared_ptr<AsyncFuture> handle = AsyncFuture::create();
PluginHostUi::track_pending_async(handle);
py::object future = handle->future();
const bool posted = PluginHostUi::post_to_ui_thread([type, preset_name, handle]() {
// The plugin was unloaded, or the task was cancelled, before this ran: do not select. This
// reads the Future's own cancelled() (not only the atomic), so a cancel Future.cancel()
// already applied is seen even before the loop runs its done-callback.
if (handle->is_cancelled())
return;
PresetTransaction transaction;
try {
bind_preset_target(type, preset_name, transaction, [handle]() { return handle->is_cancelled(); });
} catch (const std::exception& error) {
PythonGILState gil;
if (gil)
handle->fail(preset_update_failure(error));
return;
}
// Cancelled after the bind but before delivery: the resolver would drop the result anyway.
if (handle->is_cancelled())
return;
PythonGILState gil;
if (gil)
handle->complete(py::cast(transaction));
});
if (!posted)
fail_unpostable(handle, "OrcaSlicer application is not initialized");
return future;
}
py::object begin_filament_update_async(int slot_number, const std::string& preset_name)
{
PluginHostUi::require_ui_application();
std::shared_ptr<AsyncFuture> handle = AsyncFuture::create();
PluginHostUi::track_pending_async(handle);
py::object future = handle->future();
const bool posted = PluginHostUi::post_to_ui_thread([slot_number, preset_name, handle]() {
// The plugin was unloaded, or the task was cancelled, before this ran: do not select, so a
// queued UI selection never starts. This reads the Future's own cancelled() (not only the
// atomic), so a cancel Future.cancel() already applied is seen even before the loop runs its
// done-callback.
if (handle->is_cancelled())
return;
PresetTransaction transaction;
try {
bind_filament_target(slot_number, preset_name, transaction, [handle]() { return handle->is_cancelled(); });
} catch (const std::exception& error) {
PythonGILState gil;
if (gil)
handle->fail(preset_update_failure(error));
return;
}
// Cancelled after the bind but before delivery: the resolver would drop the result anyway.
if (handle->is_cancelled())
return;
PythonGILState gil;
if (gil)
handle->complete(py::cast(transaction));
});
if (!posted)
fail_unpostable(handle, "OrcaSlicer application is not initialized");
return future;
}
void reload_local_bundle(const std::string& bundle_id)
{
bool reloaded = false;
@@ -818,19 +649,6 @@ void host_bindings::register_presets(py::module_& host)
"IDs of local preset bundles in the active user's _local directory that reload_local() accepts, "
"including bundles added after startup.");
// The outcome of a user-declined selection in the awaitable begin_update(), told apart from an
// invalid/unavailable target (ValueError) and from asyncio task cancellation
// (asyncio.CancelledError). It subclasses RuntimeError, so a plugin catching RuntimeError still
// handles it, and the synchronous update() path is unchanged (it raises RuntimeError). The type
// is owned by the module (and looked up live when raised), not cached in a static reference, so
// a later interpreter reset cannot leave a stale pointer behind.
py::dict selection_cancelled_ns;
selection_cancelled_ns["__module__"] = py::str("orca.host.preset");
preset.attr("PresetSelectionCancelled") = py::module_::import("builtins").attr("type")(
"PresetSelectionCancelled",
py::make_tuple(py::reinterpret_borrow<py::object>(PyExc_RuntimeError)),
selection_cancelled_ns);
py::enum_<Preset::Type>(host, "PresetType")
.value("Invalid", Preset::TYPE_INVALID)
.value("Print", Preset::TYPE_PRINT)
@@ -865,12 +683,13 @@ void host_bindings::register_presets(py::module_& host)
});
py::class_<PresetTransaction>(preset, "PresetTransaction",
"A staged, single-target preset edit returned by awaiting begin_update(). set() stages "
"a serialized value without mutating the preset; commit() validates and applies the "
"whole batch through the normal setting-change path. begin_update() itself suspends "
"until any selection or unsaved-edit dialog is answered, without blocking the UI "
"thread; set() and commit() run synchronously on the plugin worker. Nothing is saved "
"to disk: the edited preset stays dirty until the user saves it.")
"A staged, single-target preset edit returned by 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() blocks the "
"background Script worker until any selection or unsaved-edit dialog is answered, "
"while the UI thread stays responsive; set() and commit() run synchronously on the "
"same 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)
@@ -907,17 +726,16 @@ void host_bindings::register_presets(py::module_& host)
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);
return begin_preset_update(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 "
"Resolve and bind this category's target preset and return 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 call blocks "
"the background Script worker until any such dialog is answered, leaving the UI thread responsive. A "
"selection the user cancels raises RuntimeError; an unavailable or invalid target raises ValueError. "
"Either failure creates no transaction and stages no setting. Call it from the background Script worker, "
"never from the UI thread. This form is for process and printer; filament takes "
"begin_update(slot_number, preset_name=\"\").")
.def(
"begin_update",
@@ -925,22 +743,21 @@ void host_bindings::register_presets(py::module_& host)
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);
return begin_filament_update(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.")
"Resolve and bind a filament preset by 1-based slot and return 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 "
"RuntimeError 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. The call "
"blocks the background Script worker until the selection settles, leaving the UI thread responsive; call "
"it from that worker, never from the UI thread.")
.def(
"update",
[](const PresetEditor& editor, const std::string& key, const std::string& value) {
@@ -953,8 +770,8 @@ void host_bindings::register_presets(py::module_& host)
"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. "
"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.")
"Equivalent to begin_update() with the active preset as target, then set(key, value) "
"and commit(); like begin_update() this call blocks the background Script worker.")
.def(
"update",
[](const PresetEditor& editor, int slot_number, const std::string& key, const std::string& value) {
@@ -970,9 +787,9 @@ void host_bindings::register_presets(py::module_& host)
"call with an error and no change. An out-of-range, empty or unresolved slot is rejected with "
"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.");
"ValueError on them. Equivalent to begin_update(slot_number) with an omitted preset_name "
"(the slot's current assignment is kept), then set(key, value) and commit(); like "
"begin_update() this call blocks the background Script worker.");
// The three preset categories edited by the main tabs. `process` is the print/process preset,
// `printer` the machine preset and `filament` the currently edited filament preset (its
-195
View File
@@ -143,38 +143,6 @@ std::function<void()> make_close_adapter(const CallablePtr& holder)
};
}
// The Python-side resolver scheduled onto a plugin loop by AsyncFuture. It runs on the loop's own
// thread with the GIL held. The cancelled() check drops the outcome of a coroutine whose task was
// cancelled while the UI dialog was open; set_result/set_exception on a cancelled Future would
// otherwise raise InvalidStateError on the loop.
py::object make_future_resolver()
{
return py::cpp_function([](py::object future, py::object value, bool is_error) {
if (future.attr("cancelled")().cast<bool>())
return;
if (is_error)
future.attr("set_exception")(value);
else
future.attr("set_result")(value);
});
}
// A done-callback on the awaitable's Future, run by the loop. If the Future was cancelled (the
// awaiting task was cancelled, e.g. ScriptAsyncRuntime::cancel_plugin, or the coroutine cancelled
// itself), it marks the AsyncFuture abandoned. That is what lets the UI thread -- which must never
// touch the loop or the Future -- notice cancellation atomically, so a queued selection does not
// start and one already inside a modal stops when the modal returns. The weak reference keeps the
// callback from owning the AsyncFuture.
py::object make_cancellation_watcher(const std::weak_ptr<AsyncFuture>& weak_handle)
{
return py::cpp_function([weak_handle](py::object future) {
if (future.attr("cancelled")().cast<bool>()) {
if (auto handle = weak_handle.lock())
handle->abandon();
}
});
}
// --------------------------------------------------------------------------
// Registry of live plugin UI resources. Keyed by an opaque id; tracks the
// owning plugin so all of a plugin's UI can be torn down on unload.
@@ -261,66 +229,11 @@ public:
return callbacks;
}
// Awaitables posted to the UI thread from a plugin worker, keyed by the plugin. Held weakly so
// the registration never keeps a finished operation (or its Python loop/Future) alive.
void track_async(const std::shared_ptr<AsyncFuture>& handle, const std::string& plugin_key)
{
if (!handle)
return;
std::lock_guard<std::mutex> lk(m_mtx);
auto& weaks = m_async[plugin_key];
// Prune registrations whose UI callback already returned, so the list stays bounded.
weaks.erase(std::remove_if(weaks.begin(), weaks.end(),
[](const std::weak_ptr<AsyncFuture>& weak) { return weak.expired(); }),
weaks.end());
weaks.push_back(handle);
}
void abandon_async(const std::string& plugin_key)
{
std::vector<std::shared_ptr<AsyncFuture>> pending;
{
std::lock_guard<std::mutex> lk(m_mtx);
auto it = m_async.find(plugin_key);
if (it == m_async.end())
return;
for (const std::weak_ptr<AsyncFuture>& weak : it->second) {
if (auto handle = weak.lock())
pending.push_back(std::move(handle));
}
// The registrations are intentionally kept: pending_async_count() must still see the
// in-flight UI callback so PluginManager can drain it. They are pruned once the weak
// reference expires (the callback returned).
}
// abandon() is GIL-free and thread-safe: it only stops a later completion from touching the
// unloaded plugin's loop.
for (const std::shared_ptr<AsyncFuture>& handle : pending)
handle->abandon();
}
// Live registrations for plugin_key: their UI callback has been posted but has not returned.
// Expired weak references are pruned so the count reaches 0 when the last callback finishes.
std::size_t pending_async_count(const std::string& plugin_key)
{
std::lock_guard<std::mutex> lk(m_mtx);
auto it = m_async.find(plugin_key);
if (it == m_async.end())
return 0;
auto& weaks = it->second;
weaks.erase(std::remove_if(weaks.begin(), weaks.end(),
[](const std::weak_ptr<AsyncFuture>& weak) { return weak.expired(); }),
weaks.end());
const std::size_t count = weaks.size();
if (count == 0)
m_async.erase(it);
return count;
}
private:
std::mutex m_mtx;
std::unordered_map<int, wxWindow*> m_resources;
std::unordered_map<int, std::string> m_owners;
std::unordered_map<std::string, std::vector<std::weak_ptr<GilSafeCallable>>> m_callbacks;
std::unordered_map<std::string, std::vector<std::weak_ptr<AsyncFuture>>> m_async;
int m_next_id{1};
};
@@ -733,115 +646,11 @@ void plater_notification(GUI::NotificationManager::NotificationLevel notificatio
} // namespace
// --------------------------------------------------------------------------
// AsyncFuture: the awaitable suspension primitive (see PluginHostUi.hpp).
// --------------------------------------------------------------------------
std::shared_ptr<AsyncFuture> AsyncFuture::create()
{
auto handle = std::shared_ptr<AsyncFuture>(new AsyncFuture());
py::object loop = py::module_::import("asyncio").attr("get_running_loop")();
handle->m_loop = loop;
handle->m_future = loop.attr("create_future")();
handle->m_bind = make_future_resolver();
// Watch for task/Future cancellation on the loop, so the host UI thread sees it as an atomic.
handle->m_future.attr("add_done_callback")(make_cancellation_watcher(handle));
return handle;
}
void AsyncFuture::complete(py::object value)
{
if (m_abandoned.load(std::memory_order_acquire))
return;
try {
m_loop.attr("call_soon_threadsafe")(m_bind, m_future, std::move(value), py::bool_(false));
} catch (py::error_already_set& error) {
// The loop closed before delivery (plugin teardown or shutdown). The awaiting task is gone
// with it, so the outcome is dropped.
BOOST_LOG_TRIVIAL(debug) << "orca.host awaitable result was not delivered: " << error.what();
PyErr_Clear();
}
}
void AsyncFuture::fail(py::object exc)
{
if (m_abandoned.load(std::memory_order_acquire))
return;
try {
m_loop.attr("call_soon_threadsafe")(m_bind, m_future, std::move(exc), py::bool_(true));
} catch (py::error_already_set& error) {
BOOST_LOG_TRIVIAL(debug) << "orca.host awaitable error was not delivered: " << error.what();
PyErr_Clear();
}
}
bool AsyncFuture::is_cancelled() const
{
if (m_abandoned.load(std::memory_order_acquire))
return true;
// Future.cancel() flips the Future's state synchronously on the loop thread, while the
// done-callback that sets the atomic is only scheduled afterwards; reading the Future's own
// state under the GIL closes that window for a caller that must decide before it starts or
// before it maps a slot. cancelled() is a plain flag read and never mutates the Future.
PythonGILState gil;
if (!gil || !m_future)
return false;
try {
return m_future.attr("cancelled")().cast<bool>();
} catch (py::error_already_set&) {
PyErr_Clear();
return false;
}
}
AsyncFuture::~AsyncFuture()
{
PythonGILState gil;
if (gil) {
m_bind = py::object();
m_future = py::object();
m_loop = py::object();
} else {
// Interpreter is gone; leaking is the only safe option (mirrors the UI callable holders).
(void) m_bind.release();
(void) m_future.release();
(void) m_loop.release();
}
}
void PluginHostUi::run_on_ui_thread(std::function<void()> fn)
{
run_on_ui_blocking(std::move(fn));
}
void PluginHostUi::require_ui_application()
{
if (wxTheApp == nullptr)
throw std::runtime_error("OrcaSlicer application is not initialized");
}
bool PluginHostUi::post_to_ui_thread(std::function<void()> fn)
{
if (wxTheApp == nullptr)
return false;
GUI::wxGetApp().CallAfter(std::move(fn));
return true;
}
void PluginHostUi::track_pending_async(const std::shared_ptr<AsyncFuture>& handle)
{
UiRegistry::instance().track_async(handle, PluginAuditManager::instance().current_plugin());
}
void PluginHostUi::abandon_pending_async(const std::string& plugin_key)
{
UiRegistry::instance().abandon_async(plugin_key);
}
std::size_t PluginHostUi::pending_async_count(const std::string& plugin_key)
{
return UiRegistry::instance().pending_async_count(plugin_key);
}
void PluginHostUi::RegisterBindings(pybind11::module_& host)
{
auto ui = host.def_submodule(
@@ -972,10 +781,6 @@ void PluginHostUi::close_windows_for_plugin(const std::string& plugin_key)
return;
auto teardown = [plugin_key]() {
// Stop resolving any awaitable still pending for this plugin before its windows go: a UI
// callback that runs after unload must not touch the unloaded plugin's loop.
UiRegistry::instance().abandon_async(plugin_key);
for (auto& callback : UiRegistry::instance().take_callbacks_for_plugin(plugin_key))
callback->disable();
-83
View File
@@ -2,70 +2,11 @@
#include <pybind11/pybind11.h>
#include <atomic>
#include <cstddef>
#include <functional>
#include <memory>
#include <string>
namespace Slic3r {
// One awaitable pending on a plugin worker's running asyncio loop, completed from the UI thread.
//
// This is the suspension primitive for host calls that must show a dialog before they can resolve
// (the awaitable preset begin_update(); see docs/HLSD/preset-plugin-api.md and the async-bridge
// architecture gate). It carries no GUI or preset state: only the Python loop, the Future created
// on it, and a Python-side resolver.
//
// Threading contract:
// * create() runs on the plugin worker with the GIL held and an asyncio loop running; it binds
// that loop and creates the Future, which is the awaitable handed back to Python.
// * The host posts the UI work with PluginHostUi::post_to_ui_thread(); the UI callback then calls
// complete()/fail() with the GIL held. Both marshal the outcome through
// loop.call_soon_threadsafe, so the Future is resolved on its own loop thread, never from the
// UI thread.
// * A Future that a cancelled task or a closed loop already discarded is dropped silently, so a
// late UI result cannot corrupt or resurrect a cancelled coroutine.
// * abandon() stops delivery without touching Python, for lifecycle teardown that must not wait.
// Python references are always released under the GIL, from whichever thread destroys the
// handle; if the interpreter is already gone the references are intentionally leaked rather
// than decref'd unsafely (same rule as the UI callable holders).
class AsyncFuture
{
public:
// Requires the GIL and a running asyncio loop (pybind11::error_already_set otherwise).
static std::shared_ptr<AsyncFuture> create();
// The Python Future to return as the awaitable. Requires the GIL.
pybind11::object future() const { return m_future; }
// Resolve the awaitable. Requires the GIL. complete() sets the result, fail() raises `exc`.
void complete(pybind11::object value);
void fail(pybind11::object exc);
// Stop delivery (thread-safe, GIL-free) and let a host UI callback notice it must not start or
// continue. Set when the owning plugin unloads, and by the loop-thread done-callback when the
// Future is cancelled. Idempotent.
void abandon() { m_abandoned.store(true, std::memory_order_release); }
bool abandoned() const { return m_abandoned.load(std::memory_order_acquire); }
// True once the awaitable has been cancelled or abandoned. Unlike abandoned(), this also reads
// the Python Future's own cancelled() under the GIL, so it observes a cancellation that
// Future.cancel() applied synchronously even before the loop runs the done-callback that sets
// the atomic. It never mutates the Future or the loop. Safe to call from the UI thread.
bool is_cancelled() const;
~AsyncFuture();
private:
AsyncFuture() = default;
pybind11::object m_loop;
pybind11::object m_future;
pybind11::object m_bind;
std::atomic<bool> m_abandoned{false};
};
// Binds the `orca.host.ui` submodule: native message boxes, progress dialogs,
// and interactive HTML windows/dialogs for plugins. All calls run on the main/UI
// thread (marshaled from the plugin worker thread) and the host owns every window.
@@ -81,30 +22,6 @@ public:
static void run_on_ui_thread(std::function<void()> fn);
// Fail synchronously (std::runtime_error) when there is no wx application, matching
// run_on_ui_thread()'s first check, so an awaitable entry point can reject before it touches
// asyncio.
static void require_ui_application();
// Post `fn` to run on the main/UI thread without blocking the caller. `fn` runs with the GIL
// released and must touch only C++/wx state and host-owned handles. Returns false (and drops
// `fn`) when the application is not initialized.
static bool post_to_ui_thread(std::function<void()> fn);
// Lifecycle tracking for awaitables posted from a plugin worker. track_pending_async() keys the
// handle by the current plugin; the registration holds only a weak reference and expires when
// the UI operation finishes. abandon_pending_async() (plugin unload / draining) stops every
// still-pending awaitable for the plugin from resolving into an unloaded loop.
static void track_pending_async(const std::shared_ptr<AsyncFuture>& handle);
static void abandon_pending_async(const std::string& plugin_key);
// Number of plugin-worker awaitables for plugin_key whose UI work has been posted but has not
// finished yet. Expired registrations are pruned, so this reaches 0 once every posted UI
// callback has returned. PluginManager drains on this before unloading a plugin's module: a
// Tab::select_preset dialog accepted after that would otherwise mutate presets after unload.
// GIL-free; safe from any thread.
static std::size_t pending_async_count(const std::string& plugin_key);
// Lifecycle hook: close and tear down every UI window owned by a plugin. PluginManager invokes
// this after plugin teardown and also for bulk unload during application shutdown.
static void close_windows_for_plugin(const std::string& plugin_key);
@@ -227,12 +227,9 @@ TEST_CASE("Plugin host API exposes staged preset update transactions", "[PluginH
py::object preset = import_orca_module().attr("host").attr("preset");
py::object transaction_type = preset.attr("PresetTransaction");
py::object result_type = preset.attr("PresetUpdateResult");
py::object selection_cancelled = preset.attr("PresetSelectionCancelled");
py::object runtime_error = py::module_::import("builtins").attr("RuntimeError");
CHECK(has_attr(transaction_type, "set"));
CHECK(has_attr(transaction_type, "commit"));
CHECK(py::module_::import("builtins").attr("issubclass")(selection_cancelled, runtime_error).cast<bool>());
CHECK(has_attr(result_type, "target_name"));
CHECK(has_attr(result_type, "selection_changed"));
CHECK(has_attr(result_type, "slot_assignment_changed"));