Files
bambuddy/backend/tests/unit/test_launcher_shutdown_config.py
maziggy ba1394db3e fix(shutdown): exec uvicorn as PID 1 in Docker, and bound the graceful-shutdown wait
Two defects, both invisible until you ask the app to stop.

Docker never shut down gracefully at all. CMD ["sh","-c","uvicorn ..."] left
the shell as PID 1 with uvicorn as its child, and dash does not forward
signals, so docker stop SIGTERMed the shell and uvicorn never heard about it.
Measured on the shipped image: the full 10s grace period, exit 137, and no
"Shutting down" line in the log. 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; the rebuilt image now stops in 1s with
exit 0 and checkpoints the WAL.

Separately, uvicorn's timeout_graceful_shutdown defaults to None -- wait
forever for in-flight requests. An MJPEG camera stream is a response that
never completes (httptools' connection shutdown() only flips keep_alive on an
in-flight cycle, it never closes the transport), so one open camera tile
pinned the process until systemd SIGKILLed at 90s. The ordering makes it
unfixable from inside the app: uvicorn fires the lifespan shutdown -- the code
that tears the streams down -- only after connections drain.

All six launchers now pass --timeout-graceful-shutdown 5: Dockerfile,
deploy/bambuddy.service, the systemd unit and launchd plist from
install/install.sh, the SpoolBuddy installer's unit, and the Windows NSSM
registration. On timeout uvicorn cancels the request tasks; the camera
generators already unwind cleanly on CancelledError.

TimeoutStopSec raised to 30s on the units and stop_grace_period: 30s added to
compose, as backstops rather than the mechanism. On Windows NSSM's default
1500ms AppStopMethodConsole was force-killing uvicorn mid-teardown; raised to
15s, with the WM_CLOSE and thread-message stages skipped (uvicorn is a console
app with neither a window nor a message loop).
2026-07-11 14:44:45 +02:00

128 lines
5.6 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"
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)