sillo-wire 0.1.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sillo_wire/hub.py ADDED
@@ -0,0 +1,315 @@
1
+ """Rooms, membership and fan-out.
2
+
3
+ A :class:`Hub` owns rooms and the peers in them. It is an ordinary object, not
4
+ a module of class methods over a global dict, so two hubs are two independent
5
+ worlds: a test gets a fresh one per case instead of remembering to flush shared
6
+ state, and an application that serves several tenants can keep their traffic
7
+ apart without a naming convention.
8
+
9
+ Fan-out is concurrent and non-blocking. Delivering to a room enqueues on every
10
+ peer at once and returns a :class:`~sillo_wire.envelope.DeliveryReport`; it
11
+ never waits on a socket, so one client that has stopped reading cannot hold up
12
+ the rest of the room.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import inspect
19
+ import typing
20
+
21
+ from sillo_wire.backlog import Backlog, MemoryBacklog
22
+ from sillo_wire.envelope import DeliveryReport, Envelope
23
+ from sillo_wire.errors import RoomNotFound
24
+ from sillo_wire.peer import Peer
25
+
26
+ __all__ = ["Hub"]
27
+
28
+ #: Called as ``listener(room, peer)`` when membership changes. Sync or async.
29
+ PresenceListener = typing.Callable[[str, Peer], typing.Any]
30
+
31
+
32
+ class Hub:
33
+ """A set of rooms and the peers subscribed to them.
34
+
35
+ Args:
36
+ backlog: Where delivered envelopes are retained for replay. Defaults to
37
+ an in-memory one; pass :class:`~sillo_wire.backlog.NullBacklog` to
38
+ keep nothing.
39
+ """
40
+
41
+ __slots__ = ("_backlog", "_on_join", "_on_leave", "_rooms")
42
+
43
+ def __init__(self, backlog: Backlog | None = None) -> None:
44
+ self._rooms: dict[str, set[Peer]] = {}
45
+ self._backlog: Backlog = MemoryBacklog() if backlog is None else backlog
46
+ self._on_join: list[PresenceListener] = []
47
+ self._on_leave: list[PresenceListener] = []
48
+
49
+ # ── membership ───────────────────────────────────────────────────────
50
+
51
+ async def join(self, peer: Peer, room: str) -> bool:
52
+ """Subscribe *peer* to *room*. Returns whether it was newly added.
53
+
54
+ Starts the peer's writer if it is not already running, so a caller
55
+ never has to remember to.
56
+ """
57
+ if not room:
58
+ raise ValueError("room name must not be empty")
59
+
60
+ members = self._rooms.setdefault(room, set())
61
+ if peer in members:
62
+ return False
63
+
64
+ members.add(peer)
65
+ peer.start()
66
+ await self._announce(self._on_join, room, peer)
67
+ return True
68
+
69
+ async def leave(self, peer: Peer, room: str) -> bool:
70
+ """Unsubscribe *peer* from *room*. Returns whether it was a member.
71
+
72
+ Leaving a room that does not exist, or that the peer is not in, is not
73
+ an error — a disconnect racing a cleanup produces both, routinely.
74
+ """
75
+ members = self._rooms.get(room)
76
+ if members is None or peer not in members:
77
+ return False
78
+
79
+ members.discard(peer)
80
+ if not members:
81
+ del self._rooms[room]
82
+ await self._announce(self._on_leave, room, peer)
83
+ return True
84
+
85
+ async def leave_all(self, peer: Peer) -> list[str]:
86
+ """Remove *peer* from every room. Returns the rooms it was in."""
87
+ left = [room for room, members in self._rooms.items() if peer in members]
88
+ for room in left:
89
+ await self.leave(peer, room)
90
+ return left
91
+
92
+ async def disconnect(self, peer: Peer) -> None:
93
+ """Remove *peer* from every room and close its socket.
94
+
95
+ The one call a consumer's disconnect path needs.
96
+ """
97
+ await self.leave_all(peer)
98
+ await peer.close()
99
+
100
+ # ── delivery ─────────────────────────────────────────────────────────
101
+
102
+ async def broadcast(
103
+ self,
104
+ room: str,
105
+ payload: typing.Any,
106
+ *,
107
+ retain: bool = True,
108
+ ) -> DeliveryReport:
109
+ """Deliver *payload* to every peer in *room*.
110
+
111
+ Args:
112
+ room: Which room to deliver to. An unknown room is an empty report,
113
+ not an error — rooms come and go with their last member.
114
+ payload: What to send.
115
+ retain: Whether to record the envelope in the backlog for replay.
116
+
117
+ Returns:
118
+ What happened, per peer.
119
+ """
120
+ envelope = Envelope(payload=payload, room=room)
121
+ if retain:
122
+ await self._backlog.append(envelope)
123
+
124
+ members = self._rooms.get(room)
125
+ if not members:
126
+ return DeliveryReport()
127
+
128
+ report, stale = self._offer_all(members, envelope)
129
+ if stale:
130
+ members.difference_update(stale)
131
+ if not members:
132
+ del self._rooms[room]
133
+ return report
134
+
135
+ async def send_to(
136
+ self, identity: typing.Any, payload: typing.Any
137
+ ) -> DeliveryReport:
138
+ """Deliver *payload* to every peer carrying *identity*.
139
+
140
+ This is the "reach that user wherever they are" call — one person with
141
+ a phone and two tabs is three peers, and all three get it. Nothing is
142
+ retained: the message is addressed to a person, not to a room, so there
143
+ is no room whose history it belongs in.
144
+ """
145
+ targets = {
146
+ peer
147
+ for members in self._rooms.values()
148
+ for peer in members
149
+ if peer.identity == identity
150
+ }
151
+ if not targets:
152
+ return DeliveryReport()
153
+ # Stale peers are not evicted here: a peer reached by identity may be
154
+ # in several rooms, and removing it from all of them is `prune`'s job.
155
+ report, _ = self._offer_all(targets, Envelope(payload=payload))
156
+ return report
157
+
158
+ def _offer_all(
159
+ self,
160
+ peers: typing.Iterable[Peer],
161
+ envelope: Envelope,
162
+ ) -> tuple[DeliveryReport, list[Peer]]:
163
+ """Enqueue *envelope* on each peer; report the outcome and the dead.
164
+
165
+ Synchronous on purpose. ``offer`` cannot block, so there is nothing to
166
+ await and no window in which the room's membership could change
167
+ underneath the loop — which is why the caller can act on the returned
168
+ stale list against the very set it passed in.
169
+ """
170
+ delivered = dropped = failed = 0
171
+ stale: list[Peer] = []
172
+
173
+ for peer in list(peers):
174
+ if peer.closed:
175
+ failed += 1
176
+ stale.append(peer)
177
+ elif peer.offer(envelope):
178
+ delivered += 1
179
+ else:
180
+ dropped += 1
181
+ # An overflow policy of CLOSE marks the peer closed as it
182
+ # refuses the message, so it is evictable in the same pass.
183
+ if peer.closed:
184
+ stale.append(peer)
185
+
186
+ report = DeliveryReport(delivered=delivered, dropped=dropped, failed=failed)
187
+ return report, stale
188
+
189
+ # ── replay ───────────────────────────────────────────────────────────
190
+
191
+ async def replay(
192
+ self,
193
+ peer: Peer,
194
+ room: str,
195
+ *,
196
+ since: int = 0,
197
+ limit: int | None = None,
198
+ ) -> int:
199
+ """Send *peer* what it missed in *room*. Returns how many were sent.
200
+
201
+ The cursor a client sends back after reconnecting is the ``seq`` of the
202
+ last envelope it saw, which is why sequences are monotonic and never
203
+ reused.
204
+ """
205
+ missed = await self._backlog.since(room, since)
206
+ if limit is not None:
207
+ missed = missed[-limit:] if limit > 0 else []
208
+
209
+ sent = 0
210
+ for envelope in missed:
211
+ if peer.offer(envelope):
212
+ sent += 1
213
+ return sent
214
+
215
+ async def history(self, room: str, limit: int = 50) -> list[Envelope]:
216
+ """The most recent envelopes retained for *room*."""
217
+ return await self._backlog.latest(room, limit=limit)
218
+
219
+ async def clear_history(self, room: str | None = None) -> None:
220
+ """Forget *room*'s backlog, or every room's."""
221
+ await self._backlog.clear(room)
222
+
223
+ # ── presence ─────────────────────────────────────────────────────────
224
+
225
+ def on_join(self, listener: PresenceListener) -> PresenceListener:
226
+ """Register *listener*, called when a peer joins a room.
227
+
228
+ Returns the listener, so it works as a decorator.
229
+ """
230
+ self._on_join.append(listener)
231
+ return listener
232
+
233
+ def on_leave(self, listener: PresenceListener) -> PresenceListener:
234
+ """Register *listener*, called when a peer leaves a room."""
235
+ self._on_leave.append(listener)
236
+ return listener
237
+
238
+ async def _announce(
239
+ self, listeners: list[PresenceListener], room: str, peer: Peer
240
+ ) -> None:
241
+ """Run presence listeners, tolerating both sync and async ones."""
242
+ for listener in listeners:
243
+ result = listener(room, peer)
244
+ if inspect.isawaitable(result):
245
+ await result
246
+
247
+ # ── introspection ────────────────────────────────────────────────────
248
+
249
+ def rooms(self) -> list[str]:
250
+ """Every room with at least one peer in it."""
251
+ return list(self._rooms)
252
+
253
+ def members(self, room: str) -> list[Peer]:
254
+ """The peers in *room*, or an empty list if there is no such room."""
255
+ return list(self._rooms.get(room, ()))
256
+
257
+ def identities(self, room: str) -> list[typing.Any]:
258
+ """Distinct, non-``None`` identities present in *room*.
259
+
260
+ The presence roster: who is here, rather than how many sockets are.
261
+
262
+ Raises:
263
+ RoomNotFound: If *room* does not exist. Unlike a broadcast, asking
264
+ who is in a room that has never existed is a question with no
265
+ sensible empty answer — it is usually a typo.
266
+ """
267
+ if room not in self._rooms:
268
+ raise RoomNotFound(room)
269
+ seen: list[typing.Any] = []
270
+ for peer in self._rooms[room]:
271
+ if peer.identity is not None and peer.identity not in seen:
272
+ seen.append(peer.identity)
273
+ return seen
274
+
275
+ def count(self, room: str | None = None) -> int:
276
+ """Peers in *room*, or across every room when *room* is ``None``.
277
+
278
+ A peer in two rooms counts twice in the total, because the number being
279
+ reported is subscriptions rather than connections.
280
+ """
281
+ if room is not None:
282
+ return len(self._rooms.get(room, ()))
283
+ return sum(len(members) for members in self._rooms.values())
284
+
285
+ async def prune(self) -> list[Peer]:
286
+ """Evict closed and idle peers from every room. Returns those removed.
287
+
288
+ Nothing calls this on a timer — when it runs is an application's
289
+ decision, because the right cadence depends on how long a stale
290
+ subscription actually costs anything.
291
+ """
292
+ removed: list[Peer] = []
293
+ for room in list(self._rooms):
294
+ for peer in list(self._rooms.get(room, ())):
295
+ if peer.closed or peer.is_idle():
296
+ await self.leave(peer, room)
297
+ if peer not in removed:
298
+ removed.append(peer)
299
+ return removed
300
+
301
+ async def close(self) -> None:
302
+ """Close every peer and drop every room.
303
+
304
+ The rooms go first, before anything is awaited, so a broadcast racing
305
+ the shutdown finds an empty hub rather than a room whose peers are
306
+ half-closed.
307
+ """
308
+ peers = {p for members in self._rooms.values() for p in members}
309
+ self._rooms.clear()
310
+ # Concurrently, for the same reason a broadcast is: closing a thousand
311
+ # sockets one after another makes shutdown as slow as the slowest one.
312
+ await asyncio.gather(*(peer.close() for peer in peers))
313
+
314
+ def __repr__(self) -> str:
315
+ return f"<Hub rooms={len(self._rooms)} peers={self.count()}>"
sillo_wire/peer.py ADDED
@@ -0,0 +1,231 @@
1
+ """One connected client, with a bounded outbound queue.
2
+
3
+ The queue is the point. Writing straight to a socket inside a fan-out means the
4
+ slowest member of a room sets the pace for every other member: a client that
5
+ has stopped reading fills its kernel buffer, the write blocks, and everyone
6
+ behind it waits. Here a broadcast only ever *enqueues*, which cannot block, and
7
+ a writer task per peer drains the queue at whatever rate that peer manages.
8
+
9
+ What happens when a queue fills is a policy rather than a default -- see
10
+ :class:`~sillo_wire.policy.Overflow`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import contextlib
17
+ import time
18
+ import typing
19
+ import uuid
20
+
21
+ from sillo_wire.envelope import Encoding, Envelope
22
+ from sillo_wire.errors import PeerGone
23
+ from sillo_wire.policy import Overflow
24
+
25
+ __all__ = ["Peer"]
26
+
27
+ #: Default queue depth. Deep enough to absorb a burst, shallow enough that a
28
+ #: client which has genuinely stopped reading is noticed within a second or two
29
+ #: rather than after megabytes have accumulated on its behalf.
30
+ DEFAULT_CAPACITY = 64
31
+
32
+
33
+ class Peer:
34
+ """A socket, its identity, and its outbound queue.
35
+
36
+ Args:
37
+ socket: The connection to write to. Anything with the
38
+ ``send_json`` / ``send_text`` / ``send_bytes`` trio works, which is
39
+ what makes a peer testable without a server.
40
+ encoding: How payloads are written.
41
+ identity: Who this connection belongs to, if anyone. Two peers may
42
+ share an identity — the same user with two tabs open — which is
43
+ what :meth:`~sillo_wire.hub.Hub.send_to` relies on.
44
+ capacity: Outbound queue depth.
45
+ overflow: What to do when the queue is full.
46
+ idle_timeout: Seconds of silence after which the peer is considered
47
+ stale. ``None`` disables the check.
48
+ """
49
+
50
+ __slots__ = (
51
+ "_closed",
52
+ "_queue",
53
+ "_writer",
54
+ "capacity",
55
+ "created_at",
56
+ "encoding",
57
+ "id",
58
+ "identity",
59
+ "idle_timeout",
60
+ "last_sent_at",
61
+ "overflow",
62
+ "socket",
63
+ )
64
+
65
+ def __init__(
66
+ self,
67
+ socket: typing.Any,
68
+ *,
69
+ encoding: Encoding = Encoding.JSON,
70
+ identity: typing.Any = None,
71
+ capacity: int = DEFAULT_CAPACITY,
72
+ overflow: Overflow = Overflow.DROP_OLDEST,
73
+ idle_timeout: float | None = None,
74
+ ) -> None:
75
+ if capacity < 1:
76
+ raise ValueError("capacity must be at least 1")
77
+
78
+ self.socket = socket
79
+ self.encoding = encoding
80
+ self.identity = identity
81
+ self.capacity = capacity
82
+ self.overflow = overflow
83
+ self.idle_timeout = idle_timeout
84
+
85
+ self.id = uuid.uuid4()
86
+ self.created_at = time.monotonic()
87
+ self.last_sent_at = self.created_at
88
+
89
+ self._queue: asyncio.Queue[Envelope] = asyncio.Queue(maxsize=capacity)
90
+ self._writer: asyncio.Task[None] | None = None
91
+ self._closed = False
92
+
93
+ # ── lifecycle ────────────────────────────────────────────────────────
94
+
95
+ @property
96
+ def closed(self) -> bool:
97
+ """Whether this peer has been closed, by either side."""
98
+ return self._closed
99
+
100
+ @property
101
+ def pending(self) -> int:
102
+ """Messages queued and not yet written."""
103
+ return self._queue.qsize()
104
+
105
+ def start(self) -> None:
106
+ """Begin draining the queue.
107
+
108
+ Idempotent, so a hub that joins the same peer to several rooms does not
109
+ start several writers for it.
110
+ """
111
+ if self._writer is None and not self._closed:
112
+ self._writer = asyncio.create_task(self._drain())
113
+
114
+ async def close(self) -> None:
115
+ """Stop the writer and close the socket.
116
+
117
+ Safe to call twice, and safe to call on a socket that is already gone —
118
+ a disconnect racing a cleanup is the normal case, not an error.
119
+ """
120
+ if self._closed:
121
+ return
122
+ self._closed = True
123
+
124
+ if self._writer is not None:
125
+ self._writer.cancel()
126
+ with contextlib.suppress(BaseException):
127
+ await self._writer
128
+ self._writer = None
129
+
130
+ # A socket that is already gone is the normal case here, not an error:
131
+ # close() is what runs when the client hung up.
132
+ with contextlib.suppress(Exception):
133
+ await self.socket.close()
134
+
135
+ # ── sending ──────────────────────────────────────────────────────────
136
+
137
+ def offer(self, envelope: Envelope) -> bool:
138
+ """Queue *envelope* without blocking. Returns whether it was accepted.
139
+
140
+ This is what a fan-out calls. It never awaits and never raises, so one
141
+ peer cannot affect the delivery of any other.
142
+ """
143
+ if self._closed:
144
+ return False
145
+
146
+ try:
147
+ self._queue.put_nowait(envelope)
148
+ return True
149
+ except asyncio.QueueFull:
150
+ return self._resolve_overflow(envelope)
151
+
152
+ def _resolve_overflow(self, envelope: Envelope) -> bool:
153
+ """Apply :attr:`overflow` to a message that did not fit."""
154
+ if self.overflow is Overflow.DROP_NEWEST:
155
+ return False
156
+
157
+ if self.overflow is Overflow.CLOSE:
158
+ # Closing is asynchronous, and this path must not await. Marking
159
+ # it closed stops further offers immediately; the writer task sees
160
+ # the flag and shuts the socket down.
161
+ self._closed = True
162
+ return False
163
+
164
+ # DROP_OLDEST: make room by discarding the head, then retry once. The
165
+ # retry cannot fail — nothing else consumes from this queue between the
166
+ # two calls, because neither of them awaits.
167
+ with contextlib.suppress(asyncio.QueueEmpty):
168
+ self._queue.get_nowait()
169
+ self._queue.put_nowait(envelope)
170
+ return True
171
+
172
+ async def send(self, payload: typing.Any) -> None:
173
+ """Write *payload* to the socket now, bypassing the queue.
174
+
175
+ For replies to that one peer, where the caller wants the failure. A
176
+ broadcast uses :meth:`offer` instead.
177
+
178
+ Raises:
179
+ PeerGone: If the peer is closed, or the socket raises.
180
+ """
181
+ if self._closed:
182
+ raise PeerGone(f"peer {self.id} is closed")
183
+ try:
184
+ await self._write(payload)
185
+ except Exception as exc:
186
+ raise PeerGone(f"peer {self.id} went away") from exc
187
+
188
+ async def _write(self, payload: typing.Any) -> None:
189
+ """Put one payload on the wire in this peer's encoding."""
190
+ if self.encoding is Encoding.JSON:
191
+ await self.socket.send_json(payload)
192
+ elif self.encoding is Encoding.TEXT:
193
+ await self.socket.send_text(payload)
194
+ else:
195
+ await self.socket.send_bytes(payload)
196
+ self.last_sent_at = time.monotonic()
197
+
198
+ async def _drain(self) -> None:
199
+ """Write queued envelopes until cancelled or the socket fails."""
200
+ while True:
201
+ envelope = await self._queue.get()
202
+ if self._closed:
203
+ break
204
+ try:
205
+ await self._write(envelope.payload)
206
+ except Exception:
207
+ # The socket is gone. Stop the writer rather than spinning on a
208
+ # dead connection; the hub notices through `closed` and evicts.
209
+ self._closed = True
210
+ break
211
+
212
+ # ── health ───────────────────────────────────────────────────────────
213
+
214
+ def is_idle(self, *, now: float | None = None) -> bool:
215
+ """Whether nothing has been written for longer than :attr:`idle_timeout`.
216
+
217
+ Distinct from a lifetime TTL on purpose: a connection that is being
218
+ used should not be evicted for having existed a long time, and one that
219
+ has gone quiet should be, however recently it connected.
220
+ """
221
+ if self.idle_timeout is None:
222
+ return False
223
+ moment = time.monotonic() if now is None else now
224
+ return (moment - self.last_sent_at) > self.idle_timeout
225
+
226
+ def __repr__(self) -> str:
227
+ state = "closed" if self._closed else f"pending={self.pending}"
228
+ return (
229
+ f"<Peer {str(self.id)[:8]} {self.encoding.value} "
230
+ f"identity={self.identity!r} {state}>"
231
+ )
sillo_wire/policy.py ADDED
@@ -0,0 +1,42 @@
1
+ """What to do when a peer cannot keep up.
2
+
3
+ A socket that is not being read drains into the kernel's send buffer, and once
4
+ that fills, a write blocks. In a fan-out that turns one slow client into a
5
+ stall for everybody behind it, so every peer gets a bounded queue and a policy
6
+ for what happens when the queue is full.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import enum
12
+
13
+ __all__ = ["Overflow"]
14
+
15
+
16
+ class Overflow(enum.Enum):
17
+ """What a peer does with a message it has no room for.
18
+
19
+ There is no good universal answer, only a choice about which property
20
+ matters more for the traffic in question.
21
+ """
22
+
23
+ DROP_OLDEST = "drop_oldest"
24
+ """Discard the queued message at the front and enqueue the new one.
25
+
26
+ Right for state that supersedes itself — a price tick, a cursor position, a
27
+ progress percentage. The client sees a gap but always sees *current*.
28
+ """
29
+
30
+ DROP_NEWEST = "drop_newest"
31
+ """Discard the message being sent and keep the queue as it is.
32
+
33
+ Right when order matters more than recency and the client will reconcile
34
+ from the backlog later.
35
+ """
36
+
37
+ CLOSE = "close"
38
+ """Disconnect the peer.
39
+
40
+ Right when a client that cannot keep up is a client that is broken, and
41
+ letting it reconnect is cheaper than reasoning about what it missed.
42
+ """
sillo_wire/py.typed ADDED
File without changes
sillo_wire/testing.py ADDED
@@ -0,0 +1,86 @@
1
+ """Helpers for testing code that uses :mod:`sillo_wire`.
2
+
3
+ Realtime code is awkward to test because the interesting behaviour is what a
4
+ *socket* received, and a socket is the one thing a unit test does not have.
5
+ :class:`FakeSocket` is that missing piece: it satisfies everything
6
+ :class:`~sillo_wire.peer.Peer` calls and records what it was given.
7
+
8
+ Nothing here is imported by the package itself, so it costs an application
9
+ nothing at run time.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import typing
16
+
17
+ from sillo_wire.peer import Peer
18
+
19
+ __all__ = ["FakeSocket", "drain"]
20
+
21
+
22
+ class FakeSocket:
23
+ """A stand-in for a WebSocket connection that records what it was sent.
24
+
25
+ Args:
26
+ delay: Seconds to sleep on each write. Non-zero simulates a client that
27
+ is slow to read, which is the case worth testing and the hardest to
28
+ reproduce with a real socket.
29
+ fail: Raise on every write, simulating a connection that has gone away.
30
+ """
31
+
32
+ __slots__ = ("closed", "delay", "fail", "sent")
33
+
34
+ def __init__(self, *, delay: float = 0.0, fail: bool = False) -> None:
35
+ self.sent: list[typing.Any] = []
36
+ self.delay = delay
37
+ self.fail = fail
38
+ self.closed = False
39
+
40
+ async def _record(self, payload: typing.Any) -> None:
41
+ if self.delay:
42
+ await asyncio.sleep(self.delay)
43
+ if self.fail:
44
+ raise ConnectionResetError("fake socket is closed")
45
+ self.sent.append(payload)
46
+
47
+ async def send_json(self, payload: typing.Any) -> None:
48
+ """Record a JSON payload."""
49
+ await self._record(payload)
50
+
51
+ async def send_text(self, payload: str) -> None:
52
+ """Record a text payload."""
53
+ await self._record(payload)
54
+
55
+ async def send_bytes(self, payload: bytes) -> None:
56
+ """Record a bytes payload."""
57
+ await self._record(payload)
58
+
59
+ async def close(self, code: int = 1000) -> None:
60
+ """Mark the socket closed."""
61
+ self.closed = True
62
+
63
+ async def accept(self) -> None:
64
+ """Accept the connection."""
65
+
66
+
67
+ async def drain(*peers: Peer, timeout: float = 1.0) -> None:
68
+ """Wait until every peer's queue is empty.
69
+
70
+ A broadcast only enqueues, so a test that asserts on ``socket.sent``
71
+ straight afterwards is racing the writer task. This is the wait that makes
72
+ such an assertion deterministic.
73
+
74
+ Raises:
75
+ TimeoutError: If the queues have not emptied within *timeout*, which
76
+ means a writer is stuck rather than slow.
77
+ """
78
+
79
+ async def _wait() -> None:
80
+ while any(peer.pending for peer in peers):
81
+ await asyncio.sleep(0)
82
+ # One more yield so the write that emptied the queue completes before
83
+ # the caller inspects what the socket received.
84
+ await asyncio.sleep(0)
85
+
86
+ await asyncio.wait_for(_wait(), timeout=timeout)