Browse Source

Timelapse scans stop knocking on a hung printer file service

- Baseline reads the card in one walk instead of two, and stops at
  the first listing that times out
- Re-read runs only while the print records a timelapse, doubles its
  wait after each failed round (max 30 min) and stops after five
- A connect or listing timeout pauses the background timelapse scans
  of that printer for 5 min; file browsing and dispatch are not held
- A listing that completes lifts that pause, a bare connect does not
- "421 too many connections" is its own failure, and the queue tells
  the user to restart the printer if it does not clear
maziggy 10 hours ago
parent
commit
36991dadc4

+ 56 - 17
backend/app/main.py

@@ -6687,11 +6687,18 @@ _TIMELAPSE_DIRECTORIES = ("/timelapse", "/timelapse/video", "/record", "/recordi
 # A baseline that could not be read at print start is read again on this
 # A baseline that could not be read at print start is read again on this
 # schedule while the print runs (#3412). The printer writes the video only once
 # schedule while the print runs (#3412). The printer writes the video only once
 # the print has ended, so a reading taken mid-print is as good as one taken at
 # the print has ended, so a reading taken mid-print is as good as one taken at
-# the start. The interval is shorter than the FTPS handshake cool-off (300 s),
-# which is what usually makes the card unreadable, so the first reading after
-# it ends is not long behind; while the cool-off runs a round costs nothing, as
-# no connection is opened. The cap bounds a print that never reports an end.
+# the start. The interval is shorter than the FTPS cool-offs (300 s), which are
+# what usually makes the card unreadable, so the first reading after one ends
+# is not long behind; while a cool-off runs a round costs nothing, as no
+# connection is opened. The cap bounds a print that never reports an end.
+#
+# A round that could not read the card doubles the wait before the next, up to
+# the ceiling, and after that many failed rounds in a row the re-read gives up
+# and leaves the baseline unread for the completion scan to handle (#3474). Each
+# connection to a hung file service can leave a session behind on the printer.
 _TIMELAPSE_BASELINE_REREAD_INTERVAL_SECONDS = 120.0
 _TIMELAPSE_BASELINE_REREAD_INTERVAL_SECONDS = 120.0
+_TIMELAPSE_BASELINE_REREAD_MAX_INTERVAL_SECONDS = 1800.0
+_TIMELAPSE_BASELINE_REREAD_MAX_FAILED_ROUNDS = 5
 _TIMELAPSE_BASELINE_REREAD_MAX_ATTEMPTS = 720
 _TIMELAPSE_BASELINE_REREAD_MAX_ATTEMPTS = 720
 
 
 # Poll schedule for the post-print timelapse scan (#2704). Module-level so
 # Poll schedule for the post-print timelapse scan (#2704). Module-level so
@@ -6738,16 +6745,18 @@ async def _read_timelapse_baseline(printer) -> tuple[set[str], bool]:
     "the card held nothing" for a card that was never read means every video on
     "the card held nothing" for a card that was never read means every video on
     it counts as new, and the completion scan then attaches a stale video to
     it counts as new, and the completion scan then attaches a stale video to
     this print and deletes it from the printer (#2957 follow-up, #3412). So an
     this print and deletes it from the printer (#2957 follow-up, #3412). So an
-    empty listing is asked again here in a way that tells the two apart: the
     card counts as read only when every directory walked answered, with a
     card counts as read only when every directory walked answered, with a
     listing or with "no such directory". One that failed may be the one
     listing or with "no such directory". One that failed may be the one
     holding the videos.
     holding the videos.
-    """
-    from backend.app.services.bambu_ftp import ftps_handshake_blocked, list_files_result_async
 
 
-    files, _ = await _list_timelapse_videos(printer)
-    if files:
-        return {f.get("name", "") for f in files}, True
+    One walk, one connection per directory. It used to list the card with
+    ``_list_timelapse_videos`` first and walk the same directories again when
+    that came back empty, which against a hung file service was eight dead
+    connections a round instead of one (#3474). A directory that fails does
+    not end the walk unless the failure put the printer in a cool-off: a later
+    one holding videos still answers the question.
+    """
+    from backend.app.services.bambu_ftp import ftps_background_blocked, list_files_result_async
 
 
     ip_address = getattr(printer, "ip_address", None)
     ip_address = getattr(printer, "ip_address", None)
     printer_id = getattr(printer, "id", None)
     printer_id = getattr(printer, "id", None)
@@ -6757,15 +6766,17 @@ async def _read_timelapse_baseline(printer) -> tuple[set[str], bool]:
     if printer_id is not None and not external_storage_present(printer_manager.get_status(printer_id)):
     if printer_id is not None and not external_storage_present(printer_manager.get_status(printer_id)):
         return set(), True
         return set(), True
 
 
+    read = True
     for timelapse_path in _TIMELAPSE_DIRECTORIES:
     for timelapse_path in _TIMELAPSE_DIRECTORIES:
-        if ftps_handshake_blocked(ip_address):
+        if ftps_background_blocked(ip_address):
             # Below any path: every remaining one would fail the same way.
             # Below any path: every remaining one would fail the same way.
             return set(), False
             return set(), False
         listing = await list_files_result_async(
         listing = await list_files_result_async(
             ip_address, printer.access_code, timelapse_path, printer_model=printer.model
             ip_address, printer.access_code, timelapse_path, printer_model=printer.model
         )
         )
         if not (listing.available or listing.missing):
         if not (listing.available or listing.missing):
-            return set(), False
+            read = False
+            continue
         videos = [
         videos = [
             f
             f
             for f in listing.files
             for f in listing.files
@@ -6773,7 +6784,7 @@ async def _read_timelapse_baseline(printer) -> tuple[set[str], bool]:
         ]
         ]
         if videos:
         if videos:
             return {f.get("name", "") for f in videos}, True
             return {f.get("name", "") for f in videos}, True
-    return set(), True
+    return set(), read
 
 
 
 
 async def _list_timelapse_videos(printer) -> tuple[list[dict], str | None]:
 async def _list_timelapse_videos(printer) -> tuple[list[dict], str | None]:
@@ -6787,7 +6798,7 @@ async def _list_timelapse_videos(printer) -> tuple[list[dict], str | None]:
     card" — see :func:`_read_timelapse_baseline`, which the two baseline
     card" — see :func:`_read_timelapse_baseline`, which the two baseline
     callers use instead.
     callers use instead.
     """
     """
-    from backend.app.services.bambu_ftp import list_files_async
+    from backend.app.services.bambu_ftp import ftps_background_blocked, list_files_async
 
 
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
 
 
@@ -6805,6 +6816,11 @@ async def _list_timelapse_videos(printer) -> tuple[list[dict], str | None]:
         return [], None
         return [], None
 
 
     for timelapse_path in _TIMELAPSE_DIRECTORIES:
     for timelapse_path in _TIMELAPSE_DIRECTORIES:
+        if ftps_background_blocked(getattr(printer, "ip_address", None) or ""):
+            # The printer's file service is refusing or hung: every remaining
+            # path is another connection it may keep a session for (#3474).
+            logger.debug("[TIMELAPSE] Skipping the scan for printer %s: its file service is cooling off", printer_id)
+            return [], None
         try:
         try:
             found_files = await list_files_async(
             found_files = await list_files_async(
                 printer.ip_address, printer.access_code, timelapse_path, printer_model=printer.model
                 printer.ip_address, printer.access_code, timelapse_path, printer_model=printer.model
@@ -6957,13 +6973,20 @@ def _unread_baseline_round(printer_id: int, names: set[str]) -> bool | None:
     it and the next print records a new one, so identity marks the run. A
     it and the next print records a new one, so identity marks the run. A
     printer that is momentarily disconnected says nothing about its print, so
     printer that is momentarily disconnected says nothing about its print, so
     that only skips the round.
     that only skips the round.
+
+    So does a print that is not recording a timelapse: completion only looks
+    for a video when one was recorded, so reading the card for it is a
+    connection for nothing (#3474). Skipped rather than ended, as the flag can
+    arrive after the print has started.
     """
     """
     if _timelapse_baselines.get(printer_id) is not names or printer_id not in _timelapse_baselines_unread:
     if _timelapse_baselines.get(printer_id) is not names or printer_id not in _timelapse_baselines_unread:
         return False
         return False
     state = printer_manager.get_status(printer_id)
     state = printer_manager.get_status(printer_id)
     if state is None or not state.connected:
     if state is None or not state.connected:
         return None
         return None
-    return state.state in _BASELINE_REREAD_STATES
+    if state.state not in _BASELINE_REREAD_STATES:
+        return False
+    return True if state.timelapse else None
 
 
 
 
 async def _reread_unread_timelapse_baseline(
 async def _reread_unread_timelapse_baseline(
@@ -6980,14 +7003,20 @@ async def _reread_unread_timelapse_baseline(
     start. Each round stops once the print is no longer the one the baseline
     start. Each round stops once the print is no longer the one the baseline
     was taken for or has stopped printing, and checks that again after the
     was taken for or has stopped printing, and checks that again after the
     listing returns, before anything is recorded.
     listing returns, before anything is recorded.
+
+    A round that cannot read the card doubles the wait before the next one,
+    and enough of them in a row end the re-read (#3474): see
+    ``_TIMELAPSE_BASELINE_REREAD_MAX_FAILED_ROUNDS``.
     """
     """
     from backend.app.models.archive import PrintArchive
     from backend.app.models.archive import PrintArchive
     from backend.app.models.printer import Printer
     from backend.app.models.printer import Printer
     from backend.app.services.print_log import run_of
     from backend.app.services.print_log import run_of
 
 
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
+    interval = _TIMELAPSE_BASELINE_REREAD_INTERVAL_SECONDS
+    failed_rounds = 0
     for _ in range(_TIMELAPSE_BASELINE_REREAD_MAX_ATTEMPTS):
     for _ in range(_TIMELAPSE_BASELINE_REREAD_MAX_ATTEMPTS):
-        await asyncio.sleep(_TIMELAPSE_BASELINE_REREAD_INTERVAL_SECONDS)
+        await asyncio.sleep(interval)
         now = _unread_baseline_round(printer_id, names)
         now = _unread_baseline_round(printer_id, names)
         if now is False:
         if now is False:
             return
             return
@@ -7001,8 +7030,18 @@ async def _reread_unread_timelapse_baseline(
             fresh, read = await _read_timelapse_baseline(printer)
             fresh, read = await _read_timelapse_baseline(printer)
         except Exception as e:
         except Exception as e:
             logger.debug("[TIMELAPSE] Re-reading the baseline for printer %s failed: %s", printer_id, e)
             logger.debug("[TIMELAPSE] Re-reading the baseline for printer %s failed: %s", printer_id, e)
-            continue
+            fresh, read = set(), False
         if not read:
         if not read:
+            failed_rounds += 1
+            if failed_rounds >= _TIMELAPSE_BASELINE_REREAD_MAX_FAILED_ROUNDS:
+                logger.warning(
+                    "[TIMELAPSE] Card of printer %s still unreadable after %s tries; no longer reading "
+                    "the baseline for this print, it stays unread",
+                    printer_id,
+                    failed_rounds,
+                )
+                return
+            interval = min(interval * 2, _TIMELAPSE_BASELINE_REREAD_MAX_INTERVAL_SECONDS)
             continue
             continue
         now = _unread_baseline_round(printer_id, names)
         now = _unread_baseline_round(printer_id, names)
         if now is False:
         if now is False:

+ 112 - 17
backend/app/services/bambu_ftp.py

@@ -343,6 +343,45 @@ class DeleteResult(Enum):
 # times a minute.
 # times a minute.
 _HANDSHAKE_COOLOFF_SECONDS = 300.0
 _HANDSHAKE_COOLOFF_SECONDS = 300.0
 
 
+# A printer whose file service accepted a connection and then went silent --
+# a TLS handshake or a listing that ran out its timeout -- is left alone by
+# the background timelapse scans for this long (#3474). Only those scans read
+# this gate: a user browsing files or a dispatch still connects, and any
+# listing that completes lifts it. A connect alone does not: the reporter's
+# printer connected fine and then never answered LIST. Each connection to a hung vsFTPd can leave a
+# session behind on the printer, and enough of them make it answer every
+# connection with "421 too many connections" until it is restarted.
+_STALL_COOLOFF_SECONDS = 300.0
+
+# Printers in that cool-off, mapped to when it ends. Module-level rather than
+# on the client: the async wrappers record a stall from outside any client.
+_stalled_until: dict[str, float] = {}
+
+
+def _ftps_stalled(ip_address: str) -> bool:
+    """True while *ip_address*'s file service is in its stall cool-off (#3474)."""
+    deadline = _stalled_until.get(ip_address)
+    if deadline is None:
+        return False
+    if time.monotonic() >= deadline:
+        # pop, not del: executor threads write this dict too.
+        _stalled_until.pop(ip_address, None)
+        return False
+    return True
+
+
+def _record_ftps_stall(ip_address: str, what: str) -> None:
+    """Open the stall cool-off: the printer accepted a connection and then went silent."""
+    if not _ftps_stalled(ip_address):
+        logger.warning(
+            "Printer %s stopped answering on its file service (%s). Background timelapse scans "
+            "leave it alone for %.0fs.",
+            ip_address,
+            what,
+            _STALL_COOLOFF_SECONDS,
+        )
+    _stalled_until[ip_address] = time.monotonic() + _STALL_COOLOFF_SECONDS
+
 
 
 # How long to wait for a printer to say something in cleartext on the TLS port.
 # How long to wait for a printer to say something in cleartext on the TLS port.
 # The failing case answers immediately -- the banner is the first thing a
 # The failing case answers immediately -- the banner is the first thing a
@@ -351,6 +390,11 @@ _HANDSHAKE_COOLOFF_SECONDS = 300.0
 _CLEARTEXT_PROBE_TIMEOUT = 2.0
 _CLEARTEXT_PROBE_TIMEOUT = 2.0
 
 
 
 
+def _is_too_many_connections(reply: str) -> bool:
+    """vsFTPd's per-address session limit: ``421 There are too many connections ...``."""
+    return reply.startswith("421") and "too many" in reply.lower()
+
+
 def _read_cleartext_reply(ip_address: str, port: int) -> str | None:
 def _read_cleartext_reply(ip_address: str, port: int) -> str | None:
     """Read what a printer answers the TLS port with, when it is not TLS.
     """Read what a printer answers the TLS port with, when it is not TLS.
 
 
@@ -427,6 +471,9 @@ class FtpFailureKind(Enum):
 
 
     COOLOFF = "cooloff"  # skipped without contacting the printer (#2780)
     COOLOFF = "cooloff"  # skipped without contacting the printer (#2780)
     HANDSHAKE = "handshake"  # port 990 answered with something that is not TLS
     HANDSHAKE = "handshake"  # port 990 answered with something that is not TLS
+    # Port 990 answered in cleartext "421 ... too many connections": the
+    # printer has no FTP session left for Bambuddy's address (#3474).
+    SESSIONS = "sessions"
     AUTH = "auth"  # permanent refusal, typically a rejected access code
     AUTH = "auth"  # permanent refusal, typically a rejected access code
     TIMEOUT = "timeout"
     TIMEOUT = "timeout"
     STORAGE = "storage"  # 553/552 -- the case the SD-card advice was written for
     STORAGE = "storage"  # 553/552 -- the case the SD-card advice was written for
@@ -569,6 +616,11 @@ class BambuFTPClient:
     # logged for, so the warning is said once per cool-off. See ``connect``.
     # logged for, so the warning is said once per cool-off. See ``connect``.
     _handshake_skip_logged: dict[str, float] = {}
     _handshake_skip_logged: dict[str, float] = {}
 
 
+    # What each printer answered port 990 with in cleartext, kept for its
+    # handshake cool-off: only the first failure in a window probes, and the
+    # attempts after it still need to know it was a 421 (#3474).
+    _cleartext_reply: dict[str, str] = {}
+
     def __init__(
     def __init__(
         self,
         self,
         ip_address: str,
         ip_address: str,
@@ -649,9 +701,14 @@ class BambuFTPClient:
             # this process has ever failed against.
             # this process has ever failed against.
             del cls._handshake_blocked_until[ip_address]
             del cls._handshake_blocked_until[ip_address]
             cls._handshake_skip_logged.pop(ip_address, None)
             cls._handshake_skip_logged.pop(ip_address, None)
+            cls._cleartext_reply.pop(ip_address, None)
             return False
             return False
         return True
         return True
 
 
+    def _record_stall(self, what: str) -> None:
+        if self.records_handshake_cooloff:
+            _record_ftps_stall(self.ip_address, what)
+
     def connect(self) -> bool:
     def connect(self) -> bool:
         """Connect to the printer FTP server (implicit FTPS on port 990).
         """Connect to the printer FTP server (implicit FTPS on port 990).
 
 
@@ -737,6 +794,7 @@ class BambuFTPClient:
             logger.warning("FTP connection timed out to %s: %s", self.ip_address, e)
             logger.warning("FTP connection timed out to %s: %s", self.ip_address, e)
             self.last_failure = FtpFailure(FtpFailureKind.TIMEOUT, str(e))
             self.last_failure = FtpFailure(FtpFailureKind.TIMEOUT, str(e))
             self._abandon_connection("connect timed out")
             self._abandon_connection("connect timed out")
+            self._record_stall("connect timed out")
             return False
             return False
         except ssl.SSLError as e:
         except ssl.SSLError as e:
             # Not a transient failure and not something another path or another
             # Not a transient failure and not something another path or another
@@ -774,27 +832,36 @@ class BambuFTPClient:
             # asked -- which keeps a dispatch that ignores the cool-off from
             # asked -- which keeps a dispatch that ignores the cool-off from
             # probing on each of its four attempts.
             # probing on each of its four attempts.
             detail = str(e)
             detail = str(e)
-            if getattr(e, "reason", None) == "WRONG_VERSION_NUMBER" and not self.handshake_blocked(self.ip_address):
-                reply = _read_cleartext_reply(self.ip_address, self.FTP_PORT)
+            reply: str | None = None
+            if getattr(e, "reason", None) == "WRONG_VERSION_NUMBER":
+                if not self.handshake_blocked(self.ip_address):
+                    reply = _read_cleartext_reply(self.ip_address, self.FTP_PORT)
+                    if reply:
+                        logger.warning(
+                            "Printer %s answered port %s in cleartext with: %s — that is what the TLS "
+                            "handshake read as a malformed record. Please include this line if you report it.",
+                            self.ip_address,
+                            self.FTP_PORT,
+                            reply,
+                        )
+                        self._cleartext_reply[self.ip_address] = reply
+                    else:
+                        logger.warning(
+                            "Printer %s sent nothing readable in cleartext on port %s, so its file service "
+                            "was speaking TLS again by the time we asked — the refusal was momentary.",
+                            self.ip_address,
+                            self.FTP_PORT,
+                        )
+                        self._cleartext_reply.pop(self.ip_address, None)
+                else:
+                    # Asked already in this window; what it said then still holds.
+                    reply = self._cleartext_reply.get(self.ip_address)
                 if reply:
                 if reply:
-                    logger.warning(
-                        "Printer %s answered port %s in cleartext with: %s — that is what the TLS "
-                        "handshake read as a malformed record. Please include this line if you report it.",
-                        self.ip_address,
-                        self.FTP_PORT,
-                        reply,
-                    )
                     detail = f"{e} (printer answered in cleartext: {reply})"
                     detail = f"{e} (printer answered in cleartext: {reply})"
-                else:
-                    logger.warning(
-                        "Printer %s sent nothing readable in cleartext on port %s, so its file service "
-                        "was speaking TLS again by the time we asked — the refusal was momentary.",
-                        self.ip_address,
-                        self.FTP_PORT,
-                    )
             if self.records_handshake_cooloff:
             if self.records_handshake_cooloff:
                 self._handshake_blocked_until[self.ip_address] = time.monotonic() + _HANDSHAKE_COOLOFF_SECONDS
                 self._handshake_blocked_until[self.ip_address] = time.monotonic() + _HANDSHAKE_COOLOFF_SECONDS
-            self.last_failure = FtpFailure(FtpFailureKind.HANDSHAKE, detail)
+            kind = FtpFailureKind.SESSIONS if reply and _is_too_many_connections(reply) else FtpFailureKind.HANDSHAKE
+            self.last_failure = FtpFailure(kind, detail, "421" if kind is FtpFailureKind.SESSIONS else None)
             return False
             return False
         except (OSError, ftplib.Error) as e:
         except (OSError, ftplib.Error) as e:
             logger.warning("FTP connection failed to %s: %s (type: %s)", self.ip_address, e, type(e).__name__)
             logger.warning("FTP connection failed to %s: %s (type: %s)", self.ip_address, e, type(e).__name__)
@@ -909,8 +976,12 @@ class BambuFTPClient:
                         file_entry["mtime"] = mtime
                         file_entry["mtime"] = mtime
                     files.append(file_entry)
                     files.append(file_entry)
             logger.debug("Listed %s files in %s", len(files), path)
             logger.debug("Listed %s files in %s", len(files), path)
+            # It answers again, so the background scans may ask again.
+            _stalled_until.pop(self.ip_address, None)
         except (OSError, ftplib.Error) as e:
         except (OSError, ftplib.Error) as e:
             logger.info("FTP list_files failed for %s: %s", path, e)
             logger.info("FTP list_files failed for %s: %s", path, e)
+            if isinstance(e, TimeoutError):
+                self._record_stall(f"listing {path} timed out")
             if raise_on_error:
             if raise_on_error:
                 raise
                 raise
 
 
@@ -1456,6 +1527,12 @@ def describe_upload_failure(failure: FtpFailure | None) -> str:
             f"The printer refused to store the file ({failure.code or 'storage error'}). Check that its SD card "
             f"The printer refused to store the file ({failure.code or 'storage error'}). Check that its SD card "
             "or USB drive is inserted, has space free, and is formatted FAT32 or exFAT."
             "or USB drive is inserted, has space free, and is formatted FAT32 or exFAT."
         )
         )
+    if failure.kind is FtpFailureKind.SESSIONS:
+        return (
+            "The printer refused the connection: it has no file-transfer sessions left for Bambuddy "
+            "(421 too many connections). Printers have been seen to keep this state until they are "
+            "restarted, so if it does not clear within a few minutes, restart the printer."
+        )
     if failure.kind is FtpFailureKind.HANDSHAKE:
     if failure.kind is FtpFailureKind.HANDSHAKE:
         return (
         return (
             "The printer's file service answered, but not with TLS, so no file could be sent to it. "
             "The printer's file service answered, but not with TLS, so no file could be sent to it. "
@@ -1488,6 +1565,16 @@ def describe_upload_failure(failure: FtpFailure | None) -> str:
     )
     )
 
 
 
 
+def ftps_background_blocked(ip_address: str) -> bool:
+    """Whether a background timelapse scan should leave this printer alone.
+
+    The handshake cool-off, or the printer's file service having gone silent
+    on a connection it accepted (#3474). Only for unattended scans: a user
+    browsing files is not held off by a stall.
+    """
+    return BambuFTPClient.handshake_blocked(ip_address) or _ftps_stalled(ip_address)
+
+
 def ftps_handshake_blocked(ip_address: str) -> bool:
 def ftps_handshake_blocked(ip_address: str) -> bool:
     """True while this printer's FTPS handshake cool-off is still running.
     """True while this printer's FTPS handshake cool-off is still running.
 
 
@@ -2155,6 +2242,10 @@ async def list_files_async(
         return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _list), timeout=timeout)
         return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _list), timeout=timeout)
     except TimeoutError:
     except TimeoutError:
         logger.warning("FTP list_files timed out after %ss for %s", timeout, path)
         logger.warning("FTP list_files timed out after %ss for %s", timeout, path)
+        # Here, not when the worker's own socket gives up: the two timeouts
+        # are the same length and this one starts first, so a caller walking
+        # several directories would open the next connection in between.
+        _record_ftps_stall(ip_address, f"listing {path} timed out")
         return []
         return []
 
 
 
 
@@ -2206,6 +2297,10 @@ async def list_files_result_async(
         result = await asyncio.wait_for(asyncio.shield(future), timeout=timeout)
         result = await asyncio.wait_for(asyncio.shield(future), timeout=timeout)
     except TimeoutError:
     except TimeoutError:
         logger.warning("FTP list_files timed out after %ss for %s", timeout, path)
         logger.warning("FTP list_files timed out after %ss for %s", timeout, path)
+        # Here, not when the worker's own socket gives up: the two timeouts
+        # are the same length and this one starts first, so a caller walking
+        # several directories would open the next connection in between.
+        _record_ftps_stall(ip_address, f"listing {path} timed out")
         return FileListResult(files=[], available=False, timed_out=connected.is_set())
         return FileListResult(files=[], available=False, timed_out=connected.is_set())
     # Joined callers share the listing; each gets entries of its own to edit.
     # Joined callers share the listing; each gets entries of its own to edit.
     return FileListResult(files=[dict(f) for f in result.files], available=result.available, missing=result.missing)
     return FileListResult(files=[dict(f) for f in result.files], available=result.available, missing=result.missing)

+ 8 - 1
backend/app/services/print_scheduler.py

@@ -391,6 +391,7 @@ DISPATCH_MAX_ATTEMPTS = 3
 _UPLOAD_REQUEUE_KINDS: frozenset[FtpFailureKind] = frozenset(
 _UPLOAD_REQUEUE_KINDS: frozenset[FtpFailureKind] = frozenset(
     {
     {
         FtpFailureKind.HANDSHAKE,
         FtpFailureKind.HANDSHAKE,
+        FtpFailureKind.SESSIONS,
         FtpFailureKind.COOLOFF,
         FtpFailureKind.COOLOFF,
         FtpFailureKind.TIMEOUT,
         FtpFailureKind.TIMEOUT,
         FtpFailureKind.NETWORK,
         FtpFailureKind.NETWORK,
@@ -404,7 +405,8 @@ def _refused_upload_hold_reason(printer_label: str, kind: FtpFailureKind | None)
     """What a job waiting on a printer that refused its last upload shows.
     """What a job waiting on a printer that refused its last upload shows.
 
 
     Storage and the access code need the user to do something, so they say
     Storage and the access code need the user to do something, so they say
-    what; the rest clear on their own.
+    what, and so does a printer out of FTP sessions: those have been seen to
+    stay until the printer restarts (#3474). The rest clear on their own.
     """
     """
     if kind is FtpFailureKind.STORAGE:
     if kind is FtpFailureKind.STORAGE:
         return f"{printer_label} refused to store the file — check its SD card or USB drive. {_REFUSED_UPLOAD_TAIL}"
         return f"{printer_label} refused to store the file — check its SD card or USB drive. {_REFUSED_UPLOAD_TAIL}"
@@ -413,6 +415,11 @@ def _refused_upload_hold_reason(printer_label: str, kind: FtpFailureKind | None)
             f"{printer_label} refused the file transfer — check its access code on the Printers page. "
             f"{printer_label} refused the file transfer — check its access code on the Printers page. "
             f"{_REFUSED_UPLOAD_TAIL}"
             f"{_REFUSED_UPLOAD_TAIL}"
         )
         )
+    if kind is FtpFailureKind.SESSIONS:
+        return (
+            f"{printer_label} has no file-transfer sessions left (421 too many connections) — "
+            f"if this does not clear within a few minutes, restart the printer. {_REFUSED_UPLOAD_TAIL}"
+        )
     return f"{printer_label} is not accepting files — {_REFUSED_UPLOAD_TAIL}"
     return f"{printer_label} is not accepting files — {_REFUSED_UPLOAD_TAIL}"
 
 
 
 

+ 16 - 0
backend/tests/conftest.py

@@ -284,6 +284,22 @@ def reset_usage_tracker_sessions():
     _active_sessions.clear()
     _active_sessions.clear()
 
 
 
 
+@pytest.fixture(autouse=True)
+def reset_ftp_stall_gate():
+    """Drop the FTP client's per-printer stall cool-off and cleartext replies.
+
+    Class-level dicts keyed by IP, and most tests share one printer IP: a
+    listing that timed out in one test would keep the next one's timelapse
+    scan from connecting at all (#3474)."""
+    from backend.app.services.bambu_ftp import BambuFTPClient, _stalled_until
+
+    _stalled_until.clear()
+    BambuFTPClient._cleartext_reply.clear()
+    yield
+    _stalled_until.clear()
+    BambuFTPClient._cleartext_reply.clear()
+
+
 @pytest.fixture(autouse=True)
 @pytest.fixture(autouse=True)
 def reset_auth_enabled_cache():
 def reset_auth_enabled_cache():
     """Drop the module-level auth-enabled cache between tests (issue #2572).
     """Drop the module-level auth-enabled cache between tests (issue #2572).

+ 10 - 0
backend/tests/unit/services/test_upload_failure_reason_2899.py

@@ -274,12 +274,22 @@ class TestTheWording:
 
 
         The reporter of this issue restarted a printer on the strength of the
         The reporter of this issue restarted a printer on the strength of the
         user-facing string, so the string has to carry the same restraint.
         user-facing string, so the string has to carry the same restraint.
+
+        Except where the printer said why: "421 too many connections" stayed
+        until the printer was restarted, and nothing else cleared it (#3474).
         """
         """
         for kind in FtpFailureKind:
         for kind in FtpFailureKind:
+            if kind is FtpFailureKind.SESSIONS:
+                continue
             message = describe_upload_failure(FtpFailure(kind, "detail"))
             message = describe_upload_failure(FtpFailure(kind, "detail"))
             assert "restart the printer" not in message.lower(), kind
             assert "restart the printer" not in message.lower(), kind
             assert "reboot" not in message.lower(), kind
             assert "reboot" not in message.lower(), kind
 
 
+    def test_a_printer_out_of_sessions_is_told_to_be_restarted_if_it_stays(self):
+        message = describe_upload_failure(FtpFailure(FtpFailureKind.SESSIONS, "421 There are too many", "421"))
+        assert "421" in message
+        assert "restart the printer" in message.lower()
+
     def test_an_unclassified_failure_points_at_the_log_rather_than_guessing(self):
     def test_an_unclassified_failure_points_at_the_log_rather_than_guessing(self):
         for failure in (None, FtpFailure(FtpFailureKind.UNKNOWN, "500 what")):
         for failure in (None, FtpFailure(FtpFailureKind.UNKNOWN, "500 what")):
             message = describe_upload_failure(failure)
             message = describe_upload_failure(failure)

+ 12 - 3
backend/tests/unit/test_archive_filtering.py

@@ -323,7 +323,10 @@ class TestScanForTimelapseWithRetries:
         with (
         with (
             patch("backend.app.main.async_session", return_value=mock_session),
             patch("backend.app.main.async_session", return_value=mock_session),
             patch("backend.app.main._list_timelapse_videos", side_effect=mock_list_mp4s),
             patch("backend.app.main._list_timelapse_videos", side_effect=mock_list_mp4s),
-            patch(f"{_FTP_MODULE}.list_files_result_async", AsyncMock(return_value=FileListResult([], True))),
+            # The baseline is one walk of its own (#3474), so it reads the card here.
+            patch(
+                f"{_FTP_MODULE}.list_files_result_async", AsyncMock(return_value=FileListResult(baseline_files, True))
+            ),
             patch("backend.app.main.ws_manager") as mock_ws,
             patch("backend.app.main.ws_manager") as mock_ws,
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
@@ -369,7 +372,10 @@ class TestScanForTimelapseWithRetries:
         with (
         with (
             patch("backend.app.main.async_session", return_value=mock_session),
             patch("backend.app.main.async_session", return_value=mock_session),
             patch("backend.app.main._list_timelapse_videos", side_effect=mock_list_mp4s),
             patch("backend.app.main._list_timelapse_videos", side_effect=mock_list_mp4s),
-            patch(f"{_FTP_MODULE}.list_files_result_async", AsyncMock(return_value=FileListResult([], True))),
+            # The baseline is one walk of its own (#3474), so it reads the card here.
+            patch(
+                f"{_FTP_MODULE}.list_files_result_async", AsyncMock(return_value=FileListResult(baseline_files, True))
+            ),
             patch("backend.app.main.ws_manager") as mock_ws,
             patch("backend.app.main.ws_manager") as mock_ws,
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
@@ -422,7 +428,10 @@ class TestScanForTimelapseWithRetries:
         with (
         with (
             patch("backend.app.main.async_session", return_value=mock_session),
             patch("backend.app.main.async_session", return_value=mock_session),
             patch("backend.app.main._list_timelapse_videos", side_effect=mock_list_mp4s),
             patch("backend.app.main._list_timelapse_videos", side_effect=mock_list_mp4s),
-            patch(f"{_FTP_MODULE}.list_files_result_async", AsyncMock(return_value=FileListResult([], True))),
+            # The baseline is one walk of its own (#3474), so it reads the card here.
+            patch(
+                f"{_FTP_MODULE}.list_files_result_async", AsyncMock(return_value=FileListResult(baseline_files, True))
+            ),
             patch("backend.app.main.ws_manager") as mock_ws,
             patch("backend.app.main.ws_manager") as mock_ws,
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
             patch("backend.app.main.ArchiveService", return_value=mock_service),

+ 7 - 0
backend/tests/unit/test_fallback_timelapse_baseline_2957.py

@@ -110,7 +110,14 @@ def _patches(main_module, maker, monkeypatch, tmp_path, listing):
             return []
             return []
         return listing()
         return listing()
 
 
+    async def _list_result(ip, code, path, timeout=30.0, socket_timeout=None, printer_model=None):
+        # The baseline reads the card with this one (#3474).
+        if path != "/timelapse":
+            return bambu_ftp.FileListResult(files=[], available=False, missing=True)
+        return bambu_ftp.FileListResult(files=listing(), available=True)
+
     monkeypatch.setattr(bambu_ftp, "list_files_async", _list)
     monkeypatch.setattr(bambu_ftp, "list_files_async", _list)
+    monkeypatch.setattr(bambu_ftp, "list_files_result_async", _list_result)
     monkeypatch.setattr(bambu_ftp, "download_file_bytes_async", AsyncMock(return_value=b"x" * 1024))
     monkeypatch.setattr(bambu_ftp, "download_file_bytes_async", AsyncMock(return_value=b"x" * 1024))
     monkeypatch.setattr(bambu_ftp, "remote_file_settled", AsyncMock(return_value=True))
     monkeypatch.setattr(bambu_ftp, "remote_file_settled", AsyncMock(return_value=True))
     monkeypatch.setattr(bambu_ftp, "delete_archived_timelapse", AsyncMock(return_value=True))
     monkeypatch.setattr(bambu_ftp, "delete_archived_timelapse", AsyncMock(return_value=True))

+ 3 - 2
backend/tests/unit/test_print_start_assigns_printer_id_to_vp_archive.py

@@ -25,6 +25,7 @@ from backend.app.main import (
     _timelapse_baselines,
     _timelapse_baselines,
     register_expected_print,
     register_expected_print,
 )
 )
+from backend.app.services.bambu_ftp import FileListResult
 
 
 
 
 @pytest.fixture(autouse=True)
 @pytest.fixture(autouse=True)
@@ -286,8 +287,8 @@ async def test_expected_archive_path_captures_timelapse_baseline():
         patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
         patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
         patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
         patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
         patch(
         patch(
-            "backend.app.main._list_timelapse_videos",
-            new=AsyncMock(return_value=(existing_videos, "/timelapse")),
+            "backend.app.services.bambu_ftp.list_files_result_async",
+            new=AsyncMock(return_value=FileListResult(existing_videos, True)),
         ),
         ),
     ):
     ):
         mock_session_maker.return_value = mock_session
         mock_session_maker.return_value = mock_session

+ 5 - 4
backend/tests/unit/test_timelapse_baseline_restart_recovery.py

@@ -19,6 +19,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
 import pytest
 import pytest
 
 
 from backend.app.main import _timelapse_baselines
 from backend.app.main import _timelapse_baselines
+from backend.app.services.bambu_ftp import FileListResult
 
 
 
 
 @pytest.fixture(autouse=True)
 @pytest.fixture(autouse=True)
@@ -59,8 +60,8 @@ async def test_running_observed_captures_baseline_on_restart_recovery():
     with (
     with (
         patch("backend.app.main.async_session") as mock_session_maker,
         patch("backend.app.main.async_session") as mock_session_maker,
         patch(
         patch(
-            "backend.app.main._list_timelapse_videos",
-            new=AsyncMock(return_value=(existing_videos, "/timelapse")),
+            "backend.app.services.bambu_ftp.list_files_result_async",
+            new=AsyncMock(return_value=FileListResult(existing_videos, True)),
         ),
         ),
     ):
     ):
         mock_session_maker.return_value = mock_session
         mock_session_maker.return_value = mock_session
@@ -104,7 +105,7 @@ async def test_running_observed_skips_when_baseline_already_present():
 
 
     with (
     with (
         patch("backend.app.main.async_session") as mock_session_maker,
         patch("backend.app.main.async_session") as mock_session_maker,
-        patch("backend.app.main._list_timelapse_videos", new=AsyncMock()) as mock_list,
+        patch("backend.app.main._read_timelapse_baseline", new=AsyncMock()) as mock_list,
     ):
     ):
         from backend.app.main import on_print_running_observed
         from backend.app.main import on_print_running_observed
 
 
@@ -139,7 +140,7 @@ async def test_running_observed_skips_when_printer_row_missing():
 
 
     with (
     with (
         patch("backend.app.main.async_session") as mock_session_maker,
         patch("backend.app.main.async_session") as mock_session_maker,
-        patch("backend.app.main._list_timelapse_videos", new=AsyncMock()) as mock_list,
+        patch("backend.app.main._read_timelapse_baseline", new=AsyncMock()) as mock_list,
     ):
     ):
         mock_session_maker.return_value = mock_session
         mock_session_maker.return_value = mock_session
 
 

+ 513 - 0
backend/tests/unit/test_timelapse_ftp_stall_3474.py

@@ -0,0 +1,513 @@
+"""A hung printer file service is not asked again and again (#3474).
+
+At the start of one print an H2S's FTPS service stopped answering. The
+print-start baseline came back unread, and the re-read (#3412) then read the
+card every 120 s for the whole print: two walks of four directories a round,
+each a new connection that hung for 30 s. The printer kept a session for every
+one of them and ended up answering every connection with "421 There are too
+many connections from your internet address", which no upload got past until
+the printer was restarted.
+
+Pinned here:
+- a connect or listing that times out opens a cool-off for the background
+  timelapse scans only, which a listing that completes lifts;
+- the baseline reads the card in one walk, and stops at the cool-off;
+- the re-read only runs while the print records a timelapse, backs off on
+  failure, and gives up after a few failed rounds;
+- a 421 "too many connections" is its own failure, and the queue says so.
+"""
+
+import asyncio
+import ssl
+from contextlib import asynccontextmanager
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services import bambu_ftp
+from backend.app.services.bambu_ftp import (
+    BambuFTPClient,
+    FileListResult,
+    FtpFailureKind,
+    ftps_background_blocked,
+    ftps_handshake_blocked,
+)
+
+pytestmark = pytest.mark.unit
+
+IP = "192.168.50.77"
+TOO_MANY = "421 There are too many connections from your internet address."
+
+
+@pytest.fixture(autouse=True)
+def _clean_state():
+    BambuFTPClient._handshake_blocked_until.clear()
+    BambuFTPClient._handshake_skip_logged.clear()
+    BambuFTPClient._mode_cache.clear()
+    # Listings joined by printer and folder: a hung one left by a test would be
+    # joined by the next, on a loop that has since closed.
+    bambu_ftp._inflight_listings.clear()
+    yield
+    BambuFTPClient._handshake_blocked_until.clear()
+    BambuFTPClient._handshake_skip_logged.clear()
+    BambuFTPClient._mode_cache.clear()
+    bambu_ftp._inflight_listings.clear()
+
+
+def _connect_failing_with(error, **client_kwargs) -> BambuFTPClient:
+    transport = MagicMock()
+    transport.connect.side_effect = error
+    with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=transport):
+        client = BambuFTPClient(IP, "12345678", printer_model="H2S", **client_kwargs)
+        assert client.connect() is False
+    return client
+
+
+def _wrong_version() -> ssl.SSLError:
+    error = ssl.SSLError("[SSL: WRONG_VERSION_NUMBER] wrong version number")
+    error.reason = "WRONG_VERSION_NUMBER"
+    return error
+
+
+# ---------------------------------------------------------------------------
+# The stall cool-off
+# ---------------------------------------------------------------------------
+class TestATimeoutHoldsOffTheBackgroundScans:
+    def test_a_connect_timeout_opens_it(self):
+        _connect_failing_with(TimeoutError("The handshake operation timed out"))
+
+        assert ftps_background_blocked(IP)
+
+    def test_it_does_not_hold_off_anyone_else(self):
+        """The File Manager and dispatch read the handshake gate, which a
+        timeout leaves alone: one slow handshake must not lock a user out of
+        browsing the printer for five minutes."""
+        _connect_failing_with(TimeoutError("The handshake operation timed out"))
+
+        assert not ftps_handshake_blocked(IP)
+        transport = MagicMock()
+        with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=transport):
+            assert BambuFTPClient(IP, "12345678").connect() is True
+        transport.connect.assert_called_once()
+
+    def test_a_completed_listing_lifts_it(self):
+        _connect_failing_with(TimeoutError("The handshake operation timed out"))
+        client = BambuFTPClient(IP, "12345678")
+        client._ftp = MagicMock()
+
+        client.list_files("/timelapse")
+
+        assert not ftps_background_blocked(IP)
+
+    def test_a_connect_alone_does_not(self):
+        """The reporter's printer connected fine and then never answered LIST:
+        lifting the gate on connect let every next directory through again."""
+        _connect_failing_with(TimeoutError("The handshake operation timed out"))
+
+        with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=MagicMock()):
+            assert BambuFTPClient(IP, "12345678").connect() is True
+
+        assert ftps_background_blocked(IP)
+
+    def test_it_ends_on_its_own(self, monkeypatch):
+        monkeypatch.setattr(bambu_ftp, "_STALL_COOLOFF_SECONDS", 0.0)
+        _connect_failing_with(TimeoutError("The handshake operation timed out"))
+
+        assert not ftps_background_blocked(IP)
+
+    def test_a_side_check_does_not_open_it(self):
+        """``records_handshake_cooloff=False`` is for a check whose failure
+        says nothing about the printer's file service at large."""
+        _connect_failing_with(TimeoutError("timed out"), records_handshake_cooloff=False)
+
+        assert not ftps_background_blocked(IP)
+
+    def test_other_connect_failures_do_not_open_it(self):
+        _connect_failing_with(OSError("Connection refused"))
+
+        assert not ftps_background_blocked(IP)
+
+    def test_a_listing_that_times_out_on_its_socket_opens_it(self):
+        client = BambuFTPClient(IP, "12345678")
+        client._ftp = MagicMock()
+        client._ftp.retrlines.side_effect = TimeoutError("timed out")
+
+        assert client.list_files("/timelapse") == []
+        assert ftps_background_blocked(IP)
+
+    def test_a_listing_that_fails_otherwise_does_not(self):
+        client = BambuFTPClient(IP, "12345678")
+        client._ftp = MagicMock()
+        client._ftp.cwd.side_effect = bambu_ftp.ftplib.error_perm("550 Failed to change directory.")
+
+        assert client.list_files("/record") == []
+        assert not ftps_background_blocked(IP)
+
+    async def test_a_listing_that_connected_and_then_hung_opens_it(self, monkeypatch):
+        """The reporter's first failure: connected, then LIST /timelapse never
+        came back. The worker thread is still inside it when the wait ends."""
+        release = asyncio.Event()
+        loop = asyncio.get_running_loop()
+
+        class HungClient:
+            def __init__(self, *args, **kwargs):
+                pass
+
+            def connect(self):
+                return True
+
+            def list_files(self, path, raise_on_error=False):
+                asyncio.run_coroutine_threadsafe(release.wait(), loop).result(5)
+                return []
+
+            def disconnect(self):
+                pass
+
+        monkeypatch.setattr(bambu_ftp, "BambuFTPClient", HungClient)
+        try:
+            result = await bambu_ftp.list_files_result_async(IP, "code", "/timelapse", timeout=0.1)
+        finally:
+            release.set()
+
+        assert result.timed_out
+        assert bambu_ftp._ftps_stalled(IP)
+
+    async def test_a_listing_still_connecting_when_the_wait_ends_opens_it(self, monkeypatch):
+        """A hung TLS handshake: the wait and the worker's socket timeout are
+        the same length and the wait starts first, so leaving it to ``connect``
+        let the next directory open a connection before the gate was set."""
+        release = asyncio.Event()
+        loop = asyncio.get_running_loop()
+
+        class SlowConnect:
+            def __init__(self, *args, **kwargs):
+                pass
+
+            def connect(self):
+                asyncio.run_coroutine_threadsafe(release.wait(), loop).result(5)
+                return False
+
+        monkeypatch.setattr(bambu_ftp, "BambuFTPClient", SlowConnect)
+        try:
+            result = await bambu_ftp.list_files_result_async(IP, "code", "/timelapse", timeout=0.1)
+        finally:
+            release.set()
+
+        assert not result.timed_out
+        assert bambu_ftp._ftps_stalled(IP)
+
+    async def test_the_plain_listing_opens_it_when_its_wait_ends(self, monkeypatch):
+        """The completion scan's listing has no "connected" signal at all."""
+        release = asyncio.Event()
+        loop = asyncio.get_running_loop()
+
+        class HungClient:
+            def __init__(self, *args, **kwargs):
+                pass
+
+            def connect(self):
+                asyncio.run_coroutine_threadsafe(release.wait(), loop).result(5)
+                return False
+
+        monkeypatch.setattr(bambu_ftp, "BambuFTPClient", HungClient)
+        try:
+            assert await bambu_ftp.list_files_async(IP, "code", "/timelapse", timeout=0.1) == []
+        finally:
+            release.set()
+
+        assert bambu_ftp._ftps_stalled(IP)
+
+    async def test_a_hung_walk_opens_one_connection(self, monkeypatch):
+        """End to end through the real wrapper: the baseline walk stops after
+        the first directory whose wait ran out."""
+        from backend.app import main
+
+        release = asyncio.Event()
+        loop = asyncio.get_running_loop()
+        opened: list[str] = []
+
+        class HungClient:
+            handshake_blocked = BambuFTPClient.handshake_blocked
+
+            def __init__(self, *args, **kwargs):
+                pass
+
+            def connect(self):
+                opened.append("connect")
+                asyncio.run_coroutine_threadsafe(release.wait(), loop).result(5)
+                return False
+
+        real = bambu_ftp.list_files_result_async
+
+        async def _short(ip, code, path, timeout=30.0, socket_timeout=None, printer_model=None):
+            return await real(ip, code, path, timeout=0.1, printer_model=printer_model)
+
+        monkeypatch.setattr(bambu_ftp, "BambuFTPClient", HungClient)
+        monkeypatch.setattr(bambu_ftp, "list_files_result_async", _short)
+        try:
+            assert await main._read_timelapse_baseline(_printer()) == (set(), False)
+        finally:
+            release.set()
+
+        assert opened == ["connect"]
+
+
+# ---------------------------------------------------------------------------
+# One walk of the card
+# ---------------------------------------------------------------------------
+def _printer():
+    # No id: skips the external-storage check, which needs a live status.
+    return SimpleNamespace(id=None, ip_address=IP, access_code="12345678", model="H2S")
+
+
+def _video(name):
+    return {"name": name, "is_directory": False, "size": 10, "path": f"/x/{name}"}
+
+
+class TestTheBaselineIsOneWalk:
+    async def test_a_hung_listing_ends_the_round_after_one_connection(self, monkeypatch):
+        from backend.app import main
+
+        asked: list[str] = []
+
+        async def _list_result(ip, code, path, timeout=30.0, socket_timeout=None, printer_model=None):
+            asked.append(path)
+            bambu_ftp._record_ftps_stall(ip, f"listing {path} timed out")
+            return FileListResult(files=[], available=False, timed_out=True)
+
+        other_walk = AsyncMock(return_value=[])
+        monkeypatch.setattr(bambu_ftp, "list_files_result_async", _list_result)
+        monkeypatch.setattr(bambu_ftp, "list_files_async", other_walk)
+
+        assert await main._read_timelapse_baseline(_printer()) == (set(), False)
+        assert asked == ["/timelapse"]
+        other_walk.assert_not_called()
+
+    async def test_nothing_is_asked_while_the_printer_cools_off(self, monkeypatch):
+        from backend.app import main
+
+        bambu_ftp._record_ftps_stall(IP, "test")
+        listing = AsyncMock()
+        monkeypatch.setattr(bambu_ftp, "list_files_result_async", listing)
+
+        assert await main._read_timelapse_baseline(_printer()) == (set(), False)
+        listing.assert_not_called()
+
+    async def test_a_card_without_videos_is_walked_once(self, monkeypatch):
+        """It used to be walked twice: once by the video listing, and again by
+        the walk that tells "empty" from "unreadable"."""
+        from backend.app import main
+
+        asked: list[str] = []
+
+        async def _list_result(ip, code, path, timeout=30.0, socket_timeout=None, printer_model=None):
+            asked.append(path)
+            if path == "/timelapse":
+                return FileListResult(files=[], available=True)
+            return FileListResult(files=[], available=False, missing=True)
+
+        monkeypatch.setattr(bambu_ftp, "list_files_result_async", _list_result)
+        monkeypatch.setattr(bambu_ftp, "list_files_async", AsyncMock(side_effect=AssertionError("second walk")))
+
+        assert await main._read_timelapse_baseline(_printer()) == (set(), True)
+        assert asked == list(main._TIMELAPSE_DIRECTORIES)
+
+    async def test_videos_behind_a_failed_directory_still_answer(self, monkeypatch):
+        """A directory that fails without a cool-off does not end the walk: a
+        later one holding the videos says what is on the card."""
+        from backend.app import main
+
+        async def _list_result(ip, code, path, timeout=30.0, socket_timeout=None, printer_model=None):
+            if path == "/timelapse":
+                return FileListResult(files=[], available=False)
+            if path == "/record":
+                return FileListResult(files=[_video("video_1.avi")], available=True)
+            return FileListResult(files=[], available=False, missing=True)
+
+        monkeypatch.setattr(bambu_ftp, "list_files_result_async", _list_result)
+
+        assert await main._read_timelapse_baseline(_printer()) == ({"video_1.avi"}, True)
+
+    async def test_a_failed_directory_with_nothing_after_it_is_unread(self, monkeypatch):
+        from backend.app import main
+
+        async def _list_result(ip, code, path, timeout=30.0, socket_timeout=None, printer_model=None):
+            if path == "/timelapse":
+                return FileListResult(files=[], available=False)
+            return FileListResult(files=[], available=False, missing=True)
+
+        monkeypatch.setattr(bambu_ftp, "list_files_result_async", _list_result)
+
+        assert await main._read_timelapse_baseline(_printer()) == (set(), False)
+
+    async def test_the_completion_scan_stops_at_the_cool_off(self, monkeypatch):
+        from backend.app import main
+
+        asked: list[str] = []
+
+        async def _list(ip, code, path, printer_model=None):
+            asked.append(path)
+            bambu_ftp._record_ftps_stall(ip, f"listing {path} timed out")
+            return []
+
+        monkeypatch.setattr(bambu_ftp, "list_files_async", _list)
+
+        assert await main._list_timelapse_videos(_printer()) == ([], None)
+        assert asked == ["/timelapse"]
+
+
+# ---------------------------------------------------------------------------
+# The re-read
+# ---------------------------------------------------------------------------
+PRINTER_ID = 4747
+
+
+@asynccontextmanager
+async def _session():
+    db = MagicMock()
+    db.get = AsyncMock(return_value=SimpleNamespace(id=PRINTER_ID))
+    yield db
+
+
+class _Reread:
+    """Runs the re-read with its sleeps recorded instead of waited out."""
+
+    def __init__(self, main, monkeypatch, *, timelapse=True, reads=None):
+        self.main = main
+        self.sleeps: list[float] = []
+        self.status = SimpleNamespace(connected=True, state="RUNNING", timelapse=timelapse)
+        self.read = AsyncMock(side_effect=reads) if reads is not None else AsyncMock(return_value=(set(), False))
+        self.names: set[str] = set()
+
+        async def _sleep(seconds):
+            self.sleeps.append(seconds)
+
+        monkeypatch.setattr(main.asyncio, "sleep", _sleep)
+        monkeypatch.setattr(main, "_read_timelapse_baseline", self.read)
+        monkeypatch.setattr(main, "async_session", _session)
+        monkeypatch.setattr(main.printer_manager, "get_status", lambda _pid: self.status)
+        monkeypatch.setattr(main, "_TIMELAPSE_BASELINE_REREAD_MAX_ATTEMPTS", 40)
+        main._timelapse_baselines[PRINTER_ID] = self.names
+        main._timelapse_baselines_unread.add(PRINTER_ID)
+
+    async def run(self):
+        try:
+            await asyncio.wait_for(self.main._reread_unread_timelapse_baseline(PRINTER_ID, self.names, None, None), 5)
+        finally:
+            self.main._timelapse_baselines.pop(PRINTER_ID, None)
+            self.main._timelapse_baselines_unread.discard(PRINTER_ID)
+
+
+class TestTheReread:
+    async def test_a_print_without_a_timelapse_never_reads_the_card(self, monkeypatch):
+        """The reporter's printer records no timelapses, and the completion
+        scan only looks for a video when one was recorded."""
+        from backend.app import main
+
+        reread = _Reread(main, monkeypatch, timelapse=False)
+        await reread.run()
+
+        reread.read.assert_not_called()
+        # Skipped, not ended: the flag can arrive after the print has started.
+        assert len(reread.sleeps) == main._TIMELAPSE_BASELINE_REREAD_MAX_ATTEMPTS
+
+    async def test_failed_rounds_back_off_and_then_stop(self, monkeypatch):
+        from backend.app import main
+
+        reread = _Reread(main, monkeypatch)
+        await reread.run()
+
+        assert reread.read.await_count == main._TIMELAPSE_BASELINE_REREAD_MAX_FAILED_ROUNDS
+        assert reread.sleeps == [120.0, 240.0, 480.0, 960.0, 1800.0]
+
+    async def test_the_wait_is_capped(self, monkeypatch):
+        from backend.app import main
+
+        monkeypatch.setattr(main, "_TIMELAPSE_BASELINE_REREAD_MAX_FAILED_ROUNDS", 8)
+        reread = _Reread(main, monkeypatch)
+        await reread.run()
+
+        assert max(reread.sleeps) == main._TIMELAPSE_BASELINE_REREAD_MAX_INTERVAL_SECONDS
+        assert reread.sleeps[-3:] == [1800.0, 1800.0, 1800.0]
+
+    async def test_a_round_that_raises_counts_as_failed(self, monkeypatch):
+        from backend.app import main
+
+        reread = _Reread(main, monkeypatch, reads=[OSError("boom")] * 5)
+        await reread.run()
+
+        assert reread.read.await_count == 5
+        assert reread.sleeps == [120.0, 240.0, 480.0, 960.0, 1800.0]
+
+    async def test_a_reading_after_failures_still_becomes_the_baseline(self, monkeypatch):
+        from backend.app import main
+
+        reread = _Reread(main, monkeypatch, reads=[(set(), False), (set(), False), ({"video_1.avi"}, True)])
+        names = reread.names
+        await reread.run()
+
+        assert names == {"video_1.avi"}
+        assert reread.sleeps == [120.0, 240.0, 480.0]
+
+
+# ---------------------------------------------------------------------------
+# 421 too many connections
+# ---------------------------------------------------------------------------
+class TestAPrinterOutOfSessions:
+    def test_a_421_is_its_own_failure(self):
+        with patch("backend.app.services.bambu_ftp._read_cleartext_reply", return_value=TOO_MANY):
+            client = _connect_failing_with(_wrong_version())
+
+        assert client.last_failure is not None
+        assert client.last_failure.kind is FtpFailureKind.SESSIONS
+        assert client.last_failure.code == "421"
+        assert TOO_MANY in client.last_failure.detail
+
+    def test_attempts_later_in_the_window_still_know_it(self):
+        """Only the first failure in a cool-off asks the printer what it said;
+        a dispatch ignores the cool-off and makes up to four attempts."""
+        with patch("backend.app.services.bambu_ftp._read_cleartext_reply", return_value=TOO_MANY) as probe:
+            _connect_failing_with(_wrong_version())
+            again = _connect_failing_with(_wrong_version(), respect_handshake_cooloff=False)
+
+        probe.assert_called_once()
+        assert again.last_failure is not None
+        assert again.last_failure.kind is FtpFailureKind.SESSIONS
+
+    def test_a_new_window_forgets_the_old_reply(self):
+        with patch("backend.app.services.bambu_ftp._read_cleartext_reply", return_value=TOO_MANY):
+            _connect_failing_with(_wrong_version())
+        BambuFTPClient._handshake_blocked_until[IP] = 0.0  # expired
+
+        with patch("backend.app.services.bambu_ftp._read_cleartext_reply", return_value=None):
+            client = _connect_failing_with(_wrong_version(), respect_handshake_cooloff=False)
+
+        assert client.last_failure is not None
+        assert client.last_failure.kind is FtpFailureKind.HANDSHAKE
+
+    @pytest.mark.parametrize(
+        "reply",
+        ["421 Service not available, closing control connection.", "500 OOPS: vsftpd: refusing to run"],
+    )
+    def test_other_cleartext_replies_stay_a_handshake_failure(self, reply):
+        with patch("backend.app.services.bambu_ftp._read_cleartext_reply", return_value=reply):
+            client = _connect_failing_with(_wrong_version())
+
+        assert client.last_failure is not None
+        assert client.last_failure.kind is FtpFailureKind.HANDSHAKE
+
+    def test_the_queue_says_so_and_still_retries(self):
+        from backend.app.services.print_scheduler import (
+            _UPLOAD_REQUEUE_KINDS,
+            _asks_nothing_new,
+            _refused_upload_hold_reason,
+        )
+
+        reason = _refused_upload_hold_reason("F3DP", FtpFailureKind.SESSIONS)
+
+        assert "421" in reason
+        assert "restart the printer" in reason
+        assert FtpFailureKind.SESSIONS in _UPLOAD_REQUEUE_KINDS
+        # Still reads as a refusal to the model-job summary.
+        assert _asks_nothing_new(reason)

+ 13 - 8
backend/tests/unit/test_timelapse_scan_2704.py

@@ -23,6 +23,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
 import pytest
 import pytest
 
 
 from backend.app.core.printer_scope import ALL_PRINTERS
 from backend.app.core.printer_scope import ALL_PRINTERS
+from backend.app.services.bambu_ftp import FileListResult
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -317,8 +318,8 @@ class TestBaselineIsPersisted:
         with (
         with (
             patch("backend.app.main.async_session", return_value=session),
             patch("backend.app.main.async_session", return_value=session),
             patch(
             patch(
-                "backend.app.main._list_timelapse_videos",
-                new=AsyncMock(return_value=([_video("a.avi"), _video("b.avi")], "/timelapse")),
+                "backend.app.services.bambu_ftp.list_files_result_async",
+                new=AsyncMock(return_value=FileListResult([_video("a.avi"), _video("b.avi")], True)),
             ),
             ),
         ):
         ):
             await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
             await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
@@ -336,8 +337,8 @@ class TestBaselineIsPersisted:
         with (
         with (
             patch("backend.app.main.async_session", return_value=session),
             patch("backend.app.main.async_session", return_value=session),
             patch(
             patch(
-                "backend.app.main._list_timelapse_videos",
-                new=AsyncMock(return_value=([_video("a.avi")], "/timelapse")),
+                "backend.app.services.bambu_ftp.list_files_result_async",
+                new=AsyncMock(return_value=FileListResult([_video("a.avi")], True)),
             ),
             ),
         ):
         ):
             await _capture_timelapse_baseline_at_start(_printer(), 1, logger)
             await _capture_timelapse_baseline_at_start(_printer(), 1, logger)
@@ -357,7 +358,9 @@ class TestBaselineIsPersisted:
 
 
         with (
         with (
             patch("backend.app.main.async_session", return_value=session),
             patch("backend.app.main.async_session", return_value=session),
-            patch("backend.app.main._list_timelapse_videos", new=AsyncMock(side_effect=OSError("ftp down"))),
+            patch(
+                "backend.app.services.bambu_ftp.list_files_result_async", new=AsyncMock(side_effect=OSError("ftp down"))
+            ),
         ):
         ):
             await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
             await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
 
 
@@ -706,7 +709,9 @@ class TestStaleBaselineCannotSurvive:
 
 
         with (
         with (
             patch("backend.app.main.async_session", return_value=session),
             patch("backend.app.main.async_session", return_value=session),
-            patch("backend.app.main._list_timelapse_videos", new=AsyncMock(side_effect=OSError("ftp down"))),
+            patch(
+                "backend.app.services.bambu_ftp.list_files_result_async", new=AsyncMock(side_effect=OSError("ftp down"))
+            ),
         ):
         ):
             await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
             await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
 
 
@@ -724,8 +729,8 @@ class TestStaleBaselineCannotSurvive:
         with (
         with (
             patch("backend.app.main.async_session", return_value=session),
             patch("backend.app.main.async_session", return_value=session),
             patch(
             patch(
-                "backend.app.main._list_timelapse_videos",
-                new=AsyncMock(return_value=([_video("now_on_the_printer.avi")], "/timelapse")),
+                "backend.app.services.bambu_ftp.list_files_result_async",
+                new=AsyncMock(return_value=FileListResult([_video("now_on_the_printer.avi")], True)),
             ),
             ),
         ):
         ):
             await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
             await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)

+ 15 - 8
backend/tests/unit/test_timelapse_unread_baseline_3412.py

@@ -107,7 +107,7 @@ def _ftp(main_module, maker, monkeypatch, tmp_path, listing, *, state="RUNNING",
         files = listing()
         files = listing()
         return FileListResult(files=files or [], available=files is not None)
         return FileListResult(files=files or [], available=files is not None)
 
 
-    status = SimpleNamespace(connected=True, state=state, sdcard=True, sdcard_reported=True)
+    status = SimpleNamespace(connected=True, state=state, sdcard=True, sdcard_reported=True, timelapse=True)
     monkeypatch.setattr(bambu_ftp, "list_files_async", _list)
     monkeypatch.setattr(bambu_ftp, "list_files_async", _list)
     monkeypatch.setattr(bambu_ftp, "list_files_result_async", _list_result)
     monkeypatch.setattr(bambu_ftp, "list_files_result_async", _list_result)
     monkeypatch.setattr(bambu_ftp, "download_file_bytes_async", AsyncMock(return_value=b"x" * 1024))
     monkeypatch.setattr(bambu_ftp, "download_file_bytes_async", AsyncMock(return_value=b"x" * 1024))
@@ -327,23 +327,30 @@ class TestTheCaptureAtPrintStart:
             assert (await db.get(PrintArchive, archive_id)).timelapse_baseline_unread is False
             assert (await db.get(PrintArchive, archive_id)).timelapse_baseline_unread is False
         assert spawned == []
         assert spawned == []
 
 
-    async def test_an_empty_first_listing_is_replaced_by_the_one_that_answered(
-        self, test_engine, tmp_path, monkeypatch
-    ):
-        """The first listing fails, the second reads the card: its names are the
-        baseline, not the empty set the first one returned."""
+    async def test_a_failed_listing_is_not_walked_again_at_once(self, test_engine, tmp_path, monkeypatch):
+        """One walk of the card: a /timelapse that does not answer leaves the
+        baseline unread for the re-read, rather than being asked again on the
+        spot. Against a hung file service that second walk was four more dead
+        connections (#3474)."""
         from backend.app import main
         from backend.app import main
 
 
         maker, printer, archive_id = await _seed(test_engine)
         maker, printer, archive_id = await _seed(test_engine)
+        asked: list[str] = []
         rounds = iter([None])
         rounds = iter([None])
-        _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: next(rounds, [_entry(OLD_A), _entry(OLD_B)]))
+
+        def listing():
+            asked.append("/timelapse")
+            return next(rounds, [_entry(OLD_A), _entry(OLD_B)])
+
+        _, session = _ftp(main, maker, monkeypatch, tmp_path, listing)
 
 
         with session:
         with session:
             await self._capture(main, printer, archive_id)
             await self._capture(main, printer, archive_id)
 
 
+        assert asked == ["/timelapse"]
         async with maker() as db:
         async with maker() as db:
             archive = await db.get(PrintArchive, archive_id)
             archive = await db.get(PrintArchive, archive_id)
-            assert (archive.timelapse_baseline, archive.timelapse_baseline_unread) == ([OLD_A, OLD_B], False)
+            assert (archive.timelapse_baseline, archive.timelapse_baseline_unread) == ([], True)
 
 
 
 
 class TestTheCardIsReadAgainDuringThePrint:
 class TestTheCardIsReadAgainDuringThePrint: