| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513 |
- """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)
|