paho_teardown.py 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  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 retire_paho_client(client, label: str) -> None:
  29. """Shut *client* down on a thread of its own and return immediately.
  30. *label* names the connection in logs and in the retirement thread's name,
  31. which is where a thread dump from the next stuck one will be read.
  32. Two things happen inline rather than on that thread:
  33. - The callbacks are cleared here. Blocking until the network thread was
  34. gone is what used to guarantee a client we had let go of could no longer
  35. touch our state; with the teardown detached, a zombie that finishes its
  36. handshake would auto-reconnect and report itself connected behind its
  37. replacement's back.
  38. - Nothing else -- not even `disconnect()`, which is cheap enough to run
  39. here (it queues a packet and returns) but would be one more thing
  40. between the caller and its return for no gain, since the thread starts
  41. within microseconds. It still happens and still matters: it is what
  42. stops paho's auto-reconnect, and with it the chance of an unacked
  43. `project_file` replaying onto a revived session (#1136).
  44. """
  45. for attr in _CALLBACKS:
  46. try:
  47. setattr(client, attr, None)
  48. except Exception: # pragma: no cover - paho always allows this
  49. pass
  50. def _teardown() -> None:
  51. started = time.monotonic()
  52. try:
  53. client.disconnect()
  54. except Exception:
  55. pass
  56. try:
  57. client.loop_stop()
  58. except Exception:
  59. pass
  60. waited = time.monotonic() - started
  61. if waited >= _RETIRE_SLOW_SECONDS:
  62. # The stall that used to be the event loop's. Worth saying out
  63. # loud: it means this connection is wedged somewhere paho cannot
  64. # interrupt, and the next report of it should not have to be
  65. # diagnosed from a thread dump again.
  66. logger.warning(
  67. "[%s] Retiring the old MQTT client took %.0fs (paho's network thread would "
  68. "not stop). The connection was replaced anyway.",
  69. label,
  70. waited,
  71. )
  72. try:
  73. threading.Thread(target=_teardown, name=f"mqtt-retire-{label}", daemon=True).start()
  74. except RuntimeError as e:
  75. # Out of threads entirely, which means the process has larger problems.
  76. # Send the DISCONNECT inline anyway -- it is what keeps the abandoned
  77. # session from reconnecting and replaying (#1136) -- and leave the
  78. # network thread to paho.
  79. logger.error("[%s] Could not start the MQTT teardown thread: %s", label, e)
  80. try:
  81. client.disconnect()
  82. except Exception:
  83. pass