test_connection_watchdog.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. """Tests for the dead-MQTT-session watchdog (#2732).
  2. ``check_staleness()`` guards the "connected but silent" session and returns
  3. immediately once ``state.connected`` is False — from there, paho's own
  4. auto-reconnect is the only thing still watching. The #2732 bundle shows what
  5. happens when that stops making progress: a P1S dropped on a keep-alive timeout
  6. at 02:19 and did not reconnect until 11:24, nine hours offline with the UI open
  7. throughout.
  8. This watchdog is the backstop. The rules it has to keep are narrow on purpose —
  9. it must not interfere with a session that is recovering on its own, and it must
  10. not churn clients for printers that are simply switched off.
  11. """
  12. import re
  13. import time
  14. from types import SimpleNamespace
  15. from unittest.mock import AsyncMock, MagicMock, patch
  16. import pytest
  17. from backend.app.main import (
  18. CONNECTION_WATCHDOG_OFFLINE_GRACE,
  19. CONNECTION_WATCHDOG_RETRY_INTERVAL,
  20. _connection_watchdog_last_attempt,
  21. _recover_dead_printer_sessions,
  22. )
  23. def _client(*, connected: bool, last_message_age: float | None, ip: str = "192.168.1.100"):
  24. """Stand-in for BambuMQTTClient with only the fields the watchdog reads."""
  25. return SimpleNamespace(
  26. state=SimpleNamespace(connected=connected),
  27. _last_message_time=0.0 if last_message_age is None else time.time() - last_message_age,
  28. ip_address=ip,
  29. last_connect_error=None,
  30. force_reconnect_stale_session=MagicMock(),
  31. )
  32. async def _sweep(clients: dict, *, port_open: bool = True):
  33. with (
  34. patch("backend.app.main.printer_manager._clients", clients),
  35. patch("backend.app.services.printer_diagnostic.check_port", AsyncMock(return_value=port_open)),
  36. ):
  37. return await _recover_dead_printer_sessions()
  38. @pytest.fixture(autouse=True)
  39. def _clear_cooldowns():
  40. _connection_watchdog_last_attempt.clear()
  41. yield
  42. _connection_watchdog_last_attempt.clear()
  43. class TestRebuildsDeadSessions:
  44. @pytest.mark.asyncio
  45. async def test_rebuilds_a_long_dead_session(self):
  46. """The #2732 case: offline for hours, printer answering the whole time."""
  47. client = _client(connected=False, last_message_age=32718.0)
  48. assert await _sweep({1: client}) == 1
  49. client.force_reconnect_stale_session.assert_called_once()
  50. @pytest.mark.asyncio
  51. async def test_reconnect_reason_names_the_duration(self):
  52. client = _client(connected=False, last_message_age=32718.0)
  53. await _sweep({1: client})
  54. reason = client.force_reconnect_stale_session.call_args.args[0]
  55. # The age is read from the clock, so a slow run may add a second or two
  56. seconds = int(re.search(r"offline for (\d+)s", reason).group(1))
  57. assert 32718 <= seconds <= 32728, reason
  58. @pytest.mark.asyncio
  59. async def test_sweeps_every_printer_in_the_farm(self):
  60. clients = {i: _client(connected=False, last_message_age=9999.0) for i in range(1, 4)}
  61. assert await _sweep(clients) == 3
  62. class TestLeavesHealthyAndRecoveringSessionsAlone:
  63. @pytest.mark.asyncio
  64. async def test_connected_printer_is_untouched(self):
  65. client = _client(connected=True, last_message_age=99999.0)
  66. assert await _sweep({1: client}) == 0
  67. client.force_reconnect_stale_session.assert_not_called()
  68. @pytest.mark.asyncio
  69. async def test_inside_the_grace_period_paho_keeps_the_job(self):
  70. """Below the grace window a reconnect may well be in flight; interrupting
  71. it would turn a self-healing blip into a forced session rebuild."""
  72. client = _client(connected=False, last_message_age=CONNECTION_WATCHDOG_OFFLINE_GRACE - 30)
  73. assert await _sweep({1: client}) == 0
  74. client.force_reconnect_stale_session.assert_not_called()
  75. @pytest.mark.asyncio
  76. async def test_a_client_that_never_had_a_session_is_left_to_paho(self):
  77. """No inbound message ever means this is the initial connect, where
  78. retrying is both correct and the only thing to do."""
  79. client = _client(connected=False, last_message_age=None)
  80. assert await _sweep({1: client}) == 0
  81. client.force_reconnect_stale_session.assert_not_called()
  82. @pytest.mark.asyncio
  83. async def test_reconnecting_clears_the_cooldown(self):
  84. """A printer that comes back must not carry a stale cooldown into its
  85. next outage."""
  86. client = _client(connected=False, last_message_age=9999.0)
  87. await _sweep({1: client})
  88. assert 1 in _connection_watchdog_last_attempt
  89. client.state.connected = True
  90. await _sweep({1: client})
  91. assert 1 not in _connection_watchdog_last_attempt
  92. class TestUnreachablePrinters:
  93. @pytest.mark.asyncio
  94. async def test_switched_off_printer_is_not_rebuilt(self):
  95. """Rebuilding a client against a host that isn't answering achieves
  96. nothing and would log a warning per printer all night."""
  97. client = _client(connected=False, last_message_age=9999.0)
  98. assert await _sweep({1: client}, port_open=False) == 0
  99. client.force_reconnect_stale_session.assert_not_called()
  100. @pytest.mark.asyncio
  101. async def test_unreachable_printer_still_takes_the_cooldown(self):
  102. """Otherwise every sweep re-probes the port of every dead printer."""
  103. client = _client(connected=False, last_message_age=9999.0)
  104. await _sweep({1: client}, port_open=False)
  105. assert 1 in _connection_watchdog_last_attempt
  106. class TestRetryInterval:
  107. @pytest.mark.asyncio
  108. async def test_does_not_rebuild_again_within_the_interval(self):
  109. client = _client(connected=False, last_message_age=9999.0)
  110. assert await _sweep({1: client}) == 1
  111. assert await _sweep({1: client}) == 0
  112. client.force_reconnect_stale_session.assert_called_once()
  113. @pytest.mark.asyncio
  114. async def test_retries_once_the_interval_has_passed(self):
  115. client = _client(connected=False, last_message_age=9999.0)
  116. await _sweep({1: client})
  117. _connection_watchdog_last_attempt[1] -= CONNECTION_WATCHDOG_RETRY_INTERVAL + 1
  118. assert await _sweep({1: client}) == 1
  119. assert client.force_reconnect_stale_session.call_count == 2
  120. class TestSweepIsFaultTolerant:
  121. @pytest.mark.asyncio
  122. async def test_one_broken_client_does_not_stop_the_others(self):
  123. """A farm sweep that aborts on the first bad client would leave every
  124. printer after it unrecovered."""
  125. bad = _client(connected=False, last_message_age=9999.0)
  126. bad.force_reconnect_stale_session.side_effect = RuntimeError("boom")
  127. good = _client(connected=False, last_message_age=9999.0)
  128. assert await _sweep({1: bad, 2: good}) == 2
  129. good.force_reconnect_stale_session.assert_called_once()