Skip to content

Commit b245513

Browse files
committed
Add broadcast to the threading implementation.
Fix #1466.
1 parent bd85dce commit b245513

7 files changed

Lines changed: 299 additions & 4 deletions

File tree

docs/project/changelog.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ New features
5656

5757
* Validated compatibility with Python 3.15.
5858

59+
* Added :func:`~sync.server.broadcast` to the :mod:`threading` implementation.
60+
5961
* Added the ``--insecure`` option to the ``websockets`` CLI to disable TLS
6062
certificate validation.
6163

docs/reference/features.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Both sides
3535
+------------------------------------+--------+--------+--------+--------+
3636
| Send a message |||||
3737
+------------------------------------+--------+--------+--------+--------+
38-
| Broadcast a message || |||
38+
| Broadcast a message || |||
3939
+------------------------------------+--------+--------+--------+--------+
4040
| Receive a message |||||
4141
+------------------------------------+--------+--------+--------+--------+

docs/reference/sync/server.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ Using a connection
8585

8686
.. autoproperty:: close_reason
8787

88+
Broadcast
89+
---------
90+
91+
.. autofunction:: broadcast
92+
8893
HTTP Basic Authentication
8994
-------------------------
9095

docs/topics/broadcast.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ WebSocket servers often send the same message to all connected clients or to a
1616
subset of clients for which the message is relevant.
1717

1818
Let's explore options for broadcasting a message, explain the design of
19-
:func:`~asyncio.server.broadcast`, and discuss alternatives.
19+
:func:`~asyncio.server.broadcast` in the :mod:`asyncio` implementation, and
20+
discuss alternatives.
2021

2122
For each option, we'll provide a connection handler called ``handler()`` and a
2223
function or coroutine called ``broadcast()`` that sends a message to all

src/websockets/sync/connection.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from __future__ import annotations
22

3+
import concurrent.futures
34
import contextlib
45
import logging
56
import random
67
import socket
78
import struct
89
import threading
910
import time
11+
import traceback
1012
import uuid
1113
from collections.abc import Iterable, Iterator, Mapping
1214
from types import TracebackType
@@ -1082,3 +1084,128 @@ def close_socket(self) -> None:
10821084

10831085
# Acknowledge pings sent with the ack_on_close option.
10841086
self.terminate_pending_pings()
1087+
1088+
1089+
# broadcast() is defined in the connection module even though it's primarily
1090+
# used by servers and documented in the server module because it works with
1091+
# client connections too and because it's easier to test together with the
1092+
# Connection class.
1093+
1094+
1095+
def broadcast(
1096+
connections: Iterable[Connection],
1097+
message: DataLike,
1098+
raise_exceptions: bool = False,
1099+
*,
1100+
text: bool | None = None,
1101+
**kwargs: Any,
1102+
) -> None:
1103+
"""
1104+
Broadcast a message to several WebSocket connections.
1105+
1106+
A string (:class:`str`) is sent as a Text_ frame. A bytestring or bytes-like
1107+
object (:class:`bytes`, :class:`bytearray`, or :class:`memoryview`) is sent
1108+
as a Binary_ frame.
1109+
1110+
.. _Text: https://datatracker.ietf.org/doc/html/rfc6455#section-5.6
1111+
.. _Binary: https://datatracker.ietf.org/doc/html/rfc6455#section-5.6
1112+
1113+
You may override this behavior with the ``text`` argument:
1114+
1115+
* Set ``text=True`` to send an UTF-8 bytestring or bytes-like object
1116+
(:class:`bytes`, :class:`bytearray`, or :class:`memoryview`) in a
1117+
Text_ frame. This improves performance when the message is already
1118+
UTF-8 encoded, for example if the message contains JSON and you're
1119+
using a JSON library that produces a bytestring.
1120+
* Set ``text=False`` to send a string (:class:`str`) in a Binary_
1121+
frame. This may be useful for servers that expect binary frames
1122+
instead of text frames.
1123+
1124+
:func:`broadcast` relies on :class:`concurrent.futures.ThreadPoolExecutor`
1125+
to send the messages. Make sure the thread pool is large enough relative to
1126+
the number of clients, so that slow or stuck connections don't clog it. If
1127+
that's an issue, then you should be using an asynchronous implementation.
1128+
You can configure the thread pool by passing additional keyword arguments to
1129+
:func:`broadcast`, such as ``max_workers``.
1130+
1131+
Unlike :meth:`~websockets.asyncio.connection.Connection.send`,
1132+
:func:`broadcast` doesn't support sending fragmented messages. Indeed,
1133+
fragmentation is useful for sending large messages without buffering them in
1134+
memory, while :func:`broadcast` buffers one copy per connection as fast as
1135+
possible.
1136+
1137+
:func:`broadcast` skips connections that aren't open in order to avoid
1138+
errors on connections where the closing handshake is in progress.
1139+
1140+
:func:`broadcast` ignores failures to write the message on some connections.
1141+
It continues writing to other connections. You may set ``raise_exceptions``
1142+
to :obj:`True` to record failures and raise all exceptions in a :pep:`654`
1143+
:exc:`ExceptionGroup`.
1144+
1145+
While :func:`broadcast` makes more sense for servers, it works identically
1146+
with clients, if you have a use case for opening connections to many servers
1147+
and broadcasting a message to them.
1148+
1149+
Args:
1150+
websockets: WebSocket connections to which the message will be sent.
1151+
message: Message to send.
1152+
raise_exceptions: Whether to raise an exception in case of failures.
1153+
text: Force sending in Text_ or Binary_ frames.
1154+
1155+
Raises:
1156+
TypeError: If ``message`` doesn't have a supported type.
1157+
1158+
"""
1159+
if isinstance(message, str):
1160+
send_method = "send_binary" if text is False else "send_text"
1161+
message = message.encode()
1162+
elif isinstance(message, BytesLike):
1163+
send_method = "send_text" if text is True else "send_binary"
1164+
else:
1165+
raise TypeError("data must be str or bytes")
1166+
1167+
if raise_exceptions:
1168+
exceptions: list[Exception] = []
1169+
1170+
def send_message(connection: Connection) -> None:
1171+
exception: Exception
1172+
1173+
with connection.protocol_mutex:
1174+
if connection.protocol.state is not OPEN:
1175+
return
1176+
1177+
if connection.send_in_progress:
1178+
if raise_exceptions:
1179+
exception = ConcurrencyError("sending a fragmented message")
1180+
exceptions.append(exception)
1181+
else:
1182+
connection.logger.warning(
1183+
"skipped broadcast: sending a fragmented message",
1184+
)
1185+
return
1186+
1187+
try:
1188+
# Call connection.protocol.send_text or send_binary.
1189+
# Either way, message is already converted to bytes.
1190+
getattr(connection.protocol, send_method)(message)
1191+
connection.send_data()
1192+
except Exception as write_exception:
1193+
if raise_exceptions:
1194+
exception = RuntimeError("failed to write message")
1195+
exception.__cause__ = write_exception
1196+
exceptions.append(exception)
1197+
else:
1198+
connection.logger.warning(
1199+
"skipped broadcast: failed to write message: %s",
1200+
traceback.format_exception_only(write_exception)[0].strip(),
1201+
)
1202+
1203+
with concurrent.futures.ThreadPoolExecutor(**kwargs) as executor:
1204+
executor.map(send_message, connections)
1205+
1206+
if raise_exceptions and exceptions:
1207+
raise ExceptionGroup("skipped broadcast", exceptions)
1208+
1209+
1210+
# Pretend that broadcast is actually defined in the server module.
1211+
broadcast.__module__ = "websockets.sync.server"

src/websockets/sync/server.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,18 @@
2727
from ..protocol import CONNECTING, OPEN, Event
2828
from ..server import ServerProtocol
2929
from ..typing import LoggerLike, Origin, StatusLike, Subprotocol
30-
from .connection import Connection
30+
from .connection import Connection, broadcast
3131
from .utils import Deadline
3232

3333

34-
__all__ = ["serve", "unix_serve", "ServerConnection", "Server", "basic_auth"]
34+
__all__ = [
35+
"broadcast",
36+
"serve",
37+
"unix_serve",
38+
"ServerConnection",
39+
"Server",
40+
"basic_auth",
41+
]
3542

3643

3744
class ServerConnection(Connection):

tests/sync/test_connection.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from websockets.frames import CloseCode, Frame, Opcode
1616
from websockets.protocol import CLIENT, SERVER, Protocol, State
1717
from websockets.sync.connection import *
18+
from websockets.sync.connection import broadcast
1819

1920
from ..protocol import RecordingProtocol
2021
from ..utils import MS
@@ -54,6 +55,11 @@ def assertFrameSent(self, frame):
5455
self.wait_for_remote_side()
5556
self.assertEqual(self.remote_connection.protocol.get_frames_rcvd(), [frame])
5657

58+
def assertFramesSent(self, frames):
59+
"""Check that several frames were sent."""
60+
self.wait_for_remote_side()
61+
self.assertEqual(self.remote_connection.protocol.get_frames_rcvd(), frames)
62+
5763
def assertNoFrameSent(self):
5864
"""Check that no frame was sent."""
5965
self.wait_for_remote_side()
@@ -992,6 +998,153 @@ def test_unexpected_failure_in_send_context(self, send_text):
992998
self.connection.send("😀")
993999
self.assertIsInstance(raised.exception.__cause__, AssertionError)
9941000

1001+
# Test broadcast.
1002+
1003+
def test_broadcast_text(self):
1004+
"""broadcast broadcasts a text message."""
1005+
broadcast([self.connection], "😀")
1006+
self.assertFrameSent(Frame(Opcode.TEXT, "😀".encode()))
1007+
1008+
def test_broadcast_text_reports_no_errors(self):
1009+
"""broadcast broadcasts a text message without raising exceptions."""
1010+
broadcast([self.connection], "😀", raise_exceptions=True)
1011+
self.assertFrameSent(Frame(Opcode.TEXT, "😀".encode()))
1012+
1013+
def test_broadcast_binary(self):
1014+
"""broadcast broadcasts a binary message."""
1015+
broadcast([self.connection], b"\x01\x02\xfe\xff")
1016+
self.assertFrameSent(Frame(Opcode.BINARY, b"\x01\x02\xfe\xff"))
1017+
1018+
def test_broadcast_binary_reports_no_errors(self):
1019+
"""broadcast broadcasts a binary message without raising exceptions."""
1020+
broadcast([self.connection], b"\x01\x02\xfe\xff", raise_exceptions=True)
1021+
self.assertFrameSent(Frame(Opcode.BINARY, b"\x01\x02\xfe\xff"))
1022+
1023+
def test_broadcast_text_from_bytes(self):
1024+
"""broadcast broadcasts a text message from bytes."""
1025+
broadcast([self.connection], "😀".encode(), text=True)
1026+
self.assertFrameSent(Frame(Opcode.TEXT, "😀".encode()))
1027+
1028+
def test_broadcast_binary_from_str(self):
1029+
"""broadcast broadcasts a binary message from a str."""
1030+
broadcast([self.connection], "😀", text=False)
1031+
self.assertFrameSent(Frame(Opcode.BINARY, "😀".encode()))
1032+
1033+
def test_broadcast_no_clients(self):
1034+
"""broadcast does nothing when called with an empty list of clients."""
1035+
broadcast([], "😀")
1036+
self.assertNoFrameSent()
1037+
1038+
def test_broadcast_two_clients(self):
1039+
"""broadcast broadcasts a message to several clients."""
1040+
broadcast([self.connection, self.connection], "😀")
1041+
self.assertFramesSent(
1042+
[
1043+
Frame(Opcode.TEXT, "😀".encode()),
1044+
Frame(Opcode.TEXT, "😀".encode()),
1045+
]
1046+
)
1047+
1048+
def test_broadcast_skips_closed_connection(self):
1049+
"""broadcast ignores closed connections."""
1050+
self.connection.close()
1051+
self.assertFrameSent(Frame(Opcode.CLOSE, b"\x03\xe8"))
1052+
1053+
with self.assertNoLogs("websockets", logging.WARNING):
1054+
broadcast([self.connection], "😀")
1055+
self.assertNoFrameSent()
1056+
1057+
def test_broadcast_skips_closing_connection(self):
1058+
"""broadcast ignores closing connections."""
1059+
1060+
def closer():
1061+
with self.delay_frames_rcvd(MS):
1062+
self.connection.close()
1063+
1064+
with self.run_in_thread(closer):
1065+
self.assertFrameSent(Frame(Opcode.CLOSE, b"\x03\xe8"))
1066+
1067+
with self.assertNoLogs("websockets", logging.WARNING):
1068+
broadcast([self.connection], "😀")
1069+
self.assertNoFrameSent()
1070+
1071+
def test_broadcast_skips_connection_with_send_blocked(self):
1072+
"""broadcast logs a warning when a connection is blocked in send."""
1073+
gate = threading.Event()
1074+
1075+
def fragments():
1076+
yield "⏳"
1077+
gate.wait()
1078+
1079+
with self.run_in_thread(self.connection.send, args=(fragments(),)):
1080+
self.assertFrameSent(Frame(Opcode.TEXT, "⏳".encode(), fin=False))
1081+
1082+
with self.assertLogs("websockets", logging.WARNING) as logs:
1083+
broadcast([self.connection], "😀")
1084+
1085+
self.assertEqual(
1086+
[record.getMessage() for record in logs.records],
1087+
["skipped broadcast: sending a fragmented message"],
1088+
)
1089+
1090+
gate.set()
1091+
1092+
def test_broadcast_reports_connection_with_send_blocked(self):
1093+
"""broadcast raises exceptions for connections blocked in send."""
1094+
gate = threading.Event()
1095+
1096+
def fragments():
1097+
yield "⏳"
1098+
gate.wait()
1099+
1100+
with self.run_in_thread(self.connection.send, args=(fragments(),)):
1101+
self.assertFrameSent(Frame(Opcode.TEXT, "⏳".encode(), fin=False))
1102+
1103+
with self.assertRaises(ExceptionGroup) as raised:
1104+
broadcast([self.connection], "😀", raise_exceptions=True)
1105+
1106+
self.assertEqual(
1107+
str(raised.exception), "skipped broadcast (1 sub-exception)"
1108+
)
1109+
exc = raised.exception.exceptions[0]
1110+
self.assertEqual(str(exc), "sending a fragmented message")
1111+
self.assertIsInstance(exc, ConcurrencyError)
1112+
1113+
gate.set()
1114+
1115+
@patch("socket.socket.sendall", side_effect=BrokenPipeError(32, "Broken pipe"))
1116+
def test_broadcast_skips_connection_failing_to_send(self, sendall):
1117+
"""broadcast logs a warning when a connection fails to send."""
1118+
with self.assertLogs("websockets", logging.WARNING) as logs:
1119+
broadcast([self.connection], "😀")
1120+
1121+
self.assertEqual(
1122+
[record.getMessage() for record in logs.records],
1123+
[
1124+
"skipped broadcast: failed to write message: "
1125+
"BrokenPipeError: [Errno 32] Broken pipe"
1126+
],
1127+
)
1128+
1129+
@patch("socket.socket.sendall", side_effect=BrokenPipeError(32, "Broken pipe"))
1130+
def test_broadcast_reports_connection_failing_to_send(self, sendall):
1131+
"""broadcast raises exceptions for connections failing to send."""
1132+
with self.assertRaises(ExceptionGroup) as raised:
1133+
broadcast([self.connection], "😀", raise_exceptions=True)
1134+
1135+
self.assertEqual(str(raised.exception), "skipped broadcast (1 sub-exception)")
1136+
exc = raised.exception.exceptions[0]
1137+
self.assertEqual(str(exc), "failed to write message")
1138+
self.assertIsInstance(exc, RuntimeError)
1139+
cause = exc.__cause__
1140+
self.assertEqual(str(cause), "[Errno 32] Broken pipe")
1141+
self.assertIsInstance(cause, BrokenPipeError)
1142+
1143+
def test_broadcast_type_error(self):
1144+
"""broadcast raises TypeError when called with an unsupported type."""
1145+
with self.assertRaises(TypeError):
1146+
broadcast([self.connection], ["⏳", "⌛️"])
1147+
9951148

9961149
class ServerConnectionTests(ClientConnectionTests):
9971150
LOCAL = SERVER

0 commit comments

Comments
 (0)