Skip to content

Commit

Permalink
fix: ensure proper order of signal emmision (#281)
Browse files Browse the repository at this point in the history
  • Loading branch information
Czaki authored Feb 27, 2024
1 parent 4aa62b0 commit 4b9434e
Show file tree
Hide file tree
Showing 3 changed files with 103 additions and 13 deletions.
40 changes: 27 additions & 13 deletions src/psygnal/_signal.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import inspect
import sys
import threading
import warnings
import weakref
Expand Down Expand Up @@ -52,6 +53,7 @@
__all__ = ["Signal", "SignalInstance", "_compiled"]
_NULL = object()
F = TypeVar("F", bound=Callable)
RECURSION_LIMIT = sys.getrecursionlimit()


class Signal:
Expand Down Expand Up @@ -339,6 +341,7 @@ def __init__(
self._is_blocked: bool = False
self._is_paused: bool = False
self._lock = threading.RLock()
self._emit_queue: list[tuple] = []

@staticmethod
def _instance_ref(instance: Any) -> Callable[[], Any]:
Expand Down Expand Up @@ -947,19 +950,29 @@ def __call__(
def _run_emit_loop(self, args: tuple[Any, ...]) -> None:
# allow receiver to query sender with Signal.current_emitter()
with self._lock:
with Signal._emitting(self):
for caller in self._slots:
try:
caller.cb(args)
except RecursionError as e:
raise RecursionError(
f"RecursionError in {caller.slot_repr()} when "
f"emitting signal {self.name!r} with args {args}"
) from e
except Exception as e:
raise EmitLoopError(
cb=caller, args=args, exc=e, signal=self
) from e
self._emit_queue.append(args)

if len(self._emit_queue) > 1:
return None
try:
with Signal._emitting(self):
i = 0
while i < len(self._emit_queue):
args = self._emit_queue[i]
for caller in self._slots:
caller.cb(args)
if len(self._emit_queue) > RECURSION_LIMIT:
raise RecursionError
i += 1
except RecursionError as e:
raise RecursionError(
f"RecursionError in {caller.slot_repr()} when "
f"emitting signal {self.name!r} with args {args}"
) from e
except Exception as e:
raise EmitLoopError(cb=caller, args=args, exc=e, signal=self) from e
finally:
self._emit_queue.clear()

return None

Expand Down Expand Up @@ -1108,6 +1121,7 @@ def __getstate__(self) -> dict:
"_args_queue",
"_check_nargs_on_connect",
"_check_types_on_connect",
"_emit_queue",
)
dd = {slot: getattr(self, slot) for slot in attrs}
dd["_instance"] = self._instance()
Expand Down
26 changes: 26 additions & 0 deletions tests/containers/test_evented_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,29 @@ def test_copy_no_sync():
s2 = copy(s1)
s1.add(4)
assert len(s2) == 3


def test_set_emission_order():
s = EventedSet()

def callback1():
if 1 not in s:
s.add(1)

def callback2():
if 5 not in s:
s.update(range(5, 10))

s.events.items_changed.connect(callback1)
s.events.items_changed.connect(callback2)
mock = Mock()
s.events.items_changed.connect(mock)

s.add(11)
mock.assert_has_calls(
[
call((11,), ()),
call((1,), ()),
call((5, 6, 7, 8, 9), ()),
]
)
50 changes: 50 additions & 0 deletions tests/test_psygnal.py
Original file line number Diff line number Diff line change
Expand Up @@ -988,3 +988,53 @@ def callback() -> None:

with pytest.raises(RecursionError):
s.emit()


def test_signal_order():
"""Test that signals are emitted in the order they were connected."""
emitter = Emitter()
mock1 = Mock()
mock2 = Mock()

def callback(x):
if x != 10:
emitter.one_int.emit(10)

emitter.one_int.connect(mock1)
emitter.one_int.connect(callback)
emitter.one_int.connect(mock2)
emitter.one_int.emit(1)

mock1.assert_has_calls([call(1), call(10)])
mock2.assert_has_calls([call(1), call(10)])


def test_signal_order_suspend():
"""Test that signals are emitted in the order they were connected."""
emitter = Emitter()
mock1 = Mock()
mock2 = Mock()

def callback(x):
if x < 10:
emitter.one_int.emit(10)

def callback2(x):
if x == 10:
emitter.one_int.emit(11)

def callback3(x):
if x == 10:
with emitter.one_int.paused(reducer=lambda a, b: (a[0] + b[0],)):
for i in range(12, 15):
emitter.one_int.emit(i)

emitter.one_int.connect(mock1)
emitter.one_int.connect(callback)
emitter.one_int.connect(callback2)
emitter.one_int.connect(callback3)
emitter.one_int.connect(mock2)
emitter.one_int.emit(1)

mock1.assert_has_calls([call(1), call(10), call(11), call(39)])
mock2.assert_has_calls([call(1), call(10), call(11), call(39)])

0 comments on commit 4b9434e

Please sign in to comment.