Files
bambuddy/backend/app/services/slice_dispatch.py
T
maziggy d5153f1de3 feat(slicer): live progress + filament discovery polish + OrcaSlicer warning
End-to-end live progress, two correctness fixes, and a UX warning around
  the upstream OrcaSlicer bugs we discovered while testing.

  LIVE PROGRESS
  =============

  Wire OrcaSlicer / BambuStudio's --pipe progress channel through the
  sidecar -> Bambuddy -> persistent toast so a user-initiated slice shows
  "{name} -- Generating G-code (75%) -- 47s" instead of just elapsed time.
  The same wiring covers the SliceModal's filament-analysis preview slice
  (the real slice that fires before profile picking, used to discover
  which AMS slots an unsliced plate consumes) and the embedded-settings
  fallback path triggered by Orca's --load-settings segfault on complex
  H2D models.

  - Sidecar (orca-slicer-api/bambuddy/profile-resolver, separate commit):
    switch /slice from execFile to spawn, mkfifo per request, parse the
    CLI's structured JSON progress events into a per-process
    ProgressStore, expose GET /slice/progress/:requestId.
  - Bambuddy backend: slicer_api.slice_with_profiles + slice_without_profiles
    accept request_id + on_progress, spawn a 1Hz parallel poller that
    forwards each snapshot via SliceDispatchService.set_progress(job_id,
    ...) onto the matching SliceJob; GET /slice-jobs/:id includes the
    latest snapshot on every poll. The 404 from the early-race window
    (POST fired before sidecar's progressStore.start) is treated as a
    retry rather than terminal -- otherwise the poller bailed before any
    progress could ever arrive.
  - /api/v1/slicer/preview-progress/:requestId proxies the sidecar's
    progress endpoint for the modal's filament-discovery flow (the
    /filament-requirements call is server-originated; the browser can't
    reach the sidecar directly).
  - Frontend: SliceJobTrackerContext re-renders the persistent toast with
    the new format when a useful progress frame is present, falls back
    to elapsed-time-only when the sidecar hasn't emitted yet or doesn't
    support progress. SliceModal.FilamentAnalysisSpinner generates a
    per-(source, plate) UUID, polls the proxy at 1Hz, and mirrors the
    inline spinner contents into a separate persistent toast so the
    preview slice doesn't feel silent either.

  CORRECTNESS FIXES
  =================

  - MakerWorld imports were persisting URL-encoded filenames verbatim
    ("stormtrooper-helmet%20h2d.3mf"). Backend now urllib.parse.unquote
    s the manifest-supplied name and the URL path-tail fallback before
    passing to save_3mf_bytes_to_library; frontend defensively
    decodeURIComponent s in the slice toast / analysis spinner so
    already-imported rows display cleanly without a backfill migration.
  - The fallback path's slice_without_profiles call now forwards the
    same request_id + on_progress as the primary slice_with_profiles
    call so the toast keeps updating across the segfault -> embedded-
    settings retry boundary instead of going blank.

  ORCASLICER WARNING
  ==================

  Verified two upstream OrcaSlicer CLI bugs reproduce on the latest
  nightly (2.4.0-dev, 2026-04-28) with the help of an isolated AppImage
  extract and a minimal sentinel-value-injected cube fixture:
    - OrcaSlicer/OrcaSlicer#12426 -- SIGSEGV in
      update_values_to_printer_extruders_for_multiple_filaments on
      painted multi-extruder 3MFs (commented on the existing thread,
      not a new issue)
    - OrcaSlicer/OrcaSlicer#13386 -- CLI strict-validates parameter
      values BambuStudio writes by default (solid_infill_filament: 0,
      tree_support_wall_count: -1, prime_tower_brim_width: -1) and
      rejects with exit 238, even though Orca's own GUI tolerates
      them (filed by us alongside this change)

  Settings -> Workflow -> Slicer card renders an amber inline warning
  under the preferred-slicer dropdown when orcaslicer is selected,
  linking both upstream issues and recommending BambuStudio until the
  fixes land. Option stays pickable -- users who only slice STLs aren't
  affected by either bug.
2026-04-28 16:36:49 +02:00

173 lines
6.2 KiB
Python

"""In-memory background dispatcher for slice jobs.
Mirrors the shape of `background_dispatch.py` (the print-upload dispatcher)
but tailored for slicing: jobs are independent (no printer-busy gating),
short-lived (typically 5-60s), and the result is a `LibraryFile` or
`PrintArchive` row rather than a printer-side dispatch.
The frontend kicks off a slice via `POST /library/files/{id}/slice` or
`POST /archives/{id}/slice`, gets back `{job_id, status_url}`, then polls
`GET /slice-jobs/{id}` until status is `completed` or `failed`.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Literal
logger = logging.getLogger(__name__)
SliceJobStatus = Literal["pending", "running", "completed", "failed"]
@dataclass(slots=True)
class SliceJob:
id: int
kind: Literal["library_file", "archive"]
source_id: int
source_name: str
status: SliceJobStatus = "pending"
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
started_at: datetime | None = None
completed_at: datetime | None = None
# On success: the body returned to the caller — usually a SliceResponse
# or SliceArchiveResponse dict.
result: dict[str, Any] | None = None
# On failure: HTTP status + error message.
error_status: int | None = None
error_detail: str | None = None
# Live progress fed by the sidecar's --pipe channel while the slicer
# is running. Populated by a polling task spawned alongside the
# blocking POST /slice request; None when the sidecar doesn't
# support progress (older sidecars, no request_id, etc.). Surfaced
# in the SliceJobState response so the persistent toast can render
# "Generating G-code (75%)" instead of just elapsed time.
progress: dict[str, Any] | None = None
# Retention: keep finished jobs around for 30 minutes so the polling client
# always sees a terminal state on its next tick. After that, the next access
# sweep prunes them.
_RETENTION_SECONDS = 30 * 60
class SliceDispatchService:
def __init__(self) -> None:
self._jobs: dict[int, SliceJob] = {}
self._next_id: int = 1
self._lock = asyncio.Lock()
self._tasks: dict[int, asyncio.Task] = {}
async def enqueue(
self,
*,
kind: Literal["library_file", "archive"],
source_id: int,
source_name: str,
run: Callable[[int], Awaitable[dict[str, Any]]],
) -> SliceJob:
"""Register a new slice job and start it on the event loop.
``run`` is an async callable that takes the freshly-created
``job_id`` (so it can wire up live-progress reporting via
:meth:`set_progress`) and returns the response body the caller
will receive once status flips to ``completed``.
"""
async with self._lock:
job = SliceJob(
id=self._next_id,
kind=kind,
source_id=source_id,
source_name=source_name,
)
self._next_id += 1
self._jobs[job.id] = job
self._sweep_locked()
task = asyncio.create_task(self._run_job(job, run), name=f"slice-job-{job.id}")
self._tasks[job.id] = task
return job
async def _run_job(
self,
job: SliceJob,
run: Callable[[int], Awaitable[dict[str, Any]]],
) -> None:
job.started_at = datetime.now(timezone.utc)
job.status = "running"
try:
result = await run(job.id)
job.result = result
job.status = "completed"
except _SliceJobError as exc:
# Caller-controlled HTTP error — propagate status + detail.
job.status = "failed"
job.error_status = exc.status_code
job.error_detail = exc.detail
except Exception as exc:
logger.exception("Slice job %s failed unexpectedly", job.id)
job.status = "failed"
job.error_status = 500
job.error_detail = f"Unexpected error: {exc}"
finally:
job.completed_at = datetime.now(timezone.utc)
self._tasks.pop(job.id, None)
def get(self, job_id: int) -> SliceJob | None:
return self._jobs.get(job_id)
def set_progress(self, job_id: int, progress: dict[str, Any] | None) -> None:
"""Update the live-progress snapshot for a running job.
Called by the slice route's progress poller every ~1s while the
sidecar slice request is in flight. Silently ignores unknown ids
(the job may have just finished and been retention-swept) so a
late poll doesn't crash the polling task.
"""
job = self._jobs.get(job_id)
if job is not None:
job.progress = progress
def _sweep_locked(self) -> None:
"""Drop finished jobs older than the retention window. Caller holds
the lock."""
now = datetime.now(timezone.utc)
stale_ids = [
jid
for jid, job in self._jobs.items()
if job.status in ("completed", "failed")
and job.completed_at is not None
and (now - job.completed_at).total_seconds() > _RETENTION_SECONDS
]
for jid in stale_ids:
self._jobs.pop(jid, None)
class _SliceJobError(Exception):
"""Raised inside a slice job's `run` callable to surface a specific
HTTP status + detail. The dispatcher catches these and stores them on
the job. Callers convert ``HTTPException`` to this on the boundary.
"""
def __init__(self, status_code: int, detail: str) -> None:
super().__init__(detail)
self.status_code = status_code
self.detail = detail
def http_exception_to_job_error(exc) -> _SliceJobError:
"""Convert a starlette ``HTTPException`` into the dispatcher's error
type. Handles the common case where slice helpers raise FastAPI's
``HTTPException`` for validation / sidecar failures.
"""
return _SliceJobError(exc.status_code, str(exc.detail))
# Module-level singleton, started/stopped by main.py's lifespan.
slice_dispatch = SliceDispatchService()