Skip to content

Commit d26bbfe

Browse files
committed
Restore __del__ safety-net cleanup on SyncWrapper + PublishServer + _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.
1 parent 0399687 commit d26bbfe

5 files changed

Lines changed: 299 additions & 28 deletions

File tree

changelog/70175.fixed.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Fixed leak of the minion's local ``PublishServer`` graph (``event_publisher`` -> ``pub_sock`` SyncWrapper -> ``_TCPPubServerPublisher``) when the minion exits through ``cli.daemons.Minion.shutdown`` (KeyboardInterrupt, SaltSystemExit, early-exit guards) or ``MinionManager`` GC. ``MinionManager.destroy`` now closes ``event_publisher`` and destroys ``event`` -- previously only the SIGTERM ``stop_async`` path did, so non-SIGTERM shutdown paths triggered the three-warning cascade in issue #70175. ``PublishServer.close`` also now calls ``pub.close()`` on every ``_TCPPubServerPublisher`` cached in ``_async_pub_by_loop`` (previously it closed the underlying stream only, leaving ``_closing = False`` and letting the publisher's ``__del__`` emit the "unclosed publisher client" warning at GC). ``PubServer._discard_on_close`` now cancels the pending ``_stream_read`` Task and force-closes the Subscriber so the coroutine frame (with its 1 MiB msgpack Unpacker buffer) is released the moment a subscriber disconnects, closing the per-job leak path on the steady-state per-job path. Fire-and-forget ``MinionEvent`` callers in ``salt.modules.event.fire_master`` and ``salt.modules.mine._mine_send`` are now wrapped in ``with`` blocks so the ``PublishServer`` / ``_TCPPubServerPublisher`` / ``SyncWrapper`` triad is torn down synchronously instead of leaking one bundle per invocation onto the minion event bus. ``salt.utils.error.fire_exception`` is also wrapped in a ``with`` block so the temporary ``SaltEvent`` it constructs closes both pusher and subscriber synchronously -- previously the helper (called from ``salt/minion.py:_thread_return`` job-exception path and from ``salt/metaproxy/{proxy,deltaproxy}.py``) dropped the ``SaltEvent`` reference immediately after ``fire_event``, so cleanup ran only when GC invoked ``SaltEvent.__del__`` and each finalization emitted the three-warning triad from the underlying transport chain.
1+
Fixed leak of the minion's local ``PublishServer`` graph (``event_publisher`` -> ``pub_sock`` SyncWrapper -> ``_TCPPubServerPublisher``) when the minion exits through ``cli.daemons.Minion.shutdown`` (KeyboardInterrupt, SaltSystemExit, early-exit guards) or ``MinionManager`` GC. ``MinionManager.destroy`` now closes ``event_publisher`` and destroys ``event`` -- previously only the SIGTERM ``stop_async`` path did, so non-SIGTERM shutdown paths triggered the three-warning cascade in issue #70175. ``PublishServer.close`` also now calls ``pub.close()`` on every ``_TCPPubServerPublisher`` cached in ``_async_pub_by_loop`` (previously it closed the underlying stream only, leaving ``_closing = False`` and letting the publisher's ``__del__`` emit the "unclosed publisher client" warning at GC). ``PubServer._discard_on_close`` now cancels the pending ``_stream_read`` Task and force-closes the Subscriber so the coroutine frame (with its 1 MiB msgpack Unpacker buffer) is released the moment a subscriber disconnects, closing the per-job leak path on the steady-state per-job path. Fire-and-forget ``MinionEvent`` callers in ``salt.modules.event.fire_master`` and ``salt.modules.mine._mine_send`` are now wrapped in ``with`` blocks so the ``PublishServer`` / ``_TCPPubServerPublisher`` / ``SyncWrapper`` triad is torn down synchronously instead of leaking one bundle per invocation onto the minion event bus. ``salt.utils.error.fire_exception`` is also wrapped in a ``with`` block so the temporary ``SaltEvent`` it constructs closes both pusher and subscriber synchronously -- previously the helper (called from ``salt/minion.py:_thread_return`` job-exception path and from ``salt/metaproxy/{proxy,deltaproxy}.py``) dropped the ``SaltEvent`` reference immediately after ``fire_event``, so cleanup ran only when GC invoked ``SaltEvent.__del__`` and each finalization emitted the three-warning triad from the underlying transport chain. ``salt.utils.asynchronous.SyncWrapper.__del__``, ``salt.transport.tcp.PublishServer.__del__``, and ``salt.transport.tcp._TCPPubServerPublisher.__del__`` also now fall back to ``close()`` as a GC-time safety net -- mirroring the pattern on ``salt.utils.event.SaltEvent.__del__`` -- while still emitting the ``ResourceWarning`` so leaky callers can be surfaced for tracking pre-Potassium.

salt/transport/tcp.py

Lines changed: 114 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2341,13 +2341,18 @@ async def publish(
23412341
def close(self):
23422342
self._closing = True
23432343
if self.pub_sock:
2344-
# pub_sock is a SyncWrapper - need to call close() on the wrapper itself
2344+
# pub_sock is a SyncWrapper - need to call close() on the wrapper itself.
2345+
# Guarded because ``__del__`` may drive this during GC when the
2346+
# SyncWrapper's io_loop / asyncio_loop is in a torn-down state.
23452347
import salt.utils.asynchronous
23462348

2347-
if isinstance(self.pub_sock, salt.utils.asynchronous.SyncWrapper):
2348-
salt.utils.asynchronous.SyncWrapper.close(self.pub_sock)
2349-
else:
2350-
self.pub_sock.close()
2349+
try:
2350+
if isinstance(self.pub_sock, salt.utils.asynchronous.SyncWrapper):
2351+
salt.utils.asynchronous.SyncWrapper.close(self.pub_sock)
2352+
else:
2353+
self.pub_sock.close()
2354+
except Exception: # pylint: disable=broad-except
2355+
pass
23512356
self.pub_sock = None
23522357
# PATCH: Bug 1's async-context bypass caches a raw
23532358
# ``_TCPPubServerPublisher`` per running loop in
@@ -2383,14 +2388,32 @@ def close(self):
23832388
pass
23842389
self._async_pub_by_loop = None
23852390
if self.pub_server:
2386-
self.pub_server.close()
2391+
try:
2392+
self.pub_server.close()
2393+
except Exception: # pylint: disable=broad-except
2394+
pass
23872395
self.pub_server = None
23882396
if self.pull_sock:
2389-
self.pull_sock.close()
2397+
try:
2398+
self.pull_sock.close()
2399+
except Exception: # pylint: disable=broad-except
2400+
pass
23902401
self.pull_sock = None
23912402
if self.io_loop:
2392-
self.io_loop.stop()
2393-
self.io_loop.close(all_fds=True)
2403+
# Each io_loop step can raise during GC-time close when the
2404+
# loop has already been half-torn-down (e.g. by a
2405+
# ``__del__``-driven cleanup on a partially freed C
2406+
# extension). Guard each step individually so a failure in
2407+
# ``stop()`` does not leak the underlying fds that
2408+
# ``close(all_fds=True)`` would otherwise release.
2409+
try:
2410+
self.io_loop.stop()
2411+
except Exception: # pylint: disable=broad-except
2412+
pass
2413+
try:
2414+
self.io_loop.close(all_fds=True)
2415+
except Exception: # pylint: disable=broad-except
2416+
pass
23942417
self.io_loop = None
23952418
# Drop the multiprocessing.Event reference so its internal pipe FDs can be
23962419
# released when no other references remain.
@@ -2399,10 +2422,45 @@ def close(self):
23992422

24002423
# pylint: disable=W1701
24012424
def __del__(self):
2402-
if not self._closing:
2403-
salt.utils.resource_warnings.warn_until_close(
2404-
f"unclosed publish server {self!r}", source=self, log=log
2405-
)
2425+
# On this LTS branch ``__del__`` both surfaces the leak via
2426+
# ``warn_until_close`` (loud WARNING-level log record and
2427+
# ``ResourceWarning``) AND falls back to calling ``close()`` as
2428+
# a safety net, so callers that historically relied on GC-time
2429+
# cleanup (typically ``MinionManager`` teardown paths that
2430+
# bypass explicit destroy) do not silently leak the pub/pull
2431+
# sockets, the per-loop cached ``_TCPPubServerPublisher`` map,
2432+
# the pub_server ``IOStream``, and the io_loop backing them.
2433+
#
2434+
# The companion change on ``master`` (Potassium) drops the
2435+
# ``close()`` fallback and requires callers to use a context
2436+
# manager or explicit ``close()``; the loud warning here is the
2437+
# migration signal for that change.
2438+
#
2439+
# Python's ``__del__`` runs during GC (may be delayed, may skip
2440+
# on reference cycles) and during interpreter shutdown (when
2441+
# the world is already tearing down and closing an
2442+
# ``IOStream`` or an io_loop can raise from a partially-freed C
2443+
# extension). The ``close()`` call chain below is guarded so a
2444+
# finalizer never propagates an exception.
2445+
try:
2446+
already_closed = getattr(self, "_closing", True)
2447+
except Exception: # pylint: disable=broad-except
2448+
return
2449+
if already_closed:
2450+
return
2451+
salt.utils.resource_warnings.warn_until_close(
2452+
f"unclosed publish server {self!r}", source=self, log=log
2453+
)
2454+
try:
2455+
self.close()
2456+
except Exception: # pylint: disable=broad-except
2457+
# Finalizer must never raise. ``close()`` walks pub_sock,
2458+
# ``_async_pub_by_loop`` cached publishers, pub_server,
2459+
# pull_sock, io_loop -- each step is guarded individually
2460+
# inside ``close()``. This outer handler catches any
2461+
# residual failure from a partially-freed C extension
2462+
# during interpreter shutdown.
2463+
pass
24062464

24072465
# pylint: enable=W1701
24082466

@@ -2591,10 +2649,49 @@ def close(self):
25912649

25922650
# pylint: disable=W1701
25932651
def __del__(self):
2594-
if not self._closing:
2595-
salt.utils.resource_warnings.warn_until_close(
2596-
f"unclosed publisher client {self!r}", source=self, log=log
2597-
)
2652+
# On this LTS branch ``__del__`` both surfaces the leak via
2653+
# ``warn_until_close`` (loud WARNING-level log record and
2654+
# ``ResourceWarning``) AND falls back to calling ``close()`` as
2655+
# a safety net, so callers that historically relied on GC-time
2656+
# cleanup do not silently leak the underlying ``IOStream``
2657+
# socket FD and the in-flight ``_connecting_future``.
2658+
#
2659+
# Motivation: raw ``_TCPPubServerPublisher`` instances get
2660+
# cached per-loop in ``PublishServer._async_pub_by_loop`` on
2661+
# the minion; a botched outer teardown that never calls
2662+
# ``PublishServer.close()`` (see the sibling fixes referenced
2663+
# from issue #70175) leaves these publishers unclosed and one
2664+
# ``pull.ipc`` client FD leaks per instance.
2665+
#
2666+
# The companion change on ``master`` (Potassium) drops the
2667+
# ``close()`` fallback and requires callers to use a context
2668+
# manager or explicit ``close()``; the loud warning here is the
2669+
# migration signal for that change.
2670+
#
2671+
# Python's ``__del__`` runs during GC (may be delayed, may skip
2672+
# on reference cycles) and during interpreter shutdown (when
2673+
# the world is already tearing down and touching a tornado
2674+
# ``IOStream`` can raise from a partially-freed C extension).
2675+
# The ``close()`` call chain is guarded so a finalizer never
2676+
# propagates an exception.
2677+
try:
2678+
already_closed = getattr(self, "_closing", True)
2679+
except Exception: # pylint: disable=broad-except
2680+
return
2681+
if already_closed:
2682+
return
2683+
salt.utils.resource_warnings.warn_until_close(
2684+
f"unclosed publisher client {self!r}", source=self, log=log
2685+
)
2686+
try:
2687+
self.close()
2688+
except Exception: # pylint: disable=broad-except
2689+
# Finalizer must never raise. ``close()`` handles the
2690+
# ``_connecting_future`` and ``stream.close()`` steps
2691+
# individually with try/except-pass; this outer handler is
2692+
# a last resort for partially-freed C extensions during
2693+
# interpreter shutdown.
2694+
pass
25982695

25992696
# pylint: enable=W1701
26002697

salt/utils/asynchronous.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -423,16 +423,14 @@ def __exit__(self, exc_type, exc_val, tb):
423423

424424
# pylint: disable=W1701
425425
def __del__(self):
426-
# PATCH: mirror ``SaltEvent.__del__`` at ``salt/utils/event.py``
427-
# -- deliberately do NOT close the wrapped ``obj`` / io_loop /
428-
# asyncio_loop from ``__del__``. ``__del__`` fires during GC
429-
# (may be arbitrarily delayed, may skip on reference cycles)
430-
# and during interpreter shutdown, when the world is already
431-
# tearing down and touching a tornado/asyncio loop can raise
432-
# from a partially-freed C extension. Instead, emit a
433-
# ``ResourceWarning`` so callers that missed ``close()`` /
434-
# context-manager surface loudly in tests / sentry / log
435-
# aggregators.
426+
# On this LTS branch ``__del__`` both surfaces the leak via
427+
# ``warn_until_close`` (loud WARNING-level log record and
428+
# ``ResourceWarning``) AND falls back to calling ``close()`` as
429+
# a safety net, so callers that historically relied on GC-time
430+
# cleanup do not silently leak a whole ``asyncio`` event loop,
431+
# its tornado IOLoop, and the ZMQ context / socketpairs backing
432+
# the wrapped async object (typically ``AsyncReqChannel``,
433+
# ``AsyncPubChannel`` or ``AsyncEventPublisher``).
436434
#
437435
# Motivation: ``SyncWrapper``-owned asyncio loops are the
438436
# dominant leak surface on the minion under sustained
@@ -442,6 +440,18 @@ def __del__(self):
442440
# leaked socketpairs (~902 fds) per minion, tripping the
443441
# 1024-file ulimit critical threshold and the minion's own
444442
# sock-throttle logic.
443+
#
444+
# The companion change on ``master`` (Potassium) drops the
445+
# ``close()`` fallback and requires callers to use a context
446+
# manager or explicit ``close()``; the loud warning here is the
447+
# migration signal for that change.
448+
#
449+
# Python's ``__del__`` runs during GC (may be delayed, may skip
450+
# on reference cycles) and during interpreter shutdown (when the
451+
# world is already tearing down and touching a tornado/asyncio
452+
# loop can raise from a partially-freed C extension). The
453+
# ``close()`` call chain below is guarded so a finalizer never
454+
# propagates an exception.
445455
try:
446456
unclosed = getattr(self, "obj", None) is not None or (
447457
getattr(self, "asyncio_loop", None) is not None
@@ -458,5 +468,15 @@ def __del__(self):
458468
source=self,
459469
log=log,
460470
)
471+
try:
472+
self.close()
473+
except Exception: # pylint: disable=broad-except
474+
# Finalizer must never raise. ``close()`` is itself heavily
475+
# guarded at each step (see the try/except-pass around every
476+
# ``run_until_complete`` / ``io_loop.close`` call) so we do
477+
# not expect to reach this outer handler in normal flow --
478+
# it is a last resort for partially-freed C extensions
479+
# during interpreter shutdown.
480+
pass
461481

462482
# pylint: enable=W1701

tests/pytests/unit/transport/test_tcp.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2384,3 +2384,110 @@ def connect(self, timeout=None):
23842384
assert captured["cls"] is salt.transport.tcp._TCPPubServerPublisher
23852385
assert captured["kwargs"] == {"max_write_buffer_size": 4321}
23862386
assert captured.get("connect_called") is True
2387+
2388+
2389+
def test_publish_server_del_safety_net_calls_close_70175(master_opts):
2390+
"""
2391+
Regression test for the __del__ safety-net cleanup extension of #70175.
2392+
2393+
When a caller drops the last reference to a ``PublishServer`` without
2394+
invoking ``close()`` first (typical of shutdown paths that skip
2395+
``MinionManager.destroy``), the ``__del__`` finalizer must:
2396+
2397+
1. Emit the ``ResourceWarning`` so the leaky caller still surfaces
2398+
for tracking (behavior preserved from the warn-only revision).
2399+
2. Fall back to ``close()`` so the ``pub_sock`` / ``pub_server`` /
2400+
``pull_sock`` / io_loop / per-loop cached publishers are
2401+
released, converting a ~50 MB/hr RSS leak into a bounded per-GC
2402+
cleanup.
2403+
"""
2404+
server = salt.transport.tcp.PublishServer(
2405+
master_opts,
2406+
pub_host="127.0.0.1",
2407+
pub_port=1,
2408+
pull_host="127.0.0.1",
2409+
pull_port=2,
2410+
)
2411+
assert server._closing is False
2412+
2413+
# Wire fake sub-resources so we can observe that close() actually
2414+
# traversed them. Each mock records whether ``close()`` was called.
2415+
saved_pub_sock = MagicMock()
2416+
saved_pub_server = MagicMock()
2417+
saved_pull_sock = MagicMock()
2418+
saved_io_loop = MagicMock()
2419+
stale_pub = MagicMock()
2420+
stale_pub.close = MagicMock()
2421+
server.pub_sock = saved_pub_sock
2422+
server.pub_server = saved_pub_server
2423+
server.pull_sock = saved_pull_sock
2424+
server.io_loop = saved_io_loop
2425+
server._async_pub_by_loop = {"loop-key": (stale_pub, MagicMock())}
2426+
2427+
with warnings.catch_warnings(record=True) as caught:
2428+
warnings.simplefilter("always")
2429+
del server
2430+
gc.collect()
2431+
2432+
# 1. ResourceWarning still fires (behavior preserved).
2433+
resource_warnings = [w for w in caught if issubclass(w.category, ResourceWarning)]
2434+
assert resource_warnings, (
2435+
"expected ResourceWarning from PublishServer.__del__; got "
2436+
f"{[(w.category, str(w.message)) for w in caught]}"
2437+
)
2438+
assert any("unclosed publish server" in str(w.message) for w in resource_warnings)
2439+
2440+
# 2. Safety-net close() ran -- observed via the sub-resource mocks
2441+
# (each ``.close()`` was invoked exactly once by ``PublishServer.close``).
2442+
saved_pub_sock.close.assert_called_once()
2443+
saved_pub_server.close.assert_called_once()
2444+
saved_pull_sock.close.assert_called_once()
2445+
# 3. io_loop had stop() + close() driven.
2446+
saved_io_loop.stop.assert_called_once()
2447+
saved_io_loop.close.assert_called_once_with(all_fds=True)
2448+
# 4. Per-loop cached publisher was drained.
2449+
stale_pub.close.assert_called_once()
2450+
2451+
2452+
def test_tcppubserverpublisher_del_safety_net_calls_close_70175():
2453+
"""
2454+
Regression test for the __del__ safety-net cleanup extension of #70175.
2455+
2456+
When a caller drops the last reference to a
2457+
``_TCPPubServerPublisher`` without invoking ``close()`` first, the
2458+
``__del__`` finalizer must both emit the ``ResourceWarning`` and
2459+
call ``close()`` so ``_closing`` flips True and the underlying
2460+
``IOStream`` / socket FD are released rather than lingering as a
2461+
slow leak.
2462+
"""
2463+
io_loop = tornado.ioloop.IOLoop()
2464+
publisher = salt.transport.tcp._TCPPubServerPublisher(
2465+
host="127.0.0.1", port=4511, path=None, io_loop=io_loop
2466+
)
2467+
# Install a fake stream so close() has something observable to
2468+
# close. Its ``closed()`` returns False so ``close()`` walks the
2469+
# stream branch.
2470+
fake_stream = MagicMock()
2471+
fake_stream.closed.return_value = False
2472+
fake_stream.socket = MagicMock()
2473+
publisher.stream = fake_stream
2474+
publisher._connecting_future = tornado.concurrent.Future()
2475+
assert publisher._closing is False
2476+
2477+
with warnings.catch_warnings(record=True) as caught:
2478+
warnings.simplefilter("always")
2479+
del publisher
2480+
gc.collect()
2481+
2482+
resource_warnings = [w for w in caught if issubclass(w.category, ResourceWarning)]
2483+
assert resource_warnings, (
2484+
"expected ResourceWarning from _TCPPubServerPublisher.__del__; got "
2485+
f"{[(w.category, str(w.message)) for w in caught]}"
2486+
)
2487+
assert any("unclosed publisher client" in str(w.message) for w in resource_warnings)
2488+
2489+
# Safety-net close() ran: stream + socket were closed.
2490+
fake_stream.close.assert_called_once()
2491+
fake_stream.socket.close.assert_called_once()
2492+
2493+
io_loop.close()

0 commit comments

Comments
 (0)