Skip to content

Prevent MQTT callback errors from silently killing the network loop thread#103

Merged
leandropineda merged 4 commits into
mainfrom
fix/mqtt-callback-resilience
Jun 9, 2026
Merged

Prevent MQTT callback errors from silently killing the network loop thread#103
leandropineda merged 4 commits into
mainfrom
fix/mqtt-callback-resilience

Conversation

@leandropineda

@leandropineda leandropineda commented Jun 9, 2026

Copy link
Copy Markdown
Member

Summary

paho dispatches user callbacks (on_message, on_connect, on_disconnect, …) on the network loop thread, and with suppress_exceptions=False (paho's default) it re-raises any exception that escapes a callback (client.py: if not self.suppress_exceptions: raise). That re-raise unwinds loop_forever, so the loop thread exits permanently — it is never restarted, yet is_connected() can keep reporting True. The result is a session that looks connected but silently drops every QoS-0 publish (pose, key-values) until the process is restarted.

This hardens RobotSession so a callback error can't wedge the session — and stays visible rather than merely suppressed — and reduces spurious keepalive-timeout disconnects.

Changes

  • _guard_callback(name) wraps each registered callback (on_connect, on_message, on_disconnect): an exception is logged with a traceback through the session logger (tagged with the callback name) and swallowed, so it never reaches paho's re-raise path on the loop thread. This is the primary mechanism and keeps the error observable.
  • client.enable_logger(self.logger) — without it, paho's own "Caught exception in …" message (emitted when suppress_exceptions swallows) went nowhere; now paho's internal logs are surfaced.
  • client.suppress_exceptions = True — catch-all net for any callback path not wrapped above (and future ones).
  • _on_message no longer re-raises — logs with traceback (topic context) and swallows.
  • Keepalive default 10s → 60s — 10s trips spurious "keep alive timeout" disconnects whenever the loop thread is briefly busy. Still overridable via the keepalive_secs kwarg.

Note on the keepalive change

is_connected() is a cached state flag updated by the loop thread; a silent/black-hole link is only detected via keepalive, i.e. up to ~2× keepalive. Raising the default to 60s trades slower silent-drop detection (now up to ~120s, was ~20s) for far fewer false-positive disconnects. Latency-sensitive callers can pass a smaller keepalive_secs.

Test plan

  • 4 new tests in test_robot_session_callbacks.py: handler error doesn't propagate out of _on_message; keepalive default is 60s; _guard_callback logs-and-swallows; enable_logger is wired.
  • Suite: 122 passed. Pre-existing failures unrelated to this change: test_metrics (requires the opentelemetry extra) and test_video::test_robot_session_register_camera (OpenCV camera) — both fail identically with these edits reverted.
  • flake8 clean, black --check clean.

🤖 Generated with Claude Code

leandropineda and others added 3 commits June 9, 2026 13:25
…hread

paho dispatches user callbacks (on_message, on_connect, on_disconnect, ...) on
the network loop thread and, with `suppress_exceptions=False` (the default),
re-raises any exception that escapes them. That re-raise unwinds `loop_forever`
and the loop thread exits permanently -- it is never restarted, yet
`is_connected()` can keep reporting True, so the session silently wedges
(QoS-0 publishes are dropped) until the process restarts.

- `_on_message` no longer re-raises: log with traceback and swallow, so a
  message-handler error can't take down the loop thread.
- Set `client.suppress_exceptions = True` as a catch-all covering every
  callback (on_connect's subscribe/_resend_modules, on_disconnect, etc.).
- Raise the default keepalive from 10s to 60s; 10s trips spurious
  "keep alive timeout" disconnects whenever the loop thread is briefly busy.
  Still overridable via the `keepalive_secs` kwarg.

Tests: 2 new cases in test_robot_session_callbacks.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds on suppress_exceptions to also keep the error visible. Without
enable_logger, paho's own "Caught exception in ..." message (emitted when it
swallows a callback exception) went nowhere, so suppress_exceptions alone kept
the loop alive but discarded the error.

- Add RobotSession._guard_callback(name) and wrap on_connect / on_message /
  on_disconnect at registration: an exception is logged with a traceback via
  the session logger (tagged with the callback name) and swallowed, so it never
  reaches paho's re-raise path on the network loop thread.
- Call client.enable_logger(self.logger) so paho's internal logs are surfaced.
- Keep suppress_exceptions = True as the catch-all net for unwrapped/future
  callbacks.

Tests: 2 new cases (guard logs-and-swallows; enable_logger is wired).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Note in the comment that the flag is the only thing covering callback paths
the per-callback guards do not: QoS>0 on_publish, consumer-set callbacks on
the public client, and future callbacks added without a guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@leandropineda leandropineda requested a review from b-Tomas June 9, 2026 17:30
Comment thread inorbit_edge/robot.py Outdated
The comment claimed an exception escaping _on_message kills paho's loop
thread. That was true before _guard_callback wrapped the callbacks; now the
wrapper (and suppress_exceptions) already prevent that. The clause remains for
message-scoped logging (with topic) and to keep _on_message safe if called
unwrapped -- not for loop-thread safety. Comment updated to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@leandropineda leandropineda merged commit d3e05be into main Jun 9, 2026
24 checks passed
@leandropineda leandropineda deleted the fix/mqtt-callback-resilience branch June 9, 2026 19:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants