test_scheduler_watchdog.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909
  1. """Regression tests for ``_watchdog_print_start``.
  2. The watchdog reverts queue items to ``pending`` when a dispatched print never
  3. lands on the printer (half-broken MQTT session — #887/#936/#967). H2D firmware
  4. can sit at ``FINISH`` for 50+ seconds after accepting a ``project_file``
  5. command before flipping ``gcode_state`` to ``PREPARE``, which used to trip the
  6. state-only watchdog and cause the scheduler to revert the item; the subsequent
  7. successful dispatch then looked like a reprint of the just-finished job (#1078).
  8. The fix: treat ``subtask_id`` advancing past the pre-dispatch value as an
  9. equivalent "command landed" signal, and raise the timeout from 45 s to 90 s as
  10. belt-and-braces for slow transitions that also don't emit an early subtask_id
  11. tick.
  12. """
  13. import itertools
  14. from types import SimpleNamespace
  15. from unittest.mock import AsyncMock, MagicMock, patch
  16. import pytest
  17. from backend.app.models.print_queue import PrintQueueItem
  18. from backend.app.services.print_scheduler import DISPATCH_MAX_ATTEMPTS, PrintScheduler
  19. @pytest.fixture
  20. async def db_session():
  21. """In-memory SQLite with one ``printing`` queue item at id=1."""
  22. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  23. import backend.app.models # noqa: F401 — populate Base.metadata
  24. from backend.app.core.database import Base
  25. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  26. async with engine.begin() as conn:
  27. await conn.run_sync(Base.metadata.create_all)
  28. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  29. async with session_maker() as db:
  30. db.add(PrintQueueItem(id=1, printer_id=42, archive_id=99, status="printing"))
  31. await db.commit()
  32. try:
  33. yield session_maker
  34. finally:
  35. await engine.dispose()
  36. def _status(state: str, subtask_id: str | None = None, gcode_file: str | None = None):
  37. """Minimal stand-in for PrinterState — only the fields the watchdog reads."""
  38. return SimpleNamespace(state=state, subtask_id=subtask_id, gcode_file=gcode_file)
  39. class TestWatchdogExitsEarlyOnPickup:
  40. """The watchdog must NOT revert when the printer has clearly picked up the job."""
  41. @pytest.mark.asyncio
  42. async def test_exits_on_state_change(self, db_session):
  43. """State transitioning away from pre_state is the primary "accepted" signal."""
  44. get_status = MagicMock(return_value=_status("RUNNING", "OLD_SUBTASK"))
  45. with (
  46. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  47. patch("backend.app.services.print_scheduler.async_session", db_session),
  48. ):
  49. await PrintScheduler._watchdog_print_start(
  50. queue_item_id=1,
  51. printer_id=42,
  52. pre_state="FINISH",
  53. pre_subtask_id="OLD_SUBTASK",
  54. timeout=0.3,
  55. poll_interval=0.05,
  56. )
  57. # Item should remain "printing" — watchdog recognised the pickup.
  58. async with db_session() as db:
  59. item = await db.get(PrintQueueItem, 1)
  60. assert item.status == "printing"
  61. @pytest.mark.asyncio
  62. async def test_h2d_finish_to_running_via_subtask_id_then_active_state(self, db_session):
  63. """Regression for #1078 (preserved through the two-phase rewrite for #1678):
  64. H2D keeps state=FINISH for ~50 s after accepting project_file, but
  65. subtask_id flips to our new submission_id almost immediately. The
  66. watchdog must NOT revert on the basis of state staying at FINISH —
  67. Phase A exits on the subtask_id advance, Phase B then keeps watching
  68. and exits SUCCESS as soon as the printer transitions to PREPARE /
  69. RUNNING within the longer Phase B window.
  70. """
  71. # First poll: state still FINISH, subtask_id advanced (Phase A → B).
  72. # Second poll: state has flipped to RUNNING (Phase B success).
  73. get_status = MagicMock(
  74. side_effect=[
  75. _status("FINISH", "NEW_SUBTASK_12345"),
  76. _status("RUNNING", "NEW_SUBTASK_12345"),
  77. ]
  78. + [_status("RUNNING", "NEW_SUBTASK_12345")] * 10,
  79. )
  80. with (
  81. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  82. patch("backend.app.services.print_scheduler.async_session", db_session),
  83. ):
  84. await PrintScheduler._watchdog_print_start(
  85. queue_item_id=1,
  86. printer_id=42,
  87. pre_state="FINISH",
  88. pre_subtask_id="OLD_SUBTASK_99999",
  89. timeout=0.3,
  90. phase_b_timeout=0.3,
  91. poll_interval=0.05,
  92. )
  93. async with db_session() as db:
  94. item = await db.get(PrintQueueItem, 1)
  95. assert item.status == "printing", (
  96. "Phase A exit on subtask_id advance + Phase B observing the "
  97. "active-state transition is the H2D success path — watchdog "
  98. "must keep the item 'printing' (#1078)"
  99. )
  100. class TestWatchdogRevertsWhenStuck:
  101. """Genuine half-broken sessions still need the revert + reconnect recovery."""
  102. @pytest.mark.asyncio
  103. async def test_reverts_when_neither_state_nor_subtask_id_changes(self, db_session):
  104. """Both signals unchanged across the full timeout → revert to pending
  105. and force MQTT reconnect (the #967 recovery path)."""
  106. get_status = MagicMock(return_value=_status("FINISH", "OLD_SUBTASK"))
  107. client = MagicMock()
  108. get_client = MagicMock(return_value=client)
  109. with (
  110. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  111. patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
  112. patch("backend.app.services.print_scheduler.async_session", db_session),
  113. patch("backend.app.core.database.async_session", db_session),
  114. ):
  115. await PrintScheduler._watchdog_print_start(
  116. queue_item_id=1,
  117. printer_id=42,
  118. pre_state="FINISH",
  119. pre_subtask_id="OLD_SUBTASK",
  120. timeout=0.2,
  121. poll_interval=0.05,
  122. )
  123. async with db_session() as db:
  124. item = await db.get(PrintQueueItem, 1)
  125. assert item.status == "pending"
  126. assert item.started_at is None
  127. client.force_reconnect_stale_session.assert_called_once()
  128. @pytest.mark.asyncio
  129. async def test_reverts_on_finish_to_idle_user_dismissed_prompt(self, db_session):
  130. """Regression for #1370: when pre_state is FINISH and the printer
  131. transitions to IDLE during the watchdog window, that's the user
  132. dismissing a post-print prompt — NOT acceptance of our project_file.
  133. The bundle in #1370 showed exactly this: queue item dispatched while
  134. printer was in FINISH (residual from a previous print), command sent
  135. but silently rejected by firmware, then the user manually cleared
  136. the screen prompt so the printer moved to IDLE. The original
  137. ``state != pre_state`` check returned early on this transition and
  138. the queue row was left stuck in 'printing' indefinitely, blocking
  139. all future dispatches to that printer.
  140. The watchdog now only treats transitions into the active-print
  141. state set (PREPARE / SLICING / RUNNING / PAUSE) as a valid "command
  142. landed" signal.
  143. """
  144. get_status = MagicMock(return_value=_status("IDLE", "OLD_SUBTASK"))
  145. client = MagicMock()
  146. get_client = MagicMock(return_value=client)
  147. with (
  148. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  149. patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
  150. patch("backend.app.services.print_scheduler.async_session", db_session),
  151. patch("backend.app.core.database.async_session", db_session),
  152. ):
  153. await PrintScheduler._watchdog_print_start(
  154. queue_item_id=1,
  155. printer_id=42,
  156. pre_state="FINISH",
  157. pre_subtask_id="OLD_SUBTASK",
  158. timeout=0.2,
  159. poll_interval=0.05,
  160. )
  161. async with db_session() as db:
  162. item = await db.get(PrintQueueItem, 1)
  163. assert item.status == "pending", (
  164. "FINISH -> IDLE is the user dismissing a screen prompt, not "
  165. "the printer accepting project_file — item must be reverted "
  166. "to 'pending' so the scheduler can retry (#1370)"
  167. )
  168. assert item.started_at is None
  169. @pytest.mark.asyncio
  170. async def test_does_not_revert_on_pickup_via_active_state(self, db_session):
  171. """Counterpart to the #1370 fix: transitions into the active-print
  172. state set ARE a valid "command landed" signal. PREPARE / SLICING /
  173. RUNNING / PAUSE all keep the item in 'printing'.
  174. """
  175. for active_state in ("PREPARE", "SLICING", "RUNNING", "PAUSE"):
  176. async with db_session() as db:
  177. item = await db.get(PrintQueueItem, 1)
  178. item.status = "printing"
  179. item.started_at = None
  180. await db.commit()
  181. get_status = MagicMock(return_value=_status(active_state, "OLD_SUBTASK"))
  182. with (
  183. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  184. patch("backend.app.services.print_scheduler.async_session", db_session),
  185. patch("backend.app.core.database.async_session", db_session),
  186. ):
  187. await PrintScheduler._watchdog_print_start(
  188. queue_item_id=1,
  189. printer_id=42,
  190. pre_state="IDLE",
  191. pre_subtask_id="OLD_SUBTASK",
  192. timeout=0.2,
  193. poll_interval=0.05,
  194. )
  195. async with db_session() as db:
  196. item = await db.get(PrintQueueItem, 1)
  197. assert item.status == "printing", (
  198. f"transition IDLE -> {active_state} must be treated as a "
  199. f"valid 'command landed' signal — watchdog must not revert"
  200. )
  201. @pytest.mark.asyncio
  202. async def test_default_timeout_is_90_seconds(self):
  203. """The default timeout must cover slow H2D FINISH→PREPARE transitions
  204. (~50 s observed). A 45 s default would trip on the exact scenario the
  205. subtask_id check is guarding against, leaving no fallback for printers
  206. that don't echo subtask_id."""
  207. import inspect
  208. sig = inspect.signature(PrintScheduler._watchdog_print_start)
  209. assert sig.parameters["timeout"].default == 90.0
  210. @pytest.mark.asyncio
  211. async def test_default_phase_b_timeout_is_180_seconds(self):
  212. """Phase B (subtask_id advanced, waiting for active state) must
  213. comfortably exceed the H2D FINISH→PREPARE delay (~50 s observed)
  214. before declaring a printer-side wedge. 180 s gives ~3.5× headroom
  215. and reverts the queue item in well under the previous 2-hour
  216. expected_print TTL (#1678)."""
  217. import inspect
  218. sig = inspect.signature(PrintScheduler._watchdog_print_start)
  219. assert sig.parameters["phase_b_timeout"].default == 180.0
  220. @pytest.mark.asyncio
  221. async def test_reverts_when_subtask_advanced_but_state_never_active(self, db_session):
  222. """Regression for #1678: P1S on old firmware, power-cycled mid-print,
  223. cloud+LAN re-auth dance in flight. Printer accepts project_file
  224. (gcode_file updates, subtask_id advances to our submission id) but
  225. never transitions from IDLE/FINISH to PREPARE/RUNNING. The pre-fix
  226. watchdog returned SUCCESS as soon as subtask_id advanced and the
  227. queue item stayed in 'printing' until container restart. Phase B now
  228. keeps watching; if the active-state transition never arrives, the
  229. item reverts to 'pending' so the user can retry without restarting.
  230. """
  231. get_status = MagicMock(
  232. return_value=_status("IDLE", "NEW_SUBTASK_12345", gcode_file="/new.3mf"),
  233. )
  234. client = MagicMock() # NOT None — must verify reconnect isn't called
  235. get_client = MagicMock(return_value=client)
  236. with (
  237. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  238. patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
  239. patch("backend.app.services.print_scheduler.async_session", db_session),
  240. patch("backend.app.core.database.async_session", db_session),
  241. ):
  242. await PrintScheduler._watchdog_print_start(
  243. queue_item_id=1,
  244. printer_id=42,
  245. pre_state="IDLE",
  246. pre_subtask_id="OLD_SUBTASK_99999",
  247. pre_gcode_file="/old.3mf",
  248. timeout=0.2,
  249. phase_b_timeout=0.2,
  250. poll_interval=0.05,
  251. )
  252. async with db_session() as db:
  253. item = await db.get(PrintQueueItem, 1)
  254. assert item.status == "pending", (
  255. "subtask_id advanced (Phase A → B) but state never reached an "
  256. "active value — printer-side wedge; the queue item must be "
  257. "reverted to 'pending' (#1678)"
  258. )
  259. assert item.started_at is None
  260. # File landed (subtask_id advance proves this), so a forced reconnect
  261. # would trigger 0500_4003 mid-parse (#1150) — skip.
  262. client.force_reconnect_stale_session.assert_not_called()
  263. class TestWatchdogFallbackBehaviour:
  264. """Backwards-compat and defensive behaviour around missing data."""
  265. @pytest.mark.asyncio
  266. async def test_pre_subtask_id_none_falls_back_to_state_only(self, db_session):
  267. """When we never captured a pre-dispatch subtask_id (e.g. printer just
  268. connected), the watchdog must still work on the state signal alone —
  269. and still revert when state stays unchanged, so half-broken sessions
  270. are still recovered."""
  271. get_status = MagicMock(return_value=_status("FINISH", "SOMETHING"))
  272. get_client = MagicMock(return_value=None)
  273. with (
  274. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  275. patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
  276. patch("backend.app.services.print_scheduler.async_session", db_session),
  277. patch("backend.app.core.database.async_session", db_session),
  278. ):
  279. await PrintScheduler._watchdog_print_start(
  280. queue_item_id=1,
  281. printer_id=42,
  282. pre_state="FINISH",
  283. pre_subtask_id=None,
  284. timeout=0.2,
  285. poll_interval=0.05,
  286. )
  287. async with db_session() as db:
  288. item = await db.get(PrintQueueItem, 1)
  289. assert item.status == "pending"
  290. @pytest.mark.asyncio
  291. async def test_current_subtask_id_none_does_not_trigger_early_exit(self, db_session):
  292. """If the printer transiently reports subtask_id=None (e.g. during
  293. reconnect), that must not be treated as "changed" — otherwise the
  294. watchdog would exit early without a real pickup signal and leave the
  295. item stuck in "printing" after a genuinely broken session."""
  296. get_status = MagicMock(return_value=_status("FINISH", None))
  297. get_client = MagicMock(return_value=None)
  298. with (
  299. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  300. patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
  301. patch("backend.app.services.print_scheduler.async_session", db_session),
  302. patch("backend.app.core.database.async_session", db_session),
  303. ):
  304. await PrintScheduler._watchdog_print_start(
  305. queue_item_id=1,
  306. printer_id=42,
  307. pre_state="FINISH",
  308. pre_subtask_id="OLD_SUBTASK",
  309. timeout=0.2,
  310. poll_interval=0.05,
  311. )
  312. async with db_session() as db:
  313. item = await db.get(PrintQueueItem, 1)
  314. assert item.status == "pending"
  315. @pytest.mark.asyncio
  316. async def test_printer_disconnected_returns_without_reverting(self, db_session):
  317. """If the printer drops during the watchdog window, don't touch the DB —
  318. the reconnect path will sort the queue state out."""
  319. get_status = MagicMock(return_value=None)
  320. with (
  321. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  322. patch("backend.app.services.print_scheduler.async_session", db_session),
  323. ):
  324. await PrintScheduler._watchdog_print_start(
  325. queue_item_id=1,
  326. printer_id=42,
  327. pre_state="FINISH",
  328. pre_subtask_id="OLD_SUBTASK",
  329. timeout=0.2,
  330. poll_interval=0.05,
  331. )
  332. async with db_session() as db:
  333. item = await db.get(PrintQueueItem, 1)
  334. assert item.status == "printing"
  335. @pytest.mark.asyncio
  336. async def test_no_revert_if_item_already_completed(self, db_session):
  337. """If the print completed between watchdog arm-time and timeout (item is
  338. no longer "printing"), the watchdog must not clobber whatever status it
  339. ended up in — #967 race guard. Additionally it must NOT run the MQTT
  340. session-recovery path (forced reconnect): when on_print_complete has
  341. already moved the row, the print clearly landed on the printer and a
  342. forced reconnect on a healthy session would break ongoing prints on
  343. the same printer.
  344. """
  345. # Move item on to "completed" before the watchdog fires.
  346. async with db_session() as db:
  347. item = await db.get(PrintQueueItem, 1)
  348. item.status = "completed"
  349. await db.commit()
  350. get_status = MagicMock(return_value=_status("FINISH", "OLD_SUBTASK"))
  351. client = MagicMock() # NOT None — must verify reconnect isn't called
  352. get_client = MagicMock(return_value=client)
  353. with (
  354. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  355. patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
  356. patch("backend.app.services.print_scheduler.async_session", db_session),
  357. patch("backend.app.core.database.async_session", db_session),
  358. ):
  359. await PrintScheduler._watchdog_print_start(
  360. queue_item_id=1,
  361. printer_id=42,
  362. pre_state="FINISH",
  363. pre_subtask_id="OLD_SUBTASK",
  364. timeout=0.2,
  365. poll_interval=0.05,
  366. )
  367. async with db_session() as db:
  368. item = await db.get(PrintQueueItem, 1)
  369. assert item.status == "completed" # untouched
  370. client.force_reconnect_stale_session.assert_not_called()
  371. class TestGcodeFileDiscriminator:
  372. """#1150 vs #887/#936: skip the forced reconnect when gcode_file changed
  373. (project_file landed, slow parse — reconnecting causes 0500_4003).
  374. Reconnect when gcode_file is unchanged (publish dropped — half-broken
  375. session needs the original recovery)."""
  376. @pytest.mark.asyncio
  377. async def test_skips_reconnect_when_gcode_file_changed(self, db_session):
  378. get_status = MagicMock(
  379. return_value=_status("FINISH", "OLD_SUBTASK", gcode_file="/new.3mf"),
  380. )
  381. client = MagicMock()
  382. get_client = MagicMock(return_value=client)
  383. with (
  384. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  385. patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
  386. patch("backend.app.services.print_scheduler.async_session", db_session),
  387. patch("backend.app.core.database.async_session", db_session),
  388. ):
  389. await PrintScheduler._watchdog_print_start(
  390. queue_item_id=1,
  391. printer_id=42,
  392. pre_state="FINISH",
  393. pre_subtask_id="OLD_SUBTASK",
  394. pre_gcode_file="/old.3mf",
  395. timeout=0.2,
  396. poll_interval=0.05,
  397. )
  398. # Item still reverts (the user-facing failure stays correct), but the
  399. # MQTT session is left intact so the slow printer can finish parsing.
  400. async with db_session() as db:
  401. item = await db.get(PrintQueueItem, 1)
  402. assert item.status == "pending"
  403. client.force_reconnect_stale_session.assert_not_called()
  404. @pytest.mark.asyncio
  405. async def test_reconnects_when_gcode_file_unchanged(self, db_session):
  406. get_status = MagicMock(
  407. return_value=_status("FINISH", "OLD_SUBTASK", gcode_file="/old.3mf"),
  408. )
  409. client = MagicMock()
  410. get_client = MagicMock(return_value=client)
  411. with (
  412. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  413. patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
  414. patch("backend.app.services.print_scheduler.async_session", db_session),
  415. patch("backend.app.core.database.async_session", db_session),
  416. ):
  417. await PrintScheduler._watchdog_print_start(
  418. queue_item_id=1,
  419. printer_id=42,
  420. pre_state="FINISH",
  421. pre_subtask_id="OLD_SUBTASK",
  422. pre_gcode_file="/old.3mf",
  423. timeout=0.2,
  424. poll_interval=0.05,
  425. )
  426. client.force_reconnect_stale_session.assert_called_once()
  427. class TestWatchdogRetryBudget:
  428. """A revert hands the item straight back to the next queue pass, which
  429. re-uploads the whole 3MF and waits the watchdog out again. For a printer
  430. that is genuinely wedged that loop never terminates — the #2555 reporter had
  431. one printer "since this morning still not launch" — and every lap also burns
  432. an upload slot the rest of the farm is queueing for. Retrying is right;
  433. retrying forever is not.
  434. """
  435. @staticmethod
  436. async def _wedge(db_session, *, item_id: int = 1):
  437. """Run one watchdog cycle against a printer that accepts but never starts."""
  438. get_status = MagicMock(return_value=_status("IDLE", "NEW_SUBTASK", gcode_file="/new.3mf"))
  439. get_client = MagicMock(return_value=MagicMock())
  440. with (
  441. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  442. patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
  443. patch("backend.app.services.print_scheduler.async_session", db_session),
  444. patch("backend.app.core.database.async_session", db_session),
  445. patch(
  446. "backend.app.services.notification_service.notification_service.on_queue_job_failed",
  447. AsyncMock(),
  448. ) as notify,
  449. ):
  450. await PrintScheduler._watchdog_print_start(
  451. queue_item_id=item_id,
  452. printer_id=42,
  453. pre_state="IDLE",
  454. pre_subtask_id="OLD_SUBTASK",
  455. pre_gcode_file="/old.3mf",
  456. timeout=0.2,
  457. phase_b_timeout=0.2,
  458. poll_interval=0.05,
  459. )
  460. return notify
  461. @pytest.mark.asyncio
  462. async def test_early_wedges_still_revert_for_retry(self, db_session):
  463. """Attempts below the budget must keep the existing #1678 behaviour.
  464. The transient causes are real and the watchdog already recovers from
  465. them (a publish lost on a half-broken session is fixed by the forced
  466. reconnect on the very next attempt), so the first wedges must not fail
  467. the job.
  468. """
  469. await self._wedge(db_session)
  470. async with db_session() as db:
  471. item = await db.get(PrintQueueItem, 1)
  472. assert item.status == "pending", "first wedge must still be retried"
  473. assert item.dispatch_attempts == 1
  474. assert item.started_at is None
  475. @pytest.mark.asyncio
  476. async def test_attempts_accumulate_across_wedges(self, db_session):
  477. """The counter is what bounds the loop, so it must survive the revert."""
  478. for expected in (1, 2):
  479. # Each pass starts from a fresh dispatch, i.e. the row is 'printing' again.
  480. async with db_session() as db:
  481. item = await db.get(PrintQueueItem, 1)
  482. item.status = "printing"
  483. await db.commit()
  484. await self._wedge(db_session)
  485. async with db_session() as db:
  486. item = await db.get(PrintQueueItem, 1)
  487. assert item.dispatch_attempts == expected
  488. assert item.status == "pending"
  489. @pytest.mark.asyncio
  490. async def test_gives_up_and_fails_the_item_at_the_budget(self, db_session):
  491. """The third wedge fails the row instead of queueing a fourth re-upload."""
  492. notify = None
  493. for _ in range(DISPATCH_MAX_ATTEMPTS):
  494. async with db_session() as db:
  495. item = await db.get(PrintQueueItem, 1)
  496. item.status = "printing"
  497. await db.commit()
  498. notify = await self._wedge(db_session)
  499. async with db_session() as db:
  500. item = await db.get(PrintQueueItem, 1)
  501. assert item.status == "failed", f"after {DISPATCH_MAX_ATTEMPTS} wedges the item must stop going round again"
  502. assert item.dispatch_attempts == DISPATCH_MAX_ATTEMPTS
  503. assert item.completed_at is not None
  504. # The message has to tell the user where to look — the fault is on
  505. # the printer, and no amount of retrying from our side will fix it.
  506. assert "never started printing" in item.error_message
  507. notify.assert_awaited_once()
  508. @pytest.mark.asyncio
  509. async def test_a_successful_start_never_touches_the_counter(self, db_session):
  510. """Only the revert path increments. A printer that picks the job up
  511. must not accumulate attempts towards a future give-up."""
  512. get_status = MagicMock(return_value=_status("RUNNING", "NEW_SUBTASK"))
  513. with (
  514. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  515. patch("backend.app.services.print_scheduler.async_session", db_session),
  516. patch("backend.app.core.database.async_session", db_session),
  517. ):
  518. await PrintScheduler._watchdog_print_start(
  519. queue_item_id=1,
  520. printer_id=42,
  521. pre_state="IDLE",
  522. pre_subtask_id="OLD_SUBTASK",
  523. timeout=0.2,
  524. poll_interval=0.05,
  525. )
  526. async with db_session() as db:
  527. item = await db.get(PrintQueueItem, 1)
  528. assert item.status == "printing"
  529. assert item.dispatch_attempts == 0
  530. class TestWatchdogCommandRejected:
  531. """A printer reporting HMS 0500_0500_0001_0007 refused the command outright.
  532. It is not wedged and it is not slow: its authorization check rejected a
  533. command it could not verify, and it will reject the next two identically.
  534. Spending the full 270 s and two more full 3MF uploads on that is 15 minutes
  535. of a farm's upload capacity buying nothing, and it ends with a message about
  536. SD cards (#2732).
  537. """
  538. @staticmethod
  539. def _rejected_status(state: str = "IDLE", subtask_id: str | None = "NEW_SUBTASK"):
  540. from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
  541. return SimpleNamespace(
  542. state=state,
  543. subtask_id=subtask_id,
  544. gcode_file="/new.3mf",
  545. hms_errors=[SimpleNamespace(full_code=HMS_MQTT_VERIFY_FAILED)],
  546. )
  547. @staticmethod
  548. async def _run(db_session, status):
  549. get_status = MagicMock(return_value=status)
  550. get_client = MagicMock(return_value=MagicMock())
  551. with (
  552. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  553. patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
  554. patch("backend.app.services.print_scheduler.async_session", db_session),
  555. patch("backend.app.core.database.async_session", db_session),
  556. patch(
  557. "backend.app.services.notification_service.notification_service.on_queue_job_failed",
  558. AsyncMock(),
  559. ) as notify,
  560. ):
  561. await PrintScheduler._watchdog_print_start(
  562. queue_item_id=1,
  563. printer_id=42,
  564. pre_state="IDLE",
  565. pre_subtask_id="OLD_SUBTASK",
  566. pre_gcode_file="/old.3mf",
  567. timeout=0.2,
  568. phase_b_timeout=0.2,
  569. poll_interval=0.05,
  570. )
  571. return get_client, notify
  572. @pytest.mark.asyncio
  573. async def test_fails_on_the_first_attempt(self, db_session):
  574. await self._run(db_session, self._rejected_status())
  575. async with db_session() as db:
  576. item = await db.get(PrintQueueItem, 1)
  577. assert item.status == "failed", "a refused command must not be retried"
  578. assert item.dispatch_attempts == 1, "it must not burn the whole budget"
  579. assert item.completed_at is not None
  580. @pytest.mark.asyncio
  581. async def test_error_message_names_the_fix(self, db_session):
  582. """The old wording sent this user to check their SD card."""
  583. await self._run(db_session, self._rejected_status())
  584. async with db_session() as db:
  585. item = await db.get(PrintQueueItem, 1)
  586. assert "0500-0500-0001-0007" in item.error_message
  587. assert "Developer Mode" in item.error_message
  588. assert "SD card" not in item.error_message
  589. @pytest.mark.asyncio
  590. async def test_detected_in_phase_a_before_any_subtask_advance(self, db_session):
  591. """The printer can refuse without ever echoing a subtask_id."""
  592. await self._run(db_session, self._rejected_status(subtask_id="OLD_SUBTASK"))
  593. async with db_session() as db:
  594. item = await db.get(PrintQueueItem, 1)
  595. assert item.status == "failed"
  596. assert item.dispatch_attempts == 1
  597. @pytest.mark.asyncio
  598. async def test_skips_the_forced_reconnect(self, db_session):
  599. """The MQTT session is fine — reconnecting would only add 0500_4003 (#1150)."""
  600. get_client, _ = await self._run(db_session, self._rejected_status(subtask_id="OLD_SUBTASK"))
  601. get_client.assert_not_called()
  602. @pytest.mark.asyncio
  603. async def test_notifies_with_the_rejection_reason(self, db_session):
  604. _, notify = await self._run(db_session, self._rejected_status())
  605. notify.assert_awaited_once()
  606. assert "rejected" in notify.await_args.kwargs["reason"]
  607. @pytest.mark.asyncio
  608. async def test_unrelated_hms_still_takes_the_retry_path(self, db_session):
  609. """Only this code short-circuits; every other fault keeps its retries."""
  610. status = self._rejected_status()
  611. status.hms_errors = [SimpleNamespace(full_code="0300020000018012")]
  612. await self._run(db_session, status)
  613. async with db_session() as db:
  614. item = await db.get(PrintQueueItem, 1)
  615. assert item.status == "pending"
  616. assert item.dispatch_attempts == 1
  617. @pytest.mark.asyncio
  618. async def test_a_printer_that_actually_starts_is_unaffected(self, db_session):
  619. """A stale HMS from a previous job must not kill a print that is running."""
  620. await self._run(db_session, self._rejected_status(state="RUNNING"))
  621. async with db_session() as db:
  622. item = await db.get(PrintQueueItem, 1)
  623. assert item.status == "printing"
  624. assert item.dispatch_attempts == 0
  625. def _drying_status(state: str, subtask_id: str | None = None, *, drying: dict[int, int] | None = None, **kw):
  626. """``_status`` plus the ``raw_data['ams']`` shape the drying probe reads.
  627. ``drying`` maps AMS unit id -> dry_time in minutes (0 = idle unit).
  628. """
  629. st = _status(state, subtask_id, **kw)
  630. st.raw_data = {"ams": [{"id": i, "dry_time": t} for i, t in (drying or {}).items()]}
  631. return st
  632. class TestDryingAmsIds:
  633. """``_drying_ams_ids`` is a diagnostic read of firmware telemetry (#2758)."""
  634. def test_reports_units_with_time_remaining(self):
  635. from backend.app.services.print_scheduler import _drying_ams_ids
  636. assert _drying_ams_ids(_drying_status("IDLE", drying={0: 45, 1: 0, 128: 12})) == [0, 128]
  637. def test_no_raw_data_is_not_an_error(self):
  638. """Every watchdog poll calls this, including against the bare status
  639. objects other tests build, so a missing field must read as 'not drying'
  640. rather than raise inside the dispatch loop."""
  641. from backend.app.services.print_scheduler import _drying_ams_ids
  642. assert _drying_ams_ids(_status("IDLE")) == []
  643. assert _drying_ams_ids(SimpleNamespace(raw_data={})) == []
  644. def test_unparseable_entries_are_skipped_not_fatal(self):
  645. from backend.app.services.print_scheduler import _drying_ams_ids
  646. status = SimpleNamespace(raw_data={"ams": ["nonsense", {"id": 2, "dry_time": "20"}, {"dry_time": None}]})
  647. assert _drying_ams_ids(status) == [2]
  648. class TestWatchdogNamesDryingAsTheObstacle:
  649. """#2758: an X2D with two AMS units drying accepted the file and never
  650. started. The watchdog waited out both phases three times, re-uploading the
  651. whole 3MF each lap, and closed with a message about the SD card — while the
  652. actual obstacle was on the printer's own screen the whole time.
  653. Detection only. Bambuddy does not stop the cycle: this hardware supports
  654. drying concurrently with an active print, so drying is not incompatible with
  655. printing, and it is not yet established whether the blocker is the drying or
  656. the power budget of an AMS drying without its external PSU.
  657. """
  658. @staticmethod
  659. async def _wedge_while_drying(db_session, *, drying: dict[int, int], item_id: int = 1):
  660. get_status = MagicMock(return_value=_drying_status("IDLE", "NEW_SUBTASK", gcode_file="/new.3mf", drying=drying))
  661. with (
  662. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  663. patch("backend.app.services.print_scheduler.printer_manager.get_client", MagicMock()),
  664. patch("backend.app.services.print_scheduler.async_session", db_session),
  665. patch("backend.app.core.database.async_session", db_session),
  666. patch(
  667. "backend.app.services.notification_service.notification_service.on_queue_job_failed",
  668. AsyncMock(),
  669. ),
  670. ):
  671. await PrintScheduler._watchdog_print_start(
  672. queue_item_id=item_id,
  673. printer_id=42,
  674. pre_state="IDLE",
  675. pre_subtask_id="OLD_SUBTASK",
  676. pre_gcode_file="/old.3mf",
  677. timeout=0.2,
  678. phase_b_timeout=0.2,
  679. poll_interval=0.05,
  680. )
  681. @pytest.mark.asyncio
  682. async def test_give_up_message_names_the_drying_units(self, db_session):
  683. for _ in range(DISPATCH_MAX_ATTEMPTS):
  684. async with db_session() as db:
  685. item = await db.get(PrintQueueItem, 1)
  686. item.status = "printing"
  687. await db.commit()
  688. await self._wedge_while_drying(db_session, drying={0: 45, 128: 12})
  689. async with db_session() as db:
  690. item = await db.get(PrintQueueItem, 1)
  691. assert item.status == "failed"
  692. assert "AMS 0, AMS 128" in item.error_message
  693. assert "were drying" in item.error_message
  694. # The old text sent the reporter to check the SD card. It must not be
  695. # what a drying-blocked dispatch says.
  696. assert "SD card" not in item.error_message
  697. @pytest.mark.asyncio
  698. async def test_single_unit_reads_naturally(self, db_session):
  699. for _ in range(DISPATCH_MAX_ATTEMPTS):
  700. async with db_session() as db:
  701. item = await db.get(PrintQueueItem, 1)
  702. item.status = "printing"
  703. await db.commit()
  704. await self._wedge_while_drying(db_session, drying={128: 30})
  705. async with db_session() as db:
  706. item = await db.get(PrintQueueItem, 1)
  707. assert "AMS 128 was drying" in item.error_message
  708. @pytest.mark.asyncio
  709. async def test_no_drying_keeps_the_original_message(self, db_session):
  710. """The generic advice is still right when drying had nothing to do with
  711. it — this must not become the answer to every stalled dispatch."""
  712. for _ in range(DISPATCH_MAX_ATTEMPTS):
  713. async with db_session() as db:
  714. item = await db.get(PrintQueueItem, 1)
  715. item.status = "printing"
  716. await db.commit()
  717. await self._wedge_while_drying(db_session, drying={0: 0})
  718. async with db_session() as db:
  719. item = await db.get(PrintQueueItem, 1)
  720. assert item.status == "failed"
  721. assert "SD card" in item.error_message
  722. assert "drying" not in item.error_message
  723. @pytest.mark.asyncio
  724. async def test_a_cycle_that_ends_mid_window_is_still_reported(self, db_session):
  725. """Latched, not level-tested. Drying finishing (or the user stopping it)
  726. part-way through the dispatch window must not erase the fact that it was
  727. what the printer was doing when it declined to start."""
  728. drying = _drying_status("IDLE", "NEW_SUBTASK", gcode_file="/new.3mf", drying={1: 5})
  729. finished = _drying_status("IDLE", "NEW_SUBTASK", gcode_file="/new.3mf", drying={1: 0})
  730. for _ in range(DISPATCH_MAX_ATTEMPTS):
  731. async with db_session() as db:
  732. item = await db.get(PrintQueueItem, 1)
  733. item.status = "printing"
  734. await db.commit()
  735. # Fresh per run: the first poll of each dispatch window sees the
  736. # cycle, every later poll sees it finished.
  737. get_status = MagicMock(side_effect=itertools.chain([drying], itertools.repeat(finished)))
  738. with (
  739. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  740. patch("backend.app.services.print_scheduler.printer_manager.get_client", MagicMock()),
  741. patch("backend.app.services.print_scheduler.async_session", db_session),
  742. patch("backend.app.core.database.async_session", db_session),
  743. patch(
  744. "backend.app.services.notification_service.notification_service.on_queue_job_failed",
  745. AsyncMock(),
  746. ),
  747. ):
  748. await PrintScheduler._watchdog_print_start(
  749. queue_item_id=1,
  750. printer_id=42,
  751. pre_state="IDLE",
  752. pre_subtask_id="OLD_SUBTASK",
  753. pre_gcode_file="/old.3mf",
  754. timeout=0.2,
  755. phase_b_timeout=0.2,
  756. poll_interval=0.05,
  757. )
  758. async with db_session() as db:
  759. item = await db.get(PrintQueueItem, 1)
  760. assert "AMS 1 was drying" in item.error_message
  761. @pytest.mark.asyncio
  762. async def test_drying_does_not_make_a_successful_start_fail(self, db_session):
  763. """Drying is not an error condition. A printer that starts the job while
  764. an AMS dries — which this hardware supports — must be left alone."""
  765. get_status = MagicMock(return_value=_drying_status("RUNNING", "NEW_SUBTASK", drying={0: 45}))
  766. with (
  767. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  768. patch("backend.app.services.print_scheduler.async_session", db_session),
  769. patch("backend.app.core.database.async_session", db_session),
  770. ):
  771. await PrintScheduler._watchdog_print_start(
  772. queue_item_id=1,
  773. printer_id=42,
  774. pre_state="IDLE",
  775. pre_subtask_id="OLD_SUBTASK",
  776. timeout=0.3,
  777. poll_interval=0.05,
  778. )
  779. async with db_session() as db:
  780. item = await db.get(PrintQueueItem, 1)
  781. assert item.status == "printing"
  782. assert (item.dispatch_attempts or 0) == 0