fix: MQTTTransport Overhaul - #1259
Conversation
12156e5 to
5548931
Compare
Migrate transport callbacks to Paho's version 2 API and classify connection and disconnect reasons by their documented semantics. Propagate broker-rejected SUBACKs through operation tracking, including early acknowledgements, and leave reconnect timing to the SDK. Remove obsolete reconnect-delay and private thread workarounds now that Paho 2.1 is required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
5548931 to
192b747
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Critical MID-reuse and lifecycle races can complete unrelated operations or suppress valid disconnects.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Migrates the MQTT transport to Paho callback API v2 while hardening lifecycle, operation tracking, and cleanup behavior.
Changes:
- Adopts Paho 2.1 and v2 callbacks.
- Improves connection, SUBACK, publish, and MID handling.
- Expands regression and leak-test coverage.
File summaries
| File | Description |
|---|---|
uv.lock |
Updates locked dependency metadata. |
tests/unit/iothub/test_sync_clients.py |
Tests callback deprecation warnings. |
tests/unit/common/test_mqtt_transport.py |
Covers transport behavior and races. |
tests/unit/common/pipeline/test_pipeline_stages_mqtt.py |
Tests pipeline lifecycle handling. |
tests/e2e/iothub_e2e/sync/test_sync_twin.py |
Re-enables sync twin leak checks. |
tests/e2e/iothub_e2e/sync/test_sync_send_message.py |
Re-enables sync messaging leak checks. |
tests/e2e/iothub_e2e/sync/test_sync_infrastructure.py |
Supports sync infrastructure leak checks. |
tests/e2e/iothub_e2e/aio/test_twin.py |
Re-enables async twin leak checks. |
tests/e2e/iothub_e2e/aio/test_send_message.py |
Re-enables async messaging leak checks. |
tests/e2e/iothub_e2e/aio/test_infrastructure.py |
Supports async infrastructure leak checks. |
pyproject.toml |
Requires Paho MQTT 2.1 or newer. |
azure-iot-device/azure/iot/device/common/pipeline/pipeline_stages_mqtt.py |
Updates connection and disconnect coordination. |
azure-iot-device/azure/iot/device/common/pipeline/pipeline_stages_base.py |
Documents publish resend behavior. |
azure-iot-device/azure/iot/device/common/mqtt_transport.py |
Implements v2 callbacks and operation lifecycle handling. |
Review details
Suppressed comments (3)
azure-iot-device/azure/iot/device/common/mqtt_transport.py:708
- Add the missing space after the comma.
# `wait_for_publish()`,but that is only supported for PUBLISH.
azure-iot-device/azure/iot/device/common/pipeline/pipeline_stages_base.py:783
- This rationale omits QoS 2 even though the transport now deliberately preserves and resends both QoS 1 and QoS 2 publishes after reconnect. Mentioning only QoS 1 makes the timeout behavior misleading.
# Only Sub and Unsub are here because MQTT client will resend QoS 1 publishes after reconnect automatically
azure-iot-device/azure/iot/device/common/pipeline/pipeline_stages_base.py:841
- This rationale omits QoS 2 even though the transport now deliberately preserves and resends both QoS 1 and QoS 2 publishes after reconnect. Mentioning only QoS 1 makes the retry behavior misleading.
# Only Sub and Unsub are here because MQTT client will resend QoS 1 publishes after reconnect automatically
- Files reviewed: 13/14 changed files
- Comments generated: 3
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
🟡 Changes recommended
Four moderate connection-lifecycle, cleanup, documentation, and disconnect-race issues remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (4)
azure-iot-device/azure/iot/device/common/mqtt_transport.py:600
- When
disconnect()raises, this path stops the loop but exits beforeclear_inflight=Truecompletes tracked operations as cancelled. A hard disconnect can therefore leave publish/subscribe pipeline operations pending indefinitely despite the documented cancellation semantics. Ensure hard-disconnect cleanup runs from afinallypath even when Paho teardown fails, while preserving the disconnect exception.
try:
paho_error_code = self._mqtt_client.disconnect()
except Exception as e:
raise exceptions.ProtocolClientError("Unexpected Paho failure during disconnect") from e
finally:
try:
# Always stop and join the network thread, even if disconnect() fails.
self._mqtt_client.loop_stop()
finally:
azure-iot-device/azure/iot/device/common/mqtt_transport.py:645
- This
subscribe()contract describes the QoS 0publish()special case.subscribe()raisesNoConnectionErrorwhenever Paho returnsMQTT_ERR_NO_CONN, regardless of requested subscription QoS.
:raises: NoConnectionError if a QoS 0 message is published while the client is not connected.
azure-iot-device/azure/iot/device/common/mqtt_transport.py:622
- A non-
MQTT_ERR_NO_CONNreturn exits here before either cancellation or non-publish retirement runs, even thoughloop_stop()has already ended response processing. This leaves tracked operations unable to complete (including all operations for a hard disconnect). Perform operation-manager cleanup regardless of the disconnect return code before propagating the mapped error.
else:
# This could result in ConnectionDroppedError or ProtocolClientError
err = _create_error_from_paho_error_code(paho_error_code)
raise err
azure-iot-device/azure/iot/device/common/pipeline/pipeline_stages_mqtt.py:439
- A successful CONNACK followed immediately by socket closure queues both callbacks with the same
ConnectOperationsnapshot. Connected processing clears_pending_connection_op, so this identity check then drops the valid disconnect, leaving upper stages marked connected and MQTT operations unresolved. Resolve lifecycle ordering in the transport with a per-attempt state/generation (and only report an accepted connection after that state is committed) rather than using pending-operation identity as the connection generation.
if connection_op_snapshot is not self._pending_connection_op:
logger.info(
"{}: Ignoring disconnected callback for a connection operation that is no longer pending".format(
self.name
)
)
return
- Files reviewed: 12/14 changed files
- Comments generated: 2
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
The unresolved failed-CONNACK mapping and duplicate-disconnect race must be fixed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
azure-iot-device/azure/iot/device/common/mqtt_transport.py:776
- Add the missing space after the comma.
# `wait_for_publish()`,but that is only supported for PUBLISH.
- Files reviewed: 15/17 changed files
- Comments generated: 2
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
A critical disconnected-reauthorization stall and moderate reused-MID completion race remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
azure-iot-device/azure/iot/device/common/mqtt_transport.py:943
- A reused MID can still lose the new operation's completion. If a cancelled tombstone remains, Paho reuses that MID, and the new callback fires before the initiating API returns (an ordering this manager explicitly supports), this branch consumes the tombstone and discards the new completion. Registration then stores the operation as pending with no completion left, so it hangs until an outer timeout. Coordinate callback processing with the in-progress Paho call/registration so a tombstone is cleared before callbacks for a legitimately reused MID can be classified.
if mid in self._cancelled_operation_mids:
logger.debug("Discarding completion for cancelled Paho MID {}".format(mid))
self._cancelled_operation_mids.remove(mid)
- Files reviewed: 15/17 changed files
- Comments generated: 1
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
A critical disconnect/drop race can skip cleanup and background-error reporting.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
azure-iot-device/azure/iot/device/common/mqtt_transport.py:851
- Add the missing space after the comma in this comment.
# `wait_for_publish()`,but that is only supported for PUBLISH.
- Files reviewed: 19/21 changed files
- Comments generated: 1
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Drop cleanup can incorrectly cancel or remove operations created by a newly started connection.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
azure-iot-device/azure/iot/device/common/mqtt_transport.py:851
- Add a space after the comma in this comment.
# `wait_for_publish()`,but that is only supported for PUBLISH.
- Files reviewed: 19/21 changed files
- Comments generated: 1
- Review effort level: Balanced
There was a problem hiding this comment.
🔵 Needs a closer look
The broad lifecycle and concurrency changes require human approval, and the MQTT_ERR_NOMEM handling issue remains unresolved.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
azure-iot-device/azure/iot/device/common/mqtt_transport.py:60
MQTT_ERR_NOMEMis the only Paho 2.1 error code omitted from this mapping, so an out-of-memory result now produces the misleading messageUnknown Paho error code=1. Add the recognized code to preserve a meaningfulProtocolClientError(and include it in the parameterized error-code cases).
azure-iot-device/azure/iot/device/common/mqtt_transport.py:851- Add the missing space after the comma in this comment.
- Files reviewed: 18/20 changed files
- Comments generated: 0 new
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Cancellation callbacks can deadlock under the lifecycle lock, and an early completion after MID reuse can be lost.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
azure-iot-device/azure/iot/device/common/mqtt_transport.py:975
- A cancelled MID can also be legitimately reused by a new operation whose Paho completion arrives before the initiating API returns—the early-completion race handled by
register_operation(). In that ordering this branch consumes the old tombstone and discards the new operation's completion; registration then finds neither a tombstone nor an unknown completion and leaves the new callback pending forever. Cancellation/reuse tracking needs enough generation or operation-state information to distinguish a late old completion from an early completion for the reuse, and this combined ordering needs a regression test.
if mid in self._cancelled_operation_mids:
logger.debug("Discarding completion for cancelled Paho MID {}".format(mid))
self._cancelled_operation_mids.remove(mid)
- Files reviewed: 18/20 changed files
- Comments generated: 1
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
The critical disconnect race and QoS 0 tracking defect must be resolved before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
azure-iot-device/azure/iot/device/common/mqtt_transport.py:1087
- QoS 0 publishes are also retained here because tracking records only
OperationType.PUBLISH. Paho does not retransmit QoS 0 after a disconnect, so if the connection drops before itson_publishcallback, this method leaves the callback pending forever. Preserve only resumable QoS 1/2 publishes; QoS 0 tracking must be retired/completed on disconnect, which requires recording the publish QoS/resumability inPendingOperation.
- Files reviewed: 18/20 changed files
- Comments generated: 1
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
A critical teardown race can leave SUBSCRIBE or UNSUBSCRIBE operations pending forever.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 18/20 changed files
- Comments generated: 1
- Review effort level: Balanced
| with self._op_manager.operation_context(): | ||
| return fn(self, *args, **kwargs) |
Description of the problem
Paho 2.1 exposed defects in the SDK's MQTT integration that went beyond callback signature changes. The failures and their corresponding fixes are listed together below.
Paho 2.x compatibility
paho-mqtt>=2.1.0,<3.0.0, instantiate TCP and WebSocket clients withCallbackAPIVersion.VERSION2, adapt every callback signature, and map Paho's normalized MQTT 3.1.1 reasons into SDK exceptions.reconnect_on_failure=Falseand leave reconnect policy entirely with the SDK pipeline.Connection lifecycle
connect(). The transport returned after starting Paho's loop, while callbacks and a separate pipeline watchdog independently decided CONNACK success, failure, and timeout. Callers could observe transport success before MQTT establishment, and boundary races could produce conflicting pipeline state.MQTTTransport.connect()block for CONNACK and resolve the attempt through a reusable synchronized lifecycle state machine. It returns only after accepted CONNACK, raises mapped rejected-CONNACK or disconnect errors, and enforces the 60-second CONNACK timeout. The pipeline completesConnectOperationfrom that result and mapsConnectionTimeoutErrortoOperationTimeout.connect(),disconnect(), orshutdown()and race Paho's socket, packet, and network-loop state.MQTT_ERR_NO_CONNwithout invokingon_disconnect; a reauthorization that started while already disconnected could therefore leave its internalDisconnectOperationpending forever.MQTTTransport.disconnect()blocks until the network loop exits, andMQTTTransportStagecompletes an explicit disconnect from that method's return. Reauthorization remains an MQTT-stage-owned disconnect-then-connect sequence, so higher pipeline stages do not define what reauthorization means. A deferred pipeline invocation places the return fallback behind any connection-drop callback already submitted by Paho, preserving event-before-operation ordering and preventing duplicate completion.on_mqtt_connection_dropped_handler. Explicit disconnects complete fromdisconnect(); only unsolicited closure reaches the drop handler. Drop-only tracking cleanup and background-error reporting always run for a transport-classified drop, even if aDisconnectOperationbecomes pending before the pipeline processes the callback. Those old-connection effects finish before the pending operation completes, because its callbacks may synchronously reconnect and register new work. The transport synthesizesConnectionDroppedErrorif Paho anomalously reports a successful reason for an unsolicited closure.connect()returns is a dropped connection, and later outcomes are ignored. Join the previous network thread before beginning a new attempt so old callbacks cannot complete a retry.MQTT_ERR_PROTOCOLbefore invokingon_connect, then reports the sameUnspecified errorthroughon_disconnectthat it uses for an ordinary pre-CONNACK network loss. Callback API v2 therefore does not expose which case occurred.on_connect; explicitly classify the indistinguishable callback path asConnectionFailedErrorrather than claiming a protocol-specific result.disconnect()orloop_stop()masked socket, authorization, Paho return-code, rejected-CONNACK, disconnect, or timeout errors and could change retry behavior.DisconnectedEventinstances and treat a later notification as a new drop.ConnectionLifecycle. Requested disconnect callbacks are suppressed at the transport boundary; unsolicited drops are reported once.Thread.start()left Paho in a state previously repaired through its private_threadfield; a callback afterMQTTTransportcollection could leave the Paho client and loop alive._thread. Weak-reference callback cleanup now disconnects orphaned clients and stops their network loops.MQTT operations and persistent sessions
MQTTMessageInfo.rcand.mid, inspect every v2 SUBACK reason code, and complete rejected subscriptions withProtocolClientError.MQTT_ERR_NO_CONN, Paho retained those packets for delivery after reconnect, but the SDK discarded their completion tracking and told callers the work had failed.origin/mainpreserves every in-flight operation when connection retry is enabled, including work Paho cannot resume. QoS 0 PUBLISH, SUBSCRIBE, and UNSUBSCRIBE callbacks can therefore remain pending forever after a disconnect, while QoS 1/2 PUBLISH tracking must survive for Paho's retransmission state.PUBLISH_QOS_0,PUBLISH_QOS_1, orPUBLISH_QOS_2). Disconnect cleanup preserves only QoS 1/2 publishes; it removes and tombstones QoS 0, SUBSCRIBE, and UNSUBSCRIBE MIDs, then completes those SDK callbacks as cancelled after tracking and lifecycle locks are released. If retry is disabled, all outstanding SDK callbacks are completed as cancelled.Incorrect documented contract
subscribe()docstring described QoS 0 PUBLISH behavior. It gave callers the wrong exception contract.subscribe()raisesNoConnectionErrorwhenever the client is not connected.Known boundaries
Completing an SDK-tracked operation as cancelled only ends local completion tracking. It cannot cancel MQTT work already accepted by Paho, so a retained QoS 1/2 publish may still be delivered after a later connection. Hard-disconnect semantics remain a follow-up because local cancellation cannot retract a publish already accepted by Paho.
CONNECTION_TIMEOUTbounds the wait for CONNACK, but Paho socket setup andloop_stop()are blocking calls with no safe cancellation API. Lifecycle calls, including shutdown, are therefore serialized rather than racing Paho teardown.Validation
git diff --checkpassed