Files
bambuddy/backend/tests/unit/test_slicer_stall_timeout.py
T
maziggy ce3e59884a fix(slicer): bound slices by silence, not by total slicing time (#2730)
A heavy MakerWorld model — one Bambu Studio also takes a long time over —
failed after five minutes with "Slicer sidecar unreachable". The sidecar
was reachable the whole time and still slicing when we hung up on it.

SlicerApiService carried a hardcoded 300s timeout, passed to httpx as a
bare float so it covered connect, read, write and pool alike. On a single
long request that is not a health check, it is a cap on how long a model is
allowed to take. And because httpx.ReadTimeout subclasses RequestError,
expiry landed in the same handler as a refused connection and was reported
as an unreachable sidecar — so the reporter went and updated their sidecar
container, which was never the problem.

The information to do better was already being collected. _poll_progress
polls /slice/progress/{id} once a second alongside the blocking POST to
drive the live progress toast, so at minute five Bambuddy had fresh
evidence the slicer was working. It killed the request anyway.

So the read timeout comes off the HTTP call and the poller supervises
instead: the deadline moves forward on every progress update, and only
genuine silence ends the wait. A model that keeps reporting runs to
completion however long it takes. Connect and pool keep short timeouts —
a sidecar that will not accept a connection is unreachable and should
still say so quickly.

Only a *changed* progress payload counts as alive. The sidecar re-serves
its last snapshot on every poll, so counting repeats would leave the
watchdog unable to detect a stall at all.

The window is floored at three poll intervals: liveness can only be
observed as fast as the poller ticks, so anything shorter would expire in
the gap between two polls and fail every slice instantly.

New setting slicer_stall_timeout_minutes (Settings > Workflow > Slicer),
default 15, range 1-240, alongside the sidecar URL and gated on
use_slicer_api like its neighbours. Sidecars too old to report progress
have no liveness signal, so for those the same number bounds total elapsed
time — the old behaviour, configurable and no longer 300s flat. The
message says which case applies and where to change it.

SlicerTimeoutError is its own type and maps to 504, not 502: the sidecar
answered throughout, we stopped waiting. Connection failures keep
SlicerApiUnavailableError. The preview slice path gets the same treatment.
2026-07-31 15:14:01 +02:00

230 lines
8.9 KiB
Python

"""Tests for the progress-supervised slice timeout (#2730).
The old behaviour was a flat 300 s httpx timeout on the slice POST. A heavy
model that Bambu Studio also took a long time over blew through it while the
slicer was working perfectly happily, and — because ``httpx.ReadTimeout`` is a
subclass of ``RequestError`` — the failure was reported as "Slicer sidecar
unreachable", sending the reporter off to check a sidecar that was reachable
throughout.
The wait is now bounded by *silence* instead: Bambuddy already polls the
sidecar's progress endpoint once a second, so it can tell a slow slice from a
stalled one. The deadline moves forward on every progress update.
"""
import asyncio
import httpx
import pytest
from backend.app.services.slicer_api import (
DEFAULT_SLICE_STALL_TIMEOUT_SECONDS,
SlicerApiService,
SlicerApiUnavailableError,
SlicerTimeoutError,
_Liveness,
get_stall_timeout_seconds,
)
SLICE_ARGS = {
"model_bytes": b"solid\n",
"model_filename": "cube.3mf",
"printer_profile_json": "{}",
"process_profile_json": "{}",
"filament_profile_jsons": ["{}"],
}
def _service(handler, *, timeout_seconds: float, poll_interval: float = 0.02) -> SlicerApiService:
"""A service wired to a mock sidecar, with the timing compressed.
The stall window is floored at three poll intervals — liveness can only be
observed as fast as the poller ticks — so tests shrink both together rather
than waiting out production's 1 Hz.
"""
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
svc = SlicerApiService("http://sidecar:3003", client=client, timeout_seconds=timeout_seconds)
svc.progress_poll_interval = poll_interval
return svc
class TestLivenessWindow:
"""The unit that decides when to stop waiting."""
def test_a_fresh_slice_has_the_full_window(self):
live = _Liveness(60.0, 1.0)
assert live.deadline - live.started_at == pytest.approx(60.0)
def test_progress_pushes_the_deadline_out(self):
live = _Liveness(60.0, 1.0)
live.saw_progress_endpoint()
before = live.deadline
live._last_alive += 30.0 # simulate a progress update 30s later
assert live.deadline > before
def test_without_a_progress_channel_the_window_is_total_elapsed(self):
"""No liveness signal means no way to tell slow from stalled, so the
window degrades to the pre-#2730 wall clock — just configurable."""
live = _Liveness(60.0, 1.0)
live.mark_alive() # would move the deadline if progress were supported
assert live.deadline == pytest.approx(live.started_at + 60.0)
def test_message_distinguishes_the_two_cases(self):
supported = _Liveness(60.0, 1.0)
supported.saw_progress_endpoint()
assert "stopped reporting progress" in supported.timeout_message()
unsupported = _Liveness(60.0, 1.0)
assert "does not report progress" in unsupported.timeout_message()
def test_message_points_at_the_setting(self):
live = _Liveness(900.0, 1.0)
assert "Settings -> Workflow -> Slicer" in live.timeout_message()
class TestSliceIsNotCutOffWhileProgressing:
@pytest.mark.asyncio
async def test_a_slow_slice_that_reports_progress_completes(self):
"""The reporter's case: slower than the old ceiling, still working.
The slice takes ~5x the stall window; progress keeps arriving, so it
must run to completion rather than being abandoned.
"""
progress = {"n": 0}
async def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/slice"):
await asyncio.sleep(0.5)
return httpx.Response(
200,
content=b"G1 X0\n",
headers={
"x-print-time-seconds": "100",
"x-filament-used-g": "1.0",
"x-filament-used-mm": "100",
},
)
progress["n"] += 1
return httpx.Response(200, json={"percent": progress["n"]})
svc = _service(handler, timeout_seconds=0.1)
result = await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-1", on_progress=lambda _p: None)
assert result.print_time_seconds == 100
assert progress["n"] > 1, "the poller must have been running throughout"
@pytest.mark.asyncio
async def test_repeated_identical_progress_does_not_count_as_alive(self):
"""The sidecar re-serves its last snapshot on every poll. Treating that
as progress would make a stall undetectable."""
async def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/slice"):
await asyncio.sleep(10)
return httpx.Response(200, content=b"never gets here")
return httpx.Response(200, json={"percent": 42}) # frozen
svc = _service(handler, timeout_seconds=0.3)
with pytest.raises(SlicerTimeoutError):
await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-2", on_progress=lambda _p: None)
class TestStalledSliceFails:
@pytest.mark.asyncio
async def test_silence_ends_the_wait(self):
async def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/slice"):
await asyncio.sleep(10)
return httpx.Response(200, content=b"never gets here")
return httpx.Response(404) # no progress available
svc = _service(handler, timeout_seconds=0.2)
with pytest.raises(SlicerTimeoutError) as exc:
await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-3", on_progress=lambda _p: None)
assert "does not report progress" in str(exc.value)
@pytest.mark.asyncio
async def test_timeout_is_not_reported_as_unreachable(self):
"""The whole point: this used to surface as "Slicer sidecar unreachable"."""
async def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/slice"):
await asyncio.sleep(10)
return httpx.Response(404)
svc = _service(handler, timeout_seconds=0.2)
with pytest.raises(SlicerTimeoutError) as exc:
await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-4", on_progress=lambda _p: None)
assert not isinstance(exc.value, SlicerApiUnavailableError)
assert "unreachable" not in str(exc.value)
@pytest.mark.asyncio
async def test_a_genuinely_unreachable_sidecar_still_says_so(self):
"""Timeouts got their own type; connection failures keep the old one."""
async def handler(_request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("connection refused")
svc = _service(handler, timeout_seconds=5.0)
with pytest.raises(SlicerApiUnavailableError) as exc:
await svc.slice_with_profiles(**SLICE_ARGS)
assert "unreachable" in str(exc.value)
class TestStallTimeoutSetting:
@pytest.mark.asyncio
async def test_reads_the_configured_value(self):
class _DB:
pass
async def fake_get_setting(_db, key):
assert key == "slicer_stall_timeout_minutes"
return "45"
import backend.app.api.routes.settings as settings_module
original = settings_module.get_setting
settings_module.get_setting = fake_get_setting
try:
assert await get_stall_timeout_seconds(_DB()) == 45 * 60
finally:
settings_module.get_setting = original
@pytest.mark.asyncio
@pytest.mark.parametrize("stored", [None, "", "not-a-number", "0", "-5"])
async def test_falls_back_rather_than_failing_the_slice(self, stored):
"""A bad settings row must not be the reason a print doesn't happen."""
async def fake_get_setting(_db, _key):
return stored
import backend.app.api.routes.settings as settings_module
original = settings_module.get_setting
settings_module.get_setting = fake_get_setting
try:
assert await get_stall_timeout_seconds(object()) == DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
finally:
settings_module.get_setting = original
@pytest.mark.asyncio
async def test_a_failing_lookup_falls_back_too(self):
async def boom(_db, _key):
raise RuntimeError("db is down")
import backend.app.api.routes.settings as settings_module
original = settings_module.get_setting
settings_module.get_setting = boom
try:
assert await get_stall_timeout_seconds(object()) == DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
finally:
settings_module.get_setting = original
def test_default_is_longer_than_the_old_fixed_ceiling(self):
"""300s was the number that broke; the new default must beat it."""
assert DEFAULT_SLICE_STALL_TIMEOUT_SECONDS > 300