Disconnecting Signals Safely on Plugin Unload
Stop leaked Qt connections from firing into a dead plugin: record every connection as you make it, disconnect before removing UI, handle the exceptions that…
TL;DR: record every (signal, slot) pair as you connect it, disconnect the recorded list first in unload() — before removing any UI — and swallow the TypeError and RuntimeError that a disconnect raises when the connection or the emitter has already gone. This page is part of the signal and slot event handling guide.
Complete Runnable Code
"""A connection registry that makes teardown exact rather than remembered."""
from qgis.core import QgsMessageLog, Qgis
class ConnectionRegistry:
"""Records every connection a plugin makes so unload() can undo all of them."""
def __init__(self, log_tag: str = "MyPlugin"):
self._pairs: list[tuple] = []
self._tag = log_tag
def connect(self, signal, slot) -> None:
"""Connect and remember. Use this instead of signal.connect() everywhere."""
signal.connect(slot)
self._pairs.append((signal, slot))
def disconnect_all(self) -> int:
"""Undo every recorded connection. Safe to call twice. Returns the count."""
undone = 0
for signal, slot in self._pairs:
try:
signal.disconnect(slot)
undone += 1
except TypeError:
pass # already disconnected
except RuntimeError:
pass # the emitting C++ object is gone
except Exception as exc: # never let teardown stop here
QgsMessageLog.logMessage("disconnect failed: %s" % exc,
self._tag, Qgis.Warning)
self._pairs.clear()
return undone
class MyPlugin:
def __init__(self, iface):
self.iface = iface
self.signals = ConnectionRegistry()
def initGui(self) -> None:
project = self.iface.mapCanvas()
self.signals.connect(project.extentsChanged, self.on_extent)
self.signals.connect(self.iface.currentLayerChanged, self.on_layer)
def unload(self) -> None:
self.signals.disconnect_all() # always first
# ... then remove actions, docks and canvas items
def on_extent(self) -> None:
pass
def on_layer(self, layer) -> None:
pass
Architecture Breakdown
Python’s collector does not sever Qt connections
A Qt connection is held on the C++ side by the emitting object. Dropping the Python reference to the receiver does not remove it; the connection remains, and the next emission calls into an object Python considers dead. What happens then ranges from a RuntimeError in the log to a hard crash, depending on what the slot touches.
This is the essential difference from a pure-Python callback list, and it is why teardown has to be explicit rather than implicit.
Signals that outlive your plugin
The emitters that matter are the ones QGIS owns: the project, the canvas, the interface, and any layer that stays loaded. Those objects survive your plugin by design, so every connection to them is a connection that must be undone by hand.
The canvas row is the loudest failure — a leaked extentsChanged connection produces an error every time the user moves the map, which is immediately after unload and continuously thereafter. The task row is the quietest and the most confusing, because the error arrives minutes later with no obvious cause.
Disconnect before removing UI
Order matters. Removing an action or a dock widget can itself emit signals, and if your slots are still connected they run against a plugin that is midway through dismantling itself. Disconnecting first means nothing your code owns can be invoked during the rest of the teardown.
Slots That Are Not Bound Methods
Whether a connection can be undone at all depends on what kind of object the slot is, and the three cases behave quite differently.
A bound method is the easy case: it is identifiable, so disconnect(self.on_extent) finds exactly the right connection. A lambda is not — disconnect(lambda: ...) cannot match anything, because the lambda you pass is a different object from the one you connected.
That makes a tracked registry more than a convenience for closures: it is the only reliable way to remove them, because the recorded reference is the same object that was connected. It is also an argument for preferring bound methods and functools.partial over lambdas in plugin code, since both survive being written down.
The third case — a slot on a widget the user can close — is where weak references earn their place. A connection holding a strong reference keeps the widget alive after its window is gone, and the slot then runs on something invisible.
Verifying That Nothing Is Left
def count_receivers(signal_owner, signal_name: str) -> int:
"""How many slots are connected to a signal, for a teardown assertion."""
return signal_owner.receivers(getattr(signal_owner, signal_name))
def test_unload_disconnects_everything(qgis_iface):
canvas = qgis_iface.mapCanvas()
before = count_receivers(canvas, "extentsChanged")
plugin = MyPlugin(qgis_iface)
plugin.initGui()
assert count_receivers(canvas, "extentsChanged") > before
plugin.unload()
assert count_receivers(canvas, "extentsChanged") == before
receivers() is a Qt method available on any QObject, and comparing the count before and after is a far stronger assertion than “no exception was raised”. It catches the connection somebody added last week and forgot to record.
Why This Is Worth a Registry Rather Than Discipline
It is tempting to treat this as a matter of care: connect deliberately, remember what you connected, disconnect the same list by hand. That works for the first release and degrades from there, because connections accumulate in different methods, added by different people, at different times.
A registry converts the problem from remembering to enumerating. The plugin cannot make a connection without recording it, because the recording is the connection call, so the list is correct by construction rather than by attention. That property survives new contributors and long gaps between releases, which discipline does not.
It also makes the teardown reviewable. A reviewer can see that unload() disconnects everything in the registry, and separately that every connection goes through the helper — two small assertions that together prove something much harder to check by reading a scattered set of connect calls.
Production Best Practices
- Connect through one helper, so no connection can escape the registry.
- Disconnect first in
unload(), before touching any UI. - Swallow
TypeErrorandRuntimeErrorfrom disconnect; both are normal. - Prefer bound methods to lambdas for anything long-lived.
- Clear the registry after undoing it, so teardown is idempotent.
- Assert on
receivers()in a test, not just on the absence of errors.
Frequently Asked Questions
Do I need to disconnect signals from objects my plugin owns?
Strictly no — when the emitter is destroyed, its connections go with it, so a signal from a widget your plugin created and deletes needs no explicit disconnect. Doing it anyway costs nothing and removes the need to reason about which category each connection falls into, which is worth more than the microseconds saved.
What does disconnect() raise, and when?
TypeError when the connection does not exist — including when it has already been removed — and RuntimeError when the underlying C++ emitter has been deleted. Both are expected during a normal shutdown, which is why they are caught and ignored rather than logged.
Can I disconnect everything from a signal at once?
signal.disconnect() with no argument removes every connection to that signal, including ones made by QGIS itself or by other plugins. That is almost always wrong: it fixes your leak by breaking somebody else’s feature, and the resulting bug report goes to them.
How do I find a connection I forgot to record?
Compare receivers() before and after a load-unload cycle, as the test above does, and narrow by signal until the count does not return to its baseline. Reloading the plugin repeatedly and watching the Python error log is the manual equivalent and usually faster in practice.
What about connections made inside a dialog?
If the dialog is deleted when it closes, its own connections go with it. Connections it makes to QGIS objects do not, so a dialog that listens to layer signals must disconnect them in its closeEvent — or, better, use the same registry pattern scoped to the dialog.
Does connection order matter?
Qt invokes slots in connection order, which is stable but not something to rely on. A plugin whose behaviour depends on running before or after another plugin’s slot is depending on load order, which the user controls through the Plugin Manager and which changes when they install something new.
Where ordering genuinely matters, express it in your own code — one slot that does both things in sequence — rather than in the connection sequence.
Does QgsTask need this treatment?
Yes, and with an extra step: cancel the task as well as disconnecting from it. A running task holding a reference to your plugin keeps it alive after unload, and its completion signal fires into an object that no longer has a place in the application.
Related
- Signal and Slot Event Handling in QGIS — the parent guide covering Qt dispatch
- Writing an Idempotent Plugin unload() Method — the teardown this fits inside
- Memory Management and Garbage Collection for GIS Objects — why a connection keeps objects alive