Coverage for zenmqtt/mqtt/protocol/core.py: 96%

191 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 21:55 +0000

1import asyncio 

2from asyncio import Task 

3from logging import getLogger 

4from typing import Optional, Sequence, assert_never 

5 

6from zenmqtt.connection import MQTTConnection 

7from zenmqtt.exceptions import ConnectionLostError, ProtocolError 

8from zenmqtt.instrumentation.metrics import MetricsCollector, guard_metrics 

9from zenmqtt.mqtt.auth import AuthResult 

10from zenmqtt.mqtt.codec import IncomingPacket, decode 

11from zenmqtt.mqtt.connect import ( 

12 SESSION_PRESENT_FLAG, 

13 ConnectionResult, 

14 ConnectProperties, 

15 DisconnectProperties, 

16 ServerDisconnect, 

17 WillMessage, 

18 pack_connect_packet, 

19 pack_disconnect_packet, 

20) 

21from zenmqtt.mqtt.limits import ServerLimits 

22from zenmqtt.mqtt.ping import PingRespResult 

23from zenmqtt.mqtt.protocol.commands import Commands 

24from zenmqtt.mqtt.protocol.context import ProtocolContext 

25from zenmqtt.mqtt.protocol.incoming import IncomingFlow 

26from zenmqtt.mqtt.protocol.keepalive import KeepAlive 

27from zenmqtt.mqtt.protocol.outgoing import OutgoingFlow 

28from zenmqtt.mqtt.protocol.stream import PacketReader 

29from zenmqtt.mqtt.publish import ( 

30 MQTTMessage, 

31 PubAckResult, 

32 PubCompResult, 

33 PublishAcknowledgement, 

34 PublishProperties, 

35 PubRecResult, 

36 PubRelResult, 

37) 

38from zenmqtt.mqtt.reason_codes import FAILURE_REASON_CODE, IMPLEMENTATION_SPECIFIC_ERROR 

39from zenmqtt.mqtt.subscribe import ( 

40 SubscribeResult, 

41 SubscriptionProperties, 

42 SubscriptionRequest, 

43 UnsubscribeProperties, 

44 UnsubscribeResult, 

45) 

46from zenmqtt.session import MQTTSession 

47 

48logger = getLogger(__name__) 

49 

50 

51class MQTTProtocol: 

52 """ 

53 MQTT 5 client protocol: the connection lifecycle and the read loop, which 

54 passes incoming packets to the parts of the protocol: outgoing and 

55 incoming PUBLISH flows, SUBSCRIBE/UNSUBSCRIBE and keep alive. 

56 """ 

57 

58 def __init__( 

59 self, 

60 session: MQTTSession, 

61 metrics: Optional[MetricsCollector] = None, 

62 ) -> None: 

63 """ 

64 A lost connection doesn't end anything: QoS 1/2 publish calls keep 

65 waiting for the acknowledgement of the message re-sent on the next 

66 connect, and next_message() keeps waiting. The owner calls close() 

67 when nothing will connect anymore. 

68 """ 

69 self._session = session 

70 self._context = ProtocolContext(guard_metrics(metrics)) 

71 

72 self._incoming = IncomingFlow(self._context, session) 

73 self._outgoing = OutgoingFlow(self._context, session) 

74 self._commands = Commands(self._context, session) 

75 self._keep_alive = KeepAlive(self._context) 

76 

77 self._read_loop_task: Optional[Task[None]] = None 

78 self._packet_reader: Optional[PacketReader] = None 

79 self._connection_future: Optional[asyncio.Future[ConnectionResult]] = None 

80 

81 # True if the connection is closed by disconnect call 

82 self._disconnecting = False 

83 

84 # DISCONNECT of the server for the current/last connection 

85 self.server_disconnect: Optional[ServerDisconnect] = None 

86 

87 # "Authentication Method" of the last CONNECT 

88 self._authentication_method: Optional[str] = None 

89 

90 @property 

91 def is_connected(self) -> bool: 

92 """True between successful CONNACK and loss of the connection.""" 

93 return self._context.connected 

94 

95 def set_connection(self, connection: MQTTConnection) -> None: 

96 self._context.set_connection(connection) 

97 self._connection_future = asyncio.get_running_loop().create_future() 

98 self._disconnecting = False 

99 self.server_disconnect = None 

100 

101 self._packet_reader = PacketReader(connection) 

102 self._read_loop_task = asyncio.create_task( 

103 self._read_loop(connection, self._packet_reader), 

104 name="mqtt-protocol-read-loop", 

105 ) 

106 

107 async def connect( 

108 self, 

109 client_id: str, 

110 username: Optional[str], 

111 password: Optional[str], 

112 clean_start: bool = False, 

113 keepalive: int = 0, 

114 properties: Optional[ConnectProperties] = None, 

115 will: Optional[WillMessage] = None, 

116 ) -> ConnectionResult: 

117 """ 

118 :param keepalive: seconds between control packets sent by the client, 

119 0 disables keep alive; "Server Keep Alive" from CONNACK overrides it 

120 """ 

121 connection = self._context.connection 

122 assert connection 

123 assert self._connection_future 

124 

125 properties = properties or {} 

126 

127 self._authentication_method = properties.get("authentication_method") 

128 self._incoming.on_new_connection(properties) 

129 

130 if self._packet_reader: 130 ↛ 135line 130 didn't jump to line 135 because the condition on line 130 was always true

131 self._packet_reader.maximum_packet_size = properties.get( 

132 "maximum_packet_size" 

133 ) 

134 

135 if clean_start: 

136 await self._session.reset() 

137 

138 login_packet = pack_connect_packet( 

139 client_id, 

140 username, 

141 password, 

142 clean_start=clean_start, 

143 keepalive=keepalive, 

144 properties=properties, 

145 will=will, 

146 ) 

147 

148 logger.debug("mqtt_protocol.send_connect_packet") 

149 sent_at = self._context.now() 

150 await self._context.write(connection, login_packet) 

151 

152 connection_result = await self._connection_future 

153 

154 self._context.metrics.on_connect( 

155 connection_result.reason_code, self._context.now() - sent_at 

156 ) 

157 

158 if connection_result.reason_code >= FAILURE_REASON_CODE: 

159 return connection_result 

160 

161 if not connection_result.flags & SESSION_PRESENT_FLAG: 

162 await self._discard_session() 

163 

164 self._context.set_server_limits( 

165 ServerLimits.from_connack(connection_result.properties) 

166 ) 

167 

168 await self._outgoing.on_connected(connection_result) 

169 

170 self._context.mark_connected() 

171 

172 self._keep_alive.start( 

173 connection_result.properties.get("server_keep_alive", keepalive) 

174 ) 

175 

176 return connection_result 

177 

178 async def disconnect( 

179 self, reason_code: int, properties: Optional[DisconnectProperties] = None 

180 ) -> None: 

181 connection = self._context.connection 

182 assert connection 

183 

184 self._disconnecting = True 

185 

186 if not connection.is_closing(): 

187 logger.debug("mqtt_protocol.send_disconnect_packet") 

188 await self._context.write( 

189 connection, pack_disconnect_packet(reason_code, properties or {}) 

190 ) 

191 

192 await connection.disconnect() 

193 

194 if read_loop_task := self._read_loop_task: 

195 await read_loop_task 

196 self._read_loop_task = None 

197 

198 async def wait_outgoing_completed(self) -> None: 

199 """ 

200 Waits until no outgoing QoS 1/2 message is in flight; see 

201 MQTTClient.disconnect(timeout=...). 

202 """ 

203 await self._outgoing.wait_completed() 

204 

205 async def wait_closed(self) -> None: 

206 """Waits until the current connection is closed and handled.""" 

207 if read_loop_task := self._read_loop_task: 207 ↛ exitline 207 didn't return from function 'wait_closed' because the condition on line 207 was always true

208 await asyncio.shield(read_loop_task) 

209 

210 async def close(self, exc: Exception) -> None: 

211 """ 

212 Nothing will connect anymore: fails publish calls which wait for 

213 acknowledgements and ends the messages. 

214 """ 

215 self._outgoing.close(exc) 

216 self._incoming.close() 

217 

218 async def publish( 

219 self, 

220 topic: str, 

221 payload: bytes, 

222 qos: int = 0, 

223 retain: bool = False, 

224 properties: Optional[PublishProperties] = None, 

225 ) -> Optional[PublishAcknowledgement]: 

226 return await self._outgoing.publish(topic, payload, qos, retain, properties) 

227 

228 async def next_message(self) -> Optional[MQTTMessage]: 

229 """ 

230 The next incoming message, None after close(); QoS 1/2 messages must 

231 be acknowledged by ack(). Meant for one reader, see MQTTClient.messages. 

232 """ 

233 return await self._incoming.next_message() 

234 

235 async def ack(self, message: MQTTMessage, reason_code: int = 0) -> None: 

236 await self._incoming.ack(message, reason_code) 

237 

238 async def subscribe( 

239 self, 

240 topics: Sequence[SubscriptionRequest], 

241 properties: Optional[SubscriptionProperties] = None, 

242 ) -> SubscribeResult: 

243 return await self._commands.subscribe(topics, properties) 

244 

245 async def unsubscribe( 

246 self, topics: Sequence[str], properties: Optional[UnsubscribeProperties] = None 

247 ) -> UnsubscribeResult: 

248 return await self._commands.unsubscribe(topics, properties) 

249 

250 async def ping(self) -> None: 

251 await self._keep_alive.ping() 

252 

253 async def _discard_session(self) -> None: 

254 """The server has no session, so the client must discard its own.""" 

255 if pending := await self._session.get_pending_outgoing_messages(): 

256 logger.warning( 

257 "mqtt_protocol.session_not_present.discard_pending_messages " 

258 "count:%s, set session_expiry_interval > 0 to keep them", 

259 len(pending), 

260 ) 

261 

262 await self._session.reset() 

263 

264 self._outgoing.on_session_lost() 

265 

266 async def _handle_connack(self, connection_result: ConnectionResult) -> None: 

267 assert self._connection_future 

268 

269 logger.debug("mqtt_protocol.handle_connack_packet packet:%s", connection_result) 

270 

271 if not self._connection_future.done(): 

272 self._connection_future.set_result(connection_result) 

273 

274 async def _handle_disconnect(self, disconnect_packet: ServerDisconnect) -> None: 

275 connection = self._context.connection 

276 assert connection 

277 

278 if disconnect_packet.reason_code >= FAILURE_REASON_CODE: 278 ↛ 283line 278 didn't jump to line 283 because the condition on line 278 was always true

279 logger.warning( 

280 "mqtt_protocol.handle_disconnect_packet packet:%s", disconnect_packet 

281 ) 

282 else: 

283 logger.debug( 

284 "mqtt_protocol.handle_disconnect_packet packet:%s", disconnect_packet 

285 ) 

286 

287 self.server_disconnect = disconnect_packet 

288 

289 # pending futures are failed by the read loop once the stream ends 

290 await connection.disconnect() 

291 

292 async def _handle_auth(self, packet: AuthResult) -> None: 

293 # the server sends AUTH only if CONNECT had an "Authentication Method" 

294 # (MQTT 5, 4.12) 

295 if self._authentication_method is None: 

296 raise ProtocolError("AUTH without authentication method in CONNECT") 

297 

298 # closes the connection with "Implementation specific error" 

299 raise NotImplementedError("Enhanced authentication isn't supported") 

300 

301 async def _read_loop( 

302 self, connection: MQTTConnection, packet_reader: PacketReader 

303 ) -> None: 

304 try: 

305 while packet := await packet_reader.read_packet(): 

306 header, reader, size = packet 

307 

308 logger.debug( 

309 "mqtt_protocol.read_loop.new_packet_received header:%s", header 

310 ) 

311 

312 self._context.metrics.on_packet_received(header.packet_type, size) 

313 

314 try: 

315 await self._dispatch(decode(header, reader)) 

316 except ProtocolError: 

317 raise 

318 except Exception as exc: 

319 # e.g. the session storage failed: the state of the flow 

320 # is unknown, so the connection is closed; after reconnect 

321 # the session and the server re-send what isn't completed 

322 logger.error( 

323 "mqtt_protocol.handle_incoming_packet.error", exc_info=exc 

324 ) 

325 await self._abort(connection, IMPLEMENTATION_SPECIFIC_ERROR) 

326 return 

327 except ProtocolError as exc: 

328 logger.error("mqtt_protocol.protocol_error", exc_info=exc) 

329 await self._abort(connection, exc.reason_code) 

330 except Exception as exc: 

331 logger.error("mqtt_protocol.read_loop.error", exc_info=exc) 

332 finally: 

333 await self._handle_connection_lost(connection) 

334 

335 async def _dispatch(self, packet: IncomingPacket) -> None: 

336 match packet: 

337 case MQTTMessage(): 

338 await self._incoming.handle_publish(packet) 

339 case PubAckResult(): 

340 await self._outgoing.handle_puback(packet) 

341 case PubRecResult(): 

342 await self._outgoing.handle_pubrec(packet) 

343 case PubRelResult(): 

344 await self._incoming.handle_pubrel(packet) 

345 case PubCompResult(): 

346 await self._outgoing.handle_pubcomp(packet) 

347 case SubscribeResult(): 

348 await self._commands.handle_suback(packet) 

349 case UnsubscribeResult(): 

350 await self._commands.handle_unsuback(packet) 

351 case PingRespResult(): 

352 await self._keep_alive.handle_pingresp(packet) 

353 case ConnectionResult(): 

354 await self._handle_connack(packet) 

355 case ServerDisconnect(): 

356 await self._handle_disconnect(packet) 

357 case AuthResult(): 357 ↛ 359line 357 didn't jump to line 359 because the pattern on line 357 always matched

358 await self._handle_auth(packet) 

359 case _: 

360 assert_never(packet) 

361 

362 async def _abort(self, connection: MQTTConnection, reason_code: int) -> None: 

363 await self._context.write(connection, pack_disconnect_packet(reason_code, {})) 

364 await connection.disconnect() 

365 

366 async def _handle_connection_lost(self, connection: MQTTConnection) -> None: 

367 logger.debug("mqtt_protocol.handle_connection_lost") 

368 

369 # everything up to the first await is synchronous, so no new 

370 # futures can be registered after the parts handled the loss 

371 if self._context.connected: 

372 self._context.metrics.on_connection_closed(lost=not self._disconnecting) 

373 

374 self._context.mark_disconnected() 

375 exc = ConnectionLostError(self.server_disconnect) 

376 

377 if self._connection_future and not self._connection_future.done(): 377 ↛ 378line 377 didn't jump to line 378 because the condition on line 377 was never true

378 self._connection_future.set_exception(exc) 

379 

380 self._keep_alive.on_connection_lost(exc) 

381 self._outgoing.on_connection_lost(exc) 

382 command_packet_identifiers = self._commands.on_connection_lost(exc) 

383 self._incoming.on_connection_lost() 

384 

385 # publish packets stay in the session, commands aren't re-sent 

386 for packet_identifier in command_packet_identifiers: 

387 await self._session.release_packet_identifier(packet_identifier) 

388 

389 if not connection.is_closing(): 

390 await connection.disconnect()