Skip to content

Guard PublishClient.recv against torn-down stream socket (#66435) - #69479

Open
dwoz wants to merge 1 commit into
saltstack:3007.xfrom
dwoz:fix/issue-66435
Open

Guard PublishClient.recv against torn-down stream socket (#66435)#69479
dwoz wants to merge 1 commit into
saltstack:3007.xfrom
dwoz:fix/issue-66435

Conversation

@dwoz

@dwoz dwoz commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds a small None-check in salt.transport.tcp.PublishClient.recv(timeout=0) so that a stream whose underlying socket has been concurrently torn down no longer crashes the caller. The non-blocking peek now treats a missing socket as "no events pending" and returns None, letting the existing reconnect loop take over.

What issues does this PR fix or reference?

Fixes #66435

Related (different bugs in the same module, do not fix this one):

Previous Behavior

Under load — and reliably on FreeBSD 14 with the 3007.x packages and on RHEL 9.2 / Debian 12 with ipc_mode: ipc — every salt and salt-master invocation crashed out of PublishClient.recv with one of:

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

(in 3007.0, from select.select([self._stream.socket], [], [], 0)) or, after #68136 swapped in selectors.DefaultSelector,

ValueError: Invalid file object: None

from selectors._fileobj_to_fd. In both cases the root cause is the same: between the while self._stream is None: await self.connect() check at the top of recv() and the selector peek a few lines later, the Tornado IOStream for the publish IPC socket can be closed by another task. Tornado sets IOStream.socket to None on close, so the peek tries to register None with the selector and dies with an unhandled exception. The error escaped all the way through salt.utils.asynchronous.SyncWrapper to the salt CLI, breaking every command.

New Behavior

recv(timeout=0) snapshots self._stream.socket once. If it's None, the method returns None immediately — the same return value the caller already handles when no events are pending — and the existing reconnect path takes over without crashing.

A regression test (tests/pytests/unit/transport/test_publish_client.py::test_recv_timeout_zero_stream_socket_none) constructs a PublishClient, sets its _stream to a mock whose .socket is None, and asserts recv(timeout=0) returns None without raising. It fails on unmodified 3007.x with ValueError: Invalid file object: None and passes with the fix.

3008.x and master are unaffected: that recv path has already been rewritten to use asyncio.ensure_future(self._read_into_unpacker()) instead of a kernel-level socket peek, so no merge-forward port is needed beyond what the merge bots will do.

Merge requirements satisfied?

  • Docs (no documented behavior changes)
  • Changelog (changelog/66435.fixed.md)
  • Tests written/updated (tests/pytests/unit/transport/test_publish_client.py)

Commits signed with GPG?

No (matches surrounding non-merge commits on this branch).

@dwoz
dwoz requested a review from a team as a code owner June 18, 2026 07:49
@dwoz dwoz added this to the Chlorine v3007.15 milestone Jun 18, 2026
@dwoz dwoz added the test:full Run the full test suite label Jun 18, 2026
Comment thread salt/transport/tcp.py Outdated
rvesselinov
rvesselinov previously approved these changes Jun 23, 2026
dwoz added a commit to dwoz/salt that referenced this pull request Jun 25, 2026
Per review feedback on saltstack#69479: if ``_stream.socket`` is ``None`` because
Tornado tore the IOStream down concurrently, simply returning ``None``
left ``_stream`` set and a caller looping on ``recv(timeout=0)`` would
spin returning ``None`` forever without ever reconnecting.

Mirror the ``StreamClosedError`` branch: drop the dead stream, fire the
disconnect callback, and call ``connect()`` so the next ``recv`` enters
a healthy stream.
@dwoz
dwoz requested a review from rvesselinov June 26, 2026 11:14
twangboy
twangboy previously approved these changes Jul 8, 2026
PublishClient.recv(timeout=0) was passing self._stream.socket straight
to selectors.DefaultSelector().register() without checking whether the
IOStream's underlying socket had been concurrently torn down. Tornado
sets IOStream.socket to None once the stream is closed, and the
non-blocking peek would then raise

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

(or, after the fd>1023 cleanup in saltstack#68136, a ValueError from the
selectors backend) escaping all the way out to the salt CLI, breaking
every salt and salt-master invocation on hosts where the
publisher-side stream closed underneath the client.

Treat a missing socket as "no events pending" and return None so the
caller re-enters its connect/reconnect loop instead of crashing.

Fixes saltstack#66435

@dwoz dwoz left a comment

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.

Regression-suspect. Two HIGH blockers (own-PR — cannot REQUEST_CHANGES, flagged as COMMENT).

The guard swaps a crash-on-dead-socket for an unbounded block-on-dead-socket in the exact recv(timeout=0) non-blocking path used by SaltEvent.get_event_noblock / _get_event(no_block=True) (salt CLI tight-poll). Details inline.

Cross-branch: PR body claims "3008.x and master are unaffected: that recv path has already been rewritten to use asyncio.ensure_future(self._read_into_unpacker())". Verified against origin/3008.x and origin/masterPublishClient.recv(timeout=0) is byte-identical to origin/3007.x. PR body also cites selectors.DefaultSelector as the crash site, but commit 591bfe0b51c removed that from origin/3007.x tip; the crash reproduces on released v3007.14, not on tip. Correct the PR body; if the guard is desired on 3008.x/master, plan sibling PRs or merge-forward rather than assuming bots will do the right thing.

LOW (cross-transport): ZMQ PublishClient.recv (salt/transport/zeromq.py:360) polls self._socket without a symmetric None check; ws.PublishClient.recv (salt/transport/ws.py:200) has its own while self._ws is None retry. This PR only patches TCP. Matches the reported TCP-only crash, but the ZMQ path has a comparable close() vs recv() window (raises zmq.error.ZMQError, existing consumers catch it). Not a blocker — but document the intent.

LOW (nit): comment "so a caller looping on recv(timeout=0) doesn't spin returning None forever" is misleading; the pre-existing if self._stream is None: return None at line 513 already returns None for the same state. The guard doesn't stop the spin; the await connect() does — which is exactly the source of HIGH 1.

Comment thread salt/transport/tcp.py
stream.close()
if self.disconnect_callback:
await self.disconnect_callback()
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.

Comment thread salt/transport/tcp.py
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.

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.

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.

3 participants