test_ftp_download_deadlines.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. """FTP downloads stop at their deadline, and the deadline fits the file (TO-C1, TO-C3).
  2. The bytes download used to give up on its caller at the deadline and leave the
  3. transfer running in its worker thread, and ``with_ftp_retry`` read that as a
  4. retryable failure, so each retry opened another FTP session alongside the
  5. first. The printer file download was held at a flat 600 s whatever the size:
  6. the size-based extension was capped at 300 s in total, below that base.
  7. """
  8. import asyncio
  9. import threading
  10. from pathlib import Path
  11. import pytest
  12. from backend.app.services import bambu_ftp, printer_media
  13. from backend.app.services.bambu_ftp import DownloadDeadlineExceeded
  14. class _StuckClient:
  15. """A printer that keeps a transfer going until it is told to stop."""
  16. instances: list["_StuckClient"] = []
  17. def __init__(self, *_args, **_kwargs) -> None:
  18. self.cancelled = threading.Event()
  19. self.finished = threading.Event()
  20. _StuckClient.instances.append(self)
  21. def connect(self) -> bool:
  22. return True
  23. def disconnect(self) -> None:
  24. pass
  25. def download_file(self, _remote_path, expected_size=None, cancel_event=None, **_kwargs):
  26. try:
  27. while cancel_event is None or not cancel_event.is_set():
  28. if self.cancelled.wait(0.01):
  29. break
  30. self.cancelled.set()
  31. return None
  32. finally:
  33. self.finished.set()
  34. @pytest.fixture
  35. def stuck(monkeypatch):
  36. _StuckClient.instances = []
  37. monkeypatch.setattr(bambu_ftp, "BambuFTPClient", _StuckClient)
  38. return _StuckClient.instances
  39. @pytest.mark.asyncio
  40. async def test_a_sized_download_past_its_deadline_stops_and_says_so(stuck):
  41. with pytest.raises(DownloadDeadlineExceeded):
  42. await bambu_ftp.download_file_bytes_async("10.0.0.5", "code", "/timelapse/v.mp4", timeout=0.1, expected_size=10)
  43. # The transfer was stopped before the call returned, not left running.
  44. assert stuck[0].cancelled.is_set() and stuck[0].finished.is_set()
  45. @pytest.mark.asyncio
  46. async def test_an_unsized_download_past_its_deadline_stops_and_fails(stuck):
  47. result = await bambu_ftp.download_file_bytes_async("10.0.0.5", "code", "/model.3mf", timeout=0.1)
  48. assert result is None
  49. assert stuck[0].cancelled.is_set() and stuck[0].finished.is_set()
  50. @pytest.mark.asyncio
  51. async def test_a_retry_never_starts_a_second_session_beside_the_first(stuck):
  52. with pytest.raises(DownloadDeadlineExceeded):
  53. await bambu_ftp.with_ftp_retry(
  54. bambu_ftp.download_file_bytes_async,
  55. "10.0.0.5",
  56. "code",
  57. "/timelapse/v.mp4",
  58. timeout=0.1,
  59. expected_size=10,
  60. max_retries=3,
  61. retry_delay=0,
  62. operation_name="test",
  63. )
  64. assert len(stuck) == 1
  65. @pytest.mark.asyncio
  66. async def test_a_cancelled_caller_stops_the_transfer(stuck):
  67. task = asyncio.create_task(bambu_ftp.download_file_bytes_async("10.0.0.5", "code", "/model.3mf", timeout=30))
  68. await asyncio.sleep(0.05)
  69. task.cancel()
  70. with pytest.raises(asyncio.CancelledError):
  71. await task
  72. assert stuck[0].cancelled.is_set() and stuck[0].finished.is_set()
  73. @pytest.mark.asyncio
  74. async def test_a_download_that_finishes_returns_its_bytes(monkeypatch):
  75. class _Client(_StuckClient):
  76. def download_file(self, _remote_path, expected_size=None, cancel_event=None, **_kwargs):
  77. return b"video"
  78. monkeypatch.setattr(bambu_ftp, "BambuFTPClient", _Client)
  79. assert await bambu_ftp.download_file_bytes_async("10.0.0.5", "code", "/v.mp4", expected_size=5) == b"video"
  80. @pytest.mark.asyncio
  81. async def test_an_unlisted_download_gets_the_time_its_reported_size_buys(monkeypatch):
  82. """No listing size: the SIZE the printer reports at the start extends the
  83. deadline, as for download_file_async (a 60 MB 3MF on an A1)."""
  84. class _Slow(_StuckClient):
  85. def download_file(self, _remote_path, expected_size=None, cancel_event=None, size_callback=None, **_kwargs):
  86. size_callback(10 * 1024 * 1024)
  87. if self.cancelled.wait(0.3) or (cancel_event and cancel_event.is_set()):
  88. return None
  89. return b"3mf"
  90. monkeypatch.setattr(bambu_ftp, "BambuFTPClient", _Slow)
  91. assert await bambu_ftp.download_file_bytes_async("10.0.0.5", "code", "/model.3mf", timeout=0.1) == b"3mf"
  92. def _client_with(error: Exception) -> bambu_ftp.BambuFTPClient:
  93. from unittest.mock import MagicMock
  94. client = bambu_ftp.BambuFTPClient("10.0.0.5", "code")
  95. client._ftp = MagicMock()
  96. client._ftp.retrbinary.side_effect = error
  97. client._ftp.size.side_effect = error
  98. return client
  99. def test_a_missing_path_is_told_apart_when_asked():
  100. import ftplib
  101. missing = ftplib.error_perm("550 No such file")
  102. with pytest.raises(bambu_ftp.FileNotOnPrinterError):
  103. _client_with(missing).download_file("/gone.3mf", report_missing=True)
  104. with pytest.raises(bambu_ftp.FileNotOnPrinterError):
  105. _client_with(missing).download_file("/gone.3mf", size_callback=lambda _n: None, report_missing=True)
  106. # Every other caller still reads it as a failed download.
  107. assert _client_with(missing).download_file("/gone.3mf") is None
  108. def test_other_ftp_errors_are_failures_not_missing():
  109. import ftplib
  110. assert _client_with(ftplib.error_perm("530 Not logged in")).download_file("/x", report_missing=True) is None
  111. assert _client_with(OSError("reset")).download_file("/x", report_missing=True) is None
  112. def test_the_size_extension_can_reach_past_the_print_start_ceiling():
  113. size = 250 * 1024 * 1024 # a 250 MB /ipcam chunk
  114. assert bambu_ftp._download_extension(size, 600) == 0.0 # print start keeps its 300 s ceiling
  115. assert bambu_ftp._download_extension(size, 600, ceiling=1800) == pytest.approx(1200)
  116. # A preview of a 60 MB 3MF at the default 300 s base, with the file browser's budget.
  117. assert bambu_ftp._download_extension(60 * 1024 * 1024, 300, ceiling=1800) == pytest.approx(1500)
  118. @pytest.mark.asyncio
  119. async def test_a_transfer_that_does_not_stop_at_once_is_still_waited_for(monkeypatch):
  120. """A read blocked on the socket only notices the cancel when it returns:
  121. the call must not come back (and a retry must not start) before that."""
  122. class _Deaf(_StuckClient):
  123. def download_file(self, _remote_path, expected_size=None, cancel_event=None, **_kwargs):
  124. try:
  125. self.cancelled.wait(0.8) # ignores the cancel until the "socket" returns
  126. return None
  127. finally:
  128. self.finished.set()
  129. monkeypatch.setattr(bambu_ftp, "BambuFTPClient", _Deaf)
  130. _StuckClient.instances = []
  131. with pytest.raises(DownloadDeadlineExceeded):
  132. await bambu_ftp.download_file_bytes_async("10.0.0.5", "code", "/v.mp4", timeout=0.1, expected_size=10)
  133. assert _StuckClient.instances[0].finished.is_set()
  134. @pytest.mark.asyncio
  135. async def test_a_printer_file_download_may_use_the_whole_job_budget(monkeypatch, tmp_path):
  136. seen = {}
  137. async def fake_download(*_args, **kwargs):
  138. seen.update(kwargs)
  139. return False
  140. monkeypatch.setattr(printer_media, "download_file_async", fake_download)
  141. monkeypatch.setattr(printer_media, "_ensure_printer_zip_root", lambda: tmp_path)
  142. printer = type("P", (), {"id": 1, "ip_address": "10.0.0.5", "access_code": "c", "model": "P1S"})()
  143. with pytest.raises(printer_media.PrinterFileDownloadError):
  144. await printer_media.build_printer_file(printer, "/ipcam/clip.mp4", 250 * 1024 * 1024, bundle_key="k1")
  145. assert seen["max_total_timeout"] == printer_media.MAX_PRINTER_ZIP_PREPARE_SECONDS
  146. @pytest.mark.asyncio
  147. async def test_a_printer_file_past_its_deadline_is_a_timeout(monkeypatch, tmp_path):
  148. async def fake_download(*_args, **_kwargs):
  149. raise DownloadDeadlineExceeded("/ipcam/clip.mp4")
  150. monkeypatch.setattr(printer_media, "download_file_async", fake_download)
  151. monkeypatch.setattr(printer_media, "_ensure_printer_zip_root", lambda: tmp_path)
  152. printer = type("P", (), {"id": 1, "ip_address": "10.0.0.5", "access_code": "c", "model": "P1S"})()
  153. with pytest.raises(TimeoutError):
  154. await printer_media.build_printer_file(printer, "/ipcam/clip.mp4", 1000, bundle_key="k2")
  155. assert not (Path(tmp_path) / "k2").exists()
  156. def test_a_size_refusal_is_left_to_retr():
  157. """A 550 to SIZE (no such command, a file still being written) does not
  158. make a file missing that RETR then serves."""
  159. import ftplib
  160. from unittest.mock import MagicMock
  161. client = bambu_ftp.BambuFTPClient("10.0.0.5", "code")
  162. client._ftp = MagicMock()
  163. client._ftp.size.side_effect = ftplib.error_perm("550 Could not get file size")
  164. client._ftp.retrbinary.side_effect = lambda _cmd, write: write(b"3mf-bytes")
  165. assert client.download_file("/model.3mf", size_callback=lambda _n: None, report_missing=True) == b"3mf-bytes"