ソースを参照

fix(library): STL thumbnails are made off the event loop, one at a time

Upload, ZIP extraction, the batch thumbnail run and the external folder
scan loaded and rendered STL thumbnails on the event loop, so a
high-poly model froze the web interface and printer updates until it
was done. They now run on a dedicated worker thread, like the Manyfold
import, and a lock makes one thumbnail at a time: pyplot is not
thread-safe, and one large mesh in memory at a time.
maziggy 5 日 前
親
コミット
bdbfa5d8fd

+ 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
+- **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.
 - **A frozen external RTSP camera is reconnected** — When an external RTSP camera stops sending, `ffmpeg` can keep repeating the last picture, so the live view and `/camera/status` kept showing a picture that had stopped changing, and so did anything reusing the live frame (timelapse frames, finish photos, Obico). Like the printer's own camera, an external RTSP camera that sends the same picture for 20 seconds is now reconnected. A camera that is simply slow to change (a slow source, or a smart codec on a static night scene) is restarted at most once, after which Bambuddy waits three times the longest gap it has seen, up to 5 minutes; a picture that really does not change is re-checked every 5 minutes.
 - **A USB camera can be watched from several places at once** — Every viewer of an external camera opened its own connection to it. A USB camera can only be read by one program at a time, so opening the Camera page while a Cam Wall tile (or another browser) was already showing it gave a blank picture. External cameras now share one connection per printer, like the printer's own camera, which also means one connection per MJPEG or snapshot camera and one `ffmpeg` per RTSP or USB camera instead of one per viewer. As with the printer's own camera, the viewer who opens the connection sets its frame rate: with a Cam Wall tile already showing the camera, the Camera page gets the tile's 8 frames per second until everyone has closed it.
 - **Camera snapshots and the camera test no longer fail while someone watches the live view** — A USB camera can be read by one program at a time, so while a viewer was watching, every snapshot Home Assistant or the camera wall fetched from an external camera failed, and the external camera's **Test** button failed too. Snapshots now return the live view's frame, and the Test button reports the camera working from that frame. While the live view is still starting, a snapshot asks the caller to try again a moment later instead of taking the camera from the viewer (the camera wall and the camera page now retry once on their own); this now applies to the printer's own camera as well. An external camera with its own snapshot URL is still fetched from that URL.

+ 19 - 7
backend/app/api/routes/library.py

@@ -12,6 +12,7 @@ import re
 import shutil
 import uuid
 import zipfile
+from concurrent.futures import ThreadPoolExecutor
 from datetime import datetime, timezone
 from pathlib import Path
 
@@ -756,9 +757,7 @@ async def save_3mf_bytes_to_library(
         # a triangle.
         try:
             if file_path.stat().st_size >= MIN_USABLE_STL_BYTES:
-                thumbnail_path = await asyncio.to_thread(
-                    generate_stl_thumbnail, file_path, get_library_thumbnails_dir()
-                )
+                thumbnail_path = await _render_stl_thumbnail(file_path, get_library_thumbnails_dir())
         except Exception as exc:  # noqa: BLE001 — a thumbnail must never fail the import
             logger.warning("Failed to render STL thumbnail for %s: %s", filename, exc)
 
@@ -933,6 +932,19 @@ MAX_CLIENT_THUMBNAIL_EDGE = 2048
 STORED_CLIENT_THUMBNAIL_EDGE = 512
 
 
+# STL thumbnails get their own single worker thread. They are made one at a
+# time anyway (stl_thumbnail._RENDER_LOCK), and in the shared default pool the
+# ones waiting their turn would hold threads that every other to_thread call in
+# the app needs: a burst of STL uploads would stall them all.
+_STL_THUMBNAIL_EXECUTOR = ThreadPoolExecutor(max_workers=1, thread_name_prefix="stl-thumbnail")
+
+
+async def _render_stl_thumbnail(stl_path: Path, thumbnails_dir: Path) -> str | None:
+    """generate_stl_thumbnail, off the event loop: a large mesh takes seconds."""
+    loop = asyncio.get_running_loop()
+    return await loop.run_in_executor(_STL_THUMBNAIL_EXECUTOR, generate_stl_thumbnail, stl_path, thumbnails_dir)
+
+
 async def _backfill_external_thumbnails(folder_ids: list[int]) -> None:
     """Generate STL and PDF thumbnails for an external folder tree in the background.
 
@@ -991,7 +1003,7 @@ async def _backfill_external_thumbnails(folder_ids: list[int]) -> None:
             except OSError:
                 continue
             try:
-                thumb_path = generate_stl_thumbnail(abs_path, thumbnails_dir)
+                thumb_path = await _render_stl_thumbnail(abs_path, thumbnails_dir)
             except Exception as exc:  # noqa: BLE001 — never let one bad STL kill the rest
                 logger.debug("STL thumbnail backfill skipped %s: %s", abs_path, exc)
                 continue
@@ -2554,7 +2566,7 @@ async def upload_file(
             if generate_stl_thumbnails:
                 try:
                     if file_path.stat().st_size >= MIN_USABLE_STL_BYTES:
-                        thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
+                        thumbnail_path = await _render_stl_thumbnail(file_path, thumbnails_dir)
                 except OSError:
                     pass
 
@@ -2852,7 +2864,7 @@ async def extract_zip_file(
                         # stub STLs would otherwise log one debug line per
                         # file via the empty-mesh branch in trimesh.load.
                         if generate_stl_thumbnails and len(file_content) >= MIN_USABLE_STL_BYTES:
-                            thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
+                            thumbnail_path = await _render_stl_thumbnail(file_path, thumbnails_dir)
 
                     # Create database entry (store relative paths for portability)
                     library_file = LibraryFile(
@@ -2999,7 +3011,7 @@ async def batch_generate_stl_thumbnails(
             if target_file.file_type == "pdf":
                 thumbnail_path = await asyncio.to_thread(generate_pdf_thumbnail, file_path, thumbnails_dir)
             else:
-                thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
+                thumbnail_path = await _render_stl_thumbnail(file_path, thumbnails_dir)
 
             if thumbnail_path:
                 # Update database with relative path

+ 6 - 6
backend/app/services/plate_thumbnail.py

@@ -437,12 +437,12 @@ def _render_at_size(poly3d, size: int, Poly3DCollection, shade_kw: dict) -> byte
     """Render the prepared poly3d collection to an in-memory PNG.
 
     Matplotlib's object API, not pyplot. This runs in a worker thread (#3135)
-    while stl_thumbnail renders through pyplot on the event loop, and pyplot's
-    figure registry and "current figure" are process-global: its
-    ``subplots_adjust`` would lay out whichever figure the other thread made
-    last, and neither lock placement is acceptable — held on the loop it stalls
-    the server for the whole plate render. A ``Figure`` with its own Agg canvas
-    shares nothing, so the two can run at once.
+    while stl_thumbnail renders through pyplot in another, and pyplot's figure
+    registry and "current figure" are process-global: its ``subplots_adjust``
+    would lay out whichever figure the other thread made last. stl_thumbnail
+    serialises its own renders with _RENDER_LOCK; a ``Figure`` with its own Agg
+    canvas shares nothing, so this needs no part in that lock and the two can
+    run at once.
     """
     # Local, like every other import in this module, so importing plate_thumbnail
     # in an environment without matplotlib still works. stl_thumbnail's own

+ 29 - 0
backend/app/services/stl_thumbnail.py

@@ -5,6 +5,7 @@ Generates thumbnail images from STL files using trimesh and matplotlib.
 
 import logging
 import os
+import threading
 import uuid
 from pathlib import Path
 
@@ -197,7 +198,22 @@ def _import_renderer():
     return plt, LightSource, Poly3DCollection
 
 
+# One thumbnail at a time. Callers run in worker threads (asyncio.to_thread) so
+# a slow mesh never blocks the event loop. pyplot's current-figure state is
+# global and not thread-safe, so two renders at once could draw into each
+# other's figure; and one load at a time keeps several large meshes from being
+# held in memory together. Re-entrant: generate_stl_thumbnail holds it around
+# the load and render_mesh_png takes it again.
+_RENDER_LOCK = threading.RLock()
+
+
 def render_mesh_png(mesh, size: int = 256, label: str = "mesh") -> bytes:
+    """Render ``mesh`` to PNG bytes; see ``_render_mesh_png``. One at a time."""
+    with _RENDER_LOCK:
+        return _render_mesh_png(mesh, size=size, label=label)
+
+
+def _render_mesh_png(mesh, size: int = 256, label: str = "mesh") -> bytes:
     """Render ``mesh`` (a ``trimesh.Trimesh``) to PNG bytes.
 
     The caller owns the face budget: this draws every face it is given, so a
@@ -298,6 +314,19 @@ def generate_stl_thumbnail(
     stl_path: Path,
     thumbnails_dir: Path,
     size: int = 256,
+) -> str | None:
+    """Generate a thumbnail image from an STL file; see ``_generate_stl_thumbnail``.
+
+    One at a time (``_RENDER_LOCK``). Blocking: call it in a worker thread.
+    """
+    with _RENDER_LOCK:
+        return _generate_stl_thumbnail(stl_path, thumbnails_dir, size)
+
+
+def _generate_stl_thumbnail(
+    stl_path: Path,
+    thumbnails_dir: Path,
+    size: int = 256,
 ) -> str | None:
     """Generate a thumbnail image from an STL file.
 

+ 166 - 0
backend/tests/integration/test_stl_thumbnail_off_loop.py

@@ -0,0 +1,166 @@
+"""STL thumbnails are rendered off the event loop (RC-C12).
+
+trimesh loads the mesh and matplotlib draws it, which takes seconds on a
+high-poly file. Four library paths ran that on the event loop, so an upload,
+a ZIP of STLs, a batch "generate thumbnails" or an external folder scan froze
+every request and printer update until it finished. They now render in a
+worker thread, like the Manyfold import already did, and the renderer holds a
+lock: matplotlib's pyplot is not thread-safe, and two renders at once (a combine
+and an import, say) could draw into each other's figure.
+"""
+
+import contextlib
+import io
+import struct
+import threading
+import zipfile
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.api.routes import library
+from backend.app.services import stl_thumbnail
+
+
+def _binary_stl(triangles: int = 8) -> bytes:
+    """A binary STL comfortably above MIN_USABLE_STL_BYTES."""
+    header = b"bambuddy test".ljust(80, b"\0")
+    body = b""
+    for i in range(triangles):
+        body += struct.pack("<12fH", 0, 0, 1, 0, 0, i, 1, 0, i, 0, 1, i, 0)
+    return header + struct.pack("<I", triangles) + body
+
+
+@pytest.fixture
+def render_threads(monkeypatch):
+    """Record the thread every thumbnail render runs in."""
+    seen: list[int] = []
+
+    def fake(_stl_path, _thumbnails_dir, size=256):
+        seen.append(threading.get_ident())
+        # Their own worker, not the shared pool every other to_thread call uses.
+        assert threading.current_thread().name.startswith("stl-thumbnail")
+        return None
+
+    monkeypatch.setattr(library, "generate_stl_thumbnail", fake)
+    return seen
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_an_upload_renders_off_the_loop(async_client: AsyncClient, render_threads):
+    loop_thread = threading.get_ident()
+    resp = await async_client.post(
+        "/api/v1/library/files", files={"file": ("part.stl", _binary_stl(), "application/octet-stream")}
+    )
+
+    assert resp.status_code == 200, resp.text
+    assert render_threads and loop_thread not in render_threads
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_zip_of_stls_renders_off_the_loop(async_client: AsyncClient, render_threads):
+    loop_thread = threading.get_ident()
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w") as zf:
+        zf.writestr("a.stl", _binary_stl())
+        zf.writestr("b.stl", _binary_stl())
+
+    resp = await async_client.post(
+        "/api/v1/library/files/extract-zip", files={"file": ("models.zip", buf.getvalue(), "application/zip")}
+    )
+
+    assert resp.status_code == 200, resp.text
+    assert len(render_threads) == 2 and loop_thread not in render_threads
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_batch_renders_off_the_loop(async_client: AsyncClient, render_threads):
+    upload = await async_client.post(
+        "/api/v1/library/files",
+        files={"file": ("part.stl", _binary_stl(), "application/octet-stream")},
+        params={"generate_stl_thumbnails": "false"},
+    )
+    assert upload.status_code == 200, upload.text
+    loop_thread = threading.get_ident()
+
+    resp = await async_client.post("/api/v1/library/generate-stl-thumbnails", json={"all_missing": True})
+
+    assert resp.status_code == 200, resp.text
+    assert render_threads and loop_thread not in render_threads
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_an_external_folder_backfill_renders_off_the_loop(
+    async_client: AsyncClient, db_session, render_threads, monkeypatch
+):
+    upload = await async_client.post(
+        "/api/v1/library/files",
+        files={"file": ("part.stl", _binary_stl(), "application/octet-stream")},
+        params={"generate_stl_thumbnails": "false"},
+    )
+    assert upload.status_code == 200, upload.text
+    folder = await async_client.post("/api/v1/library/folders", json={"name": "ext"})
+    from backend.app.models.library import LibraryFile
+
+    row = await db_session.get(LibraryFile, upload.json()["id"])
+    row.folder_id = folder.json()["id"]
+    await db_session.commit()
+
+    @contextlib.asynccontextmanager
+    async def session():
+        yield db_session
+
+    monkeypatch.setattr(library, "async_session", session)
+    loop_thread = threading.get_ident()
+
+    await library._backfill_external_thumbnails([folder.json()["id"]])
+
+    assert render_threads and loop_thread not in render_threads
+
+
+def test_renders_wait_for_each_other():
+    """The figure is pyplot's global state: one render at a time."""
+    import trimesh
+
+    mesh = trimesh.creation.box()
+    finished = threading.Event()
+
+    def render():
+        stl_thumbnail.render_mesh_png(mesh, size=64)
+        finished.set()
+
+    with stl_thumbnail._RENDER_LOCK:
+        worker = threading.Thread(target=render)
+        worker.start()
+        assert not finished.wait(0.5)
+    worker.join(30)
+
+    assert finished.is_set()
+
+
+def test_one_thumbnail_is_made_at_a_time(tmp_path, monkeypatch):
+    """Loading included: several large meshes are never held in memory together."""
+    import trimesh
+
+    stl = tmp_path / "part.stl"
+    stl.write_bytes(_binary_stl())
+    loading = threading.Event()
+    real_load = trimesh.load
+
+    def load(*args, **kwargs):
+        loading.set()
+        return real_load(*args, **kwargs)
+
+    monkeypatch.setattr(trimesh, "load", load)
+
+    with stl_thumbnail._RENDER_LOCK:
+        worker = threading.Thread(target=stl_thumbnail.generate_stl_thumbnail, args=(stl, tmp_path))
+        worker.start()
+        assert not loading.wait(0.5)
+    worker.join(30)
+
+    assert loading.is_set()