• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

cjrh / aiomsg / 28950424692

08 Jul 2026 02:28PM UTC coverage: 84.361% (-0.7%) from 85.065%
28950424692

push

github

web-flow
feat: automatic conversion of incoming websocket conn (#140)

167 of 203 new or added lines in 2 files covered. (82.27%)

561 of 665 relevant lines covered (84.36%)

3.36 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.0
/python-lib/aiomsg/__init__.py
1
"""
2

3
aiomsg
4
======
5

6
 (Servers)
7

8
Broadly 3 kinds of transmission (ends):
9

10
- receive-only
11
- send-only
12
- duplex
13

14
Broadly 2 kinds of distribution patterns:
15

16
- other ends receive all messages
17
- other ends get round-robin
18

19
Broadly 2 kinds of receiving patterns (this is minor):
20

21
- keep receiving from a client while there is data
22
- force switch after each message
23

24
Broadly 2 kinds of health/heartbeat patterns:
25

26
- for send-only+receive-only: receiver reconnects on timeout
27
- for duplex: connector sends a ping, binder sends pong. Connector must
28
  reconnect on a pong timeout
29

30
Run tests with watchmedo (available after ``pip install Watchdog`` ):
31

32
.. code-block:: bash
33

34
    watchmedo shell-command -W -D -R \\
35
        -c 'clear && py.test -s --durations=10 -vv' \\
36
        -p '*.py'
37

38
"""
39
import logging
4✔
40
import asyncio
4✔
41
import uuid
4✔
42
import json
4✔
43
from enum import Enum, auto
4✔
44
from asyncio import StreamReader, StreamWriter
4✔
45
from collections import UserDict
4✔
46
from itertools import cycle
4✔
47
from weakref import WeakSet
4✔
48
from typing import (
4✔
49
    Dict,
50
    Optional,
51
    Tuple,
52
    Union,
53
    List,
54
    AsyncGenerator,
55
    Callable,
56
    MutableMapping,
57
    Awaitable,
58
    Sequence,
59
)
60

61
from . import envelope
4✔
62
from . import msgproto
4✔
63
from . import ws
4✔
64

65
__all__ = ["Søcket", "SendMode", "DeliveryGuarantee"]
4✔
66

67
logger = logging.getLogger(__name__)
4✔
68
SEND_MODES = ["round_robin", "publish"]
4✔
69
JSONCompatible = Union[str, int, float, bool, List, Dict, None]
4✔
70

71

72
class NoConnectionsAvailableError(Exception):
4✔
73
    pass
4✔
74

75

76
class SendMode(Enum):
4✔
77
    PUBLISH = auto()
4✔
78
    ROUNDROBIN = auto()
4✔
79

80

81
class ConnectionEnd(Enum):
4✔
82
    BINDER = auto()
4✔
83
    CONNECTOR = auto()
4✔
84

85

86
class DeliveryGuarantee(Enum):
4✔
87
    AT_MOST_ONCE = auto()
4✔
88
    AT_LEAST_ONCE = auto()
4✔
89

90

91
class ConnectionsDict(UserDict):
4✔
92
    def __init__(self, *args, **kwargs):
4✔
93
        super().__init__(*args, **kwargs)
4✔
94
        self.cycle = None
4✔
95
        self.update_cycle()
4✔
96

97
    def __setitem__(self, key, value):
4✔
98
        super().__setitem__(key, value)
4✔
99
        self.update_cycle()
4✔
100

101
    def __delitem__(self, key):
4✔
102
        super().__delitem__(key)
4✔
103
        self.update_cycle()
4✔
104

105
    def update_cycle(self):
4✔
106
        self.cycle = cycle(self.data)
4✔
107

108
    def __next__(self):
4✔
109
        try:
4✔
110
            return next(self.cycle)
4✔
111
        except StopIteration:
4✔
112
            raise NoConnectionsAvailableError
4✔
113

114

115
# noinspection NonAsciiCharacters
116
class Søcket:
4✔
117
    def __init__(
4✔
118
        self,
119
        send_mode: SendMode = SendMode.ROUNDROBIN,
120
        delivery_guarantee: DeliveryGuarantee = DeliveryGuarantee.AT_MOST_ONCE,
121
        identity: Optional[bytes] = None,
122
        loop=None,
123
        reconnection_delay: Callable[[], float] = lambda: 0.1,
124
    ):
125
        """
126
        :param reconnection_delay: In large microservices
127
            architectures, an outage in one service will result in all the
128
            dependant services trying to connect over and over again (and
129
            sending their buffered data immediately). This parameter lets you
130
            provide a means of staggering the reconnections to avoid
131
            overwhelming the service that comes back into action after an
132
            outage. For example, you could stagger all your dependent
133
            microservices by providing:
134

135
                lambda: random.random(10)
136

137
            which means that the reconnection delay for a specific socket
138
            will be a random number of seconds between 0 and 10. This will
139
            spread out all the reconnecting services over a 10 second
140
            window.
141
        """
142
        self._tasks = WeakSet()
4✔
143
        self.send_mode = send_mode
4✔
144
        self.delivery_guarantee = delivery_guarantee
4✔
145
        self.identity = identity or uuid.uuid4().bytes
4✔
146
        self.loop = loop or asyncio.get_event_loop()
4✔
147

148
        self._queue_recv = asyncio.Queue(maxsize=65536)
4✔
149
        self._connections: MutableMapping[bytes, Connection] = ConnectionsDict()
4✔
150
        self._user_send_queue = asyncio.Queue()
4✔
151

152
        self.server = None
4✔
153
        # Long-running task created by ``connect()`` that keeps (re)connecting
154
        # for the life of the socket. Tracked so ``close()`` can cancel it.
155
        self.connect_task: Optional[asyncio.Task] = None
4✔
156
        self.socket_type: Optional[ConnectionEnd] = None
4✔
157
        self.closed = False
4✔
158
        self.at_least_one_connection = asyncio.Event()
4✔
159

160
        # Keyed by the 16-byte msg_id of an in-flight DATA_REQ; the handle is
161
        # the scheduled resend, cancelled when the matching ACK arrives.
162
        self.waiting_for_acks: Dict[bytes, asyncio.Handle] = {}
4✔
163
        self.reconnection_delay = reconnection_delay
4✔
164

165
        logger.debug("Starting the sender task.")
4✔
166
        # Note this task is started before any connections have been made.
167
        self.sender_task = self.loop.create_task(self._sender_main())
4✔
168
        if send_mode is SendMode.PUBLISH:
4✔
169
            self.sender_handler = self._sender_publish
4✔
170
        elif send_mode is SendMode.ROUNDROBIN:
4✔
171
            self.sender_handler = self._sender_robin
4✔
172
        else:  # pragma: no cover
173
            raise Exception("Unknown send mode.")
174

175
    def idstr(self) -> str:
4✔
176
        return self.identity.hex()
4✔
177

178
    async def bind(
4✔
179
        self,
180
        hostname: Optional[Union[str, Sequence[str]]] = "127.0.0.1",
181
        port: int = 25000,
182
        ssl_context=None,
183
        **kwargs,
184
    ):
185
        """
186
        :param hostname: Hostname to bind. This can be a few different types,
187
            see the documentation for `asyncio.start_server()`.
188
        :param port: See documentation for `asyncio.start_server()`.
189
        :param ssl_context: See documentation for `asyncio.start_server()`.
190
        :param kwargs: All extra kwargs are passed through to
191
            `asyncio.start_server`. See the asyncio documentation for
192
            details.
193
        """
194
        self.check_socket_type()
4✔
195
        logger.info(f"Binding socket {self.idstr()} to {hostname}:{port}")
4✔
196
        self.server = await asyncio.start_server(
4✔
197
            self._accept,
198
            hostname,
199
            port,
200
            ssl=ssl_context,
201
            reuse_address=True,
202
            **kwargs,
203
        )
204
        logger.info("Server started.")
4✔
205
        return self
4✔
206

207
    async def connect(
4✔
208
        self,
209
        hostname: str = "127.0.0.1",
210
        port: int = 25000,
211
        ssl_context=None,
212
        connect_timeout: float = 1.0,
213
    ):
214
        self.check_socket_type()
4✔
215

216
        async def new_connection():
4✔
217
            """Called each time a new connection is attempted. This
218
            suspend while the connection is up."""
219
            writer = None
4✔
220
            try:
4✔
221
                logger.debug("Attempting to open connection")
4✔
222
                reader, writer = await asyncio.wait_for(
4✔
223
                    asyncio.open_connection(
224
                        hostname, port, ssl=ssl_context
225
                    ),
226
                    timeout=connect_timeout,
227
                )
228
                logger.info(f"Socket {self.idstr()} connected.")
4✔
229
                await self._connection(reader, writer)
4✔
230
            except asyncio.TimeoutError:
4✔
231
                # Make timeouts look like socket connection errors
232
                raise OSError
×
233
            finally:
234
                logger.info(f"Socket {self.idstr()} disconnected.")
4✔
235
                # NOTE: the writer is closed inside _connection.
236

237
        async def connect_with_retry():
4✔
238
            """This is a long-running task that is intended to run
239
            for the life of the Socket object. It will continually
240
            try to connect."""
241
            logger.info(f"Socket {self.idstr()} connecting to {hostname}:{port}")
4✔
242
            while not self.closed:
4✔
243
                try:
4✔
244
                    await new_connection()
4✔
245
                    if self.closed:
4✔
246
                        break
4✔
247
                except OSError:
4✔
248
                    if self.closed:
4✔
249
                        break
1✔
250
                    else:
251
                        logger.warning("Connection error, reconnecting...")
4✔
252
                        await asyncio.sleep(self.reconnection_delay())
4✔
253
                        continue
4✔
254
                except asyncio.CancelledError:
4✔
255
                    break
4✔
256
                except Exception:
×
257
                    logger.exception("Unexpected error")
×
258

259
        self.connect_task = self.loop.create_task(connect_with_retry())
4✔
260
        return self
4✔
261

262
    async def messages(self) -> AsyncGenerator[bytes, None]:
4✔
263
        """Convenience method to make it a little easier to get started
264
        with basic, reactive sockets. This method is intended to be
265
        consumed with ``async for``, like this:
266

267
        .. code-block: python3
268

269
            import asyncio
270
            from aiomsg import SmartSock
271

272
            async def main(addr: str):
273
                async for msg in SmartSock().bind(addr).messages():
274
                    print(f'Got a message: {msg}')
275

276
            asyncio.run(main('localhost:8080'))
277

278
        (This is a complete program btw!)
279
        """
280
        async for source, msg in self.identity_messages():
4✔
281
            yield msg
4✔
282

283
    async def identity_messages(self) -> AsyncGenerator[Tuple[bytes, bytes], None]:
4✔
284
        """This is like the ``.messages`` asynchronous generator, but it
285
        returns a tuple of (identity, message) rather than only the message.
286

287
        Example:
288

289
        .. code-block: python3
290

291
            import asyncio
292
            from aiomsg import SmartSock
293

294
            async def main(addr: str):
295
                async for src, msg in SmartSock().bind(addr).messages():
296
                    print(f'Got a message from {src.hex()}: {msg}')
297

298
            asyncio.run(main('localhost:8080'))
299

300
        """
301
        while True:
4✔
302
            yield await self.recv_identity()
4✔
303

304
    async def _accept(self, reader: StreamReader, writer: StreamWriter):
4✔
305
        """Bind-side accept path: sniff raw-vs-WebSocket (PROTOCOL.md §10), then
306
        run the ordinary connection handler over the resulting byte-stream pair.
307

308
        The connect end never receives WebSocket, so it calls ``_connection``
309
        directly; only accepted (bind) connections pass through this sniff.
310
        """
311
        wrapped = await ws.sniff_and_wrap(reader, writer)
4✔
312
        if wrapped is None:
4✔
NEW
313
            writer.close()
×
NEW
314
            try:
×
NEW
315
                await writer.wait_closed()
×
NEW
316
            except (OSError, asyncio.CancelledError):
×
NEW
317
                pass
×
NEW
318
            return
×
319
        conn_reader, conn_writer = wrapped
4✔
320
        await self._connection(conn_reader, conn_writer)
4✔
321

322
    async def _connection(self, reader: StreamReader, writer: StreamWriter):
4✔
323
        """Each new connection will create a task with this coroutine."""
324
        logger.debug("Creating new connection")
4✔
325

326
        # Handshake: both ends send a HELLO (version + identity) and read the
327
        # peer's HELLO before any other traffic. See PROTOCOL.md §4.
328
        logger.debug(f"Sending my identity {self.idstr()}")
4✔
329
        await msgproto.send_msg(writer, envelope.hello(self.identity))
4✔
330
        hello_raw = await msgproto.read_msg(reader)
4✔
331
        if not hello_raw:
4✔
332
            return
×
333

334
        hello = envelope.decode(hello_raw)
4✔
335
        if hello is None or hello.type is not envelope.MsgType.HELLO:
4✔
336
            logger.error("Expected a HELLO handshake; closing connection.")
×
337
            return
×
338
        if hello.version != envelope.PROTOCOL_VERSION:
4✔
339
            logger.error(
×
340
                f"Unsupported protocol version {hello.version}; closing connection."
341
            )
342
            return
×
343
        identity = hello.identity
4✔
344

345
        logger.debug(f"Received identity {identity.hex()}")
4✔
346
        if identity in self._connections:
4✔
347
            logger.error(
×
348
                f"Socket with identity {identity.hex()} is already "
349
                f"connected. This connection will not be created."
350
            )
351
            return
×
352

353
        # Create the connection object. These objects are kept in a
354
        # collection that is used for message distribution.
355
        connection = Connection(
4✔
356
            identity=identity, reader=reader, writer=writer, recv_event=self.raw_recv
357
        )
358
        if len(self._connections) == 0:
4✔
359
            logger.warning("First connection made")
4✔
360
            self.at_least_one_connection.set()
4✔
361
        self._connections[connection.identity] = connection
4✔
362

363
        try:
4✔
364
            await connection.run()
4✔
365
        except asyncio.CancelledError:
×
366
            logger.info(f"Connection {identity.hex()} cancelled.")
×
367
        except Exception:
×
368
            logger.exception(f"Unhandled exception inside _connection")
×
369
            raise
×
370
        finally:
371
            logger.debug("connection closed")
4✔
372
            if connection.identity in self._connections:
4✔
373
                del self._connections[connection.identity]
4✔
374

375
            writer.close()
4✔
376
            await writer.wait_closed()
4✔
377

378
            if not self._connections:
4✔
379
                logger.warning("No connections!")
4✔
380
                self.at_least_one_connection.clear()
4✔
381

382
    async def _close(self):
4✔
383
        logger.info(f"Closing {self.idstr()}")
4✔
384
        self.closed = True
4✔
385

386
        # REP dict, close all events waiting to fire
387
        for msg_id, handle in self.waiting_for_acks.items():
4✔
388
            logger.debug(f"Cancelling pending resend event for msg_id {msg_id}")
×
389
            handle.cancel()
×
390

391
        if self.server:
4✔
392
            # Stop new connections from being accepted.
393
            self.server.close()
4✔
394

395
        # Close all active connections *before* awaiting
396
        # ``server.wait_closed()``. From Python 3.13, ``wait_closed()`` only
397
        # returns once every active connection has also closed; if we waited
398
        # first, a still-connected peer would block close() until its timeout.
399
        await asyncio.gather(
4✔
400
            *(c.close() for c in self._connections.values()), return_exceptions=True
401
        )
402

403
        if self.server:
4✔
404
            await self.server.wait_closed()
4✔
405

406
        # Stop the reconnection loop (no-op for bind-only sockets). By now any
407
        # live connection has been closed above, so the task is either between
408
        # retries or about to observe ``self.closed``; cancelling joins it.
409
        if self.connect_task:
4✔
410
            self.connect_task.cancel()
4✔
411
            await asyncio.gather(self.connect_task, return_exceptions=True)
4✔
412

413
        self.sender_task.cancel()
4✔
414
        await self.sender_task
4✔
415

416
        for task in self._tasks:
4✔
417
            task.cancel()
×
418
        await asyncio.gather(*self._tasks, return_exceptions=True)
4✔
419

420
        logger.info(f"Closed {self.idstr()}")
4✔
421

422
    async def close(self, timeout=10):
4✔
423
        try:
4✔
424
            await asyncio.wait_for(self._close(), timeout)
4✔
425
        except asyncio.TimeoutError:
×
426
            logger.exception("Timed out during close:")
×
427

428

429
        if not self.sender_task.done():
4✔
430
            logger.warning('sender_task was not complete.')
×
431

432
    def raw_recv(self, identity: bytes, env: envelope.Envelope):
4✔
433
        """Called when *any* active connection receives a data envelope.
434

435
        Connection-level frames (HELLO, HEARTBEAT) are handled inside
436
        :class:`Connection`; only DATA, DATA_REQ and ACK reach here.
437
        """
438
        logger.debug(f"In raw_recv, identity: {identity.hex()} type: {env.type}")
4✔
439

440
        if env.type is envelope.MsgType.DATA:
4✔
441
            # Plain application message (AT_MOST_ONCE). Pass it on as-is.
442
            self._queue_recv.put_nowait((identity, env.payload))
4✔
443
            return
4✔
444

445
        if env.type is envelope.MsgType.DATA_REQ:
×
446
            # An AT_LEAST_ONCE message. Deliver the payload to the application
447
            # and acknowledge receipt back to the sender on the same connection.
448
            self._queue_recv.put_nowait((identity, env.payload))
×
449

450
            msg_id = env.msg_id
×
451

452
            def notify_ack():
×
453
                logger.debug(f"Acknowledging DATA_REQ msg_id: {msg_id.hex()}")
×
454
                # Specifying the identity routes the ACK to the exact connection
455
                # the DATA_REQ arrived on.
456
                self._user_send_queue.put_nowait((identity, envelope.ack(msg_id)))
×
457

458
            # Defer the ACK slightly so the payload is handed to the application
459
            # before the sender learns it was received. Otherwise there is a
460
            # race where the ACK arrives, the sender stops, and the receiver
461
            # crashes before processing the payload — making the "guarantee"
462
            # a lie. 20 ms is plenty.
463
            self.loop.call_later(0.02, notify_ack)
×
464
            return
×
465

466
        if env.type is envelope.MsgType.ACK:
×
467
            # Acknowledgement of one of our DATA_REQ sends: cancel the pending
468
            # resend. ACKs are never surfaced to the application.
469
            handle = self.waiting_for_acks.pop(env.msg_id, None)
×
470
            logger.debug(f"ACK for {env.msg_id.hex()}, handle: {handle}")
×
471
            if handle:
×
472
                handle.cancel()
×
473
            return
×
474

475
    async def recv_identity(self) -> Tuple[bytes, bytes]:
4✔
476
        # Some connection sent us some data
477
        identity, message = await self._queue_recv.get()
4✔
478
        logger.debug(f"Received message from {identity.hex()}: {message}")
4✔
479

480
        return identity, message
4✔
481

482
    async def recv(self) -> bytes:
4✔
483
        # Just drop the identity
484
        _, message = await self.recv_identity()
4✔
485
        return message
4✔
486

487
    async def recv_string(self, **kwargs) -> str:
4✔
488
        """Automatically decode messages into strings.
489

490
        The ``kwargs`` are passed to the ``.decode()`` method of the
491
        received bytes object; for example ``encoding`` and ``errors``.
492
        If you wanted to override the error handler for decoding unicode,
493
        you might do something like the following:
494

495
        .. code-block:: python3
496

497
            msg_str = await sock.recv_string(errors='backslashreplace')
498

499
        Which will substitute unicode-invalid bytes with hexadecimal values
500
        formatted like ``\\xNN``.
501
        """
502
        return (await self.recv()).decode(**kwargs)
4✔
503

504
    async def recv_json(self, **kwargs) -> JSONCompatible:
4✔
505
        """Automatically deserialize messages in JSON format
506

507
        The ``kwargs`` are passed to the ``json.loads()`` method.
508
        """
509
        data = await self.recv()
4✔
510
        return json.loads(data, **kwargs)
4✔
511

512
    async def send(self, data: bytes, identity: Optional[bytes] = None, retries=None):
4✔
513
        logger.debug(f"Adding message to user queue: {data[:20]}")
4✔
514
        if (
4✔
515
            identity or self.send_mode is SendMode.ROUNDROBIN
516
        ) and self.delivery_guarantee is DeliveryGuarantee.AT_LEAST_ONCE:
517
            # AT_LEAST_ONCE: send a DATA_REQ and schedule a resend that fires
518
            # unless an ACK cancels it. (Not reachable for PUBLISH, which is
519
            # intentionally unsupported — see PROTOCOL.md §6.)
520
            #####################################################################
521
            msg_id = uuid.uuid4().bytes
×
522
            wire = envelope.data_req(msg_id, data)
×
523

524
            def resend(retries):
×
525
                if retries == 0:
×
526
                    logger.info(f"No more retries to send. Dropping [{data[:20]}...]")
×
527
                    return
×
528

529
                self._tasks.add(self.loop.create_task(self.send(data, identity)))
×
530
                # After deleting this here, a new one will be created when
531
                # we re-enter ``async def send()``
532
                logger.debug(f"Removing the acks entry")
×
533
                del self.waiting_for_acks[msg_id]
×
534

535
            handle: asyncio.Handle = self.loop.call_later(
×
536
                5.0, resend, 5 if retries is None else retries - 1
537
            )
538
            # In self.raw_recv(), this handle will be cancelled if the other
539
            # side sends back an acknowledgement of receipt (ACK).
540
            logger.debug("Creating future resend acks entry")
×
541
            self.waiting_for_acks[msg_id] = handle
×
542
            #####################################################################
543
        else:
544
            # AT_MOST_ONCE: a plain DATA envelope, no acknowledgement.
545
            wire = envelope.data(data)
4✔
546

547
        await self._user_send_queue.put((identity, wire))
4✔
548

549
    async def send_string(self, data: str, identity: Optional[bytes] = None, **kwargs):
4✔
550
        """Automatically convert the string to bytes when sending.
551

552
        The ``kwargs`` are passed to the internal ``data.encode()`` method. """
553
        await self.send(data.encode(**kwargs), identity)
4✔
554

555
    async def send_json(
4✔
556
        self, obj: JSONCompatible, identity: Optional[bytes] = None, **kwargs
557
    ):
558
        """Automatically serialise the given ``obj`` to a JSON representation
559
        when sending.
560

561
        The ``kwargs`` are passed to the ``json.dumps()`` method. In particular,
562
        you might find the ``default`` parameter of ``dumps`` useful, since
563
        this can be used to automatically convert an otherwise
564
        JSON-incompatible attribute into something that can be represented.
565
        For example:
566

567
        .. code-block:: python3
568

569
            class Blah:
570
                def __init__(self, x, y):
571
                    self.x = x
572
                    self.y = y
573

574
                def __str__(self):
575
                    return f'{x},{y}'
576

577
            d = dict(text='hi', obj=Blah(1, 2))
578

579
            await sock.send_json(d, default=str)
580
            # The bytes that will be sent: {"text": "hi", "obj": "1,2"}
581

582
        It requires a bit more work to make a class properly serialize and
583
        deserialize to JSON, however. You will need to carefully study
584
        how to use the ``object_hook`` parameter in the ``json.loads()``
585
        method.
586
        """
587
        await self.send_string(json.dumps(obj, **kwargs), identity)
4✔
588

589
    def _sender_publish(self, message: bytes):
4✔
590
        logger.debug(f"Sending message via publish")
4✔
591
        # TODO: implement grouping by named channels
592
        if not self._connections:
4✔
593
            raise NoConnectionsAvailableError
4✔
594

595
        for identity, c in self._connections.items():
4✔
596
            logger.debug(f"Sending to connection: {identity.hex()}")
4✔
597
            try:
4✔
598
                c.writer_queue.put_nowait(message)
4✔
599
                logger.debug("Placed message on connection writer queue.")
4✔
600
            except asyncio.QueueFull:
4✔
601
                logger.error(
4✔
602
                    f"Dropped msg to Connection {identity.hex()}, its write queue is full."
603
                )
604

605
    def _sender_robin(self, message: bytes):
4✔
606
        """
607
        Raises:
608

609
        - NoConnectionsAvailableError
610

611
        """
612
        logger.debug(f"Sending message via round_robin")
4✔
613
        queues_full = set()
4✔
614
        while True:
4✔
615
            identity = next(self._connections)
4✔
616
            logger.debug(f"Got connection: {identity.hex()}")
4✔
617
            if identity in queues_full:
4✔
618
                logger.warning(f"All send queues are full. Dropping message.")
4✔
619
                return
4✔
620
            try:
4✔
621
                connection = self._connections[identity]
4✔
622
                connection.writer_queue.put_nowait(message)
4✔
623
                logger.debug(f"Added message to connection send queue.")
4✔
624
                return
4✔
625
            except asyncio.QueueFull:
4✔
626
                logger.warning(
4✔
627
                    "Cannot send to Connection blah, its write queue is full! "
628
                    "Trying a different peer..."
629
                )
630
                queues_full.add(identity)
4✔
631

632
    def _sender_identity(self, message: bytes, identity: bytes):
4✔
633
        """Send directly to a peer with a distinct identity"""
634
        logger.debug(
4✔
635
            f"Sending message via identity {identity.hex()}: {message[:20]}..."
636
        )
637
        c = self._connections.get(identity)
4✔
638
        if not c:
4✔
639
            logger.error(
4✔
640
                f"Peer {identity.hex()} is not connected. Message will be dropped."
641
            )
642
            return
4✔
643

644
        try:
4✔
645
            c.writer_queue.put_nowait(message)
4✔
646
            logger.debug("Placed message on connection writer queue.")
4✔
647
        except asyncio.QueueFull:
4✔
648
            logger.error("Dropped msg to Connection blah, its write " "queue is full.")
4✔
649

650
    async def _sender_main(self):
4✔
651
        while True:
4✔
652
            q_task: asyncio.Task = self.loop.create_task(self._user_send_queue.get())
4✔
653
            w_task: asyncio.Task = self.loop.create_task(
4✔
654
                self.at_least_one_connection.wait()
655
            )
656
            try:
4✔
657
                await asyncio.wait([w_task, q_task], return_when=asyncio.ALL_COMPLETED)
4✔
658
            except asyncio.CancelledError:
4✔
659
                q_task.cancel()
4✔
660
                w_task.cancel()
4✔
661
                return
4✔
662

663
            identity, data = q_task.result()
4✔
664
            logger.debug(f"Got data to send: {data[:64]}")
4✔
665
            try:
4✔
666
                if identity is not None:
4✔
667
                    self._sender_identity(data, identity)
4✔
668
                else:
669
                    try:
4✔
670
                        logger.debug(f"Sending msg via handler: {data[:64]}")
4✔
671
                        self.sender_handler(message=data)
4✔
672
                    except NoConnectionsAvailableError:
1✔
673
                        logger.error("No connections available")
×
674
                        self.at_least_one_connection.clear()
×
675
                        try:
×
676
                            # Put it back onto the queue
677
                            self._user_send_queue.put_nowait((identity, data))
×
678
                        except asyncio.QueueFull:
×
679
                            logger.error(
×
680
                                "Send queue full when trying to recover "
681
                                "from no connections being available. "
682
                                "Dropping data!"
683
                            )
684
            except Exception as e:
×
685
                logger.exception(f"Unexpected error when sending a message: {e}")
×
686

687
    def check_socket_type(self):
4✔
688
        if self.socket_type is not None:  # pragma: no cover
689
            raise SystemError(f"Socket type already set: {self.socket_type}")
690

691
    async def __aenter__(self) -> "Søcket":
4✔
692
        return self
4✔
693

694
    async def __aexit__(self, exc_type, exc_val, exc_tb):
4✔
695
        await self.close()
4✔
696

697

698
class HeartBeatFailed(ConnectionError):
4✔
699
    pass
4✔
700

701

702
class Connection:
4✔
703
    def __init__(
4✔
704
        self,
705
        identity: bytes,
706
        reader: StreamReader,
707
        writer: StreamWriter,
708
        recv_event: Callable[[bytes, "envelope.Envelope"], None],
709
        loop=None,
710
        writer_queue_maxsize=0,
711
    ):
712
        self.loop = loop or asyncio.get_event_loop()
4✔
713
        self.identity = identity
4✔
714
        self.reader = reader
4✔
715
        self.writer = writer
4✔
716
        self.writer_queue = asyncio.Queue(maxsize=writer_queue_maxsize)
4✔
717
        self.reader_event = recv_event
4✔
718

719
        self.reader_task: Optional[asyncio.Task] = None
4✔
720
        self.writer_task: Optional[asyncio.Task] = None
4✔
721

722
        self.heartbeat_interval = 5
4✔
723
        self.heartbeat_timeout = 15
4✔
724
        self.heartbeat_message = envelope.heartbeat()
4✔
725

726
    def warn_dropping_data(self):  # pragma: no cover
727
        qsize = self.writer_queue.qsize()
728
        if qsize:
729
            logger.warning(
730
                f"Closing connection {self.identity.hex()} but there is "
731
                f"still data in the writer queue: {qsize}. "
732
                f"These messages will be lost."
733
            )
734

735
    async def close(self):
4✔
736
        # Kill the reader task
737
        self.reader_task.cancel()
4✔
738
        try:
4✔
739
            await asyncio.wait_for(self.writer_queue.join(), 10.0)
4✔
740
        except asyncio.TimeoutError:
×
741
            self.warn_dropping_data()
×
742

743
        self.writer_task.cancel()
4✔
744
        await asyncio.gather(self.reader_task, self.writer_task)
4✔
745
        self.reader_task = None
4✔
746
        self.writer_task = None
4✔
747

748
    async def _recv(self):
4✔
749
        while True:
4✔
750
            try:
4✔
751
                logger.debug("Waiting for messages in connection")
4✔
752
                message = await asyncio.wait_for(
4✔
753
                    msgproto.read_msg(self.reader), timeout=self.heartbeat_timeout
754
                )
755
                logger.debug(f"Got message in connection: {message}")
4✔
756
            except asyncio.TimeoutError:
4✔
757
                logger.warning("Heartbeat failed. Closing connection.")
×
758
                self.writer_queue.put_nowait(None)
×
759
                return
×
760
                # raise HeartBeatFailed
761
            except asyncio.CancelledError:
4✔
762
                return
4✔
763

764
            if not message:
4✔
765
                logger.debug("Connection closed (recv)")
4✔
766
                self.writer_queue.put_nowait(None)
4✔
767
                return
4✔
768

769
            env = envelope.decode(message)
4✔
770
            if env is None:
4✔
771
                # Unrecognised envelope type; ignore it (forward-compat).
772
                logger.debug("Ignoring unrecognised envelope")
×
773
                continue
×
774

775
            if env.type is envelope.MsgType.HEARTBEAT:
4✔
776
                logger.debug("Heartbeat received")
×
777
                continue
×
778

779
            try:
4✔
780
                logger.debug(
4✔
781
                    f"Received {env.type.name} on connection {self.identity.hex()}"
782
                )
783
                self.reader_event(self.identity, env)
4✔
784
            except asyncio.QueueFull:
×
785
                logger.error(
×
786
                    # TODO: fix message
787
                    "Data lost on connection blah because the recv "
788
                    "queue is full!"
789
                )
790
            except Exception as e:
×
791
                logger.exception(f"Unhandled error in _recv: {e}")
×
792

793
    async def send_wait(self, message: bytes):
4✔
794
        await msgproto.send_msg(self.writer, message)
4✔
795

796
    @staticmethod
4✔
797
    async def _send(
4✔
798
        identity: bytes,
799
        send_wait: Callable[[bytes], Awaitable[None]],
800
        writer_queue: asyncio.Queue,
801
        heartbeat_interval: float,
802
        heartbeat_message: bytes,
803
        reader_task: asyncio.Task,
804
    ):
805
        while True:
4✔
806
            try:
4✔
807
                try:
4✔
808
                    message = await asyncio.wait_for(
4✔
809
                        writer_queue.get(), timeout=heartbeat_interval
810
                    )
811
                except asyncio.TimeoutError:
4✔
812
                    logger.debug("Sending a heartbeat")
4✔
813
                    message = heartbeat_message
4✔
814
                except asyncio.CancelledError:
4✔
815
                    break
4✔
816
                else:
817
                    writer_queue.task_done()
4✔
818

819
                if not message:
4✔
820
                    logger.info("Connection closed (send)")
4✔
821
                    reader_task.cancel()
4✔
822
                    break
4✔
823

824
                logger.debug(
4✔
825
                    f"Got message from connection writer queue. {message[:64]}"
826
                )
827
                try:
4✔
828
                    await send_wait(message)
4✔
829
                    logger.debug("Sent message")
4✔
830
                except OSError as e:
4✔
831
                    logger.error(
4✔
832
                        f"Connection {identity.hex()} aborted, dropping "
833
                        f"message: {message[:50]}...{message[-50:]}\n"
834
                        f"error: {e}"
835
                    )
836
                    break
4✔
837
                except asyncio.CancelledError:
4✔
838
                    # Try to still send this message.
839
                    # await msgproto.send_msg(self.writer, message)
840
                    break
4✔
841
            except Exception as e:
4✔
842
                logger.error(f"Unhandled error: {e}")
4✔
843

844
    async def run(self):
4✔
845
        logger.info(f"Connection {self.identity.hex()} running.")
4✔
846
        self.reader_task = self.loop.create_task(self._recv())
4✔
847
        self.writer_task = self.loop.create_task(
4✔
848
            self._send(
849
                self.identity,
850
                self.send_wait,
851
                self.writer_queue,
852
                self.heartbeat_interval,
853
                self.heartbeat_message,
854
                self.reader_task,
855
            )
856
        )
857

858
        try:
4✔
859
            await asyncio.wait(
4✔
860
                [self.reader_task, self.writer_task], return_when=asyncio.ALL_COMPLETED
861
            )
862
        except asyncio.CancelledError:
4✔
863
            self.reader_task.cancel()
4✔
864
            self.writer_task.cancel()
4✔
865
            group = asyncio.gather(self.reader_task, self.writer_task)
4✔
866
            await group
4✔
867
            self.warn_dropping_data()
4✔
868
        logger.info(f"Connection {self.identity.hex()} no longer active.")
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc