mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-17 20:57:26 +00:00
fix: remove async behavior and clang-tidy
This commit is contained in:
9 files changed
+176
-715
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in new issue
Block a user