| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139 |
- """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
|