test_ftp_failed_connect_cleanup_2780.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. """A connection that never came up still has to be closed (#2780).
  2. Every failure path in ``BambuFTPClient.connect`` used to clear ``self._ftp``
  3. and stop there, leaving a connected socket for the garbage collector to
  4. notice. Once, that is untidy. At the volume this code runs at it is not: a
  5. single print used to walk ~110 candidate paths, so a printer refusing FTPS got
  6. ~110 sockets opened and dropped in a couple of minutes, and one support bundle
  7. recorded 1813 in a day.
  8. That matters beyond tidiness, because the leading explanation for the refusal
  9. is the printer running out of connection slots -- a single manual connect to
  10. the same printer completes a clean handshake while Bambuddy is failing, and
  11. vsFTPd answers a per-source limit in cleartext, which is exactly the
  12. ``WRONG_VERSION_NUMBER`` we see. If that is right, abandoning sockets is not a
  13. side effect of the problem, it is part of what sustains it.
  14. These tests assert the socket is closed, not merely dereferenced, because
  15. dereferencing is what the old code did and it looked identical from outside.
  16. """
  17. import ftplib # nosec B402 — tests need the real ftplib to construct its own error types
  18. import ssl
  19. from unittest.mock import MagicMock, patch
  20. import pytest
  21. from backend.app.services.bambu_ftp import BambuFTPClient
  22. pytestmark = pytest.mark.unit
  23. @pytest.fixture(autouse=True)
  24. def _no_cooloff():
  25. """The SSL path opens a per-IP cool-off that outlives the test."""
  26. BambuFTPClient._handshake_blocked_until.clear()
  27. yield
  28. BambuFTPClient._handshake_blocked_until.clear()
  29. def _client(ip="192.168.1.210"):
  30. return BambuFTPClient(ip, "12345678", printer_model="P2S")
  31. @pytest.mark.parametrize(
  32. "error",
  33. [
  34. # The two the #2780 bundle actually recorded, 1813 and 49 times.
  35. ssl.SSLError("[SSL: WRONG_VERSION_NUMBER] wrong version number"),
  36. TimeoutError("_ssl.c:1015: The handshake operation timed out"),
  37. ftplib.error_perm("530 Login incorrect."),
  38. ftplib.error_temp("421 Service not available."),
  39. OSError("Connection reset by peer"),
  40. ],
  41. ids=["ssl", "timeout", "perm", "temp", "oserror"],
  42. )
  43. def test_a_failed_connect_closes_its_socket(error):
  44. fake_ftp = MagicMock()
  45. fake_ftp.connect.side_effect = error
  46. with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
  47. client = _client()
  48. assert client.connect() is False
  49. fake_ftp.close.assert_called_once()
  50. # Never QUIT: that is a command, and there is no working control channel
  51. # to send it on.
  52. fake_ftp.quit.assert_not_called()
  53. assert client._ftp is None
  54. def test_a_failure_after_connect_still_closes():
  55. """Login and prot_p run on a live socket, so a failure there leaks a
  56. genuinely established connection -- the worst case for a session limit."""
  57. fake_ftp = MagicMock()
  58. fake_ftp.login.side_effect = ftplib.error_perm("530 Login incorrect.")
  59. with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
  60. client = _client()
  61. assert client.connect() is False
  62. fake_ftp.close.assert_called_once()
  63. def test_close_raising_does_not_propagate():
  64. """Cleanup is best-effort; the socket may already be gone. A raise here
  65. would turn a handled connect failure into an unhandled one."""
  66. fake_ftp = MagicMock()
  67. fake_ftp.connect.side_effect = OSError("boom")
  68. fake_ftp.close.side_effect = OSError("already closed")
  69. with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
  70. assert _client().connect() is False
  71. class TestDisconnect:
  72. def test_a_healthy_disconnect_quits(self):
  73. fake_ftp = MagicMock()
  74. with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
  75. client = _client()
  76. assert client.connect() is True
  77. client.disconnect()
  78. fake_ftp.quit.assert_called_once()
  79. assert client._ftp is None
  80. def test_a_failing_quit_falls_back_to_closing(self):
  81. """``ftplib.FTP.quit`` sends QUIT and only then closes, so when the
  82. send raises it never reaches its own close and the socket stays open.
  83. The old handler swallowed that exception and left it there.
  84. """
  85. fake_ftp = MagicMock()
  86. fake_ftp.quit.side_effect = OSError("Broken pipe")
  87. with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
  88. client = _client()
  89. assert client.connect() is True
  90. client.disconnect()
  91. fake_ftp.close.assert_called_once()
  92. assert client._ftp is None
  93. def test_the_ssl_path_still_opens_the_cooloff():
  94. """The cleanup change must not disturb the gate that stops the storm."""
  95. fake_ftp = MagicMock()
  96. fake_ftp.connect.side_effect = ssl.SSLError("[SSL: WRONG_VERSION_NUMBER] wrong version number")
  97. with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
  98. client = _client("192.168.1.211")
  99. assert client.connect() is False
  100. assert BambuFTPClient.handshake_blocked("192.168.1.211") is True