浏览代码

Ask a printer that refuses FTPS what it actually said (issue #2780)

@grolmus measured a 9-printer farm and the numbers settle what this
failure is not. Reproduced here, three results:

  cleartext "421" banner on the TLS port
    -> [SSL: WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1032)
  1.2-only server, client forced to 1.3
    -> [SSL: TLSV1_ALERT_PROTOCOL_VERSION]
  1.2-only server, an uncapped client
    -> negotiates 1.2 and connects

The first is byte-for-byte what the farm logs. So WRONG_VERSION_NUMBER
means the printer's first bytes were not a TLS record, a version
mismatch cannot produce it, and reaching a 1.2-only peer needs no cap.

What it still does not say is WHICH cleartext message, and that is the
part that would name the fault. OpenSSL has eaten those bytes by the
time the exception surfaces, so on this error the client now opens one
plain connection and reads them. The log then carries the printer's own
words -- an FTP refusal such as "421 Too many connections" would settle
it outright -- marked as the line to quote in a report. This gets the
answer from every affected install rather than from the one farm able
to take a packet capture.

Three things keep it from making the suspected fault worse:

- The failed socket is closed BEFORE the probe opens its connection.
  Holding a dead handshake open across a second connect to a printer
  that may be out of connection slots is the leak #2780's own cleanup
  was added to stop.
- It asks once per cool-off window, not once per attempt. Checked
  before the new deadline is written, so a live entry means an earlier
  failure already asked -- which matters because a dispatch ignores the
  cool-off (#2898) and reaches this branch four times.
- Connect and read share one timeout budget rather than getting one
  each.

Only WRONG_VERSION_NUMBER is probed. A protocol-version alert means the
peer did speak TLS, so there is nothing in the clear to read and the
probe would only sit out its timeout. A vsFTPd answering its connection
limit by accepting and staying silent -- the other half of the standing
theory -- arrives as a handshake timeout and lands on that branch
instead; there is a test saying so, because widening the trigger later
would look like an improvement.

The profile registry is corrected to what was measured. Its docstring
claimed "the P2S evidently does offer 1.3"; six P2S units refuse it.
Worse, the X2D (#1638) and H2C (#2582) entries were capped on the
reading that WRONG_VERSION_NUMBER came from a TLS-1.3 ClientHello,
which cannot happen -- so the cap is not what changed those outcomes
and both are now marked RE-TEST WANTED. They are kept rather than
removed: their reporters saw the symptom clear, nobody here has that
hardware, and the entry costs nothing on a printer that does not offer
1.3 anyway. The P2S entry (#1401) is a different symptom -- a 426
truncation mid-transfer -- and is the only one a session-ticket problem
could explain, though grolmus's firmware refuses 1.3 there too.

Both measurements are pinned by tests, so the explanation stays
falsifiable instead of becoming the next set of confident wrong
comments. Two existing cool-off tests now count two connections where
they counted one; the promise they exist for -- contacted twice, not
~110 -- is unchanged, and they say why rather than carrying a new
number.
maziggy 2 周之前
父节点
当前提交
cc39acfc74

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **Bambuddy now asks a printer that refuses FTPS what it actually said (#2780, measured by @grolmus)** — When a printer's file service answers port 990 with something that is not TLS, Python reports `[SSL: WRONG_VERSION_NUMBER]` and the bytes that caused it are gone, consumed by the TLS layer before the error surfaces. That has left #2780 open on a theory rather than a finding. The client now opens one plain connection straight afterwards and reads what the printer says, so the log carries the printer's own words — an FTP refusal such as `421 Too many connections` would identify the fault outright — and the line is marked as the one to quote in a report. Reading nothing is informative too, and says so: a healthy implicit-FTPS service stays silent until it gets a handshake, so silence means the refusal had already passed. It asks once per cool-off window rather than once per attempt, which keeps it to one extra connection per printer per five minutes — the suspected fault is a printer running out of connections, so the diagnosis must not add to it. What made this worth doing is a measurement from a nine-printer farm, reproduced here: a cleartext banner on the TLS port produces exactly the error the field reports, a genuine TLS version mismatch produces a different one, and a client with no version cap reaches a TLS-1.2-only peer unaided. So this failure was never a TLS-version problem, and the per-model `cap_tls_v1_2` knob cannot affect it. Two of the three entries carrying that knob were added on the belief that it could; they are kept, since their reporters saw the symptom clear and nobody here has the hardware to re-test on, but they are now marked for re-test and the reasoning recorded next to them is what was measured rather than what was assumed. Both measurements are pinned by tests, so the explanation stays falsifiable.
 - **The K value is on the AMS slot itself, not only in the popover (#2532, requested and contributed by @gyrene2083)** — Reading back a slot's pressure-advance value meant hovering it: the K factor lived in the filament popover alone, so checking whether a calibration had actually taken across four slots was four hovers, and comparing two of them side by side was not possible at all. Every slot card now carries the value under the material name, the way Bambu Studio shows it per slot — on regular AMS units, on AMS-HT, and on the external spool of a dual-nozzle machine. Only a value the printer actually reported is shown: a loaded but never-calibrated slot stays blank rather than inheriting the 0.020 that fills the popover's own field, and a slot the firmware reports as exactly 0 counts as uncalibrated the same way the stored K-profiles do. The label is shortened to **K** with the full localized name on hover, because "K Factor", "K-Faktor" and "Facteur K" ate the value itself — the whole point of the line — on cards under about 350px, and the figure is set in tabular numerals so it measures the same in Safari as in Chromium. Where one slot of a unit is calibrated and its neighbours are not, the neighbours hold the same row open so the fill bars stay level across the card.
 
 ### Fixed

+ 89 - 2
backend/app/services/bambu_ftp.py

@@ -120,6 +120,62 @@ class DeleteResult(Enum):
 _HANDSHAKE_COOLOFF_SECONDS = 300.0
 
 
+# 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
+# vsFTPd refusal sends -- so this only ever elapses in full when the service has
+# gone back to speaking TLS and is waiting for a ClientHello that will not come.
+_CLEARTEXT_PROBE_TIMEOUT = 2.0
+
+
+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.
+
+    ``WRONG_VERSION_NUMBER`` means the peer's first bytes were not a TLS
+    record -- measured, not inferred: a cleartext ``421`` banner reproduces
+    that exact error and message, while a genuine version mismatch produces
+    ``TLSV1_ALERT_PROTOCOL_VERSION`` instead (#2780).
+
+    What it does not say is *which* cleartext message, and that is the part
+    that would identify the fault. OpenSSL has already consumed those bytes by
+    the time the error surfaces, so this opens one plain connection and reads
+    them directly. Answering it from the reporter's own printers beats waiting
+    on a packet capture from the one farm that can take one.
+
+    Returns the reply, or None when the printer said nothing readable -- which
+    is itself informative: a healthy implicit-FTPS service sends nothing until
+    it has a ClientHello, so silence means the fault had already passed.
+    """
+    sock = None
+    # One budget for connect *and* read. Given a timeout each, a printer that
+    # is slow to accept would then get the full read window on top of it, and
+    # the wait this adds to a failed connect would be double what it says.
+    deadline = time.monotonic() + _CLEARTEXT_PROBE_TIMEOUT
+    try:
+        sock = socket.create_connection((ip_address, port), _CLEARTEXT_PROBE_TIMEOUT)
+        sock.settimeout(max(0.05, deadline - time.monotonic()))
+        # One read. A refusal is a single short line; anything longer is not
+        # the thing being looked for, and this must not become a transfer.
+        raw = sock.recv(256)
+    except OSError as e:
+        # Refused or reset is a different fact from "answered in cleartext",
+        # and worth having in the log rather than flattened into silence.
+        logger.debug("Cleartext probe of %s:%s could not connect: %s", ip_address, port, e)
+        return None
+    finally:
+        if sock is not None:
+            try:
+                sock.close()
+            except OSError:
+                pass
+
+    if not raw:
+        return None
+    # latin-1 cannot fail, and an FTP reply line is ASCII in practice. Control
+    # characters are stripped so a stray byte cannot mangle the log line.
+    text = raw.decode("latin-1").strip()
+    return "".join(c for c in text if c.isprintable()) or None
+
+
 def _ftp_reply_code(error: BaseException) -> str | None:
     """The three-digit reply code an ftplib error carries, if it carries one.
 
@@ -472,9 +528,40 @@ class BambuFTPClient:
                 self.FTP_PORT,
                 _HANDSHAKE_COOLOFF_SECONDS,
             )
-            self._handshake_blocked_until[self.ip_address] = time.monotonic() + _HANDSHAKE_COOLOFF_SECONDS
-            self.last_failure = FtpFailure(FtpFailureKind.HANDSHAKE, str(e))
+            # Close the dead socket before asking this printer for anything
+            # else. The probe below opens a second connection, and the leading
+            # theory for this failure is a printer out of connection slots --
+            # holding a failed handshake open across that is the exact thing
+            # #2780's cleanup was added to stop. Idempotent, so the call that
+            # used to sit at the end of this branch simply moved up.
             self._abandon_connection()
+
+            # Ask the printer what it actually said, once per cool-off window.
+            # Checked before the deadline below is written, so a live entry here
+            # means an earlier failure already opened this window and already
+            # asked -- which keeps a dispatch that ignores the cool-off from
+            # probing on each of its four attempts.
+            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)
+                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})"
+                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._handshake_blocked_until[self.ip_address] = time.monotonic() + _HANDSHAKE_COOLOFF_SECONDS
+            self.last_failure = FtpFailure(FtpFailureKind.HANDSHAKE, detail)
             return False
         except (OSError, ftplib.Error) as e:
             logger.warning("FTP connection failed to %s: %s (type: %s)", self.ip_address, e, type(e).__name__)

+ 61 - 28
backend/app/services/ftp_profiles.py

@@ -45,12 +45,27 @@ class FTPProfile:
     # the printer). Capping to TLS 1.2 makes session resumption
     # synchronous and the upload completes normally.
     #
-    # Note this cap only bites on models that *offer* 1.3 in the first
-    # place. Probed directly on :990, an X1C and an H2D both refuse
-    # TLS 1.0, 1.1 and 1.3 with a handshake_failure alert and complete
-    # only on 1.2 — so for those models the cap is a no-op and the
-    # negotiated version was never 1.3. The P2S evidently does offer
-    # 1.3, which is why it alone surfaced the session-reuse bug.
+    # This cap only bites on models that *offer* 1.3 in the first place,
+    # and on the evidence so far none of them do. Probed directly on
+    # :990, an X1C and an H2D refuse TLS 1.0, 1.1 and 1.3 and complete
+    # only on 1.2; @grolmus then probed a 9-printer farm (#2780,
+    # 2026-08-21) and got the same result on six P2S units, two X1C and
+    # an H2D — tls1_3 refused, tls1_2 ok, every one. This comment used
+    # to claim "the P2S evidently does offer 1.3"; six say otherwise.
+    #
+    # A cap is also not needed to reach a 1.2-only peer. Measured
+    # against a local TLS-1.2-only server with the same context this
+    # module builds: an uncapped client negotiates 1.2 and connects.
+    # A client forced to 1.3 gets TLSV1_ALERT_PROTOCOL_VERSION — never
+    # WRONG_VERSION_NUMBER, which comes from bytes that are not a TLS
+    # record at all. See
+    # ``tests/unit/services/test_cleartext_probe_2780.py``, which pins
+    # both measurements so this comment stays falsifiable.
+    #
+    # So the entries below are kept as tuning slots and as a record of
+    # what each reporter saw, not because the mechanism is understood.
+    # Two of the three explain a symptom this cap cannot affect; see
+    # their own comments.
     # (P1S untested; no claim made either way.)
     #
     # **Defaults to False** — only applied to printer models where a
@@ -72,35 +87,53 @@ DEFAULT_PROFILE = FTPProfile()
 # AFTER alias normalisation, so internal SSDP codes ("N7") resolve via
 # ``_MODEL_ALIASES`` below.
 _PROFILES: dict[str, FTPProfile] = {
-    # P2S firmware 01.02.00.00 trips the vsFTPd + TLS 1.3 session-reuse
-    # bug on the FTPS data channel (#1401, reporter @iitazz). Cap to
-    # TLS 1.2 so session resumption is synchronous and the upload
-    # completes.
+    # P2S firmware 01.02.00.00 (#1401, reporter @iitazz). Symptom is a
+    # 426 truncation part-way through a transfer, on the data channel —
+    # a different failure from the handshake ones below, and the only
+    # one here whose mechanism a TLS-1.3 session-ticket problem could
+    # actually explain. The reporter confirmed the fix.
+    #
+    # Unresolved: @grolmus's six P2S units refuse TLS 1.3 outright
+    # (#2780), so on their firmware the negotiated version was already
+    # 1.2 and this cap changes nothing. Either the firmware moved
+    # between the two reports, or #1401 was fixed by something else in
+    # the same change. Kept because a reporter confirmed it and no one
+    # has hardware to re-test it on.
     "P2S": FTPProfile(
         cap_tls_v1_2=True,
     ),
     # X2D firmware 01.01.00.00 fails the implicit-FTPS handshake on
-    # port 990 with ``[SSL: WRONG_VERSION_NUMBER]`` against Python
-    # 3.13's default TLS-1.3 ClientHello (#1638, reporter @vasmarfas).
-    # Without the 3MF download the print falls through to the no-3MF
-    # fallback archive path and the card lands almost empty (no
-    # filament total, no layers, no MakerWorld link). Cap to TLS 1.2
-    # by analogy with P2S; if the symptom turns out to be a different
-    # FTPS variant on the X2D (explicit AUTH TLS, different port) the
-    # entry stays useful as a per-model tuning slot for the follow-up.
+    # port 990 with ``[SSL: WRONG_VERSION_NUMBER]`` (#1638, reporter
+    # @vasmarfas). Without the 3MF download the print falls through to
+    # the no-3MF fallback archive path and the card lands almost empty
+    # (no filament total, no layers, no MakerWorld link).
+    #
+    # RE-TEST WANTED. This was capped on the reading that the error came
+    # from "Python 3.13's default TLS-1.3 ClientHello". That reading is
+    # now measured wrong: WRONG_VERSION_NUMBER is what a *non-TLS*
+    # answer produces, a version mismatch reports itself differently,
+    # and an uncapped client reaches a 1.2-only peer unaided (#2780).
+    # So this cap cannot be what changed the outcome, and the X2D is
+    # most likely answering :990 with something that is not TLS — the
+    # cleartext probe in ``bambu_ftp`` will now say what. Left in place
+    # rather than removed: nobody here has an X2D, and the entry costs
+    # nothing on a printer that does not offer 1.3 anyway.
     "X2D": FTPProfile(
         cap_tls_v1_2=True,
     ),
-    # H2C firmware 01.02.00.00 (#2582, reporter @gyrene2083) — same H2
-    # generation and same firmware line as P2S, and with no profile it
-    # ran on the Python-default TLS 1.3. Reported symptom is exactly the
-    # one the X2D comment describes: the sliced 3MF intermittently fails
-    # to come off the printer over FTPS, so the print drops to the no-3MF
-    # fallback archive with no slice data — which is why the Print Log
-    # shows no filament and nothing is deducted. Cap to TLS 1.2 by analogy
-    # with P2S (intermittent "sometimes works" points at the session-reuse
-    # variant, not X2D's deterministic handshake failure); if a debug
-    # capture shows a different FTPS variant the entry stays the tuning slot.
+    # H2C firmware 01.02.00.00 (#2582, reporter @gyrene2083). The sliced
+    # 3MF intermittently fails to come off the printer over FTPS, so the
+    # print drops to the no-3MF fallback archive with no slice data —
+    # which is why the Print Log shows no filament and nothing is
+    # deducted.
+    #
+    # RE-TEST WANTED, same reasoning as the X2D above. Capped "by
+    # analogy with P2S" on the belief that the profile-less path "ran on
+    # the Python-default TLS 1.3"; measurement says a 1.2-only peer
+    # negotiates 1.2 without a cap, so there was no 1.3 to fall back
+    # from (#2780). "Intermittent" now points somewhere better: it is
+    # the signature of the transient non-TLS refusal @grolmus sees on
+    # his P2S units, which is the same H2 firmware line.
     "H2C": FTPProfile(
         cap_tls_v1_2=True,
     ),

+ 14 - 4
backend/tests/unit/services/test_bambu_ftp.py

@@ -1703,19 +1703,29 @@ class TestHandshakeCoolOff:
 
     def test_blocked_printer_is_not_contacted_again(self, plaintext_server):
         self._client(plaintext_server).connect()
-        assert plaintext_server.accepts == 1
+        # Two, not one: the failed handshake, then one cleartext read asking
+        # what the printer actually answered with (#2780). That read is the
+        # whole diagnosis and it happens once per cool-off, so the promise
+        # this test exists for -- contacted a couple of times, not ~110 --
+        # still holds.
+        assert plaintext_server.accepts == 2
 
         for _ in range(5):
             assert self._client(plaintext_server).connect() is False
-        # Still one: the cool-off answered without opening a socket.
-        assert plaintext_server.accepts == 1
+        # Still two: the cool-off answered without opening a socket, and it
+        # gates the probe as well as the handshake.
+        assert plaintext_server.accepts == 2
 
     def test_cooloff_expiry_lets_the_printer_be_retried(self, plaintext_server, monkeypatch):
         monkeypatch.setattr(bambu_ftp, "_HANDSHAKE_COOLOFF_SECONDS", 0.0)
         self._client(plaintext_server).connect()
         assert bambu_ftp.ftps_handshake_blocked("127.0.0.1") is False
         assert self._client(plaintext_server).connect() is False
-        assert plaintext_server.accepts == 2
+        # Two handshakes and a cleartext probe on each. A zero-length cool-off
+        # is what makes the probe repeat -- it is gated on the window being
+        # already open, and here there is never a window. At the real 300s it
+        # runs once, which `test_blocked_printer_is_not_contacted_again` pins.
+        assert plaintext_server.accepts == 4
 
     def test_block_is_per_printer(self, plaintext_server):
         self._client(plaintext_server).connect()

+ 383 - 0
backend/tests/unit/services/test_cleartext_probe_2780.py

@@ -0,0 +1,383 @@
+"""Ask the printer what it actually said (#2780).
+
+``[SSL: WRONG_VERSION_NUMBER]`` on port 990 means the printer's first bytes
+were not a TLS record. That is measured rather than assumed, and the two tests
+at the top of this file are the measurement: a cleartext banner reproduces the
+exact error the field reports, while a genuine TLS version mismatch produces a
+different one. Both matter, because the profile registry used to explain this
+failure as a TLS 1.3 problem and prescribe a version cap for it -- which cannot
+work, since the error was never about the negotiated version.
+
+What the error does not say is *which* cleartext message, and that is the part
+that would identify the fault. OpenSSL has consumed those bytes by the time the
+exception surfaces, so the client now opens one plain connection and reads them.
+The reporter with the affected farm offered a packet capture; this gets the same
+answer from every affected install instead of one.
+"""
+
+import logging
+import socket
+import ssl
+import threading
+import time
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from backend.app.services import bambu_ftp
+from backend.app.services.bambu_ftp import BambuFTPClient
+
+pytestmark = pytest.mark.unit
+
+LOGGER = "backend.app.services.bambu_ftp"
+REFUSAL = b"421 Too many connections. Try again later.\r\n"
+
+
+@pytest.fixture(autouse=True)
+def _clean_state():
+    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()
+
+
+class _Listener:
+    """A socket on an ephemeral port that answers however the test says.
+
+    ``mode="cleartext"`` sends an FTP refusal in the clear, the way a vsFTPd
+    that is turning connections away does. ``mode="silent"`` accepts and says
+    nothing, which is what a healthy implicit-FTPS service does while it waits
+    for a ClientHello.
+    """
+
+    def __init__(self, mode: str):
+        self.mode = mode
+        self.accepts = 0
+        self._sock = socket.socket()
+        self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+        self._sock.bind(("127.0.0.1", 0))
+        self._sock.listen(8)
+        self.port = self._sock.getsockname()[1]
+        self._stop = threading.Event()
+        self._conns: list[socket.socket] = []
+        self._thread = threading.Thread(target=self._serve, daemon=True)
+        self._thread.start()
+
+    def _serve(self):
+        # A blocking accept() is not reliably woken by closing the socket from
+        # another thread, which left every teardown here waiting out its join.
+        self._sock.settimeout(0.1)
+        while not self._stop.is_set():
+            try:
+                conn, _ = self._sock.accept()
+            except TimeoutError:
+                continue
+            except OSError:
+                return
+            self.accepts += 1
+            if self.mode == "cleartext":
+                try:
+                    conn.sendall(REFUSAL)
+                except OSError:
+                    pass
+                conn.close()
+            else:
+                # Hold it open and stay quiet, so the probe has to time out.
+                self._conns.append(conn)
+
+    def stop(self):
+        self._stop.set()
+        self._sock.close()
+        for c in self._conns:
+            try:
+                c.close()
+            except OSError:
+                pass
+        self._thread.join(timeout=2)
+
+
+@pytest.fixture()
+def cleartext_printer():
+    server = _Listener("cleartext")
+    yield server
+    server.stop()
+
+
+@pytest.fixture()
+def silent_printer():
+    server = _Listener("silent")
+    yield server
+    server.stop()
+
+
+@pytest.fixture(autouse=True)
+def _fast_probe(monkeypatch):
+    """A real timeout would make the silent case a two-second test."""
+    monkeypatch.setattr(bambu_ftp, "_CLEARTEXT_PROBE_TIMEOUT", 0.25)
+
+
+# ---------------------------------------------------------------------------
+# The measurement the rest of this rests on
+# ---------------------------------------------------------------------------
+def test_a_cleartext_banner_is_what_produces_wrong_version_number(cleartext_printer):
+    """The exact error the affected farm logs, from a non-TLS answer."""
+    ctx = ssl.create_default_context()
+    ctx.check_hostname = False
+    ctx.verify_mode = ssl.CERT_NONE
+    raw = socket.create_connection(("127.0.0.1", cleartext_printer.port), 5)
+
+    with pytest.raises(ssl.SSLError) as caught:
+        ctx.wrap_socket(raw, server_hostname="printer").do_handshake()
+
+    assert caught.value.reason == "WRONG_VERSION_NUMBER"
+
+
+def test_a_version_mismatch_produces_a_different_error(tmp_path):
+    """So "cap the TLS version" cannot be the fix for WRONG_VERSION_NUMBER.
+
+    Two of the cap_tls_v1_2 profile entries were written on the belief that it
+    was. A real mismatch reports itself as a protocol-version alert, and a
+    server that only speaks 1.2 negotiates fine against our own context without
+    any cap -- so neither half of that reasoning holds.
+    """
+    import subprocess  # nosec B404 -- generating a throwaway cert for a local server
+
+    subprocess.run(  # nosec B603 B607
+        [
+            "openssl",
+            "req",
+            "-x509",
+            "-newkey",
+            "rsa:2048",
+            "-keyout",
+            str(tmp_path / "k.pem"),
+            "-out",
+            str(tmp_path / "c.pem"),
+            "-days",
+            "1",
+            "-nodes",
+            "-subj",
+            "/CN=printer",
+        ],
+        check=True,
+        capture_output=True,
+    )
+    server_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+    server_ctx.load_cert_chain(str(tmp_path / "c.pem"), str(tmp_path / "k.pem"))
+    server_ctx.maximum_version = ssl.TLSVersion.TLSv1_2
+
+    listener = socket.socket()
+    listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+    listener.bind(("127.0.0.1", 0))
+    listener.listen(4)
+    port = listener.getsockname()[1]
+
+    def serve():
+        for _ in range(2):
+            try:
+                conn, _addr = listener.accept()
+            except OSError:
+                return
+            try:
+                server_ctx.wrap_socket(conn, server_side=True).close()
+            except (ssl.SSLError, OSError):
+                try:
+                    conn.close()
+                except OSError:
+                    pass
+
+    thread = threading.Thread(target=serve, daemon=True)
+    thread.start()
+    try:
+
+        def attempt(*, force_tls13: bool):
+            ctx = ssl.create_default_context()
+            ctx.check_hostname = False
+            ctx.verify_mode = ssl.CERT_NONE
+            ctx.minimum_version = ssl.TLSVersion.TLSv1_3 if force_tls13 else ssl.TLSVersion.TLSv1_2
+            if force_tls13:
+                ctx.maximum_version = ssl.TLSVersion.TLSv1_3
+            raw = socket.create_connection(("127.0.0.1", port), 5)
+            try:
+                ctx.wrap_socket(raw, server_hostname="printer").do_handshake()
+                return None
+            finally:
+                try:
+                    raw.close()
+                except OSError:
+                    pass
+
+        with pytest.raises(ssl.SSLError) as caught:
+            attempt(force_tls13=True)
+        assert caught.value.reason != "WRONG_VERSION_NUMBER"
+        assert "PROTOCOL_VERSION" in caught.value.reason
+
+        # And the half that makes the caps no-ops: a 1.2-only peer needs no help.
+        assert attempt(force_tls13=False) is None
+    finally:
+        listener.close()
+        thread.join(timeout=2)
+
+
+# ---------------------------------------------------------------------------
+# The probe
+# ---------------------------------------------------------------------------
+class TestTheProbe:
+    def _client(self, server):
+        client = BambuFTPClient("127.0.0.1", "12345678", timeout=5.0, printer_model="P2S")
+        client.FTP_PORT = server.port
+        return client
+
+    def test_it_puts_the_printers_own_words_in_the_log(self, cleartext_printer, caplog):
+        with caplog.at_level(logging.WARNING, logger=LOGGER):
+            assert self._client(cleartext_printer).connect() is False
+
+        messages = [r.getMessage() for r in caplog.records]
+        assert any("421 Too many connections" in m for m in messages), messages
+        # And it has to be findable by someone filing a report.
+        assert any("include this line if you report it" in m for m in messages), messages
+
+    def test_the_reason_carries_it_too(self, cleartext_printer):
+        """So the failure reaches the user's message, not only the log."""
+        client = self._client(cleartext_printer)
+        client.connect()
+
+        assert client.last_failure is not None
+        assert "421 Too many connections" in client.last_failure.detail
+
+    def test_silence_is_reported_as_the_fault_having_passed(self, caplog):
+        """The printer sent non-TLS bytes, then had recovered a moment later.
+
+        Driven through a stubbed probe rather than a silent server, because a
+        server that accepts and stays quiet never reaches this branch at all --
+        it produces a handshake *timeout*, not WRONG_VERSION_NUMBER, and the
+        TimeoutError branch handles that one. Reading nothing here means the
+        refusal passed between the handshake and the question, which is worth
+        saying rather than logging nothing at all.
+        """
+        transport = MagicMock()
+        error = ssl.SSLError(1, "[SSL: WRONG_VERSION_NUMBER] wrong version number")
+        error.reason = "WRONG_VERSION_NUMBER"
+        transport.connect.side_effect = error
+
+        with (
+            patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=transport),
+            patch("backend.app.services.bambu_ftp._read_cleartext_reply", return_value=None),
+            caplog.at_level(logging.WARNING, logger=LOGGER),
+        ):
+            assert BambuFTPClient("192.0.2.10", "12345678").connect() is False
+
+        assert any("nothing readable in cleartext" in r.getMessage() for r in caplog.records)
+        # And a probe that finds nothing must not cost the cool-off: the
+        # handshake still failed, whatever the printer said a moment later.
+        assert BambuFTPClient.handshake_blocked("192.0.2.10") is True
+
+    def test_an_accept_and_stay_quiet_printer_is_a_timeout_not_this(self, silent_printer):
+        """The other half of #2780's theory, and it lands somewhere else.
+
+        A vsFTPd answering its global connection limit by accepting and never
+        speaking produces a handshake timeout. Probing that would read nothing
+        by definition, so this branch is deliberately not reached.
+        """
+        client = self._client(silent_printer)
+        client.timeout = 0.5
+
+        with patch("backend.app.services.bambu_ftp._read_cleartext_reply") as probe:
+            assert client.connect() is False
+
+        probe.assert_not_called()
+        assert client.last_failure is not None
+        assert client.last_failure.kind.value == "timeout"
+
+    def test_it_asks_once_per_cooloff_not_once_per_attempt(self, cleartext_printer):
+        """A dispatch ignores the cool-off, so it reaches this branch four times.
+
+        Probing each time would add a connection per attempt to a printer whose
+        suspected fault is having too many -- the opposite of what #2780's
+        socket-leak fix was for.
+        """
+        for _ in range(4):
+            client = self._client(cleartext_printer)
+            client.respect_handshake_cooloff = False
+            client.connect()
+
+        # Four handshakes, and exactly one probe on top of them.
+        assert cleartext_printer.accepts == 5
+
+    def test_a_fresh_cooloff_window_asks_again(self, cleartext_printer):
+        """A printer that recovers and fails later is a new event to diagnose."""
+        self._client(cleartext_printer).connect()
+        before = cleartext_printer.accepts
+        BambuFTPClient._handshake_blocked_until.clear()
+
+        self._client(cleartext_printer).connect()
+        assert cleartext_printer.accepts == before + 2  # handshake + probe
+
+    def test_a_real_version_mismatch_is_not_probed(self):
+        """Nothing to read: that peer spoke TLS, it just would not agree on one.
+
+        Without this check the probe would connect and sit out its whole
+        timeout on every such failure.
+        """
+        transport = MagicMock()
+        error = ssl.SSLError(1, "[SSL: TLSV1_ALERT_PROTOCOL_VERSION] tlsv1 alert protocol version")
+        error.reason = "TLSV1_ALERT_PROTOCOL_VERSION"
+        transport.connect.side_effect = error
+
+        with (
+            patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=transport),
+            patch("backend.app.services.bambu_ftp._read_cleartext_reply") as probe,
+        ):
+            assert BambuFTPClient("192.0.2.10", "12345678").connect() is False
+
+        probe.assert_not_called()
+
+    def test_a_refused_probe_reads_as_nothing_rather_than_raising(self):
+        """Nothing is listening, so it must come back None, not blow up.
+
+        Uses a port that was just released rather than patching
+        ``socket.create_connection``, which is process-wide and would sit under
+        anything else running in this worker.
+        """
+        released = socket.socket()
+        released.bind(("127.0.0.1", 0))
+        port = released.getsockname()[1]
+        released.close()
+
+        assert bambu_ftp._read_cleartext_reply("127.0.0.1", port) is None
+
+    def test_the_dead_socket_is_closed_before_the_printer_is_asked_again(self):
+        """Ordering, and it is the whole reason this is safe to do at all.
+
+        The probe opens a second connection to a printer whose suspected fault
+        is having no connection slots left. Holding the failed handshake open
+        across that would be the leak #2780's cleanup was added to stop, with
+        an extra connection layered on top.
+        """
+        transport = MagicMock()
+        error = ssl.SSLError(1, "[SSL: WRONG_VERSION_NUMBER] wrong version number")
+        error.reason = "WRONG_VERSION_NUMBER"
+        transport.connect.side_effect = error
+        client = BambuFTPClient("192.0.2.10", "12345678")
+        observed = {}
+
+        def _probe(*_args):
+            observed["still_open"] = client._ftp is not None
+            observed["closed"] = transport.close.called
+            return "421 Too many connections."
+
+        with (
+            patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=transport),
+            patch("backend.app.services.bambu_ftp._read_cleartext_reply", _probe),
+        ):
+            assert client.connect() is False
+
+        assert observed == {"still_open": False, "closed": True}
+
+    def test_the_probe_does_not_outlive_its_timeout(self, silent_printer):
+        started = time.monotonic()
+        assert bambu_ftp._read_cleartext_reply("127.0.0.1", silent_printer.port) is None
+        assert time.monotonic() - started < 2.0