Browse Source

fix(ftp): printer downloads stop at their deadline, fit the file's size and say what failed

- The bytes download stops its transfer at the deadline and waits for
  the worker, so a retry never runs beside it; a deadline that fitted
  the file's size raises DownloadDeadlineExceeded and is not retried.
  Without a listing size, the size the printer reports extends it.
- The file browser's previews answer 404 only for a file the printer
  says is gone, 502 for a failed download and 504 for one too slow for
  its size, and may take up to the file browser's budget.
- The printer file download may use its 30-minute budget instead of a
  flat 10 minutes, and a failure is 502/504, not 404.
- Timelapse scan and select answer 502/504 instead of 500; the
  automatic attach treats a deadline as a failed round.
maziggy 6 days ago
parent
commit
7a2fd80b91

+ 2 - 0
CHANGELOG.md

@@ -164,6 +164,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Printer file downloads tell a missing file from a failed one, and big files get the time they need** — Downloading a file from the printer, and the file browser's previews (G-code, plates, plate thumbnails), treated every failure as "File not found", including a slow transfer of a file just listed. Bambuddy now answers "not found" only when the printer says the file is gone, and otherwise that the download failed or took too long; the download's message and the timelapse messages show which. A file download was also held to 10 minutes whatever its size, so a 250 MB camera recording on a slow link always failed, and the previews to 5 minutes; both may now take as long as the file's size calls for, up to the 30-minute limit. A timelapse that could not be downloaded is reported the same way instead of as a server error.
+- **A timed-out printer download no longer runs on beside its retry** — When a timelapse download ran out of time, it kept going in the background while the next attempt opened a second connection to the printer. The transfer is now stopped first, and a download that already had the time its size allows is not retried.
 - **Large STL files no longer freeze Bambuddy while their thumbnail is made** — Uploading an STL, extracting a ZIP of STLs, generating missing thumbnails and scanning an external folder rendered each thumbnail in a way that held up everything else, so a high-poly model froze the web interface and printer updates for seconds or longer. Thumbnails are now made in the background, one at a time.
 - **A frozen external RTSP camera is reconnected** — When an external RTSP camera stops sending, `ffmpeg` can keep repeating the last picture, so the live view and `/camera/status` kept showing a picture that had stopped changing, and so did anything reusing the live frame (timelapse frames, finish photos, Obico). Like the printer's own camera, an external RTSP camera that sends the same picture for 20 seconds is now reconnected. A camera that is simply slow to change (a slow source, or a smart codec on a static night scene) is restarted at most once, after which Bambuddy waits three times the longest gap it has seen, up to 5 minutes; a picture that really does not change is re-checked every 5 minutes.
 - **A USB camera can be watched from several places at once** — Every viewer of an external camera opened its own connection to it. A USB camera can only be read by one program at a time, so opening the Camera page while a Cam Wall tile (or another browser) was already showing it gave a blank picture. External cameras now share one connection per printer, like the printer's own camera, which also means one connection per MJPEG or snapshot camera and one `ffmpeg` per RTSP or USB camera instead of one per viewer. As with the printer's own camera, the viewer who opens the connection sets its frame rate: with a Cam Wall tile already showing the camera, the Camera page gets the tile's 8 frames per second until everyone has closed it.

+ 58 - 48
backend/app/api/routes/archives.py

@@ -2950,6 +2950,7 @@ async def scan_timelapse(
     from backend.app.core.database import async_session
     from backend.app.models.printer import Printer
     from backend.app.services.bambu_ftp import (
+        DownloadDeadlineExceeded,
         delete_archived_timelapse,
         download_file_bytes_async,
         ftps_handshake_blocked,
@@ -3138,32 +3139,36 @@ async def scan_timelapse(
     # Get FTP retry settings
     ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
 
-    if ftp_retry_enabled:
-        timelapse_data = await with_ftp_retry(
-            download_file_bytes_async,
-            printer.ip_address,
-            printer.access_code,
-            remote_path,
-            socket_timeout=ftp_timeout,
-            printer_model=printer.model,
-            expected_size=matching_file.get("size"),
-            max_retries=ftp_retry_count,
-            retry_delay=ftp_retry_delay,
-            operation_name=f"Download timelapse {matching_file['name']}",
-            cooloff_ip=printer.ip_address,
-        )
-    else:
-        timelapse_data = await download_file_bytes_async(
-            printer.ip_address,
-            printer.access_code,
-            remote_path,
-            socket_timeout=ftp_timeout,
-            printer_model=printer.model,
-            expected_size=matching_file.get("size"),
-        )
+    try:
+        if ftp_retry_enabled:
+            timelapse_data = await with_ftp_retry(
+                download_file_bytes_async,
+                printer.ip_address,
+                printer.access_code,
+                remote_path,
+                socket_timeout=ftp_timeout,
+                printer_model=printer.model,
+                expected_size=matching_file.get("size"),
+                max_retries=ftp_retry_count,
+                retry_delay=ftp_retry_delay,
+                operation_name=f"Download timelapse {matching_file['name']}",
+                cooloff_ip=printer.ip_address,
+            )
+        else:
+            timelapse_data = await download_file_bytes_async(
+                printer.ip_address,
+                printer.access_code,
+                remote_path,
+                socket_timeout=ftp_timeout,
+                printer_model=printer.model,
+                expected_size=matching_file.get("size"),
+            )
+    except DownloadDeadlineExceeded as exc:
+        # Not retried: the deadline already fitted the file's size.
+        raise HTTPException(504, "The timelapse download took longer than its size allows") from exc
 
     if not timelapse_data:
-        raise HTTPException(500, "Failed to download timelapse")
+        raise HTTPException(502, "Could not download the timelapse from the printer")
 
     # Confirm the printer has finished writing before we commit to this file and
     # delete the original: matching the listing's size proves we got what it
@@ -3213,6 +3218,7 @@ async def select_timelapse(
     from backend.app.core.database import async_session
     from backend.app.models.printer import Printer
     from backend.app.services.bambu_ftp import (
+        DownloadDeadlineExceeded,
         delete_archived_timelapse,
         download_file_bytes_async,
         get_ftp_retry_settings,
@@ -3263,32 +3269,36 @@ async def select_timelapse(
     # Download and attach
     ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
 
-    if ftp_retry_enabled:
-        timelapse_data = await with_ftp_retry(
-            download_file_bytes_async,
-            printer.ip_address,
-            printer.access_code,
-            remote_path,
-            socket_timeout=ftp_timeout,
-            printer_model=printer.model,
-            expected_size=expected_size,
-            max_retries=ftp_retry_count,
-            retry_delay=ftp_retry_delay,
-            operation_name=f"Download timelapse {filename}",
-            cooloff_ip=printer.ip_address,
-        )
-    else:
-        timelapse_data = await download_file_bytes_async(
-            printer.ip_address,
-            printer.access_code,
-            remote_path,
-            socket_timeout=ftp_timeout,
-            printer_model=printer.model,
-            expected_size=expected_size,
-        )
+    try:
+        if ftp_retry_enabled:
+            timelapse_data = await with_ftp_retry(
+                download_file_bytes_async,
+                printer.ip_address,
+                printer.access_code,
+                remote_path,
+                socket_timeout=ftp_timeout,
+                printer_model=printer.model,
+                expected_size=expected_size,
+                max_retries=ftp_retry_count,
+                retry_delay=ftp_retry_delay,
+                operation_name=f"Download timelapse {filename}",
+                cooloff_ip=printer.ip_address,
+            )
+        else:
+            timelapse_data = await download_file_bytes_async(
+                printer.ip_address,
+                printer.access_code,
+                remote_path,
+                socket_timeout=ftp_timeout,
+                printer_model=printer.model,
+                expected_size=expected_size,
+            )
+    except DownloadDeadlineExceeded as exc:
+        # Not retried: the deadline already fitted the file's size.
+        raise HTTPException(504, "The timelapse download took longer than its size allows") from exc
 
     if not timelapse_data:
-        raise HTTPException(500, "Failed to download timelapse")
+        raise HTTPException(502, "Could not download the timelapse from the printer")
 
     # Confirm the printer has finished writing before we commit to this file and
     # delete the original: matching the listing's size proves we got what it

+ 36 - 10
backend/app/api/routes/printers.py

@@ -53,6 +53,8 @@ from backend.app.schemas.printer import (
 )
 from backend.app.services import camera_light as camera_light_service, drying_preflight, kprofile_drift
 from backend.app.services.bambu_ftp import (
+    DownloadDeadlineExceeded,
+    FileNotOnPrinterError,
     cache_3mf_download,
     delete_file_async,
     download_file_bytes_async,
@@ -81,6 +83,7 @@ from backend.app.services.printer_manager import (
 )
 from backend.app.services.printer_media import (
     MAX_PRINTER_ZIP_PREPARE_SECONDS,
+    PrinterFileDownloadError,
     PrinterFilesZipInsufficientSpaceError,
     PrinterFilesZipTooLargeError,
     build_printer_file,
@@ -1672,10 +1675,12 @@ async def download_printer_file(
         raise HTTPException(413, str(exc)) from exc
     except PrinterFilesZipInsufficientSpaceError as exc:
         raise HTTPException(507, str(exc)) from exc
+    except PrinterFileDownloadError as exc:
+        raise HTTPException(502, f"Could not download {path} from the printer") from exc
     except FileNotFoundError:
         raise HTTPException(404, f"File not found: {path}")
     except TimeoutError as exc:
-        raise HTTPException(504, "Printer download exceeded the 30-minute limit") from exc
+        raise HTTPException(504, str(exc) or "Printer download exceeded the 30-minute limit") from exc
 
     # Determine content type based on extension
     filename = path.split("/")[-1]
@@ -1703,6 +1708,33 @@ async def download_printer_file(
     )
 
 
+async def _download_printer_file_bytes(printer, path: str) -> bytes:
+    """Download a printer file for a preview: 404 missing, 502 failed, 504 too slow.
+
+    A failed download used to be reported as 404 "File not found" for a file the
+    browser had just listed. One FTP session, no listing first: the plate
+    picker asks once per plate, and printers limit their FTPS handshakes.
+    """
+    try:
+        data = await download_file_bytes_async(
+            printer.ip_address,
+            printer.access_code,
+            path,
+            printer_model=printer.model,
+            report_missing=True,
+            # The size the printer reports buys a slow link its time, up to the
+            # file browser's budget: print start's 300 s ceiling would add none.
+            max_total_timeout=MAX_PRINTER_ZIP_PREPARE_SECONDS,
+        )
+    except FileNotOnPrinterError as exc:
+        raise HTTPException(404, f"File not found: {path}") from exc
+    except DownloadDeadlineExceeded as exc:
+        raise HTTPException(504, f"Downloading {path} from the printer took longer than its size allows") from exc
+    if data is None:
+        raise HTTPException(502, f"Could not download {path} from the printer")
+    return data
+
+
 @router.get("/{printer_id}/files/gcode")
 async def get_printer_file_gcode(
     printer_id: int,
@@ -1714,9 +1746,7 @@ async def get_printer_file_gcode(
 
     printer = await _load_printer_or_404(printer_id)
 
-    data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
-    if data is None:
-        raise HTTPException(404, f"File not found: {path}")
+    data = await _download_printer_file_bytes(printer, path)
 
     filename = path.split("/")[-1]
     lower = filename.lower()
@@ -1761,9 +1791,7 @@ async def get_printer_file_plates(
             "is_multi_plate": False,
         }
 
-    data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
-    if data is None:
-        raise HTTPException(404, f"File not found: {path}")
+    data = await _download_printer_file_bytes(printer, path)
 
     plates = []
 
@@ -1988,9 +2016,7 @@ async def get_printer_file_plate_thumbnail(
 
     printer = await _load_printer_or_404(printer_id)
 
-    data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
-    if data is None:
-        raise HTTPException(404, f"File not found: {path}")
+    data = await _download_printer_file_bytes(printer, path)
 
     try:
         with zipfile.ZipFile(io.BytesIO(data), "r") as zf:

+ 12 - 7
backend/app/main.py

@@ -6321,6 +6321,7 @@ async def _attach_first_unclaimed_timelapse(
     rounds where something changed are worth an INFO line.
     """
     from backend.app.services.bambu_ftp import (
+        DownloadDeadlineExceeded,
         delete_archived_timelapse,
         download_file_bytes_async,
         remote_file_settled,
@@ -6378,13 +6379,17 @@ async def _attach_first_unclaimed_timelapse(
     # depend on a size we actually had, not on one we hoped was there.
     expected_size = target.get("size")
 
-    timelapse_data = await download_file_bytes_async(
-        printer.ip_address,
-        printer.access_code,
-        remote_path,
-        printer_model=printer.model,
-        expected_size=expected_size,
-    )
+    try:
+        timelapse_data = await download_file_bytes_async(
+            printer.ip_address,
+            printer.access_code,
+            remote_path,
+            printer_model=printer.model,
+            expected_size=expected_size,
+        )
+    except DownloadDeadlineExceeded:
+        # Too slow for its size this round; the printer keeps its copy.
+        timelapse_data = None
     if not timelapse_data:
         # Short or failed transfer. The printer keeps its copy, so the next
         # round can try again — which is exactly why the delete below is

+ 120 - 10
backend/app/services/bambu_ftp.py

@@ -82,17 +82,21 @@ _DOWNLOAD_FLOOR_BYTES_PER_SEC = 25 * 1024
 _DOWNLOAD_MAX_TIMEOUT = 300.0
 
 
-def _download_extension(size: int | None, base_timeout: float) -> float:
+def _download_extension(size: int | None, base_timeout: float, ceiling: float = _DOWNLOAD_MAX_TIMEOUT) -> float:
     """Extra seconds to allow a download the printer says is this big.
 
     Zero when the size is unknown -- no SIZE reply means no transfer got under
     way, so the base deadline stands and a printer that is not answering still
     fails on schedule. Zero, too, once the base deadline is already the more
     generous of the two: this only ever lengthens a deadline.
+
+    ``ceiling`` caps the whole deadline. It defaults to the 300 s print start
+    can afford while it holds a database session; a caller that holds none and
+    has its own budget (the printer file browser) passes that instead.
     """
     if not size or size <= 0:
         return 0.0
-    return max(0.0, min(size / _DOWNLOAD_FLOOR_BYTES_PER_SEC, _DOWNLOAD_MAX_TIMEOUT) - base_timeout)
+    return max(0.0, min(size / _DOWNLOAD_FLOOR_BYTES_PER_SEC, ceiling) - base_timeout)
 
 
 # How long a download will wait for another one on the same printer to finish
@@ -889,7 +893,14 @@ class BambuFTPClient:
 
         return files
 
-    def download_file(self, remote_path: str, expected_size: int | None = None) -> bytes | None:
+    def download_file(
+        self,
+        remote_path: str,
+        expected_size: int | None = None,
+        cancel_event: threading.Event | None = None,
+        size_callback: Callable[[int], None] | None = None,
+        report_missing: bool = False,
+    ) -> bytes | None:
         """Download a file from the printer.
 
         ``expected_size`` is the byte count the directory listing reported for
@@ -902,14 +913,45 @@ class BambuFTPClient:
 
         A zero-byte result is always treated as a failure, matching
         :meth:`download_to_file` — no caller has a use for an empty file.
+
+        ``size_callback`` is handed the size the printer reports (SIZE) before
+        the transfer starts, for a caller that sets its deadline from it.
+        ``report_missing`` raises :class:`FileNotOnPrinterError` when RETR is
+        answered 550, as :meth:`download_to_file` does, instead of returning
+        None: every other caller reads a failed download as None.
         """
         if not self._ftp:
             return None
 
+        if size_callback is not None:
+            # Only a hint for the deadline: a SIZE refusal (missing command, a
+            # file still being written) is left to RETR to judge.
+            try:
+                server_size = self._ftp.size(remote_path)
+            except (OSError, ftplib.Error):
+                server_size = None
+            if server_size is not None and server_size > 0:
+                size_callback(server_size)
+
+        buffer = BytesIO()
+
+        def _write(block: bytes) -> None:
+            # Checked as data arrives: ``download_file_bytes_async`` sets it at
+            # its deadline, so the transfer stops instead of running on in its
+            # worker thread beside whatever the caller does next.
+            if cancel_event is not None and cancel_event.is_set():
+                raise DownloadCancelled(remote_path)
+            buffer.write(block)
+
         try:
-            buffer = BytesIO()
-            self._ftp.retrbinary(f"RETR {remote_path}", buffer.write)
+            self._ftp.retrbinary(f"RETR {remote_path}", _write)
             data = buffer.getvalue()
+        except DownloadCancelled:
+            raise
+        except ftplib.error_perm as exc:
+            if report_missing and str(exc).startswith("550"):
+                raise FileNotOnPrinterError(f"{remote_path}: {exc}") from exc
+            return None
         except (OSError, ftplib.Error):
             return None
 
@@ -1616,6 +1658,7 @@ async def download_file_async(
     cancel_event: threading.Event | None = None,
     min_free_bytes: int | None = None,
     serialize: bool = True,
+    max_total_timeout: float = _DOWNLOAD_MAX_TIMEOUT,
 ) -> bool:
     """Async wrapper for downloading a file with timeout.
 
@@ -1637,6 +1680,9 @@ async def download_file_async(
         timeout: Overall operation timeout (asyncio)
         socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
         printer_model: Printer model for A1-specific workarounds
+        max_total_timeout: the most the size-based extension may stretch the
+            deadline to (see ``_download_extension``). The default suits print
+            start; the printer file browser passes its own job budget.
         serialize: take this printer's download gate for the transfer (#2957).
             Pass False from a path that must neither queue behind another
             download nor make one queue behind it -- the printer file browser
@@ -1720,7 +1766,7 @@ async def download_file_async(
                 # the printer has told us, so give a transfer that is genuinely
                 # under way the time that size needs (#2957). Re-raises into the
                 # handler below when the size is unknown or already covered.
-                extension = _download_extension(completion.get("size"), timeout)
+                extension = _download_extension(completion.get("size"), timeout, max_total_timeout)
                 if extension <= 0:
                     raise
                 logger.info(
@@ -2273,6 +2319,8 @@ async def download_file_bytes_async(
     printer_model: str | None = None,
     timeout: float = 300.0,
     expected_size: int | None = None,
+    report_missing: bool = False,
+    max_total_timeout: float = _DOWNLOAD_MAX_TIMEOUT,
 ) -> bytes | None:
     """Async wrapper for downloading file as bytes.
 
@@ -2288,6 +2336,17 @@ async def download_file_bytes_async(
         expected_size: size from the directory listing; a mismatch fails the
             download instead of returning a truncated file. See
             :meth:`BambuFTPClient.download_file`.
+        report_missing: raise :class:`FileNotOnPrinterError` for a path the
+            printer says is not there, so a caller can answer 404 for that
+            and 502 for a download that failed.
+        max_total_timeout: without ``expected_size``, the size the printer
+            reports when the transfer starts stretches the deadline up to this
+            (see ``_download_extension``).
+
+    At the deadline the transfer is stopped and its thread waited for (bounded
+    by the socket timeout), so a retry never opens a second session beside it.
+    A deadline that fitted the file's size raises
+    :class:`DownloadDeadlineExceeded`, which ``with_ftp_retry`` does not retry.
     """
     # Unlike ``_download_extension`` there is no 300 s ceiling here: that one
     # exists because ``on_print_start`` holds a DB session across its 3MF hunt.
@@ -2295,26 +2354,77 @@ async def download_file_bytes_async(
     if expected_size and expected_size > 0:
         timeout = max(timeout, expected_size / _DOWNLOAD_FLOOR_BYTES_PER_SEC)
     loop = asyncio.get_event_loop()
+    cancel = threading.Event()
+    reported: dict[str, int] = {}
+    sized = bool(expected_size and expected_size > 0)
 
     def _download():
+        if cancel.is_set():
+            return None
         client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if client.connect():
             try:
-                return client.download_file(remote_path, expected_size=expected_size)
+                return client.download_file(
+                    remote_path,
+                    expected_size=expected_size,
+                    cancel_event=cancel,
+                    size_callback=None if sized else (lambda n: reported.__setitem__("size", n)),
+                    report_missing=report_missing,
+                )
             finally:
                 client.disconnect()
         return None
 
+    async def _stop(worker) -> bytes | None:
+        """Tell the transfer to stop and wait for its thread to end.
+
+        Waited for unconditionally, like ``download_file_async``: a thread left
+        running would hold its FTP session while a retry opens another. The
+        wait is bounded by the socket timeout (a blocked read, then the QUIT).
+        A result that arrives meanwhile is kept; anything it raises is not the
+        caller's concern any more.
+        """
+        cancel.set()
+        try:
+            return await asyncio.shield(worker)
+        except Exception:
+            return None
+
+    worker = loop.run_in_executor(_ftp_executor, _download)
+    allowed = timeout
     try:
-        return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _download), timeout=timeout)
+        try:
+            return await asyncio.wait_for(asyncio.shield(worker), timeout=timeout)
+        except TimeoutError:
+            # No listing size: the size the printer reported when the transfer
+            # started buys a slow link its time, as in download_file_async.
+            extension = 0.0 if sized else _download_extension(reported.get("size"), timeout, max_total_timeout)
+            if extension <= 0:
+                raise
+            allowed = timeout + extension
+            sized = True
+            return await asyncio.wait_for(asyncio.shield(worker), timeout=extension)
+    except asyncio.CancelledError:
+        await asyncio.shield(_stop(worker))
+        raise
+    except DownloadCancelled:
+        return None
     except TimeoutError:
+        data = await _stop(worker)
+        if data:
+            # Finished inside the grace period: keep it.
+            return data
         logger.warning(
             "FTP download of %s (%s bytes) exceeded its %.0fs cap for %s",
             remote_path,
-            expected_size if expected_size else "unknown",
-            timeout,
+            expected_size or reported.get("size") or "unknown",
+            allowed,
             ip_address,
         )
+        if sized:
+            # The deadline already fitted the file at a floor rate no working
+            # link falls below: another attempt would reach the same end.
+            raise DownloadDeadlineExceeded(remote_path) from None
         return None
 
 

+ 28 - 3
backend/app/services/printer_media.py

@@ -20,6 +20,7 @@ from backend.app.core.config import settings
 from backend.app.core.tasks import spawn_background_task
 from backend.app.services.bambu_ftp import (
     DownloadCancelled,
+    DownloadDeadlineExceeded,
     DownloadInsufficientSpace,
     DownloadLimitExceeded,
     download_file_async,
@@ -40,6 +41,14 @@ _cleanup_task: asyncio.Task | None = None
 _CLEANUP_INTERVAL_SECONDS = 15 * 60
 
 
+class PrinterFileDownloadError(Exception):
+    """The file is on the printer but could not be downloaded from it.
+
+    Not FileNotFoundError: the routes answered that with 404 "File not found"
+    for a file the browser had just listed.
+    """
+
+
 class PrinterFilesZipTooLargeError(ValueError):
     """The selected printer files exceed the bounded ZIP staging limit."""
 
@@ -378,6 +387,10 @@ async def build_printer_files_zip(
                         staged_path,
                         timeout=600,
                         socket_timeout=60,
+                        # The job's budget, not print start's 300 s: a 250 MB
+                        # /ipcam chunk at a few hundred KB/s needs ~15 minutes,
+                        # and a dead link still ends at the socket timeout.
+                        max_total_timeout=MAX_PRINTER_ZIP_PREPARE_SECONDS,
                         printer_model=printer.model,
                         expected_size=expected_size,
                         max_bytes=MAX_PRINTER_ZIP_BYTES - total_bytes,
@@ -510,6 +523,7 @@ async def build_printer_file(
             local_path,
             timeout=600,
             socket_timeout=60,
+            max_total_timeout=MAX_PRINTER_ZIP_PREPARE_SECONDS,
             printer_model=printer.model,
             expected_size=expected_size,
             max_bytes=MAX_PRINTER_ZIP_BYTES,
@@ -520,7 +534,7 @@ async def build_printer_file(
             serialize=False,
         )
         if not downloaded:
-            raise FileNotFoundError("The selected printer file could not be downloaded")
+            raise PrinterFileDownloadError("The selected printer file could not be downloaded")
         file_size = (await asyncio.to_thread(local_path.stat)).st_size
         return PrinterFilesZipResult(
             path=local_path,
@@ -529,6 +543,12 @@ async def build_printer_file(
             failed_paths=(),
             total_bytes=file_size,
         )
+    except DownloadDeadlineExceeded as exc:
+        await asyncio.shield(asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True))
+        raise TimeoutError(f"Downloading {remote_path} from the printer took longer than its size allows") from exc
+    except PrinterFileDownloadError:
+        await asyncio.shield(asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True))
+        raise
     except DownloadLimitExceeded as exc:
         await asyncio.shield(asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True))
         raise PrinterFilesZipTooLargeError(f"Downloaded file exceeds the {MAX_PRINTER_ZIP_BYTES}-byte limit") from exc
@@ -630,7 +650,7 @@ async def _run_printer_files_job(
             _write_job_status,
             PrinterFilesJobStatus(job_id, printer.id, "failed", len(paths), filename=filename, message=str(exc)),
         )
-    except TimeoutError:
+    except TimeoutError as exc:
         await asyncio.to_thread(
             _write_job_status,
             PrinterFilesJobStatus(
@@ -639,9 +659,14 @@ async def _run_printer_files_job(
                 "failed",
                 len(paths),
                 filename=filename,
-                message="Printer download preparation exceeded the 30-minute limit",
+                message=str(exc) or "Printer download preparation exceeded the 30-minute limit",
             ),
         )
+    except PrinterFileDownloadError as exc:
+        await asyncio.to_thread(
+            _write_job_status,
+            PrinterFilesJobStatus(job_id, printer.id, "failed", len(paths), filename=filename, message=str(exc)),
+        )
     except FileNotFoundError as exc:
         await asyncio.to_thread(
             _write_job_status,

+ 120 - 0
backend/tests/integration/test_printer_file_download_errors.py

@@ -0,0 +1,120 @@
+"""A printer file that could not be fetched is not reported as missing (TO-C2, TO-C3, TO-C1).
+
+The file browser's previews (G-code, plates, plate thumbnails) and its single
+file download answered every failure with 404 "File not found", including a
+timeout on a file the browser had just listed. They now answer 404 only when
+the printer says the path is not there, 502 when the download failed, and 504
+when it ran out of the time its size allows. The timelapse scan answered a
+failed download with 500; it now gives 502, and 504 for a deadline.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.bambu_ftp import DownloadDeadlineExceeded, FileNotOnPrinterError
+from backend.app.services.printer_media import PrinterFileDownloadError
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+PREVIEWS = [
+    ("/files/gcode", {"path": "/cache/part.gcode"}),
+    ("/files/plates", {"path": "/cache/part.3mf"}),
+    ("/files/plate-thumbnail/1", {"path": "/cache/part.3mf"}),
+]
+
+
+@pytest.mark.parametrize(("route", "params"), PREVIEWS)
+@pytest.mark.parametrize(
+    ("outcome", "status"),
+    [
+        (FileNotOnPrinterError("/cache/part"), 404),
+        (None, 502),
+        (DownloadDeadlineExceeded("/cache/part"), 504),
+    ],
+)
+async def test_a_preview_says_what_went_wrong(
+    async_client: AsyncClient, printer_factory, route, params, outcome, status
+):
+    printer = await printer_factory()
+    download = AsyncMock(side_effect=outcome) if isinstance(outcome, Exception) else AsyncMock(return_value=outcome)
+
+    with patch("backend.app.api.routes.printers.download_file_bytes_async", download):
+        resp = await async_client.get(f"/api/v1/printers/{printer.id}{route}", params=params)
+
+    assert resp.status_code == status, resp.text
+    # One FTP session per preview: the plate picker asks once per plate.
+    assert download.await_count == 1
+    assert download.await_args.kwargs["report_missing"] is True
+    # The file browser's budget: with print start's 300 s ceiling a slow file
+    # would get no more than the flat base deadline.
+    from backend.app.services.printer_media import MAX_PRINTER_ZIP_PREPARE_SECONDS
+
+    assert download.await_args.kwargs["max_total_timeout"] == MAX_PRINTER_ZIP_PREPARE_SECONDS
+
+
+@pytest.mark.parametrize(
+    ("raised", "status"),
+    [
+        (PrinterFileDownloadError("could not be downloaded"), 502),
+        (TimeoutError("took longer than its size allows"), 504),
+        (FileNotFoundError("Invalid printer file path"), 404),
+    ],
+)
+async def test_the_file_download_says_what_went_wrong(async_client: AsyncClient, printer_factory, raised, status):
+    printer = await printer_factory()
+
+    with patch("backend.app.api.routes.printers.build_printer_file", AsyncMock(side_effect=raised)):
+        resp = await async_client.get(f"/api/v1/printers/{printer.id}/files/download", params={"path": "/ipcam/a.mp4"})
+
+    assert resp.status_code == status, resp.text
+
+
+@pytest.mark.parametrize(
+    ("outcome", "status"),
+    [(None, 502), (DownloadDeadlineExceeded("/timelapse/t.mp4"), 504)],
+)
+async def test_a_timelapse_scan_says_what_went_wrong(
+    async_client: AsyncClient, archive_factory, printer_factory, outcome, status
+):
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id, filename="t.gcode.3mf")
+    listing = [
+        {"name": "t.gcode.mp4", "path": "/timelapse/t.gcode.mp4", "is_directory": False, "size": 10, "mtime": None}
+    ]
+    download = AsyncMock(side_effect=outcome) if isinstance(outcome, Exception) else AsyncMock(return_value=outcome)
+
+    with (
+        patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=listing)),
+        patch("backend.app.services.bambu_ftp.get_ftp_retry_settings", AsyncMock(return_value=(True, 3, 0.0, 30.0))),
+        patch("backend.app.services.bambu_ftp.download_file_bytes_async", download),
+    ):
+        resp = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
+
+    assert resp.status_code == status, resp.text
+    if status == 504:
+        assert download.await_count == 1  # a size-fitted deadline is not retried
+
+
+async def test_an_automatic_timelapse_attach_survives_a_deadline():
+    """The background attach treats it as a failed round: the printer keeps its copy."""
+    import logging
+
+    from backend.app import main
+
+    printer = MagicMock(ip_address="10.0.0.5", access_code="c", model="P1S")
+    download = AsyncMock(side_effect=DownloadDeadlineExceeded("/timelapse/new.mp4"))
+    with patch("backend.app.services.bambu_ftp.download_file_bytes_async", download):
+        result = await main._attach_first_unclaimed_timelapse(
+            1,
+            printer,
+            [{"name": "new.mp4", "path": "/timelapse/new.mp4", "size": 10}],
+            set(),
+            set(),
+            1,
+            logging.getLogger(__name__),
+        )
+
+    assert result is False
+    download.assert_awaited_once()

+ 3 - 2
backend/tests/unit/services/test_ftp_bytes_download_deadline_3272.py

@@ -74,7 +74,8 @@ async def test_cap_log_names_file_and_size(caplog):
         patch.object(bambu_ftp, "BambuFTPClient", return_value=client),
         patch.object(bambu_ftp.asyncio, "wait_for", expire),
         caplog.at_level(logging.WARNING, logger=bambu_ftp.logger.name),
+        # A deadline that fitted the file's size is not retried (TO-C1).
+        pytest.raises(bambu_ftp.DownloadDeadlineExceeded),
     ):
-        result = await bambu_ftp.download_file_bytes_async(IP, "12345678", "/timelapse/a.avi", expected_size=75_647_046)
-    assert result is None
+        await bambu_ftp.download_file_bytes_async(IP, "12345678", "/timelapse/a.avi", expected_size=75_647_046)
     assert "/timelapse/a.avi (75647046 bytes) exceeded its 2955s cap" in caplog.text

+ 230 - 0
backend/tests/unit/services/test_ftp_download_deadlines.py

@@ -0,0 +1,230 @@
+"""FTP downloads stop at their deadline, and the deadline fits the file (TO-C1, TO-C3).
+
+The bytes download used to give up on its caller at the deadline and leave the
+transfer running in its worker thread, and ``with_ftp_retry`` read that as a
+retryable failure, so each retry opened another FTP session alongside the
+first. The printer file download was held at a flat 600 s whatever the size:
+the size-based extension was capped at 300 s in total, below that base.
+"""
+
+import asyncio
+import threading
+from pathlib import Path
+
+import pytest
+
+from backend.app.services import bambu_ftp, printer_media
+from backend.app.services.bambu_ftp import DownloadDeadlineExceeded
+
+
+class _StuckClient:
+    """A printer that keeps a transfer going until it is told to stop."""
+
+    instances: list["_StuckClient"] = []
+
+    def __init__(self, *_args, **_kwargs) -> None:
+        self.cancelled = threading.Event()
+        self.finished = threading.Event()
+        _StuckClient.instances.append(self)
+
+    def connect(self) -> bool:
+        return True
+
+    def disconnect(self) -> None:
+        pass
+
+    def download_file(self, _remote_path, expected_size=None, cancel_event=None, **_kwargs):
+        try:
+            while cancel_event is None or not cancel_event.is_set():
+                if self.cancelled.wait(0.01):
+                    break
+            self.cancelled.set()
+            return None
+        finally:
+            self.finished.set()
+
+
+@pytest.fixture
+def stuck(monkeypatch):
+    _StuckClient.instances = []
+    monkeypatch.setattr(bambu_ftp, "BambuFTPClient", _StuckClient)
+    return _StuckClient.instances
+
+
+@pytest.mark.asyncio
+async def test_a_sized_download_past_its_deadline_stops_and_says_so(stuck):
+    with pytest.raises(DownloadDeadlineExceeded):
+        await bambu_ftp.download_file_bytes_async("10.0.0.5", "code", "/timelapse/v.mp4", timeout=0.1, expected_size=10)
+
+    # The transfer was stopped before the call returned, not left running.
+    assert stuck[0].cancelled.is_set() and stuck[0].finished.is_set()
+
+
+@pytest.mark.asyncio
+async def test_an_unsized_download_past_its_deadline_stops_and_fails(stuck):
+    result = await bambu_ftp.download_file_bytes_async("10.0.0.5", "code", "/model.3mf", timeout=0.1)
+
+    assert result is None
+    assert stuck[0].cancelled.is_set() and stuck[0].finished.is_set()
+
+
+@pytest.mark.asyncio
+async def test_a_retry_never_starts_a_second_session_beside_the_first(stuck):
+    with pytest.raises(DownloadDeadlineExceeded):
+        await bambu_ftp.with_ftp_retry(
+            bambu_ftp.download_file_bytes_async,
+            "10.0.0.5",
+            "code",
+            "/timelapse/v.mp4",
+            timeout=0.1,
+            expected_size=10,
+            max_retries=3,
+            retry_delay=0,
+            operation_name="test",
+        )
+
+    assert len(stuck) == 1
+
+
+@pytest.mark.asyncio
+async def test_a_cancelled_caller_stops_the_transfer(stuck):
+    task = asyncio.create_task(bambu_ftp.download_file_bytes_async("10.0.0.5", "code", "/model.3mf", timeout=30))
+    await asyncio.sleep(0.05)
+    task.cancel()
+    with pytest.raises(asyncio.CancelledError):
+        await task
+
+    assert stuck[0].cancelled.is_set() and stuck[0].finished.is_set()
+
+
+@pytest.mark.asyncio
+async def test_a_download_that_finishes_returns_its_bytes(monkeypatch):
+    class _Client(_StuckClient):
+        def download_file(self, _remote_path, expected_size=None, cancel_event=None, **_kwargs):
+            return b"video"
+
+    monkeypatch.setattr(bambu_ftp, "BambuFTPClient", _Client)
+
+    assert await bambu_ftp.download_file_bytes_async("10.0.0.5", "code", "/v.mp4", expected_size=5) == b"video"
+
+
+@pytest.mark.asyncio
+async def test_an_unlisted_download_gets_the_time_its_reported_size_buys(monkeypatch):
+    """No listing size: the SIZE the printer reports at the start extends the
+    deadline, as for download_file_async (a 60 MB 3MF on an A1)."""
+
+    class _Slow(_StuckClient):
+        def download_file(self, _remote_path, expected_size=None, cancel_event=None, size_callback=None, **_kwargs):
+            size_callback(10 * 1024 * 1024)
+            if self.cancelled.wait(0.3) or (cancel_event and cancel_event.is_set()):
+                return None
+            return b"3mf"
+
+    monkeypatch.setattr(bambu_ftp, "BambuFTPClient", _Slow)
+
+    assert await bambu_ftp.download_file_bytes_async("10.0.0.5", "code", "/model.3mf", timeout=0.1) == b"3mf"
+
+
+def _client_with(error: Exception) -> bambu_ftp.BambuFTPClient:
+    from unittest.mock import MagicMock
+
+    client = bambu_ftp.BambuFTPClient("10.0.0.5", "code")
+    client._ftp = MagicMock()
+    client._ftp.retrbinary.side_effect = error
+    client._ftp.size.side_effect = error
+    return client
+
+
+def test_a_missing_path_is_told_apart_when_asked():
+    import ftplib
+
+    missing = ftplib.error_perm("550 No such file")
+    with pytest.raises(bambu_ftp.FileNotOnPrinterError):
+        _client_with(missing).download_file("/gone.3mf", report_missing=True)
+    with pytest.raises(bambu_ftp.FileNotOnPrinterError):
+        _client_with(missing).download_file("/gone.3mf", size_callback=lambda _n: None, report_missing=True)
+    # Every other caller still reads it as a failed download.
+    assert _client_with(missing).download_file("/gone.3mf") is None
+
+
+def test_other_ftp_errors_are_failures_not_missing():
+    import ftplib
+
+    assert _client_with(ftplib.error_perm("530 Not logged in")).download_file("/x", report_missing=True) is None
+    assert _client_with(OSError("reset")).download_file("/x", report_missing=True) is None
+
+
+def test_the_size_extension_can_reach_past_the_print_start_ceiling():
+    size = 250 * 1024 * 1024  # a 250 MB /ipcam chunk
+    assert bambu_ftp._download_extension(size, 600) == 0.0  # print start keeps its 300 s ceiling
+    assert bambu_ftp._download_extension(size, 600, ceiling=1800) == pytest.approx(1200)
+    # A preview of a 60 MB 3MF at the default 300 s base, with the file browser's budget.
+    assert bambu_ftp._download_extension(60 * 1024 * 1024, 300, ceiling=1800) == pytest.approx(1500)
+
+
+@pytest.mark.asyncio
+async def test_a_transfer_that_does_not_stop_at_once_is_still_waited_for(monkeypatch):
+    """A read blocked on the socket only notices the cancel when it returns:
+    the call must not come back (and a retry must not start) before that."""
+
+    class _Deaf(_StuckClient):
+        def download_file(self, _remote_path, expected_size=None, cancel_event=None, **_kwargs):
+            try:
+                self.cancelled.wait(0.8)  # ignores the cancel until the "socket" returns
+                return None
+            finally:
+                self.finished.set()
+
+    monkeypatch.setattr(bambu_ftp, "BambuFTPClient", _Deaf)
+    _StuckClient.instances = []
+
+    with pytest.raises(DownloadDeadlineExceeded):
+        await bambu_ftp.download_file_bytes_async("10.0.0.5", "code", "/v.mp4", timeout=0.1, expected_size=10)
+
+    assert _StuckClient.instances[0].finished.is_set()
+
+
+@pytest.mark.asyncio
+async def test_a_printer_file_download_may_use_the_whole_job_budget(monkeypatch, tmp_path):
+    seen = {}
+
+    async def fake_download(*_args, **kwargs):
+        seen.update(kwargs)
+        return False
+
+    monkeypatch.setattr(printer_media, "download_file_async", fake_download)
+    monkeypatch.setattr(printer_media, "_ensure_printer_zip_root", lambda: tmp_path)
+
+    printer = type("P", (), {"id": 1, "ip_address": "10.0.0.5", "access_code": "c", "model": "P1S"})()
+    with pytest.raises(printer_media.PrinterFileDownloadError):
+        await printer_media.build_printer_file(printer, "/ipcam/clip.mp4", 250 * 1024 * 1024, bundle_key="k1")
+
+    assert seen["max_total_timeout"] == printer_media.MAX_PRINTER_ZIP_PREPARE_SECONDS
+
+
+@pytest.mark.asyncio
+async def test_a_printer_file_past_its_deadline_is_a_timeout(monkeypatch, tmp_path):
+    async def fake_download(*_args, **_kwargs):
+        raise DownloadDeadlineExceeded("/ipcam/clip.mp4")
+
+    monkeypatch.setattr(printer_media, "download_file_async", fake_download)
+    monkeypatch.setattr(printer_media, "_ensure_printer_zip_root", lambda: tmp_path)
+
+    printer = type("P", (), {"id": 1, "ip_address": "10.0.0.5", "access_code": "c", "model": "P1S"})()
+    with pytest.raises(TimeoutError):
+        await printer_media.build_printer_file(printer, "/ipcam/clip.mp4", 1000, bundle_key="k2")
+    assert not (Path(tmp_path) / "k2").exists()
+
+
+def test_a_size_refusal_is_left_to_retr():
+    """A 550 to SIZE (no such command, a file still being written) does not
+    make a file missing that RETR then serves."""
+    import ftplib
+    from unittest.mock import MagicMock
+
+    client = bambu_ftp.BambuFTPClient("10.0.0.5", "code")
+    client._ftp = MagicMock()
+    client._ftp.size.side_effect = ftplib.error_perm("550 Could not get file size")
+    client._ftp.retrbinary.side_effect = lambda _cmd, write: write(b"3mf-bytes")
+
+    assert client.download_file("/model.3mf", size_callback=lambda _n: None, report_missing=True) == b"3mf-bytes"

+ 1 - 1
backend/tests/unit/test_download_deadline_and_gate_2957.py

@@ -130,7 +130,7 @@ class TestASlowTransferSurvivesItsDeadline:
 
         with (
             patch.object(ftp_mod, "BambuFTPClient", _Client),
-            patch.object(ftp_mod, "_download_extension", lambda size, base: 0.2 if size else 0.0),
+            patch.object(ftp_mod, "_download_extension", lambda size, base, ceiling=300.0: 0.2 if size else 0.0),
             pytest.raises(ftp_mod.DownloadDeadlineExceeded),
         ):
             await ftp_mod.with_ftp_retry(