76 KiB
Threads, timers and the application lifecycle
The GUI-thread rule and every way OrcaSlicer moves work between threads (CallAfter,
wxQueueEvent, wxThreadEvent, the Jobs framework, BackgroundSlicingProcess, network-agent
callbacks), worker-thread exception safety, wxTimer, when queued and idle work actually runs,
yields and nested event loops, progress dialogs, startup, shutdown and exception policy, the splash
screen, and the access rules for wxGetApp() and app_config. Read it whenever code runs off the
main thread, defers work, starts a timer, yields, shows progress, or runs during startup or shutdown.
wx cites are relative to the pinned wx 3.3.2 tree (located as in SKILL.md §Ground truth).
Orca builds wx with wxBUILD_DEBUG_LEVEL=0 and libslic3r_gui with wxDEBUG_LEVEL=0, so wxASSERT
is compiled out and wxCHECK* returns silently: a timer started off the main thread never fires on macOS,
Exit() on a loop that is not the active one is ignored, Start(0) on macOS fails — all without
a diagnostic. [source] marks behaviour derived from the implementation rather than the docs.
The generic CallAfter mechanics and the liveness rule for deferred lambdas on the main thread
live in references/events.md §CallAfter; this file owns the cross-thread side.
Contents: Rules · The GUI-thread rule · Marshaling to the GUI thread · Liveness of deferred work · Blocking round-trips · Worker threads: lifetime and exceptions · The Jobs framework · BackgroundSlicingProcess · wxTimer · When queued and idle work runs · Yields and nested event loops · Progress and busy indicators · Application lifecycle · Exceptions in the main loop · Splash screen · wxGetApp() and app_config
Rules
- Apart from
CallAfter/QueueEventandwxLog, call wx only on the main thread: windows,wxBitmap/wxImagecopies, timers, busy cursors,wxExecute, yields,Bind/Unbind,wxWeakRef. → GUI-thread rule - From a worker, marshal with
wxGetApp().CallAfter([by-value captures]{…}), orwxQueueEvent(handler, new Evt…)to a handler whose lifetime strictly contains the thread's. Never post from a worker to a window that can be destroyed while the thread runs. → Marshaling - Never use
wxPostEvent/AddPendingEventfrom a worker. → Marshaling - Re-check liveness inside every deferred lambda (
is_closing()for app-level state, an alive flag for a window); a check on the worker side is only an early-out. → Liveness - A worker blocked on a UI round-trip must be cancellable from the UI side, and the UI must never wait for that worker except through an API that pumps the round-trip. → Blocking round-trips
- Catch everything at the top of every
std::thread/boost::threadbody; useerror_codefilesystem calls on workers. → Worker threads - Stop or join workers before
OnExitreturns; after wx cleanup starts,wxGetApp()dereferences null. → Worker threads - UI-initiated background work is a
JobonPlater::get_ui_job_worker()(or a dialog-ownedPlaterWorker): UI only infinalize/call_on_main_thread, pollwas_canceled(), never apply partial results, handleeptr. → Jobs - Drop queued jobs with
cancel_all()/replace_job();cancel()only flags the running job. → Jobs - A
wxTimermust not outlive the handler it notifies; prefer a member timer. → wxTimer - Bind
wxEVT_TIMERwith the timer's id, on the timer itself, or overrideNotify(); never rely on an owner-wide bind when the owner has two timers. → wxTimer - Never start a timer with 0 ms (macOS rejects it); for "right after this handler" use
CallAfter. → wxTimer - Don't throw from
wxTimer::Notify(). → wxTimer - Don't feed long UI-thread work through self-reposting
CallAfterchains orRequestMore()loops; slice it withIdleScheduleror a timer. → When queued and idle work runs - Expect
CallAfterbodies, timers and (category permitting) worker events to run inside everyShowModal(),wxYield()and progress-dialogUpdate()/Pulse()(on GTK a progress-dialog yield runs timers but no queued event). → Yields - No
wxYield()in handlers to "let the UI update" — move the work off the main thread. If a yield is unavoidable, create thewxWindowDisablerbefore yielding. → Yields - Exit a nested
wxEventLoopfrom deferred code withScheduleExit();Exit()andExitMainLoop()are silent no-ops while another loop is stacked on top. → Nested loops wxProgressDialog: usewxPD_AUTO_HIDEwhen closing it programmatically; onUpdate()==falsedestroy it orResume(); don't start one-shot timers before showing it on MSW. → Progress- No exception may escape an event handler,
CallAfterbody, timer handler orJob::finalize: Orca'sOnExceptionInMainLooprethrows and the session ends. → Exceptions - Never touch windows in
OnExitor theGUI_Appdestructor; stop timers, workers and callbacks inMainFrame::shutdown/GUI_App::shutdown. → Shutdown - Code reachable during shutdown or
recreate_GUIchecks!wxTheApp || wxGetApp().is_closing()and nullplater()/mainframe;is_closing()stays false duringrecreate_GUI. → wxGetApp() and app_config AppConfigis main-thread only;save()throws on a worker. → wxGetApp() and app_config- A splash that stays up across startup work overrides
FilterEventand is held in awxWeakRef. → Splash screen
The GUI-thread rule
Contract. "GUI calls, such as those to a wxWindow or wxBitmap are explicitly not safe at all in
secondary threads … the recommended way is to simply process the GUI calls in the main thread
through an event that is posted by wxQueueEvent(). This does not imply that calls to these classes
are thread-safe, however, as most wxWidgets classes are not thread-safe, including wxString"
(interface/wx/thread.h:967-978). The GUI thread is the one in which wx was initialised, where
wxIsMainThread() returns true (docs/doxygen/overviews/thread.h:52-59; interface/wx/thread.h:1806).
New code should use the C++ concurrency classes rather than the wx ones; the GUI rule still applies
(docs/doxygen/overviews/thread.h:19-21).
"GUI" is wider than windows:
| Main-thread only | Why |
|---|---|
Window create/show/destroy, SetLabel, Refresh, layout, dialogs |
documented rule; on GTK no TLW creation off the main thread (interface/wx/thread.h:1837) |
wxTimer start/stop |
"A timer can only be used from the main thread" (interface/wx/timer.h:41) |
wxExecute |
"can only be used from the main thread" (interface/wx/utils.h:1250); see references/strings-i18n-files.md |
wxBeginBusyCursor/wxBusyCursor, wxYield/YieldFor |
GUI calls; YieldFor off the main thread returns false and does nothing [source] (src/common/evtloopcmn.cpp:128-136) |
Copies of wxImage/wxBitmap/wxFont (lambda capture, SetPayload) |
wxRefCounter::IncRef is a plain m_count++ [source] (include/wx/object.h:166) |
wxWeakRef create/copy/test, Bind/Unbind |
wxTrackable's tracker list is an unlocked linked list (include/wx/tracker.h:40-50); DoBind takes no lock [source] |
wxMutexGuiEnter/Leave "only works under some ports (wxMSW currently)" (interface/wx/thread.h:1834-1838)
and the threading overview deliberately leaves them out as unsafe ("we explicitly don't name wxMutexGUIEnter() and
wxMutexGUILeave() as they're not safe. See also ticket #10366", docs/doxygen/overviews/thread.h:8-11).
Never use them.
wxLog calls are allowed from workers: messages logged from other threads are buffered until
wxLog::Flush() runs on the main thread, and may appear out of order
(docs/doxygen/overviews/log.h:228-237). Orca code logs with BOOST_LOG_TRIVIAL, which is thread-safe.
OrcaSlicer.
- Threads that reach the GUI: network agents and printer agents,
Http::perform()callbacks (run on the request's ownstd::thread), the slicing thread (BackgroundSlicingProcess), the Jobs worker, the preset-sync and preset-updater threads, ad-hocstd::threads (plugin dialogs, texture compression, 3Dconnexion HID), and TBB parallel loops (which must make no wx calls at all). - Main-thread test:
Slic3r::is_main_thread_active()(libslic3r/Thread.hpp; the id is recorded bysave_main_thread_id()at startup). Code callable from both sides uses "run now if main, elseCallAfter":if (is_main_thread_active()) app_config->save(); else CallAfter([this] { app_config->save(); }); // GUI_App::load_pending_vendors - Threads are created with
Slic3r::create_thread(attrs, fn)(libslic3r/Thread.hpp, 16 MB stack, because deep CGAL recursion in the emboss path overflowed 4 MB) and named withset_thread_name. - Long work never runs in an event handler. UI-initiated work goes to the Jobs framework; slicing
and export to
BackgroundSlicingProcess; other background work to a worker thread that reports back through the mechanisms below.
Pitfalls.
- Rule: Never build or copy a
wxImage/wxBitmapon a worker and hand it to the UI thread. Why: wx makes no thread-safety promise for them and their ref-count is non-atomic; a copy in a lambda capture races the UI-side copy. Ship raw bytes and decode on the UI thread.Cite:// Wrong (Http thread) wxImage img; img.LoadFile(stream); wxGetApp().CallAfter([img] { show(img); }); // Right wxGetApp().CallAfter([alive, body = std::move(body)] { if (!alive->load()) return; wxMemoryInputStream s(body.data(), body.size()); wxImage img(s); show(img); });DeviceErrorDialog::get_fail_snapshot_from_cloud(its comment states this rule). The camera decoder (wxMediaCtrl3) deliberately builds frames on its worker; it hands them over only throughm_frame, which both threads touch underm_mutex, never through a capture or an event payload —references/webview-gl-aui-media.md§Camera and media.
Marshaling to the GUI thread
| Mechanism | From a worker | Target lifetime requirement | Use for |
|---|---|---|---|
wxGetApp().CallAfter(fn) / wxTheApp->CallAfter(fn) |
yes | app lives until wx cleanup; re-check every captured pointer inside | arbitrary code; the Orca default |
wxQueueEvent(h, new Evt(…)) / h->QueueEvent(…) |
yes | h must outlive the posting thread |
typed results to a known long-lived handler |
wxQueueEvent(h, threadEvt.Clone()) with wxThreadEvent |
yes | same | results that must not run inside masked yields |
Job::Ctl::update_status / call_on_main_thread |
worker of a Worker |
the Worker (joined in its dtor) |
UI-initiated jobs |
BackgroundSlicingProcess::execute_ui_task |
slicing thread | plater (BSP thread joined first) | synchronous UI call with cancellation |
window->CallAfter(fn), wxPostEvent, AddPendingEvent |
no (except window->CallAfter when the thread is joined before the window dies) |
— | main-thread → main-thread deferral |
CallAfter from a worker
Contract. "It is safe to use CallAfter() from other, non-GUI, threads, but that the method will be
always called in the main, GUI, thread context." The method form must name a method of the object
CallAfter is called on; the functor form is meant for lambdas (interface/wx/event.h:491-564).
Its documented purpose is deferring work that can't run in the current handler — "you shouldn't show
a modal dialog from a mouse click event handler as this would break the mouse capture state".
Mechanism [source].
CallAfter(fn)isQueueEvent(new wxAsyncMethodCallEventFunctor<T>(this, fn))on the object it is called on (include/wx/event.h:3852-3855).- The functor is copied (
m_fn(fn),include/wx/event.h:1554-1569): a lambda capturing astd::unique_ptrdoes not compile. Capture astd::shared_ptr. - It runs through
SafelyProcessEvent, sowxApp::FilterEventsees it and an exception goes toOnExceptionInMainLoop(Exceptions). - Its event keeps the default
wxEVT_CATEGORY_UI(include/wx/event.h:1014), so on MSW and macOS the body runs inside every progress-dialogUpdate()/Pulse()and everyYieldFor(wxEVT_CATEGORY_UI…); on GTK a masked yield runs no queued event at all, so it waits until the yield returns (event categories). - Target choice. A
window->CallAfterqueued from the main thread is dropped if the window dies first, because~wxEvtHandlercallsDeletePendingEvents()(src/common/event.cpp:1234-1238). From a worker the same call is a race:QueueEventappends underm_pendingEventsLock, butDeletePendingEvents()runs without it (src/common/event.cpp:1319-1366), and a window freed before the call is plain use-after-free. Post to the app, which outlives every window. QueueEventwithwxTheApp == nullptrdeletes the event silently (src/common/event.cpp:1323-1333), butwxGetApp()is*static_cast<GUI_App*>(wxApp::GetInstance())(include/wx/app.h:941): afterSetInstance(nullptr)in wx cleanup it dereferences null. Workers must be stopped before that.
Usage.
// worker thread (agent / Http / std::thread)
if (!wxTheApp || wxGetApp().is_closing()) return; // early-out only
wxGetApp().CallAfter([this, dev_id, msg] { // copies, not references
if (wxGetApp().is_closing()) return; // the real check, on the UI thread
handle(dev_id, msg);
});
Cite: ActionRegistry::init (on_source/on_capability), the message_arrive_fn lambda in
GUI_App (network-agent callbacks).
CallAfter is also the tool on the main thread for deferring past the current event — e.g. the
config wizard is opened from GUI_App::post_init through CallAfter because on macOS that is the
only way to pop a modal at startup without breaking combo boxes.
wxQueueEvent, wxPostEvent, AddPendingEvent
Contract.
QueueEvent(wxEvent*)is asynchronous, "takes ownership of the event parameter … the pointer can't be used any more after the function returns", "can be used for inter-thread communication from the worker threads to the main thread. It is safe in the sense that it uses locking internally", and wakes the loop withwxWakeUpIdle()(interface/wx/event.h:411-466).wxQueueEvent(dest, ev):dest"can't be NULL",evmust be heap-allocated (interface/wx/event.h:5368-5382).AddPendingEvent/wxPostEventcopy the event withClone()(a stack event is fine) but "can't be used to post events from worker threads for the event objects with wxString fields (i.e. in practice most of them)" (interface/wx/event.h:469-488,5355-5366). The thread overview: useQueueEventfor inter-thread communication; with only the main thread either is fine. In 3.3wxStringis always standard-string-backed (std::wstringin Orca's wchar build) and never copy-on-write (include/wx/string.h:119-132), so the old shared-buffer explanation is historical — follow the documented rule anyway.- Every queued or posted event class must implement
Clone()(references/events.md§Custom events). - Ownership is the event's own data only:
wxCommandEvent::SetClientObject/SetClientDatapayloads are never owned by the event. The target must outlive the posting thread — the race with~wxEvtHandlerabove applies to everyQueueEvent.
Usage.
auto* e = new wxCommandEvent(EVT_MY_DONE, id); e->SetString(path);
wxQueueEvent(long_lived_handler, e); // ownership passes to wx
wxThreadEvent
Contract. Category wxEVT_CATEGORY_THREAD, so it is "NOT to be processed by
wxEventLoopBase::YieldFor calls (unless the wxEVT_CATEGORY_THREAD is specified - which is never in wx
code)"; Clone() unshares the string; for SetPayload<T>, "T's copy constructor must be thread-safe,
i.e. create a copy that doesn't share anything with the original" (interface/wx/event.h:3736-3790).
The thread overview recommends it precisely to avoid out-of-order processing when
YieldFor/wxProgressDialog is in use (docs/doxygen/overviews/thread.h:79-85).
Source facts.
wxThreadEventderives fromwxEvent, notwxCommandEvent(include/wx/event.h:1367): it does not propagate to parents. Queue it on the object that bindswxEVT_THREAD.- Payloads: PODs,
std::string, or astd::shared_ptrthe worker no longer touches. NeverwxImage/wxBitmap/wxFont. - "Never processed in yields" is only true for masked
YieldFor:wxYield()/wxSafeYield()usewxEVT_CATEGORY_ALL, and the MSW nativewxProgressDialogyieldsALL & ~USER_INPUT, which includes THREAD (src/msw/progdlg.cppwxProgressDialog::DispatchEvents).
wxThreadEvent ev(wxEVT_THREAD, wxID_OK);
ev.SetPayload(std::make_shared<Result>(std::move(r)));
wxQueueEvent(dialog, ev.Clone()); // dialog binds wxEVT_THREAD and outlives the thread
Event categories and yields
A queued event whose category is not in the active yield's mask stays queued and runs after the
yield (src/common/event.cpp wxEvtHandler::ProcessPendingEvents, IsEventAllowedInsideYield)
[source]. Categories come from GetEventCategory(): wxEvent defaults to UI
(include/wx/event.h:1014), wxCommandEvent returns USER_INPUT (include/wx/event.h:1656)
[source], wxThreadEvent returns THREAD.
| Queued event | Category | Generic / Orca progress dialog Update/Pulse (UI|USER_INPUT) |
MSW native wxProgressDialog (ALL & ~USER_INPUT) |
wxYield, wxSafeYield, ShowModal |
|---|---|---|---|---|
CallAfter; plain wxEvent subclasses (Orca SimpleEvent, Event<T>, SlicingProcessCompletedEvent) |
UI | runs | runs | runs |
wxCommandEvent family (e.g. new wxCommandEvent(id) queued by BSP) |
USER_INPUT | runs | held | runs |
wxThreadEvent |
THREAD | held | runs | runs |
GTK exception [source]: the "runs" cells of the masked-yield column hold only on MSW and macOS.
wxGTK's wxGUIEventLoop::DoYieldFor loops while (Pending()) gtk_main_iteration();, and
Pending() → wxApp::EventsPending() removes wx's idle source — the only place GTK runs pending
events — before every iteration (src/gtk/evtloop.cpp:377-395, src/gtk/app.cpp wxApp::EventsPending);
the base DoYieldFor processes pending events only for wxEVT_CATEGORY_ALL
(src/common/evtloopcmn.cpp:172-193). So on GTK a masked YieldFor (generic/Orca progress dialog
Update/Pulse) runs no queued event of any category, despite the doc's "selectively processed by
all ports" (interface/wx/evtloop.h:260-263); they run once control is back in the main loop (wx
re-arms its idle source from the next GTK event/size_allocate signal emission, wx_add_idle_hooks).
Full yields (wxYield, wxSafeYield) and modal loops (ShowModal) run them on every port.
Timer events are not queued; see wxTimer for the MSW one-shot loss inside masked yields.
OrcaSlicer: who marshals how
- Network and printer agents.
GUI_Appinstalls the agent callbacks (set_on_message_fn,set_on_local_message_fn,set_on_server_connected_fn, …) with the doubleis_closing()check and by-value captures, and hands lower layers a queue-on-main function:m_agent->set_queue_on_main_fn([this](std::function<void()> cb) { CallAfter(cb); }).MachineObject/DeviceManagerstate is mutated only inside those main-thread lambdas (obj->parse_json(…)), so device objects need no locking; device UI is pull-based from a timer (MonitorPanel). Seedocs/HLSD/printer-agent.mdfor the agent design. - Ad-hoc threads (preset sync, plugin dialogs):
wxGetApp().CallAfterplus an alive flag (the preset-sync thread: appCallAfterwith anis_closing()re-check inside, and astd::weak_ptr<int>sync token that ends its loop,GUI_App::start_sync_user_preset). - Windows' own
CallAfterfrom a thread only when the thread is joined before the window dies: the 3Dconnexion thread postswxGetApp().plater()->CallAfter(…), andMainFrame::shutdownstops it (get_mouse3d_controller().shutdown()) first;wxMediaCtrl3's decoding thread posts its repaintCallAfteron the control, and~wxMediaCtrl3joins it before the window's base destructors run. - Backup thread.
MainFrameregistersSlic3r::set_backup_callback, whose action 0 callswxPostEvent(this, wxCommandEvent(EVT_BACKUP_POST))from the backup thread — the documented-unsafe API (harmless only because the event's string is empty). New code useswxQueueEvent(handler, new wxCommandEvent(…)).MainFrame::shutdownclears the callback first. - Main thread → main thread decoupling uses
wxPostEvent, e.g. Preferences postingSimpleEvent(EVT_GLCANVAS_COLOR_MODE_CHANGED)to the plater after a colour-mode change. - Webview script messages arrive synchronously inside native callbacks on WebKitGTK and
WKWebView; defer window work out of them — see
references/webview-gl-aui-media.md.
Coalescing high-rate updates
A worker reporting progress hundreds of times a second must not queue one CallAfter per report: each
is a heap event, and the app drains every pending event (including ones queued meanwhile) before
it returns to native input and paint (src/common/appbase.cpp:561-601) [source]. Keep the latest
values in shared state and keep at most one call queued:
struct Progress { std::mutex m; int pct = 0; std::string msg; bool queued = false; };
// worker
void report(const std::shared_ptr<Progress>& st, std::shared_ptr<std::atomic<bool>> alive, int pct, std::string msg) {
{ std::lock_guard<std::mutex> lk(st->m); st->pct = pct; st->msg = std::move(msg);
if (st->queued) return; st->queued = true; }
wxGetApp().CallAfter([st, alive] {
int pct; std::string msg;
{ std::lock_guard<std::mutex> lk(st->m); pct = st->pct; msg = st->msg; st->queued = false; }
if (!alive->load() || wxGetApp().is_closing()) return;
/* update the UI with pct, msg */
});
}
The Jobs framework avoids the per-report wx event for you: status messages sit in the worker's own queue and are drained in one batch per idle/paint (each still applied, in order).
Pitfalls.
- Rule: From a worker, post to the app (or a handler that outlives the thread), never to a window.
Why:
this->CallAfter/wxQueueEvent(this, …)from a thread races the window's destruction;~wxEvtHandler'sDeletePendingEvents()is unlocked (src/common/event.cpp:1361-1366).Cite:// Wrong (Http / agent thread) CallAfter([this, text] { m_label->SetLabel(text); }); // Right wxGetApp().CallAfter([this, alive = m_alive, text] { if (!alive->load()) return; m_label->SetLabel(text); });src/common/event.cppwxEvtHandler::QueueEvent,wxEvtHandler::~wxEvtHandler;PluginsDialog/DeviceErrorDialogalive flags. - Rule: Never
wxPostEvent/AddPendingEventfrom a worker; usewxQueueEvent(h, new Evt)orwxThreadEvent::Clone(). Cite:interface/wx/event.h:469-488. - Rule: Capture
std::shared_ptr, notstd::unique_ptr, inCallAfter. Why: the functor is copied; a move-only lambda does not compile. Cite:include/wx/event.h:1554-1569. - Rule: Don't expect a
wxThreadEventto be held back by every yield. Why: only maskedYieldForholds it;wxYieldand the MSW native progress dialog dispatch it, andCallAfter(UI category) is never held on MSW/macOS (GTK holds every queued event in a masked yield). Cite:src/msw/progdlg.cppDispatchEvents.
Liveness of deferred work
Every deferred body runs later, on the main thread, when the objects it names may be gone. The check must happen inside the lambda: a worker-side check followed by posting is a TOCTOU race — the object can die between the check and execution. Three tools, by scope:
| Scope | Tool | Set | Checked |
|---|---|---|---|
| App shutdown | GUI_App::is_closing() (std::atomic<bool> m_is_closing) |
set_closing(true) in the MainFrame close handler before m_plater->reset(), and in GUI_App::shutdown() |
before posting (early-out) and first thing inside the lambda |
| One window | std::shared_ptr<std::atomic<bool>> m_alive = std::make_shared<std::atomic<bool>>(true); dtor does m_alive->store(false, std::memory_order_release) |
the derived destructor | capture alive = m_alive by value; alive->load(std::memory_order_acquire) inside |
| Main-thread-only object | wxWeakRef<T> (interface/wx/weakref.h) |
nulls in ~wxTrackable |
main thread only — never create, copy or test it on a worker |
- Exemplars:
PluginsDialog,PluginsConfigDialog,SpeedDialWebDialog(alive flag);DeviceErrorDialog(alive flag plus a request sequence number so a stale reply can't overwrite a newer one). The legacystd::shared_ptr<int>token +std::weak_ptr<int>capture works only when thelock()/expired()check sits inside the main-thread lambda; legacy code that checks on the worker and then callsthis->CallAfteris the race above — don't copy it. is_closing()covers app shutdown only.GUI_App::recreate_GUI(language change) destroys and rebuildsMainFramewithout setting it (GUI_App::shutdownreturns early whileis_recreating_gui()), so lambdas holdingMainFrame-child pointers need their own alive flag or must re-fetch throughwxGetApp().plater().- After the main loop ends, still-queued app
CallAfters are deleted without running (Shutdown); their captures are destroyed withwxTheApp == nullptr, so capture destructors must not call into wx. wxWeakRefand the alive flag differ in timing: the flag flips at the start of the derived destructor; awxWeakRefnulls only in~wxTrackable, after the derived destructors andDestroyChildren(include/wx/event.h:3705-3706;include/wx/tracker.h:76-85). Seereferences/windows-dialogs.mdforDestroy()-scheduled windows, which stay alive (and run their ownCallAfters) until idle deletion.
Blocking round-trips
Sometimes a worker needs a result computed on the UI thread. Every such round-trip has two failure modes: deadlock (the UI waits on the worker while the worker waits on the UI) and a lost wake-up (the queued call is discarded or never pumped, so the worker waits forever).
| Helper | Shape | Deadlock/cancel protection |
|---|---|---|
Job::Ctl::call_on_main_thread(fn) → std::future<void> |
message in the worker's out-queue, delivered by process_events() or synchronously inside wait_for_current_job/wait_for_idle |
the UI waits only through the Worker API, which delivers the call |
BackgroundSlicingProcess::execute_ui_task(fn) |
m_plater->CallAfter + UITask state + condition variable; returns false if cancelled |
stop()/stop_internal() call cancel_ui_task() on the UI thread before waiting for the slicing thread |
run_on_ui_blocking(fn) (src/slic3r/plugin/host/PluginHostUi.cpp) |
runs fn inline when wxIsMainThread(); else wxGetApp().CallAfter with promise/fn captured by reference, then fut.get() |
by-reference capture is safe only because the caller blocks until the lambda ran |
Rules that follow:
- Run inline when already on the main thread —
CallAfter+future.get()on the main thread deadlocks. - Never copy the by-reference capture into a fire-and-forget
CallAfter. - Never wait on a
call_on_main_threadfuture while holding a lock the UI needs. - A round-trip stalls while the main thread cannot pump: the idle-driven Jobs pump inside MSW native modal loops, and everything while a native macOS message/file/dir panel is up (When queued and idle work runs). Don't open native modals while a job waits on the UI, or accept the stall.
- Give the UI side a cancel that releases the worker (BSP's
UITaskpattern): when the UI must stop a worker that may be blocked on a round-trip, mark the pending task cancelled, wake the worker, then wait. - A round-trip queued with
CallAfterthat is deleted unrun (target destroyed, or the app's queue discarded after the main loop ends) never sets its promise: the worker blocks forever infut.get(), and whoever later joins it deadlocks. Stop such workers before the loop ends.
Worker threads: lifetime and exceptions
Exceptions. An exception escaping a std::thread/boost::thread body calls std::terminate
and aborts the whole app. Wrap the entire body; log and convert to a status the UI can show.
- Rule: Wrap the entire body of any detached or background thread in
try/catch, and on background threads use theerror_code-basedboost::filesystem::directory_iterator(log and skip) instead of the throwing one. Why: On macOSreaddir()can transiently fail with ENOTSUP (errno 45); boost turns it into afilesystem_error, which killed OrcaSlicer at startup from the preset-sync thread (GUI_App::scan_orphaned_info_files, run by the thread started inGUI_App::start_sync_user_preset).Cite:for (auto& e : fs::directory_iterator(dir)) ... // Wrong on a background thread boost::system::error_code ec; // Right for (fs::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) ... if (ec) BOOST_LOG_TRIVIAL(warning) << "scan failed: " << ec.message();05ed2e4dfa(GUI_App::start_sync_user_presetthread body,GUI_App::scan_orphaned_info_files); the same top-leveltry/catchis indetail::run_off_thread_with_progress(PluginsDialog.hpp). - The wx-side counterpart:
wxTimer::Notify()must not throw (wxTimer), and a handler exception ends the session (Exceptions).
Lifetime.
- A joinable thread must be joined before the objects it posts to die; a detached thread must reach
the app only through
wxTheApp-guarded calls and alive flags, and must not touch wx afterOnExit(wxGetApp()dereferences null oncewxEntryCleanuphas runSetInstance(nullptr),src/common/init.cppwxEntryCleanup).if (wxTheApp == nullptr) return;beforewxTheApp->CallAfter(asdetail::run_off_thread_with_progressdoes) narrows but does not close that window, so long-running detached threads also need a stop signal checked in their loop. - Orca joins its long-lived threads on the shutdown path: Jobs workers in
~BoostThreadWorker, the slicing thread in~BackgroundSlicingProcess/join_background_thread, the preset-sync thread inGUI_App::stop_sync_user_preset(joined whenis_closing(), otherwise detached), the 3Dconnexion thread inMainFrame::shutdown.
The wxThread family (reading only — Orca uses std::thread/boost::thread):
wxThreaddefaults to detached (interface/wx/thread.h:1023),wxThreadHelperto joinable (interface/wx/thread.h:435). Detached threads delete themselves: neverdeletethem and never pollIsRunning(); "calling a routine on any running wxThread should be avoided if possible" — notify completion withwxQueueEventinstead. Joinable threads must beWait()ed and deleted.Delete()only asks, and works only ifEntry()pollsTestDestroy();Kill()is a last resort;~wxThreadHelperusesKill, soWait()/Delete()yourself (interface/wx/thread.h:436-446).Entry()must "not call any GUI function … rather use wxQueueEvent()" (interface/wx/thread.hsample, around line 369).- Deriving from
wxThreadandwxEvtHandlerto send messages to a worker "does not work at all"; usewxThreadHelperorwxMessageQueue<T>(docs/doxygen/overviews/thread.h:60-74); since 3.3wxMessageQueue::Post(T&&)accepts move-only types (interface/wx/msgqueue.h:90-97). wxTHREAD_WAIT_DEFAULTis nowwxTHREAD_WAIT_BLOCK;wxTHREAD_WAIT_YIELD"is dangerous" and "is only implemented in wxMSW and simply ignored under the other platforms" (interface/wx/thread.h:648-675), so the 3.3 default change (docs/changes.txt:39-41) only affects MSW code callingWait()/Delete()from the GUI thread.wxCRITICAL_SECTION(name)declares a function-static critical section, which is initialised safely only if the function is first called from the main thread before other threads call it; otherwise make it a global (interface/wx/thread.h:1762-1779). Preferstd::mutex.wxSecretStoredocuments no thread-affinity notes in 3.3.2 (interface/wx/secretstore.h).
The Jobs framework
src/slic3r/GUI/Jobs/: the standard way to run UI-initiated work (arrange, orient, fill bed, emboss,
send/print, texture bakes) off the main thread with progress and cancellation.
Contract (Job.hpp, Worker.hpp).
Job::process(Ctl&)runs on the worker thread.Job::finalize(bool canceled, std::exception_ptr&)runs on the UI thread afterprocessreturned; an exception fromprocessarrives ineptr. "Assign nullptr to this second argument before function return to prevent further action. Leaving it with a non-null value will result in rethrowing by the worker."Job::Ctl(call fromprocess):update_status(int, std::string),was_canceled(),call_on_main_thread(fn) → std::future<void>,clear_percent(),show_error_info(…).Worker: "It is assumed that every method of this class is called from the same main thread."push,is_idle,cancel(ask the current job to stop),cancel_all(drop queued jobs and cancel the current one),process_events("needs to be called continuously … e.g. in a wxIdle handler"),wait_for_current_job(ms),wait_for_idle(ms).- Helpers:
queue_job(w, process_fn[, finish_fn]),queue_job(w, std::shared_ptr<Job>),replace_job(w, …)=cancel_all()+ queue (non-blocking; the new job starts once the current one returns),stop_current_job(w, ms)=cancel+wait_for_current_job,stop_queue(w, ms)=cancel_all+wait_for_idle.
BoostThreadWorker (BoostThreadWorker.cpp) — one boost::thread (via create_thread) with an
input job queue and an output message queue (Status, Finalize, MainThreadCall):
run()setsm_canceled = falseat the start of each job, catches every exception fromprocessintoeptr, recordscanceled, and pushes aFinalizemessage.process_events()drains the output queue on the calling (UI) thread.wait_for_current_job/wait_for_idledeliver messages synchronously inside the waiting call, sofinalizeandcall_on_main_threadbodies can run inside e.g.Plater::reslice'sstop_queue(get_ui_job_worker(), 10000).- Delivering
Finalizecallsjob->finalize(canceled, eptr)and thenstd::rethrow_exception(eptr)if it is still set — on the UI thread, where it reachesOnExceptionInMainLoop. - The destructor runs
cancel_all(),wait_for_idle(10 s)(delivering pending messages), pushes a null job and joins for up to 10 s, logging if the join fails.
PlaterWorker<W> (PlaterWorker.hpp) wraps any worker for a wxWindow*:
- pumps
process_events()fromwxEVT_IDLEandwxEVT_PAINThandlers bound on that window throughEventGuard(paint can arrive where idle doesn't run, e.g. inside an MSW modal resize loop); - wraps
Ctlso everyupdate_status/call_on_main_thread/clear_percent/show_error_infois followed bywxWakeUpIdle(); - sets the busy cursor for the job's duration (
CursorSetterRAII, which marshalswxBeginBusyCursor/wxEndBusyCursorthroughcall_on_main_thread); - logs process/finalize timing, and turns a
std::exceptionleft ineptrintoshow_error(window, …)before clearing it; non-stdexceptions still propagate.
The plater's single worker is Plater::get_ui_job_worker() (a
PlaterWorker<BoostThreadWorker> named "ui_worker", reporting through
NotificationProgressIndicator, whose cancel button calls worker.cancel()). Dialogs with their own
progress own one: m_worker = std::make_unique<PlaterWorker<BoostThreadWorker>>(this, m_status_bar, "send_worker")
(SelectMachineDialog, SendToPrinterDialog; DownloadProgressDialog, BindMachineDialog and
OAuthDialog the same with their own worker names, OAuthDialog with a null progress indicator).
Usage — the shape of ArrangeJob/OrientJob/FillBedJob:
void MyJob::process(Ctl& ctl) {
ctl.call_on_main_thread([this] { prepare(); }).wait(); // snapshot the model on the UI thread
for (...) {
if (ctl.was_canceled()) return;
/* work on the snapshot only */
ctl.update_status(pct, _u8L("Working"));
}
}
void MyJob::finalize(bool canceled, std::exception_ptr& eptr) {
try { if (eptr) std::rethrow_exception(eptr); }
catch (libnest2d::GeometryException&) { show_error(m_plater, _L("…")); eptr = nullptr; } // handled
catch (...) { eptr = std::current_exception(); } // left for PlaterWorker
if (canceled || eptr) return; // never apply partial results
/* apply the result to the model */ // UI thread: safe to touch Plater/canvas
}
replace_job(plater->get_ui_job_worker(), std::make_unique<MyJob>());
State written in process and read in finalize needs no lock: finalize is delivered only after
process returned.
Pitfalls.
- Rule: Drop queued work with
cancel_all()/replace_job(), notcancel(). Why:cancel()only sets the flag;run()resets it when the next job starts, so queued jobs still run and acancel()with no job running is a no-op. Cite:BoostThreadWorker::run,Worker.hpp. - Rule: Clear
eptrfor every exceptionfinalizehandles. Why: a non-nulleptris rethrown on the UI thread afterfinalize;PlaterWorkerconverts onlystd::exceptions, anything else reachesOnExceptionInMainLoopand ends the session. Cite:BoostThreadWorker::WorkerMessage::deliver. - Rule: Touch UI only in
finalizeorcall_on_main_thread;processworks on a snapshot. Cite:Job.hppcomments;ArrangeJob::process. - Rule: Stop a dialog-owned worker (
stop_queue(*m_worker, ms)orm_worker.reset()) at the start of the owner's close path or destructor, before other members die. Why: the worker's destructor delivers pendingFinalize/MainThreadCallmessages synchronously, so job callbacks capturing the dialog run inside its destruction. Cite:BoostThreadWorker::~BoostThreadWorker. - Rule: Expect
finalizeto run insidewait_for_*/stop_queuecalls, not only from idle. Why: synchronous delivery; code afterstop_queuesees the model already modified by the finalized job. Cite:BoostThreadWorker::wait_for_idle.
BackgroundSlicingProcess
The slicing/export thread (BackgroundSlicingProcess.cpp/.hpp) talks to the plater three ways:
- Completion and status events:
SlicingStatusEventandSlicingProcessCompletedEventarewxEventsubclasses (the latter carries the exception asstd::exception_ptr, whichClone()copies safely), queued withwxQueueEvent(wxGetApp().mainframe->m_plater, evt.Clone()); export-began/finished events arenew wxCommandEvent(id)(+SetString(path)) passed towxQueueEvent. Posting to the plater from a worker is safe here only because the thread is joined in~BackgroundSlicingProcess/join_background_threadbefore the plater dies. - Synchronous UI calls (thumbnail rendering):
execute_ui_task(fn)— see Blocking round-trips. It plans aUITaskonly if cancellation is not in progress (m_mutex.try_lock()and!m_print->canceled()), postsm_plater->CallAfter, and waits forFinishedorCanceled; the queued lambda runsfnonly while the task is stillPlanned. Only one UI task can be planned at a time because the slicing thread blocks on it. - Stopping:
stop()andstop_internal()run on the UI thread, callcancel_ui_task(m_ui_task)first, then cancel the print and wait forSTATE_CANCELED.stop_internal()(called byPrint::apply()through the cancel callback) "shall not trigger any UI update through the wxWidgets event".
wxTimer
Contract (interface/wx/timer.h).
- Three ways to receive notifications (
interface/wx/timer.h:24-39): overrideNotify(); orSetOwner(handler, id)/ the(owner, id)ctor andBind(wxEVT_TIMER)on the owner; or default-construct and bind on the timer itself ("the timer object will be its own owner object, since it is derived from wxEvtHandler"). - "In any case, you must start the timer with wxTimer::Start() after constructing it before it
actually starts sending notifications. It can be stopped later with wxTimer::Stop()." "A timer can
only be used from the main thread." (
interface/wx/timer.h:37-41) - Destructor: "Stops the timer if it is running" (
interface/wx/timer.h:65) — the documented safety net. Events go to the owner, so the timer must be stopped or destroyed before its owner handler dies; the member-timer-with-this-owner layout in the class docs is the sanctioned one. Start(ms = -1, oneShot = false)returns false "if the timer could not be started (in MS Windows timers are a limited resource)";-1reuses the previous interval; a running timer "will be stopped by this method before restarting it" (interface/wx/timer.h:119-135).StartOnce(ms)wrapsStart(ms, wxTIMER_ONE_SHOT)and is the preferred one-shot ("Alternatively, and preferably, call StartOnce()"); one-shot timers stop after their single notification (interface/wx/timer.h:129-144).Notify(): "throwing exceptions from this method is currently not supported, use event-based timer handling" if the handler can throw (interface/wx/timer.h:104-106).wxTimerRunner: RAII, "Starts the timer in its ctor, stops in the dtor" (interface/wx/timer.h:153-165).
Source facts.
- Default id is not
wxID_ANY.SetOwner(owner, wxID_ANY)assigns a freshwxNewId()(src/common/timerimpl.cppwxTimerImpl::SetOwner). An id-lessBind(wxEVT_TIMER, h)on the owner catches every timer that owner owns; callingSetOwner(this)again gives the timer a new id, so aBind(…, timer.GetId())made earlier stops matching. - Delivery is synchronous through a raw owner pointer:
wxTimerImpl::SendEventcallsm_owner->SafelyProcessEvent(src/common/timerimpl.cpp;include/wx/private/timer.h:62), and nothing clears the pointer when the owner dies. A memberwxTimer m_timer{this}is safe because members are destroyed before the owner'swxEvtHandlerbase. - macOS rejects 0 ms:
wxCHECK_MSG(m_milli > 0, false, …)(src/osx/core/timer.cpp:74), soStartOnce(0)returns false and never fires — and so does a firstStart()/StartOnce()with no interval, since the stored interval starts at 0 (src/common/timerimpl.cppwxTimerImplctor). - Needs the app:
wxTimer::Initgets its implementation fromwxApptraits and returns silently without them (src/common/timercmn.cpp:55-62); a global or static timer constructed before thewxAppobject has no implementation and every call on it is a silent no-op. - Started off the main thread it is broken, silently: the main-thread assert in
wxTimerImpl::Startis compiled out (its comment: "under MSW timers only work when they're started from the main thread",src/common/timerimpl.cpp:53-58); on macOS theCFRunLoopTimeris attached toCFRunLoopGetCurrent()of the calling thread and never fires (src/osx/core/timer.cpp:94-96); on GTKg_timeout_addregisters it with the default GLib context (src/gtk/timer.cpp:59), so it fires on the main thread while the worker races the timer's state.
Platforms.
| Implementation | Notes | |
|---|---|---|
| MSW | ::SetTimer on a hidden window (src/msw/timer.cpp) |
Start() can fail ("Couldn't create a timer", returns false) — check it; one-shot timer events are lost inside a YieldFor without wxEVT_CATEGORY_TIMER (interface/wx/event.h:52-59, interface/wx/evtloop.h:265-268), which includes the time a wxProgressDialog is shown (interface/wx/progdlg.h:34-39). [source] The masked yield removes an unwanted WM_TIMER from the queue and never re-posts it (src/msw/evtloop.cpp wxGUIEventLoop::DoYieldFor); the ::SetTimer stays armed until the first delivered tick (src/msw/timer.cpp wxProcessTimer), so in practice the one-shot is late by whole intervals, or lost while yields keep discarding its ticks. This bites the generic dialog and Orca's ProgressDialog (UI|USER_INPUT), while the native MSW wxProgressDialog yields TIMER too (DispatchEvents) |
| macOS | CFRunLoopTimer in kCFRunLoopCommonModes (src/osx/core/timer.cpp:96) |
fires during live resize, menu tracking and native modals [source]; interval must be > 0 |
| GTK | g_timeout_add at default priority; the callback takes wxGDKThreadsLock and calls WakeUpIdle() after Notify() (src/gtk/timer.cpp:26-59) |
a timer that is always due runs ahead of repaints and of posted events (pending events run from wx's G_PRIORITY_LOW idle source, see When queued and idle work runs); a masked YieldFor filters only GDK events, so the timer fires inside it whatever the mask [source] (src/gtk/evtloop.cpp wxgtk_main_do_event) |
OrcaSlicer.
- Conventions: a member
wxTimerowned by the window it serves, or a self-owned timer bound on itself (m_timer.Bind(wxEVT_TIMER, …),IdleScheduler;m_timer_set_color/m_fps_overlay_timerinGLCanvas3D). Start on page/window show, stop on hide, stop before destruction. MonitorPanelkeeps a heapwxTimerwithSetOwner(this), started inMonitorPanel::init_timerandMonitorPanel::Show(true), stopped inShow(false), stopped and deleted in~MonitorPanel. ItsBind(wxEVT_TIMER, &MonitorPanel::on_timer, this)has no id, which is why re-SetOwnerinShowkeeps working — and why a second timer on that panel would also land inon_timer.- Debouncing uses
StartOnceon dedicated members:GLCanvas3D::m_render_timer,m_timer_set_color,m_fps_overlay_timer. GLCanvas3D::RenderTimer/ToolbarHighlighterTimer/GizmoHighlighterTimeroverrideNotify()towxPostEventa distinct event type (EVT_GLCANVAS_RENDER_TIMER, …) to the owner, which sidesteps owner/id collisions entirely.IdleScheduleris the sanctioned timer-driven slicer of background UI work (When queued and idle work runs).
Pitfalls.
- Rule: A
wxTimermust not outlive the handler that receives its events: make it a member of (or owned by) the window it notifies, or stop and delete it before that handler dies. Touch it only on the main thread. Destroy heap-allocated modeless frames and dialogs withDestroy(), neverdelete; stack-allocated modal dialogs are fine. Why: the next tick callsm_owner->SafelyProcessEventon freed memory. The destructor already stops the timer, soStop(); delete timer;is belt-and-braces; ordering is what matters. A rawdeleteof a TLW bypasses thewxPendingDeletequeue that lets in-flight events drain, and a child dialog is deleted immediately inside its parent's destructor, so a pointer to a child dialog dies with the parent (references/windows-dialogs.md).Cite:// Shape of detail::run_off_thread_with_progress: worker done → back on the UI thread wxTheApp->CallAfter([alive, progress, timer, on_finish, restore] { timer->Stop(); delete timer; // timer first, on the main thread if (!alive || alive->load(std::memory_order_acquire)) { progress->Destroy(); // child of the host: only while the host lives if (restore) restore(); // e.g. re-raise the host on macOS on_finish(); } });detail::run_off_thread_with_progress(PluginsDialog.hpp;alivemay be null for host-less callers such asinstall_local_plugin_package);0a0d59b76badded the macOS re-raise step to this completion sequence. - Rule: With two timers on one owner, bind with
timer.GetId(), bind on each timer, or checkevt.GetTimer(). Why: default ids are freshwxNewId()s and an id-less bind catches all. - Rule: Never
StartOnce(0)"to run on the next iteration" — useCallAfter. Why: macOS rejects 0 ms silently.IdleScheduleruses a 5 ms "next slice" for this reason. - Rule: Don't create timers as globals/statics or start them from workers. Why: no implementation before the app exists; a worker-started timer never fires on macOS and races the main thread on GTK; no diagnostic in Orca.
When queued and idle work runs
Order. Pending (queued) events run before idle (src/common/evtloopcmn.cpp
wxEventLoopManual::DoRunLoop; macOS src/osx/core/evtloop_cf.cpp CommonModeObserverCallBack).
wxAppConsoleBase::ProcessPendingEvents loops until no handler has pending events, including
events queued while it runs (src/common/appbase.cpp:561-601) [source]: a CallAfter that
re-posts itself never lets native input or paint in. Order is FIFO only per target handler
(references/events.md §Posting).
Idle (contract details in references/events.md §Idle events): sent once when the loop becomes
idle, not continuously; a stream needs RequestMore() or periodic wxWakeUpIdle(), "but note that
both of these approaches (and especially the first one) increase the system load"
(interface/wx/event.h:4382-4395). Call wxWakeUpIdle() from a worker after changing state an idle
handler consumes (GLTexture::Compressor::compress, PlaterWorker's WakeUpCtl). The docs say
children of hidden windows get no idle events (interface/wx/event.h:4396-4401), but
wxWindowBase::SendIdleEvents recurses into every child without a visibility check
(src/common/wincmn.cpp:2783-2808) [source]; TLWs pending deletion are skipped
(src/common/appcmn.cpp:408-429). Idle processing also flushes wxLog and deletes Destroy()ed TLWs
and ScheduleForDestruction objects (src/common/appbase.cpp ProcessIdle, DeletePendingObjects).
Platforms [source] (per-port table with cites: references/events.md §7 Posting and queueing):
MSW runs pending events — not idle — inside native modal loops (menus, move/resize, common dialogs)
through a WH_GETMESSAGE hook; macOS runs them from run-loop observers and pauses pending events
and idle while a native wxMessageDialog/wxFileDialog/wxDirDialog is modal; GTK (GTK3 by default,
X11 and Wayland) runs both from one G_PRIORITY_LOW idle source that timers, redraws and input floods
outrank, which a masked YieldFor removes (event categories) and which
RequestMore() keeps installed — a busy loop.
Orca's MessageDialog family is generic (DPIDialog) and doesn't pause anything; native
wxMessageBox/wxFileDialog do.
Visible progress during blocking main-thread work needs a run-loop turn: wxWindowGTK::Update()
returns immediately on Wayland (src/gtk/window.cpp wxWindowGTK::Update, #25036) and
wxNonOwnedWindow::Update on macOS is throttled to about 30 Hz (src/osx/nonownedwnd_osx.cpp:484-489),
so Refresh(); Update(); mid-computation paints nothing or late. Move the work off the main thread
(references/painting-custom-widgets.md §Invalidation).
Idle loops — OrcaSlicer.
IdleScheduler(IdleScheduler.hpp/.cpp,MainFrame::m_idle, design indocs/HLSD/deferred-page-construction.md) runs deferred page prebuilds: a self-ownedwxTimerticks every 250 ms; a slice starts only after 500 ms without input (GUI_App::input_idle_ms(), stamped byGUI_App::FilterEventfor non-command user-input events and main-window resizes) and, on Windows, while no input other than mouse moves is queued; slices are 40 ms; the next slice isStartOnce(5)(a timer message, so paint, timers and input queued meanwhile run first; GTK runs a due timer ahead of repaints and posted events; macOS rejects 0 ms); it skips whilewxEventLoopBase::GetActive()->IsYielding()and guards re-entry withm_in_slice;MainFrame::shutdowncallsm_idle.stop(). Use it — notwxEVT_IDLE+RequestMore()or self-repostingCallAfters — for background UI construction.GLCanvas3D::on_idlecallsRequestMore()only while an extra frame is pending (FPS cap, ImGui settling), which bounds the busy loop.- The app-level idle handler (bound at the end of
GUI_App::on_init_inner) runspost_init()once and then savesapp_configwhenever it is dirty. GUI_App::FilterEventruns for every event in the process: keep it as cheap as it is (a timestamp, thenEvent_Skip) (interface/wx/eventfilter.h:18-20).
Yields and nested event loops
Yields
Contract.
wxApp::Yielduses the active loop; with none running, "it creates a temporary event loop … useful to process pending messages during the program startup". "Use extreme caution … it can result in unexpected reentrances." (interface/wx/app.h:226-238).wxYield()does nothing without an app (src/common/utilscmn.cppwxYield).SafeYield(win, onlyIfNeeded)disables all windows exceptwin, yields, re-enables (interface/wx/app.h:1085-1094). [source] UnlikeYield, it returns false and does nothing when no loop is active (src/common/appcmn.cpp:384-391) — useless inOnInit.YieldFor(mask)processes only the given categories (UI=1, USER_INPUT=2, SOCKET=4, TIMER=8, THREAD=16;interface/wx/event.h:25-76). "only wxMSW and wxGTK do support selective yield of native events … wxWidgets events posted using … QueueEvent are instead selectively processed by all ports"; "Under wxMSW, if eventsToProcess doesn't include wxEVT_CATEGORY_TIMER, events from one-off timers may be lost" (interface/wx/evtloop.h:250-272). [source] wxGTK processes no posted event at all inside a masked yield (event categories).- The overview: if
YieldForis used "directly or indirectly (e.g. through wxProgressDialog)", expect re-entrancy and out-of-order processing (docs/doxygen/overviews/thread.h:79-85).
Source facts (src/common/evtloopcmn.cpp:128-192): YieldFor from a non-main thread returns
false immediately; wxLog is suspended during the yield; exceptions stored during the yield are
rethrown afterwards (RethrowStoredException); Yield(onlyIfNeeded=true) returns false if already
yielding; nested yields are allowed. A full yield (wxEVT_CATEGORY_ALL) runs pending events and
one idle pass, so a Destroy()ed TLW can be freed inside the yield, not only at the next main-loop
idle.
What runs inside a yield or modal: every queued CallAfter, timer callback and worker event whose
category the mask allows (on GTK a masked yield runs no queued event but does run timers),
Destroy()ed-TLW deletion on full yields, and user clicks unless input is disabled. A lambda queued before a modal opens can run while the caller is still inside ShowModal().
Hidden yields count too: wxExecute(…, wxEXEC_SYNC) "will call wxYield()" while it waits (all
windows disabled unless wxEXEC_NODISABLE; wxEXEC_BLOCK adds wxEXEC_NOEVENTS and yields
nothing) (interface/wx/utils.h:1203-1212, 1243-1248; see references/strings-i18n-files.md).
Nested event loops
Contract.
- Loops form a stack; only the top one is active.
Exit()works only on the running loop;ScheduleExit()exits a loop "as soon as all its nested loops exit" (interface/wx/evtloop.h:25-36, 104-130); it requires the loop to be insideRun()(IsInsideRun(),src/common/evtloopcmn.cpp:98-107).IsRunning()isGetActive() == this(include/wx/evtloop.h:366), false while a nested loop is active. ShowModal()"creates a temporary event loop which takes precedence over the application's main event loop … This also results in a call to wxApp::ProcessPendingEvents()" (interface/wx/dialog.h:608-618).wxApp::ExitMainLoopis documented as callingExit()on the active loop (interface/wx/app.h:76-82); [source] it exitsm_mainLooponly if that is the running loop (src/common/appbase.cpp:396-404), so with a modal open it does nothing. UseGetMainLoop()->ScheduleExit()or close the top window.wxEventLoopActivatorswaps the active loop RAII-style (interface/wx/evtloop.h:298-330).wxEventLoopBase::GetActive()"may return NULL if there is no active event loop (e.g. during application startup or shutdown)" (interface/wx/evtloop.h:47-52): guard it before callingGetActive()->YieldFor(…).
OrcaSlicer.
detail::run_wait_with_progress(PluginsDialog.hpp) blocks in a localwxEventLoop loop; loop.Run();that the worker's completionCallAfterexits, with afinishedflag for a completion that lands beforeRun(). A completion can arrive while another modal is stacked on that loop, so the exit call must beScheduleExit():Exit()then fails itsIsRunning()check and returns silently (src/common/evtloopcmn.cpp:91-96), and anif (loop.IsRunning()) loop.Exit();guard skips it just as silently — either way the local loop keeps running after the stacked modal closes.GUI_App::drain_pending_events(ms)(ProcessPendingEvents+ up to 20wxYields + 50 ms sleeps) belongs toGUI_App::hot_reload_network_plugin, not to shutdown: clear agent callbacks → drain 200 ms → stop discovery/disconnect →wait_for_network_idle(500)→ drain 100 ms → delete the agent → drain → unload the module, so callbacks the old agent already queued run before the agent and its module go away. It runs underwxBusyCursor+wxBusyInfo+wxWindowDisabler; reentrant by design.
Pitfalls.
- Rule: Don't
wxYield()in a handler to let the UI repaint. Why: it runs every pendingCallAfter, timer and click re-entrantly (and may freeDestroy()ed windows); paint may still not happen on Wayland. Use a worker or a Job. Never yield from inside aCallAfterbody that can be re-entered. - Rule: Exit a nested loop from deferred code with
ScheduleExit().Cite:if (loop.IsRunning()) loop.Exit(); // Wrong: silently skipped while a modal is stacked on top loop.ScheduleExit(); // Right: exits once the nested loops have exitedinterface/wx/evtloop.h:104-130. - Rule: Don't rely on
wxSafeYieldinOnInit. Why: no active loop → no-op;wxYield/wxApp::Yieldcreate a temporary loop.
Progress and busy indicators
wxProgressDialog
Contract (interface/wx/progdlg.h).
- It internally calls
YieldFor(wxEVT_CATEGORY_UI | wxEVT_CATEGORY_USER_INPUT), which can cause re-entrancy and out-of-order processing; usewxThreadEventfor thread messages; one-shot timers firing while it is shown are discarded on wxMSW, so don't start them before showing it (interface/wx/progdlg.h:29-39). - Create it on the stack like a modal dialog; created from
OnInit, it must be destroyed before the main loop starts (interface/wx/progdlg.h:41-59). Update(value, msg, &skip)"Returns true unless the Cancel button has been pressed"; on false either destroy the dialog orResume(). Atvalue == maximum: withwxPD_AUTO_HIDEit hides and returns; without it the dialog becomes modal andUpdatedoesn't return until the user dismisses it (interface/wx/progdlg.h:208-229).Pulse()is indeterminate mode (interface/wx/progdlg.h:166);WasCancelled()lets other code poll (interface/wx/progdlg.h:195).wxPD_APP_MODALdisables all windows (interface/wx/progdlg.h:62-66); a non-null parent is always disabled; a null parent withoutwxPD_APP_MODALneeds your own re-entrancy guard (interface/wx/progdlg.h:112-117).
Platforms. On MSW wxProgressDialog is native: a TaskDialog run by a
wxProgressDialogTaskRunner wxThread, yielding ALL & ~USER_INPUT (TIMER and THREAD included)
(src/msw/progdlg.cpp DispatchEvents) [source]; anything based on TaskDialog() has no dark
mode (interface/wx/app.h:1436-1440). On macOS and GTK it is wxGenericProgressDialog, which creates
a temporary event loop if none is active (src/generic/progdlgg.cpp:360-366). On MSW (native
dialog or Orca's generic one) and macOS, CallAfter bodies run inside Update()/Pulse(); on GTK
the masked yield runs no queued event, so they wait until control returns to an event loop
[source] (event categories).
OrcaSlicer.
Slic3r::GUI::ProgressDialog(Widgets/ProgressDialog.hpp) is a themed fork ofwxGenericProgressDialog: sameYieldFor(UI | USER_INPUT)semantics, temporary event loop when none is active, generic on every platform (so no TaskDialog dark-mode gap).detail::run_off_thread_with_progress(PluginsDialog.hpp, wrapped byPluginsDialog::run_with_dialog/run_with_dialog_wait): a heap wxwxProgressDialog(the native TaskDialog on MSW, the generic dialog on macOS and GTK) pulsed by a self-owned 100 mswxTimer, the work on a detachedstd::threadwith a top-leveltry/catch, then the completionCallAftershown in wxTimer pitfalls.- Job progress goes through
NotificationProgressIndicator(plater) or the dialog's status bar (dialog-ownedPlaterWorker), driven fromdeliveron the UI thread — no yields.
Busy indicators
wxBusyCursoris RAII aroundwxBeginBusyCursor/wxEndBusyCursor, which nest by counter (interface/wx/busycursor.h) — main thread only.wxBusyInfoshows a stay-on-top window for its scope; "Normally the main thread should always return to the main loop … hence this class shouldn't be needed". To refresh it, callwxTheApp->Yield()with awxWindowDisablercreated first (interface/wx/busyinfo.h:13-15, 59-78).wxWindowDisabler(winToSkip)disables shown TLWs; on MSW a skipped window that appears in the taskbar lets the user close the app from the taskbar menu — give itwxFRAME_NO_TASKBARorwxFRAME_TOOL_WINDOW(interface/wx/utils.h:59-110).
Pitfalls.
- Rule: Pass
wxPD_AUTO_HIDEif the dialog is closed programmatically. Why:Update(max)without it blocks modally until the user dismisses it. Cite:src/generic/progdlgg.cppwxGenericProgressDialog::Update(ShowModal()at maximum). - Rule: Don't
Refresh(); Update();to show progress mid-computation. Why: no paint on Wayland, throttled on macOS. Move the work off the main thread.
Application lifecycle
Startup
Contract.
OnInit: return false to exit;OnExitis then not called; call the basewxApp::OnInit()if wx command-line parsing is wanted (interface/wx/app.h:387-418).- Windows shown before the main loop don't end the app when closed (
docs/doxygen/overviews/app.h:79-82). OnRunruns the main loop untilExitMainLoopor the last TLW closes;SetExitOnFrameDelete(false)keeps the app alive (interface/wx/app.h:433-452, 1198-1208).- macOS [source]:
wxApp::CallOnInitfirst runs[NSApp run]on a dummy event, so open-file requests can arrive beforeOnInit; wx stores them withOSXStoreOpenFilesand callsMacOpenFilesonly afterOnInitreturned true (src/osx/cocoa/utils.mm:135-150, 420-450). Orca overridesGUI_App::OSXStoreOpenFiles(it forwards to the base).
OrcaSlicer.
GUI_Run(GUI_Init.cpp) doesnew GUI_App(), runs the single-instance check,SetInstance, and callswxEntrywith onlyargv[0](real arguments travel inGUI_App::init_params); it catchesSlic3r::Exception/std::exceptionthrown outside wx's own catch (e.g. from theGUI_Appconstructor) and shows a fatal message box.DECLARE_APP(GUI_App)/IMPLEMENT_APP(GUI_App)sit insidenamespace Slic3r::GUI(the wx overview wants global scope,docs/doxygen/overviews/app.h:65-67; harmless here becauseGUI_RundriveswxEntryitself), so the accessor isSlic3r::GUI::wxGetApp()— qualify it asGUI::wxGetApp()outside that namespace.GUI_App::OnInitwrapson_init_inner()incatch (const std::exception&)→generic_exception_handle(), return false. That handler terminates or rethrows, so thereturn falseis never reached: a startupstd::exceptionleavesOnInitand ends in wx'swxSafeCall→OnUnhandledException(fatal exit code), not inGUI_Run's catch [source] (src/common/init.cpp:550-568).on_init_innerruns before the main loop exists: it shows the splash, buildsMainFrame, shows it, destroys the splash, and binds the app idle handler last (full order:references/orca-architecture.md).post_init()runs from the first app idle, not fromOnInit: deferred startup work that needs a running loop (opening command-line files, the config wizard viaCallAfter, page prebuilds throughMainFrame::prebuild_pages_when_idle()) belongs there.wxEVT_QUERY_END_SESSION(bound on the app inon_init_inner): sends a vetoable close to the main frame andEndModal(wxID_ABORT)to every dialog ondialogStack. wx raises it on MSW and, on macOS, fromapplicationShouldTerminate:(src/osx/carbon/app.cppwxApp::OSXOnShouldTerminate) [source]; not on GTK.
Shutdown
wx order [source] (wxEntry → CallOnExit → wxEntryCleanup):
- The main loop returns.
CallOnExit:DoDelayedCleanup()deletes pending objects anddeletes every remaining TLW (wxAppBase::DeleteAllTLWs,src/common/appcmn.cpp:129-146).OnExit()runs — "called after destroying all application windows and controls, but before wxWidgets cleanup … call [base] at the end" (interface/wx/app.h:358-371).DoDelayedCleanup()again;wxConfigis no longer created on demand (src/common/appbase.cpp:303-323).wxEntryCleanup:app->CleanUp(),wxApp::SetInstance(nullptr)—wxTheAppis null from here — thendelete app, whose~wxEvtHandlerdeletes still-queuedCallAfterevents without running them, then module cleanup (src/common/init.cppwxEntryCleanup).
Consequences: in OnExit all windows are gone; pending app CallAfters never run after the loop
exits; in ~GUI_App wxTheApp is null. Don't let wx objects die in the app destructor — delete them
in OnExit (docs/doxygen/overviews/app.h:84-126).
OrcaSlicer close sequence.
MainFrame'swxEVT_CLOSE_WINDOWhandler may veto (gizmo in editing mode,Plater::close_with_confirmproject/preset prompts,check_print_host_queue).- Otherwise:
MarkdownTip::ExitTip(),wxGetApp().set_closing(true),m_plater->reset(),MainFrame::shutdown(),event.Skip()(wx thenDestroy()s the frame — or, for a vetoable close while a modal dialog is open, vetoes it after this teardown:references/windows-dialogs.md§2). MainFrame::shutdown():m_idle.stop(); plugin pages and dock panes removed;Slic3r::set_backup_callback(nullptr);get_ui_job_worker().cancel_all();unbind_canvas_event_handlers()(on macOS Cmd+Q delivers a mouse event afterwards);reset_canvas_volumes();Show(false)(no paint events into dying windows); 3Dconnexion shutdown; other-instance handler shutdown; saveapp_configif dirty; clear tab lists;GUI_App::shutdown().GUI_App::shutdown(): removable-drive manager shutdown; destroy the login dialog; then — unless recreating the GUI — stop the http server,set_closing(true), mark the plugin manager shutting down, clear the printer-agent cache.GUI_App::OnExit(windows already deleted by wx): stop the http server and preset sync, deleteDeviceManager/UserManager, clear the agent cache, delete the agent, thenwxApp::OnExit().
There is no CallAfter drain at shutdown: queued app CallAfters are discarded with the app, and
is_closing() makes any that still run inert. Anything a callback might touch must be stopped in
steps 3–4, not in OnExit.
Pitfalls.
- Rule: Never touch windows in
OnExitor~GUI_App. Why: wx deleted every TLW beforeOnExit;wxTheAppis null in the destructor. - Rule: A new worker, timer or callback registered on the app or main frame gets a stop in
MainFrame::shutdown/GUI_App::shutdown. Why: the frame is hidden and the loop is about to end; anything still firing reaches half-destroyed windows (the canvas-unbind and Mouse3D steps exist for this).
Exceptions in the main loop
Contract. OnExceptionInMainLoop() returns true to continue, false to exit; the default exits,
except on MSW where it shows a dialog; if it rethrows and the exception can't be stored, the program
terminates after OnUnhandledException() (interface/wx/app.h:466-512), which "should not throw"
(interface/wx/app.h:515-533). With C++11 StoreCurrentException uses std::current_exception
(interface/wx/app.h:555-645). wxHandleFatalExceptions() enables OnFatalException for crashes
(MSVC SEH on MSW; interface/wx/app.h:1636-1653). wxExit() is for emergencies only
(interface/wx/app.h:1758-1767).
Mechanism [source] (src/common/event.cpp:1688-1749 wxEvtHandler::WXConsumeException): when
OnExceptionInMainLoop throws, the active loop Exit()s (unless yielding), the exception is stored
and rethrown by RethrowStoredException once control is back in wx code (end of a loop's Run, end of
YieldFor) — it unwinds out of ShowModal() and the loop. Exceptions must never cross native
callbacks ("can't propagate through the C GTK+ code").
OrcaSlicer policy. GUI_App::OnExceptionInMainLoop → generic_exception_handle():
std::bad_alloc and boost::io::bad_format_string show a wxMessageBox and std::terminate(); any
other std::exception is logged with wxLogError and rethrown (non-std exceptions escape it
unlogged), so per
WXConsumeException the active loop exits and the exception is rethrown out of the main loop. wxEntry
runs OnRun inside wxSafeCall (src/common/init.cpp:550-568, include/wx/private/safecall.h):
CallOnExit runs during unwinding, the default OnUnhandledException prints "Unhandled …;
terminating the application", and wxEntry returns the fatal exit code [source] (255 on every
platform since 3.3, including MSVC builds, docs/changes.txt:17-19). An uncaught
exception in any handler, CallAfter body, timer handler or job finalize therefore ends the
session. Catch inside handlers; workers catch everything themselves.
Debugging. Set the system option catch-unhandled-exceptions to 0 (environment variable
wx_catch_unhandled_exceptions=0) to let the exception abort at the throw site with a usable backtrace
instead of being caught by wxSafeCall — which also wraps every SafelyProcessEvent and event-loop
run, so the option covers handler exceptions too (include/wx/private/safecall.h). Set from code, it
must be set "very early during program startup, within the constructor of the wxApp derivative"
(interface/wx/sysopt.h:16-23, 40-49; 3.3.0+).
Splash screen
Contract. "Show it in application initialisation, and then either explicitly destroy it or let it
time-out" (interface/wx/splash.h:21-22).
Source facts (src/generic/splash.cpp), none of which the docs mention:
wxSplashScreen::Initregisters the splash as a globalwxEventFilter(wxEvtHandler::AddFilter(this));FilterEventcallsClose(true)on anywxEVT_KEY_DOWN,LEFT_DOWN,RIGHT_DOWNorMIDDLE_DOWN;OnCloseWindowcallsDestroy().wxSPLASH_TIMEOUTcloses it from a timer the same way. Both paths destroy it while your code may still hold the pointer.- TLW
Destroy()is deferred to idle, and a full yield runs idle, so the splash is freed inside the nextwxYield(); awxWeakRefto it nulls only when that pending delete runs. - It adds
wxFRAME_TOOL_WINDOW | wxFRAME_NO_TASKBAR, setswxWS_EX_TRANSIENT(never chosen as a parent) and, on GTK,GDK_WINDOW_TYPE_HINT_SPLASHSCREEN.
OrcaSlicer. SplashScreen : wxSplashScreen (GUI_App.cpp):
- no
wxSPLASH_TIMEOUT— a timeout closed it long before init finished, leaving a blank screen; FilterEventoverridden to returnwxEventFilter::Event_Skip, disabling close-on-click/key;- held in
wxWeakRef<SplashScreen> scrninGUI_App::on_init_inner, updated withSetText(text, progress)(+wxYield()at the long phases;OnInitruns before the main loop, sowxYielduses a temporary loop), and removed withscrn->Destroy(); scrn = nullptr;after the main frame is shown; SetTextcopies the text intom_text_action, thenRefresh()/Update(), pluswxYield()under__WXOSX__(without it the splash doesn't update on macOS);- paints itself (
OnPaintonm_window) withStateColor::darkModeColorForcolours; on Wayland it installs an empty CSD titlebar because some compositors ignore the splash type hint;wxSTAY_ON_TOPon macOS.
Pitfalls.
- Rule: If a
wxSplashScreenmust stay up across a long startup phase, overrideFilterEvent()to a no-op, hold it in awxWeakRef, and destroy it only explicitly. Why: the global filterClose()s — and therebyDestroy()s — the splash on any key or mouse press; a stray click during startup freed it inside one ofon_init_inner's ownwxYield()s while the code still held the raw pointer — an intermittent use-after-free (the Linux startup crash). Cite:4088a36095(SplashScreen::FilterEvent,wxWeakRef<SplashScreen> scrninGUI_App::on_init_inner). - Rule: Passing a temporary such as
_L("…") + dotsstraight into aconst wxString¶meter is safe even if the callee yields or repaints. Why: the temporary lives until the end of the full-expression,SplashScreen::SetTextcopies it before yielding, and 3.3'swxStringis a non-COWstd::wstring(include/wx/string.h:119-132).6b55e324c9bound one such temporary to a named local, but that did not address the crash;4088a36095did. - Rule: Never keep a pointer into a temporary conversion buffer beyond its statement:
const char* p = s.ToUTF8().data();(ormb_str(), orc_str()stored asconst char*) dangles —ToUTF8()/utf8_str()return a temporarywxScopedCharBuffer(interface/wx/string.h:755, 886). Copy immediately:std::string u = s.ToUTF8().data();, or uses.utf8_string()(interface/wx/string.h:765) / Orca'sinto_u8(s)(GUI.hpp). Seereferences/strings-i18n-files.md.
wxGetApp() and app_config
wxGetApp()returnsGUI_App&(DECLARE_APP); qualify asGUI::wxGetApp()outsideSlic3r::GUI. Singletons hang off it:app_config,preset_bundle,plater(),mainframe,getDeviceManager(),getAgent().- Lifetime.
app_configis created in theGUI_Appconstructor and is non-null for the whole GUI lifetime.plater()andmainframeare null beforeon_init_innercreates the frame, are replaced duringrecreate_GUI, and must not be used afterMainFrame::shutdown.sidebar(),obj_list()andmodel()dereference the plater unchecked — never call them from code reachable before the plater exists or during shutdown. - Shutdown and threads. Code that can run during shutdown, or on a worker, checks
!wxTheApp || wxGetApp().is_closing()before touching the app (ActionRegistry::init); deferred bodies checkis_closing()again inside.wxGetApp()itself dereferences null once wx cleanup has started. AppConfigis main-thread only. It is a plainstd::mapstore with no locking, andAppConfig::save()throwsCriticalExceptionwhen!is_main_thread_active(). Workers read the values they need before starting, and marshalset/savethroughCallAfter(theGUI_App::load_pending_vendorsshape).- Saving.
set(...)marks the config dirty; the app idle handler saves wheneverdirty()(afterpost_init), andMainFrame::shutdownsaves if dirty. Callapp_config->save()explicitly only when the value must hit disk immediately (Preferences rows do). - Reading. Values are strings:
get("key") == "true";get_bool(key)accepts"true"or"1".get_bool(section, key)tests"true"insectionbut reads the"1"form from the"app"section.AppConfigvswxConfig:references/strings-i18n-files.md.