mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
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.
140 lines
5.9 KiB
Python
140 lines
5.9 KiB
Python
"""Event-loop concerns handled at app startup.
|
|
|
|
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
|
|
|
|
import asyncio
|
|
import logging
|
|
import sys
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _is_proactor_connection_reset(context: dict[str, Any]) -> bool:
|
|
"""True if `context` describes the Windows Proactor cleanup-RST noise.
|
|
|
|
asyncio's default exception handler is invoked in two distinct cases
|
|
we care about — generic uncaught task exceptions, and the specific
|
|
`_call_connection_lost` cleanup path — and we only want to suppress
|
|
the latter. Match on three signals together so a real
|
|
`ConnectionResetError` raised inside an application task still
|
|
surfaces normally:
|
|
|
|
1. The exception is `ConnectionResetError` (or a subclass).
|
|
2. asyncio's own message string mentions `_call_connection_lost`
|
|
(the Proactor-cleanup callback is the only place Python emits
|
|
this exact phrase).
|
|
3. We're actually on Windows, where the Proactor is in use.
|
|
"""
|
|
if sys.platform != "win32":
|
|
return False
|
|
exc = context.get("exception")
|
|
if not isinstance(exc, ConnectionResetError):
|
|
return False
|
|
message = context.get("message", "")
|
|
return "_call_connection_lost" in message
|
|
|
|
|
|
def _proactor_reset_filter(loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
|
|
"""Custom event-loop exception handler.
|
|
|
|
Handles the Proactor-cleanup `ConnectionResetError` by logging it at
|
|
DEBUG instead of ERROR, and delegates everything else to asyncio's
|
|
default handler so unrelated bugs are still visible.
|
|
"""
|
|
if _is_proactor_connection_reset(context):
|
|
logger.debug(
|
|
"asyncio Proactor: peer reset socket during cleanup (WinError 10054); "
|
|
"ignored — application-layer reconnect handles the disconnect"
|
|
)
|
|
return
|
|
loop.default_exception_handler(context)
|
|
|
|
|
|
def install_proactor_reset_filter(loop: asyncio.AbstractEventLoop | None = None) -> bool:
|
|
"""Install the filter on `loop` (or the running loop if omitted).
|
|
|
|
Returns True when the filter was installed (Windows only), False on
|
|
every other platform — so callers can branch on the return value if
|
|
they want to log the install / skip.
|
|
"""
|
|
if sys.platform != "win32":
|
|
return False
|
|
if loop is 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
|