asyncio_handlers.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. """Event-loop concerns handled at app startup.
  2. Two of them, both about which loop implementation Bambuddy finds itself on.
  3. ``install_proactor_reset_filter`` silences the noisy Windows Proactor
  4. cleanup-RST that fires whenever a printer / MQTT broker / camera RSTs a socket
  5. instead of closing it; ``warn_if_running_on_uvloop`` says so out loud when the
  6. loop is uvloop, which Bambuddy is not launched on and does not want.
  7. """
  8. from __future__ import annotations
  9. import asyncio
  10. import logging
  11. import sys
  12. from typing import Any
  13. logger = logging.getLogger(__name__)
  14. def _is_proactor_connection_reset(context: dict[str, Any]) -> bool:
  15. """True if `context` describes the Windows Proactor cleanup-RST noise.
  16. asyncio's default exception handler is invoked in two distinct cases
  17. we care about — generic uncaught task exceptions, and the specific
  18. `_call_connection_lost` cleanup path — and we only want to suppress
  19. the latter. Match on three signals together so a real
  20. `ConnectionResetError` raised inside an application task still
  21. surfaces normally:
  22. 1. The exception is `ConnectionResetError` (or a subclass).
  23. 2. asyncio's own message string mentions `_call_connection_lost`
  24. (the Proactor-cleanup callback is the only place Python emits
  25. this exact phrase).
  26. 3. We're actually on Windows, where the Proactor is in use.
  27. """
  28. if sys.platform != "win32":
  29. return False
  30. exc = context.get("exception")
  31. if not isinstance(exc, ConnectionResetError):
  32. return False
  33. message = context.get("message", "")
  34. return "_call_connection_lost" in message
  35. def _proactor_reset_filter(loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
  36. """Custom event-loop exception handler.
  37. Handles the Proactor-cleanup `ConnectionResetError` by logging it at
  38. DEBUG instead of ERROR, and delegates everything else to asyncio's
  39. default handler so unrelated bugs are still visible.
  40. """
  41. if _is_proactor_connection_reset(context):
  42. logger.debug(
  43. "asyncio Proactor: peer reset socket during cleanup (WinError 10054); "
  44. "ignored — application-layer reconnect handles the disconnect"
  45. )
  46. return
  47. loop.default_exception_handler(context)
  48. def install_proactor_reset_filter(loop: asyncio.AbstractEventLoop | None = None) -> bool:
  49. """Install the filter on `loop` (or the running loop if omitted).
  50. Returns True when the filter was installed (Windows only), False on
  51. every other platform — so callers can branch on the return value if
  52. they want to log the install / skip.
  53. """
  54. if sys.platform != "win32":
  55. return False
  56. if loop is None:
  57. loop = asyncio.get_running_loop()
  58. loop.set_exception_handler(_proactor_reset_filter)
  59. return True
  60. # Every launch path Bambuddy ships pins ``--loop asyncio``: the Dockerfile,
  61. # install/install.sh, deploy/bambuddy.service, the Windows service and the
  62. # SpoolBuddy installer. That flag was added for #1896 and is load-bearing --
  63. # see the warning text below for what it holds up.
  64. _LOOP_FLAG = "--loop asyncio"
  65. def running_on_uvloop(loop: asyncio.AbstractEventLoop | None = None) -> bool:
  66. """Is `loop` (or the running loop) a uvloop loop?
  67. Asks the loop what it is rather than whether uvloop imports: uvloop is a
  68. hard dependency here -- ``requirements.txt`` pins ``uvicorn[standard]``,
  69. which installs it on Linux -- so its mere presence says nothing. Matching
  70. on the module name rather than ``isinstance(loop, uvloop.Loop)`` keeps this
  71. from importing uvloop just to ask the question, which on a host without it
  72. would be an ImportError in the middle of startup.
  73. """
  74. if loop is None:
  75. try:
  76. loop = asyncio.get_running_loop()
  77. except RuntimeError:
  78. return False
  79. return type(loop).__module__.split(".")[0] == "uvloop"
  80. def warn_if_running_on_uvloop(loop: asyncio.AbstractEventLoop | None = None) -> bool:
  81. """Log a loud warning when the process is running on uvloop.
  82. Bambuddy is developed, tested and shipped on asyncio's own loop, and two
  83. faults have already been traced to uvloop's differences from it:
  84. * #1896 -- uvloop's SSL layer can drop buffered data when a client closes
  85. without a TLS close_notify, so a Virtual Printer FTP upload can be
  86. truncated, acked ``226``, archived and forwarded to a printer as a
  87. corrupt ``.gcode.3mf``. There is a second guard for that one (the ZIP
  88. is validated before the ack), but it is a backstop, not a licence to
  89. run the loop that needs it.
  90. * #3001 -- ``uvloop.loop.Server`` rejects attribute assignment, which
  91. took out every RTSP camera in 1.2.5.4. Fixed, and the fix is loop
  92. agnostic; it is named here because it is how we learned that installs
  93. on uvloop exist at all.
  94. Nothing is blocked and no loop is swapped: a running server that answers
  95. requests is worth more than a purist one that refuses to boot, and by the
  96. time this runs uvicorn has long since chosen. The point is that the two
  97. populations this reaches -- the Proxmox VE Helper-Scripts LXC, which writes
  98. its own unit with no loop pinned, and native installs predating the #1896
  99. fix, which never gained the flag because ``update.sh`` does not rewrite
  100. unit files -- have no other way to find out. The camera outage was visible;
  101. a truncated upload is not.
  102. Returns True when the warning was emitted.
  103. """
  104. if not running_on_uvloop(loop):
  105. return False
  106. logger.warning(
  107. "Running on uvloop, which Bambuddy is not tested or shipped on. Virtual Printer FTP "
  108. "uploads can be silently truncated on this loop (#1896). Add '%s' to the uvicorn "
  109. "command in your service file and restart. Every installer Bambuddy ships already "
  110. "does this; a unit written by a third-party script, or one created before 2026-07-05, "
  111. "will not, and updating does not add it.",
  112. _LOOP_FLAG,
  113. )
  114. return True