Skip to content

[wip] close async audit 3008.x - #70322

Merged
dwoz merged 12 commits into
saltstack:3008.xfrom
dwoz:vcops-90587/close-async-audit-3008.x
Sep 25, 2026
Merged

dwoz merged 12 commits into
saltstack:3008.xfrom
dwoz:vcops-90587/close-async-audit-3008.x

Conversation

@dwoz

@dwoz dwoz commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

What issues does this PR fix or reference?

Fixes

Previous Behavior

Remove this section if not relevant

New Behavior

Remove this section if not relevant

Merge requirements satisfied?

[NOTICE] Bug fixes or features added to Salt require tests.

Commits signed with GPG?

Yes/No

@dwoz
dwoz requested a review from a team as a code owner September 24, 2026 21:29
@dwoz dwoz added the test:full Run the full test suite label Sep 24, 2026
dwoz added 10 commits September 25, 2026 00:48
…connect notify

``AsyncPubChannel.connect_callback`` fires a ``_minion_event`` on the
master when a reconnect completes.  The one-shot ``AsyncReqChannel``
it uses for that notify was wrapped in sync ``with``, which routes
teardown through ``__exit__``/``close`` -- the same sync fallback
that PR-70316 addressed on the reconnect path.  Because
``connect_callback`` runs on the same ioloop that owns the underlying
``RequestClient``, that sync teardown cannot await the running
``_send_recv`` task, so the socket ref keeps the ``zmq.asyncio.Context``
alive until later GC finalizes it from an ioloop callback and can
wedge in ``zmq_ctx_term()``.

``connect_callback`` is already ``async def``; switching to
``async with`` routes cleanup through ``__aexit__`` -> ``close_async``,
which awaits the send/recv exit future.
``AsyncAuth._authenticate`` opens an ``AsyncReqChannel`` for the sign-in
loop.  It used the sync ``with`` context manager, which invokes the
sync ``__exit__``/``close`` on exit -- the same race the wedge fix in
PR-70316 addressed on the minion reconnect path: sync ``close`` cannot
await the running ``_send_recv`` task, so a socket ref held by that
task's coroutine locals keeps the ``zmq.asyncio.Context`` alive until
GC finalizes it from an ioloop callback and can wedge in
``zmq_ctx_term()``.

``_authenticate`` is already ``async def`` and its body already
awaits; switching the context manager to ``async with`` is the
minimal change to route teardown through
``AsyncReqChannel.__aexit__`` -> ``close_async`` -> transport's
``close_async``, which awaits the send/recv exit future.
``AsyncAuth.sign_in`` accepts a caller-supplied ``channel`` or opens
its own ``AsyncReqChannel``; the ``finally`` clause called sync
``channel.close()`` on the owned-here path.  That is the sync
teardown that races the running ``_send_recv`` task and lets the
``zmq.asyncio.Context`` reach GC while a socket ref is still live --
the wedge PR-70316 traced through the minion reconnect path.

Route the owned-here close through ``close_async`` when available
(``AsyncReqChannel`` exposes it) so the transport's
``_send_recv_exit_future`` is awaited before the socket + context are
released.  A ``getattr`` guard keeps compatibility with third-party
channel subclasses that only expose the sync ``close``, matching the
shape PR-70316 used in ``Minion.connect_master`` and
``Minion.handle_event``.
… through it

``AsyncRemotePillar`` is only constructed on an active asyncio loop
(via ``get_async_pillar`` from ``async def post_master_init``,
``pillar_refresh``, and the master's ``_pillar`` handler), and its
``destroy`` calls sync ``channel.close()``.  That is the exact
same-thread + loop-running teardown PR-70316 traced through the
minion reconnect path: sync ``close`` cannot await the transport's
running ``_send_recv`` task, so the socket ref keeps the
``zmq.asyncio.Context`` alive until GC finalizes it from a later
ioloop callback and wedges in ``zmq_ctx_term()``.

Add ``AsyncRemotePillar.aclose()`` that mirrors the shape of the
minion reconnect fix: prefer ``channel.close_async`` when the
channel exposes it (``AsyncReqChannel`` does) and fall back to sync
``close`` for third-party subclasses.  Route the two async callers
that owned an ``AsyncRemotePillar`` -- ``Minion._post_master_init``
and ``Minion.pillar_refresh`` -- through ``await async_pillar.aclose()``.

``destroy`` / ``__del__`` are intentionally kept as a compatibility
layer for third-party consumers that never migrated to ``aclose``.
Async callers reach ``aclose`` first, which flips ``_closing`` and
makes the sync ``destroy`` a no-op, so ``__del__``'s
``channel.close()`` teardown does not fire under normal use.  A
debug log records the fallback path if it does hit, so leaks are
visible instead of silent.
Companion to the ``with`` -> ``async with`` conversions in this branch
and the new ``AsyncRemotePillar.aclose``.  Each site's fix rides on
one of three invariants:

  (a) ``AsyncReqChannel.__aexit__`` routes through
      ``close_async`` -> transport ``close_async``, which awaits the
      transport's ``_send_recv_exit_future`` before running teardown;
  (b) ``AsyncRemotePillar.aclose`` closes the underlying channel's
      ``zmq.asyncio.Context`` (and legacy ``destroy`` / ``__del__``
      short-circuit once aclose has run);
  (c) the ``getattr(channel, "close_async", None)`` guard prefers the
      async path and only falls back to sync ``close`` for
      third-party subclasses.

Five focused tests cover those categories rather than reproducing the
full production wedge (which the existing
``test_context_finalizer_wedge`` covers end-to-end).  Objects are
constructed directly against a dead master URI (tcp://127.0.0.1:1)
so no traffic actually flows and no salt master is required.

Verified locally: (3), (4) fail on this branch without the
``AsyncRemotePillar.aclose`` commit (AttributeError on ``aclose``);
(2) fails with ``__aexit__`` reverted to sync ``close`` -- the spy on
``close_async`` shows zero invocations.  Tests (1) and (5) pass
independent of the fixes: (1) checks ``context.closed`` post-teardown
(both paths flip it, though only the async path guarantees drain
ordering); (5) is a shape guard for the ``getattr`` pattern.
The finalizer tracer at
``scratch/vcops-90587-ctx-trace/zmq_finalizer_trace_v5.py`` (deployed
to nsx on Ani Baghoumian's ``ab002212-63-maas-easy-deploy`` env)
captured the exact wedge signature in production and pinned the
creation site of the leaking ``zmq.asyncio.Context`` to::

    handle_event -> _fire_master_main -> _send_req_async_main
      -> self.req_channel.send(...)
      -> transport.send -> await self.connect()
      -> _init_socket()

The trace showed a ``FINALIZE_ENTRY`` with ``closed=False`` invoked
from ``tornado/ioloop.py:945 val = self.callback()`` -- i.e. the
``weakref.finalize`` registered on a ``RequestClient`` fired from an
ioloop callback and blocked in ``zmq_ctx_term()``.

Reading the deployed source revealed the state-machine bug behind it.

``RequestClient.close_async`` does the right thing::

    self._closing = True
    self.socket = None
    self.context = None
    ...
    context.destroy(linger=1000)   # closed=True on the Context

But ``RequestClient.connect`` then silently resurrects the transport::

    async def connect(self):
        async with self._connect_lock:
            if self.socket is None:
                self._connect_called = True
                self._closing = False        # <-- clears the "closed" bit
                self._init_socket()          # <-- allocates a fresh Context
                                             #     AND registers a second
                                             #     weakref.finalize on
                                             #     this RequestClient

The wedge race is:

  1. Reconnect calls ``await old_channel.close_async()``.  The OLD
     ``RequestClient``'s Context #1 is destroyed cleanly and
     ``self.context`` is set to None.
  2. Reconnect assigns ``self.req_channel = <new>``.
  3. An in-flight coroutine (``_fire_master_main`` ->
     ``_send_req_async_main``) that captured the OLD channel across
     an ``await`` yield point now calls ``.send()`` on it.
  4. Old ``.send()`` -> old ``transport.send()`` -> ``await
     self.connect()``.  Before this change ``connect()``
     unconditionally reset ``_closing`` to False and ran
     ``_init_socket``, which allocated Context #2 and registered a
     second ``weakref.finalize`` against the same (dead)
     ``RequestClient``.
  5. Nothing calls ``close_async`` on the OLD channel again -- the
     reconnect already dropped its reference.  When the OLD channel
     is finally GC'd the second finalizer fires from an ioloop
     callback and blocks in ``zmq_ctx_term()``.  Loop wedges.

Refuse the reconnect.  A closed ``RequestClient`` cannot be reused;
any caller holding a stale reference gets a clean
``SaltClientError`` (which the surrounding ``AsyncReqChannel`` retry
logic already handles), and no second Context is allocated or
finalized.  With this in place the trace-log invariant holds: every
``FINALIZE_ENTRY`` on a ``RequestClient`` has ``closed=True`` and
the finalizer's ``context.destroy`` short-circuits.

Regression test in
``tests/pytests/functional/transport/zeromq/test_close_async_audit.py::test_closed_request_client_refuses_reconnect``
constructs a ``RequestClient``, drives ``connect -> close_async ->
connect``, and asserts (a) the second ``connect`` raises
``SaltClientError`` and (b) ``self.context`` remains ``None`` (no
resurrection).  Verified fail-without-fix (``DID NOT RAISE``) and
pass-with-fix (``1 passed``) on the debian-12 CI container.
Commit 868bb2f ("channel/client: use ``async with`` on ephemeral
AsyncReqChannel in reconnect notify") switched
``AsyncPubChannel.connect_callback``'s cleanup from sync
``with AsyncReqChannel.factory(...)`` to ``async with``.  The existing
mock in ``test_async_pub_channel_connect_cb`` only wired
``mock.__enter__``, so on the patched code the ``async with`` did not
invoke it, the mocked channel object was never returned, and
``mock.send`` was never called -- failing the assertion.

Mirror the source change in the test:  wire ``__aenter__`` /
``__aexit__`` (as AsyncMock, since ``async with`` awaits them), and
assert on ``mock.__aexit__`` instead of ``mock.__exit__``.
PR saltstack#70316's RequestClient.close_async graceful drain intentionally
short-circuits the ``_send_recv`` SaltReqTimeoutError branch during
the reconnect window -- that branch is what used to log

    Request timed out while waiting for a response. reconnecting.

so the assertion for that log line was pinning the OLD failure mode
and now fires on every CI run against Python 3.14 in the ci-onedir
container. Reproduced locally with

    docker exec 35799623947_debian-12 bash -c 'cd /salt-fix && \
        PYTHONPATH=/salt-fix /salt/.nox/ci-test-onedir/bin/python \
        -m pytest tests/pytests/scenarios/regression/\
test_resource_runaway_oom.py::test_return_retry_resource_runaway \
        --run-slow --transport=zeromq -v'

Confirmed the failure hits on origin/3008.x baseline too, so the
regression came from saltstack#70316 and not from this branch's audit
commits.

The other sanity check -- ``failed to return the job information for
job`` -- still proves the return-retry path exhausted against an
unreachable master, which is what the test is really guarding
against (the leak measurements below are unconditionally logged for
the memory-shape assertion the docstring already calls out).
Three tests were pinning the old ``with AsyncReqChannel.factory(...)``
context-manager shape (sync ``__enter__`` / ``__exit__``), but
salt/client/__init__.py:2217 now uses ``async with`` so the underlying
AsyncReqChannel's ``__aenter__`` / ``__aexit__`` are what actually get
called.  With the old mock shape, ``async with`` doesn't find the
async dunders on the mock and the test bombs out before ``send`` is
ever invoked.

Reproduced locally in the ci-test-onedir container:

    docker exec 35799623947_debian-12 bash -c 'cd /salt-fix && \
        PYTHONPATH=/salt-fix /salt/.nox/ci-test-onedir/bin/python \
        -m pytest --transport=zeromq -v \
        tests/pytests/unit/test_client.py::test_pub_async_default_timeout \
        tests/pytests/unit/test_client.py::test_pub_async_explicit_timeout \
        tests/pytests/unit/test_client.py::test_pub_async_uses_publish_timeout_from_config'

before: FAILED FAILED FAILED
after:  3 passed in 1.05s

Same mirror change as the earlier
``test_async_pub_channel_connect_cb`` fix on this branch: swap the
plain ``MagicMock`` context-manager wiring for ``AsyncMock`` on
``__aenter__`` and ``__aexit__``.  ``send`` itself is already
async-shaped so it needs no adjustment.
The earlier ``async with AsyncReqChannel.factory(...)`` migration in
this branch changed ``__aenter__`` to eagerly ``await
self.transport.connect()``.  Under normal use it's fine -- every
production callsite (``AsyncPubChannel`` reconnect notify,
``LocalClient.pub_async``, ``AsyncAuth._authenticate``) calls
``channel.send()`` right after entering the block, and ``send()``
already lazy-connects on the first call.  But tests that mock the
inner call (``AsyncAuth`` tests set ``auth.sign_in = mock_sign_in``
so ``_authenticate``'s body never actually reaches
``channel.send``) still hit the real ``AsyncReqChannel.factory``,
and with the eager ``__aenter__`` each such ``async with`` now
allocates a real ``zmq.asyncio.Context``, spawns
``_send_recv``, and registers the ``weakref.finalize`` from
PR-70315.  ``__aexit__`` runs ``close_async`` which drives the
5s ``_send_recv_exit_future`` await and a ``context.destroy
(linger=1000)``.  Across a full pytest session those wall-clock
costs compounded until group 3 wedged past every ``--timeout``
setting we tried.

Reproduced in the ci-test-onedir container:

    docker exec 35799623947_debian-12 bash -c 'cd /salt-fix && \
        PYTHONPATH=/salt-fix /salt/.nox/ci-test-onedir/bin/python \
        -m pytest --transport=zeromq --slow-tests --core-tests \
        --test-group-count=4 --test-group=3 --timeout=45 -q \
        tests/pytests/unit'

before: 6-8 ``+++ Timeout +++`` markers, session killed at 30min
after:  0 timeouts, ``2468 passed, 510 skipped, 1 xfailed in 118s``

Fix: match the sync ``__enter__`` shape -- ``async with`` returns
without side effects, and ``send()`` connects lazily.  The wedge
fix from PR-70316 lives in ``close_async``, not ``__aenter__``,
so this is purely removing an eager-connect that was never load-
bearing.
…iant

Same branch's ``channel/client: make AsyncReqChannel.__aenter__ lazy``
(commit ccfd7ee) intentionally stopped ``__aenter__`` from
allocating a ``zmq.asyncio.Context`` up front.  Under that new shape,
``test_async_with_on_async_req_channel_closes_context`` picked up
``transport.context = None`` at inspection time and blew up in the
assertion with ``AttributeError: 'NoneType' object has no attribute
'closed'`` on the Photon OS 4 FIPS runner (job 108000377473 on run
36109529376).

Restore the test's intent -- verify that ``close_async`` releases
an allocated context -- by explicitly calling
``await channel.transport.connect()`` inside the ``async with``.
That forces the ``Context`` into existence so ``__aexit__``'s
``close_async`` has something to close, which is exactly the wedge
scenario the test is guarding against.

This branch was successfully deployed

1 active deployment
ci — f8c7ab85 Deployed Sep 25, 2026 by dwoz via Build Onedir Packages / Windows (amd64) #27237
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