test_ftp_session_close_logging_3009.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. """Every FTP session the client opens says how it closed (#3009).
  2. The reporter of #3009 read a print-completion trace that showed two FTP
  3. connects, one DELE and then nothing, and concluded the connections were never
  4. closed -- the SD-card corruption they were chasing being the consequence.
  5. They were closed. ``disconnect()`` and ``_abandon_connection()`` simply logged
  6. nothing at any level, so a clean close and a genuinely leaked socket produced
  7. the same log: silence. These tests pin the close line down, because a
  8. diagnostic that only exists until someone tidies it away is worth nothing to
  9. the next person reading a support bundle.
  10. """
  11. import logging
  12. import pytest
  13. from backend.app.services.bambu_ftp import BambuFTPClient
  14. from backend.tests.unit.services.mock_ftp_server import MockBambuFTPServer
  15. from .conftest import _find_free_port
  16. def _close_lines(caplog) -> list[str]:
  17. return [r.getMessage() for r in caplog.records if "FTP session to" in r.getMessage()]
  18. class TestACleanSessionSaysSo:
  19. """The ordinary path: connect, work, QUIT."""
  20. def test_a_clean_close_is_logged_once(self, ftp_client_factory, caplog):
  21. client = ftp_client_factory()
  22. assert client.connect() is True
  23. with caplog.at_level(logging.DEBUG, logger="backend.app.services.bambu_ftp"):
  24. client.disconnect()
  25. lines = _close_lines(caplog)
  26. assert len(lines) == 1, lines
  27. assert "closed after QUIT" in lines[0]
  28. assert "127.0.0.1" in lines[0]
  29. def test_the_line_carries_how_long_the_session_was_held(self, ftp_client_factory, caplog):
  30. """Without a duration the line cannot distinguish a short delete from a
  31. session that sat open for the length of a print -- which is the exact
  32. question #3009 asked."""
  33. client = ftp_client_factory()
  34. client.connect()
  35. with caplog.at_level(logging.DEBUG, logger="backend.app.services.bambu_ftp"):
  36. client.disconnect()
  37. assert "held 0." in _close_lines(caplog)[0]
  38. def test_a_delete_through_the_async_wrapper_closes_and_says_so(self, ftp_server, ftp_root, caplog):
  39. """The path #3009 actually traced: the post-print SD-card cleanup in
  40. ``on_print_complete`` calls ``delete_file_async`` once per candidate."""
  41. import asyncio
  42. from backend.app.services.bambu_ftp import DeleteResult, delete_file_async
  43. (ftp_root / "cube.gcode").write_bytes(b"G28\n")
  44. original_port = BambuFTPClient.FTP_PORT
  45. BambuFTPClient.FTP_PORT = ftp_server.port
  46. try:
  47. with caplog.at_level(logging.DEBUG, logger="backend.app.services.bambu_ftp"):
  48. result = asyncio.run(delete_file_async("127.0.0.1", "12345678", "/cube.gcode", printer_model="X1C"))
  49. finally:
  50. BambuFTPClient.FTP_PORT = original_port
  51. assert result == DeleteResult.DELETED
  52. assert len(_close_lines(caplog)) == 1
  53. def test_the_550_path_closes_too(self, ftp_server, caplog):
  54. """The line #3009's log ends on. A candidate the printer does not have
  55. answers 550, and that session has to close like any other."""
  56. import asyncio
  57. from backend.app.services.bambu_ftp import DeleteResult, delete_file_async
  58. original_port = BambuFTPClient.FTP_PORT
  59. BambuFTPClient.FTP_PORT = ftp_server.port
  60. try:
  61. with caplog.at_level(logging.DEBUG, logger="backend.app.services.bambu_ftp"):
  62. result = asyncio.run(delete_file_async("127.0.0.1", "12345678", "/not_here.3mf", printer_model="X1C"))
  63. finally:
  64. BambuFTPClient.FTP_PORT = original_port
  65. assert result == DeleteResult.NOT_FOUND
  66. lines = _close_lines(caplog)
  67. assert len(lines) == 1, lines
  68. assert "closed after QUIT" in lines[0]
  69. def test_disconnect_without_a_session_says_nothing(self, ftp_client_factory, caplog):
  70. """No socket was opened, so there is no session to account for. A line
  71. here would be worse than none: it would pair with no connect."""
  72. client = ftp_client_factory()
  73. with caplog.at_level(logging.DEBUG, logger="backend.app.services.bambu_ftp"):
  74. client.disconnect()
  75. assert _close_lines(caplog) == []
  76. class TestAFailedConnectIsAccountedForToo:
  77. """A connect that opens a socket and then fails still closed something."""
  78. def test_a_rejected_login_reports_the_close(self, ftp_client_factory, caplog):
  79. with caplog.at_level(logging.DEBUG, logger="backend.app.services.bambu_ftp"):
  80. assert ftp_client_factory(access_code="wrongcode").connect() is False
  81. lines = _close_lines(caplog)
  82. assert len(lines) == 1, lines
  83. assert "closed without QUIT" in lines[0]
  84. assert "login rejected" in lines[0]
  85. def test_an_unreachable_printer_reports_the_close(self, ftp_server, caplog):
  86. client = BambuFTPClient("192.0.2.1", "12345678", timeout=1.0, printer_model="X1C")
  87. client.FTP_PORT = ftp_server.port
  88. with caplog.at_level(logging.DEBUG, logger="backend.app.services.bambu_ftp"):
  89. assert client.connect() is False
  90. lines = _close_lines(caplog)
  91. assert len(lines) == 1, lines
  92. assert "closed without QUIT" in lines[0]
  93. # No socket was ever established, so there is no duration to claim.
  94. assert "held unknown" in lines[0]
  95. class TestTheSessionIsNotDoubleCounted:
  96. """Isolated class: ``server.stop()`` calls ``close_all()``, which nukes every
  97. asyncore socket in the process."""
  98. def test_a_failing_quit_reports_one_close_not_two(self, ftp_certs, tmp_path, caplog):
  99. """``disconnect()`` falls through to ``_abandon_connection()`` when QUIT
  100. cannot be sent. Both log, so the fallback must not produce a second line
  101. for one session."""
  102. cert_path, key_path = ftp_certs
  103. server = MockBambuFTPServer("127.0.0.1", _find_free_port(), str(tmp_path), cert_path, key_path)
  104. server.start()
  105. client = BambuFTPClient("127.0.0.1", "12345678", timeout=5.0)
  106. client.FTP_PORT = server.port
  107. assert client.connect() is True
  108. server.stop()
  109. with caplog.at_level(logging.DEBUG, logger="backend.app.services.bambu_ftp"):
  110. client.disconnect()
  111. lines = _close_lines(caplog)
  112. assert len(lines) == 1, lines
  113. assert "closed without QUIT" in lines[0]
  114. assert "QUIT failed" in lines[0]
  115. assert client._ftp is None
  116. class TestTheCoolOffSkipStaysSilent:
  117. """No connect was attempted, so there is nothing to close."""
  118. def test_a_skipped_connect_logs_no_close(self, ftp_client_factory, caplog):
  119. import time
  120. BambuFTPClient._handshake_blocked_until["127.0.0.1"] = time.monotonic() + 300
  121. client = ftp_client_factory()
  122. with caplog.at_level(logging.DEBUG, logger="backend.app.services.bambu_ftp"):
  123. assert client.connect() is False
  124. assert _close_lines(caplog) == []