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.
This commit is contained in:
maziggy 2026-08-02 09:50:38 +02:00
commit bade12ff49
25 changed files with 571 additions and 68 deletions

View file

@ -34,6 +34,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Failure detection can now authenticate to a token-protected Obico ML API (#2733)** — Obico's `ml_api` container takes an optional `ML_API_TOKEN` environment variable; with it set, the container answers 401 to any detection request that doesn't carry that token, which is how you stop everything else on your network from using your inference server. Bambuddy never sent one, so the only way to use it was to remove the token from the server — a step the reporter had already taken for their Home Assistant setup and did not want to undo. **Settings → Failure Detection** now has an **ML API Token** field; leave it empty and requests go out exactly as before. Also worth knowing: this failed in the most confusing way possible, because Obico protects its detection endpoint but leaves its health endpoint open. Bambuddy's **Test** button pinged the open one, so it reported success against a server that was rejecting every real call, and detection just silently never fired. Test now checks both and says outright when a token is rejected, and the status card reports a rejected token as a rejected token rather than a bare HTTP error. Translated in all locales; wiki documents the token, the health-endpoint trap and how to recover from it.
### Fixed
- **A heavy model failed to slice after five minutes with "Slicer sidecar unreachable" (#2730, reporter @kpp39)** — A MakerWorld model that Bambu Studio also takes a long time over never finished slicing in Bambuddy: five minutes in, it failed claiming the slicer sidecar could not be reached. **Root cause.** The slice request carried a fixed five-minute limit covering the whole operation, and it was applied to the wrong thing. Slicing is a single long request, so the limit was a ceiling on how long a model was allowed to take — not a check on whether anything had gone wrong. When it expired, the resulting error was indistinguishable from a genuine connection failure, so a slice that was progressing normally was reported as an unreachable sidecar. The reporter went and updated their sidecar container, which was never the problem: it was reachable throughout and still slicing when Bambuddy hung up on it. **Fix.** Bambuddy already polls the sidecar once a second for progress — that is what drives the live progress toast — so it can tell a slow slice from a stuck one, and now does. The limit applies to *silence*: a model that keeps reporting progress runs to completion however long it takes, and a slice is only abandoned when the slicer has said nothing for the configured period. The new **Slicer stall timeout** under Settings → Workflow → Slicer sets that period, defaulting to fifteen minutes, and the failure message now says the slice ran out of time and where to change it rather than blaming the connection. Sidecars too old to report progress have no liveness signal to offer, so for those the setting still bounds total slicing time — the previous behaviour, but configurable and no longer five minutes flat. A sidecar that genuinely cannot be reached still fails immediately and still says so. Translated in all locales; wiki documents the setting. Covered by tests for a slow-but-progressing slice completing, a stalled one failing, a frozen progress report not counting as progress, the two failure messages, and the setting falling back safely when unset or unparseable.
- **Deleted prints stayed in their project as cards with broken previews, and could not be removed (#2731, reporter @sroesner)** — Deleting a print that belonged to a project left it on the project page with a missing thumbnail, and there was no way to unassign it. **Root cause.** Deleting a print is a soft delete by default: the files go from disk, the row stays so Quick Stats keeps counting its filament, time and cost. Every other part of Bambuddy skips those rows — the projects module skipped none of them, so a deleted print kept its project link and kept being listed, pointing at a thumbnail that no longer existed. The same broken previews appeared on the project cards in the overview, not just the detail page, and in the project timeline, where clicking the entry led to an archive that no longer opens. Unassigning was impossible because the only way to change a print's project is from the Archives page, which correctly hides deleted prints — so the entry could be seen but never reached. **Fix.** A deleted print now leaves its project everywhere: the archive list, the card previews, the timeline, and the counts. Excluding it from the *counts* is a deliberate difference from how Quick Stats treats the same print — a project is a piece of work with a definite membership rather than a lifetime total, so a project that lists eleven prints should not claim twelve. Existing broken entries disappear on upgrade with nothing to clean up; the API can still clear a stale link if anything needs repairing. Two more places were counting deleted prints for the same reason and are fixed with it: the archive CSV/Excel export handed back rows the interface says are gone, and per-project failure analysis measured a failure rate against prints that had been deleted from the project. The project page also no longer needs a manual reload to catch up: deleting a print refreshed the archive list but nothing project-related, and assigning a print to a project refreshed the project cards but not the project page itself, so for the following minute either view could still be showing what was there before. Covered by tests for the listing, the card previews, the timeline, the counts, both services, the cache refresh, and a guard that a project's live prints are untouched by any of it.
- **A printer refusing Bambuddy's commands looked healthy, and its queue failed with the wrong advice (#2732, reporter @hennischd)** — Uploads succeeded, the printer echoed the job back, then sat idle for 270 seconds and the job was re-uploaded twice more before failing with a message about SD cards. Temperature changes returned success and did nothing. The connection diagnostic passed every check, and the support bundle said Developer Mode was on. **Root cause.** The printer was rejecting every control command and saying so: HMS `0500-0500-0001-0007`, "MQTT command verification failed" — the firmware's authorization check, which Bambu Lab documents Developer Mode as the way to disable. Nothing in Bambuddy connected that to anything. The error itself was received and then discarded by the frontend, because this code's meaning lives in bits that Bambuddy's short-code form throws away: it collapses to `0500_0007`, matches no catalog entry, and uncatalogued errors without firmware actions are filtered out of the badge count and the error list. Meanwhile the Developer Mode probe reads any response that isn't an explicit refusal as confirmation, and this firmware answers the probe with an empty result while refusing everything else — so Bambuddy inferred a healthy printer from a non-answer, and reported that inference as a passing diagnostic. **Fix.** HMS codes are now looked up by their full identifier before the short form, so this error survives to the screen, shows the four-group code the printer's own display shows, and carries the fix rather than Bambu's "update Studio or Handy" (which does not apply to a print sent from Bambuddy). The error is treated as authoritative about the printer's state: it sets Developer Mode to off regardless of what the probe concluded, which makes the diagnostic and the support bundle report the real situation, and it clears itself when the printer stops reporting it, so enabling Developer Mode and restarting is picked up without restarting Bambuddy. The probe no longer reads an inconclusive answer as confirmation — it reports what it knows, which for this firmware is nothing. A queue item whose command is rejected now fails on the first attempt naming the code and the fix, instead of spending three uploads and fifteen minutes of a farm's upload capacity to arrive at the wrong conclusion; a print that is visibly running is never touched, whatever HMS is lingering. Separately, the log hint suggesting a wrong or mis-cased serial number no longer fires in the moment after a reconnect, when the report counter it reads has just been reset and proves nothing — it cost this reporter a detour through their serial number on a printer whose serial was correct. Translated in all locales. Covered by tests for the code surviving the filter, the display form, the developer-mode override and its self-clearing, the inconclusive probe, first-attempt failure, the running-print guard and the suppressed hint.
- **A printer that dropped off MQTT could stay offline indefinitely (#2732, reporter @hennischd)** — In the same bundle, the printer lost its MQTT session to a keep-alive timeout at 02:19 and did not come back until 11:24 — nine hours offline, with the web UI open the whole time. Bambuddy's stale-session detector only covers the other failure: a session that is still connected but has gone quiet. Once the connection is *down*, it returns immediately and the MQTT library's own retry is the only thing still watching; when that stops making progress, nothing notices. Bambuddy now runs a backstop sweep every minute. A printer that had a working session, has been silent for five minutes, and still answers on its MQTT port gets its client rebuilt from scratch with a fresh session — which also drops any command left unacknowledged on the dead one, so it cannot replay into the new session. Printers that are simply switched off are left alone: the port check tells the two apart, so there is no client churn and no nightly log spam for a farm that powers down. The rebuild is rate-limited per printer and never touches a connected one, and the log line names how long the printer was gone and what the last connection error was, so a session that dies repeatedly leaves a trail. Covered by tests for the recovery itself, the grace period, the switched-off case, the retry interval, and a farm sweep continuing past a printer that throws.

View file

@ -3905,6 +3905,7 @@ async def _try_preview_slice_filaments(
"""
from backend.app.api.routes.settings import get_setting
from backend.app.services.slice_preview import get_preview_filaments
from backend.app.services.slicer_api import get_stall_timeout_seconds
preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
if preferred == "orcaslicer":
@ -3930,6 +3931,7 @@ async def _try_preview_slice_filaments(
file_name=file_path.name,
api_url=api_url,
request_id=request_id,
timeout_seconds=await get_stall_timeout_seconds(db),
)

View file

@ -3082,6 +3082,7 @@ async def _try_preview_slice_filaments(
"""
from backend.app.api.routes.settings import get_setting
from backend.app.services.slice_preview import get_preview_filaments
from backend.app.services.slicer_api import get_stall_timeout_seconds
preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
if preferred == "orcaslicer":
@ -3107,6 +3108,7 @@ async def _try_preview_slice_filaments(
file_name=file_path.name,
api_url=api_url,
request_id=request_id,
timeout_seconds=await get_stall_timeout_seconds(db),
)
@ -3607,6 +3609,8 @@ async def _run_slicer_with_fallback(
SlicerApiService,
SlicerApiUnavailableError,
SlicerInputError,
SlicerTimeoutError,
get_stall_timeout_seconds,
)
user: User | None = None
@ -3717,7 +3721,9 @@ async def _run_slicer_with_fallback(
# gates the toggle on the picked printer matching the design's target,
# so this path never re-targets across printer models.
embedded_mode = bool(request.use_embedded_settings and is_3mf)
service = SlicerApiService(api_url)
# Bounds silence rather than total slicing time (#2730), so a heavy model
# that keeps reporting progress runs to completion however long it takes.
service = SlicerApiService(api_url, timeout_seconds=await get_stall_timeout_seconds(db))
# #1493: cross-nozzle-class re-slice (single <-> dual). Without
# intervention the slicer rejects with either "G-code in unprintable
@ -3960,6 +3966,12 @@ async def _run_slicer_with_fallback(
used_embedded_settings = True
except SlicerInputError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SlicerTimeoutError as exc:
# 504, not 502: the sidecar answered for the whole run, we stopped
# waiting. Reported separately so the user is told the slice ran out of
# time and where to change that, rather than that the sidecar is
# unreachable — which is what a read timeout used to look like (#2730).
raise HTTPException(status_code=504, detail=str(exc)) from exc
except SlicerApiServerError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except SlicerApiUnavailableError as exc:

View file

@ -285,6 +285,21 @@ class AppSettings(BaseModel):
default="",
description="BambuStudio sidecar URL (e.g. http://localhost:3001). Empty falls back to the BAMBU_STUDIO_API_URL env var.",
)
# How long to keep waiting on a slice that isn't finishing. Measured against
# the sidecar's progress channel, not total elapsed time — a heavy model can
# legitimately slice for half an hour, and a wall-clock ceiling cannot tell
# that apart from a stalled one (#2730). Sidecars too old to report progress
# fall back to using this as a total-elapsed ceiling, which is the pre-#2730
# behaviour with a configurable number.
slicer_stall_timeout_minutes: int = Field(
default=15,
ge=1,
le=240,
description=(
"Give up on a slice after this many minutes with no progress from the sidecar. "
"On sidecars that do not report progress, applies to total slicing time instead."
),
)
# Prometheus metrics endpoint
prometheus_enabled: bool = Field(default=False, description="Enable Prometheus metrics endpoint at /metrics")
@ -583,6 +598,7 @@ class AppSettingsUpdate(BaseModel):
use_slicer_api: bool | None = None
orcaslicer_api_url: str | None = None
bambu_studio_api_url: str | None = None
slicer_stall_timeout_minutes: int | None = Field(default=None, ge=1, le=240)
prometheus_enabled: bool | None = None
prometheus_token: str | None = None
low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)

View file

@ -63,6 +63,7 @@ async def get_preview_filaments(
file_name: str,
api_url: str,
request_id: str | None = None,
timeout_seconds: float | None = None,
) -> list[dict] | None:
"""Run a preview slice for ``plate_id``, parse the resulting slice_info,
and return the per-plate filament list.
@ -92,7 +93,11 @@ async def get_preview_filaments(
return cached
try:
async with SlicerApiService(base_url=api_url) as svc:
# Preview slices are bounded the same way as real ones (#2730):
# a heavy plate can take a long time and must not be cut off
# while the slicer is visibly working.
svc_kwargs = {} if timeout_seconds is None else {"timeout_seconds": timeout_seconds}
async with SlicerApiService(base_url=api_url, **svc_kwargs) as svc:
result = await svc.slice_without_profiles(
model_bytes=file_bytes,
model_filename=file_name,

View file

@ -11,6 +11,7 @@ under the hood, response body is raw G-code or 3MF with metadata in the
import asyncio
import io
import logging
import time
import zipfile
from collections.abc import Callable
from typing import NamedTuple
@ -40,6 +41,18 @@ class SlicerInputError(SlicerApiError):
"""Sidecar rejected the input as invalid (4xx)."""
class SlicerTimeoutError(SlicerApiError):
"""We gave up waiting on a slice that never finished.
Kept apart from ``SlicerApiUnavailableError`` because they call for
opposite reactions and used to be reported as the same thing: an
``httpx.ReadTimeout`` is a subclass of ``RequestError``, so a slice that
simply took a long time surfaced as "Slicer sidecar unreachable" sending
the reporter of #2730 off to check a sidecar that was reachable throughout
and still slicing when we hung up on it.
"""
class SliceResult(NamedTuple):
"""Result of a slice operation."""
@ -51,6 +64,36 @@ class SliceResult(NamedTuple):
_shared_http_client: httpx.AsyncClient | None = None
# Fallback for callers that don't pass one (tests, and any path that runs
# without a DB session to read the setting from). The user-facing value is
# ``slicer_stall_timeout_minutes`` under Settings -> Workflow -> Slicer.
DEFAULT_SLICE_STALL_TIMEOUT_SECONDS = 15 * 60.0
# How often the progress poller ticks. Also the granularity of the stall check,
# since a missed tick is what the stall clock is counting.
_PROGRESS_POLL_INTERVAL = 1.0
async def get_stall_timeout_seconds(db) -> float:
"""Read ``slicer_stall_timeout_minutes`` (Settings -> Workflow -> Slicer).
Falls back to the default on anything unparseable rather than failing the
slice a bad settings row must not be the reason a print doesn't happen.
"""
from backend.app.api.routes.settings import get_setting
try:
raw = await get_setting(db, "slicer_stall_timeout_minutes")
except Exception:
return DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
try:
minutes = int(str(raw).strip())
except (TypeError, ValueError):
return DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
if minutes < 1:
return DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
return float(minutes) * 60.0
def _format_sidecar_error(response: httpx.Response) -> str:
"""Build a human-readable error string from a sidecar 4xx/5xx response.
@ -149,6 +192,65 @@ def _guess_model_content_type(filename: str) -> str:
return "application/octet-stream"
class _Liveness:
"""Tracks when the slicer last showed a sign of life.
``deadline`` is what the slice waits against, and it moves forward on every
genuine progress update. A slice therefore fails only after the configured
window of *silence*, however long the whole thing has been running (#2730).
``progress_supported`` stays False for sidecars that never answer the
progress endpoint. Those give us nothing to judge liveness by, so the caller
treats the same window as a total-elapsed ceiling rather than pretending a
stall can be detected.
"""
def __init__(self, window_seconds: float, poll_interval: float = _PROGRESS_POLL_INTERVAL) -> None:
# Liveness can only be observed as often as the poller ticks, so a
# window shorter than a few ticks would expire in the gap between two
# polls and fail every slice instantly, however healthy. The settings
# schema already floors the user-facing value at a minute; this guards
# the constructor, which tests and any future caller can pass anything.
self.window_seconds = max(window_seconds, poll_interval * 3)
self.progress_supported = False
self.started_at = time.monotonic()
self._last_alive = self.started_at
def saw_progress_endpoint(self) -> None:
self.progress_supported = True
def mark_alive(self) -> None:
self._last_alive = time.monotonic()
@property
def deadline(self) -> float:
"""Monotonic time at which we stop waiting."""
base = self._last_alive if self.progress_supported else self.started_at
return base + self.window_seconds
def silent_for(self) -> float:
return time.monotonic() - self._last_alive
def elapsed(self) -> float:
return time.monotonic() - self.started_at
def timeout_message(self) -> str:
minutes = self.window_seconds / 60
if self.progress_supported:
return (
f"The slicer stopped reporting progress for {minutes:.0f} minutes "
f"(slicing had been running for {self.elapsed() / 60:.0f} minutes). "
"Raise 'Slicer stall timeout' under Settings -> Workflow -> Slicer if this model "
"legitimately needs longer between progress updates."
)
return (
f"Slicing did not finish within {minutes:.0f} minutes, and this sidecar does not "
"report progress, so there was no way to tell a slow model from a stalled one. "
"Raise 'Slicer stall timeout' under Settings -> Workflow -> Slicer, or update the "
"sidecar to a version that reports progress."
)
class SlicerApiService:
"""Talks to an OrcaSlicer / BambuStudio API sidecar."""
@ -157,10 +259,25 @@ class SlicerApiService:
base_url: str,
*,
client: httpx.AsyncClient | None = None,
timeout_seconds: float = 300.0,
timeout_seconds: float = DEFAULT_SLICE_STALL_TIMEOUT_SECONDS,
) -> None:
"""``timeout_seconds`` bounds *silence*, not total slicing time (#2730).
While a slice is running Bambuddy polls the sidecar's progress channel
once a second, so it can tell a model that is merely slow from one that
has stopped: the clock is reset by every progress update, and only runs
out when the slicer has said nothing for this long. A heavy model that
keeps reporting will run to completion however long it takes.
Sidecars too old to report progress have no liveness signal to offer, so
for those the same number bounds total elapsed time the pre-#2730
behaviour, but configurable and no longer five minutes flat.
"""
self.base_url = base_url.rstrip("/")
self.timeout_seconds = timeout_seconds
# Instance-level so tests can compress the timing; production always
# uses the module default.
self.progress_poll_interval = _PROGRESS_POLL_INTERVAL
if client is not None:
self._client = client
self._owns_client = False
@ -217,6 +334,8 @@ class SlicerApiService:
self,
request_id: str,
on_progress: Callable[[dict], None],
*,
liveness: "_Liveness | None" = None,
) -> None:
"""Poll the sidecar's progress endpoint at ~1Hz and forward each
snapshot to ``on_progress``. Runs until cancelled.
@ -232,14 +351,27 @@ class SlicerApiService:
slice grace expiry) just costs a few wasted GETs that the cancel
will stop. Network errors and non-JSON 5xx are swallowed; the
next tick retries.
When ``liveness`` is supplied this doubles as the stall watchdog: every
200 carrying a *changed* payload marks the slicer alive, which is what
keeps the slice's deadline moving (#2730). An unchanged payload
deliberately does not count the sidecar re-serves its last snapshot on
every poll, so treating a repeat as progress would leave the watchdog
unable to detect a stall at all.
"""
url = f"{self.base_url}/slice/progress/{request_id}"
last_payload: dict | None = None
while True:
try:
response = await self._client.get(url, timeout=5.0)
if response.status_code == 200:
payload = response.json()
if isinstance(payload, dict):
if liveness is not None:
liveness.saw_progress_endpoint()
if payload != last_payload:
liveness.mark_alive()
last_payload = payload
on_progress(payload)
# 404 / other 4xx = no progress available (yet, or ever
# for older sidecars). Keep polling — the outer slice
@ -249,10 +381,85 @@ class SlicerApiService:
# returns a non-JSON 5xx. Don't crash the poller.
pass
try:
await asyncio.sleep(1.0)
await asyncio.sleep(self.progress_poll_interval)
except asyncio.CancelledError:
return
async def _post_slice(
self,
*,
files: list | dict,
data: dict,
request_id: str | None,
on_progress: Callable[[dict], None] | None,
) -> httpx.Response:
"""POST /slice, supervised by the progress channel rather than a clock.
Before #2730 this was a plain ``httpx`` call with a flat 300 s timeout on
every phase. A genuinely heavy model the reporter's was a MakerWorld
model that Bambu Studio also took a long time over hit the ceiling
while it was still slicing perfectly happily, and because
``httpx.ReadTimeout`` is a ``RequestError`` it was reported as "Slicer
sidecar unreachable". Meanwhile Bambuddy was polling the sidecar's
progress endpoint once a second and could see the thing working.
So the read timeout comes off the HTTP call and the poller supervises
instead: the deadline is pushed forward by every progress update, and
only a genuine silence ends the wait. Connect and pool keep short
timeouts a sidecar that won't accept the connection at all is
unreachable, and should still say so quickly.
"""
liveness = _Liveness(self.timeout_seconds, self.progress_poll_interval)
# Poll whenever we have a request_id, even if the caller wants no
# progress callbacks: the poll is what makes stall detection possible,
# and one GET per second is cheaper than a wrongly-cancelled slice.
progress_task: asyncio.Task | None = None
if request_id is not None:
progress_task = asyncio.create_task(
self._poll_progress(request_id, on_progress or (lambda _payload: None), liveness=liveness),
name=f"slicer-progress-{request_id}",
)
post_task = asyncio.create_task(
self._client.post(
f"{self.base_url}/slice",
files=files,
data=data,
timeout=httpx.Timeout(connect=30.0, read=None, write=None, pool=30.0),
),
name="slicer-slice-post",
)
try:
while True:
remaining = liveness.deadline - time.monotonic()
if remaining <= 0:
post_task.cancel()
logger.warning(
"Slice abandoned after %.0fs (silent for %.0fs, progress channel %s)",
liveness.elapsed(),
liveness.silent_for(),
"available" if liveness.progress_supported else "unavailable",
)
raise SlicerTimeoutError(liveness.timeout_message())
# Re-check at poll granularity so a progress update that lands
# mid-wait extends the deadline promptly.
done, _pending = await asyncio.wait({post_task}, timeout=min(remaining, self.progress_poll_interval))
if post_task in done:
break
finally:
if progress_task is not None:
progress_task.cancel()
# Await both so neither is left pending — a cancelled POST still
# needs its connection released back to the pool.
await asyncio.gather(post_task, progress_task or asyncio.sleep(0), return_exceptions=True)
try:
return post_task.result()
except httpx.RequestError as exc:
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
async def slice_with_profiles(
self,
*,
@ -328,30 +535,7 @@ class SlicerApiService:
# and surfaces structured updates via on_progress. Uses a
# short-tick poll (1s) since the slicer emits stage changes
# several times per minute on complex models.
progress_task: asyncio.Task | None = None
if request_id is not None and on_progress is not None:
progress_task = asyncio.create_task(
self._poll_progress(request_id, on_progress),
name=f"slicer-progress-{request_id}",
)
try:
response = await self._client.post(
f"{self.base_url}/slice",
files=files,
data=data,
timeout=self.timeout_seconds,
)
except httpx.RequestError as exc:
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
finally:
if progress_task is not None:
progress_task.cancel()
try:
await progress_task
except (asyncio.CancelledError, Exception):
pass # Polling errors must not fail the slice.
response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
return _handle_slice_response(response, export_3mf=export_3mf)
async def slice_without_profiles(
@ -396,30 +580,7 @@ class SlicerApiService:
# embedded-settings fallback path triggered by an Orca/Bambu CLI
# segfault on complex H2D models — both want to keep updating
# the user's toast through the slow operation.
progress_task: asyncio.Task | None = None
if request_id is not None and on_progress is not None:
progress_task = asyncio.create_task(
self._poll_progress(request_id, on_progress),
name=f"slicer-progress-{request_id}",
)
try:
response = await self._client.post(
f"{self.base_url}/slice",
files=files,
data=data,
timeout=self.timeout_seconds,
)
except httpx.RequestError as exc:
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
finally:
if progress_task is not None:
progress_task.cancel()
try:
await progress_task
except (asyncio.CancelledError, Exception):
pass
response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
return _handle_slice_response(response, export_3mf=export_3mf)

View file

@ -69,6 +69,17 @@ def _install_mock_sidecar(handler: Callable[[httpx.Request], httpx.Response]) ->
return client
def _is_slice_post(request: httpx.Request) -> bool:
"""True for the slice call itself, false for the progress polls beside it.
Since #2730 a slice is supervised by a 1 Hz poll of
``GET /slice/progress/{id}``, which shares this mock transport. Tests that
count *slice attempts* primary vs embedded-settings fallback have to
exclude those, or the count becomes a measure of how long the test took.
"""
return request.method == "POST" and request.url.path.endswith("/slice")
async def _wait_for_job(client: AsyncClient, job_id: int, timeout: float = 5.0) -> dict:
"""Poll `/api/v1/slice-jobs/{id}` until the job hits a terminal state.
@ -414,6 +425,8 @@ class TestSliceLibraryFile:
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
if not _is_slice_post(request):
return httpx.Response(404)
call_count["n"] += 1
# First call: profile triplet present → simulate CLI 5xx
if call_count["n"] == 1:
@ -454,7 +467,9 @@ class TestSliceLibraryFile:
# STL has no embedded settings — the CLI 5xx is terminal.
call_count = {"n": 0}
def handler(_: httpx.Request) -> httpx.Response:
def handler(request: httpx.Request) -> httpx.Response:
if not _is_slice_post(request):
return httpx.Response(404)
call_count["n"] += 1
return httpx.Response(
status_code=500,
@ -568,6 +583,8 @@ class TestSliceLibraryFile:
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
if not _is_slice_post(request):
return httpx.Response(404)
call_count["n"] += 1
captured["body"] = request.content
return httpx.Response(
@ -777,6 +794,8 @@ class TestCrossClassSliceAllLoop:
captured_requests: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
if not _is_slice_post(request):
return httpx.Response(404)
# Multipart bodies aren't trivially parseable here; pull
# the plate field by string search since the helper sends
# ``name="plate"`` immediately followed by the value.
@ -1463,6 +1482,8 @@ class TestSliceSlicerRejection:
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
if not _is_slice_post(request):
return httpx.Response(404)
call_count["n"] += 1
return httpx.Response(
status_code=500,
@ -1721,6 +1742,8 @@ class TestUnusedSlotSubstitutionOnSinglePlateSource:
captured: list[list[str]] = []
def handler(request: httpx.Request) -> httpx.Response:
if not _is_slice_post(request):
return httpx.Response(404)
captured.append(self._filament_names_sent(request.content))
return httpx.Response(
status_code=200,
@ -1818,6 +1841,8 @@ class TestUnusedSlotSubstitutionOnSinglePlateSource:
captured: list[list[str]] = []
def handler(request: httpx.Request) -> httpx.Response:
if not _is_slice_post(request):
return httpx.Response(404)
captured.append(self._filament_names_sent(request.content))
return httpx.Response(
status_code=200,

View file

@ -0,0 +1,229 @@
"""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

View file

@ -1278,6 +1278,10 @@ export interface AppSettings {
// Per-install sidecar URLs. Empty string falls back to the env defaults.
orcaslicer_api_url: string;
bambu_studio_api_url: string;
// Minutes of silence from the sidecar before a slice is abandoned. Bounds
// stalls, not total slicing time — a model that keeps reporting progress
// runs to completion however long it takes.
slicer_stall_timeout_minutes: number;
// Prometheus metrics
prometheus_enabled: boolean;
prometheus_token: string;

View file

@ -2237,6 +2237,8 @@ export default {
slicerCard: 'Slicer',
orcaslicerApiUrl: 'OrcaSlicer Sidecar-URL',
bambuStudioApiUrl: 'Bambu Studio Sidecar-URL',
slicerStallTimeout: 'Zeitlimit bei Slicer-Stillstand (Minuten)',
slicerStallTimeoutDescription: 'Bricht einen Slice-Vorgang ab, wenn der Sidecar so lange keinen Fortschritt meldet. Aufwendige Modelle, die weiter Fortschritt melden, werden nie abgebrochen, egal wie lange sie brauchen. Sidecars ohne Fortschrittsmeldung nutzen diesen Wert stattdessen als Gesamtzeitlimit.',
slicerApiUrlDescription: 'URL des Slicer-API-Sidecar-Containers. Leer lassen, um die SLICER_API_URL- bzw. BAMBU_STUDIO_API_URL-Umgebungsvariablen zu nutzen.',
slicerBundlesRemoved: {
title: 'Slicer-Bundles (entfernt)',

View file

@ -2256,6 +2256,8 @@ export default {
slicerCard: 'Slicer',
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerStallTimeout: 'Slicer stall timeout (minutes)',
slicerStallTimeoutDescription: 'Give up on a slice after this long with no progress from the sidecar. Heavy models that keep reporting progress are never cut off, however long they take. Sidecars that do not report progress use this as a total time limit instead.',
slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
slicerBundlesRemoved: {
title: 'Slicer Bundles (removed)',

View file

@ -2240,6 +2240,8 @@ export default {
slicerCard: 'Laminador',
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerStallTimeout: 'Tiempo de espera por inactividad del laminador (minutos)',
slicerStallTimeoutDescription: 'Abandona un laminado tras este tiempo sin progreso del sidecar. Los modelos pesados que siguen informando progreso nunca se interrumpen, por mucho que tarden. Los sidecars que no informan progreso usan este valor como limite de tiempo total.',
slicerApiUrlDescription: 'URL del contenedor auxiliar de la API del laminador. Déjelo en blanco para usar los valores predeterminados de las variables de entorno SLICER_API_URL / BAMBU_STUDIO_API_URL.',
slicerBundlesRemoved: {
title: 'Paquetes del laminador (eliminado)',

View file

@ -2193,6 +2193,8 @@ export default {
slicerCard: 'Slicer',
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerStallTimeout: "Delai d'inactivite du trancheur (minutes)",
slicerStallTimeoutDescription: 'Abandonne un decoupage apres cette duree sans progression du sidecar. Les modeles lourds qui continuent a signaler leur progression ne sont jamais interrompus, quel que soit le temps necessaire. Les sidecars qui ne signalent pas de progression utilisent cette valeur comme limite de duree totale.',
slicerApiUrlDescription: 'URL du conteneur sidecar slicer-API. Laisser vide pour utiliser les variables d\'environnement SLICER_API_URL / BAMBU_STUDIO_API_URL.',
slicerBundlesRemoved: {
title: 'Bundles de slicer (supprimé)',

View file

@ -2193,6 +2193,8 @@ export default {
slicerCard: 'Slicer',
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerStallTimeout: 'Timeout di inattivita dello slicer (minuti)',
slicerStallTimeoutDescription: 'Interrompe uno slice dopo questo tempo senza progressi dal sidecar. I modelli pesanti che continuano a segnalare progressi non vengono mai interrotti, per quanto tempo richiedano. I sidecar che non segnalano progressi usano questo valore come limite di tempo totale.',
slicerApiUrlDescription: 'URL del container sidecar slicer-API. Lascia vuoto per usare le variabili d\'ambiente SLICER_API_URL / BAMBU_STUDIO_API_URL.',
slicerBundlesRemoved: {
title: 'Bundle slicer (rimosso)',

View file

@ -2236,6 +2236,8 @@ export default {
slicerCard: 'スライサー',
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerStallTimeout: 'スライサー停止タイムアウト(分)',
slicerStallTimeoutDescription: 'サイドカーからの進捗がこの時間なければスライスを中止します。進捗を報告し続ける重いモデルは、どれだけ時間がかかっても中断されません。進捗を報告しないサイドカーでは、この値が合計時間の上限になります。',
slicerApiUrlDescription: 'slicer-APIサイドカーコンテナのURL。空のままにすると SLICER_API_URL / BAMBU_STUDIO_API_URL 環境変数のデフォルト値が使用されます。',
slicerBundlesRemoved: {
title: 'スライサーバンドル(削除済み)',

View file

@ -2110,6 +2110,8 @@ export default {
slicerCard: '슬라이서',
orcaslicerApiUrl: 'OrcaSlicer 사이드카 URL',
bambuStudioApiUrl: 'Bambu Studio 사이드카 URL',
slicerStallTimeout: '슬라이서 정지 시간 제한(분)',
slicerStallTimeoutDescription: '사이드카에서 이 시간 동안 진행 상황이 없으면 슬라이싱을 중단합니다. 진행 상황을 계속 보고하는 무거운 모델은 아무리 오래 걸려도 중단되지 않습니다. 진행 상황을 보고하지 않는 사이드카에서는 이 값이 전체 시간 제한으로 사용됩니다.',
slicerApiUrlDescription: '슬라이서 API 사이드카 컨테이너의 URL. SLICER_API_URL / BAMBU_STUDIO_API_URL 환경 변수 기본값을 사용하려면 비워두세요.',
slicerBundlesRemoved: {
title: '슬라이서 번들 (제거됨)',

View file

@ -2193,6 +2193,8 @@ export default {
slicerCard: 'Fatiador',
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerStallTimeout: 'Tempo limite de inatividade do fatiador (minutos)',
slicerStallTimeoutDescription: 'Desiste de um fatiamento apos esse tempo sem progresso do sidecar. Modelos pesados que continuam relatando progresso nunca sao interrompidos, por mais que demorem. Sidecars que nao relatam progresso usam este valor como limite de tempo total.',
slicerApiUrlDescription: 'URL do contêiner sidecar slicer-API. Deixe em branco para usar SLICER_API_URL / BAMBU_STUDIO_API_URL.',
slicerBundlesRemoved: {
title: 'Bundles do fatiador (removido)',

View file

@ -2111,6 +2111,8 @@ export default {
slicerCard: "Слайсер",
orcaslicerApiUrl: "URL API-службы OrcaSlicer",
bambuStudioApiUrl: "URL API-службы Bambu Studio",
slicerStallTimeout: 'Тайм-аут простоя слайсера (минуты)',
slicerStallTimeoutDescription: 'Прервать нарезку, если sidecar не сообщает о прогрессе в течение этого времени. Тяжёлые модели, которые продолжают сообщать о прогрессе, не прерываются, сколько бы времени ни потребовалось. Для sidecar без отчёта о прогрессе это значение используется как общий лимит времени.',
slicerApiUrlDescription: "URL контейнера API-службы слайсера. Оставьте пустым, чтобы использовать значения переменных окружения SLICER_API_URL или BAMBU_STUDIO_API_URL.",
slicerBundlesRemoved: {
title: "Пакеты профилей слайсера (удалено)",

View file

@ -2241,6 +2241,8 @@ export default {
slicerCard: 'Dilimleyici',
orcaslicerApiUrl: 'OrcaSlicer yardımcı bileşen URL',
bambuStudioApiUrl: 'Bambu Studio yardımcı bileşen URL',
slicerStallTimeout: 'Dilimleyici duraklama zaman asimi (dakika)',
slicerStallTimeoutDescription: 'Sidecar bu sure boyunca ilerleme bildirmezse dilimleme iptal edilir. Ilerleme bildirmeye devam eden agir modeller ne kadar surerse sursun kesilmez. Ilerleme bildirmeyen sidecar surumleri bu degeri toplam sure siniri olarak kullanir.',
slicerApiUrlDescription: 'Dilimleyici-API yardımcı bileşen konteynerinin URL\'si. SLICER_API_URL / BAMBU_STUDIO_API_URL ortam değişkeni varsayılanlarını kullanmak için boş bırakın.',
slicerBundlesRemoved: {
title: 'Dilimleyici Paketleri (kaldırıldı)',

View file

@ -2256,6 +2256,8 @@ export default {
slicerCard: "Слайсер",
orcaslicerApiUrl: "URL допоміжного сервісу OrcaSlicer",
bambuStudioApiUrl: "URL допоміжного сервісу Bambu Studio",
slicerStallTimeout: 'Тайм-аут простою слайсера (хвилини)',
slicerStallTimeoutDescription: 'Перервати нарізку, якщо sidecar не повідомляє про прогрес протягом цього часу. Важкі моделі, які продовжують повідомляти про прогрес, ніколи не перериваються, скільки б часу не знадобилося. Для sidecar без звіту про прогрес це значення використовується як загальний ліміт часу.',
slicerApiUrlDescription: "URL контейнера допоміжного сервісу slicer-API. Залиште поле порожнім, щоб використовувати типові значення зі змінних середовища SLICER_API_URL / BAMBU_STUDIO_API_URL.",
slicerBundlesRemoved: {
title: "Пакети профілів слайсера (вилучено)",

View file

@ -2238,6 +2238,8 @@ export default {
slicerCard: '切片器',
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerStallTimeout: '切片器停滞超时(分钟)',
slicerStallTimeoutDescription: '若 sidecar 在此时长内没有任何进度,则放弃本次切片。持续报告进度的复杂模型无论耗时多久都不会被中断。不报告进度的 sidecar 则将此值作为总时长上限。',
slicerApiUrlDescription: 'slicer-API sidecar 容器的 URL。留空以使用 SLICER_API_URL / BAMBU_STUDIO_API_URL 环境变量默认值。',
slicerBundlesRemoved: {
title: '切片器捆绑包(已移除)',

View file

@ -2238,6 +2238,8 @@ export default {
slicerCard: '切片器',
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerStallTimeout: '切片器停滯逾時(分鐘)',
slicerStallTimeoutDescription: '若 sidecar 在此時長內沒有任何進度,則放棄本次切片。持續回報進度的複雜模型無論耗時多久都不會被中斷。不回報進度的 sidecar 則將此值作為總時長上限。',
slicerApiUrlDescription: 'slicer-API sidecar 容器的 URL。留空以使用 SLICER_API_URL / BAMBU_STUDIO_API_URL 環境變數預設值。',
slicerBundlesRemoved: {
title: '切片器捆綁包(已移除)',

View file

@ -1009,6 +1009,7 @@ export function SettingsPage() {
(settings.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) ||
(settings.use_slicer_api ?? false) !== (localSettings.use_slicer_api ?? false) ||
(settings.orcaslicer_api_url ?? '') !== (localSettings.orcaslicer_api_url ?? '') ||
(settings.slicer_stall_timeout_minutes ?? 15) !== (localSettings.slicer_stall_timeout_minutes ?? 15) ||
(settings.bambu_studio_api_url ?? '') !== (localSettings.bambu_studio_api_url ?? '') ||
settings.prometheus_enabled !== localSettings.prometheus_enabled ||
settings.prometheus_token !== localSettings.prometheus_token ||
@ -1112,6 +1113,7 @@ export function SettingsPage() {
open_in_slicer: localSettings.open_in_slicer,
use_slicer_api: localSettings.use_slicer_api,
orcaslicer_api_url: localSettings.orcaslicer_api_url,
slicer_stall_timeout_minutes: localSettings.slicer_stall_timeout_minutes,
bambu_studio_api_url: localSettings.bambu_studio_api_url,
prometheus_enabled: localSettings.prometheus_enabled,
prometheus_token: localSettings.prometheus_token,
@ -4889,6 +4891,26 @@ export function SettingsPage() {
</p>
</div>
)}
{(localSettings.use_slicer_api ?? false) && (
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('settings.slicerStallTimeout')}
</label>
<input
type="number"
min={1}
max={240}
value={localSettings.slicer_stall_timeout_minutes ?? 15}
onChange={(e) =>
updateSetting('slicer_stall_timeout_minutes', Number(e.target.value))
}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.slicerStallTimeoutDescription')}
</p>
</div>
)}
</CardContent>
</Card>

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BZPDldI_.js"></script>
<script type="module" crossorigin src="/assets/index-CMvWx2qm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
</head>
<body>