Skip to content

fix: run ExposureWidget and PropertyWidget core-event handlers on the main thread - #563

Draft
hinderling wants to merge 2 commits into
pymmcore-plus:mainfrom
hinderling:fix/exposure-widget-thread-safety
Draft

hinderling wants to merge 2 commits into
pymmcore-plus:mainfrom
hinderling:fix/exposure-widget-thread-safety

Conversation

@hinderling

@hinderling hinderling commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Core events can be delivered on a thread other than the GUI thread (for example with the psygnal signal backend during an acquisition). Two widgets update Qt widgets directly inside those handlers.

  • PropertyWidget._on_core_changed sets its slider/spinbox value under blockSignals. When called from a worker thread, the inner slider and spinbox valueChanged signals are delivered to the main thread after the block is released, so _on_widget_changed writes the value back into the core. With the Device Property Browser open during an acquisition, this rewrote the SLM ExposureTime several hundred times per run on a microscope and changed the illumination of frames. Reproducible with the demo config: 6 core writes from a worker thread produce 12 write-backs from the main thread.
  • ExposureWidget._on_exp_changed and _on_load set a spinbox from the worker thread. That is not thread safe and crashes the process under load (demo config, 6 of 9 runs).

Fix

Wrap the four handlers in superqt's ensure_main_thread. Under the Qt backend these calls already arrive queued on the main thread, so there is no behavior change there.

Tests

One test per widget calls the handler from a worker thread and asserts that the widget updates and nothing is written back to the core. Both fail on main.

With the psygnal signal backend, core events run their handlers on the
emitting thread. During an acquisition that is the MDA thread, so
_on_exp_changed / _on_load mutated the spinbox from a non-GUI thread.
Cross-thread widget mutation is undefined behavior, and in particular
the signals_blocked guard is released before Qt processes the
cross-thread setValue, so the spinbox can later emit valueChanged
(which is wired to mmc.setExposure) with a stale value, overriding the
exposure of frames in a running acquisition. Observed in the field as
episodic two-state brightness flicker in multichannel timelapses:
random frames acquired at the widget's exposure instead of the
event's, with per-channel brightness ratios matching one stray
exposure value.

Both handlers now use superqt's ensure_main_thread, so the update is
queued to the GUI thread and applied inside the signals_blocked guard.
The added test asserts that a worker-thread event does not mutate the
spinbox synchronously and lands without emitting valueChanged.
@codecov

codecov Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.42%. Comparing base (d61a158) to head (66d974d).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #563      +/-   ##
==========================================
+ Coverage   87.36%   87.42%   +0.06%     
==========================================
  Files         115      115              
  Lines       14098    14103       +5     
==========================================
+ Hits        12317    12330      +13     
+ Misses       1781     1773       -8     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@hinderling
hinderling marked this pull request as draft September 1, 2026 22:19
Core events can arrive on a worker thread. Setting the compound
slider/spinbox from there lets the inner widgets' valueChanged reach the
main thread after blockSignals is released, and the widget writes the
value back to the core. Wrap _on_core_changed and _on_config_loaded in
ensure_main_thread, add a test, drop the unused type-ignore comments and
trim the comments in ExposureWidget.
@hinderling hinderling changed the title fix: marshal ExposureWidget core-event updates to the main thread fix: run ExposureWidget and PropertyWidget core-event handlers on the main thread Sep 3, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant