Преглед изворни кода

Keep a dispatch's retries out of the FTPS cool-off (issue #2898)

    A failed TLS handshake arms a 300s per-IP cool-off, and connect()
    consulted it for every caller. A print dispatch retries after 2s, so
    once the cool-off was armed all four attempts were answered from the
    gate rather than the network, and every further job queued for that
    printer failed the same way for the rest of the window. The reporter's
    farm lost three jobs to one handshake error, with the retry budget
    contributing nothing to any of them.

    The gate was serving two callers that want opposite things from it. The
    background sweeps -- the post-print 3MF, cover and timelapse fetches --
    walk ~110 candidate paths against one wedged printer with nobody
    waiting, and backing off for minutes is right for them. A dispatch is
    one delete plus at most four upload attempts with someone watching a
    progress bar. So the split is by caller: a client built with
    respect_handshake_cooloff=False goes to the printer regardless, and the
    dispatch's delete and upload -- and a firmware upload, same shape --
    opt out. Everything else keeps #2780's behaviour untouched.

    In the reported trace it is the pre-upload delete that takes the SSL
    error and arms the cool-off, 8ms before the upload's first attempt, so
    exempting the upload alone would have left one dispatch's worth of the
    problem in place.

    Callers that do respect the cool-off no longer sleep out a retry loop
    against it: with_ftp_retry takes the printer's IP and stops at the
    attempt that armed the gate, instead of spending three more attempts
    and six seconds on connections that cannot happen. It also reports the
    attempts it really made -- "failed after 4 attempts" for one attempt is
    part of how this read as a network problem.

    Two diagnosis fixes go with it. The cool-off skip was the one connect()
    failure path that reported without naming its cause, and at DEBUG, so
    four identical reason-free warnings were all the operator saw. It now
    says at WARNING that nothing was sent and how long the printer has
    left, once per cool-off rather than once per attempt -- not every
    caller is gated, and a download-zip of 200 files would otherwise repeat
    the sentence 200 times, which is the flood #2780 set out to stop.

    And a dispatch that fails this way no longer tells anyone to check
    whether the SD card is inserted and formatted -- nothing reached the
    printer's filesystem, so the card is the one part of the machine that
    was working. The message names the file service and rules the card out.
    It is used only when a handshake failed during the dispatch itself,
    read from the cool-off deadline MOVING rather than merely being armed:
    the dispatch ignores the gate, so it can be running underneath one an
    unrelated background fetch left behind, and blaming TLS for an upload
    that really hit a full disk would repeat the mistake in the other
    direction.

    Tests count sockets rather than return values, since "returned False"
    looks identical whether or not anything was attempted -- which is what
    made the original report a log dive. Reverting any one of the five
    behaviours above fails a distinct test.
maziggy пре 2 недеља
родитељ
комит
a1e5afbd2d

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
CHANGELOG.md


+ 2 - 0
backend/app/api/routes/archives.py

@@ -2569,6 +2569,7 @@ async def scan_timelapse(
             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(
@@ -2691,6 +2692,7 @@ async def select_timelapse(
             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(

+ 3 - 0
backend/app/main.py

@@ -3717,6 +3717,7 @@ async def on_print_start(printer_id: int, data: dict):
                             max_retries=ftp_retry_count,
                             retry_delay=ftp_retry_delay,
                             operation_name=f"Download 3MF from {remote_path}",
+                            cooloff_ip=printer.ip_address,
                             non_retry_exceptions=(FileNotOnPrinterError,),
                         )
                     else:
@@ -3796,6 +3797,7 @@ async def on_print_start(printer_id: int, data: dict):
                                     max_retries=ftp_retry_count,
                                     retry_delay=ftp_retry_delay,
                                     operation_name=f"Download 3MF from {remote_full_path}",
+                                    cooloff_ip=printer.ip_address,
                                 )
                             else:
                                 downloaded = await download_file_async(
@@ -3860,6 +3862,7 @@ async def on_print_start(printer_id: int, data: dict):
                                         max_retries=ftp_retry_count,
                                         retry_delay=ftp_retry_delay,
                                         operation_name=f"Re-download 3MF from {remote_path}",
+                                        cooloff_ip=printer.ip_address,
                                         non_retry_exceptions=(FileNotOnPrinterError,),
                                     )
                                 else:

+ 107 - 9
backend/app/services/bambu_ftp.py

@@ -225,6 +225,10 @@ class BambuFTPClient:
     # their cool-off expires. See ``_HANDSHAKE_COOLOFF_SECONDS``.
     _handshake_blocked_until: dict[str, float] = {}
 
+    # Which cool-off deadline each printer's "not attempted" warning was last
+    # logged for, so the warning is said once per cool-off. See ``connect``.
+    _handshake_skip_logged: dict[str, float] = {}
+
     def __init__(
         self,
         ip_address: str,
@@ -232,12 +236,26 @@ class BambuFTPClient:
         timeout: float | None = None,
         printer_model: str | None = None,
         force_prot_c: bool = False,
+        respect_handshake_cooloff: bool = True,
     ):
+        """Set ``respect_handshake_cooloff=False`` for bounded, user-initiated work.
+
+        The cool-off exists to stop an unbounded sweep re-running one doomed
+        handshake a hundred times over (#2780). Dispatching a print is not
+        that: it is one delete plus at most four upload attempts, with someone
+        waiting on the result. Sharing the sweep's gate cost those attempts
+        their whole retry budget, and failed every further job queued for that
+        printer for the rest of the 300s window (#2898).
+
+        Leave it at the default everywhere else. Opting out is only defensible
+        because the caller's own connection count is bounded and small.
+        """
         self.ip_address = ip_address
         self.access_code = access_code
         self.timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT
         self.printer_model = printer_model
         self.force_prot_c = force_prot_c
+        self.respect_handshake_cooloff = respect_handshake_cooloff
         self._ftp: ImplicitFTP_TLS | None = None
 
     def _is_a1_model(self) -> bool:
@@ -283,6 +301,7 @@ class BambuFTPClient:
             # Drop it on the way past rather than leaving an entry per printer
             # this process has ever failed against.
             del cls._handshake_blocked_until[ip_address]
+            cls._handshake_skip_logged.pop(ip_address, None)
             return False
         return True
 
@@ -290,13 +309,35 @@ class BambuFTPClient:
         """Connect to the printer FTP server (implicit FTPS on port 990).
 
         Returns False without touching the network while the printer is inside
-        the cool-off a previous TLS handshake failure opened (#2780).
+        the cool-off a previous TLS handshake failure opened (#2780) -- unless
+        this client was built with ``respect_handshake_cooloff=False``.
         """
-        if self.handshake_blocked(self.ip_address):
-            logger.debug(
-                "FTP connect to %s skipped: FTPS handshake failed recently, cooling off",
-                self.ip_address,
-            )
+        if self.respect_handshake_cooloff and self.handshake_blocked(self.ip_address):
+            # WARNING, not DEBUG. This is the one connect() failure path that
+            # reported without its cause, so at default log level four
+            # reason-free "FTP connection failed" lines two seconds apart gave
+            # no hint that nothing had been sent (#2898). Every caller reaching
+            # here is already gated by handshake_blocked() at its own sweep
+            # boundary, so this costs about one line per print, not a flood.
+            deadline = self._handshake_blocked_until.get(self.ip_address)
+            remaining = max(0.0, deadline - time.monotonic()) if deadline is not None else 0.0
+            if deadline is not None and self._handshake_skip_logged.get(self.ip_address) != deadline:
+                self._handshake_skip_logged[self.ip_address] = deadline
+                logger.warning(
+                    "FTP connect to %s not attempted: its FTPS handshake failed recently and it is "
+                    "cooling off for another %.0fs. Nothing was sent to the printer.",
+                    self.ip_address,
+                    remaining,
+                )
+            else:
+                # Said once already for this cool-off. Repeating it per candidate
+                # path is the log flood #2780 set out to stop -- a download-zip
+                # of 200 files would print the same sentence 200 times.
+                logger.debug(
+                    "FTP connect to %s skipped: still cooling off for another %.0fs",
+                    self.ip_address,
+                    remaining,
+                )
             return False
         try:
             use_prot_c = self._should_use_prot_c()
@@ -928,6 +969,18 @@ class BambuFTPClient:
         return result if result else None
 
 
+def ftps_handshake_cooloff_deadline(ip_address: str) -> float | None:
+    """The monotonic deadline of this printer's handshake cool-off, or None.
+
+    Compare it across an operation to tell "a handshake failed while I was
+    working" from "this printer was already cooling off from something else".
+    Those need different words to the user, and the flag's presence alone
+    cannot separate them: a caller that opted out of the cool-off can be
+    running while an unrelated background fetch has one armed (#2898).
+    """
+    return BambuFTPClient._handshake_blocked_until.get(ip_address)
+
+
 def ftps_handshake_blocked(ip_address: str) -> bool:
     """True while this printer's FTPS handshake cool-off is still running.
 
@@ -1249,6 +1302,7 @@ async def upload_file_async(
     progress_callback: Callable[[int, int], None] | None = None,
     socket_timeout: float | None = None,
     printer_model: str | None = None,
+    respect_handshake_cooloff: bool = True,
 ) -> bool:
     """Async wrapper for uploading a file with timeout and progress callback.
 
@@ -1266,6 +1320,9 @@ async def upload_file_async(
         progress_callback: Optional callback for progress updates
         socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
         printer_model: Printer model for A1-specific workarounds
+        respect_handshake_cooloff: see ``BambuFTPClient.__init__``. False for a
+            user-initiated upload, whose attempts are bounded and were being
+            spent against a cool-off that outlives them (#2898).
     """
     loop = asyncio.get_event_loop()
     is_a1 = printer_model in BambuFTPClient.A1_MODELS if printer_model else False
@@ -1287,7 +1344,12 @@ async def upload_file_async(
             f"mode={mode_str}, socket_timeout={socket_timeout}s, deadline={deadline:.0f}s)..."
         )
         client = BambuFTPClient(
-            ip_address, access_code, timeout=socket_timeout, printer_model=printer_model, force_prot_c=force_prot_c
+            ip_address,
+            access_code,
+            timeout=socket_timeout,
+            printer_model=printer_model,
+            force_prot_c=force_prot_c,
+            respect_handshake_cooloff=respect_handshake_cooloff,
         )
         if client.connect():
             logger.info("FTP connected to %s", ip_address)
@@ -1465,6 +1527,7 @@ async def delete_file_async(
     socket_timeout: float | None = None,
     printer_model: str | None = None,
     timeout: float = 60.0,
+    respect_handshake_cooloff: bool = True,
 ) -> DeleteResult:
     """Async wrapper for deleting a file.
 
@@ -1477,11 +1540,21 @@ async def delete_file_async(
         printer_model: Printer model for A1-specific workarounds
         timeout: overall async cap so a saturated ``_ftp_executor`` can't pin
             the caller (and any DB connection it holds) indefinitely (#2572).
+        respect_handshake_cooloff: see ``BambuFTPClient.__init__``. The delete
+            that clears the way for a dispatch shares the upload's exemption --
+            it is one connection, and in #2898's trace it is the one that armed
+            the cool-off the upload then spent all four attempts against.
     """
     loop = asyncio.get_event_loop()
 
     def _delete() -> DeleteResult:
-        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
+        client = BambuFTPClient(
+            ip_address,
+            access_code,
+            timeout=socket_timeout,
+            printer_model=printer_model,
+            respect_handshake_cooloff=respect_handshake_cooloff,
+        )
         if client.connect():
             try:
                 return client.delete_file(remote_path)
@@ -1710,6 +1783,7 @@ async def with_ftp_retry(
     retry_delay: float = 2.0,
     operation_name: str = "FTP operation",
     non_retry_exceptions: tuple[type[BaseException], ...] = (),
+    cooloff_ip: str | None = None,
     **kwargs,
 ) -> T | None:
     """Execute FTP operation with retry logic.
@@ -1721,6 +1795,10 @@ async def with_ftp_retry(
         retry_delay: Seconds to wait between retries (default: 2.0)
         operation_name: Name for logging purposes
         non_retry_exceptions: Exception types that should immediately abort retries
+        cooloff_ip: printer IP whose FTPS handshake cool-off should end the loop
+            early. Pass it from any caller that respects the cool-off; leave it
+            unset for one that opted out, or the loop would stop on a gate its
+            own attempts are ignoring (#2898).
         **kwargs: Keyword arguments for the operation
 
     Returns:
@@ -1731,8 +1809,10 @@ async def with_ftp_retry(
     another full deadline reaching the same conclusion (#2529).
     """
     last_error = None
+    attempts_made = 0
 
     for attempt in range(max_retries + 1):
+        attempts_made = attempt + 1
         try:
             result = await operation(*args, **kwargs)
             # Check for "falsy" success indicators
@@ -1753,10 +1833,28 @@ async def with_ftp_retry(
 
         # Don't wait after the last attempt
         if attempt < max_retries:
+            # A cool-off outlasts this loop by two orders of magnitude, so once
+            # it is armed every remaining attempt returns False without opening
+            # a socket. Spending them anyway bought nothing and cost the caller
+            # `max_retries * retry_delay` seconds of sleeping, then reported the
+            # failure with the wrong reason (#2898).
+            if cooloff_ip and ftps_handshake_blocked(cooloff_ip):
+                logger.warning(
+                    "%s: stopping after attempt %s/%s — %s is inside its FTPS handshake cool-off, "
+                    "so the remaining attempts would not reach it",
+                    operation_name,
+                    attempt + 1,
+                    max_retries + 1,
+                    cooloff_ip,
+                )
+                break
             logger.info("%s will retry in %ss...", operation_name, retry_delay)
             await asyncio.sleep(retry_delay)
 
-    logger.error("%s failed after %s attempts", operation_name, max_retries + 1)
+    # attempts_made, not max_retries + 1: the loop can stop early on a cool-off,
+    # and reporting attempts that were never made is how #2898 read as a network
+    # problem when nothing had gone near the network.
+    logger.error("%s failed after %s attempts", operation_name, attempts_made)
     if last_error:
         logger.debug("Last error: %s", last_error)
     return None

+ 6 - 0
backend/app/services/firmware_update.py

@@ -344,6 +344,11 @@ class FirmwareUpdateService:
                     progress_callback=on_upload_progress,
                     socket_timeout=ftp_timeout,
                     printer_model=model,
+                    # Someone pressed "update firmware" and is watching a
+                    # progress bar. Bounded and user-initiated, like a print
+                    # dispatch, so it does not spend its retries on a cool-off
+                    # meant for the background sweeps (#2898).
+                    respect_handshake_cooloff=False,
                     max_retries=ftp_retry_count,
                     retry_delay=ftp_retry_delay,
                     operation_name=f"Upload firmware to printer {printer_id}",
@@ -357,6 +362,7 @@ class FirmwareUpdateService:
                     progress_callback=on_upload_progress,
                     socket_timeout=ftp_timeout,
                     printer_model=model,
+                    respect_handshake_cooloff=False,
                 )
 
             if not success:

+ 32 - 0
backend/app/services/print_scheduler.py

@@ -33,6 +33,7 @@ from backend.app.services.bambu_ftp import (
     UploadCancelled,
     cache_3mf_download,
     delete_file_async,
+    ftps_handshake_cooloff_deadline,
     get_ftp_retry_settings,
     upload_file_async,
     with_ftp_retry,
@@ -6093,6 +6094,14 @@ class PrintScheduler:
         # pending->printing CAS) transparently open a fresh transaction.
         await db.commit()
 
+        # Where this printer's handshake cool-off stood before we touched it.
+        # The delete and upload below ignore the cool-off, so finding one armed
+        # afterwards proves nothing on its own -- a background timelapse or 3MF
+        # fetch for an earlier print could have armed it minutes ago. A deadline
+        # that MOVED, though, can only mean a handshake failed during this
+        # dispatch, which is what the failure message needs to know (#2898).
+        cooloff_before = ftps_handshake_cooloff_deadline(printer.ip_address)
+
         # Delete existing file if present (avoids 553 error on overwrite)
         try:
             logger.debug("Queue item %s: Deleting existing file %s if present...", item.id, remote_path)
@@ -6102,6 +6111,13 @@ class PrintScheduler:
                 remote_path,
                 socket_timeout=ftp_timeout,
                 printer_model=printer.model,
+                # This delete and the upload below are one bounded, user-initiated
+                # unit -- at most nine connections -- so neither skips on the
+                # handshake cool-off the opportunistic sweeps rely on. In #2898's
+                # trace this delete took the TLS failure and armed the cool-off,
+                # and the upload's four attempts were then spent against it
+                # without a socket being opened.
+                respect_handshake_cooloff=False,
             )
             logger.debug("Queue item %s: Delete result: %s", item.id, delete_result)
         except Exception as e:
@@ -6144,6 +6160,7 @@ class PrintScheduler:
                     socket_timeout=ftp_timeout,
                     printer_model=printer.model,
                     progress_callback=progress_bridge,
+                    respect_handshake_cooloff=False,
                     max_retries=ftp_retry_count,
                     retry_delay=ftp_retry_delay,
                     operation_name=f"Upload print to {printer.name}",
@@ -6157,6 +6174,7 @@ class PrintScheduler:
                     socket_timeout=ftp_timeout,
                     printer_model=printer.model,
                     progress_callback=progress_bridge,
+                    respect_handshake_cooloff=False,
                 )
         except UploadCancelled as e:
             uploaded = False
@@ -6174,6 +6192,20 @@ class PrintScheduler:
             injected_path.unlink(missing_ok=True)
 
         if not uploaded:
+            # A cool-off armed during this dispatch is proof the printer
+            # answered port 990 with something that was not TLS, so the SD card
+            # is the wrong thing to go and look at -- and it is what three of
+            # #2898's queue items were sent to check. "During this dispatch" is
+            # load-bearing: an unrelated background fetch can leave a cool-off
+            # armed for minutes, and blaming TLS for an upload that actually hit
+            # a full disk would repeat the mistake in the other direction.
+            cooloff_after = ftps_handshake_cooloff_deadline(printer.ip_address)
+            if not upload_error and cooloff_after is not None and cooloff_after != cooloff_before:
+                upload_error = (
+                    "The printer's file service did not answer over TLS, so the file could not be sent to it. "
+                    "Its SD card is not involved. Bambuddy will leave this printer alone for a few minutes "
+                    "before trying again."
+                )
             error_msg = upload_error or (
                 "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT). "
                 "See server logs for detailed diagnostics."

+ 2 - 0
backend/tests/unit/services/conftest.py

@@ -127,9 +127,11 @@ def clear_ftp_mode_cache():
     """
     BambuFTPClient._mode_cache.clear()
     BambuFTPClient._handshake_blocked_until.clear()
+    BambuFTPClient._handshake_skip_logged.clear()
     yield
     BambuFTPClient._mode_cache.clear()
     BambuFTPClient._handshake_blocked_until.clear()
+    BambuFTPClient._handshake_skip_logged.clear()
 
 
 @pytest.fixture()

+ 412 - 0
backend/tests/unit/services/test_ftp_cooloff_retry_budget_2898.py

@@ -0,0 +1,412 @@
+"""A dispatch must not spend its retries on a cool-off that outlives them (#2898).
+
+``BambuFTPClient.connect`` refuses to open a socket for 300s after a TLS
+handshake failure (#2780). That gate was written for the background sweeps --
+the post-print 3MF, cover and timelapse fetches, which walk ~110 candidate
+paths against one wedged printer and have nobody waiting on them.
+
+It sat inside ``connect``, so it applied to print dispatch too, which wants the
+opposite. On a 10-printer farm one handshake failure took out three queued
+jobs: the pre-upload delete armed the cool-off, all four upload attempts were
+then answered from the gate 2s apart without a socket being opened, and the
+next two jobs for that printer failed the same way inside the same window.
+
+The split these tests pin: work that is bounded and user-initiated (a dispatch
+is one delete plus at most four upload attempts) opts out; everything else
+keeps #2780's behaviour exactly. Sockets are counted rather than inferred,
+because "returned False" looks identical either way -- which is what made the
+original report a log dive.
+"""
+
+import logging
+import ssl
+from contextlib import ExitStack
+from pathlib import Path
+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,
+    DeleteResult,
+    delete_file_async,
+    upload_file_async,
+    with_ftp_retry,
+)
+
+pytestmark = pytest.mark.unit
+
+IP = "192.168.50.142"  # the P2S from the report
+LOGGER = "backend.app.services.bambu_ftp"
+
+
+@pytest.fixture(autouse=True)
+def _clean_cooloff():
+    BambuFTPClient._handshake_blocked_until.clear()
+    BambuFTPClient._handshake_skip_logged.clear()
+    BambuFTPClient._mode_cache.clear()
+    yield
+    BambuFTPClient._handshake_blocked_until.clear()
+    BambuFTPClient._handshake_skip_logged.clear()
+    BambuFTPClient._mode_cache.clear()
+
+
+@pytest.fixture()
+def refusing_printer():
+    """Answers port 990 with something that is not TLS, every time.
+
+    Yields the transport mock; ``transport.connect.call_count`` is the number
+    of times we actually went near the printer, which is the whole question
+    here.
+    """
+    transport = MagicMock()
+    transport.connect.side_effect = ssl.SSLError("[SSL: WRONG_VERSION_NUMBER] wrong version number")
+    with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=transport):
+        yield transport
+
+
+def _arm(ip=IP):
+    """Put *ip* into the cool-off the way a real handshake failure would."""
+    BambuFTPClient._handshake_blocked_until[ip] = bambu_ftp.time.monotonic() + bambu_ftp._HANDSHAKE_COOLOFF_SECONDS
+    assert BambuFTPClient.handshake_blocked(ip) is True
+
+
+# ---------------------------------------------------------------------------
+# The gate itself
+# ---------------------------------------------------------------------------
+class TestConnectHonoursTheOptOut:
+    def test_the_default_still_refuses_to_open_a_socket(self, refusing_printer):
+        """#2780's protection is the default and must stay untouched."""
+        _arm()
+        assert BambuFTPClient(IP, "12345678", printer_model="P2S").connect() is False
+        assert refusing_printer.connect.call_count == 0
+
+    def test_an_exempt_client_reaches_the_printer(self, refusing_printer):
+        _arm()
+        client = BambuFTPClient(IP, "12345678", printer_model="P2S", respect_handshake_cooloff=False)
+        assert client.connect() is False  # the printer is still broken...
+        assert refusing_printer.connect.call_count == 1  # ...but we found that out ourselves
+
+    def test_the_opt_out_does_not_leak_to_the_next_client(self, refusing_printer):
+        """The flag is per client, not a global switch someone can leave on."""
+        _arm()
+        BambuFTPClient(IP, "12345678", respect_handshake_cooloff=False).connect()
+        refusing_printer.connect.reset_mock()
+
+        BambuFTPClient(IP, "12345678").connect()
+        assert refusing_printer.connect.call_count == 0
+
+    def test_the_skip_says_why_at_a_level_operators_see(self, caplog):
+        """The reason-free WARNING is what made this a log dive.
+
+        Every other ``connect`` failure path names its cause; this one logged
+        at DEBUG, so at default level four identical "FTP connection failed"
+        lines gave no hint that nothing had been sent.
+        """
+        _arm()
+        with caplog.at_level(logging.WARNING, logger=LOGGER):
+            assert BambuFTPClient(IP, "12345678").connect() is False
+
+        messages = [r.getMessage() for r in caplog.records]
+        assert any("cooling off" in m and IP in m for m in messages), messages
+        # And it has to be legible as "we did nothing", not as a network error.
+        assert any("Nothing was sent to the printer" in m for m in messages), messages
+
+    def test_it_says_it_once_per_cooloff_and_not_once_per_attempt(self, caplog):
+        """Raising this to WARNING must not re-create the flood #2780 stopped.
+
+        Not every caller is gated: downloading a ZIP of files the user picked
+        walks the whole selection, so 200 files would otherwise repeat the same
+        sentence 200 times.
+        """
+        _arm()
+        with caplog.at_level(logging.DEBUG, logger=LOGGER):
+            for _ in range(200):
+                BambuFTPClient(IP, "12345678").connect()
+
+        warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
+        assert len(warnings) == 1, [r.getMessage() for r in warnings]
+        # Still recoverable at DEBUG for anyone reading a support bundle.
+        assert sum("still cooling off" in r.getMessage() for r in caplog.records) == 199
+
+    def test_a_fresh_handshake_failure_is_announced_again(self, caplog):
+        """Once per cool-off, not once per process.
+
+        A printer that recovers and fails again is a new event, and silence
+        would be the DEBUG-level problem this fix set out to remove.
+        """
+        _arm()
+        with caplog.at_level(logging.WARNING, logger=LOGGER):
+            BambuFTPClient(IP, "12345678").connect()
+            _arm()  # a later handshake failure pushes the deadline out
+            BambuFTPClient(IP, "12345678").connect()
+
+        warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
+        assert len(warnings) == 2, [r.getMessage() for r in warnings]
+
+
+# ---------------------------------------------------------------------------
+# The retry loop
+# ---------------------------------------------------------------------------
+class TestRetryLoopStopsOnAnArmedCooloff:
+    async def _run(self, *, cooloff_ip, calls):
+        async def op():
+            calls.append(1)
+            _arm()  # the first attempt is what arms it, as in the report
+            return False
+
+        return await with_ftp_retry(
+            op,
+            max_retries=3,
+            retry_delay=0.01,
+            operation_name="Download 3MF",
+            cooloff_ip=cooloff_ip,
+        )
+
+    async def test_a_respecting_caller_stops_after_the_attempt_that_armed_it(self, caplog):
+        calls = []
+        with caplog.at_level(logging.WARNING, logger=LOGGER):
+            assert await self._run(cooloff_ip=IP, calls=calls) is None
+        assert len(calls) == 1
+
+        messages = [r.getMessage() for r in caplog.records]
+        assert any("stopping after attempt 1/4" in m for m in messages), messages
+        # The tally has to match what was really tried. "failed after 4
+        # attempts" for one attempt is how this read as a network problem.
+        assert any("failed after 1 attempts" in m for m in messages), messages
+
+    async def test_a_caller_without_the_ip_keeps_its_full_budget(self):
+        """Dispatch ignores the cool-off, so the loop must not stop on it.
+
+        Stopping here would undo the exemption from the other end: the
+        attempts would still be refused, just by the retry loop instead of by
+        ``connect``.
+        """
+        calls = []
+        assert await self._run(cooloff_ip=None, calls=calls) is None
+        assert len(calls) == 4
+
+
+# ---------------------------------------------------------------------------
+# The reported failure, end to end
+# ---------------------------------------------------------------------------
+class TestDispatchKeepsItsAttempts:
+    async def test_every_upload_attempt_reaches_the_printer(self, refusing_printer, tmp_path):
+        """The trace from the report: cool-off armed, then four dead attempts.
+
+        The reporter's evidence is that the handshake failure is transient --
+        a manual connect a second later completes cleanly -- so the retry the
+        gate suppressed is precisely the retry that would have worked.
+        """
+        _arm()
+        local = tmp_path / "job.gcode.3mf"
+        local.write_bytes(b"x" * 1024)
+
+        result = await with_ftp_retry(
+            upload_file_async,
+            IP,
+            "12345678",
+            local,
+            "/job.gcode.3mf",
+            timeout=5.0,
+            printer_model="P2S",
+            respect_handshake_cooloff=False,
+            max_retries=3,
+            retry_delay=0.01,
+            operation_name="Upload print to Bambulab P2S-4",
+        )
+
+        assert result is None
+        assert refusing_printer.connect.call_count == 4
+
+    async def test_without_the_exemption_the_same_upload_touches_nothing(self, refusing_printer, tmp_path):
+        """Mutation guard: revert the exemption and the test above must fail.
+
+        Without this, ``call_count == 4`` above would pass for the wrong reason
+        if the cool-off were ever simply removed.
+        """
+        _arm()
+        local = tmp_path / "job.gcode.3mf"
+        local.write_bytes(b"x" * 1024)
+
+        result = await with_ftp_retry(
+            upload_file_async,
+            IP,
+            "12345678",
+            local,
+            "/job.gcode.3mf",
+            timeout=5.0,
+            printer_model="P2S",
+            max_retries=3,
+            retry_delay=0.01,
+            operation_name="Upload print to Bambulab P2S-4",
+        )
+
+        assert result is None
+        assert refusing_printer.connect.call_count == 0
+
+    async def test_the_pre_upload_delete_is_exempt_too(self, refusing_printer):
+        """In the report's trace the delete is what armed the cool-off.
+
+        It runs 8ms before the upload's first attempt, so leaving it gated
+        would keep one whole dispatch's worth of the problem in place.
+        """
+        _arm()
+        result = await delete_file_async(
+            IP, "12345678", "/job.gcode.3mf", printer_model="P2S", respect_handshake_cooloff=False
+        )
+        assert result is DeleteResult.FAILED
+        assert refusing_printer.connect.call_count == 1
+
+    async def test_a_background_download_is_still_gated(self, refusing_printer):
+        """The sweeps keep #2780 exactly: nobody is waiting, so back off."""
+        _arm()
+        assert await bambu_ftp.download_file_bytes_async(IP, "12345678", "/timelapse/a.mp4") is None
+        assert refusing_printer.connect.call_count == 0
+
+
+# ---------------------------------------------------------------------------
+# What the operator is told
+# ---------------------------------------------------------------------------
+@pytest.fixture
+async def dispatch_case(tmp_path):
+    """Minimal one-printer, one-queued-job database for ``_start_print``."""
+    from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+    import backend.app.models  # noqa: F401 - populate Base.metadata
+    from backend.app.core.database import Base
+    from backend.app.models.archive import PrintArchive
+    from backend.app.models.print_queue import PrintQueueItem
+    from backend.app.models.printer import Printer
+
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+
+    base_dir = tmp_path / "case"
+    archive_rel = Path("archives") / "job.3mf"
+    archive_abs = base_dir / archive_rel
+    archive_abs.parent.mkdir(parents=True, exist_ok=True)
+    archive_abs.write_bytes(b"archive payload")
+
+    async with session_maker() as db:
+        printer = Printer(
+            name="Bambulab P2S-4",
+            serial_number="SERIAL",
+            ip_address=IP,
+            access_code="12345678",
+            model="P2S",
+        )
+        db.add(printer)
+        await db.flush()
+        archive = PrintArchive(
+            printer_id=printer.id,
+            filename="job.3mf",
+            file_path=str(archive_rel),
+            file_size=archive_abs.stat().st_size,
+            status="completed",
+        )
+        db.add(archive)
+        await db.flush()
+        item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="pending")
+        db.add(item)
+        await db.commit()
+        item_id = item.id
+
+    try:
+        yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, item_id=item_id)
+    finally:
+        await engine.dispose()
+
+
+async def _failed_dispatch_message(dispatch_case, *, handshake_fails: bool) -> str:
+    """Run one dispatch whose upload fails, and return what the user is told.
+
+    ``handshake_fails`` makes the stand-in upload arm the cool-off the way the
+    real one does when the printer answers port 990 with something other than
+    TLS -- which is the only thing that separates the two messages.
+    """
+    import backend.app.services.print_scheduler as scheduler_module
+    from backend.app.models.print_queue import PrintQueueItem
+    from backend.app.services.print_scheduler import PrintScheduler
+    from backend.tests._fixtures.background_tasks import discarding_spawn_patch
+
+    async def _upload(*_args, **_kwargs):
+        if handshake_fails:
+            _arm()
+        return False
+
+    scheduler = PrintScheduler()
+    async with dispatch_case.session_maker() as db:
+        item = await db.get(PrintQueueItem, dispatch_case.item_id)
+        patches = [
+            patch.object(scheduler_module.settings, "base_dir", dispatch_case.base_dir),
+            patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
+            patch(
+                "backend.app.services.print_scheduler.get_ftp_retry_settings",
+                AsyncMock(return_value=(False, 0, 0, 1.0)),
+            ),
+            patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
+            patch("backend.app.services.print_scheduler.upload_file_async", _upload),
+            patch("backend.app.services.print_scheduler.notification_service.on_queue_job_failed", AsyncMock()),
+            discarding_spawn_patch(),
+            patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
+            patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
+            patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
+        ]
+        with ExitStack() as stack:
+            for p in patches:
+                stack.enter_context(p)
+            await scheduler._start_print(db, item)
+
+        refreshed = await db.get(PrintQueueItem, dispatch_case.item_id)
+        assert refreshed.status == "failed"
+        return refreshed.error_message or ""
+
+
+class TestTheFailureNamesTheRightHardware:
+    async def test_a_handshake_failure_does_not_send_anyone_to_the_sd_card(self, dispatch_case):
+        """Three of the report's queue items were told to check an SD card.
+
+        The printer had answered port 990 with something that was not TLS.
+        Nothing had reached its filesystem, so its card could not have been
+        the problem, and the operator was sent to look at the one part of the
+        machine that was working.
+        """
+        message = await _failed_dispatch_message(dispatch_case, handshake_fails=True)
+
+        assert "SD card is inserted" not in message, message
+        assert "did not answer over TLS" in message, message
+        # It goes further than dropping the advice: the card is the first thing
+        # anyone would reach for next, so the message rules it out by name.
+        assert "SD card is not involved" in message, message
+
+    async def test_an_ordinary_upload_failure_keeps_the_storage_advice(self, dispatch_case):
+        """No cool-off means no evidence about TLS, so the old wording stands.
+
+        This is the half that stops the new message from swallowing every
+        upload failure: the cool-off is read as evidence, not assumed.
+        """
+        assert BambuFTPClient.handshake_blocked(IP) is False
+        message = await _failed_dispatch_message(dispatch_case, handshake_fails=False)
+
+        assert "SD card is inserted" in message, message
+
+    async def test_a_cooloff_left_by_something_else_is_not_taken_as_evidence(self, dispatch_case):
+        """The printer can be cooling off from work this dispatch had no part in.
+
+        A background timelapse or 3MF fetch for an earlier print arms the same
+        gate, and it lasts five minutes. Since the dispatch ignores the gate, an
+        upload running underneath it can still fail on a full disk -- and
+        answering that with "the file service did not answer over TLS" would be
+        the same wrong-hardware mistake pointing the other way.
+        """
+        _arm()  # armed before the dispatch, and nothing re-arms it during
+        message = await _failed_dispatch_message(dispatch_case, handshake_fails=False)
+
+        assert "did not answer over TLS" not in message, message
+        assert "SD card is inserted" in message, message

Неке датотеке нису приказане због велике количине промена