possible clean-up problem with `sync_to_async`

hi
so i was looking at the test case for signals and saw that there are no tests for the async asend method
so i made a copy of django/tests/dispatch/tests.py at main · django/django · GitHub this test which looks like this:

    async def test_multiple_registration_async(self):
        a = Callable()
        a_signal.connect(a)
        a_signal.connect(a)
        a_signal.connect(a)
        a_signal.connect(a)
        a_signal.connect(a)
        a_signal.connect(a)
        result = await a_signal.asend(sender=self, val="test")
        self.assertEqual(len(result), 1)
        self.assertEqual(len(a_signal.receivers), 1)
        del a
        del result
        garbage_collect()
        self.assertTestIsClean(a_signal)

this uses a sync receiver with asend, so the receiver is ran by sync_to_async

i should mentioned that i have a fork of this which uses pytest with anyio to run async tests

when running this with django’s test runner, i get the following error

Testing against Django installed in '/home/amirreza/projects/django/.venv/lib/python3.13/site-packages/django' with up to 16 processes
Found 22 test(s).
System check identified no issues (0 silenced).
..

test_multiple_registration_async (dispatch.tests.DispatcherTests.test_multiple_registration_async) failed:

    AssertionError('True is not false')

Unfortunately, tracebacks cannot be pickled, making it impossible for the
parallel test runner to handle this exception cleanly.

In order to see the traceback, you should install tblib:

    python -m pip install tblib

multiprocessing.pool.RemoteTraceback: 
"""
Traceback (most recent call last):
  File "/usr/lib/python3.13/unittest/case.py", line 58, in testPartExecutor
    yield
  File "/usr/lib/python3.13/unittest/case.py", line 651, in run
    self._callTestMethod(testMethod)
    ~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^
  File "/usr/lib/python3.13/unittest/case.py", line 606, in _callTestMethod
    if method() is not None:
       ~~~~~~^^
  File "/home/amirreza/projects/django/.venv/lib/python3.13/site-packages/asgiref/sync.py", line 254, in __call__
    return call_result.result()
           ~~~~~~~~~~~~~~~~~~^^
  File "/usr/lib/python3.13/concurrent/futures/_base.py", line 449, in result
    return self.__get_result()
           ~~~~~~~~~~~~~~~~~^^
  File "/usr/lib/python3.13/concurrent/futures/_base.py", line 401, in __get_result
    raise self._exception
  File "/home/amirreza/projects/django/.venv/lib/python3.13/site-packages/asgiref/sync.py", line 331, in main_wrap
    result = await self.awaitable(*args, **kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/amirreza/projects/django/tests/dispatch/tests.py", line 182, in test_multiple_registration_async
    self.assertTestIsClean(a_signal)
    ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^
  File "/home/amirreza/projects/django/tests/dispatch/tests.py", line 35, in assertTestIsClean
    self.assertFalse(signal.has_listeners())
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.13/unittest/case.py", line 738, in assertFalse
    raise self.failureException(msg)
AssertionError: True is not false

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/usr/lib/python3.13/multiprocessing/pool.py", line 125, in worker
    result = (True, func(*args, **kwds))
                    ~~~~^^^^^^^^^^^^^^^
  File "/home/amirreza/projects/django/.venv/lib/python3.13/site-packages/django/test/runner.py", line 462, in _run_subsuite
    result = runner.run(subsuite)
  File "/home/amirreza/projects/django/.venv/lib/python3.13/site-packages/django/test/runner.py", line 377, in run
    test(result)
    ~~~~^^^^^^^^
  File "/usr/lib/python3.13/unittest/suite.py", line 84, in __call__
    return self.run(*args, **kwds)
           ~~~~~~~~^^^^^^^^^^^^^^^
  File "/usr/lib/python3.13/unittest/suite.py", line 122, in run
    test(result)
    ~~~~^^^^^^^^
  File "/home/amirreza/projects/django/.venv/lib/python3.13/site-packages/django/test/testcases.py", line 302, in __call__
    self._setup_and_call(result)
    ~~~~~~~~~~~~~~~~~~~~^^^^^^^^
  File "/home/amirreza/projects/django/.venv/lib/python3.13/site-packages/django/test/testcases.py", line 357, in _setup_and_call
    super().__call__(result)
    ~~~~~~~~~~~~~~~~^^^^^^^^
  File "/usr/lib/python3.13/unittest/case.py", line 707, in __call__
    return self.run(*args, **kwds)
           ~~~~~~~~^^^^^^^^^^^^^^^
  File "/usr/lib/python3.13/unittest/case.py", line 650, in run
    with outcome.testPartExecutor(self):
         ~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
  File "/usr/lib/python3.13/contextlib.py", line 162, in __exit__
    self.gen.throw(value)
    ~~~~~~~~~~~~~~^^^^^^^
  File "/usr/lib/python3.13/unittest/case.py", line 75, in testPartExecutor
    _addError(self.result, test_case, exc_info)
    ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.13/unittest/case.py", line 98, in _addError
    result.addFailure(test, exc_info)
    ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^
  File "/home/amirreza/projects/django/.venv/lib/python3.13/site-packages/django/test/runner.py", line 307, in addFailure
    self.check_picklable(test, err)
    ~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^
  File "/home/amirreza/projects/django/.venv/lib/python3.13/site-packages/django/test/runner.py", line 215, in check_picklable
    self._confirm_picklable(err)
    ~~~~~~~~~~~~~~~~~~~~~~~^^^^^
  File "/home/amirreza/projects/django/.venv/lib/python3.13/site-packages/django/test/runner.py", line 185, in _confirm_picklable
    pickle.loads(pickle.dumps(obj))
                 ~~~~~~~~~~~~^^^^^
TypeError: cannot pickle 'traceback' object
"""

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/amirreza/projects/django/tests/runtests.py", line 788, in <module>
    failures = django_tests(
        options.verbosity,
    ...<16 lines>...
        getattr(options, "durations", None),
    )
  File "/home/amirreza/projects/django/tests/runtests.py", line 427, in django_tests
    failures = test_runner.run_tests(test_labels)
  File "/home/amirreza/projects/django/.venv/lib/python3.13/site-packages/django/test/runner.py", line 1093, in run_tests
    result = self.run_suite(suite)
  File "/home/amirreza/projects/django/.venv/lib/python3.13/site-packages/django/test/runner.py", line 1020, in run_suite
    return runner.run(suite)
           ~~~~~~~~~~^^^^^^^
  File "/usr/lib/python3.13/unittest/runner.py", line 240, in run
    test(result)
    ~~~~^^^^^^^^
  File "/usr/lib/python3.13/unittest/suite.py", line 84, in __call__
    return self.run(*args, **kwds)
           ~~~~~~~~^^^^^^^^^^^^^^^
  File "/home/amirreza/projects/django/.venv/lib/python3.13/site-packages/django/test/runner.py", line 549, in run
    subsuite_index, events = test_results.next(timeout=0.1)
                             ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
  File "/usr/lib/python3.13/multiprocessing/pool.py", line 873, in next
    raise value
TypeError: cannot pickle 'traceback' object
Exception ignored in: <function Pool.__del__ at 0x7738feae67a0>
Traceback (most recent call last):
  File "/usr/lib/python3.13/multiprocessing/pool.py", line 268, in __del__
ResourceWarning: unclosed running multiprocessing pool <multiprocessing.pool.Pool state=RUN pool_size=2>

with the pytest version, i only get the assertion error, not the pickle error

essentially the error happens because the receiver still exists, even after garbage collecting

i managed to fix this issue by adding a asyncio.sleep(0) after the garbage collector call
after the sleep, there are no receivers left

my guess is that sync_to_async is somehow keeping the sync receiver from being collected

you can find my pytest version of this at signals-py/tests/test_dispatch/test_dispatch.py at main · khiyavan/signals-py · GitHub

Hi @amirreza8002,

Can you give the branch from this PR a run and report back the results?

hi @carltongibson
it gave the same result

for easy access i’ll post the result from pytest as well

============================================================================================================================= FAILURES ==============================================================================================================================
__________________________________________________________________________________________________________ TestDispatcherAsync.test_multiple_registration ___________________________________________________________________________________________________________

self = <tests.test_dispatch.test_dispatch.TestDispatcherAsync object at 0x7dfd42bc2b50>

    async def test_multiple_registration(self):
        a = Callable()
        a_signal.connect(a)
        a_signal.connect(a)
        a_signal.connect(a)
        a_signal.connect(a)
        a_signal.connect(a)
        a_signal.connect(a)
        result = await a_signal.asend(sender=self, val="test")
        assert len(result) == 1
        assert len(a_signal.receivers) == 1
        del a
        del result
        garbage_collect()
        # await asyncio.sleep(0)
>       self.assert_test_is_clean(a_signal)

tests/test_dispatch/test_dispatch.py:430: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <tests.test_dispatch.test_dispatch.TestDispatcherAsync object at 0x7dfd42bc2b50>, signal = <signals.dispatch.dispatcher.Signal object at 0x7dfd42c012b0>

    def assert_test_is_clean(self, signal: Signal):
        print(signal.receivers)
>       assert not signal.has_listeners()
E       assert not True
E        +  where True = has_listeners()
E        +    where has_listeners = <signals.dispatch.dispatcher.Signal object at 0x7dfd42c012b0>.has_listeners

tests/test_dispatch/test_dispatch.py:315: AssertionError
----------------------------------------------------------------------------------------------------------------------- Captured stdout call ------------------------------------------------------------------------------------------------------------------------
[((138526690167888, 138526740808176), <weakref at 0x7dfd42293b00; to 'tests.test_dispatch.test_dispatch.Callable' at 0x7dfd4228cc50>, None, False)]
====================================================================================================================== short test summary info ======================================================================================================================
FAILED tests/test_dispatch/test_dispatch.py::TestDispatcherAsync::test_multiple_registration - assert not True

i added a print that shows the list of receivers

this is from the test with the branch from the linked PR
django’s test gave the same error as i posted before

So, yes, I’d need to dig into that. The interesting question is why releasing control back to the event loop allows the reference to clear. :thinking: (without digging I can’t say)

The asgiref PR is not that issue.

FWIW if you install tblib, as per the error message, you can see the stacktrace for the test with the Django test runner.

1 Like