mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 05:01:37 +02:00
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.
146 lines
4.6 KiB
Python
146 lines
4.6 KiB
Python
"""Test fixtures for FTP service tests.
|
|
|
|
Provides a real implicit FTPS server (via mock_ftp_server) and client factory
|
|
for integration-style testing of BambuFTPClient against a live server.
|
|
|
|
The server fixture is class-scoped to avoid the overhead of starting a new
|
|
TLS server for every test (~67 TLS handshakes → ~9 per class).
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import socket
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from backend.app.services.bambu_ftp import BambuFTPClient
|
|
from backend.app.services.virtual_printer.certificate import CertificateService
|
|
from backend.tests.unit.services.mock_ftp_server import MockBambuFTPServer
|
|
|
|
BAMBU_DIRS = ("cache", "timelapse", "model", "data", "data/Metadata")
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def ftp_certs(tmp_path_factory):
|
|
"""Generate self-signed TLS certificates once per test session."""
|
|
cert_dir = tmp_path_factory.mktemp("ftp_certs")
|
|
svc = CertificateService(cert_dir, serial="TEST_FTP_SERVER")
|
|
cert_path, key_path = svc.generate_certificates()
|
|
return str(cert_path), str(key_path)
|
|
|
|
|
|
def _find_free_port() -> int:
|
|
"""Find a free TCP port on localhost."""
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
@pytest.fixture(scope="class")
|
|
def ftp_root(tmp_path_factory):
|
|
"""Create temp directory with standard Bambu printer directory structure."""
|
|
root = tmp_path_factory.mktemp("ftp_root")
|
|
for d in BAMBU_DIRS:
|
|
(root / d).mkdir(parents=True, exist_ok=True)
|
|
return root
|
|
|
|
|
|
@pytest.fixture(scope="class")
|
|
def ftp_server(ftp_certs, ftp_root):
|
|
"""Start a mock implicit FTPS server, yield it, stop on cleanup."""
|
|
cert_path, key_path = ftp_certs
|
|
port = _find_free_port()
|
|
server = MockBambuFTPServer(
|
|
host="127.0.0.1",
|
|
port=port,
|
|
root_dir=str(ftp_root),
|
|
cert_path=cert_path,
|
|
key_path=key_path,
|
|
access_code="12345678",
|
|
)
|
|
server.start()
|
|
yield server
|
|
server.stop()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _ftp_test_cleanup(request):
|
|
"""Reset server state between tests within a class.
|
|
|
|
Clears injected failures and restores the Bambu directory structure
|
|
so each test starts with a clean filesystem. Skips cleanup for test
|
|
classes that don't use the class-scoped ftp_server (e.g.
|
|
TestDisconnectServerGone).
|
|
"""
|
|
yield
|
|
# Only clean up if this test class uses the class-scoped fixtures
|
|
ftp_root = request.node.funcargs.get("ftp_root")
|
|
if ftp_root is None:
|
|
return
|
|
server = request.node.funcargs.get("ftp_server")
|
|
if server is not None:
|
|
server.clear_failures()
|
|
# Restore clean directory structure
|
|
root = str(ftp_root)
|
|
for entry in os.listdir(root):
|
|
path = os.path.join(root, entry)
|
|
if os.path.isdir(path):
|
|
shutil.rmtree(path)
|
|
else:
|
|
os.remove(path)
|
|
for d in BAMBU_DIRS:
|
|
os.makedirs(os.path.join(root, d), exist_ok=True)
|
|
|
|
|
|
@pytest.fixture()
|
|
def ftp_client_factory(ftp_server):
|
|
"""Factory that creates BambuFTPClient instances pointed at the mock server."""
|
|
|
|
def _make_client(
|
|
printer_model: str = "X1C",
|
|
force_prot_c: bool = False,
|
|
access_code: str = "12345678",
|
|
timeout: float = 10.0,
|
|
) -> BambuFTPClient:
|
|
client = BambuFTPClient(
|
|
ip_address="127.0.0.1",
|
|
access_code=access_code,
|
|
timeout=timeout,
|
|
printer_model=printer_model,
|
|
force_prot_c=force_prot_c,
|
|
)
|
|
# Override port to point at mock server
|
|
client.FTP_PORT = ftp_server.port
|
|
return client
|
|
|
|
return _make_client
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_ftp_mode_cache():
|
|
"""Clear BambuFTPClient's per-printer caches before and after each test.
|
|
|
|
Both are class-level dicts keyed by IP, and every test here talks to
|
|
127.0.0.1 — a handshake cool-off left behind by one test would make the
|
|
next one's ``connect()`` return False without touching the server (#2780).
|
|
"""
|
|
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()
|
|
def patch_ftp_port(ftp_server):
|
|
"""Patch FTP_PORT at class level for async wrapper tests.
|
|
|
|
Async wrappers create their own BambuFTPClient instances internally,
|
|
so we need to patch the class-level default port.
|
|
"""
|
|
with patch.object(BambuFTPClient, "FTP_PORT", ftp_server.port):
|
|
yield ftp_server
|