Bladeren bron

fix(printers): the plate picker downloads a printer's 3MF once

The plate list and every plate thumbnail each downloaded the whole 3MF
from the printer again, all at once. The file browser's previews (plate
list, plate thumbnails, G-code) now share one download per printer and
path and keep it for a minute, within 128 MB in total. A file deleted
through Bambuddy, replaced by a print dispatch, or on a deleted printer
is forgotten at once; a download still running then is not kept.
maziggy 5 dagen geleden
bovenliggende
commit
026acaf511

+ 1 - 0
CHANGELOG.md

@@ -164,6 +164,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **The plate picker downloads a printer's 3MF once** — Opening a 3MF in the printer's file browser downloaded the whole file again for the plate list and for every plate thumbnail, all at once: a ten-plate 60 MB file meant eleven full downloads, slow on any printer and slower still on an A1. The plate list, plate thumbnails and G-code preview now share one download and keep it for a minute (the 3D view still fetches its own copy).
 - **Printer file downloads tell a missing file from a failed one, and big files get the time they need** — Downloading a file from the printer, and the file browser's previews (G-code, plates, plate thumbnails), treated every failure as "File not found", including a slow transfer of a file just listed. Bambuddy now answers "not found" only when the printer says the file is gone, and otherwise that the download failed or took too long; the download's message and the timelapse messages show which. A file download was also held to 10 minutes whatever its size, so a 250 MB camera recording on a slow link always failed, and the previews to 5 minutes; both may now take as long as the file's size calls for, up to the 30-minute limit. A timelapse that could not be downloaded is reported the same way instead of as a server error.
 - **A timed-out printer download no longer runs on beside its retry** — When a timelapse download ran out of time, it kept going in the background while the next attempt opened a second connection to the printer. The transfer is now stopped first, and a download that already had the time its size allows is not retried.
 - **Large STL files no longer freeze Bambuddy while their thumbnail is made** — Uploading an STL, extracting a ZIP of STLs, generating missing thumbnails and scanning an external folder rendered each thumbnail in a way that held up everything else, so a high-poly model froze the web interface and printer updates for seconds or longer. Thumbnails are now made in the background, one at a time.

+ 30 - 20
backend/app/api/routes/printers.py

@@ -67,6 +67,7 @@ from backend.app.services.bambu_ftp import (
 from backend.app.services.hms_actions import HMSAction
 from backend.app.services.print_storage import ftp_probe_paths, print_file_reachable_over_ftp
 from backend.app.services.printer_diagnostic import run_connection_diagnostic
+from backend.app.services.printer_file_cache import printer_file_cache
 from backend.app.services.printer_manager import (
     ams_units_to_dicts,
     display_temperatures,
@@ -1712,27 +1713,33 @@ async def _download_printer_file_bytes(printer, path: str) -> bytes:
     """Download a printer file for a preview: 404 missing, 502 failed, 504 too slow.
 
     A failed download used to be reported as 404 "File not found" for a file the
-    browser had just listed. One FTP session, no listing first: the plate
-    picker asks once per plate, and printers limit their FTPS handshakes.
+    browser had just listed. One FTP session, no listing first, and shared: the
+    plate picker asks once per plate, all at once, and each request downloaded
+    the whole 3MF again (see services/printer_file_cache).
     """
-    try:
-        data = await download_file_bytes_async(
-            printer.ip_address,
-            printer.access_code,
-            path,
-            printer_model=printer.model,
-            report_missing=True,
-            # The size the printer reports buys a slow link its time, up to the
-            # file browser's budget: print start's 300 s ceiling would add none.
-            max_total_timeout=MAX_PRINTER_ZIP_PREPARE_SECONDS,
-        )
-    except FileNotOnPrinterError as exc:
-        raise HTTPException(404, f"File not found: {path}") from exc
-    except DownloadDeadlineExceeded as exc:
-        raise HTTPException(504, f"Downloading {path} from the printer took longer than its size allows") from exc
-    if data is None:
-        raise HTTPException(502, f"Could not download {path} from the printer")
-    return data
+
+    async def fetch() -> bytes:
+        try:
+            data = await download_file_bytes_async(
+                printer.ip_address,
+                printer.access_code,
+                path,
+                printer_model=printer.model,
+                report_missing=True,
+                # The size the printer reports buys a slow link its time, up to
+                # the file browser's budget: print start's 300 s ceiling would
+                # add none.
+                max_total_timeout=MAX_PRINTER_ZIP_PREPARE_SECONDS,
+            )
+        except FileNotOnPrinterError as exc:
+            raise HTTPException(404, f"File not found: {path}") from exc
+        except DownloadDeadlineExceeded as exc:
+            raise HTTPException(504, f"Downloading {path} from the printer took longer than its size allows") from exc
+        if data is None:
+            raise HTTPException(502, f"Could not download {path} from the printer")
+        return data
+
+    return await printer_file_cache.get(printer.id, path, fetch)
 
 
 @router.get("/{printer_id}/files/gcode")
@@ -2175,6 +2182,9 @@ async def delete_printer_file(
     from backend.app.services.bambu_ftp import DeleteResult
 
     result = await delete_file_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
+    if result != DeleteResult.FAILED:
+        # Gone from the printer: a preview must not be served from the old copy.
+        printer_file_cache.forget(printer_id, path)
     if result == DeleteResult.NOT_FOUND:
         raise HTTPException(404, f"File not found on printer: {path}")
     if result == DeleteResult.FAILED:

+ 2 - 0
backend/app/main.py

@@ -1081,6 +1081,7 @@ async def forget_printer(printer_id: int) -> None:
     )
     from backend.app.services.notify_live_activities import notify_live_activities
     from backend.app.services.obico_detection import obico_detection_service
+    from backend.app.services.printer_file_cache import printer_file_cache
 
     # Keyed (printer_id, filename). The archive-keyed maps of those prints go too.
     archive_ids = set()
@@ -1135,6 +1136,7 @@ async def forget_printer(printer_id: int) -> None:
         marked.discard(printer_id)
 
     printer_manager.forget_printer(printer_id)
+    printer_file_cache.forget_printer(printer_id)
     print_dispatch_context.clear(printer_id)
     kprofile_drift.forget_printer(printer_id, deleted=True)
     layer_timelapse.cancel_session(printer_id)

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

@@ -8825,6 +8825,12 @@ class PrintScheduler:
         # meanwhile gets no more toasts, and neither does whoever has their id.
         toast_uid = await self._queue_owner_id(db, item)
 
+        # The printer's copy at this path was replaced (or deleted above): the
+        # file browser's previews must not show the old one.
+        from backend.app.services.printer_file_cache import printer_file_cache
+
+        printer_file_cache.forget(printer.id, remote_path)
+
         if not uploaded:
             # This used to be one string for every upload failure, telling
             # everyone to check the SD card. The client knows which of seven

+ 122 - 0
backend/app/services/printer_file_cache.py

@@ -0,0 +1,122 @@
+"""A short-lived, shared copy of printer files for the file browser's previews.
+
+The plate picker asks for the plate list and then one thumbnail per plate, and
+each request downloaded the whole 3MF from the printer again: ten plates of a
+60 MB file meant eleven full downloads, all opened at once over FTP. Requests
+for the same file now share one download, and its bytes serve the requests
+that follow for ``CACHE_SECONDS``.
+
+Kept in memory, bounded in total (least recently used goes first) and per file:
+a file too big to keep is still shared by the requests waiting on it. A failed
+download is never kept. Entries go when the file is deleted through Bambuddy
+and when the printer is deleted. A file replaced on the printer by other means
+can be served from the old copy for at most ``CACHE_SECONDS``.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from collections import OrderedDict
+from collections.abc import Awaitable, Callable
+
+# Long enough for the plate picker's burst of requests and a look at the
+# plates, short enough that a file replaced on the printer is soon re-read.
+CACHE_SECONDS = 60.0
+# Modest, for the 1-2 GB appliance: held only while someone is previewing.
+MAX_TOTAL_BYTES = 128 * 1024 * 1024
+MAX_ENTRY_BYTES = 64 * 1024 * 1024
+
+Key = tuple[int, str]
+
+
+class PrinterFileCache:
+    def __init__(self) -> None:
+        self._entries: OrderedDict[Key, tuple[float, bytes]] = OrderedDict()
+        self._total = 0
+        self._in_flight: dict[Key, asyncio.Task[bytes]] = {}
+        # Bumped by forget(): a download that started before the file was
+        # deleted must not put the old bytes back when it finishes.
+        self._generation: dict[Key, int] = {}
+
+    async def get(self, printer_id: int, path: str, fetch: Callable[[], Awaitable[bytes]]) -> bytes:
+        """The file's bytes: kept, already downloading, or fetched now.
+
+        ``fetch`` raises on failure; every request waiting on that download
+        sees the same exception, and nothing is kept.
+        """
+        key = (printer_id, path)
+        self._sweep()
+        entry = self._entries.get(key)
+        if entry is not None:
+            self._entries.move_to_end(key)
+            return entry[1]
+
+        task = self._in_flight.get(key)
+        if task is None:
+            task = asyncio.ensure_future(self._fetch(key, fetch, self._generation.get(key, 0)))
+            self._in_flight[key] = task
+        # Shielded: a request that leaves (its client closed the page) must not
+        # end the download the others are waiting on.
+        return await asyncio.shield(task)
+
+    async def _fetch(self, key: Key, fetch: Callable[[], Awaitable[bytes]], generation: int) -> bytes:
+        # ``generation`` is read when the download is created, not when this
+        # first runs: a forget() in between must still win.
+        try:
+            data = await fetch()
+        finally:
+            if self._in_flight.get(key) is asyncio.current_task():
+                self._in_flight.pop(key, None)
+        if self._generation.get(key, 0) == generation:
+            self._keep(key, data)
+        return data
+
+    def _keep(self, key: Key, data: bytes) -> None:
+        size = len(data)
+        if size > MAX_ENTRY_BYTES or size > MAX_TOTAL_BYTES:
+            return
+        self._drop(key)
+        self._sweep()
+        while self._entries and self._total + size > MAX_TOTAL_BYTES:
+            oldest = next(iter(self._entries))
+            self._drop(oldest)
+        self._entries[key] = (time.monotonic() + CACHE_SECONDS, data)
+        self._total += size
+
+    def _sweep(self) -> None:
+        """Free every entry whose minute is up, not only the one asked for."""
+        now = time.monotonic()
+        for key in [key for key, (expires, _data) in self._entries.items() if expires <= now]:
+            self._drop(key)
+
+    def _drop(self, key: Key) -> None:
+        entry = self._entries.pop(key, None)
+        if entry is not None:
+            self._total -= len(entry[1])
+
+    def forget(self, printer_id: int, path: str) -> None:
+        """Drop a file that changed on the printer (deleted through Bambuddy)."""
+        self._forget_key((printer_id, path))
+
+    def forget_printer(self, printer_id: int) -> None:
+        """Drop everything kept for a deleted printer."""
+        for key in {key for key in [*self._entries, *self._in_flight] if key[0] == printer_id}:
+            self._forget_key(key)
+
+    def clear(self) -> None:
+        """Drop everything (tests)."""
+        self._entries.clear()
+        self._total = 0
+        self._in_flight.clear()
+        self._generation.clear()
+
+    def _forget_key(self, key: Key) -> None:
+        self._drop(key)
+        # A download still running finishes for its waiters but is not kept,
+        # and the next request starts a new one.
+        self._in_flight.pop(key, None)
+        self._generation[key] = self._generation.get(key, 0) + 1
+
+
+printer_file_cache = PrinterFileCache()

+ 13 - 0
backend/tests/conftest.py

@@ -213,6 +213,19 @@ def mfa_encryption_isolation(monkeypatch, tmp_path):
     enc_mod._key_source = None
 
 
+@pytest.fixture(autouse=True)
+def reset_printer_file_cache():
+    """A fresh file-browser preview cache per test.
+
+    Test databases start their printer ids at 1 again, so a preview kept by one
+    test would be served to the next one's printer 1."""
+    from backend.app.services.printer_file_cache import printer_file_cache
+
+    printer_file_cache.clear()
+    yield
+    printer_file_cache.clear()
+
+
 @pytest.fixture(autouse=True)
 def reset_spoolman_location_sync_cache():
     """Drop the per-URL Spoolman location-sync TTL cache between tests.

+ 45 - 0
backend/tests/integration/test_printer_file_download_errors.py

@@ -118,3 +118,48 @@ async def test_an_automatic_timelapse_attach_survives_a_deadline():
 
     assert result is False
     download.assert_awaited_once()
+
+
+async def test_the_plate_picker_downloads_the_file_once(async_client: AsyncClient, printer_factory):
+    """The plate list and every plate thumbnail share one download (TO-C2)."""
+    import io
+    import zipfile
+
+    printer = await printer_factory()
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w") as zf:
+        for n in (1, 2, 3):
+            zf.writestr(f"Metadata/plate_{n}.gcode", "; gcode")
+            zf.writestr(f"Metadata/plate_{n}.png", b"\x89PNG fake")
+    download = AsyncMock(return_value=buf.getvalue())
+    params = {"path": "/cache/multi.3mf"}
+
+    with patch("backend.app.api.routes.printers.download_file_bytes_async", download):
+        plates = await async_client.get(f"/api/v1/printers/{printer.id}/files/plates", params=params)
+        thumbs = [
+            await async_client.get(f"/api/v1/printers/{printer.id}/files/plate-thumbnail/{n}", params=params)
+            for n in (1, 2, 3)
+        ]
+
+    assert plates.status_code == 200, plates.text
+    assert [t.status_code for t in thumbs] == [200, 200, 200]
+    assert download.await_count == 1
+
+
+async def test_a_deleted_printer_file_is_downloaded_again(async_client: AsyncClient, printer_factory):
+    from backend.app.services.bambu_ftp import DeleteResult
+
+    printer = await printer_factory()
+    download = AsyncMock(return_value=b"; gcode")
+    params = {"path": "/cache/part.gcode"}
+
+    with (
+        patch("backend.app.api.routes.printers.download_file_bytes_async", download),
+        patch("backend.app.api.routes.printers.delete_file_async", AsyncMock(return_value=DeleteResult.DELETED)),
+    ):
+        await async_client.get(f"/api/v1/printers/{printer.id}/files/gcode", params=params)
+        deleted = await async_client.delete(f"/api/v1/printers/{printer.id}/files", params=params)
+        await async_client.get(f"/api/v1/printers/{printer.id}/files/gcode", params=params)
+
+    assert deleted.status_code == 200, deleted.text
+    assert download.await_count == 2

+ 234 - 0
backend/tests/unit/services/test_printer_file_cache.py

@@ -0,0 +1,234 @@
+"""One download of a printer file serves the plate picker (TO-C2).
+
+The plate picker asks for the plate list and then one thumbnail per plate,
+and each request downloaded the whole 3MF from the printer again: ten plates
+of a 60 MB file on an A1 meant eleven full downloads, all at once. Requests for
+the same file now share one download, and its bytes serve the requests that
+follow for a minute.
+"""
+
+import asyncio
+
+import pytest
+
+from backend.app.services import printer_file_cache as cache_mod
+from backend.app.services.printer_file_cache import PrinterFileCache
+
+pytestmark = pytest.mark.asyncio
+
+
+class _Printer:
+    """Counts downloads; each one can be held open until released."""
+
+    def __init__(self, data: bytes = b"3mf-bytes") -> None:
+        self.data = data
+        self.calls = 0
+        self.release = asyncio.Event()
+        self.release.set()
+
+    async def fetch(self) -> bytes:
+        self.calls += 1
+        await self.release.wait()
+        return self.data
+
+
+async def test_requests_at_the_same_time_share_one_download():
+    cache, printer = PrinterFileCache(), _Printer()
+    printer.release.clear()
+
+    tasks = [asyncio.create_task(cache.get(1, "/cache/a.3mf", printer.fetch)) for _ in range(10)]
+    await asyncio.sleep(0)
+    printer.release.set()
+
+    assert await asyncio.gather(*tasks) == [b"3mf-bytes"] * 10
+    assert printer.calls == 1
+
+
+async def test_a_request_soon_after_is_served_from_memory():
+    cache, printer = PrinterFileCache(), _Printer()
+
+    await cache.get(1, "/cache/a.3mf", printer.fetch)
+    await cache.get(1, "/cache/a.3mf", printer.fetch)
+
+    assert printer.calls == 1
+
+
+async def test_it_is_downloaded_again_once_the_minute_is_up(monkeypatch):
+    now = [1000.0]
+    monkeypatch.setattr(cache_mod.time, "monotonic", lambda: now[0])
+    cache, printer = PrinterFileCache(), _Printer()
+
+    await cache.get(1, "/cache/a.3mf", printer.fetch)
+    now[0] += cache_mod.CACHE_SECONDS + 1
+    await cache.get(1, "/cache/a.3mf", printer.fetch)
+
+    assert printer.calls == 2
+
+
+async def test_files_and_printers_are_kept_apart():
+    cache, printer = PrinterFileCache(), _Printer()
+
+    await cache.get(1, "/cache/a.3mf", printer.fetch)
+    await cache.get(1, "/cache/b.3mf", printer.fetch)
+    await cache.get(2, "/cache/a.3mf", printer.fetch)
+
+    assert printer.calls == 3
+
+
+async def test_a_failure_is_not_kept():
+    cache, printer = PrinterFileCache(), _Printer()
+    attempts = []
+
+    async def failing():
+        attempts.append(1)
+        raise RuntimeError("printer busy")
+
+    with pytest.raises(RuntimeError):
+        await cache.get(1, "/cache/a.3mf", failing)
+    assert await cache.get(1, "/cache/a.3mf", printer.fetch) == b"3mf-bytes"
+    assert len(attempts) == 1 and printer.calls == 1
+
+
+async def test_a_waiter_that_leaves_does_not_end_the_download_for_the_others():
+    cache, printer = PrinterFileCache(), _Printer()
+    printer.release.clear()
+
+    first = asyncio.create_task(cache.get(1, "/cache/a.3mf", printer.fetch))
+    second = asyncio.create_task(cache.get(1, "/cache/a.3mf", printer.fetch))
+    await asyncio.sleep(0)
+    first.cancel()
+    printer.release.set()
+
+    assert await second == b"3mf-bytes"
+    assert printer.calls == 1
+
+
+async def test_a_deleted_file_is_forgotten():
+    cache, printer = PrinterFileCache(), _Printer()
+
+    await cache.get(1, "/cache/a.3mf", printer.fetch)
+    cache.forget(1, "/cache/a.3mf")
+    await cache.get(1, "/cache/a.3mf", printer.fetch)
+
+    assert printer.calls == 2
+
+
+async def test_a_deleted_printer_is_forgotten():
+    cache, printer = PrinterFileCache(), _Printer()
+
+    await cache.get(1, "/cache/a.3mf", printer.fetch)
+    await cache.get(2, "/cache/a.3mf", printer.fetch)
+    cache.forget_printer(1)
+    await cache.get(1, "/cache/a.3mf", printer.fetch)
+    await cache.get(2, "/cache/a.3mf", printer.fetch)
+
+    assert printer.calls == 3
+
+
+async def test_a_file_too_big_to_keep_is_still_shared_while_it_downloads(monkeypatch):
+    monkeypatch.setattr(cache_mod, "MAX_ENTRY_BYTES", 4)
+    cache, printer = PrinterFileCache(), _Printer(b"too-big")
+    printer.release.clear()
+
+    tasks = [asyncio.create_task(cache.get(1, "/cache/a.3mf", printer.fetch)) for _ in range(3)]
+    await asyncio.sleep(0)
+    printer.release.set()
+    await asyncio.gather(*tasks)
+    await cache.get(1, "/cache/a.3mf", printer.fetch)
+
+    assert printer.calls == 2
+
+
+async def test_the_oldest_files_make_room(monkeypatch):
+    monkeypatch.setattr(cache_mod, "MAX_TOTAL_BYTES", 20)
+    cache = PrinterFileCache()
+    printer = _Printer(b"x" * 8)
+
+    await cache.get(1, "/a", printer.fetch)
+    await cache.get(1, "/b", printer.fetch)
+    await cache.get(1, "/a", printer.fetch)  # used again: /b is now the oldest
+    await cache.get(1, "/c", printer.fetch)  # 24 bytes: /b goes
+    assert printer.calls == 3
+
+    await cache.get(1, "/a", printer.fetch)
+    assert printer.calls == 3
+    await cache.get(1, "/b", printer.fetch)
+    assert printer.calls == 4
+
+
+async def test_a_file_deleted_while_downloading_is_not_kept():
+    cache, printer = PrinterFileCache(), _Printer()
+    printer.release.clear()
+
+    old = asyncio.create_task(cache.get(1, "/cache/a.3mf", printer.fetch))
+    await asyncio.sleep(0)
+    cache.forget(1, "/cache/a.3mf")
+    printer.release.set()
+    await old
+    await cache.get(1, "/cache/a.3mf", printer.fetch)
+
+    assert printer.calls == 2
+
+
+async def test_expired_files_free_their_memory(monkeypatch):
+    """Not only when the same file is asked for again: two large previews
+    must not stay resident until something pushes them out."""
+    now = [1000.0]
+    monkeypatch.setattr(cache_mod.time, "monotonic", lambda: now[0])
+    cache = PrinterFileCache()
+
+    await cache.get(1, "/a", _Printer(b"x" * 10).fetch)
+    await cache.get(1, "/b", _Printer(b"y" * 10).fetch)
+    now[0] += cache_mod.CACHE_SECONDS + 1
+    await cache.get(1, "/c", _Printer(b"z" * 3).fetch)
+
+    assert list(cache._entries) == [(1, "/c")]
+    assert cache._total == 3
+
+
+async def test_a_failure_reaches_every_request_waiting_on_it():
+    cache, printer = PrinterFileCache(), _Printer()
+    printer.release.clear()
+
+    async def failing():
+        await printer.release.wait()
+        raise RuntimeError("printer busy")
+
+    tasks = [asyncio.create_task(cache.get(1, "/cache/a.3mf", failing)) for _ in range(3)]
+    await asyncio.sleep(0)
+    printer.release.set()
+    results = await asyncio.gather(*tasks, return_exceptions=True)
+
+    assert all(isinstance(r, RuntimeError) for r in results)
+    assert cache._total == 0
+
+
+async def test_a_printer_deleted_during_a_download_keeps_nothing():
+    cache, printer = PrinterFileCache(), _Printer()
+    printer.release.clear()
+
+    task = asyncio.create_task(cache.get(1, "/cache/a.3mf", printer.fetch))
+    await asyncio.sleep(0)
+    cache.forget_printer(1)
+    printer.release.set()
+    await task
+
+    assert cache._entries == {} and cache._total == 0
+
+
+async def test_files_that_expire_during_a_download_are_freed_when_it_lands(monkeypatch):
+    now = [1000.0]
+    monkeypatch.setattr(cache_mod.time, "monotonic", lambda: now[0])
+    cache = PrinterFileCache()
+    await cache.get(1, "/a", _Printer(b"x" * 10).fetch)
+    slow = _Printer(b"z" * 3)
+    slow.release.clear()
+
+    task = asyncio.create_task(cache.get(1, "/c", slow.fetch))
+    await asyncio.sleep(0)
+    now[0] += cache_mod.CACHE_SECONDS + 1
+    slow.release.set()
+    await task
+
+    assert list(cache._entries) == [(1, "/c")]
+    assert cache._total == 3

+ 4 - 0
backend/tests/unit/test_forget_printer.py

@@ -175,6 +175,9 @@ async def test_forget_printer_clears_the_services():
 
     printers._cover_cache[GONE] = {("Cube", "front"): b"png"}
     printers._cover_404_cache[GONE] = {}
+    from backend.app.services.printer_file_cache import printer_file_cache
+
+    printer_file_cache._keep((GONE, "/cache/a.3mf"), b"3mf")
 
     await main.forget_printer(GONE)
 
@@ -195,6 +198,7 @@ async def test_forget_printer_clears_the_services():
     assert GONE not in camera._active_external_streams
     assert GONE not in printers._cover_cache
     assert GONE not in printers._cover_404_cache
+    assert not [key for key in printer_file_cache._entries if key[0] == GONE]
 
 
 async def test_the_plate_references_of_a_printer_go(tmp_path, monkeypatch):

+ 48 - 0
backend/tests/unit/test_scheduler_release_conn_before_ftp_2572.py

@@ -169,3 +169,51 @@ async def test_connection_released_before_preheat_soak(dispatch_case):
     assert observed.get("in_transaction_at_soak") is False, (
         "the pooled connection was still idle-in-transaction during the heat-soak wait"
     )
+
+
+@pytest.mark.asyncio
+async def test_a_dispatch_forgets_the_file_browsers_copy(dispatch_case):
+    """The upload replaces the printer's file at that path (TO-C2): a preview
+    kept from before must not be served for the new one."""
+    from backend.app.services.printer_file_cache import printer_file_cache
+
+    scheduler = PrintScheduler()
+    printer_id = dispatch_case.ids.printer_id
+    paths: list[str] = []
+
+    async def delete_old(_ip, _code, remote_path, *args, **kwargs):
+        # The preview someone opened of the old file at this path.
+        paths.append(remote_path)
+        printer_file_cache._keep((printer_id, remote_path), b"old 3mf")
+        return True
+
+    printer_file_cache._keep((printer_id, "/other.3mf"), b"other")
+
+    async with dispatch_case.session_maker() as db:
+        item = await db.get(PrintQueueItem, dispatch_case.ids.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.printer_manager.start_print", MagicMock(return_value=True)),
+            patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
+            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", delete_old),
+            patch("backend.app.services.print_scheduler.upload_file_async", AsyncMock(return_value=True)),
+            patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
+            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)
+
+    assert paths, "the dispatch never replaced a file"
+    assert (printer_id, paths[0]) not in printer_file_cache._entries
+    assert (printer_id, "/other.3mf") in printer_file_cache._entries