Files
bambuddy/backend/tests/unit/test_asyncio_handlers.py
T
maziggy 56800589ff fix(#1113): silence Windows asyncio Proactor cleanup-RST noise
bambuddy.log on Windows fills with

    Exception in callback _ProactorBasePipeTransport._call_connection_lost()
    ConnectionResetError: [WinError 10054] An existing connection was
    forcibly closed by the remote host

  every time a printer / MQTT broker / camera RSTs a TCP socket instead
  of FINing it. The application-layer reconnect (paho-mqtt, httpx)
  handles the actual disconnect fine; the traceback is asyncio
  bookkeeping. Reported by @cadtoolbox who runs 9 printers including 5
  offline X1Es, so the log filled multiple times per minute.

  New backend/app/core/asyncio_handlers.py installs a custom
  loop.set_exception_handler on Windows that pattern-matches three
  signals together (platform == win32, exception is
  ConnectionResetError, asyncio message contains
  _call_connection_lost) and demotes the entry to DEBUG. Genuine
  ConnectionResetErrors raised inside application coroutines have a
  different message string and still surface; BrokenPipeError /
  ConnectionAbortedError on the same cleanup path also still surface.

  Wired from lifespan startup before any task can spawn that might
  trip it. Linux / macOS use the Selector loop, so install is an
  explicit no-op there with a False return.

  9 unit tests in test_asyncio_handlers.py covering signature match,
  rejection of unrelated resets, platform gate, suppress vs.
  pass-through to default handler.
2026-04-27 16:14:31 +02:00

128 lines
5.2 KiB
Python

"""Tests for the Windows asyncio Proactor cleanup-RST filter (#1113)."""
from __future__ import annotations
import asyncio
from unittest.mock import patch
import pytest
from backend.app.core.asyncio_handlers import (
_is_proactor_connection_reset,
_proactor_reset_filter,
install_proactor_reset_filter,
)
# `_is_proactor_connection_reset` short-circuits on non-Windows; pretend we're
# on Windows for the discrimination tests so they exercise the actual logic.
@pytest.fixture
def fake_windows():
with patch("backend.app.core.asyncio_handlers.sys.platform", "win32"):
yield
class TestIsProactorConnectionReset:
"""The discriminator that decides whether a context is the noise we silence."""
def test_matches_proactor_cleanup_reset(self, fake_windows):
ctx = {
"exception": ConnectionResetError(10054, "An existing connection was forcibly closed"),
"message": "Exception in callback _ProactorBasePipeTransport._call_connection_lost()",
}
assert _is_proactor_connection_reset(ctx) is True
def test_rejects_when_not_on_windows(self):
# No `fake_windows` fixture — sys.platform reflects the real OS.
ctx = {
"exception": ConnectionResetError(10054, "irrelevant"),
"message": "Exception in callback _ProactorBasePipeTransport._call_connection_lost()",
}
# The whole point of the filter is to be a Windows-only no-op.
with patch("backend.app.core.asyncio_handlers.sys.platform", "linux"):
assert _is_proactor_connection_reset(ctx) is False
def test_rejects_unrelated_connection_reset(self, fake_windows):
"""A real `ConnectionResetError` raised inside an app coroutine —
not from the Proactor cleanup path — must NOT be suppressed.
Otherwise we'd hide genuine connectivity bugs."""
ctx = {
"exception": ConnectionResetError(),
"message": "Task exception was never retrieved",
}
assert _is_proactor_connection_reset(ctx) is False
def test_rejects_other_exception_types(self, fake_windows):
"""Other OSErrors (BrokenPipeError, ConnectionAbortedError) might
share the cleanup path but they're a different signal worth
keeping visible — we only silence the specific 10054 family."""
ctx = {
"exception": BrokenPipeError(),
"message": "Exception in callback _ProactorBasePipeTransport._call_connection_lost()",
}
assert _is_proactor_connection_reset(ctx) is False
def test_rejects_when_no_exception(self, fake_windows):
"""asyncio sometimes invokes the handler with no exception object
(e.g. resource warnings) — those shouldn't blanket-match."""
ctx = {"message": "_call_connection_lost was slow"}
assert _is_proactor_connection_reset(ctx) is False
class TestProactorResetFilter:
"""The handler glue itself — does it suppress the right ones and
pass everything else through to the default handler?"""
@pytest.mark.asyncio
async def test_suppresses_proactor_reset(self, fake_windows):
loop = asyncio.get_running_loop()
with patch.object(loop, "default_exception_handler") as default:
_proactor_reset_filter(
loop,
{
"exception": ConnectionResetError(10054, "forcibly closed"),
"message": "Exception in callback _ProactorBasePipeTransport._call_connection_lost()",
},
)
# Suppression = default handler is never reached.
default.assert_not_called()
@pytest.mark.asyncio
async def test_passes_unrelated_through_to_default(self, fake_windows):
"""A different uncaught exception must go through asyncio's normal
path so it surfaces in logs and tests as an actual problem."""
loop = asyncio.get_running_loop()
ctx = {
"exception": ValueError("real bug"),
"message": "Task exception was never retrieved",
}
with patch.object(loop, "default_exception_handler") as default:
_proactor_reset_filter(loop, ctx)
default.assert_called_once_with(ctx)
class TestInstallation:
"""Wiring: install_proactor_reset_filter only runs on Windows."""
@pytest.mark.asyncio
async def test_install_is_no_op_on_non_windows(self):
"""Linux/macOS use the Selector loop, which doesn't hit this code
path — the install must be inert so the Linux production path
keeps the default exception handler untouched."""
loop = asyncio.get_running_loop()
with (
patch("backend.app.core.asyncio_handlers.sys.platform", "linux"),
patch.object(loop, "set_exception_handler") as setter,
):
installed = install_proactor_reset_filter(loop)
assert installed is False
setter.assert_not_called()
@pytest.mark.asyncio
async def test_install_attaches_handler_on_windows(self, fake_windows):
loop = asyncio.get_running_loop()
with patch.object(loop, "set_exception_handler") as setter:
installed = install_proactor_reset_filter(loop)
assert installed is True
setter.assert_called_once_with(_proactor_reset_filter)