test_download_deadline_and_gate_2957.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. """Downloads get a deadline that fits the file, and take turns on a printer (#2957).
  2. Two things about ``ftp_timeout``. It is passed as *both* the socket inactivity
  3. timeout and the whole-transfer deadline, so its 30 s default is really a cap on
  4. how big a file a printer is allowed to serve: the reporter measured the same
  5. 5.4 MB 3MF at 45 s off a worn P1S SD card and 25 s off a new one, and a 15.15 MB
  6. 3MF at 105 s. None of those transfers were unhealthy. And it bounded nothing
  7. about concurrency -- he watched Bambu Studio lose its own connection to the
  8. printer while two Bambuddy downloads for the same file ran against it at once.
  9. So the total deadline now follows the size the printer reports, and a printer
  10. serves one Bambuddy download at a time. Both are deliberately soft: the
  11. extension is granted only once SIZE has been answered (so a dead printer still
  12. fails on schedule, and the queue wait #2572 capped is untouched), and a download
  13. that cannot have the gate goes anyway rather than letting a print lose its 3MF
  14. to queueing.
  15. """
  16. from __future__ import annotations
  17. import asyncio
  18. import gc
  19. import threading
  20. import time
  21. from pathlib import Path
  22. from unittest.mock import patch
  23. import pytest
  24. import backend.app.services.bambu_ftp as ftp_mod
  25. from backend.app.services.bambu_ftp import (
  26. _DOWNLOAD_FLOOR_BYTES_PER_SEC,
  27. _DOWNLOAD_MAX_TIMEOUT,
  28. _download_extension,
  29. _serialized_download,
  30. download_file_async,
  31. download_file_try_paths_async,
  32. )
  33. class _FakeClient:
  34. """Enough of ``BambuFTPClient`` for the async wrappers to drive it."""
  35. _mode_cache: dict[str, str] = {}
  36. A1_MODELS = ()
  37. def __init__(self, *a, **kw):
  38. pass
  39. @classmethod
  40. def cache_mode(cls, ip_address, mode):
  41. pass
  42. def connect(self):
  43. return True
  44. def disconnect(self):
  45. pass
  46. class TestTheDeadlineFollowsTheFile:
  47. def test_a_15mb_3mf_gets_far_more_than_the_30s_default(self):
  48. """The reporter's file. 105 s measured, 30 s allowed."""
  49. assert _download_extension(15_150_000, 30.0) > 105.0
  50. def test_an_unknown_size_extends_nothing(self):
  51. """No SIZE reply means no transfer got under way. A printer that is not
  52. answering must still fail on the base deadline."""
  53. assert _download_extension(None, 30.0) == 0.0
  54. assert _download_extension(0, 30.0) == 0.0
  55. def test_a_small_file_that_already_fits_extends_nothing(self):
  56. assert _download_extension(64 * 1024, 30.0) == 0.0
  57. def test_it_is_capped(self):
  58. """``on_print_start`` holds a pooled DB connection across the whole 3MF
  59. hunt, so an unbounded deadline is a connection leak with extra steps."""
  60. assert _download_extension(10 * 1024 * 1024 * 1024, 30.0) == _DOWNLOAD_MAX_TIMEOUT - 30.0
  61. def test_the_floor_is_pessimistic_not_the_measured_rate(self):
  62. """25 KB/s. The reporter's P1S managed ~145 KB/s on its bad day, so the
  63. allowance is several times what a real slow link needs."""
  64. assert _DOWNLOAD_FLOOR_BYTES_PER_SEC == 25 * 1024
  65. @pytest.mark.asyncio
  66. class TestASlowTransferSurvivesItsDeadline:
  67. async def test_a_transfer_that_reports_its_size_is_given_the_time(self, tmp_path):
  68. """The whole point, at 1/1000 scale: a deadline the transfer blows past,
  69. and a printer that answered SIZE. 1 MB at the 25 KB/s floor buys ~39 s,
  70. so a transfer that takes 0.4 s finishes instead of being declared dead
  71. at 0.05 s. Nothing is patched here but the socket."""
  72. payload = b"x" * 4096
  73. class _Client(_FakeClient):
  74. def download_to_file(self, remote_path, local_path, *, size_callback=None, cancel_event=None, **kwargs):
  75. size_callback(1_000_000)
  76. # Honouring the cancel flag is what makes this a real test: the
  77. # expired-deadline path sets it, and a transfer that ignored it
  78. # would be salvaged by the #1014 grace and prove nothing.
  79. for _ in range(40):
  80. if cancel_event is not None and cancel_event.is_set():
  81. raise ftp_mod.DownloadCancelled(remote_path)
  82. time.sleep(0.01)
  83. local_path.write_bytes(payload)
  84. return True
  85. with patch.object(ftp_mod, "BambuFTPClient", _Client):
  86. ok = await download_file_async("10.0.0.1", "x", "/f.3mf", tmp_path / "f.3mf", timeout=0.05)
  87. assert ok is True
  88. assert (tmp_path / "f.3mf").read_bytes() == payload
  89. async def test_a_transfer_that_blows_even_the_size_deadline_is_not_retried(self, tmp_path):
  90. """Otherwise the retry loop spends the whole stretched deadline again to
  91. reach the same conclusion -- four times, holding a pooled database
  92. connection, because ``on_print_start`` never lets go of one. Same reason
  93. ``UploadCancelled`` has been non-retryable since #2529."""
  94. attempts = {"n": 0}
  95. class _Client(_FakeClient):
  96. def download_to_file(self, remote_path, local_path, *, size_callback=None, cancel_event=None, **kwargs):
  97. attempts["n"] += 1
  98. size_callback(1_000_000)
  99. for _ in range(200):
  100. if cancel_event is not None and cancel_event.is_set():
  101. raise ftp_mod.DownloadCancelled(remote_path)
  102. time.sleep(0.01)
  103. return False
  104. with (
  105. patch.object(ftp_mod, "BambuFTPClient", _Client),
  106. patch.object(ftp_mod, "_download_extension", lambda size, base: 0.2 if size else 0.0),
  107. pytest.raises(ftp_mod.DownloadDeadlineExceeded),
  108. ):
  109. await ftp_mod.with_ftp_retry(
  110. download_file_async,
  111. "10.0.0.11",
  112. "x",
  113. "/f.3mf",
  114. tmp_path / "f.3mf",
  115. timeout=0.05,
  116. max_retries=3,
  117. retry_delay=0,
  118. )
  119. assert attempts["n"] == 1, "a transfer that already had its full size-derived deadline was retried"
  120. async def test_an_ordinary_timeout_is_still_an_ordinary_retryable_miss(self, tmp_path):
  121. """No SIZE, no extension, no new exception -- the pre-existing contract."""
  122. class _Client(_FakeClient):
  123. def download_to_file(self, remote_path, local_path, **kwargs):
  124. time.sleep(0.6)
  125. return False
  126. with patch.object(ftp_mod, "BambuFTPClient", _Client):
  127. assert await download_file_async("10.0.0.12", "x", "/f.3mf", tmp_path / "f.3mf", timeout=0.05) is False
  128. async def test_a_printer_that_never_answers_size_still_fails_on_time(self, tmp_path):
  129. class _Client(_FakeClient):
  130. def download_to_file(self, remote_path, local_path, **kwargs):
  131. time.sleep(1.5)
  132. return False
  133. started = time.monotonic()
  134. with patch.object(ftp_mod, "BambuFTPClient", _Client):
  135. ok = await download_file_async("10.0.0.2", "x", "/f.3mf", tmp_path / "f.3mf", timeout=0.2)
  136. elapsed = time.monotonic() - started
  137. assert ok is False
  138. assert elapsed < 5.0, "an unknown size must not buy a transfer any extra time"
  139. @pytest.mark.asyncio
  140. class TestOnlyOneDownloadPerPrinter:
  141. async def test_the_second_download_waits_for_the_first(self):
  142. order: list[str] = []
  143. async def _hold(tag: str, seconds: float):
  144. async with _serialized_download("10.0.0.3", tag) as held:
  145. order.append(f"{tag}:in:{held}")
  146. await asyncio.sleep(seconds)
  147. order.append(f"{tag}:out")
  148. await asyncio.gather(_hold("a", 0.15), _hold("b", 0.01))
  149. assert order == ["a:in:True", "a:out", "b:in:True", "b:out"]
  150. async def test_a_waiter_that_gives_up_goes_anyway(self):
  151. """The gate is contention relief, not a correctness control. A print
  152. that lost its 3MF because a thumbnail held the printer would be a worse
  153. bug than the contention."""
  154. with patch.object(ftp_mod, "_DOWNLOAD_GATE_WAIT_SECONDS", 0.05):
  155. async def _holder():
  156. async with _serialized_download("10.0.0.4", "holder"):
  157. await asyncio.sleep(0.3)
  158. async def _waiter():
  159. async with _serialized_download("10.0.0.4", "waiter") as held:
  160. return held
  161. holder = asyncio.create_task(_holder())
  162. await asyncio.sleep(0.01)
  163. went_anyway = await _waiter()
  164. await holder
  165. assert went_anyway is False
  166. async def test_the_gate_is_released_when_the_body_raises(self):
  167. with pytest.raises(RuntimeError):
  168. async with _serialized_download("10.0.0.5", "boom"):
  169. raise RuntimeError("boom")
  170. async with _serialized_download("10.0.0.5", "after") as held:
  171. assert held is True
  172. async def test_a_real_download_takes_the_gate(self, tmp_path):
  173. """Not just the helper: the two entry points every download goes
  174. through have to be the ones holding it."""
  175. concurrent = {"max": 0, "now": 0}
  176. lock = threading.Lock()
  177. class _Client(_FakeClient):
  178. def download_to_file(self, remote_path, local_path: Path, **kwargs):
  179. with lock:
  180. concurrent["now"] += 1
  181. concurrent["max"] = max(concurrent["max"], concurrent["now"])
  182. time.sleep(0.1)
  183. with lock:
  184. concurrent["now"] -= 1
  185. local_path.write_bytes(b"data")
  186. return True
  187. with patch.object(ftp_mod, "BambuFTPClient", _Client):
  188. await asyncio.gather(
  189. download_file_async("10.0.0.6", "x", "/a.3mf", tmp_path / "a.3mf", timeout=30),
  190. download_file_try_paths_async("10.0.0.6", "x", ["/b.3mf"], tmp_path / "b.3mf", timeout=30),
  191. )
  192. assert concurrent["max"] == 1, "two downloads ran against one printer at the same time"
  193. async def test_the_file_browser_stays_outside_the_gate(self, tmp_path):
  194. """``printer_media`` documented itself lock-free before this gate
  195. existed, in both directions: a 3MF preview must not wait out somebody
  196. else's ten-gigabyte selection, and that selection must not hold the
  197. printer for the twenty minutes it legitimately takes."""
  198. overlapped = asyncio.Event()
  199. class _Client(_FakeClient):
  200. def download_to_file(self, remote_path, local_path: Path, **kwargs):
  201. time.sleep(0.15)
  202. local_path.write_bytes(b"data")
  203. return True
  204. async def _holder():
  205. async with _serialized_download("10.0.0.10", "holder"):
  206. overlapped.set()
  207. await asyncio.sleep(0.3)
  208. with patch.object(ftp_mod, "BambuFTPClient", _Client):
  209. holder = asyncio.create_task(_holder())
  210. await overlapped.wait()
  211. started = time.monotonic()
  212. ok = await download_file_async(
  213. "10.0.0.10", "x", "/big.mp4", tmp_path / "big.mp4", timeout=30, serialize=False
  214. )
  215. elapsed = time.monotonic() - started
  216. await holder
  217. assert ok is True
  218. assert elapsed < 1.0, "an opted-out download queued behind the gate anyway"
  219. async def test_different_printers_do_not_queue_behind_each_other(self):
  220. started = asyncio.Event()
  221. async def _slow():
  222. async with _serialized_download("10.0.0.7", "slow"):
  223. started.set()
  224. await asyncio.sleep(0.3)
  225. task = asyncio.create_task(_slow())
  226. await started.wait()
  227. async with _serialized_download("10.0.0.8", "other") as held:
  228. assert held is True
  229. await task
  230. @pytest.mark.asyncio
  231. class TestTheCapNoLongerLeavesAWorkerOnTheSocket:
  232. async def test_a_capped_path_walk_stops_its_worker(self, tmp_path):
  233. """``asyncio.wait_for`` cannot cancel an executor thread, so the cap used
  234. to return while the worker kept walking the remaining paths -- still
  235. holding the printer's FTP socket. The reporter's log has one of those
  236. still going as the archive flow's own download landed."""
  237. cancelled = threading.Event()
  238. walked: list[str] = []
  239. class _Client(_FakeClient):
  240. def download_to_file(self, remote_path, local_path, *, cancel_event=None, **kwargs):
  241. walked.append(remote_path)
  242. for _ in range(60):
  243. if cancel_event is not None and cancel_event.is_set():
  244. cancelled.set()
  245. raise ftp_mod.DownloadCancelled(remote_path)
  246. time.sleep(0.01)
  247. return False
  248. with patch.object(ftp_mod, "BambuFTPClient", _Client):
  249. hit = await download_file_try_paths_async(
  250. "10.0.0.9", "x", ["/1.3mf", "/2.3mf", "/3.3mf"], tmp_path / "f.3mf", timeout=0.1
  251. )
  252. assert hit is None
  253. assert cancelled.is_set(), "the capped worker was left running on the printer's socket"
  254. assert walked == ["/1.3mf"], "the worker kept walking paths after its caller had given up"
  255. async def test_a_late_transport_error_is_not_logged_as_loop_noise(self, tmp_path):
  256. """Shielding the worker so the cap can wait it out means nobody is left
  257. to read what it raised, and asyncio reports that as a bare
  258. ``Future exception was never retrieved`` ERROR with a traceback -- after
  259. the caller has already logged the real failure. Precisely the class of
  260. noise #2968 was about, so it must not come back in through this door."""
  261. loop_errors: list[str] = []
  262. asyncio.get_running_loop().set_exception_handler(lambda _loop, ctx: loop_errors.append(ctx.get("message", "")))
  263. class _Client(_FakeClient):
  264. def download_to_file(self, remote_path, local_path, **kwargs):
  265. time.sleep(0.3)
  266. raise OSError("late transport failure")
  267. with patch.object(ftp_mod, "BambuFTPClient", _Client):
  268. assert await download_file_try_paths_async("10.0.0.13", "x", ["/a"], tmp_path / "a", timeout=0.05) is None
  269. await asyncio.sleep(0.6)
  270. gc.collect()
  271. await asyncio.sleep(0)
  272. assert loop_errors == [], f"the shielded worker leaked its failure into the log: {loop_errors}"