Files
bambuddy/backend/tests/unit/test_launcher_shutdown_config.py
maziggy 08df660f6c Replace the embedded G-code viewer with the slicer's own renderer
Sliced files previewed through a vendored copy of PrettyGCode in an
iframe. It drew each move as a screen-space line -- a line has no
thickness in the scene, so it cannot occlude the layer behind it, which
is why prints came out stringy and shimmered where layers crossed. Being
a separate app in a frame, it could be neither themed nor translated, and
carried its own machinery for detecting a proxy refusing the embed.

Now built on libvgcode, the renderer OrcaSlicer draws its own preview
with, vendored from three-slicer (AGPL, same as us). It takes the THREE
namespace as an argument and imports nothing, so it runs on our 0.181
rather than the 0.160 its package pins.

The parser is ours; upstream renders its own kernel's output and ships no
G-code parser at all. Two things it has to get right, both found by
checking a real plate rather than assuming:

- BambuStudio does not use the OrcaSlicer/PrusaSlicer annotations. It
  writes "; FEATURE:", "; LINE_WIDTH:", "; CHANGE_LAYER" and
  "; Z_HEIGHT:", not ";TYPE:", ";WIDTH:" and ";LAYER_CHANGE". Reading
  only the latter showed a 52-layer print as 23,165 layers in one colour,
  because with no layer marker recognised every travel Z-hop split a
  layer and every segment took the fallback feature.
- It emits a tenth of its moves as G2/G3 arcs -- 706 extruding ones in a
  single plate. Ignoring them punched holes through curved walls and tree
  supports. Arcs with no X/Y are the helical travel lift and lay down
  nothing, so they interpolate as travels.

Four colour modes: filament (default, from the AMS slots the file was
sliced with), feature, layer height, line width. Speed, fan and
temperature are deliberately absent -- upstream derives those from
settings rather than the toolpath, and guesses dressed as measurements
are worse than an honest omission. The parser now carries the data to do
them properly later.

Legend entries are switches. Hiding removes the records before the mesh
is built rather than recolouring them: the shader packs colour into a
single float with no alpha, so there is no transparent to set, and
removal is the useful behaviour anyway -- a hidden support stops
occluding what it covered.

The scene is built once and only the toolpath rebuilds. Doing otherwise
constructed a new WebGLRenderer on every render, because the buildVolume
default is an object literal and so a fresh identity each time; browsers
cap live WebGL contexts and drop the oldest, which blanked the canvas
after a few interactions.

utils/framing.ts goes with the iframe, along with six now-orphaned
strings in all 13 locales. src/lib/vendor is excluded from eslint --
acting on findings in vendored code makes it impossible to re-copy on the
next upstream release.
2026-08-09 14:10:18 +02:00

141 lines
6.4 KiB
Python

"""Every launcher must be able to shut Bambuddy down gracefully.
Two defects, found together, both invisible until you look for them:
1. **The Docker image never received SIGTERM at all.** ``CMD ["sh", "-c",
"uvicorn ..."]`` leaves the shell as PID 1 with uvicorn as its child, and
dash does not forward signals. Measured on the shipped image: ``docker stop``
ran the full 10s grace period, exited 137 (SIGKILL), and the container log
contained no "Shutting down" line. So *every* stop, restart and image update
was a hard kill — no WAL checkpoint, no MQTT disconnect, no virtual-printer
teardown. ``exec`` makes uvicorn PID 1 and the signal lands.
2. **Uvicorn waits forever for in-flight requests.**
``timeout_graceful_shutdown`` defaults to None, and an MJPEG camera stream is
a response that never completes — ``httptools``'s connection ``shutdown()``
only flips ``keep_alive = False`` on an in-flight cycle, it does not close the
transport. One open camera tile pins the process indefinitely, and the app's
own teardown never runs because uvicorn only fires the lifespan shutdown
*after* connections drain. The flag caps the wait and cancels the tasks; the
camera generators already unwind cleanly on CancelledError.
Neither shows up in any functional test — the app is perfectly healthy right up
until you ask it to stop. Hence this: pin the launchers themselves.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[3]
FLAG = "--timeout-graceful-shutdown"
# These pin repo-root launcher files (Dockerfile, compose, service units,
# install scripts) that the Docker test image deliberately does not ship —
# Dockerfile.test copies only backend/, pyproject.toml and
# requirements. In a source checkout the files are always present and the
# guard below is live (a moved/deleted launcher still fails loudly on every
# `test_backend.sh` run); inside the stripped test image there is nothing to
# check, so skip rather than fail. `frontend/package.json` is present in every
# checkout but never in the test image, so it distinguishes the two.
pytestmark = pytest.mark.skipif(
not (REPO / "frontend" / "package.json").is_file(),
reason="launcher config files aren't shipped in the Docker test image; verified in native runs",
)
def _read(rel: str) -> str:
path = REPO / rel
assert path.is_file(), f"launcher moved or was removed: {rel}"
return path.read_text()
def _uvicorn_lines(text: str) -> list[str]:
"""Lines that actually launch uvicorn, ignoring comments about it."""
return [
line for line in text.splitlines() if "uvicorn" in line and not line.lstrip().startswith(("#", "REM", "<!--"))
]
class TestDockerImage:
def test_cmd_execs_uvicorn_so_it_becomes_pid_1(self):
"""Without exec, `sh` is PID 1, dash eats the SIGTERM, and docker stop
always ends in SIGKILL after the grace period.
"""
cmd = next(line for line in _read("Dockerfile").splitlines() if line.startswith("CMD "))
assert "exec uvicorn" in cmd, (
"Dockerfile CMD must `exec` uvicorn. Without it the shell stays as PID 1, "
"uvicorn never receives SIGTERM, and every docker stop is a SIGKILL:\n" + cmd
)
def test_cmd_bounds_the_graceful_shutdown(self):
cmd = next(line for line in _read("Dockerfile").splitlines() if line.startswith("CMD "))
assert FLAG in cmd, cmd
def test_compose_allows_more_than_dockers_default_grace(self):
compose = _read("docker-compose.yml")
assert "stop_grace_period:" in compose, (
"docker-compose.yml should raise stop_grace_period above Docker's 10s default, "
"so a slow teardown on a Pi is not clipped by a SIGKILL."
)
class TestSystemdUnits:
@pytest.mark.parametrize("unit", ["deploy/bambuddy.service"])
def test_execstart_bounds_the_graceful_shutdown(self, unit):
exec_start = next(line for line in _read(unit).splitlines() if line.startswith("ExecStart="))
assert FLAG in exec_start, exec_start
@pytest.mark.parametrize("unit", ["deploy/bambuddy.service"])
def test_stop_timeout_leaves_room_for_the_teardown(self, unit):
"""systemd's timer is the backstop, not the mechanism — but it still has
to outlast uvicorn's own 5s wait plus the app's ~1-2s of teardown.
"""
match = re.search(r"^TimeoutStopSec=(\d+)", _read(unit), re.M)
assert match, "unit should state a TimeoutStopSec rather than inherit the 90s default"
assert int(match.group(1)) >= 15, (
f"TimeoutStopSec={match.group(1)}s can clip the teardown: uvicorn waits up to 5s "
"for in-flight requests, then the app checkpoints the WAL and stops the virtual "
"printers."
)
class TestInstallScript:
def test_generated_systemd_unit_bounds_the_shutdown(self):
lines = _uvicorn_lines(_read("install/install.sh"))
exec_start = [line for line in lines if line.startswith("ExecStart=")]
assert exec_start, "install.sh no longer emits a systemd ExecStart line"
for line in exec_start:
assert FLAG in line, line
def test_generated_launchd_plist_bounds_the_shutdown(self):
"""The macOS plist passes argv as a <string> array, so the flag and its
value are two separate entries.
"""
plist_region = _read("install/install.sh")
assert f"<string>{FLAG}</string>" in plist_region, (
"the launchd plist in install.sh does not pass --timeout-graceful-shutdown"
)
class TestWindowsService:
def test_nssm_registration_bounds_the_shutdown(self):
bat = _read("installers/windows/service/install-service.bat")
install_line = next(line for line in _uvicorn_lines(bat) if "install Bambuddy" in line)
assert FLAG in install_line, install_line
def test_nssm_waits_long_enough_for_the_ctrl_c_stop(self):
"""NSSM's default AppStopMethodConsole is 1500ms. Uvicorn shuts down on
the Ctrl-C but needs longer than that, so Windows was force-killing it
mid-teardown.
"""
bat = _read("installers/windows/service/install-service.bat")
match = re.search(r"AppStopMethodConsole\s+(\d+)", bat)
assert match, "install-service.bat must raise NSSM's 1500ms console-stop default"
assert int(match.group(1)) >= 10000, match.group(1)