Keep the RTSPS proxy's handler set off the server object (issue #3001)

asyncio's Server has a __dict__ and uvloop's, a Cython cdef class, does
not, so the attribute added in 1.2.5.4 raised AttributeError under uvloop.
Every RTSP camera failed before opening a socket, which is the
diagnostic's capture_exception at 0 ms.

Our own unit files all pin --loop asyncio for #1896 and were never
affected. The reports come from units we do not write: the Proxmox VE
Helper-Scripts LXC pins no loop, and installs predating that fix never
gained the flag because update.sh does not rewrite unit files. The loop
is not ours to assume, so fix the code rather than add another flag.

The set moves to a module-level WeakKeyDictionary, keyed weakly so an
abandoned proxy retires its own entry rather than leaking one and later
handing a new server a dead one's handlers.

Pinned on a real uvloop loop and, for hosts without uvloop, against a
__slots__ server; conftest builds its loop from the default policy, so
nothing in the suite had ever run the branch that broke.

Also routes the two external-camera teardowns through close_tls_proxy,
which #2968 introduced and left them out of.

-----

Say so at startup when running on uvloop (issue #3001)

An install on the wrong loop had no way to find out it was. #3001 was
loud enough to notice; the #1896 upload truncation it is also exposed to
is silent, and shows up as a print failing from a file that was corrupt
on arrival.

One WARNING in the lifespan naming the loop, the risk and the flag to
add. A warning and not a refusal: uvicorn has already chosen its loop by
the time any application code runs, and a server that answers requests
beats one that will not boot.

Asks the running loop what it is rather than whether uvloop imports --
uvicorn[standard] installs uvloop everywhere, so its presence says
nothing -- and matches on the module name so the question never imports
uvloop on a host without it.

-----

Repair a service file written before the --loop asyncio pin (issue #3001)

install.sh has pinned the loop since #1896, but nothing has ever
rewritten an existing service file, so every native install created
between 2025-11-28 (when uvicorn[standard] brought uvloop into the venv)
and 2026-07-05 still runs on uvloop no matter how often it is updated.

Both update scripts now add the flag themselves while the service is
stopped, so it takes effect on the same restart -- systemd via sed,
launchd via PlistBuddy, each backing the file up first and inserting
nothing but the flag.

Refuses to edit and explains instead when the shape is not a plain
single-line uvicorn unit: a wrapper script, a continued ExecStart,
several of them, a read-only file, or a service with drop-ins, since a
drop-in may be what defines ExecStart and editing the fragment would
change nothing while reporting success. A deliberate --loop uvloop is
left alone. Reads the effective ExecStart from systemd rather than the
file, so it is idempotent.
This commit is contained in:
maziggy
2026-08-30 08:01:42 +02:00
parent 3f1ed85791
commit 0dfcff5925
10 changed files with 449 additions and 27 deletions
+3
View File
@@ -19,6 +19,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Dutch (nl) is now a supported interface language (#2891, requested and contributed by @Igiegel)** — Adds `nl` as the fourteenth locale, listed as "Nederlands" in the language picker. The translation was contributed as a file on the issue and needed three corrections before it could be wired up, all of which the parity gate found. First, the nine `stats.timeframe.*` entries had their **keys** translated along with their values (`'today'` had become `'vandaag'`), which would have left the Statistics timeframe selector resolving nothing and rendering raw key names for every Dutch user — the values were kept and the keys restored. Second, the file was translated against an older `en.ts` and was 84 leaves short, missing the Filament Track Switch feed prompts, the AI-detection status strings, the no-3MF internal-history banner, the batch-order stranded-plate notices, the Avery starting-position field and the whole `locationHaSensors` section from #2824; those were translated and added. Rather than splice them in, `nl.ts` was regenerated from the `en.ts` skeleton with the contributor's strings carried over, so its structure, key order and section comments now match the reference locale exactly and a future diff against `en.ts` reads as content rather than as reordering. Third, 229 leaves were identical to English; each was checked individually and all were kept, because Dutch takes most technical UI vocabulary verbatim — printer, filament, status, nozzle, timelapse, dashboard — and Dutch slicer users use the English feature names (support, ironing, prime tower, gap fill) untranslated. Those 123 distinct values are now listed explicitly in a `NL_COGNATES` allow-list in `check-i18n-parity.mjs`, the same shape the other twelve locales use, so the exemption is an enumerated translator decision rather than a blanket skip. Parity green at 6264 leaves across all 14 locales.
### Changed
- **Updating now repairs a service file that was written before the `--loop asyncio` pin existed (#3001)** — `install.sh` has pinned the loop since 2026-07-05 (#1896), but nothing has ever rewritten an *existing* service file: `install/update.sh` does a `git reset --hard`, a pip install, a frontend build and a restart, and never touches the unit. uvloop has been in every native venv since `uvicorn[standard]` entered `requirements.txt` on 2025-11-28, and uvicorn's `--loop auto` prefers it, so every native install created in that seven-month window has been running on uvloop ever since and no amount of updating has changed that. It cost them every RTSP camera on 1.2.5.4, and before that it left them exposed to a Virtual Printer FTP upload being silently truncated into a corrupt `.gcode.3mf` — which is why #1896 shipped a ZIP-validation backstop alongside the pin, since the pin could never reach the installs that already existed. Both update scripts now add the missing flag themselves: `update.sh` to the systemd unit, `update_macos.sh` to the launchd plist, in each case while the service is stopped so the repair takes effect on the same restart. Only the one flag is ever inserted — a hand-edited port, extra hardening, `ExecStartPre` lines and everything else stay byte-identical, and the file is copied to a timestamped backup first. Anything that is not a single-line unit invoking uvicorn directly is described rather than edited: a wrapper script, an `ExecStart` continued across lines, several `ExecStart` lines, a unit that is not writable, or a service carrying systemd drop-ins, since a drop-in may be what defines `ExecStart` and editing the fragment would then change nothing while reporting success. A loop pinned deliberately is also left alone — someone who wrote `--loop uvloop` on purpose gets no argument, only the startup warning. The check reads the *effective* `ExecStart` from systemd rather than the file, so a drop-in that already pins the loop counts and the repair is idempotent.
- **Bambuddy now says so at startup when it is running on uvloop (#3001)** — every unit file this project ships pins `--loop asyncio`, added for #1896 because uvloop's SSL layer can drop buffered data and truncate a Virtual Printer FTP upload into a corrupt `.gcode.3mf` that is acked `226` and forwarded to a printer. Two populations run a unit nobody here wrote and therefore have no such pin: the Proxmox VE Helper-Scripts LXC, which composes its own `ExecStart`, and native installs created before that fix landed on 2026-07-05, which never gained the flag because `install/update.sh` does not rewrite unit files. Neither had any way to know. #3001 only surfaced them because losing every camera at once is loud; a truncated upload is silent, and there is nothing to notice until a print fails from a file that was corrupt on arrival. Startup now logs one WARNING naming the loop, the risk and the exact flag to add. It is a warning and not a refusal: a server that answers requests beats a purist one that will not boot, and by the time any application code runs uvicorn has already chosen its loop. The check asks the running loop what it is rather than whether uvloop imports — uvloop is a hard dependency here, since `requirements.txt` pins `uvicorn[standard]`, so its presence says nothing about what is in use — and it matches on the module name so that asking the question never imports uvloop on a host that lacks it.
- **The spool form is wider, and its colour, weight and cost fields have their own tab** — The Printers tab is a model list beside a detail pane, which needs the room. Colour, spool weights, price, category and storage location move out of the bottom of a long scroll into a **Color & Cost** tab, laid out in two columns rather than one short field per row. Filament identity and the slicer preset stay on the first tab.
- **Home Assistant sensors moved out of the Smart Plugs settings tab into their own (#2824)** — Sensors are things you read and plugs are things you switch, and with storage-location bindings joining the printer ones the two no longer belong on one page. Both now live under **Settings → Sensors**; nothing about the printer bindings themselves changed, only where they are. The template that carried the printer alert is renamed from "Home Assistant Sensor Alert" to "Printer Sensor Alert", along with the toggle and badge labels that name it, because once a storage-location alert existed beside it the old name no longer said which one it was; the rename only touches templates still holding the old default name, so one that was edited keeps whatever it was called. A battery-class printer sensor now draws a battery icon instead of the generic gauge — the printer map never had an entry for it and the shared one does, which reads as the omission it was rather than a choice worth preserving.
- **Storage locations sort the way they are named (#2824)** — The locations list was ordered by `ORDER BY name`, which puts "Drybox 10" between "Drybox 1" and "Drybox 2". It is now sorted on the numbers inside the name, so a rack numbered past nine reads in rack order everywhere the list appears.
@@ -26,6 +28,7 @@ All notable changes to Bambuddy will be documented in this file.
- **The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation)** — Release tags are Authenticode-signed through the SignPath Foundation OSS programme, and the production certificate does not sign on demand the way the self-signed test certificate does: every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build it came from. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample when the test policy approves automatically in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job that uploads the unsigned artifact and stops; a second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour. Because the artifact is uploaded before the wait begins and is addressed by id, a missed approval window is recovered by re-running the second job alone rather than rebuilding the installer — which is the reason to separate them rather than simply raise the timeout in place. The second job runs for unsigned builds too, so the daily prereleases that are deliberately left unsigned to preserve the signing quota keep going out through exactly one set of alias, artifact and release steps. The property that matters is unchanged and now recorded next to the steps that depend on it: none of the alias, upload or release-attach steps carry `always()`, so GitHub skips all three when signing fails or times out, and an unsigned `.exe` cannot reach a release. Nothing about the signed output changes, and the restructure behaves identically under the test policy — the request simply completes immediately instead of waiting — so it can be proven green before the production certificate arrives.
### Fixed
- **Every camera stopped working on 1.2.5.4 (#3001, reported by @Jieper001, confirmed by @JmanB52D and @hikingthunder)** — live view, snapshots, timelapse frames and the camera diagnostic all failed at once on every RTSP model — X1, H2 and P2 — with the in-app diagnostic reporting `capture_exception` at 0 ms while network reachability passed at 1 ms. That 0 ms is the whole story: the failure happened before a socket was opened. The RTSPS proxy added in 1.2.5.4 finished by hanging its set of in-flight connection handlers on the server object as an attribute. `asyncio.start_server` returns an `asyncio.base_events.Server`, which has a `__dict__` and accepts that; under uvloop it returns a `uvloop.loop.Server`, a Cython cdef class with no `__dict__`, which raises `AttributeError` outright. Every launch path this repo ships — the Dockerfile, `install/install.sh`, `deploy/bambuddy.service`, the Windows service and the SpoolBuddy installer — pins `--loop asyncio`, added for #1896, so none of them selects uvloop and none of them could hit this. What broke is the installs running a unit file we did not write. The Proxmox VE Helper-Scripts LXC composes its own `ExecStart` with no loop pinned, and `requirements.txt` pins `uvicorn[standard]`, which installs uvloop on Linux, so uvicorn's default `--loop auto` selects it — that is the reporter's install and the two that confirmed it. Native installs created before the #1896 pin landed on 2026-07-05 are in the same position for a different reason: `install/update.sh` never rewrites the unit file, so a service written before that date has never been given the flag by any update since. Those installs are also still exposed to #1896 itself, where a truncated Virtual Printer FTP upload corrupts a `.gcode.3mf` silently; the camera outage is simply the visible half. Hence a fix in the code rather than another flag in a unit file: the proxy now works on either loop instead of depending on the launch command to steer around it. The handler set now lives in a module-level registry keyed weakly by server, which both loops accept; keying it weakly rather than by `id(server)` means a proxy abandoned without a close takes its entry with it, instead of leaking one forever and eventually handing a new server a dead one's handlers once CPython recycles the address. A1 and P1 use the chamber-image protocol and return before the proxy is built, so they were never affected, and external RTSPS cameras caught the error and fell back to a direct connection, so they kept working without the TLS workaround. The reason the test suite could not see any of this is that `conftest` builds its event loop from the default policy, so every async test in the repo runs on the selector loop — the one loop where the assignment was legal. The regression is now pinned twice: once by a test that drives the real function on a real uvloop loop, and once by a test that gives it a `__slots__` server, so the contract holds even where uvloop is not installed. The two external-camera teardowns were also switched to `close_tls_proxy`, which #2968 introduced and left them out of, so they no longer leave handlers running past the server that owned them.
- **Some archived 3MFs lost their G-code when re-imported into the File Manager (#2993, reported via the in-app form)** — they never lost it. The download serves the stored file byte for byte, and the G-code was still in the zip; what differed was who was asked. On the archive side the answer came from the file itself — the green GCODE badge reads the layer count and print time that were parsed out of the plate G-code — while the library decided from the filename alone, so a sliced 3MF stored as `Foo.3mf` rather than `Foo.gcode.3mf` carried the badge and still came back as a source-only project with no Print button. That splits on how the print reached the printer, not on anything about the file — a slicer's LAN send names it `.gcode.3mf`, while a per-plate export or a cloud-dispatched print arrives as plain `.3mf` — which is why it looked random. Both sides now ask the same question of the zip itself, and every route into the library (upload, ZIP import, MakerWorld, external-folder scan) classifies on content rather than on the name. Files already in your library are re-checked once on the next start. The backend was always willing to print these, so this was only ever the interface refusing to offer something that would have worked; a genuine model file is unaffected, and one that now shows **Print** correctly stops offering **Slice**.
- **Swapping a spool left the previous spool's preset name on the AMS slot card** — pull a Bambu ABS Orange out of A1, put a PLA Matte Dark Blue in, and the card still read "Bambu ABS" against the new colour. The backend had it right all along: the RFID auto-assign rewrites the slot's stored preset the moment the tag is read. The browser simply never refetched it. The slot card reads that stored preset ahead of the filament id the printer is reporting, so one cached row outranked correct data arriving over the WebSocket — and because every other field on the card (colour, material, fill, K value) rides the status push and updated instantly, it surfaced as a single wrong line rather than an obviously stale card. The manual assign path already refreshed it; the RFID path did not. Spoolman mode was the worse half of the same bug: its AMS sync writes that same row but announced nothing at all, so there was no event to refresh on — it now reports each slot it changed or cleared. Two further changes make the card right without waiting on any of that: the slot's queries no longer sit behind the 3-second cascade debounce meant for print completion (a swap touches one slot, and any further event restarted that timer), and the card now ignores a stored preset whose filament id disagrees with what the printer reports in the slot, so the correct name is on screen from the status push alone. A hand-picked preset name still wins wherever the stored row and the slot agree, and a user or local preset — whose ids genuinely cannot be compared — is untouched.
- **A print that could not fetch its own 3MF could be charged another plate's filament (#2957, reported by @doncaruana)** — when the source file is missing, the usage tracker looks for a replacement in the library or in a previous archive and matched on the filename stem alone. That is far weaker evidence than it looks: Bambu Studio writes the printer-side filename from the project's `Title` metadata, so every plate of a project arrives on the printer under one name however the file was renamed on disk. The reporter's single-filament job was handed a previous archive's three-filament plate and three spools were debited for material they never extruded, with nothing on the archive to say the numbers were someone else's. A candidate is now refused when it holds a different plate than the one running, and an all-plates export is refused unless it actually contains that plate — previously the plate was looked for later, found missing, and every filament in the file was summed onto one plate's print. Where the printer echoes only the 3MF filename and the plate cannot be known at all, which is the reporter's own firmware, the candidate is still accepted on its name and a warning now says so rather than the deduction happening silently.
+72 -6
View File
@@ -1,10 +1,10 @@
"""Asyncio event-loop exception handlers used at app startup.
"""Event-loop concerns handled at app startup.
Currently houses a single Windows-specific filter for the noisy
``_ProactorBasePipeTransport._call_connection_lost`` ``WinError 10054``
that fires every time a printer / MQTT broker / camera RSTs a TCP socket
instead of closing it cleanly. See ``install_proactor_reset_filter`` for
the why and the failure mode it suppresses.
Two of them, both about which loop implementation Bambuddy finds itself on.
``install_proactor_reset_filter`` silences the noisy Windows Proactor
cleanup-RST that fires whenever a printer / MQTT broker / camera RSTs a socket
instead of closing it; ``warn_if_running_on_uvloop`` says so out loud when the
loop is uvloop, which Bambuddy is not launched on and does not want.
"""
from __future__ import annotations
@@ -71,3 +71,69 @@ def install_proactor_reset_filter(loop: asyncio.AbstractEventLoop | None = None)
loop = asyncio.get_running_loop()
loop.set_exception_handler(_proactor_reset_filter)
return True
# Every launch path Bambuddy ships pins ``--loop asyncio``: the Dockerfile,
# install/install.sh, deploy/bambuddy.service, the Windows service and the
# SpoolBuddy installer. That flag was added for #1896 and is load-bearing --
# see the warning text below for what it holds up.
_LOOP_FLAG = "--loop asyncio"
def running_on_uvloop(loop: asyncio.AbstractEventLoop | None = None) -> bool:
"""Is `loop` (or the running loop) a uvloop loop?
Asks the loop what it is rather than whether uvloop imports: uvloop is a
hard dependency here -- ``requirements.txt`` pins ``uvicorn[standard]``,
which installs it on Linux -- so its mere presence says nothing. Matching
on the module name rather than ``isinstance(loop, uvloop.Loop)`` keeps this
from importing uvloop just to ask the question, which on a host without it
would be an ImportError in the middle of startup.
"""
if loop is None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return False
return type(loop).__module__.split(".")[0] == "uvloop"
def warn_if_running_on_uvloop(loop: asyncio.AbstractEventLoop | None = None) -> bool:
"""Log a loud warning when the process is running on uvloop.
Bambuddy is developed, tested and shipped on asyncio's own loop, and two
faults have already been traced to uvloop's differences from it:
* #1896 -- uvloop's SSL layer can drop buffered data when a client closes
without a TLS close_notify, so a Virtual Printer FTP upload can be
truncated, acked ``226``, archived and forwarded to a printer as a
corrupt ``.gcode.3mf``. There is a second guard for that one (the ZIP
is validated before the ack), but it is a backstop, not a licence to
run the loop that needs it.
* #3001 -- ``uvloop.loop.Server`` rejects attribute assignment, which
took out every RTSP camera in 1.2.5.4. Fixed, and the fix is loop
agnostic; it is named here because it is how we learned that installs
on uvloop exist at all.
Nothing is blocked and no loop is swapped: a running server that answers
requests is worth more than a purist one that refuses to boot, and by the
time this runs uvicorn has long since chosen. The point is that the two
populations this reaches -- the Proxmox VE Helper-Scripts LXC, which writes
its own unit with no loop pinned, and native installs predating the #1896
fix, which never gained the flag because ``update.sh`` does not rewrite
unit files -- have no other way to find out. The camera outage was visible;
a truncated upload is not.
Returns True when the warning was emitted.
"""
if not running_on_uvloop(loop):
return False
logger.warning(
"Running on uvloop, which Bambuddy is not tested or shipped on. Virtual Printer FTP "
"uploads can be silently truncated on this loop (#1896). Add '%s' to the uvicorn "
"command in your service file and restart. Every installer Bambuddy ships already "
"does this; a unit written by a third-party script, or one created before 2026-07-05, "
"will not, and updating does not add it.",
_LOOP_FLAG,
)
return True
+5 -1
View File
@@ -8767,10 +8767,14 @@ async def lifespan(app: FastAPI):
# Startup
# Install Windows-only asyncio Proactor cleanup-RST filter (#1113) before
# anything else can spawn tasks that might trip it.
from backend.app.core.asyncio_handlers import install_proactor_reset_filter
from backend.app.core.asyncio_handlers import install_proactor_reset_filter, warn_if_running_on_uvloop
install_proactor_reset_filter()
# Before init_db, so the warning is near the top of the log rather than
# below a migration run. See warn_if_running_on_uvloop for what is at stake.
warn_if_running_on_uvloop()
await init_db()
# Browser download tokens expire after five minutes. Remove abandoned
+32 -5
View File
@@ -14,6 +14,7 @@ import ssl
import struct
import subprocess
import uuid
import weakref
from datetime import datetime
from pathlib import Path
@@ -55,6 +56,28 @@ _active_capture_pids: set[int] = set()
# the Obico-vs-snapshot pair from the report.
_inflight_captures: dict[str, asyncio.Task[bytes | None]] = {}
# In-flight connection handlers for each live TLS proxy server (#3001).
#
# This belongs on the server object, and for one release it lived there as an
# instance attribute. That works on asyncio's own Server and raises
# AttributeError on uvloop's, which is a Cython cdef class with no __dict__.
# Every launch path this repo ships pins --loop asyncio (added for #1896), so
# none of them could hit it -- but requirements.txt pins uvicorn[standard],
# which installs uvloop, so anything launched without that flag gets uvloop
# from --loop auto and loses every RTSP camera. That is real deployments: the
# Proxmox VE Helper-Scripts LXC writes its own unit, and native installs
# predating the #1896 pin never get it either, since update.sh does not rewrite
# unit files. So the loop is not ours to assume, and this must not depend on it.
# The test suite could not see it either: conftest builds the loop from the
# default policy, so it only ever exercised the loop where the assignment is
# legal.
#
# Keyed weakly so a server that is dropped without close_tls_proxy() -- an
# exception between create and close -- takes its entry with it. Keying on
# id(server) instead would leak those entries forever and, worse, hand a later
# server a dead one's handler set once CPython recycles the address.
_proxy_handlers: "weakref.WeakKeyDictionary[asyncio.Server, set[asyncio.Task]]" = weakref.WeakKeyDictionary()
def get_ffmpeg_path() -> str | None:
"""Find the ffmpeg executable path.
@@ -249,6 +272,8 @@ async def create_tls_proxy(target_host: str, target_port: int) -> tuple[int, "as
# pointing here and no indication that it is a teardown race rather than a
# camera fault. Holding the set also gives close_tls_proxy something to
# cancel, so shutdown stops depending on ffmpeg having dropped its end.
# The set is published in _proxy_handlers once the server exists; see the
# note there for why it is not an attribute on the server itself.
handlers: set[asyncio.Task] = set()
async def _handle(client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter):
@@ -341,7 +366,7 @@ async def create_tls_proxy(target_host: str, target_port: int) -> tuple[int, "as
server = await asyncio.start_server(_handle, "127.0.0.1", 0)
_local_port[0] = server.sockets[0].getsockname()[1]
server._bambuddy_proxy_handlers = handlers # type: ignore[attr-defined]
_proxy_handlers[server] = handlers
logger.debug("TLS proxy for %s:%s listening on 127.0.0.1:%s", target_host, target_port, _local_port[0])
return _local_port[0], server
@@ -357,12 +382,14 @@ async def close_tls_proxy(server: "asyncio.Server") -> None:
way to guarantee no handler outlives the server that owns it.
``Server.close_clients()`` would do this natively, but it landed in Python
3.13 and Bambuddy supports 3.10, so the handler set is tracked by hand.
3.13 and Bambuddy supports 3.10, so the handler set is tracked by hand in
``_proxy_handlers``.
Safe to call on a plain ``asyncio.Server`` from anywhere else: without the
attribute it degrades to the close/wait it replaces.
Safe to call on any ``asyncio.Server`` from anywhere else: a server that
was not created here is simply absent from the registry, and this degrades
to the close/wait it replaces.
"""
handlers: set[asyncio.Task] = getattr(server, "_bambuddy_proxy_handlers", set())
handlers: set[asyncio.Task] = _proxy_handlers.pop(server, set())
server.close()
for task in list(handlers):
task.cancel()
+4 -6
View File
@@ -643,7 +643,7 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
try:
from urllib.parse import urlparse
from backend.app.services.camera import create_tls_proxy
from backend.app.services.camera import close_tls_proxy, create_tls_proxy
parsed = urlparse(safe_url)
target_port = parsed.port or 322
@@ -722,8 +722,7 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
return None
finally:
if proxy_server:
proxy_server.close()
await proxy_server.wait_closed()
await close_tls_proxy(proxy_server)
def _transcode_to_jpeg(data: bytes) -> bytes | None:
@@ -1076,7 +1075,7 @@ async def _stream_rtsp(
try:
from urllib.parse import urlparse
from backend.app.services.camera import create_tls_proxy
from backend.app.services.camera import close_tls_proxy, create_tls_proxy
parsed = urlparse(safe_url)
target_port = parsed.port or 322
@@ -1204,8 +1203,7 @@ async def _stream_rtsp(
process.kill()
await process.wait()
if proxy_server:
proxy_server.close()
await proxy_server.wait_closed()
await close_tls_proxy(proxy_server)
async def _stream_usb(
@@ -0,0 +1,77 @@
"""Bambuddy says so when it finds itself on a loop it is not shipped on (#3001).
Every unit file in this repo pins ``--loop asyncio``, added for #1896 because
uvloop's SSL layer can truncate a Virtual Printer FTP upload. Two populations
run units we do not write and so do not have the flag: the Proxmox VE
Helper-Scripts LXC, which composes its own ``ExecStart``, and native installs
created before 2026-07-05, since ``install/update.sh`` never rewrites the unit
file. #3001 -- every RTSP camera failing at once -- is how we found out those
installs exist. A truncated upload gives no such signal, so the loop now
announces itself instead of waiting for the next visible symptom.
"""
from __future__ import annotations
import asyncio
import logging
import pytest
from backend.app.core.asyncio_handlers import running_on_uvloop, warn_if_running_on_uvloop
class _FakeUvloopLoop:
"""Stands in for ``uvloop.Loop`` by module name, which is what is matched.
Lets the detection be tested on hosts without uvloop, and keeps the two
tests below honest about *how* they identify the loop: by asking the loop
what it is, not by asking whether uvloop imports.
"""
__module__ = "uvloop.loop"
async def test_the_default_loop_is_not_flagged(caplog):
"""The loop every shipped unit file selects must stay silent."""
with caplog.at_level(logging.WARNING):
assert running_on_uvloop() is False
assert warn_if_running_on_uvloop() is False
assert "uvloop" not in caplog.text
def test_uvloop_is_detected_and_warned_about(caplog):
"""The warning has to name the flag, or it is not actionable."""
loop = _FakeUvloopLoop()
assert running_on_uvloop(loop) is True # type: ignore[arg-type]
with caplog.at_level(logging.WARNING):
assert warn_if_running_on_uvloop(loop) is True # type: ignore[arg-type]
assert "--loop asyncio" in caplog.text, "the warning must state the fix, not just the diagnosis"
assert "#1896" in caplog.text, "the truncation risk is the reason this matters; cite it"
assert caplog.records[-1].levelno == logging.WARNING
def test_uvloop_is_detected_on_a_real_uvloop_loop():
"""The stand-in above is only worth having if it matches the real thing.
Not an async test on purpose: an async test inherits the session's selector
loop, which is the loop this is trying not to be.
"""
uvloop = pytest.importorskip("uvloop", reason="uvloop is a uvicorn[standard] extra; Linux only")
async def scenario() -> tuple[bool, bool]:
return running_on_uvloop(), warn_if_running_on_uvloop()
detected, warned = uvloop.run(scenario())
assert detected is True
assert warned is True
def test_no_running_loop_is_not_uvloop():
"""Called outside a loop -- ``asyncio.get_running_loop`` raises -- this must
answer False rather than propagate, since it runs during startup."""
with pytest.raises(RuntimeError):
asyncio.get_running_loop()
assert running_on_uvloop() is False
assert warn_if_running_on_uvloop() is False
@@ -0,0 +1,111 @@
"""The RTSPS proxy must survive whichever event loop production actually runs (#3001).
1.2.5.4 shipped ``server._bambuddy_proxy_handlers = handlers`` at the end of
``create_tls_proxy``. That is legal on ``asyncio.base_events.Server``, which
carries a ``__dict__``, and an outright ``AttributeError`` on
``uvloop.loop.Server``, a Cython cdef class that does not::
AttributeError: 'uvloop.loop.Server' object has no attribute
'_bambuddy_proxy_handlers' and no __dict__ for setting new attributes
Which loop you get is decided by the launch command, not by anything in the
app. Every unit file this repo ships pins ``--loop asyncio`` (added for #1896),
so none of them could hit this -- but ``requirements.txt`` pins
``uvicorn[standard]``, which installs uvloop on Linux, so any launcher without
that flag gets uvloop from ``--loop auto``. The Proxmox VE Helper-Scripts LXC
writes its own unit with no loop pinned, and native installs predating the
#1896 pin never gained it, because ``update.sh`` does not rewrite unit files.
So the loop this code runs on is not ours to assume, which is the whole reason
these tests exist. The proxy raised before opening a socket, which is why the
in-app diagnostic reported
``capture_exception`` at 0 ms while network reachability passed at 1 ms, and
why live view, snapshots and timelapse frames all went at once on every RTSP
model (X1, H2*, P2*). A1/P1 use the chamber-image protocol and return before
the proxy, so they were untouched.
The suite could not see any of it: ``conftest.event_loop`` builds its loop from
the default policy, so every async test in the repo runs on the selector loop
-- the one loop where that assignment works. Hence two tests here. The first
drives the real function on a real uvloop loop. The second states the
underlying contract without needing uvloop installed at all: the proxy must not
store anything *on* the server object, because the server it gets is not
guaranteed to accept attributes.
"""
from __future__ import annotations
import asyncio
import pytest
from backend.app.services.camera import _proxy_handlers, close_tls_proxy, create_tls_proxy
def test_create_tls_proxy_works_on_a_uvloop_loop():
"""The regression itself, on the loop that production uses.
Deliberately not an async test: the point is the loop implementation, and
an async test would inherit the session's selector loop and prove nothing.
``uvloop.run`` owns its loop start to finish.
"""
uvloop = pytest.importorskip("uvloop", reason="uvloop is a uvicorn[standard] extra; Linux only")
async def scenario() -> int:
# Port 322 is never dialled -- create_tls_proxy only binds the local
# listener; the upstream connection is opened per client handler.
port, server = await create_tls_proxy("127.0.0.1", 322)
try:
assert port > 0
assert server in _proxy_handlers, (
"handler set must be reachable from the registry, or close_tls_proxy has nothing to cancel"
)
finally:
await close_tls_proxy(server)
assert server not in _proxy_handlers, "close_tls_proxy must drop its registry entry"
return port
assert uvloop.run(scenario()) > 0
async def test_create_tls_proxy_stores_nothing_on_the_server(monkeypatch):
"""Runs everywhere, including where uvloop is not installed.
A stand-in ``Server`` with ``__slots__`` reproduces uvloop's constraint --
no ``__dict__``, so any attribute the proxy tries to attach raises. If this
fails, the code has gone back to writing on the server object.
"""
class SlottedServer:
"""Minimum of ``asyncio.Server`` that ``create_tls_proxy`` touches."""
__slots__ = ("sockets", "__weakref__")
def __init__(self, sockets):
self.sockets = sockets
def close(self):
pass
async def wait_closed(self):
pass
real_start_server = asyncio.start_server
created: list[asyncio.Server] = []
async def fake_start_server(*args, **kwargs):
"""Bind for real -- the port has to be usable -- then hide the Server."""
real = await real_start_server(*args, **kwargs)
created.append(real)
return SlottedServer(real.sockets)
monkeypatch.setattr(asyncio, "start_server", fake_start_server)
try:
port, server = await create_tls_proxy("127.0.0.1", 322)
assert port > 0
assert _proxy_handlers.get(server) == set(), "a fresh proxy has no handlers yet, but must have an entry"
await close_tls_proxy(server)
assert server not in _proxy_handlers, "close_tls_proxy must drop its registry entry"
finally:
for real in created:
real.close()
await real.wait_closed()
@@ -36,7 +36,7 @@ import ssl
import pytest
from backend.app.services.camera import close_tls_proxy, create_tls_proxy
from backend.app.services.camera import _proxy_handlers, close_tls_proxy, create_tls_proxy
@pytest.fixture(scope="module")
@@ -148,8 +148,8 @@ class TestTheHandlerIsHeldWhileItRuns:
port, proxy = await create_tls_proxy("127.0.0.1", upstream_port)
try:
_, writer = await asyncio.open_connection("127.0.0.1", port)
assert await _wait_for(lambda: len(proxy._bambuddy_proxy_handlers) == 1)
assert not next(iter(proxy._bambuddy_proxy_handlers)).done()
assert await _wait_for(lambda: len(_proxy_handlers[proxy]) == 1)
assert not next(iter(_proxy_handlers[proxy])).done()
writer.close()
finally:
@@ -166,11 +166,11 @@ class TestTheHandlerIsHeldWhileItRuns:
port, proxy = await create_tls_proxy("127.0.0.1", upstream_port)
try:
_, writer = await asyncio.open_connection("127.0.0.1", port)
assert await _wait_for(lambda: len(proxy._bambuddy_proxy_handlers) == 1)
assert await _wait_for(lambda: len(_proxy_handlers[proxy]) == 1)
writer.close()
assert await _wait_for(lambda: proxy._bambuddy_proxy_handlers == set())
assert await _wait_for(lambda: _proxy_handlers[proxy] == set())
finally:
await _close(proxy)
finally:
@@ -187,11 +187,14 @@ class TestCloseDoesNotDependOnThePeer:
try:
port, proxy = await create_tls_proxy("127.0.0.1", upstream_port)
_, writer = await asyncio.open_connection("127.0.0.1", port)
assert await _wait_for(lambda: len(proxy._bambuddy_proxy_handlers) == 1)
assert await _wait_for(lambda: len(_proxy_handlers[proxy]) == 1)
await _close(proxy)
assert proxy._bambuddy_proxy_handlers == set()
# Stronger than "the set is empty": since #3001 the handler set
# lives in a module-level registry rather than on the server, and
# close_tls_proxy retires the whole entry.
assert proxy not in _proxy_handlers
writer.close()
finally:
await _shutdown(upstream)
@@ -204,8 +207,8 @@ class TestCloseDoesNotDependOnThePeer:
try:
port, proxy = await create_tls_proxy("127.0.0.1", upstream_port)
_, writer = await asyncio.open_connection("127.0.0.1", port)
assert await _wait_for(lambda: len(proxy._bambuddy_proxy_handlers) == 1)
handler = next(iter(proxy._bambuddy_proxy_handlers))
assert await _wait_for(lambda: len(_proxy_handlers[proxy]) == 1)
handler = next(iter(_proxy_handlers[proxy]))
with caplog.at_level(logging.ERROR, logger="asyncio"):
await _close(proxy)
+83
View File
@@ -52,6 +52,87 @@ cleanup_old_backups() {
log "Pruned old backups, kept newest $max_count file(s)"
}
# Restore the --loop asyncio pin on a unit file written before it existed (#3001).
#
# install.sh has pinned the loop since 2026-07-05 (#1896), but this script has
# never rewritten a unit file, and nothing else does either -- so an install
# created before that date still runs on uvloop today no matter how many times
# it has been updated. uvloop has been in every native venv since
# uvicorn[standard] landed on 2025-11-28, and uvicorn's --loop auto prefers it,
# so that is a seven-month window of installs still on the wrong loop. It cost
# them every RTSP camera on 1.2.5.4 (#3001), and before that it exposed them to
# Virtual Printer FTP uploads being silently truncated (#1896). Neither is
# discoverable from the outside, which is why this repairs rather than reports.
#
# Only ever inserts the one flag. Everything else in the unit -- hardening,
# environment, ExecStartPre, a hand-edited port -- is left byte-identical, and
# the file is copied aside first.
repair_loop_flag() {
local fragment exec_line backup
# The effective ExecStart, so a drop-in that already pins the loop counts.
if systemctl show "$SERVICE_NAME" --property=ExecStart --value 2>/dev/null | grep -q -- '--loop'; then
return 0
fi
fragment="$(systemctl show "$SERVICE_NAME" --property=FragmentPath --value 2>/dev/null || true)"
if [ -z "$fragment" ] || [ ! -f "$fragment" ]; then
warn "Cannot locate the unit file for $SERVICE_NAME; not repairing the --loop flag."
return 0
fi
# A drop-in, not the fragment, may be what defines ExecStart. Editing the
# fragment would then change nothing while reporting success.
if [ -n "$(systemctl show "$SERVICE_NAME" --property=DropInPaths --value 2>/dev/null || true)" ]; then
warn "$SERVICE_NAME has systemd drop-ins; add '--loop asyncio' to its uvicorn command by hand. See #1896."
return 0
fi
# Anything but exactly one single-line uvicorn ExecStart is someone else's
# arrangement -- a wrapper script, a continuation, several ExecStart lines --
# and is described rather than edited.
if [ "$(grep -c '^ExecStart=' "$fragment")" -ne 1 ]; then
warn "$fragment has no single ExecStart line; add '--loop asyncio' to its uvicorn command by hand. See #1896."
return 0
fi
exec_line="$(grep '^ExecStart=' "$fragment")"
case "$exec_line" in
*uvicorn*) ;;
*)
warn "$fragment does not start uvicorn directly; add '--loop asyncio' to it by hand. See #1896."
return 0
;;
esac
case "$exec_line" in
*\\)
warn "$fragment continues its ExecStart onto another line; add '--loop asyncio' by hand. See #1896."
return 0
;;
esac
if [ ! -w "$fragment" ]; then
warn "$fragment is not writable; add '--loop asyncio' to its uvicorn command by hand. See #1896."
return 0
fi
backup="$fragment.bak-$(date +%Y%m%d-%H%M%S)"
cp -p "$fragment" "$backup" || {
warn "Could not back up $fragment; leaving it alone."
return 0
}
# Appended, not spliced: uvicorn accepts its options in any order after the
# app path, and appending cannot disturb a value already on the line.
if ! sed -i 's|^ExecStart=.*|& --loop asyncio|' "$fragment"; then
warn "Failed to edit $fragment; restoring from $backup."
cp -p "$backup" "$fragment" || true
return 0
fi
log "Added the missing '--loop asyncio' flag to $fragment (was written before #1896; backup at $backup)"
log "Without it Bambuddy runs on uvloop, which breaks RTSP cameras (#3001) and can truncate Virtual Printer FTP uploads (#1896)."
systemctl daemon-reload || warn "systemctl daemon-reload failed; the new flag applies after the next reload."
}
on_error() {
local exit_code="$1"
@@ -227,6 +308,8 @@ else
warn "Skipping frontend build (frontend/package.json not found)."
fi
repair_loop_flag
log "Starting service: $SERVICE_NAME"
systemctl start "$SERVICE_NAME"
SERVICE_STOPPED=0
+50
View File
@@ -57,6 +57,54 @@ is_service_active() {
launchctl list | grep -q "$SERVICE_NAME"
}
# Restore the --loop asyncio pin on a plist written before it existed (#3001).
#
# The macOS twin of the systemd repair in update.sh, and there for the same
# reason: install.sh has pinned the loop since 2026-07-05 (#1896), this script
# has never rewritten the plist, and nothing else does -- so an install created
# before that date still launches on uvloop today. uvloop reaches macOS as
# well, since uvicorn[standard] only excludes it on Windows. That costs every
# RTSP camera (#3001) and risks silently truncated Virtual Printer FTP uploads
# (#1896), neither of which is visible from outside the machine.
#
# PlistBuddy is used rather than sed because the plist is XML and
# ProgramArguments is an array; appending the two strings is safe because
# uvicorn accepts its options in any order after the app path.
repair_loop_flag() {
local plistbuddy="/usr/libexec/PlistBuddy" backup
[ -f "$PLIST_PATH" ] || return 0
if grep -q -- '--loop' "$PLIST_PATH"; then
return 0
fi
if [ ! -x "$plistbuddy" ]; then
warn "PlistBuddy not found; add '--loop' and 'asyncio' to ProgramArguments in $PLIST_PATH by hand. See #1896."
return 0
fi
# A plist that does not invoke uvicorn directly is someone else's
# arrangement and is described rather than edited.
if ! grep -q 'uvicorn' "$PLIST_PATH"; then
warn "$PLIST_PATH does not start uvicorn directly; add '--loop asyncio' to it by hand. See #1896."
return 0
fi
backup="$PLIST_PATH.bak-$(date +%Y%m%d-%H%M%S)"
cp -p "$PLIST_PATH" "$backup" || {
warn "Could not back up $PLIST_PATH; leaving it alone."
return 0
}
if ! "$plistbuddy" -c 'Add :ProgramArguments: string --loop' \
-c 'Add :ProgramArguments: string asyncio' "$PLIST_PATH" >/dev/null 2>&1; then
warn "Failed to edit $PLIST_PATH; restoring from $backup."
cp -p "$backup" "$PLIST_PATH" || true
return 0
fi
log "Added the missing '--loop asyncio' flag to $PLIST_PATH (was written before #1896; backup at $backup)"
log "Without it Bambuddy runs on uvloop, which breaks RTSP cameras (#3001) and can truncate Virtual Printer FTP uploads (#1896)."
}
on_error() {
local exit_code="$1"
@@ -211,6 +259,8 @@ else
warn "Skipping frontend build (frontend/package.json not found)."
fi
repair_loop_flag
log "Starting service: $SERVICE_NAME"
launchctl load "$PLIST_PATH"
SERVICE_STOPPED=0