Debug events¶
Cellier uses a synchronous event bus to coordinate changes between the model layer, GUI widgets, and the render layer. When something isn't updating as expected (e.g., a widget not responding or a render not triggering), the event system is often the place to look,
This document describes the three debugging tools available and walks through the most common failure scenarios.
Concepts¶
Events and entities¶
Every change in the system is announced as a typed event on the EventBus.
Each event type is associated with one entity — the model object that
changed. The entity field is the routing key the bus uses to deliver events
only to subscribers that care about a specific object:
| Event type | Entity | Entity field |
|---|---|---|
DimsChangedEvent, CameraChangedEvent, ResliceStartedEvent, VisualAddedEvent, VisualRemovedEvent, SceneAddedEvent, SceneRemovedEvent |
Scene | scene_id |
AppearanceChangedEvent, ChannelAppearanceChangedEvent, PickWriteChangedEvent, AABBChangedEvent, VisualVisibilityChangedEvent, TransformChangedEvent, ResliceCompletedEvent, ResliceCancelledEvent |
Visual | visual_id |
FrameRenderedEvent |
Canvas | canvas_id |
DataStoreMetadataChangedEvent, DataStoreContentsChangedEvent |
Data store | data_store_id |
CanvasMousePress2DEvent, CanvasMouseMove2DEvent, CanvasMouseRelease2DEvent, CanvasMousePress3DEvent, CanvasMouseMove3DEvent, CanvasMouseRelease3DEvent |
Canvas (emitter) | source_id |
A subscriber registered with entity_id=some_visual_id only receives events
for that visual. A subscriber registered with entity_id=None receives every
event of that type regardless of which entity changed.
source_id and echo filtering¶
Every event carries a source_id — the UUID of the object that triggered the
change. GUI widgets use this to prevent a loop where its own update event triggers an update. That is, the
widget checks if event.source_id == self._id: return to ignore changes it
caused itself.
source_id is injected via a ContextVar side-channel in the three controller
mutation methods:
controller.update_slice_indices(scene_id, {0: 5}, source_id=widget._id)
controller.update_appearance_field(visual_id, "clim", (0.0, 1.0), source_id=widget._id)
controller.update_aabb_field(visual_id, "enabled", True, source_id=widget._id)
If a model field is mutated directly (bypassing these methods), source_id
falls back to the controller's own ._id.
Tool 1 — EventBus.get_subscribers()¶
The primary inspection tool. Returns a list of SubscriberInfo objects
describing every callback registered for a given event type.
from cellier.events import DimsChangedEvent, AppearanceChangedEvent
subscribers = controller._outgoing_events.get_subscribers(DimsChangedEvent)
for info in subscribers:
print(info.callback_qualname, info.owner_id, info.entity_id, info.is_alive)
Example output:
SliceCoordinator._on_dims_changed <UUID-coordinator> None True
CellierController._on_dims_changed_bus <UUID-controller> None True
QtDimsControl._on_dims_changed <UUID-slider> <UUID-scene> True
Filtering by entity¶
Pass entity_id to scope results to one scene, visual, or canvas. The results
include both subscriptions scoped to that entity and unscoped subscriptions
(entity_id=None), because both would fire for that entity's events:
# Everything that fires when scene X's dims change.
subscribers = controller._outgoing_events.get_subscribers(
DimsChangedEvent, entity_id=scene.id
)
Inspecting the live subscriber object¶
SubscriberInfo.callback_instance holds the bound object for method callbacks.
Use this in a debugging session to inspect the subscriber directly:
for info in controller._outgoing_events.get_subscribers(AppearanceChangedEvent):
print(type(info.callback_instance).__name__, info.callback_qualname)
# e.g. GFXMultiscaleImageVisual on_appearance_changed
SubscriberInfo fields¶
| Field | Type | Description |
|---|---|---|
callback_qualname |
str |
Dotted name of the callback, e.g. "QtDimsControl._on_dims_changed". "(dead)" if the weak reference was collected. |
callback_instance |
object |
The bound instance for a method callback; None for plain functions or dead weak refs. |
owner_id |
UUID or None |
UUID used to group this subscription for bulk removal via unsubscribe_all. |
entity_id |
UUID or None |
Entity this subscription is scoped to, or None for unscoped. |
is_weak |
bool |
True when the bus holds a weak reference to the callback. |
is_alive |
bool |
False when a weak reference has been garbage-collected. |
Tool 2 — _Subscription.__repr__¶
The internal _Subscription objects stored in EventBus._subs now have a
human-readable repr. This is useful when you break inside EventBus.emit()
or inspect _subs directly in a debugger:
Each entry now prints as:
_Subscription(callback='QtDimsControl._on_dims_changed' owner_id=3f2a... entity_id=9c1b... strong/alive)
_Subscription(callback='CellierController._on_dims_changed_bus' owner_id=1a2b... entity_id=None strong/alive)
_Subscription(callback='(dead)' owner_id=7d4e... entity_id=9c1b... weak/dead)
The strong/alive, weak/alive, and weak/dead suffixes immediately show
whether a subscription is still active.
Tool 3 — Source ID logging¶
Traces source_id injection through the ContextVar → psygnal → bus bridge.
Enable it to see who triggered each model mutation, which bridge handler
resolved the ContextVar, and whether it fell back to the controller's own ID.
What the output looks like¶
A slider moving its contrast-limits produces three lines:
[SOURCE_ID] set field=clim visual=9c1b... source=3f2a...
[SOURCE_ID] bridge handler=_on_appearance_psygnal visual=9c1b... field=clim resolved_source=3f2a... override_active=True
[SOURCE_ID] reset field=clim visual=9c1b...
set—update_appearance_fieldwas called withsource_id=widget._id.bridge— the psygnal handler fired, read theContextVar, and resolved the source.override_active=Trueconfirms it came from a controller method.reset— theContextVarwas restored after the mutation.
The [SOURCE_ID] prefix is added by the Rich handler (the default,
use_rich=True). With enable_debug_logging(..., use_rich=False) the records
carry no color label — each line is prefixed with the logger name
cellier.render.source_id instead. The set / bridge / reset message
bodies are identical either way.
Spotting a direct model mutation¶
If override_active=False appears in a bridge line, the model field was
mutated directly (bypassing update_appearance_field or similar), and the
source_id fell back to the controller's own ID:
[SOURCE_ID] bridge handler=_on_appearance_psygnal visual=9c1b... field=clim resolved_source=<controller-id> override_active=False
This means no widget will echo-filter the event — all subscribers will receive it as if an external change occurred.
Common failure scenarios¶
Subscriber not firing¶
Symptom: a widget or render layer callback is not called when the model changes.
- Check that the subscription exists and is alive:
- If
is_alive=False— the subscriber was garbage-collected. The subscription was registered withweak=Trueand the owning object has been destroyed. Ensure the object is kept alive for as long as the subscription is needed, or callcontroller.unsubscribe_owner(owner_id)at teardown rather than relying on GC. - If the subscriber is missing entirely —
subscribewas never called, orunsubscribe_allwas called prematurely. Check widget teardown paths. - If
entity_idon the subscription does not match theentity_idof the emitted event — the event will be silently skipped. Confirm that the UUID passed tosubscribematches the UUID of the model object that changed.
Widget updating when it shouldn't (echo not filtered)¶
Symptom: a widget re-applies a value it just set, causing flickering or redundant work.
- Enable source ID logging and move the widget:
- Check
override_activein thebridgeline. If it isFalse, the mutation bypassedupdate_appearance_fieldandsource_idwas set to the controller's ID — the widget'sif event.source_id == self._idcheck will never match. - Confirm the widget passes its own
._idassource_id: Not passingsource_iddefaults to the controller's ID, which no widget will match.
Infinite update loop¶
Symptom: the application hangs or the stack overflows after a widget interaction.
The safeguard chain is: source_id echo filter (skip redundant self-updates)
and blockSignals (prevent programmatically-updated Qt widgets from re-firing
their signals). Both must be in place. If blockSignals is missing from a
widget's _set_value path, a programmatic update to widget B will fire
widget B's valueChanged, which will call update_*, which will emit an event,
which will update widget A, and so on.
To diagnose:
- Check
get_subscribersfor the event type — confirm the chain of subscriptions matches your expectations. - Add a temporary breakpoint in
EventBus.emitand inspect the call stack depth. A loop will show repeated frames. - Confirm every widget's programmatic update path calls
blockSignals(True)before setting the value andblockSignals(False)after.
Source ID belongs to the wrong object¶
Symptom: a widget receives an event and does not filter it, even though the widget itself caused the change.
Enable source ID logging and compare the resolved_source in the bridge line
against widget._id. Common causes:
- The widget passed a different UUID to
source_idthan the one it uses in theif event.source_id == self._idcheck. - Two different widget instances share the same model visual but each have their
own
._id. Widget A's change arrives at widget B withsource_id=A._id, which B does not filter — this is correct behaviour. TheblockSignalsguard in B's_set_valueprevents the cascade.