test_print_start_no_second_download_3308.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. """A 3MF whose transfer timed out is not downloaded a second time (#3308).
  2. The reporter's A1 serves FTPS at ~87 KB/s during its start-up routine. The
  3. sweep found ``/side_skull_Medium 7x5.5x2 in.gcode.3mf`` -- the printer answered
  4. SIZE -- and spent the 300 s its size bought it without finishing. The directory
  5. walk then listed the folder, picked that same file as the exact-name match and
  6. spent another 300 s on it: twelve minutes holding the printer's one FTPS slot
  7. at its slowest, with the cover lookups queued behind it, before the fallback
  8. archive and its recovery retry (which may take 900 s) could even start.
  9. A path the sweep failed to transfer is skipped by the walk. Only that path:
  10. while a file of the print's own name is listed the walk offers no file that
  11. merely contains the name, so skipping it cannot let a look-alike stand in. And it counts as a
  12. file listed for the print whose transfer failed, so an earlier run's 3MF
  13. (#3307) does not stand in for it either -- the retry fetches the real file.
  14. """
  15. from contextlib import ExitStack
  16. from types import SimpleNamespace
  17. from unittest.mock import AsyncMock, MagicMock, patch
  18. import pytest
  19. from backend.app.core.config import settings as app_settings
  20. from backend.app.main import (
  21. _active_prints,
  22. _expected_print_creators,
  23. _expected_print_registered_at,
  24. _expected_prints,
  25. _print_ams_mappings,
  26. _timelapse_baselines,
  27. )
  28. from backend.app.services.bambu_ftp import FileNotOnPrinterError
  29. pytestmark = pytest.mark.unit
  30. FILE = "side_skull_Medium 7x5.5x2 in.gcode.3mf"
  31. EXACT = f"/{FILE}"
  32. @pytest.fixture(autouse=True)
  33. def _isolated(tmp_path):
  34. dicts = (
  35. _expected_prints,
  36. _expected_print_registered_at,
  37. _expected_print_creators,
  38. _print_ams_mappings,
  39. _active_prints,
  40. _timelapse_baselines,
  41. )
  42. for d in dicts:
  43. d.clear()
  44. with patch.object(app_settings, "archive_dir", tmp_path / "archive"):
  45. yield
  46. for d in dicts:
  47. d.clear()
  48. def _printer():
  49. printer = MagicMock()
  50. printer.id = 2
  51. printer.auto_archive = True
  52. printer.external_camera_enabled = False
  53. printer.external_camera_url = None
  54. printer.plate_detection_enabled = False
  55. printer.name = "A1"
  56. printer.model = "A1"
  57. printer.ip_address = "192.0.2.20"
  58. printer.access_code = "12345678"
  59. return printer
  60. async def _print_start(*, transfer, listing):
  61. """on_print_start for the reporter's A1 print; *transfer* decides each path."""
  62. printer = _printer()
  63. def execute_router(stmt, *args, **kwargs):
  64. sql = str(stmt).lower()
  65. if "from printers" in sql or "from printer " in sql:
  66. return MagicMock(
  67. scalar_one_or_none=MagicMock(return_value=printer),
  68. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[printer]))),
  69. )
  70. return MagicMock(
  71. scalar_one_or_none=MagicMock(return_value=None),
  72. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
  73. )
  74. added: list = []
  75. session = AsyncMock()
  76. session.__aenter__ = AsyncMock(return_value=session)
  77. session.__aexit__ = AsyncMock()
  78. session.execute = AsyncMock(side_effect=execute_router)
  79. session.commit = AsyncMock()
  80. session.refresh = AsyncMock()
  81. session.add = MagicMock(side_effect=added.append)
  82. attempts: list[str] = []
  83. async def download(ip, code, remote_path, local_path, **kwargs):
  84. attempts.append(remote_path)
  85. return await transfer(remote_path, local_path)
  86. state = MagicMock(current_project_url=None, sdcard=True, sdcard_reported=True)
  87. archived = MagicMock(id=99, printer_id=2, status="printing", filename=FILE)
  88. service = MagicMock()
  89. service.archive_print = AsyncMock(return_value=archived)
  90. finder = AsyncMock(return_value=None)
  91. retry = MagicMock()
  92. with ExitStack() as stack:
  93. session_maker = stack.enter_context(patch("backend.app.main.async_session"))
  94. notif = stack.enter_context(patch("backend.app.main.notification_service"))
  95. plug = stack.enter_context(patch("backend.app.main.smart_plug_manager"))
  96. ws = stack.enter_context(patch("backend.app.main.ws_manager"))
  97. relay = stack.enter_context(patch("backend.app.main.mqtt_relay"))
  98. pm = stack.enter_context(patch("backend.app.main.printer_manager"))
  99. stack.enter_context(patch("backend.app.main.download_file_async", new=download))
  100. stack.enter_context(patch("backend.app.main.download_file_try_paths_async", new=AsyncMock(return_value=None)))
  101. stack.enter_context(patch("backend.app.main.get_cached_3mf", return_value=None))
  102. stack.enter_context(patch("backend.app.main.cache_3mf_download"))
  103. stack.enter_context(patch("backend.app.main.peek_plate_index_in_3mf", return_value=None))
  104. stack.enter_context(
  105. patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=list(listing)))
  106. )
  107. stack.enter_context(patch("backend.app.main.ftps_handshake_blocked", return_value=False))
  108. stack.enter_context(
  109. patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30)))
  110. )
  111. stack.enter_context(patch("backend.app.main._record_energy_start", new_callable=AsyncMock))
  112. stack.enter_context(patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock))
  113. stack.enter_context(patch("backend.app.main._maybe_start_layer_timelapse"))
  114. stack.enter_context(patch("backend.app.main._capture_timelapse_baseline_at_start", new_callable=AsyncMock))
  115. stack.enter_context(patch("backend.app.main._schedule_fallback_3mf_retry", new=retry))
  116. stack.enter_context(
  117. patch("backend.app.main._ask_outcome_for_external_print", new=AsyncMock(return_value=False))
  118. )
  119. stack.enter_context(
  120. patch("backend.app.main._drop_archive_of_deleted_printer", new=AsyncMock(return_value=False))
  121. )
  122. stack.enter_context(patch("backend.app.main._printer_gone", new=AsyncMock(return_value=False)))
  123. stack.enter_context(patch("backend.app.main.ArchiveService", return_value=service))
  124. stack.enter_context(patch("backend.app.main._archived_3mf_for_repeat", new=finder))
  125. session_maker.return_value = session
  126. notif.on_print_start = AsyncMock()
  127. plug.on_print_start = AsyncMock()
  128. ws.send_print_start = AsyncMock()
  129. ws.send_archive_updated = AsyncMock()
  130. ws.send_archive_created = AsyncMock()
  131. relay.on_print_start = AsyncMock()
  132. pm.get_status = MagicMock(return_value=state)
  133. pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST3308"))
  134. from backend.app.main import on_print_start
  135. await on_print_start(2, {"filename": FILE, "subtask_name": FILE})
  136. fallback = next(
  137. (r for r in added if isinstance(getattr(r, "extra_data", None), dict) and r.extra_data.get("no_3mf_available")),
  138. None,
  139. )
  140. return SimpleNamespace(attempts=attempts, fallback=fallback, service=service, finder=finder, retry=retry)
  141. def _listed(*names):
  142. return [{"name": name, "is_directory": False} for name in names]
  143. async def _times_out_at_root(remote_path, local_path):
  144. """The exact path is served but never finishes; every other path is a 550."""
  145. if remote_path == EXACT:
  146. return False
  147. raise FileNotOnPrinterError(remote_path)
  148. class TestNoSecondDownload:
  149. @pytest.mark.asyncio
  150. async def test_the_reported_case_tries_the_file_once(self):
  151. run = await _print_start(
  152. transfer=_times_out_at_root,
  153. listing=_listed("FractMod.gcode.3mf", f"sort_of_{FILE}", FILE),
  154. )
  155. assert run.attempts.count(EXACT) == 1
  156. @pytest.mark.asyncio
  157. async def test_a_transfer_that_errored_is_not_tried_again_either(self):
  158. async def errors_at_root(remote_path, local_path):
  159. if remote_path == EXACT:
  160. raise OSError("connection reset mid-transfer")
  161. raise FileNotOnPrinterError(remote_path)
  162. run = await _print_start(transfer=errors_at_root, listing=_listed(FILE))
  163. assert run.attempts.count(EXACT) == 1
  164. @pytest.mark.asyncio
  165. async def test_and_leaves_it_to_the_retry(self):
  166. run = await _print_start(transfer=_times_out_at_root, listing=_listed(FILE))
  167. assert run.fallback is not None
  168. assert run.fallback.extra_data["no_3mf_reason"] == "ftp_transfer_failed"
  169. run.retry.assert_called_once()
  170. @pytest.mark.asyncio
  171. async def test_no_file_that_only_contains_the_name_stands_in(self):
  172. run = await _print_start(transfer=_times_out_at_root, listing=_listed(f"sort_of_{FILE}", FILE))
  173. assert f"/sort_of_{FILE}" not in run.attempts
  174. run.service.archive_print.assert_not_awaited()
  175. @pytest.mark.asyncio
  176. async def test_nor_an_earlier_runs_3mf(self):
  177. """The file is on the card under its own name; #3307's stand-in is
  178. for a file the printer never names."""
  179. run = await _print_start(transfer=_times_out_at_root, listing=_listed(FILE))
  180. run.finder.assert_not_awaited()
  181. def _served_on_second_try(path, *, timed_out=()):
  182. """*path* answers 550 to the sweep and is served to the walk, standing for
  183. a file the walk reaches; paths in *timed_out* never finish."""
  184. seen: dict[str, int] = {}
  185. async def transfer(remote_path, local_path):
  186. seen[remote_path] = seen.get(remote_path, 0) + 1
  187. if remote_path in timed_out:
  188. return False
  189. if remote_path == path and seen[remote_path] == 2:
  190. local_path.parent.mkdir(parents=True, exist_ok=True)
  191. local_path.write_bytes(b"PK\x03\x04")
  192. return True
  193. raise FileNotOnPrinterError(remote_path)
  194. return transfer
  195. class TestTheWalkOtherwiseAsBefore:
  196. @pytest.mark.asyncio
  197. async def test_550_everywhere_still_walks_and_downloads(self):
  198. run = await _print_start(transfer=_served_on_second_try(f"/cache/{FILE}"), listing=_listed(FILE))
  199. assert run.attempts.count(f"/cache/{FILE}") == 2
  200. run.service.archive_print.assert_awaited_once()
  201. @pytest.mark.asyncio
  202. async def test_only_the_failed_path_is_skipped(self):
  203. run = await _print_start(
  204. transfer=_served_on_second_try(f"/cache/{FILE}", timed_out=(EXACT,)), listing=_listed(FILE)
  205. )
  206. assert run.attempts.count(EXACT) == 1
  207. run.service.archive_print.assert_awaited_once()