test_ftp_cooloff_retry_budget_2898.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. """A dispatch must not spend its retries on a cool-off that outlives them (#2898).
  2. ``BambuFTPClient.connect`` refuses to open a socket for 300s after a TLS
  3. handshake failure (#2780). That gate was written for the background sweeps --
  4. the post-print 3MF, cover and timelapse fetches, which walk ~110 candidate
  5. paths against one wedged printer and have nobody waiting on them.
  6. It sat inside ``connect``, so it applied to print dispatch too, which wants the
  7. opposite. On a 10-printer farm one handshake failure took out three queued
  8. jobs: the pre-upload delete armed the cool-off, all four upload attempts were
  9. then answered from the gate 2s apart without a socket being opened, and the
  10. next two jobs for that printer failed the same way inside the same window.
  11. The split these tests pin: work that is bounded and user-initiated (a dispatch
  12. is one delete plus at most four upload attempts) opts out; everything else
  13. keeps #2780's behaviour exactly. Sockets are counted rather than inferred,
  14. because "returned False" looks identical either way -- which is what made the
  15. original report a log dive.
  16. """
  17. import logging
  18. import ssl
  19. from contextlib import ExitStack
  20. from pathlib import Path
  21. from types import SimpleNamespace
  22. from unittest.mock import AsyncMock, MagicMock, patch
  23. import pytest
  24. from backend.app.services import bambu_ftp
  25. from backend.app.services.bambu_ftp import (
  26. BambuFTPClient,
  27. DeleteResult,
  28. delete_file_async,
  29. upload_file_async,
  30. with_ftp_retry,
  31. )
  32. pytestmark = pytest.mark.unit
  33. IP = "192.168.50.142" # the P2S from the report
  34. LOGGER = "backend.app.services.bambu_ftp"
  35. @pytest.fixture(autouse=True)
  36. def _clean_cooloff():
  37. BambuFTPClient._handshake_blocked_until.clear()
  38. BambuFTPClient._handshake_skip_logged.clear()
  39. BambuFTPClient._mode_cache.clear()
  40. yield
  41. BambuFTPClient._handshake_blocked_until.clear()
  42. BambuFTPClient._handshake_skip_logged.clear()
  43. BambuFTPClient._mode_cache.clear()
  44. @pytest.fixture()
  45. def refusing_printer():
  46. """Answers port 990 with something that is not TLS, every time.
  47. Yields the transport mock; ``transport.connect.call_count`` is the number
  48. of times we actually went near the printer, which is the whole question
  49. here.
  50. """
  51. transport = MagicMock()
  52. transport.connect.side_effect = ssl.SSLError("[SSL: WRONG_VERSION_NUMBER] wrong version number")
  53. with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=transport):
  54. yield transport
  55. def _arm(ip=IP):
  56. """Put *ip* into the cool-off the way a real handshake failure would."""
  57. BambuFTPClient._handshake_blocked_until[ip] = bambu_ftp.time.monotonic() + bambu_ftp._HANDSHAKE_COOLOFF_SECONDS
  58. assert BambuFTPClient.handshake_blocked(ip) is True
  59. # ---------------------------------------------------------------------------
  60. # The gate itself
  61. # ---------------------------------------------------------------------------
  62. class TestConnectHonoursTheOptOut:
  63. def test_the_default_still_refuses_to_open_a_socket(self, refusing_printer):
  64. """#2780's protection is the default and must stay untouched."""
  65. _arm()
  66. assert BambuFTPClient(IP, "12345678", printer_model="P2S").connect() is False
  67. assert refusing_printer.connect.call_count == 0
  68. def test_an_exempt_client_reaches_the_printer(self, refusing_printer):
  69. _arm()
  70. client = BambuFTPClient(IP, "12345678", printer_model="P2S", respect_handshake_cooloff=False)
  71. assert client.connect() is False # the printer is still broken...
  72. assert refusing_printer.connect.call_count == 1 # ...but we found that out ourselves
  73. def test_the_opt_out_does_not_leak_to_the_next_client(self, refusing_printer):
  74. """The flag is per client, not a global switch someone can leave on."""
  75. _arm()
  76. BambuFTPClient(IP, "12345678", respect_handshake_cooloff=False).connect()
  77. refusing_printer.connect.reset_mock()
  78. BambuFTPClient(IP, "12345678").connect()
  79. assert refusing_printer.connect.call_count == 0
  80. def test_the_skip_says_why_at_a_level_operators_see(self, caplog):
  81. """The reason-free WARNING is what made this a log dive.
  82. Every other ``connect`` failure path names its cause; this one logged
  83. at DEBUG, so at default level four identical "FTP connection failed"
  84. lines gave no hint that nothing had been sent.
  85. """
  86. _arm()
  87. with caplog.at_level(logging.WARNING, logger=LOGGER):
  88. assert BambuFTPClient(IP, "12345678").connect() is False
  89. messages = [r.getMessage() for r in caplog.records]
  90. assert any("cooling off" in m and IP in m for m in messages), messages
  91. # And it has to be legible as "we did nothing", not as a network error.
  92. assert any("Nothing was sent to the printer" in m for m in messages), messages
  93. def test_it_says_it_once_per_cooloff_and_not_once_per_attempt(self, caplog):
  94. """Raising this to WARNING must not re-create the flood #2780 stopped.
  95. Not every caller is gated: downloading a ZIP of files the user picked
  96. walks the whole selection, so 200 files would otherwise repeat the same
  97. sentence 200 times.
  98. """
  99. _arm()
  100. with caplog.at_level(logging.DEBUG, logger=LOGGER):
  101. for _ in range(200):
  102. BambuFTPClient(IP, "12345678").connect()
  103. warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
  104. assert len(warnings) == 1, [r.getMessage() for r in warnings]
  105. # Still recoverable at DEBUG for anyone reading a support bundle.
  106. assert sum("still cooling off" in r.getMessage() for r in caplog.records) == 199
  107. def test_a_fresh_handshake_failure_is_announced_again(self, caplog):
  108. """Once per cool-off, not once per process.
  109. A printer that recovers and fails again is a new event, and silence
  110. would be the DEBUG-level problem this fix set out to remove.
  111. """
  112. _arm()
  113. with caplog.at_level(logging.WARNING, logger=LOGGER):
  114. BambuFTPClient(IP, "12345678").connect()
  115. _arm() # a later handshake failure pushes the deadline out
  116. BambuFTPClient(IP, "12345678").connect()
  117. warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
  118. assert len(warnings) == 2, [r.getMessage() for r in warnings]
  119. # ---------------------------------------------------------------------------
  120. # The retry loop
  121. # ---------------------------------------------------------------------------
  122. class TestRetryLoopStopsOnAnArmedCooloff:
  123. async def _run(self, *, cooloff_ip, calls):
  124. async def op():
  125. calls.append(1)
  126. _arm() # the first attempt is what arms it, as in the report
  127. return False
  128. return await with_ftp_retry(
  129. op,
  130. max_retries=3,
  131. retry_delay=0.01,
  132. operation_name="Download 3MF",
  133. cooloff_ip=cooloff_ip,
  134. )
  135. async def test_a_respecting_caller_stops_after_the_attempt_that_armed_it(self, caplog):
  136. calls = []
  137. with caplog.at_level(logging.WARNING, logger=LOGGER):
  138. assert await self._run(cooloff_ip=IP, calls=calls) is None
  139. assert len(calls) == 1
  140. messages = [r.getMessage() for r in caplog.records]
  141. assert any("stopping after attempt 1/4" in m for m in messages), messages
  142. # The tally has to match what was really tried. "failed after 4
  143. # attempts" for one attempt is how this read as a network problem.
  144. assert any("failed after 1 attempts" in m for m in messages), messages
  145. async def test_a_caller_without_the_ip_keeps_its_full_budget(self):
  146. """Dispatch ignores the cool-off, so the loop must not stop on it.
  147. Stopping here would undo the exemption from the other end: the
  148. attempts would still be refused, just by the retry loop instead of by
  149. ``connect``.
  150. """
  151. calls = []
  152. assert await self._run(cooloff_ip=None, calls=calls) is None
  153. assert len(calls) == 4
  154. # ---------------------------------------------------------------------------
  155. # The reported failure, end to end
  156. # ---------------------------------------------------------------------------
  157. class TestDispatchKeepsItsAttempts:
  158. async def test_every_upload_attempt_reaches_the_printer(self, refusing_printer, tmp_path):
  159. """The trace from the report: cool-off armed, then four dead attempts.
  160. The reporter's evidence is that the handshake failure is transient --
  161. a manual connect a second later completes cleanly -- so the retry the
  162. gate suppressed is precisely the retry that would have worked.
  163. """
  164. _arm()
  165. local = tmp_path / "job.gcode.3mf"
  166. local.write_bytes(b"x" * 1024)
  167. result = await with_ftp_retry(
  168. upload_file_async,
  169. IP,
  170. "12345678",
  171. local,
  172. "/job.gcode.3mf",
  173. timeout=5.0,
  174. printer_model="P2S",
  175. respect_handshake_cooloff=False,
  176. max_retries=3,
  177. retry_delay=0.01,
  178. operation_name="Upload print to Bambulab P2S-4",
  179. )
  180. assert result is None
  181. assert refusing_printer.connect.call_count == 4
  182. async def test_without_the_exemption_the_same_upload_touches_nothing(self, refusing_printer, tmp_path):
  183. """Mutation guard: revert the exemption and the test above must fail.
  184. Without this, ``call_count == 4`` above would pass for the wrong reason
  185. if the cool-off were ever simply removed.
  186. """
  187. _arm()
  188. local = tmp_path / "job.gcode.3mf"
  189. local.write_bytes(b"x" * 1024)
  190. result = await with_ftp_retry(
  191. upload_file_async,
  192. IP,
  193. "12345678",
  194. local,
  195. "/job.gcode.3mf",
  196. timeout=5.0,
  197. printer_model="P2S",
  198. max_retries=3,
  199. retry_delay=0.01,
  200. operation_name="Upload print to Bambulab P2S-4",
  201. )
  202. assert result is None
  203. assert refusing_printer.connect.call_count == 0
  204. async def test_the_pre_upload_delete_is_exempt_too(self, refusing_printer):
  205. """In the report's trace the delete is what armed the cool-off.
  206. It runs 8ms before the upload's first attempt, so leaving it gated
  207. would keep one whole dispatch's worth of the problem in place.
  208. """
  209. _arm()
  210. result = await delete_file_async(
  211. IP, "12345678", "/job.gcode.3mf", printer_model="P2S", respect_handshake_cooloff=False
  212. )
  213. assert result is DeleteResult.FAILED
  214. assert refusing_printer.connect.call_count == 1
  215. async def test_a_background_download_is_still_gated(self, refusing_printer):
  216. """The sweeps keep #2780 exactly: nobody is waiting, so back off."""
  217. _arm()
  218. assert await bambu_ftp.download_file_bytes_async(IP, "12345678", "/timelapse/a.mp4") is None
  219. assert refusing_printer.connect.call_count == 0
  220. # ---------------------------------------------------------------------------
  221. # What the operator is told
  222. # ---------------------------------------------------------------------------
  223. @pytest.fixture
  224. async def dispatch_case(tmp_path):
  225. """Minimal one-printer, one-queued-job database for ``_start_print``."""
  226. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  227. import backend.app.models # noqa: F401 - populate Base.metadata
  228. from backend.app.core.database import Base
  229. from backend.app.models.archive import PrintArchive
  230. from backend.app.models.print_queue import PrintQueueItem
  231. from backend.app.models.printer import Printer
  232. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  233. async with engine.begin() as conn:
  234. await conn.run_sync(Base.metadata.create_all)
  235. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  236. base_dir = tmp_path / "case"
  237. archive_rel = Path("archives") / "job.3mf"
  238. archive_abs = base_dir / archive_rel
  239. archive_abs.parent.mkdir(parents=True, exist_ok=True)
  240. archive_abs.write_bytes(b"archive payload")
  241. async with session_maker() as db:
  242. printer = Printer(
  243. name="Bambulab P2S-4",
  244. serial_number="SERIAL",
  245. ip_address=IP,
  246. access_code="12345678",
  247. model="P2S",
  248. )
  249. db.add(printer)
  250. await db.flush()
  251. archive = PrintArchive(
  252. printer_id=printer.id,
  253. filename="job.3mf",
  254. file_path=str(archive_rel),
  255. file_size=archive_abs.stat().st_size,
  256. status="completed",
  257. )
  258. db.add(archive)
  259. await db.flush()
  260. item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="pending")
  261. db.add(item)
  262. await db.commit()
  263. item_id = item.id
  264. try:
  265. yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, item_id=item_id)
  266. finally:
  267. await engine.dispose()
  268. async def _failed_dispatch_message(dispatch_case, *, handshake_fails: bool) -> str:
  269. """Run one dispatch whose upload fails, and return what the user is told.
  270. ``handshake_fails`` makes the stand-in upload arm the cool-off the way the
  271. real one does when the printer answers port 990 with something other than
  272. TLS -- which is the only thing that separates the two messages.
  273. """
  274. import backend.app.services.print_scheduler as scheduler_module
  275. from backend.app.models.print_queue import PrintQueueItem
  276. from backend.app.services.print_scheduler import PrintScheduler
  277. from backend.tests._fixtures.background_tasks import discarding_spawn_patch
  278. async def _upload(*_args, **_kwargs):
  279. if handshake_fails:
  280. _arm()
  281. return False
  282. scheduler = PrintScheduler()
  283. async with dispatch_case.session_maker() as db:
  284. item = await db.get(PrintQueueItem, dispatch_case.item_id)
  285. patches = [
  286. patch.object(scheduler_module.settings, "base_dir", dispatch_case.base_dir),
  287. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  288. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
  289. patch(
  290. "backend.app.services.print_scheduler.get_ftp_retry_settings",
  291. AsyncMock(return_value=(False, 0, 0, 1.0)),
  292. ),
  293. patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
  294. patch("backend.app.services.print_scheduler.upload_file_async", _upload),
  295. patch("backend.app.services.print_scheduler.notification_service.on_queue_job_failed", AsyncMock()),
  296. discarding_spawn_patch(),
  297. patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
  298. patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
  299. patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
  300. ]
  301. with ExitStack() as stack:
  302. for p in patches:
  303. stack.enter_context(p)
  304. await scheduler._start_print(db, item)
  305. refreshed = await db.get(PrintQueueItem, dispatch_case.item_id)
  306. assert refreshed.status == "failed"
  307. return refreshed.error_message or ""
  308. class TestTheFailureNamesTheRightHardware:
  309. async def test_a_handshake_failure_does_not_send_anyone_to_the_sd_card(self, dispatch_case):
  310. """Three of the report's queue items were told to check an SD card.
  311. The printer had answered port 990 with something that was not TLS.
  312. Nothing had reached its filesystem, so its card could not have been
  313. the problem, and the operator was sent to look at the one part of the
  314. machine that was working.
  315. """
  316. message = await _failed_dispatch_message(dispatch_case, handshake_fails=True)
  317. assert "SD card is inserted" not in message, message
  318. assert "did not answer over TLS" in message, message
  319. # It goes further than dropping the advice: the card is the first thing
  320. # anyone would reach for next, so the message rules it out by name.
  321. assert "SD card is not involved" in message, message
  322. async def test_an_ordinary_upload_failure_keeps_the_storage_advice(self, dispatch_case):
  323. """No cool-off means no evidence about TLS, so the old wording stands.
  324. This is the half that stops the new message from swallowing every
  325. upload failure: the cool-off is read as evidence, not assumed.
  326. """
  327. assert BambuFTPClient.handshake_blocked(IP) is False
  328. message = await _failed_dispatch_message(dispatch_case, handshake_fails=False)
  329. assert "SD card is inserted" in message, message
  330. async def test_a_cooloff_left_by_something_else_is_not_taken_as_evidence(self, dispatch_case):
  331. """The printer can be cooling off from work this dispatch had no part in.
  332. A background timelapse or 3MF fetch for an earlier print arms the same
  333. gate, and it lasts five minutes. Since the dispatch ignores the gate, an
  334. upload running underneath it can still fail on a full disk -- and
  335. answering that with "the file service did not answer over TLS" would be
  336. the same wrong-hardware mistake pointing the other way.
  337. """
  338. _arm() # armed before the dispatch, and nothing re-arms it during
  339. message = await _failed_dispatch_message(dispatch_case, handshake_fails=False)
  340. assert "did not answer over TLS" not in message, message
  341. assert "SD card is inserted" in message, message