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).
Native (non-Docker) installs launched uvicorn without --loop asyncio, so
uvicorn[standard] auto-selected uvloop. uvloop's SSL layer drops
already-received but still-buffered data when the client closes the data
connection without a TLS close_notify while the reader is flow-control
paused on slow storage. cmd_STOR writes each chunk to disk inside the read
loop, so a slow consumer falls behind, the tail is lost, read() returns a
clean EOF, and the loop exits with no exception -- the server acked 226 for
a file it truncated itself, then archived, queued, and forwarded the corrupt
3MF to the real printer.
Fix in two independent layers:
1. Remove the trigger: add --loop asyncio to every native launch path,
matching the Dockerfile -- deploy/bambuddy.service, install/install.sh
(systemd + launchd), spoolbuddy/install/install.sh, the Windows NSSM
service, README, CONTRIBUTING dev command.
2. Defense in depth (loop-independent): cmd_STOR now validates that a
received .3mf opens as a ZIP (reads the central directory, no
decompression) before replying 226. A truncated/corrupt file is dropped
and answered with 426, and on_file_received never runs -- so a broken
upload surfaces as an immediate slicer-side send error instead of being
archived and pushed to the printer. Scoped to .3mf; other filetypes pass
through unchanged.
bambuddy.service shipped with ProtectHome=true, which makes /home/* invisible
to the service namespace. Installing into /home/bambuddy/ (instead of the
default /opt/bambuddy/) made ExecStart=/home/bambuddy/venv/bin/uvicorn fail
with status=203/EXEC because systemd couldn't resolve the binary path.
ReadWritePaths=$INSTALL_PATH does not reliably re-expose /home/* subpaths for
exec resolution.
install/install.sh now detects /home/* INSTALL_PATH and emits ProtectHome=read-only;
default /opt/bambuddy installs keep ProtectHome=true. The manual deploy template
defaults to read-only with a comment on when to tighten it.
read-only keeps /home immutable to the service - no security regression, since
ReadWritePaths still gates writes to the install/data/log dirs only.
Two related failure modes have been biting Docker users repeatedly,
most recently in #1211:
1. Docker named volumes are created by the daemon as root:root, and
the previous `chmod 777 /app/data` Dockerfile workaround only
covered the named-volume root — so subdirs Bambuddy creates at
runtime (virtual_printer/uploads, virtual_printer/certs, etc.)
inherited wrong ownership when the container ran as 1000:1000.
2. The shipped docker-compose.yml ships
`./virtual_printer:/app/data/virtual_printer` uncommented, and
dockerd creates a missing bind-mount source on the host as root
before the container starts — leaving the host directory
unwritable by uid 1000 inside the container even though the named
volume above it had the chmod-777 workaround.
Symptom either way: [Errno 13] Permission denied:
'/app/data/virtual_printer/uploads', no virtual printer ever starts,
"VP doesn't work" support reports follow.
Replace the chmod-777 hack with a proper entrypoint:
- deploy/docker-entrypoint.sh runs as root, chowns /app/data and
/app/logs (and /app/data/virtual_printer when bind-mounted) to
PUID:PGID, then drops to that uid via gosu before exec'ing the
app. The chown is gated behind a top-level ownership check so
subsequent restarts skip the recursive traversal — no multi-
second startup penalty on multi-GB archive directories.
- A sentinel .bambuddy file in each data path prevents Docker from
re-syncing image directory metadata on every mount (otherwise
empty volumes have their ownership reverted from the image on
each restart, defeating the idempotency).
- When the container is started with an explicit `user:` directive
or `--user` flag the entrypoint detects it isn't root and falls
through to direct exec — preserving compatibility for users who
pin a specific uid.
Compose template changes:
- Remove `user: "${PUID:-1000}:${PGID:-1000}"` (entrypoint owns
privilege drop now).
- Add PUID / PGID env vars with the same defaults.
- Comment out the ./virtual_printer:/app/data/virtual_printer
bind mount by default, with explicit "only needed if you also
run a native install of Bambuddy on the same host and want both
to share the VP CA cert" guidance. The entrypoint chowns the
host-side dir through the bind mount the first time it sees
wrong ownership, so existing uncomented installs continue to
work and #1211 specifically gets fixed.
Root cause: When a print completed with the camera stream popup open,
spawning a second ffmpeg process for finish photo capture caused a
conflict that froze the browser tab and video window.
Solution: Buffer the last frame from active camera streams. When
capturing finish photo, use the buffered frame if a stream is active
instead of spawning a new ffmpeg process.
Backend changes:
- camera.py: Added _last_frames buffer and get_buffered_frame() helper
- main.py: Photo capture uses buffered frame when stream is active
- main.py: Moved slow operations to background tasks (energy calc,
photo capture, smart plug, notifications, maintenance)
- archive.py: Fixed sync file write blocking event loop (asyncio.to_thread)
- printers.py: Added debug endpoint to simulate print completion
Frontend changes:
- useWebSocket.ts: Throttled printer status updates (100ms) to prevent
UI overload from rapid WebSocket messages
- useWebSocket.ts: Debounced archive invalidations (3s) to prevent
cascade of re-renders on print completion
- useWebSocket.test.ts: Updated tests for throttled/debounced handlers
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>