test_timelapse_ftp_stall_3474.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. """A hung printer file service is not asked again and again (#3474).
  2. At the start of one print an H2S's FTPS service stopped answering. The
  3. print-start baseline came back unread, and the re-read (#3412) then read the
  4. card every 120 s for the whole print: two walks of four directories a round,
  5. each a new connection that hung for 30 s. The printer kept a session for every
  6. one of them and ended up answering every connection with "421 There are too
  7. many connections from your internet address", which no upload got past until
  8. the printer was restarted.
  9. Pinned here:
  10. - a connect or listing that times out opens a cool-off for the background
  11. timelapse scans only, which a listing that completes lifts;
  12. - the baseline reads the card in one walk, and stops at the cool-off;
  13. - the re-read only runs while the print records a timelapse, backs off on
  14. failure, and gives up after a few failed rounds;
  15. - a 421 "too many connections" is its own failure, and the queue says so.
  16. """
  17. import asyncio
  18. import ssl
  19. from contextlib import asynccontextmanager
  20. from types import SimpleNamespace
  21. from unittest.mock import AsyncMock, MagicMock, patch
  22. import pytest
  23. from backend.app.services import bambu_ftp
  24. from backend.app.services.bambu_ftp import (
  25. BambuFTPClient,
  26. FileListResult,
  27. FtpFailureKind,
  28. ftps_background_blocked,
  29. ftps_handshake_blocked,
  30. )
  31. pytestmark = pytest.mark.unit
  32. IP = "192.168.50.77"
  33. TOO_MANY = "421 There are too many connections from your internet address."
  34. @pytest.fixture(autouse=True)
  35. def _clean_state():
  36. BambuFTPClient._handshake_blocked_until.clear()
  37. BambuFTPClient._handshake_skip_logged.clear()
  38. BambuFTPClient._mode_cache.clear()
  39. # Listings joined by printer and folder: a hung one left by a test would be
  40. # joined by the next, on a loop that has since closed.
  41. bambu_ftp._inflight_listings.clear()
  42. yield
  43. BambuFTPClient._handshake_blocked_until.clear()
  44. BambuFTPClient._handshake_skip_logged.clear()
  45. BambuFTPClient._mode_cache.clear()
  46. bambu_ftp._inflight_listings.clear()
  47. def _connect_failing_with(error, **client_kwargs) -> BambuFTPClient:
  48. transport = MagicMock()
  49. transport.connect.side_effect = error
  50. with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=transport):
  51. client = BambuFTPClient(IP, "12345678", printer_model="H2S", **client_kwargs)
  52. assert client.connect() is False
  53. return client
  54. def _wrong_version() -> ssl.SSLError:
  55. error = ssl.SSLError("[SSL: WRONG_VERSION_NUMBER] wrong version number")
  56. error.reason = "WRONG_VERSION_NUMBER"
  57. return error
  58. # ---------------------------------------------------------------------------
  59. # The stall cool-off
  60. # ---------------------------------------------------------------------------
  61. class TestATimeoutHoldsOffTheBackgroundScans:
  62. def test_a_connect_timeout_opens_it(self):
  63. _connect_failing_with(TimeoutError("The handshake operation timed out"))
  64. assert ftps_background_blocked(IP)
  65. def test_it_does_not_hold_off_anyone_else(self):
  66. """The File Manager and dispatch read the handshake gate, which a
  67. timeout leaves alone: one slow handshake must not lock a user out of
  68. browsing the printer for five minutes."""
  69. _connect_failing_with(TimeoutError("The handshake operation timed out"))
  70. assert not ftps_handshake_blocked(IP)
  71. transport = MagicMock()
  72. with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=transport):
  73. assert BambuFTPClient(IP, "12345678").connect() is True
  74. transport.connect.assert_called_once()
  75. def test_a_completed_listing_lifts_it(self):
  76. _connect_failing_with(TimeoutError("The handshake operation timed out"))
  77. client = BambuFTPClient(IP, "12345678")
  78. client._ftp = MagicMock()
  79. client.list_files("/timelapse")
  80. assert not ftps_background_blocked(IP)
  81. def test_a_connect_alone_does_not(self):
  82. """The reporter's printer connected fine and then never answered LIST:
  83. lifting the gate on connect let every next directory through again."""
  84. _connect_failing_with(TimeoutError("The handshake operation timed out"))
  85. with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=MagicMock()):
  86. assert BambuFTPClient(IP, "12345678").connect() is True
  87. assert ftps_background_blocked(IP)
  88. def test_it_ends_on_its_own(self, monkeypatch):
  89. monkeypatch.setattr(bambu_ftp, "_STALL_COOLOFF_SECONDS", 0.0)
  90. _connect_failing_with(TimeoutError("The handshake operation timed out"))
  91. assert not ftps_background_blocked(IP)
  92. def test_a_side_check_does_not_open_it(self):
  93. """``records_handshake_cooloff=False`` is for a check whose failure
  94. says nothing about the printer's file service at large."""
  95. _connect_failing_with(TimeoutError("timed out"), records_handshake_cooloff=False)
  96. assert not ftps_background_blocked(IP)
  97. def test_other_connect_failures_do_not_open_it(self):
  98. _connect_failing_with(OSError("Connection refused"))
  99. assert not ftps_background_blocked(IP)
  100. def test_a_listing_that_times_out_on_its_socket_opens_it(self):
  101. client = BambuFTPClient(IP, "12345678")
  102. client._ftp = MagicMock()
  103. client._ftp.retrlines.side_effect = TimeoutError("timed out")
  104. assert client.list_files("/timelapse") == []
  105. assert ftps_background_blocked(IP)
  106. def test_a_listing_that_fails_otherwise_does_not(self):
  107. client = BambuFTPClient(IP, "12345678")
  108. client._ftp = MagicMock()
  109. client._ftp.cwd.side_effect = bambu_ftp.ftplib.error_perm("550 Failed to change directory.")
  110. assert client.list_files("/record") == []
  111. assert not ftps_background_blocked(IP)
  112. async def test_a_listing_that_connected_and_then_hung_opens_it(self, monkeypatch):
  113. """The reporter's first failure: connected, then LIST /timelapse never
  114. came back. The worker thread is still inside it when the wait ends."""
  115. release = asyncio.Event()
  116. loop = asyncio.get_running_loop()
  117. class HungClient:
  118. def __init__(self, *args, **kwargs):
  119. pass
  120. def connect(self):
  121. return True
  122. def list_files(self, path, raise_on_error=False):
  123. asyncio.run_coroutine_threadsafe(release.wait(), loop).result(5)
  124. return []
  125. def disconnect(self):
  126. pass
  127. monkeypatch.setattr(bambu_ftp, "BambuFTPClient", HungClient)
  128. try:
  129. result = await bambu_ftp.list_files_result_async(IP, "code", "/timelapse", timeout=0.1)
  130. finally:
  131. release.set()
  132. assert result.timed_out
  133. assert bambu_ftp._ftps_stalled(IP)
  134. async def test_a_listing_still_connecting_when_the_wait_ends_opens_it(self, monkeypatch):
  135. """A hung TLS handshake: the wait and the worker's socket timeout are
  136. the same length and the wait starts first, so leaving it to ``connect``
  137. let the next directory open a connection before the gate was set."""
  138. release = asyncio.Event()
  139. loop = asyncio.get_running_loop()
  140. class SlowConnect:
  141. def __init__(self, *args, **kwargs):
  142. pass
  143. def connect(self):
  144. asyncio.run_coroutine_threadsafe(release.wait(), loop).result(5)
  145. return False
  146. monkeypatch.setattr(bambu_ftp, "BambuFTPClient", SlowConnect)
  147. try:
  148. result = await bambu_ftp.list_files_result_async(IP, "code", "/timelapse", timeout=0.1)
  149. finally:
  150. release.set()
  151. assert not result.timed_out
  152. assert bambu_ftp._ftps_stalled(IP)
  153. async def test_the_plain_listing_opens_it_when_its_wait_ends(self, monkeypatch):
  154. """The completion scan's listing has no "connected" signal at all."""
  155. release = asyncio.Event()
  156. loop = asyncio.get_running_loop()
  157. class HungClient:
  158. def __init__(self, *args, **kwargs):
  159. pass
  160. def connect(self):
  161. asyncio.run_coroutine_threadsafe(release.wait(), loop).result(5)
  162. return False
  163. monkeypatch.setattr(bambu_ftp, "BambuFTPClient", HungClient)
  164. try:
  165. assert await bambu_ftp.list_files_async(IP, "code", "/timelapse", timeout=0.1) == []
  166. finally:
  167. release.set()
  168. assert bambu_ftp._ftps_stalled(IP)
  169. async def test_a_hung_walk_opens_one_connection(self, monkeypatch):
  170. """End to end through the real wrapper: the baseline walk stops after
  171. the first directory whose wait ran out."""
  172. from backend.app import main
  173. release = asyncio.Event()
  174. loop = asyncio.get_running_loop()
  175. opened: list[str] = []
  176. class HungClient:
  177. handshake_blocked = BambuFTPClient.handshake_blocked
  178. def __init__(self, *args, **kwargs):
  179. pass
  180. def connect(self):
  181. opened.append("connect")
  182. asyncio.run_coroutine_threadsafe(release.wait(), loop).result(5)
  183. return False
  184. real = bambu_ftp.list_files_result_async
  185. async def _short(ip, code, path, timeout=30.0, socket_timeout=None, printer_model=None):
  186. return await real(ip, code, path, timeout=0.1, printer_model=printer_model)
  187. monkeypatch.setattr(bambu_ftp, "BambuFTPClient", HungClient)
  188. monkeypatch.setattr(bambu_ftp, "list_files_result_async", _short)
  189. try:
  190. assert await main._read_timelapse_baseline(_printer()) == (set(), False)
  191. finally:
  192. release.set()
  193. assert opened == ["connect"]
  194. # ---------------------------------------------------------------------------
  195. # One walk of the card
  196. # ---------------------------------------------------------------------------
  197. def _printer():
  198. # No id: skips the external-storage check, which needs a live status.
  199. return SimpleNamespace(id=None, ip_address=IP, access_code="12345678", model="H2S")
  200. def _video(name):
  201. return {"name": name, "is_directory": False, "size": 10, "path": f"/x/{name}"}
  202. class TestTheBaselineIsOneWalk:
  203. async def test_a_hung_listing_ends_the_round_after_one_connection(self, monkeypatch):
  204. from backend.app import main
  205. asked: list[str] = []
  206. async def _list_result(ip, code, path, timeout=30.0, socket_timeout=None, printer_model=None):
  207. asked.append(path)
  208. bambu_ftp._record_ftps_stall(ip, f"listing {path} timed out")
  209. return FileListResult(files=[], available=False, timed_out=True)
  210. other_walk = AsyncMock(return_value=[])
  211. monkeypatch.setattr(bambu_ftp, "list_files_result_async", _list_result)
  212. monkeypatch.setattr(bambu_ftp, "list_files_async", other_walk)
  213. assert await main._read_timelapse_baseline(_printer()) == (set(), False)
  214. assert asked == ["/timelapse"]
  215. other_walk.assert_not_called()
  216. async def test_nothing_is_asked_while_the_printer_cools_off(self, monkeypatch):
  217. from backend.app import main
  218. bambu_ftp._record_ftps_stall(IP, "test")
  219. listing = AsyncMock()
  220. monkeypatch.setattr(bambu_ftp, "list_files_result_async", listing)
  221. assert await main._read_timelapse_baseline(_printer()) == (set(), False)
  222. listing.assert_not_called()
  223. async def test_a_card_without_videos_is_walked_once(self, monkeypatch):
  224. """It used to be walked twice: once by the video listing, and again by
  225. the walk that tells "empty" from "unreadable"."""
  226. from backend.app import main
  227. asked: list[str] = []
  228. async def _list_result(ip, code, path, timeout=30.0, socket_timeout=None, printer_model=None):
  229. asked.append(path)
  230. if path == "/timelapse":
  231. return FileListResult(files=[], available=True)
  232. return FileListResult(files=[], available=False, missing=True)
  233. monkeypatch.setattr(bambu_ftp, "list_files_result_async", _list_result)
  234. monkeypatch.setattr(bambu_ftp, "list_files_async", AsyncMock(side_effect=AssertionError("second walk")))
  235. assert await main._read_timelapse_baseline(_printer()) == (set(), True)
  236. assert asked == list(main._TIMELAPSE_DIRECTORIES)
  237. async def test_videos_behind_a_failed_directory_still_answer(self, monkeypatch):
  238. """A directory that fails without a cool-off does not end the walk: a
  239. later one holding the videos says what is on the card."""
  240. from backend.app import main
  241. async def _list_result(ip, code, path, timeout=30.0, socket_timeout=None, printer_model=None):
  242. if path == "/timelapse":
  243. return FileListResult(files=[], available=False)
  244. if path == "/record":
  245. return FileListResult(files=[_video("video_1.avi")], available=True)
  246. return FileListResult(files=[], available=False, missing=True)
  247. monkeypatch.setattr(bambu_ftp, "list_files_result_async", _list_result)
  248. assert await main._read_timelapse_baseline(_printer()) == ({"video_1.avi"}, True)
  249. async def test_a_failed_directory_with_nothing_after_it_is_unread(self, monkeypatch):
  250. from backend.app import main
  251. async def _list_result(ip, code, path, timeout=30.0, socket_timeout=None, printer_model=None):
  252. if path == "/timelapse":
  253. return FileListResult(files=[], available=False)
  254. return FileListResult(files=[], available=False, missing=True)
  255. monkeypatch.setattr(bambu_ftp, "list_files_result_async", _list_result)
  256. assert await main._read_timelapse_baseline(_printer()) == (set(), False)
  257. async def test_the_completion_scan_stops_at_the_cool_off(self, monkeypatch):
  258. from backend.app import main
  259. asked: list[str] = []
  260. async def _list(ip, code, path, printer_model=None):
  261. asked.append(path)
  262. bambu_ftp._record_ftps_stall(ip, f"listing {path} timed out")
  263. return []
  264. monkeypatch.setattr(bambu_ftp, "list_files_async", _list)
  265. assert await main._list_timelapse_videos(_printer()) == ([], None)
  266. assert asked == ["/timelapse"]
  267. # ---------------------------------------------------------------------------
  268. # The re-read
  269. # ---------------------------------------------------------------------------
  270. PRINTER_ID = 4747
  271. @asynccontextmanager
  272. async def _session():
  273. db = MagicMock()
  274. db.get = AsyncMock(return_value=SimpleNamespace(id=PRINTER_ID))
  275. yield db
  276. class _Reread:
  277. """Runs the re-read with its sleeps recorded instead of waited out."""
  278. def __init__(self, main, monkeypatch, *, timelapse=True, reads=None):
  279. self.main = main
  280. self.sleeps: list[float] = []
  281. self.status = SimpleNamespace(connected=True, state="RUNNING", timelapse=timelapse)
  282. self.read = AsyncMock(side_effect=reads) if reads is not None else AsyncMock(return_value=(set(), False))
  283. self.names: set[str] = set()
  284. async def _sleep(seconds):
  285. self.sleeps.append(seconds)
  286. monkeypatch.setattr(main.asyncio, "sleep", _sleep)
  287. monkeypatch.setattr(main, "_read_timelapse_baseline", self.read)
  288. monkeypatch.setattr(main, "async_session", _session)
  289. monkeypatch.setattr(main.printer_manager, "get_status", lambda _pid: self.status)
  290. monkeypatch.setattr(main, "_TIMELAPSE_BASELINE_REREAD_MAX_ATTEMPTS", 40)
  291. main._timelapse_baselines[PRINTER_ID] = self.names
  292. main._timelapse_baselines_unread.add(PRINTER_ID)
  293. async def run(self):
  294. try:
  295. await asyncio.wait_for(self.main._reread_unread_timelapse_baseline(PRINTER_ID, self.names, None, None), 5)
  296. finally:
  297. self.main._timelapse_baselines.pop(PRINTER_ID, None)
  298. self.main._timelapse_baselines_unread.discard(PRINTER_ID)
  299. class TestTheReread:
  300. async def test_a_print_without_a_timelapse_never_reads_the_card(self, monkeypatch):
  301. """The reporter's printer records no timelapses, and the completion
  302. scan only looks for a video when one was recorded."""
  303. from backend.app import main
  304. reread = _Reread(main, monkeypatch, timelapse=False)
  305. await reread.run()
  306. reread.read.assert_not_called()
  307. # Skipped, not ended: the flag can arrive after the print has started.
  308. assert len(reread.sleeps) == main._TIMELAPSE_BASELINE_REREAD_MAX_ATTEMPTS
  309. async def test_failed_rounds_back_off_and_then_stop(self, monkeypatch):
  310. from backend.app import main
  311. reread = _Reread(main, monkeypatch)
  312. await reread.run()
  313. assert reread.read.await_count == main._TIMELAPSE_BASELINE_REREAD_MAX_FAILED_ROUNDS
  314. assert reread.sleeps == [120.0, 240.0, 480.0, 960.0, 1800.0]
  315. async def test_the_wait_is_capped(self, monkeypatch):
  316. from backend.app import main
  317. monkeypatch.setattr(main, "_TIMELAPSE_BASELINE_REREAD_MAX_FAILED_ROUNDS", 8)
  318. reread = _Reread(main, monkeypatch)
  319. await reread.run()
  320. assert max(reread.sleeps) == main._TIMELAPSE_BASELINE_REREAD_MAX_INTERVAL_SECONDS
  321. assert reread.sleeps[-3:] == [1800.0, 1800.0, 1800.0]
  322. async def test_a_round_that_raises_counts_as_failed(self, monkeypatch):
  323. from backend.app import main
  324. reread = _Reread(main, monkeypatch, reads=[OSError("boom")] * 5)
  325. await reread.run()
  326. assert reread.read.await_count == 5
  327. assert reread.sleeps == [120.0, 240.0, 480.0, 960.0, 1800.0]
  328. async def test_a_reading_after_failures_still_becomes_the_baseline(self, monkeypatch):
  329. from backend.app import main
  330. reread = _Reread(main, monkeypatch, reads=[(set(), False), (set(), False), ({"video_1.avi"}, True)])
  331. names = reread.names
  332. await reread.run()
  333. assert names == {"video_1.avi"}
  334. assert reread.sleeps == [120.0, 240.0, 480.0]
  335. # ---------------------------------------------------------------------------
  336. # 421 too many connections
  337. # ---------------------------------------------------------------------------
  338. class TestAPrinterOutOfSessions:
  339. def test_a_421_is_its_own_failure(self):
  340. with patch("backend.app.services.bambu_ftp._read_cleartext_reply", return_value=TOO_MANY):
  341. client = _connect_failing_with(_wrong_version())
  342. assert client.last_failure is not None
  343. assert client.last_failure.kind is FtpFailureKind.SESSIONS
  344. assert client.last_failure.code == "421"
  345. assert TOO_MANY in client.last_failure.detail
  346. def test_attempts_later_in_the_window_still_know_it(self):
  347. """Only the first failure in a cool-off asks the printer what it said;
  348. a dispatch ignores the cool-off and makes up to four attempts."""
  349. with patch("backend.app.services.bambu_ftp._read_cleartext_reply", return_value=TOO_MANY) as probe:
  350. _connect_failing_with(_wrong_version())
  351. again = _connect_failing_with(_wrong_version(), respect_handshake_cooloff=False)
  352. probe.assert_called_once()
  353. assert again.last_failure is not None
  354. assert again.last_failure.kind is FtpFailureKind.SESSIONS
  355. def test_a_new_window_forgets_the_old_reply(self):
  356. with patch("backend.app.services.bambu_ftp._read_cleartext_reply", return_value=TOO_MANY):
  357. _connect_failing_with(_wrong_version())
  358. BambuFTPClient._handshake_blocked_until[IP] = 0.0 # expired
  359. with patch("backend.app.services.bambu_ftp._read_cleartext_reply", return_value=None):
  360. client = _connect_failing_with(_wrong_version(), respect_handshake_cooloff=False)
  361. assert client.last_failure is not None
  362. assert client.last_failure.kind is FtpFailureKind.HANDSHAKE
  363. @pytest.mark.parametrize(
  364. "reply",
  365. ["421 Service not available, closing control connection.", "500 OOPS: vsftpd: refusing to run"],
  366. )
  367. def test_other_cleartext_replies_stay_a_handshake_failure(self, reply):
  368. with patch("backend.app.services.bambu_ftp._read_cleartext_reply", return_value=reply):
  369. client = _connect_failing_with(_wrong_version())
  370. assert client.last_failure is not None
  371. assert client.last_failure.kind is FtpFailureKind.HANDSHAKE
  372. def test_the_queue_says_so_and_still_retries(self):
  373. from backend.app.services.print_scheduler import (
  374. _UPLOAD_REQUEUE_KINDS,
  375. _asks_nothing_new,
  376. _refused_upload_hold_reason,
  377. )
  378. reason = _refused_upload_hold_reason("F3DP", FtpFailureKind.SESSIONS)
  379. assert "421" in reason
  380. assert "restart the printer" in reason
  381. assert FtpFailureKind.SESSIONS in _UPLOAD_REQUEUE_KINDS
  382. # Still reads as a refusal to the model-job summary.
  383. assert _asks_nothing_new(reason)