Skip to content
Open
Show file tree
Hide file tree
Changes from all 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/66435.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed ``TypeError: argument must be an int, or have a fileno() method.`` (or, after the fd>1023 cleanup in #68136, a ``ValueError`` from the selectors backend) raised from ``salt.transport.tcp.PublishClient.recv`` when the IOStream's underlying socket was torn down between the stream-not-None check and the non-blocking read. The ``recv(timeout=0)`` path now detects the dead stream, drops it, invokes the disconnect callback and reconnects instead of crashing or spinning forever.
18 changes: 18 additions & 0 deletions salt/transport/tcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,24 @@ async def recv(self, timeout=None):
# misses data that ``read_bytes`` would return immediately.
if self._stream is None:
return None
# Tornado's IOStream keeps a reference to itself but sets
# ``socket`` to ``None`` once closed. If we let a read task
# start against that stream ``read_bytes`` blows up with
# ``TypeError: argument must be an int, or have a fileno()
# method.`` (or a ``ValueError`` from the selectors backend
# after the fd>1023 cleanup in #68136), which escaped all the
# way out to the salt CLI. Drop the dead stream and trigger
# the normal reconnect path so a caller looping on
# ``recv(timeout=0)`` doesn't spin returning ``None`` forever.
# See issue #66435.
if self._stream.socket is None:
stream = self._stream
self._stream = None
stream.close()
if self.disconnect_callback:
await self.disconnect_callback()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

HIGH (masks-real-error / latent TypeError)await self.disconnect_callback(). On the minion, _MinionPubChannel.disconnect_callback (salt/channel/client.py:587) is a synchronous def returning None. await None raises TypeError: object NoneType can't be used in 'await' expression. The test hides this by injecting an AsyncMock. Same latent bug pre-exists at _read_into_unpacker:485, but this PR adds a second site with the same shape and its own test that hides it.

  • Suggested fix: if inspect.iscoroutinefunction(self.disconnect_callback): await self.disconnect_callback() else call it; or make _MinionPubChannel.disconnect_callback async. And drop AsyncMock from the test — use a plain MagicMock matching real-world semantics.

await self.connect()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

HIGH (regression / hangs-forever)await self.connect() inside PublishClient.recv(timeout=0). recv(timeout=0) is a non-blocking peek used by SaltEvent.get_event_noblock and _get_event(no_block=True) (the salt CLI's tight polling loop). connect() delegates to _connect()getstream(), whose retry loop only exits on _closed/_closing or after getstream's per-attempt timeout (default 5 s) plus a 1 s backoff, looping forever if the master is unreachable. Non-blocking callers will now block seconds → minutes instead of returning None.

  • Suggested fix: drop the dead stream, clear _read_task, fire the disconnect callback, and return None — let the existing while self._stream is None loop in the blocking branch (or the on_recv_handler sleep-loop) do the reconnect. Do not await connect() in the timeout==0 branch.

return None
task = self._ensure_read_task()
if task is None:
return None
Expand Down
50 changes: 49 additions & 1 deletion tests/pytests/unit/transport/test_publish_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import salt.transport.ws
import salt.transport.zeromq
import salt.utils.stringutils
from tests.support.mock import MagicMock, patch
from tests.support.mock import AsyncMock, MagicMock, patch

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -339,6 +339,54 @@ async def test_recv_timeout_zero():
pass


async def test_recv_timeout_zero_stream_socket_none():

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

MED (test gap) — Test replaces client.connect with an AsyncMock that instantly returns. Does not verify: (a) caller doesn't block when real connect() retries fail (the actual regression above), (b) sync-disconnect_callback TypeError path, (c) _read_task state coherence after the guard runs, (d) any behavior on the actual production crash reproduction (.socket=None combined with a real Tornado IOStream under read_bytes).

  • Suggested fix: add a test using a disconnect_callback that's a plain def (matches production), and a test asserting recv(timeout=0) returns in <100 ms even when connect() is slow.

"""
Regression test for #66435.

If a stream's underlying socket has been torn down concurrently (the
Tornado ``IOStream`` keeps a reference to itself but its ``socket``
attribute becomes ``None`` once closed), ``recv(timeout=0)`` used to
let ``_read_into_unpacker`` call ``read_bytes`` on the dead stream
and crash with::

TypeError: argument must be an int, or have a fileno() method.

(or, after the fd>1023 cleanup in #68136, a ``ValueError`` from the
selectors backend) escaping all the way out to the salt CLI.

The non-blocking peek must drop the dead stream and trigger the
normal reconnect path so a caller looping on ``recv(timeout=0)``
doesn't spin returning ``None`` forever.
"""
host = "127.0.0.1"
port = 11122
ioloop = asyncio.get_running_loop()
mock_stream = MagicMock()
mock_stream.socket = None
mock_unpacker = MagicMock()
mock_unpacker.__iter__.return_value = []
disconnect_callback = AsyncMock()

async def fake_connect(*args, **kwargs):
return None

with patch("salt.utils.msgpack.Unpacker", return_value=mock_unpacker):
client = salt.transport.tcp.PublishClient(
{}, ioloop, host=host, port=port, disconnect_callback=disconnect_callback
)
client._stream = mock_stream
with patch.object(client, "connect", side_effect=fake_connect) as mock_connect:
# Must not raise.
result = await client.recv(timeout=0)

assert result is None
# Dead stream is dropped and reconnect path is triggered.
assert client._stream is None
mock_stream.close.assert_called_once()
disconnect_callback.assert_awaited_once()
mock_connect.assert_called_once()


def test_close_does_not_leak_pending_read_task(caplog):
"""
Regression test for #68998.
Expand Down
Loading