| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133 |
- """A connection that never came up still has to be closed (#2780).
- Every failure path in ``BambuFTPClient.connect`` used to clear ``self._ftp``
- and stop there, leaving a connected socket for the garbage collector to
- notice. Once, that is untidy. At the volume this code runs at it is not: a
- single print used to walk ~110 candidate paths, so a printer refusing FTPS got
- ~110 sockets opened and dropped in a couple of minutes, and one support bundle
- recorded 1813 in a day.
- That matters beyond tidiness, because the leading explanation for the refusal
- is the printer running out of connection slots -- a single manual connect to
- the same printer completes a clean handshake while Bambuddy is failing, and
- vsFTPd answers a per-source limit in cleartext, which is exactly the
- ``WRONG_VERSION_NUMBER`` we see. If that is right, abandoning sockets is not a
- side effect of the problem, it is part of what sustains it.
- These tests assert the socket is closed, not merely dereferenced, because
- dereferencing is what the old code did and it looked identical from outside.
- """
- import ftplib # nosec B402 — tests need the real ftplib to construct its own error types
- import ssl
- from unittest.mock import MagicMock, patch
- import pytest
- from backend.app.services.bambu_ftp import BambuFTPClient
- pytestmark = pytest.mark.unit
- @pytest.fixture(autouse=True)
- def _no_cooloff():
- """The SSL path opens a per-IP cool-off that outlives the test."""
- BambuFTPClient._handshake_blocked_until.clear()
- yield
- BambuFTPClient._handshake_blocked_until.clear()
- def _client(ip="192.168.1.210"):
- return BambuFTPClient(ip, "12345678", printer_model="P2S")
- @pytest.mark.parametrize(
- "error",
- [
- # The two the #2780 bundle actually recorded, 1813 and 49 times.
- ssl.SSLError("[SSL: WRONG_VERSION_NUMBER] wrong version number"),
- TimeoutError("_ssl.c:1015: The handshake operation timed out"),
- ftplib.error_perm("530 Login incorrect."),
- ftplib.error_temp("421 Service not available."),
- OSError("Connection reset by peer"),
- ],
- ids=["ssl", "timeout", "perm", "temp", "oserror"],
- )
- def test_a_failed_connect_closes_its_socket(error):
- fake_ftp = MagicMock()
- fake_ftp.connect.side_effect = error
- with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
- client = _client()
- assert client.connect() is False
- fake_ftp.close.assert_called_once()
- # Never QUIT: that is a command, and there is no working control channel
- # to send it on.
- fake_ftp.quit.assert_not_called()
- assert client._ftp is None
- def test_a_failure_after_connect_still_closes():
- """Login and prot_p run on a live socket, so a failure there leaks a
- genuinely established connection -- the worst case for a session limit."""
- fake_ftp = MagicMock()
- fake_ftp.login.side_effect = ftplib.error_perm("530 Login incorrect.")
- with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
- client = _client()
- assert client.connect() is False
- fake_ftp.close.assert_called_once()
- def test_close_raising_does_not_propagate():
- """Cleanup is best-effort; the socket may already be gone. A raise here
- would turn a handled connect failure into an unhandled one."""
- fake_ftp = MagicMock()
- fake_ftp.connect.side_effect = OSError("boom")
- fake_ftp.close.side_effect = OSError("already closed")
- with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
- assert _client().connect() is False
- class TestDisconnect:
- def test_a_healthy_disconnect_quits(self):
- fake_ftp = MagicMock()
- with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
- client = _client()
- assert client.connect() is True
- client.disconnect()
- fake_ftp.quit.assert_called_once()
- assert client._ftp is None
- def test_a_failing_quit_falls_back_to_closing(self):
- """``ftplib.FTP.quit`` sends QUIT and only then closes, so when the
- send raises it never reaches its own close and the socket stays open.
- The old handler swallowed that exception and left it there.
- """
- fake_ftp = MagicMock()
- fake_ftp.quit.side_effect = OSError("Broken pipe")
- with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
- client = _client()
- assert client.connect() is True
- client.disconnect()
- fake_ftp.close.assert_called_once()
- assert client._ftp is None
- def test_the_ssl_path_still_opens_the_cooloff():
- """The cleanup change must not disturb the gate that stops the storm."""
- fake_ftp = MagicMock()
- fake_ftp.connect.side_effect = ssl.SSLError("[SSL: WRONG_VERSION_NUMBER] wrong version number")
- with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
- client = _client("192.168.1.211")
- assert client.connect() is False
- assert BambuFTPClient.handshake_blocked("192.168.1.211") is True
|