test_slicer_stall_timeout.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. """Tests for the progress-supervised slice timeout (#2730).
  2. The old behaviour was a flat 300 s httpx timeout on the slice POST. A heavy
  3. model that Bambu Studio also took a long time over blew through it while the
  4. slicer was working perfectly happily, and — because ``httpx.ReadTimeout`` is a
  5. subclass of ``RequestError`` — the failure was reported as "Slicer sidecar
  6. unreachable", sending the reporter off to check a sidecar that was reachable
  7. throughout.
  8. The wait is now bounded by *silence* instead: Bambuddy already polls the
  9. sidecar's progress endpoint once a second, so it can tell a slow slice from a
  10. stalled one. The deadline moves forward on every progress update.
  11. """
  12. import asyncio
  13. import httpx
  14. import pytest
  15. from backend.app.services.slicer_api import (
  16. DEFAULT_SLICE_STALL_TIMEOUT_SECONDS,
  17. SlicerApiService,
  18. SlicerApiUnavailableError,
  19. SlicerTimeoutError,
  20. _Liveness,
  21. get_stall_timeout_seconds,
  22. )
  23. SLICE_ARGS = {
  24. "model_bytes": b"solid\n",
  25. "model_filename": "cube.3mf",
  26. "printer_profile_json": "{}",
  27. "process_profile_json": "{}",
  28. "filament_profile_jsons": ["{}"],
  29. }
  30. def _service(handler, *, timeout_seconds: float, poll_interval: float = 0.02) -> SlicerApiService:
  31. """A service wired to a mock sidecar, with the timing compressed.
  32. The stall window is floored at three poll intervals — liveness can only be
  33. observed as fast as the poller ticks — so tests shrink both together rather
  34. than waiting out production's 1 Hz.
  35. """
  36. client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
  37. svc = SlicerApiService("http://sidecar:3003", client=client, timeout_seconds=timeout_seconds)
  38. svc.progress_poll_interval = poll_interval
  39. return svc
  40. class TestLivenessWindow:
  41. """The unit that decides when to stop waiting."""
  42. def test_a_fresh_slice_has_the_full_window(self):
  43. live = _Liveness(60.0, 1.0)
  44. assert live.deadline - live.started_at == pytest.approx(60.0)
  45. def test_progress_pushes_the_deadline_out(self):
  46. live = _Liveness(60.0, 1.0)
  47. live.saw_progress_endpoint()
  48. before = live.deadline
  49. live._last_alive += 30.0 # simulate a progress update 30s later
  50. assert live.deadline > before
  51. def test_without_a_progress_channel_the_window_is_total_elapsed(self):
  52. """No liveness signal means no way to tell slow from stalled, so the
  53. window degrades to the pre-#2730 wall clock — just configurable."""
  54. live = _Liveness(60.0, 1.0)
  55. live.mark_alive() # would move the deadline if progress were supported
  56. assert live.deadline == pytest.approx(live.started_at + 60.0)
  57. def test_message_distinguishes_the_two_cases(self):
  58. supported = _Liveness(60.0, 1.0)
  59. supported.saw_progress_endpoint()
  60. assert "stopped reporting progress" in supported.timeout_message()
  61. unsupported = _Liveness(60.0, 1.0)
  62. assert "does not report progress" in unsupported.timeout_message()
  63. def test_message_points_at_the_setting(self):
  64. live = _Liveness(900.0, 1.0)
  65. assert "Settings -> Workflow -> Slicer" in live.timeout_message()
  66. class TestSliceIsNotCutOffWhileProgressing:
  67. @pytest.mark.asyncio
  68. async def test_a_slow_slice_that_reports_progress_completes(self):
  69. """The reporter's case: slower than the old ceiling, still working.
  70. The slice takes ~5x the stall window; progress keeps arriving, so it
  71. must run to completion rather than being abandoned.
  72. """
  73. progress = {"n": 0}
  74. async def handler(request: httpx.Request) -> httpx.Response:
  75. if request.url.path.endswith("/slice"):
  76. await asyncio.sleep(0.5)
  77. return httpx.Response(
  78. 200,
  79. content=b"G1 X0\n",
  80. headers={
  81. "x-print-time-seconds": "100",
  82. "x-filament-used-g": "1.0",
  83. "x-filament-used-mm": "100",
  84. },
  85. )
  86. progress["n"] += 1
  87. return httpx.Response(200, json={"percent": progress["n"]})
  88. svc = _service(handler, timeout_seconds=0.1)
  89. result = await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-1", on_progress=lambda _p: None)
  90. assert result.print_time_seconds == 100
  91. assert progress["n"] > 1, "the poller must have been running throughout"
  92. @pytest.mark.asyncio
  93. async def test_repeated_identical_progress_does_not_count_as_alive(self):
  94. """The sidecar re-serves its last snapshot on every poll. Treating that
  95. as progress would make a stall undetectable."""
  96. async def handler(request: httpx.Request) -> httpx.Response:
  97. if request.url.path.endswith("/slice"):
  98. await asyncio.sleep(10)
  99. return httpx.Response(200, content=b"never gets here")
  100. return httpx.Response(200, json={"percent": 42}) # frozen
  101. svc = _service(handler, timeout_seconds=0.3)
  102. with pytest.raises(SlicerTimeoutError):
  103. await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-2", on_progress=lambda _p: None)
  104. class TestStalledSliceFails:
  105. @pytest.mark.asyncio
  106. async def test_silence_ends_the_wait(self):
  107. async def handler(request: httpx.Request) -> httpx.Response:
  108. if request.url.path.endswith("/slice"):
  109. await asyncio.sleep(10)
  110. return httpx.Response(200, content=b"never gets here")
  111. return httpx.Response(404) # no progress available
  112. svc = _service(handler, timeout_seconds=0.2)
  113. with pytest.raises(SlicerTimeoutError) as exc:
  114. await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-3", on_progress=lambda _p: None)
  115. assert "does not report progress" in str(exc.value)
  116. @pytest.mark.asyncio
  117. async def test_timeout_is_not_reported_as_unreachable(self):
  118. """The whole point: this used to surface as "Slicer sidecar unreachable"."""
  119. async def handler(request: httpx.Request) -> httpx.Response:
  120. if request.url.path.endswith("/slice"):
  121. await asyncio.sleep(10)
  122. return httpx.Response(404)
  123. svc = _service(handler, timeout_seconds=0.2)
  124. with pytest.raises(SlicerTimeoutError) as exc:
  125. await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-4", on_progress=lambda _p: None)
  126. assert not isinstance(exc.value, SlicerApiUnavailableError)
  127. assert "unreachable" not in str(exc.value)
  128. @pytest.mark.asyncio
  129. async def test_a_genuinely_unreachable_sidecar_still_says_so(self):
  130. """Timeouts got their own type; connection failures keep the old one."""
  131. async def handler(_request: httpx.Request) -> httpx.Response:
  132. raise httpx.ConnectError("connection refused")
  133. svc = _service(handler, timeout_seconds=5.0)
  134. with pytest.raises(SlicerApiUnavailableError) as exc:
  135. await svc.slice_with_profiles(**SLICE_ARGS)
  136. assert "unreachable" in str(exc.value)
  137. class TestStallTimeoutSetting:
  138. @pytest.mark.asyncio
  139. async def test_reads_the_configured_value(self):
  140. class _DB:
  141. pass
  142. async def fake_get_setting(_db, key):
  143. assert key == "slicer_stall_timeout_minutes"
  144. return "45"
  145. import backend.app.api.routes.settings as settings_module
  146. original = settings_module.get_setting
  147. settings_module.get_setting = fake_get_setting
  148. try:
  149. assert await get_stall_timeout_seconds(_DB()) == 45 * 60
  150. finally:
  151. settings_module.get_setting = original
  152. @pytest.mark.asyncio
  153. @pytest.mark.parametrize("stored", [None, "", "not-a-number", "0", "-5"])
  154. async def test_falls_back_rather_than_failing_the_slice(self, stored):
  155. """A bad settings row must not be the reason a print doesn't happen."""
  156. async def fake_get_setting(_db, _key):
  157. return stored
  158. import backend.app.api.routes.settings as settings_module
  159. original = settings_module.get_setting
  160. settings_module.get_setting = fake_get_setting
  161. try:
  162. assert await get_stall_timeout_seconds(object()) == DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
  163. finally:
  164. settings_module.get_setting = original
  165. @pytest.mark.asyncio
  166. async def test_a_failing_lookup_falls_back_too(self):
  167. async def boom(_db, _key):
  168. raise RuntimeError("db is down")
  169. import backend.app.api.routes.settings as settings_module
  170. original = settings_module.get_setting
  171. settings_module.get_setting = boom
  172. try:
  173. assert await get_stall_timeout_seconds(object()) == DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
  174. finally:
  175. settings_module.get_setting = original
  176. def test_default_is_longer_than_the_old_fixed_ceiling(self):
  177. """300s was the number that broke; the new default must beat it."""
  178. assert DEFAULT_SLICE_STALL_TIMEOUT_SECONDS > 300