Skip to content

[3006.x] Fix nested SyncWrapper deadlock in tcp.TCPPublishServer.publish (#69986) - #69998

Merged
dwoz merged 2 commits into
saltstack:3006.xfrom
dwoz:dwoz/fix/bug1-syncwrapper-3006.x
Aug 12, 2026
Merged

[3006.x] Fix nested SyncWrapper deadlock in tcp.TCPPublishServer.publish (#69986)#69998
dwoz merged 2 commits into
saltstack:3006.xfrom
dwoz:dwoz/fix/bug1-syncwrapper-3006.x

Conversation

@dwoz

@dwoz dwoz commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

3006.x's publish is sync (no async def), so async dispatch is fire-and-forget matching the fire_event / spawn_callback precedent in salt/utils/event.py. See adaptation notes: [agents/reports/bug1-3006x-adaptation.md] (in the working branch).

Fixes #69986

Test plan

  • salt.transport.tcp unit tests pass
  • Add regression test covering TCPPublishServer.publish from inside a running asyncio loop (gap flagged by adaptation notes)

Related PRs

When ``TCPPublishServer.publish`` was invoked from a running asyncio
loop (e.g. via ``MWorker._return -> store_job -> fire_event``), the
outer ``SaltEvent.pusher`` SyncWrapper's worker thread ran this
coroutine, then ``self.pub_sock.send`` invoked SyncWrapper *again* --
it detected the inner thread's running io_loop, spawned yet another
thread, and both deadlocked on ``threading.Thread.join()``.

Detect the async context via ``asyncio.get_running_loop()`` and
bypass the outer SyncWrapper. Since 3006.x's ``publish`` is sync
(no ``async def``), dispatch to ``loop.create_task(...)`` as a
fire-and-forget (matches the ``fire_event`` / ``spawn_callback``
precedent in ``salt/utils/event.py``). Cache a raw
``IPCMessageClient`` per running loop via
``WeakKeyDictionary`` so a fresh SyncWrapper asyncio_loop can't
inherit a dead client via id() recycling. Invalidate proactively
(pre-flight ``stream.closed()``) and reactively (retry once on
``salt.ext.tornado.iostream.StreamClosedError``).

3006.x-specific counterpart to 3008.x PR saltstack#69992.

Fixes saltstack#69986
Comment thread salt/transport/tcp.py
Before: concurrent tasks captured pub from the outer scope
before taking the per-loop lock.  If task A hit
StreamClosedError, replaced per_loop[loop] with a healthy
pub2, and released the lock, task B then acquired the lock
still holding its captured pub1, tried to send on that
already-closed publisher, and evicted the healthy pub2 from
the cache -- cascading unnecessary reconnects under sustained
concurrent publish.

Re-resolve the active publisher from per_loop inside the
lock so waiting tasks pick up the newly reconnected instance.
Also guard the eviction path so we only pop the cache entry
when it still points at the publisher we tried; a peer task's
successful replacement must not be dropped.

Refs review comment on PR saltstack#69998.
@dwoz
dwoz merged commit b668a8a into saltstack:3006.x Aug 12, 2026
850 of 854 checks passed
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