paho_teardown.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. """Letting go of a paho MQTT client without waiting for its network thread.
  2. `Client.loop_stop()` is two statements: set `_thread_terminate`, then `join()`
  3. the network thread with no timeout. That thread only reads the flag between
  4. iterations of `loop_forever`, so it cannot read it while parked inside
  5. `reconnect()` -> `_ssl_wrap_socket()` -> `do_handshake()`. paho gives that
  6. handshake the connection's keepalive as its socket timeout -- 30s for a printer
  7. -- and a socket timeout is per operation, renewed by every byte the peer sends.
  8. A broker that still answers on its port but never finishes the handshake
  9. therefore holds the join open for as long as it keeps trickling; a silent one
  10. still holds it 30s.
  11. Whoever called `loop_stop()` waits that out, and in Bambuddy that caller is the
  12. asyncio thread. #3068: a printer 38 hours offline, still answering on 8883, was
  13. picked up by the connection watchdog exactly as intended; the rebuild ended in
  14. that join and the process stopped serving HTTP -- UI, API and health check --
  15. while staying alive, so the container's `restart: unless-stopped` never fired.
  16. #1445 was the same join reached from the add-printer probe.
  17. """
  18. import logging
  19. import threading
  20. import time
  21. logger = logging.getLogger(__name__)
  22. # How long a retirement may take before it is worth a line in the support
  23. # bundle. A healthy paho thread exits in well under a second.
  24. _RETIRE_SLOW_SECONDS = 5.0
  25. # The callbacks Bambuddy's three MQTT services set between them. Anything else
  26. # paho offers is already None because nobody here assigns it.
  27. _CALLBACKS = ("on_connect", "on_disconnect", "on_subscribe", "on_message")
  28. def discard_queued_publishes(client) -> int | None:
  29. """Empty paho's outbox; returns how many publishes it held.
  30. On a reconnect of the same client paho re-sends every QoS 1 publish still
  31. waiting for its PUBACK, right after ``on_connect`` returns. Bambu printers
  32. often never PUBACK (#1164), so that can be a ``stop`` from hours ago
  33. cancelling the print running now (#3454). Uses paho 2.x internals
  34. (``_out_messages`` under ``_out_message_mutex``; the reconnect has already
  35. reset the in-flight count), pinned by test_mqtt_reconnect_replay_3454.py.
  36. Returns None when paho no longer has them."""
  37. try:
  38. with client._out_message_mutex:
  39. held = len(client._out_messages)
  40. client._out_messages.clear()
  41. except AttributeError:
  42. return None
  43. return held
  44. def retire_paho_client(client, label: str) -> None:
  45. """Shut *client* down on a thread of its own and return immediately.
  46. *label* names the connection in logs and in the retirement thread's name,
  47. which is where a thread dump from the next stuck one will be read.
  48. Two things happen inline rather than on that thread:
  49. - The callbacks are cleared here. Blocking until the network thread was
  50. gone is what used to guarantee a client we had let go of could no longer
  51. touch our state; with the teardown detached, a zombie that finishes its
  52. handshake would auto-reconnect and report itself connected behind its
  53. replacement's back.
  54. - Nothing else -- not even `disconnect()`, which is cheap enough to run
  55. here (it queues a packet and returns) but would be one more thing
  56. between the caller and its return for no gain, since the thread starts
  57. within microseconds. It still happens and still matters: it is what
  58. stops paho's auto-reconnect, and with it the chance of an unacked
  59. `project_file` replaying onto a revived session (#1136).
  60. """
  61. for attr in _CALLBACKS:
  62. try:
  63. setattr(client, attr, None)
  64. except Exception: # pragma: no cover - paho always allows this
  65. pass
  66. def _teardown() -> None:
  67. started = time.monotonic()
  68. # A zombie whose handshake finishes after this would otherwise send its
  69. # old outbox on the revived session; with on_connect cleared above,
  70. # nothing else empties it (#3454).
  71. discard_queued_publishes(client)
  72. try:
  73. client.disconnect()
  74. except Exception:
  75. pass
  76. try:
  77. client.loop_stop()
  78. except Exception:
  79. pass
  80. waited = time.monotonic() - started
  81. if waited >= _RETIRE_SLOW_SECONDS:
  82. # The stall that used to be the event loop's. Worth saying out
  83. # loud: it means this connection is wedged somewhere paho cannot
  84. # interrupt, and the next report of it should not have to be
  85. # diagnosed from a thread dump again.
  86. logger.warning(
  87. "[%s] Retiring the old MQTT client took %.0fs (paho's network thread would "
  88. "not stop). The connection was replaced anyway.",
  89. label,
  90. waited,
  91. )
  92. try:
  93. threading.Thread(target=_teardown, name=f"mqtt-retire-{label}", daemon=True).start()
  94. except RuntimeError as e:
  95. # Out of threads entirely, which means the process has larger problems.
  96. # Send the DISCONNECT inline anyway -- it is what keeps the abandoned
  97. # session from reconnecting and replaying (#1136) -- and leave the
  98. # network thread to paho.
  99. logger.error("[%s] Could not start the MQTT teardown thread: %s", label, e)
  100. try:
  101. client.disconnect()
  102. except Exception:
  103. pass