test_stl_thumbnail_off_loop.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. """STL thumbnails are rendered off the event loop (RC-C12).
  2. trimesh loads the mesh and matplotlib draws it, which takes seconds on a
  3. high-poly file. Four library paths ran that on the event loop, so an upload,
  4. a ZIP of STLs, a batch "generate thumbnails" or an external folder scan froze
  5. every request and printer update until it finished. They now render in a
  6. worker thread, like the Manyfold import already did, and the renderer holds a
  7. lock: matplotlib's pyplot is not thread-safe, and two renders at once (a combine
  8. and an import, say) could draw into each other's figure.
  9. """
  10. import contextlib
  11. import io
  12. import struct
  13. import threading
  14. import zipfile
  15. import pytest
  16. from httpx import AsyncClient
  17. from backend.app.api.routes import library
  18. from backend.app.services import stl_thumbnail
  19. def _binary_stl(triangles: int = 8) -> bytes:
  20. """A binary STL comfortably above MIN_USABLE_STL_BYTES."""
  21. header = b"bambuddy test".ljust(80, b"\0")
  22. body = b""
  23. for i in range(triangles):
  24. body += struct.pack("<12fH", 0, 0, 1, 0, 0, i, 1, 0, i, 0, 1, i, 0)
  25. return header + struct.pack("<I", triangles) + body
  26. @pytest.fixture
  27. def render_threads(monkeypatch):
  28. """Record the thread every thumbnail render runs in."""
  29. seen: list[int] = []
  30. def fake(_stl_path, _thumbnails_dir, size=256):
  31. seen.append(threading.get_ident())
  32. # Their own worker, not the shared pool every other to_thread call uses.
  33. assert threading.current_thread().name.startswith("stl-thumbnail")
  34. return None
  35. monkeypatch.setattr(library, "generate_stl_thumbnail", fake)
  36. return seen
  37. @pytest.mark.asyncio
  38. @pytest.mark.integration
  39. async def test_an_upload_renders_off_the_loop(async_client: AsyncClient, render_threads):
  40. loop_thread = threading.get_ident()
  41. resp = await async_client.post(
  42. "/api/v1/library/files", files={"file": ("part.stl", _binary_stl(), "application/octet-stream")}
  43. )
  44. assert resp.status_code == 200, resp.text
  45. assert render_threads and loop_thread not in render_threads
  46. @pytest.mark.asyncio
  47. @pytest.mark.integration
  48. async def test_a_zip_of_stls_renders_off_the_loop(async_client: AsyncClient, render_threads):
  49. loop_thread = threading.get_ident()
  50. buf = io.BytesIO()
  51. with zipfile.ZipFile(buf, "w") as zf:
  52. zf.writestr("a.stl", _binary_stl())
  53. zf.writestr("b.stl", _binary_stl())
  54. resp = await async_client.post(
  55. "/api/v1/library/files/extract-zip", files={"file": ("models.zip", buf.getvalue(), "application/zip")}
  56. )
  57. assert resp.status_code == 200, resp.text
  58. assert len(render_threads) == 2 and loop_thread not in render_threads
  59. @pytest.mark.asyncio
  60. @pytest.mark.integration
  61. async def test_a_batch_renders_off_the_loop(async_client: AsyncClient, render_threads):
  62. upload = await async_client.post(
  63. "/api/v1/library/files",
  64. files={"file": ("part.stl", _binary_stl(), "application/octet-stream")},
  65. params={"generate_stl_thumbnails": "false"},
  66. )
  67. assert upload.status_code == 200, upload.text
  68. loop_thread = threading.get_ident()
  69. resp = await async_client.post("/api/v1/library/generate-stl-thumbnails", json={"all_missing": True})
  70. assert resp.status_code == 200, resp.text
  71. assert render_threads and loop_thread not in render_threads
  72. @pytest.mark.asyncio
  73. @pytest.mark.integration
  74. async def test_an_external_folder_backfill_renders_off_the_loop(
  75. async_client: AsyncClient, db_session, render_threads, monkeypatch
  76. ):
  77. upload = await async_client.post(
  78. "/api/v1/library/files",
  79. files={"file": ("part.stl", _binary_stl(), "application/octet-stream")},
  80. params={"generate_stl_thumbnails": "false"},
  81. )
  82. assert upload.status_code == 200, upload.text
  83. folder = await async_client.post("/api/v1/library/folders", json={"name": "ext"})
  84. from backend.app.models.library import LibraryFile
  85. row = await db_session.get(LibraryFile, upload.json()["id"])
  86. row.folder_id = folder.json()["id"]
  87. await db_session.commit()
  88. @contextlib.asynccontextmanager
  89. async def session():
  90. yield db_session
  91. monkeypatch.setattr(library, "async_session", session)
  92. loop_thread = threading.get_ident()
  93. await library._backfill_external_thumbnails([folder.json()["id"]])
  94. assert render_threads and loop_thread not in render_threads
  95. def test_renders_wait_for_each_other():
  96. """The figure is pyplot's global state: one render at a time."""
  97. import trimesh
  98. mesh = trimesh.creation.box()
  99. finished = threading.Event()
  100. def render():
  101. stl_thumbnail.render_mesh_png(mesh, size=64)
  102. finished.set()
  103. with stl_thumbnail._RENDER_LOCK:
  104. worker = threading.Thread(target=render)
  105. worker.start()
  106. assert not finished.wait(0.5)
  107. worker.join(30)
  108. assert finished.is_set()
  109. def test_one_thumbnail_is_made_at_a_time(tmp_path, monkeypatch):
  110. """Loading included: several large meshes are never held in memory together."""
  111. import trimesh
  112. stl = tmp_path / "part.stl"
  113. stl.write_bytes(_binary_stl())
  114. loading = threading.Event()
  115. real_load = trimesh.load
  116. def load(*args, **kwargs):
  117. loading.set()
  118. return real_load(*args, **kwargs)
  119. monkeypatch.setattr(trimesh, "load", load)
  120. with stl_thumbnail._RENDER_LOCK:
  121. worker = threading.Thread(target=stl_thumbnail.generate_stl_thumbnail, args=(stl, tmp_path))
  122. worker.start()
  123. assert not loading.wait(0.5)
  124. worker.join(30)
  125. assert loading.is_set()