Coverage for zenmqtt/mqtt/protocol/incoming.py: 99%
153 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 21:55 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 21:55 +0000
1import asyncio
2import dataclasses
3from collections import OrderedDict, deque
4from dataclasses import dataclass, field
5from functools import partial
6from logging import getLogger
7from typing import Optional, cast
9from zenmqtt.exceptions import (
10 ProtocolError,
11 ReceiveMaximumExceededError,
12 TopicAliasInvalidError,
13)
14from zenmqtt.instrumentation.monitors import AckBlockedMonitor, BufferMonitor
15from zenmqtt.mqtt.connect import ConnectProperties
16from zenmqtt.mqtt.limits import DEFAULT_RECEIVE_MAXIMUM
17from zenmqtt.mqtt.protocol.context import ProtocolContext
18from zenmqtt.mqtt.publish import (
19 MQTTMessage,
20 PubRelResult,
21 pack_puback_packet,
22 pack_pubcomp_packet,
23 pack_pubrec_packet,
24)
25from zenmqtt.mqtt.reason_codes import FAILURE_REASON_CODE, PACKET_IDENTIFIER_NOT_FOUND
26from zenmqtt.session import MQTTSession, PacketIdentifier
28logger = getLogger(__name__)
31@dataclass(slots=True)
32class _PendingAck:
33 message: MQTTMessage
34 # loop time
35 received_at: float
36 acked: bool = False
37 reason_code: int = 0
40@dataclass(slots=True)
41class _ConnectionState:
42 """State of incoming messages which doesn't survive the reconnect."""
44 topic_aliases: dict[int, str] = field(default_factory=dict)
45 # QoS 1/2 messages which aren't acknowledged yet; QoS 2 stays here until
46 # PUBREL, the number is limited by "Receive Maximum" of the client
47 inflight: set[PacketIdentifier] = field(default_factory=set)
48 # QoS 1/2 messages in the order of receiving: PUBACK and PUBREC must be
49 # sent in this order (MQTT 5, 4.6). OrderedDict: getting the first item of
50 # a dict after deletions from its beginning gets slower with every deleted
51 # item, of an OrderedDict it takes constant time
52 pending_acks: OrderedDict[PacketIdentifier, _PendingAck] = field(
53 default_factory=OrderedDict
54 )
57class IncomingFlow:
58 """
59 Incoming PUBLISH. The read loop never waits for the application: messages
60 wait in a buffer which the application reads by next_message(), so control
61 packets (PINGRESP, PUBACK, ...) are handled for a slow application.
63 QoS 1/2 messages are acknowledged by ack(), so their number in the buffer
64 is limited by "Receive Maximum"; QoS 0 messages aren't limited. A big
65 buffer is reported by BufferMonitor.
66 """
68 def __init__(self, context: ProtocolContext, session: MQTTSession) -> None:
69 self._context = context
70 self._session = session
72 # limits sent by the client in CONNECT
73 self._receive_maximum = DEFAULT_RECEIVE_MAXIMUM
74 self._topic_alias_maximum = 0
76 self._state = _ConnectionState()
77 self._ack_lock = asyncio.Lock()
78 self._ack_monitor = AckBlockedMonitor()
80 # messages which wait for the application; None marks the end of
81 # messages
82 self._buffer: deque[Optional[MQTTMessage]] = deque()
83 self._buffer_ready = asyncio.Event()
84 self.buffer_monitor = BufferMonitor(context.metrics)
86 @property
87 def inflight(self) -> set[PacketIdentifier]:
88 return self._state.inflight
90 def on_new_connection(self, properties: ConnectProperties) -> None:
91 """
92 Topic aliases and the receive quota don't survive the reconnect; acks
93 of messages from the previous connection are ignored, the server
94 re-sends them.
95 """
96 self._receive_maximum = properties.get(
97 "receive_maximum", DEFAULT_RECEIVE_MAXIMUM
98 )
99 self._topic_alias_maximum = properties.get("topic_alias_maximum", 0)
101 self._state = _ConnectionState()
103 def on_connection_lost(self) -> None:
104 # not acknowledged messages are re-sent by the server, QoS 0 messages
105 # are delivered
106 self._buffer = deque(
107 message for message in self._buffer if message is None or not message.qos
108 )
110 def close(self) -> None:
111 """The client stops for good: the end of messages."""
112 self._enqueue(None)
114 async def next_message(self) -> Optional[MQTTMessage]:
115 """
116 Waits for the next message, None is the end of messages. Meant for one
117 reader: the end of messages goes to one of the waiting readers only.
118 """
119 while not self._buffer:
120 self._buffer_ready.clear()
121 await self._buffer_ready.wait()
123 message = self._buffer.popleft()
124 self.buffer_monitor.update(len(self._buffer))
126 return message
128 async def handle_publish(self, packet: MQTTMessage) -> None:
129 message = self._resolve_topic_alias(packet)
131 logger.debug("mqtt_protocol.handle_publish_packet packet:%s", message)
133 if not message.qos:
134 self._context.metrics.on_message_received(0, False)
135 self._enqueue(message)
136 return
138 packet_identifier = cast(int, message.packet_identifier)
139 state = self._state
141 # a re-sent QoS 2 message already holds its slot
142 if packet_identifier not in state.inflight:
143 if len(state.inflight) >= self._receive_maximum:
144 raise ReceiveMaximumExceededError(
145 f"More than {self._receive_maximum} incoming messages in flight"
146 )
148 state.inflight.add(packet_identifier)
150 if packet_identifier in state.pending_acks:
151 # the server sent the message again before the ack: the pending
152 # ack answers both, the message isn't delivered again (MQTT 5,
153 # 4.3.3)
154 logger.debug(
155 "mqtt_protocol.handle_publish_packet.duplicate pid:%s",
156 packet_identifier,
157 )
158 self._context.metrics.on_message_received(message.qos, True)
159 return
161 duplicate = message.qos == 2 and await self._session.has_incoming_message(
162 packet_identifier
163 )
164 self._context.metrics.on_message_received(message.qos, duplicate)
166 pending = _PendingAck(message, self._context.now())
167 state.pending_acks[packet_identifier] = pending
169 if duplicate:
170 # PUBREC was sent already, the message isn't delivered again
171 logger.debug(
172 "mqtt_protocol.handle_publish_packet.duplicate pid:%s",
173 packet_identifier,
174 )
175 pending.acked = True
176 await self._send_acks()
177 return
179 self._enqueue(message)
181 async def handle_pubrel(self, pubrel_result: PubRelResult) -> None:
182 connection = self._context.connection
183 assert connection
185 logger.debug("mqtt_protocol.handle_pubrel_packet packet:%s", pubrel_result)
187 packet_identifier = pubrel_result.packet_identifier
189 self._state.inflight.discard(packet_identifier)
191 if await self._session.complete_incoming_message(packet_identifier):
192 reason_code = 0x0
193 else:
194 logger.warning(
195 "mqtt_protocol.handle_pubrel_packet.unknown pid:%s", packet_identifier
196 )
197 reason_code = PACKET_IDENTIFIER_NOT_FOUND
199 await self._context.write(
200 connection, pack_pubcomp_packet(packet_identifier, reason_code, {})
201 )
203 async def ack(self, message: MQTTMessage, reason_code: int = 0) -> None:
204 """
205 Acknowledges a message: PUBACK for QoS 1, PUBREC for QoS 2, nothing for
206 QoS 0. Acks are sent in the order messages were received (MQTT 5, 4.6),
207 so an ack waits for the acks of earlier messages: one message which
208 isn't acknowledged holds back the acks of all later ones, and they
209 count to "Receive Maximum". A warning is logged when such a message
210 isn't acknowledged for ACK_BLOCKED_WARNING seconds.
212 :param reason_code: 0 or >= 0x80 to reject the message; the server
213 doesn't re-send a rejected message
214 """
215 if reason_code and reason_code < FAILURE_REASON_CODE:
216 raise ValueError(f"Invalid reason code: 0x{reason_code:02X}")
218 if not message.qos:
219 return
221 pending = self._state.pending_acks.get(cast(int, message.packet_identifier))
223 if pending is None or pending.message is not message:
224 # acknowledged already or received by the previous connection
225 logger.debug("mqtt_protocol.ack.ignored pid:%s", message.packet_identifier)
226 return
228 pending.acked = True
229 pending.reason_code = reason_code
231 await self._send_acks()
233 async def _send_acks(self) -> None:
234 """Sends acks of the first messages in the order of receiving."""
235 async with self._ack_lock:
236 state = self._state
237 pending_acks = state.pending_acks
239 while pending_acks:
240 packet_identifier, pending = next(iter(pending_acks.items()))
242 if not pending.acked:
243 self._ack_monitor.watch(
244 pending.message,
245 pending.received_at,
246 partial(self._messages_waiting_for, state, pending),
247 )
248 return
250 del pending_acks[packet_identifier]
252 connection = self._context.connection
254 if ( 254 ↛ 259line 254 didn't jump to line 259 because the condition on line 254 was never true
255 state is not self._state
256 or not connection
257 or connection.is_closing()
258 ):
259 return
261 reason_code = pending.reason_code
263 if pending.message.qos == 1:
264 state.inflight.discard(packet_identifier)
265 packet = pack_puback_packet(packet_identifier, reason_code, {})
266 else:
267 if reason_code < FAILURE_REASON_CODE:
268 # in flight until PUBREL
269 await self._session.register_incoming_message(packet_identifier)
270 else:
271 # the flow ends with the rejecting PUBREC
272 state.inflight.discard(packet_identifier)
274 packet = pack_pubrec_packet(packet_identifier, reason_code, {})
276 logger.debug(
277 "mqtt_protocol.send_ack pid:%s qos:%s reason:0x%02X",
278 packet_identifier,
279 pending.message.qos,
280 reason_code,
281 )
282 await self._context.write(connection, packet)
284 def _messages_waiting_for(
285 self, state: _ConnectionState, head: _PendingAck
286 ) -> Optional[int]:
287 """
288 The number of messages whose acks wait for `head`, None if it doesn't
289 hold them back anymore.
290 """
291 packet_identifier = cast(int, head.message.packet_identifier)
293 if (
294 state is not self._state
295 or head.acked
296 or state.pending_acks.get(packet_identifier) is not head
297 ):
298 return None
300 return len(state.pending_acks) - 1
302 def _resolve_topic_alias(self, message: MQTTMessage) -> MQTTMessage:
303 topic_alias = message.properties.get("topic_alias")
305 if topic_alias is None:
306 if not message.topic:
307 raise ProtocolError("PUBLISH without topic and topic alias")
309 return message
311 if not 0 < topic_alias <= self._topic_alias_maximum:
312 raise TopicAliasInvalidError(
313 f"Topic alias {topic_alias} isn't in range "
314 f"1..{self._topic_alias_maximum}"
315 )
317 if message.topic:
318 self._state.topic_aliases[topic_alias] = message.topic
319 return message
321 if (topic := self._state.topic_aliases.get(topic_alias)) is None:
322 raise ProtocolError(f"Unknown topic alias {topic_alias}")
324 return dataclasses.replace(message, topic=topic)
326 def _enqueue(self, message: Optional[MQTTMessage]) -> None:
327 self._buffer.append(message)
328 self._buffer_ready.set()
329 self.buffer_monitor.update(len(self._buffer))