Skip to content

Restore __del__ safety-net cleanup on SyncWrapper + PublishServer + _… - #70261

Merged
dwoz merged 4 commits into
saltstack:3008.xfrom
dwoz:dwoz/fix/70175-del-cleanup-safety-net
Sep 11, 2026
Merged

Restore __del__ safety-net cleanup on SyncWrapper + PublishServer + _…#70261
dwoz merged 4 commits into
saltstack:3008.xfrom
dwoz:dwoz/fix/70175-del-cleanup-safety-net

Conversation

@dwoz

@dwoz dwoz commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

…TCPPubServerPublisher (#70175)

Extends the "warn + fall back to close()" pattern from commit 9955b89 (salt.utils.event.SaltEvent.__del__) to the three sub-classes SaltEvent composes with:

  • salt.utils.asynchronous.SyncWrapper.__del__
  • salt.transport.tcp.PublishServer.__del__
  • salt.transport.tcp._TCPPubServerPublisher.__del__

Each __del__ still emits the ResourceWarning via salt.utils.resource_warnings.warn_until_close (so leaky callers keep surfacing for pre-Potassium tracking), then falls back to close() wrapped in try/except so a finalizer never propagates. For PublishServer.close the individual sub-resource close steps (pub_sock, pub_server, pull_sock, io_loop.stop, io_loop.close) are additionally guarded, because they can raise during GC-time execution when the io_loop is in a partially torn-down state -- which is exactly the failure mode driving ~50 MB/hr RSS growth on the minion under sustained event traffic.

Companion to sibling branches dwoz/fix/70175-pubserver-perjob-leak, dwoz/fix/70175-saltevent-caller-close,
dwoz/fix/70175-receive-path-saltevent and to shutdown-path fix #70206. The master (Potassium) branch drops the close() fallback and requires explicit close() / context-manager use; the loud ResourceWarning here is the migration signal for that change.

Regression tests:

  • tests/pytests/unit/utils/test_asynchronous.py::test_syncwrapper_del_safety_net_calls_close_70175
  • tests/pytests/unit/transport/test_tcp.py::test_publish_server_del_safety_net_calls_close_70175
  • tests/pytests/unit/transport/test_tcp.py::test_tcppubserverpublisher_del_safety_net_calls_close_70175

Each test:

  1. Instantiates the class without with, drops the reference, forces GC
  2. Asserts the ResourceWarning still fires (behavior preserved)
  3. Asserts a class-appropriate observable that only close() would set (asyncio_loop.is_closed() for SyncWrapper; sub-resource close() mock calls for PublishServer; stream+socket close for _TCPPubServerPublisher)

All three tests fail on pre-patch origin/3008.x with only the ResourceWarning firing, and pass with the safety-net restored.

Refs #70175, #70206.

@dwoz
dwoz requested a review from a team as a code owner September 10, 2026 08:14
@dwoz dwoz added the test:full Run the full test suite label Sep 10, 2026
@dwoz
dwoz force-pushed the dwoz/fix/70175-del-cleanup-safety-net branch from d8c41b9 to f5e3aad Compare September 10, 2026 08:22
@dwoz
dwoz force-pushed the dwoz/fix/70175-del-cleanup-safety-net branch from f5e3aad to d26bbfe Compare September 10, 2026 09:09
twangboy
twangboy previously approved these changes Sep 10, 2026
…rors

Under heavy master/minion connection churn (observed running the
4-master cluster tests on a 32-CPU Rocky 9 container, probabilistic in
CI), a tornado call inside ``TCPPuller.handle_stream`` intermittently
raises either:

  * ``ValueError('fd %s added twice')`` from ``IOLoop.add_handler``
    (called from tornado's ``IOStream._add_io_state`` when a stream
    tries to register a fd already being tracked by another handler), or
  * ``AssertionError('Already reading')`` from ``IOStream.read_bytes``
    when a prior read on the same stream is still outstanding. (Older
    tornado forks surfaced this as ``StreamAlreadyReadingError``; on
    tornado 6.x it is an ``AssertionError``.)

Both were being swallowed by the historical broad-except on line 1957
of ``salt/transport/tcp.py`` -- so the outer ``while not stream.closed()``
loop immediately re-invoked ``stream.read_bytes`` on the same broken
fd, spinning the tornado io_loop at 77-119% CPU and growing the log
file to hundreds of MB in seconds until the CI step timed out. The
``EventPublisher`` pinned; cluster tests hung; CI killed them with
SIGTERM. Deterministic repro on the local Rocky 9 container.

Fix:

  1. ``TCPPuller.handle_stream`` narrow-catches the two state errors
     (``ValueError``, ``AssertionError``), logs at WARNING with full
     traceback, calls ``stream.close()`` (guarded), and ``break``s out
     of the reader loop.  The prior generic broad-except is also now
     break-out rather than continue -- a persistent unrecoverable error
     at that level means the per-stream reader can no longer make
     progress on the fd, so continuing would spin.  The ``OSError``
     branch is split: ``errno == 0`` continues (spurious, preserves
     existing behavior), any other ``OSError`` closes and breaks
     (EBADF / ECONNRESET / etc. mean the fd is unusable).

  2. ``_TCPPubServerPublisher.close`` best-effort calls
     ``stream.io_loop.remove_handler(fd)`` before closing the stream
     when the stream still holds a socket.  Rationale: when
     ``stream.connect()`` raised ``fd added twice`` earlier, the fd
     was partially registered but the stream's ``_state`` may not
     have advanced -- so tornado's own ``stream.close()`` (which only
     calls ``remove_handler`` when ``_state is not None``) does not
     always deregister the handler, leaving a dangling selector entry
     that resurfaces as another ``fd added twice`` the next time the
     same fd is reused.  Uses ``stream.io_loop`` (a tornado ``IOLoop``)
     rather than ``self.io_loop`` (an asyncio loop) because the former
     is what tornado registered the handler on.

Unit tests: three new tests in
``tests/pytests/unit/transport/test_tcp.py`` cover the two
handle_stream branches (mock stream whose ``read_bytes`` raises the
state error; assert the reader breaks and closes the stream instead of
retrying) and the publisher close hardening (mock stream + fake io_loop;
assert ``remove_handler(fd)`` is called before ``stream.close()``).

Bundled with PR saltstack#70261's safety-net cleanup work so CI can validate
the combination together.
``_stream_read`` retained a 1 MiB msgpack.Unpacker per accepted stream
because the ``client -> _read_task -> coroutine frame -> client``
reference cycle only collected on the next cyclic-GC pass -- under
sustained per-job subscriber churn (each state.apply child forks a
fresh event-bus subscriber) tracemalloc showed +35 pinned Unpackers
~= +37 MiB retained after only 20 jobs on a live minion.

``_discard_on_close._cb()`` also leaked the per-subscriber
``self._writers`` entry -- an ``asyncio.Queue`` + drain-Task tuple,
~25 kB apiece.  ``_discard_slow_client`` already popped this on the
drain-timeout path, but the clean-close callback did not.

This is the follow-up to PR saltstack#70260 (which cancelled the read task):
that alone was not enough because the coroutine frame stayed pinned
via the cycle above.

Fixes:

* ``_stream_read`` gains a ``try/finally`` that does ``del unpacker``
  and clears ``client._read_task = None`` so refcount collection
  reclaims the coroutine frame (and its Unpacker) immediately on exit.
* ``_discard_on_close._cb()`` also clears ``client._read_task`` (for
  the case where the coroutine has not yet resumed to observe the
  cancellation) and pops the ``self._writers`` entry, cancelling the
  drain task -- matching what ``_discard_slow_client`` already does.

Combined with saltstack#70260 this drops retained per-job Python memory from
~85 kB/job to ~1.8 kB/job (measured with tracemalloc on a live 200-job
RaaS-driven burst).

Regression tests in ``tests/pytests/unit/transport/`` verify both
invariants and were confirmed to fail on ``origin/3008.x`` without
this patch.

See issue saltstack#70175.
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