Skip to content

Reap asyncio Tasks on SyncWrapper.run_sync to prevent per-call retention - #70171

Open
dwoz wants to merge 3 commits into
saltstack:3008.xfrom
dwoz:dwoz/fix/syncwrapper-asyncio-task-leak-3008.x
Open

Reap asyncio Tasks on SyncWrapper.run_sync to prevent per-call retention#70171
dwoz wants to merge 3 commits into
saltstack:3008.xfrom
dwoz:dwoz/fix/syncwrapper-asyncio-task-leak-3008.x

Conversation

@dwoz

@dwoz dwoz commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

SyncWrapper._target at salt/utils/asynchronous.py:270 installs self.asyncio_loop as the current asyncio loop on the worker thread with asyncio.set_event_loop(asyncio_loop), then drives the wrapped coroutine through tornado's io_loop.run_sync. Any asyncio.Task the wrapped coroutine schedules on the current asyncio loop and does not await -- pyzmq's future-based sockets and tornado's asyncio bridge fire tasks on the current asyncio loop internally -- survives past the run_sync window and pins its coroutine + contextvars.Context until close() is called. Long-lived driver processes (EventReturn, BatchManager) don't call close() in steady state, so the retention accumulates for the process lifetime.

Evidence from a 26-hour observation on a live master (gdb-injected PyRun_SimpleString(gc.get_objects())):

  • EventReturn retained 18,107 asyncio.Task + 18,109 coroutine + 18,129 contextvars.Context at a 1:1:1 ratio.
  • Steady-state slope: 0.36 MB/hr on EventReturn; 0.30 MB/hr on BatchManager.
  • EventPublisher control process (no SyncWrapper) had zero retention -- confirms the mechanism is SyncWrapper-specific.

After io_loop.run_sync returns in _target, cancel all pending tasks on asyncio_loop and drive the loop until they finish, matching the pattern already used in close(). Same set of tasks that would eventually be reaped at close() are reaped after every dispatch instead. Gated on _loop_can_run_until_complete(asyncio_loop) so shutdown-race edges match the close() path.

Fixes #70169

Affected files

  • salt/utils/asynchronous.py -- _target now reaps pending tasks in a finally block after run_sync returns.
  • tests/pytests/unit/utils/test_asynchronous.py -- new regression test test_sync_wrapper_reaps_pending_tasks_after_run_sync; xfail marker added in the previous commit is removed by the fix commit.
  • changelog/70169.fixed.md -- towncrier entry.

Test plan

  • venv314/bin/pytest tests/pytests/unit/utils/test_asynchronous.py -v -- 7 passed (including the flipped xfail).
  • venv314/bin/pytest tests/pytests/unit/utils/ --ignore=tests/pytests/unit/utils/test_vmware.py -q -- 2147 passed, 385 skipped. 2 unrelated pre-existing failures (test_pycrypto::test_gen_hash_passlib[blowfish-expected2], verify/test_verify.py::test_verify_socket) confirmed to fail on origin/3008.x too.
  • venv314/bin/pre-commit run --files salt/utils/asynchronous.py tests/pytests/unit/utils/test_asynchronous.py changelog/70169.fixed.md -- all hooks pass.

dwoz added 2 commits August 27, 2026 18:09
Refs saltstack#70169.

SyncWrapper._target installs self.asyncio_loop as the current asyncio
loop on the worker thread but drives the wrapped coroutine through
tornado's io_loop.run_sync.  Any asyncio.Task created inside the wrapped
coroutine that doesn't complete within the run_sync window is left
pending on self.asyncio_loop, pinning its coroutine + contextvars.Context
until close() is called.

Marked xfail(strict=True) until the fix commit lands.
Fixes saltstack#70169.

SyncWrapper._target installs self.asyncio_loop as the current asyncio
loop on the worker thread and drives the wrapped coroutine through
tornado's io_loop.run_sync.  Any asyncio.Task the wrapped coroutine
schedules on the current asyncio loop and does not await -- pyzmq's
future-based sockets and tornado's asyncio bridge do this internally --
survives past the run_sync window, pinning its coroutine and
contextvars.Context until close().

Long-lived driver processes (EventReturn, BatchManager) don't call
close() in steady state, so the retention accumulates for the process
lifetime.  A 26-hour observation on a live master saw ~18k retained
Task/coroutine/Context triples on EventReturn (0.36 MB/hr).

After run_sync returns, cancel all pending tasks on asyncio_loop and
drive the loop until they finish, matching the pattern already used in
close().  The regression test xfail marker added by the previous commit
is removed.
@dwoz
dwoz requested a review from a team as a code owner August 28, 2026 01:12
@dwoz dwoz added the test:full Run the full test suite label Aug 28, 2026
@dwoz dwoz added this to the Argon v3008.3 milestone Aug 28, 2026
Follow-up to the reap-on-run_sync fix.  The previous commit
blanket-cancelled every pending asyncio.Task on ``asyncio_loop``
after ``run_sync`` returned, which broke clients like
``salt.transport.tcp.PublishClient`` that intentionally keep a
persistent ``_read_into_unpacker`` task in flight across multiple
``recv()`` calls.  Cancelling that task mid-lifecycle left
``tornado.iostream.IOStream._read_future`` set, and the next
``recv()`` failed with ``AssertionError: Already reading``.

Symptom on CI (test:full run 33132150880): uniform failures across
all Linux distros in ``functional zeromq 1``, ``integration zeromq
1/5/7``, ``scenarios zeromq``, and ``unit zeromq 4``.  Concrete
minimal reproduction was ``tests/pytests/unit/test_minion.py::
test_minion_manager_async_stop`` which drives ``SaltEvent.subscriber``
(a ``SyncWrapper(ipc_publish_client)``) through two consecutive
``recv()`` calls -- the second raised ``Already reading``.
Long-running integration jobs (``integration zeromq 5`` at 2h10m,
``scenarios zeromq`` at 1h30m) hit the same root cause but
manifested as ``test.ping`` / ``mine.get`` / ``publish.publish``
timing out because the master's own event subscriber recv loop
was silently torn down after each dispatch.

Only cancel tasks that (a) did not exist before this ``run_sync``
call, and (b) have no strong user-object referrer -- i.e. weren't
stored as an attribute on the wrapped object.  Filter referrers by
type: internal asyncio / GC machinery (``coroutine``, ``Task``,
``Future``, ``FrameType``, ``TaskStepMethWrapper``, containers)
doesn't count as ownership, but a ``PublishClient`` /
``AsyncReqChannel`` / etc. holding the task as ``self._read_task``
does.

The regression test from saltstack#70169 continues to pass -- its
``_AsyncioTaskScheduler.schedule_and_return`` fires
``loop.create_task(_child())`` without storing the returned task,
so ``_child`` has no user-object referrer and is correctly reaped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test:full Run the full test suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants