Coverage for zenmqtt/mqtt/protocol/outgoing.py: 91%

205 statements  

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

1import asyncio 

2from collections import deque 

3from dataclasses import dataclass 

4from logging import getLogger 

5from typing import Optional, cast 

6 

7from zenmqtt.connection import MQTTConnection 

8from zenmqtt.exceptions import ( 

9 ConnectionLostError, 

10 NotConnectedError, 

11 ServerLimitError, 

12 SessionLostError, 

13) 

14from zenmqtt.mqtt.connect import ConnectionResult 

15from zenmqtt.mqtt.limits import DEFAULT_RECEIVE_MAXIMUM 

16from zenmqtt.mqtt.protocol.context import ProtocolContext 

17from zenmqtt.mqtt.publish import ( 

18 PubAckResult, 

19 PubCompResult, 

20 PublishAcknowledgement, 

21 PublishProperties, 

22 PubRecResult, 

23 pack_publish_packet, 

24 pack_pubrel_packet, 

25) 

26from zenmqtt.mqtt.reason_codes import FAILURE_REASON_CODE, PACKET_IDENTIFIER_NOT_FOUND 

27from zenmqtt.session import ( 

28 MQTTSession, 

29 OutgoingMessage, 

30 OutgoingMessageState, 

31 PacketIdentifier, 

32) 

33 

34logger = getLogger(__name__) 

35 

36 

37class _SendQuota: 

38 """ 

39 Flow control (MQTT 5, section 4.9): limits the number of QoS 1/2 

40 publications which are not acknowledged yet by "Receive Maximum". 

41 """ 

42 

43 def __init__(self, limit: int) -> None: 

44 self._available = limit 

45 self._holders: set[PacketIdentifier] = set() 

46 self._waiters: deque[asyncio.Future[None]] = deque() 

47 

48 async def acquire(self, packet_identifier: PacketIdentifier) -> bool: 

49 """ 

50 Returns True if the caller had to wait for a free slot. 

51 """ 

52 waited = False 

53 

54 if self._available > 0 and not self._waiters: 

55 self._available -= 1 

56 else: 

57 waited = True 

58 waiter: asyncio.Future[None] = asyncio.get_running_loop().create_future() 

59 self._waiters.append(waiter) 

60 

61 try: 

62 await waiter 

63 except asyncio.CancelledError: 

64 # the slot was handed to us right before cancellation 

65 if waiter.done() and not waiter.cancelled(): 

66 self._wake_up_or_return() 

67 raise 

68 

69 self._holders.add(packet_identifier) 

70 

71 return waited 

72 

73 def release(self, packet_identifier: PacketIdentifier) -> None: 

74 # only messages sent within this connection hold a slot 

75 if packet_identifier in self._holders: 

76 self._holders.remove(packet_identifier) 

77 self._wake_up_or_return() 

78 

79 def abort(self, exc: Exception) -> None: 

80 while self._waiters: 80 ↛ 81line 80 didn't jump to line 81 because the condition on line 80 was never true

81 if not (waiter := self._waiters.popleft()).done(): 

82 waiter.set_exception(exc) 

83 

84 def _wake_up_or_return(self) -> None: 

85 while self._waiters: 

86 if not (waiter := self._waiters.popleft()).done(): 86 ↛ 85line 86 didn't jump to line 85 because the condition on line 86 was always true

87 waiter.set_result(None) 

88 return 

89 

90 self._available += 1 

91 

92 

93@dataclass(slots=True) 

94class _PendingPublish: 

95 qos: int 

96 # there is no future for messages re-sent from the session of a previous run 

97 future: Optional[asyncio.Future[PublishAcknowledgement]] = None 

98 # loop time when the packet was sent within the current connection 

99 sent_at: Optional[float] = None 

100 

101 

102def _strip_topic_alias(properties: PublishProperties) -> PublishProperties: 

103 # topic aliases don't survive the reconnect 

104 return cast( 

105 PublishProperties, {k: v for k, v in properties.items() if k != "topic_alias"} 

106 ) 

107 

108 

109class OutgoingFlow: 

110 """ 

111 Outgoing PUBLISH: QoS 1/2 messages are stored in the session until the 

112 final acknowledgement and re-sent after reconnect. 

113 """ 

114 

115 def __init__( 

116 self, 

117 context: ProtocolContext, 

118 session: MQTTSession, 

119 ) -> None: 

120 self._context = context 

121 self._session = session 

122 # set by close(): publish calls fail instead of waiting for a connect 

123 self._closed_with: Optional[Exception] = None 

124 

125 self._pending: dict[PacketIdentifier, _PendingPublish] = {} 

126 self._send_quota: Optional[_SendQuota] = None 

127 

128 # QoS 1/2 publish calls which haven't returned yet 

129 self._publishing = 0 

130 # set when a message completes or a publish call returns, wakes up 

131 # wait_completed() 

132 self._progress = asyncio.Event() 

133 

134 async def publish( 

135 self, 

136 topic: str, 

137 payload: bytes, 

138 qos: int = 0, 

139 retain: bool = False, 

140 properties: Optional[PublishProperties] = None, 

141 ) -> Optional[PublishAcknowledgement]: 

142 if qos not in (0, 1, 2): 

143 raise ValueError(f"Invalid QoS: {qos}") 

144 

145 connection = self._context.ensure_connected() 

146 properties = properties or {} 

147 

148 # all limits are checked before the packet identifier is acquired 

149 self._context.server_limits.check_publish(qos, retain, properties) 

150 

151 if qos == 0: 

152 packet = pack_publish_packet( 

153 0, topic, payload, qos, retain, False, properties 

154 ) 

155 self._context.server_limits.check_packet_size(packet) 

156 

157 logger.debug("mqtt_protocol.send_publish_packet") 

158 sent_at = self._context.now() 

159 await self._context.write(connection, packet) 

160 self._context.metrics.on_publish_completed( 

161 0, 0, self._context.now() - sent_at 

162 ) 

163 return None 

164 

165 self._publishing += 1 

166 

167 try: 

168 return await self._publish_stored(topic, payload, qos, retain, properties) 

169 finally: 

170 self._publishing -= 1 

171 self._progress.set() 

172 

173 async def wait_completed(self) -> None: 

174 """ 

175 Waits until no QoS 1/2 message is in flight: every publish call 

176 returned, and every message re-sent from the session is completed or 

177 failed. 

178 """ 

179 while self._pending or self._publishing: 

180 self._progress.clear() 

181 await self._progress.wait() 

182 

183 async def _publish_stored( 

184 self, 

185 topic: str, 

186 payload: bytes, 

187 qos: int, 

188 retain: bool, 

189 properties: PublishProperties, 

190 ) -> PublishAcknowledgement: 

191 packet_identifier = await self._session.acquire_packet_identifier() 

192 

193 try: 

194 packet = pack_publish_packet( 

195 packet_identifier, topic, payload, qos, retain, False, properties 

196 ) 

197 connection = await self._reserve(packet_identifier, packet) 

198 

199 await self._session.store_outgoing_message( 

200 OutgoingMessage( 

201 packet_identifier=packet_identifier, 

202 topic=topic, 

203 payload=payload, 

204 qos=qos, 

205 retain=retain, 

206 properties=properties, 

207 state=OutgoingMessageState.AWAITING_ACK, 

208 ) 

209 ) 

210 except BaseException: 

211 # e.g. the session storage failed or the call was cancelled 

212 if self._send_quota: 212 ↛ 215line 212 didn't jump to line 215 because the condition on line 212 was always true

213 self._send_quota.release(packet_identifier) 

214 

215 await self._session.release_packet_identifier(packet_identifier) 

216 raise 

217 

218 # from here the message belongs to the session: if the connection was 

219 # lost meanwhile, it'll be re-sent on the next connect 

220 return await self._send_stored(packet_identifier, qos, packet, connection) 

221 

222 async def on_connected(self, connection_result: ConnectionResult) -> None: 

223 """Called after successful CONNACK, before the client is connected.""" 

224 self._closed_with = None 

225 

226 # publish calls wait while the server's "Receive Maximum" is reached 

227 self._send_quota = _SendQuota( 

228 connection_result.properties.get("receive_maximum", DEFAULT_RECEIVE_MAXIMUM) 

229 ) 

230 

231 await self._resend_pending_messages() 

232 

233 def on_session_lost(self) -> None: 

234 """The server didn't keep the session, stored messages are discarded.""" 

235 self._fail_all(SessionLostError()) 

236 

237 def on_connection_lost(self, exc: ConnectionLostError) -> None: 

238 if self._send_quota: 

239 # messages waiting for the quota weren't stored nor sent 

240 self._send_quota.abort(NotConnectedError()) 

241 self._send_quota = None 

242 

243 # QoS 1/2 messages are re-sent from the session and publish calls get 

244 # the acknowledgement of the re-sent message 

245 for pending in self._pending.values(): 

246 pending.sent_at = None 

247 

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

249 """Nothing will connect anymore, publish calls don't wait.""" 

250 self._closed_with = exc 

251 self._fail_all(exc) 

252 

253 async def handle_puback(self, puback_result: PubAckResult) -> None: 

254 logger.debug("mqtt_protocol.handle_puback_packet packet:%s", puback_result) 

255 

256 await self._complete(puback_result.packet_identifier, puback_result) 

257 

258 async def handle_pubrec(self, pubrec_packet: PubRecResult) -> None: 

259 connection = self._context.connection 

260 assert connection 

261 

262 packet_identifier = pubrec_packet.packet_identifier 

263 

264 logger.debug("mqtt_protocol.handle_pubrec_packet packet:%s", pubrec_packet) 

265 

266 if pubrec_packet.reason_code >= FAILURE_REASON_CODE: 

267 # the server rejected the message, the flow ends here 

268 await self._complete(packet_identifier, pubrec_packet) 

269 return 

270 

271 if await self._session.mark_outgoing_message_released(packet_identifier): 

272 reason_code = 0x0 

273 else: 

274 logger.warning( 

275 "mqtt_protocol.handle_pubrec_packet.unknown pid:%s", packet_identifier 

276 ) 

277 reason_code = PACKET_IDENTIFIER_NOT_FOUND 

278 

279 await self._context.write( 

280 connection, pack_pubrel_packet(packet_identifier, reason_code, {}) 

281 ) 

282 

283 async def handle_pubcomp(self, pubcomp_packet: PubCompResult) -> None: 

284 logger.debug("mqtt_protocol.handle_pubcomp_packet packet:%s", pubcomp_packet) 

285 

286 await self._complete(pubcomp_packet.packet_identifier, pubcomp_packet) 

287 

288 async def _reserve( 

289 self, packet_identifier: PacketIdentifier, packet: bytes 

290 ) -> MQTTConnection: 

291 """Checks the packet and takes a slot of the send quota.""" 

292 self._context.server_limits.check_packet_size(packet) 

293 

294 self._context.ensure_connected() 

295 

296 if self._send_quota: 296 ↛ 300line 296 didn't jump to line 300 because the condition on line 296 was always true

297 await self._acquire_send_quota(self._send_quota, packet_identifier) 

298 

299 # the connection could be lost while the call waited for the quota 

300 return self._context.ensure_connected() 

301 

302 async def _send_stored( 

303 self, 

304 packet_identifier: PacketIdentifier, 

305 qos: int, 

306 packet: bytes, 

307 connection: MQTTConnection, 

308 ) -> PublishAcknowledgement: 

309 connected = self._context.connected 

310 

311 # e.g. the connection was lost and closed while the message was stored 

312 if not connected and self._closed_with: 

313 raise self._closed_with 

314 

315 future: asyncio.Future[PublishAcknowledgement] = ( 

316 asyncio.get_running_loop().create_future() 

317 ) 

318 pending = _PendingPublish(qos, future) 

319 self._pending[packet_identifier] = pending 

320 

321 if connected: 321 ↛ 334line 321 didn't jump to line 334 because the condition on line 321 was always true

322 logger.debug("mqtt_protocol.send_publish_packet pid:%s", packet_identifier) 

323 pending.sent_at = self._context.now() 

324 

325 try: 

326 await self._context.write(connection, packet) 

327 except Exception: 

328 logger.debug( 

329 "mqtt_protocol.send_publish_packet.failed pid:%s, " 

330 "it'll be re-sent after reconnect", 

331 packet_identifier, 

332 ) 

333 

334 return await future 

335 

336 async def _resend_pending_messages(self) -> None: 

337 connection = self._context.connection 

338 assert connection 

339 

340 messages = await self._session.get_pending_outgoing_messages() 

341 

342 if messages: 

343 self._context.metrics.on_messages_resent(len(messages)) 

344 

345 for message in messages: 

346 packet_identifier = message.packet_identifier 

347 

348 if message.state == OutgoingMessageState.AWAITING_ACK: 

349 properties = _strip_topic_alias(message.properties) 

350 packet = pack_publish_packet( 

351 packet_identifier, 

352 message.topic, 

353 message.payload, 

354 message.qos, 

355 message.retain, 

356 True, 

357 properties, 

358 ) 

359 

360 try: 

361 self._context.server_limits.check_publish( 

362 message.qos, message.retain, properties 

363 ) 

364 self._context.server_limits.check_packet_size(packet) 

365 except ServerLimitError as exc: 

366 # the server would close the connection on every re-send 

367 logger.warning( 

368 "mqtt_protocol.resend_pending_message.discard pid:%s reason:%s", 

369 packet_identifier, 

370 exc, 

371 ) 

372 await self._session.complete_outgoing_message(packet_identifier) 

373 

374 if discarded := self._pending.pop(packet_identifier, None): 374 ↛ 375line 374 didn't jump to line 375 because the condition on line 374 was never true

375 self._fail(discarded, exc) 

376 self._progress.set() 

377 

378 continue 

379 

380 if self._send_quota: 380 ↛ 385line 380 didn't jump to line 385 because the condition on line 380 was always true

381 await self._acquire_send_quota(self._send_quota, packet_identifier) 

382 else: 

383 packet = pack_pubrel_packet(packet_identifier, 0x0, {}) 

384 

385 logger.debug( 

386 "mqtt_protocol.resend_pending_message pid:%s state:%s", 

387 packet_identifier, 

388 message.state.name, 

389 ) 

390 

391 pending = self._pending.setdefault( 

392 packet_identifier, _PendingPublish(message.qos) 

393 ) 

394 pending.sent_at = self._context.now() 

395 

396 await self._context.write(connection, packet) 

397 

398 async def _complete( 

399 self, packet_identifier: PacketIdentifier, result: PublishAcknowledgement 

400 ) -> None: 

401 await self._session.complete_outgoing_message(packet_identifier) 

402 

403 pending = self._pending.pop(packet_identifier, None) 

404 self._progress.set() 

405 

406 if pending and pending.sent_at is not None: 406 ↛ 411line 406 didn't jump to line 411 because the condition on line 406 was always true

407 self._context.metrics.on_publish_completed( 

408 pending.qos, result.reason_code, self._context.now() - pending.sent_at 

409 ) 

410 

411 if self._send_quota: 411 ↛ 414line 411 didn't jump to line 414 because the condition on line 411 was always true

412 self._send_quota.release(packet_identifier) 

413 

414 if pending and pending.future and not pending.future.done(): 

415 pending.future.set_result(result) 

416 

417 async def _acquire_send_quota( 

418 self, send_quota: _SendQuota, packet_identifier: PacketIdentifier 

419 ) -> None: 

420 started_at = self._context.now() 

421 

422 if await send_quota.acquire(packet_identifier): 

423 self._context.metrics.on_send_quota_wait(self._context.now() - started_at) 

424 

425 def _fail_all(self, exc: Exception) -> None: 

426 pending, self._pending = self._pending, {} 

427 self._progress.set() 

428 

429 for publish in pending.values(): 

430 self._fail(publish, exc) 

431 

432 @staticmethod 

433 def _fail(pending: _PendingPublish, exc: Exception) -> None: 

434 if pending.future and not pending.future.done(): 434 ↛ exitline 434 didn't return from function '_fail' because the condition on line 434 was always true

435 pending.future.set_exception(exc)