Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/69816.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix ZMQ POLLOUT retry loop in `salt/transport/zeromq.py` so that a single 300ms poll miss does not abort the send before the configured request timeout fires.
19 changes: 11 additions & 8 deletions salt/transport/zeromq.py
Original file line number Diff line number Diff line change
Expand Up @@ -2167,17 +2167,20 @@ async def _send_recv(
break

try:
# Wait for socket to be ready for sending
if not await socket.poll(300, zmq.POLLOUT):
if not future.done():
future.set_exception(
SaltReqTimeoutError("Socket not ready for sending")
)
# Wait for socket to be ready for sending.
# Poll in a loop so a single 300ms POLLOUT miss does not
# abort the send before the configured request timeout fires.
sent = False
while not future.done():
if await socket.poll(300, zmq.POLLOUT):
await socket.send(message)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If socket.poll(300, zmq.POLLOUT) blocks for up to 300ms, the request future could potentially time out or be cancelled right during that poll window.

To prevent sending a message after the request has already been marked done/cancelled, we should double-check future.done() right after poll() returns True before executing socket.send().

            if await socket.poll(300, zmq.POLLOUT):
                if future.done():
                    break
                await socket.send(message)
                sent = True
                break

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5ad9022. Also checked 3006.x — it uses a different architecture (tornado generators, not asyncio RequestClient._send_recv) so the same POLLOUT issue does not exist there.

sent = True
break
if not sent:
# The future was completed by _timeout_message or cancel
if not self._closing:
await self._reconnect()
break

await socket.send(message)
except (zmq.eventloop.future.CancelledError, asyncio.CancelledError) as exc:
send_recv_running = False
if not future.done():
Expand Down
67 changes: 67 additions & 0 deletions tests/pytests/unit/transport/test_zeromq_concurrency.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,70 @@ async def test_request_client_reconnect_task_safety(minion_opts, io_loop):

# The old task should have exited cleanly.
client.close()


async def test_request_client_pollout_retries_on_transient_failure(
minion_opts, io_loop
):
"""
Regression test for the POLLOUT retry loop in _send_recv.

A single 300ms POLLOUT miss should not abort the send. Instead, the
polling loop re-tries ``socket.poll`` so long as the request future is
still pending. If the socket eventually becomes writable within the
request timeout, the message goes through.

This test sets up a mock socket that returns ``False`` from ``poll``
for the first N calls (simulating transient ZMQ POLLOUT misses) and
then returns ``True``, confirming that the retry loop keeps trying
rather than falling through to the timeout/reconnect path.
"""
client = salt.transport.zeromq.RequestClient(minion_opts, io_loop)

poll_attempts = []

async def mocked_poll(timeout, flag=None, **kwargs):
poll_attempts.append(True)
# Return False (not ready) for the first 3 calls, then True
return len(poll_attempts) > 3

sent_payloads = []

async def mocked_send(msg, **kwargs):
sent_payloads.append(msg)

async def mocked_recv(**kwargs):
return salt.payload.dumps({"ret": "ok"})

mock_socket = AsyncMock()
mock_socket.poll = mocked_poll
mock_socket.send = mocked_send
mock_socket.recv = mocked_recv

await client.connect()

if client.socket:
client.socket.close()
client.socket = mock_socket
if client.send_recv_task:
client.send_recv_task.cancel()

client.send_recv_task = asyncio.create_task(
client._send_recv(mock_socket, client._queue, task_id=client.send_recv_task_id)
)

# Send a message with a generous timeout
result = await client.send({"cmd": "test"}, timeout=30)

client._closing = True
if client.send_recv_task and not client.send_recv_task.done():
client.send_recv_task.cancel()

# We should have polled at least 4 times (3 failures + 1 success)
assert len(poll_attempts) >= 4, (
f"Expected at least 4 POLLOUT attempts, got {len(poll_attempts)}. "
"The retry loop did not re-poll on transient failures."
)
# The message should have been sent on the wire
assert len(sent_payloads) == 1, "Message was never sent despite socket becoming ready."
assert result == {"ret": "ok"}, "Unexpected response payload."