jararaca 0.3.17__tar.gz → 0.3.18__tar.gz

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.

Potentially problematic release.


This version of jararaca might be problematic. Click here for more details.

Files changed (91) hide show
  1. {jararaca-0.3.17 → jararaca-0.3.18}/PKG-INFO +1 -1
  2. {jararaca-0.3.17 → jararaca-0.3.18}/pyproject.toml +1 -1
  3. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/presentation/websocket/redis.py +164 -49
  4. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/presentation/websocket/websocket_interceptor.py +35 -12
  5. {jararaca-0.3.17 → jararaca-0.3.18}/LICENSE +0 -0
  6. {jararaca-0.3.17 → jararaca-0.3.18}/README.md +0 -0
  7. {jararaca-0.3.17 → jararaca-0.3.18}/docs/CNAME +0 -0
  8. {jararaca-0.3.17 → jararaca-0.3.18}/docs/architecture.md +0 -0
  9. {jararaca-0.3.17 → jararaca-0.3.18}/docs/assets/_f04774c9-7e05-4da4-8b17-8be23f6a1475.jpeg +0 -0
  10. {jararaca-0.3.17 → jararaca-0.3.18}/docs/assets/_f04774c9-7e05-4da4-8b17-8be23f6a1475.webp +0 -0
  11. {jararaca-0.3.17 → jararaca-0.3.18}/docs/assets/tracing_example.png +0 -0
  12. {jararaca-0.3.17 → jararaca-0.3.18}/docs/expose-type.md +0 -0
  13. {jararaca-0.3.17 → jararaca-0.3.18}/docs/http-rpc.md +0 -0
  14. {jararaca-0.3.17 → jararaca-0.3.18}/docs/index.md +0 -0
  15. {jararaca-0.3.17 → jararaca-0.3.18}/docs/interceptors.md +0 -0
  16. {jararaca-0.3.17 → jararaca-0.3.18}/docs/messagebus.md +0 -0
  17. {jararaca-0.3.17 → jararaca-0.3.18}/docs/retry.md +0 -0
  18. {jararaca-0.3.17 → jararaca-0.3.18}/docs/scheduler.md +0 -0
  19. {jararaca-0.3.17 → jararaca-0.3.18}/docs/stylesheets/custom.css +0 -0
  20. {jararaca-0.3.17 → jararaca-0.3.18}/docs/websocket.md +0 -0
  21. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/__init__.py +0 -0
  22. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/__main__.py +0 -0
  23. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/broker_backend/__init__.py +0 -0
  24. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/broker_backend/mapper.py +0 -0
  25. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/broker_backend/redis_broker_backend.py +0 -0
  26. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/cli.py +0 -0
  27. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/common/__init__.py +0 -0
  28. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/core/__init__.py +0 -0
  29. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/core/providers.py +0 -0
  30. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/core/uow.py +0 -0
  31. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/di.py +0 -0
  32. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/files/entity.py.mako +0 -0
  33. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/lifecycle.py +0 -0
  34. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/messagebus/__init__.py +0 -0
  35. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/messagebus/bus_message_controller.py +0 -0
  36. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/messagebus/consumers/__init__.py +0 -0
  37. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/messagebus/decorators.py +0 -0
  38. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/messagebus/interceptors/__init__.py +0 -0
  39. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +0 -0
  40. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/messagebus/interceptors/publisher_interceptor.py +0 -0
  41. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/messagebus/message.py +0 -0
  42. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/messagebus/publisher.py +0 -0
  43. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/messagebus/worker.py +0 -0
  44. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/microservice.py +0 -0
  45. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/observability/decorators.py +0 -0
  46. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/observability/interceptor.py +0 -0
  47. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/observability/providers/__init__.py +0 -0
  48. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/observability/providers/otel.py +0 -0
  49. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/persistence/base.py +0 -0
  50. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/persistence/exports.py +0 -0
  51. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/persistence/interceptors/__init__.py +0 -0
  52. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/persistence/interceptors/aiosqa_interceptor.py +0 -0
  53. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/persistence/interceptors/constants.py +0 -0
  54. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/persistence/interceptors/decorators.py +0 -0
  55. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/persistence/session.py +0 -0
  56. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/persistence/sort_filter.py +0 -0
  57. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/persistence/utilities.py +0 -0
  58. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/presentation/__init__.py +0 -0
  59. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/presentation/decorators.py +0 -0
  60. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/presentation/hooks.py +0 -0
  61. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/presentation/http_microservice.py +0 -0
  62. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/presentation/server.py +0 -0
  63. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/presentation/websocket/__init__.py +0 -0
  64. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/presentation/websocket/base_types.py +0 -0
  65. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/presentation/websocket/context.py +0 -0
  66. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/presentation/websocket/decorators.py +0 -0
  67. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/presentation/websocket/types.py +0 -0
  68. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/py.typed +0 -0
  69. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/reflect/__init__.py +0 -0
  70. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/reflect/controller_inspect.py +0 -0
  71. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/reflect/metadata.py +0 -0
  72. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/rpc/__init__.py +0 -0
  73. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/rpc/http/__init__.py +0 -0
  74. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/rpc/http/backends/__init__.py +0 -0
  75. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/rpc/http/backends/httpx.py +0 -0
  76. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/rpc/http/backends/otel.py +0 -0
  77. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/rpc/http/decorators.py +0 -0
  78. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/rpc/http/httpx.py +0 -0
  79. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/scheduler/__init__.py +0 -0
  80. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/scheduler/beat_worker.py +0 -0
  81. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/scheduler/decorators.py +0 -0
  82. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/scheduler/types.py +0 -0
  83. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/tools/app_config/__init__.py +0 -0
  84. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/tools/app_config/decorators.py +0 -0
  85. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/tools/app_config/interceptor.py +0 -0
  86. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/tools/typescript/__init__.py +0 -0
  87. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/tools/typescript/decorators.py +0 -0
  88. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/tools/typescript/interface_parser.py +0 -0
  89. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/utils/__init__.py +0 -0
  90. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/utils/rabbitmq_utils.py +0 -0
  91. {jararaca-0.3.17 → jararaca-0.3.18}/src/jararaca/utils/retry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: jararaca
3
- Version: 0.3.17
3
+ Version: 0.3.18
4
4
  Summary: A simple and fast API framework for Python
5
5
  Home-page: https://github.com/LuscasLeo/jararaca
6
6
  Author: Lucas S
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "jararaca"
3
- version = "0.3.17"
3
+ version = "0.3.18"
4
4
  description = "A simple and fast API framework for Python"
5
5
  authors = ["Lucas S <me@luscasleo.dev>"]
6
6
  readme = "README.md"
@@ -59,6 +59,7 @@ class RedisWebSocketConnectionBackend(WebSocketConnectionBackend):
59
59
  consume_broadcast_timeout: int = 1,
60
60
  consume_send_timeout: int = 1,
61
61
  retry_delay: float = 5.0,
62
+ max_concurrent_tasks: int = 1000,
62
63
  ) -> None:
63
64
 
64
65
  self.redis = conn
@@ -67,6 +68,8 @@ class RedisWebSocketConnectionBackend(WebSocketConnectionBackend):
67
68
 
68
69
  self.lock = asyncio.Lock()
69
70
  self.tasks: set[asyncio.Task[Any]] = set()
71
+ self.max_concurrent_tasks = max_concurrent_tasks
72
+ self.task_semaphore = asyncio.Semaphore(max_concurrent_tasks)
70
73
 
71
74
  self.consume_broadcast_timeout = consume_broadcast_timeout
72
75
  self.consume_send_timeout = consume_send_timeout
@@ -101,16 +104,26 @@ class RedisWebSocketConnectionBackend(WebSocketConnectionBackend):
101
104
  return self.__broadcast_func
102
105
 
103
106
  async def broadcast(self, message: bytes) -> None:
104
- await self.redis.publish(
105
- self.broadcast_pubsub_channel,
106
- BroadcastMessage.from_message(message).encode(),
107
- )
107
+ try:
108
+ await self.redis.publish(
109
+ self.broadcast_pubsub_channel,
110
+ BroadcastMessage.from_message(message).encode(),
111
+ )
112
+ except Exception as e:
113
+ logger.error(
114
+ f"Failed to publish broadcast message to Redis: {e}", exc_info=True
115
+ )
116
+ raise
108
117
 
109
118
  async def send(self, rooms: list[str], message: bytes) -> None:
110
- await self.redis.publish(
111
- self.send_pubsub_channel,
112
- SendToRoomsMessage.from_message(rooms, message).encode(),
113
- )
119
+ try:
120
+ await self.redis.publish(
121
+ self.send_pubsub_channel,
122
+ SendToRoomsMessage.from_message(rooms, message).encode(),
123
+ )
124
+ except Exception as e:
125
+ logger.error(f"Failed to publish send message to Redis: {e}", exc_info=True)
126
+ raise
114
127
 
115
128
  def configure(
116
129
  self, broadcast: BroadcastFunc, send: SendFunc, shutdown_event: asyncio.Event
@@ -129,7 +142,12 @@ class RedisWebSocketConnectionBackend(WebSocketConnectionBackend):
129
142
  self.consume_send(self.send_func, self.shutdown_event)
130
143
  )
131
144
 
132
- self.tasks.add(send_task)
145
+ # Use lock when modifying tasks set to prevent race conditions
146
+ async def add_task() -> None:
147
+ async with self.lock:
148
+ self.tasks.add(send_task)
149
+
150
+ asyncio.get_event_loop().create_task(add_task())
133
151
  send_task.add_done_callback(self.handle_send_task_done)
134
152
 
135
153
  def setup_broadcast_consumer(self) -> None:
@@ -138,11 +156,23 @@ class RedisWebSocketConnectionBackend(WebSocketConnectionBackend):
138
156
  self.consume_broadcast(self.broadcast_func, self.shutdown_event)
139
157
  )
140
158
 
141
- self.tasks.add(broadcast_task)
159
+ # Use lock when modifying tasks set to prevent race conditions
160
+ async def add_task() -> None:
161
+ async with self.lock:
162
+ self.tasks.add(broadcast_task)
163
+
164
+ asyncio.get_event_loop().create_task(add_task())
142
165
 
143
166
  broadcast_task.add_done_callback(self.handle_broadcast_task_done)
144
167
 
145
168
  def handle_broadcast_task_done(self, task: asyncio.Task[Any]) -> None:
169
+ # Remove task from set safely with lock
170
+ async def remove_task() -> None:
171
+ async with self.lock:
172
+ self.tasks.discard(task)
173
+
174
+ asyncio.get_event_loop().create_task(remove_task())
175
+
146
176
  if task.cancelled():
147
177
  logger.warning("Broadcast task was cancelled.")
148
178
  elif task.exception() is not None:
@@ -162,6 +192,13 @@ class RedisWebSocketConnectionBackend(WebSocketConnectionBackend):
162
192
  )
163
193
 
164
194
  def handle_send_task_done(self, task: asyncio.Task[Any]) -> None:
195
+ # Remove task from set safely with lock
196
+ async def remove_task() -> None:
197
+ async with self.lock:
198
+ self.tasks.discard(task)
199
+
200
+ asyncio.get_event_loop().create_task(remove_task())
201
+
165
202
  if task.cancelled():
166
203
  logger.warning("Send task was cancelled.")
167
204
  elif task.exception() is not None:
@@ -204,54 +241,132 @@ class RedisWebSocketConnectionBackend(WebSocketConnectionBackend):
204
241
  self, broadcast: BroadcastFunc, shutdown_event: asyncio.Event
205
242
  ) -> None:
206
243
  logger.info("Starting broadcast consumer...")
207
- async with self.redis.pubsub() as pubsub:
208
- await pubsub.subscribe(self.broadcast_pubsub_channel)
209
-
210
- while not shutdown_event.is_set():
211
- message: dict[str, Any] | None = await pubsub.get_message(
212
- ignore_subscribe_messages=True,
213
- timeout=self.consume_broadcast_timeout,
244
+ try:
245
+ # Validate Redis connection before starting
246
+ try:
247
+ await self.redis.ping()
248
+ logger.info("Redis connection validated for broadcast consumer")
249
+ except Exception as e:
250
+ logger.error(f"Redis connection validation failed: {e}", exc_info=True)
251
+ raise
252
+
253
+ async with self.redis.pubsub() as pubsub:
254
+ await pubsub.subscribe(self.broadcast_pubsub_channel)
255
+ logger.info(
256
+ f"Subscribed to broadcast channel: {self.broadcast_pubsub_channel}"
214
257
  )
215
258
 
216
- if message is None:
217
- continue
218
-
219
- broadcast_message = BroadcastMessage.decode(message["data"])
220
-
221
- async with self.lock:
222
- task = asyncio.get_event_loop().create_task(
223
- broadcast(message=broadcast_message.message)
259
+ while not shutdown_event.is_set():
260
+ message: dict[str, Any] | None = await pubsub.get_message(
261
+ ignore_subscribe_messages=True,
262
+ timeout=self.consume_broadcast_timeout,
224
263
  )
225
264
 
226
- self.tasks.add(task)
227
-
228
- task.add_done_callback(self.tasks.discard)
265
+ if message is None:
266
+ continue
267
+
268
+ broadcast_message = BroadcastMessage.decode(message["data"])
269
+
270
+ # Use semaphore for backpressure control
271
+ acquired = False
272
+ try:
273
+ await self.task_semaphore.acquire()
274
+ acquired = True
275
+
276
+ async def broadcast_with_cleanup(msg: bytes) -> None:
277
+ try:
278
+ await broadcast(message=msg)
279
+ finally:
280
+ self.task_semaphore.release()
281
+
282
+ async with self.lock:
283
+ task = asyncio.get_event_loop().create_task(
284
+ broadcast_with_cleanup(broadcast_message.message)
285
+ )
286
+
287
+ self.tasks.add(task)
288
+
289
+ task.add_done_callback(self.tasks.discard)
290
+ except Exception as e:
291
+ # Release semaphore if we acquired it but failed to create task
292
+ if acquired:
293
+ self.task_semaphore.release()
294
+ logger.error(
295
+ f"Error processing broadcast message: {e}", exc_info=True
296
+ )
297
+ # Continue processing other messages
298
+ continue
299
+ except Exception as e:
300
+ logger.error(
301
+ f"Fatal error in broadcast consumer, will retry: {e}", exc_info=True
302
+ )
303
+ raise
229
304
 
230
305
  async def consume_send(self, send: SendFunc, shutdown_event: asyncio.Event) -> None:
231
306
  logger.info("Starting send consumer...")
232
- async with self.redis.pubsub() as pubsub:
233
- await pubsub.subscribe(self.send_pubsub_channel)
234
-
235
- while not shutdown_event.is_set():
236
-
237
- message: dict[str, Any] | None = await pubsub.get_message(
238
- ignore_subscribe_messages=True, timeout=self.consume_send_timeout
239
- )
240
-
241
- if message is None:
242
- continue
243
-
244
- send_message = SendToRoomsMessage.decode(message["data"])
245
-
246
- async with self.lock:
247
-
248
- task = asyncio.get_event_loop().create_task(
249
- send(send_message.rooms, send_message.message)
307
+ try:
308
+ # Validate Redis connection before starting
309
+ try:
310
+ await self.redis.ping()
311
+ logger.info("Redis connection validated for send consumer")
312
+ except Exception as e:
313
+ logger.error(f"Redis connection validation failed: {e}", exc_info=True)
314
+ raise
315
+
316
+ async with self.redis.pubsub() as pubsub:
317
+ await pubsub.subscribe(self.send_pubsub_channel)
318
+ logger.info(f"Subscribed to send channel: {self.send_pubsub_channel}")
319
+
320
+ while not shutdown_event.is_set():
321
+ message: dict[str, Any] | None = await pubsub.get_message(
322
+ ignore_subscribe_messages=True,
323
+ timeout=self.consume_send_timeout,
250
324
  )
251
325
 
252
- self.tasks.add(task)
253
-
254
- task.add_done_callback(self.tasks.discard)
326
+ if message is None:
327
+ continue
328
+
329
+ send_message = SendToRoomsMessage.decode(message["data"])
330
+
331
+ # Use semaphore for backpressure control
332
+ acquired = False
333
+ try:
334
+ await self.task_semaphore.acquire()
335
+ acquired = True
336
+
337
+ async def send_with_cleanup(
338
+ rooms: list[str], msg: bytes
339
+ ) -> None:
340
+ try:
341
+ await send(rooms, msg)
342
+ finally:
343
+ self.task_semaphore.release()
344
+
345
+ async with self.lock:
346
+
347
+ task = asyncio.get_event_loop().create_task(
348
+ send_with_cleanup(
349
+ send_message.rooms, send_message.message
350
+ )
351
+ )
352
+
353
+ self.tasks.add(task)
354
+
355
+ task.add_done_callback(self.tasks.discard)
356
+ except Exception as e:
357
+ # Release semaphore if we acquired it but failed to create task
358
+ if acquired:
359
+ self.task_semaphore.release()
360
+ logger.error(
361
+ f"Error processing send message: {e}", exc_info=True
362
+ )
363
+ # Continue processing other messages
364
+ continue
365
+ except Exception as e:
366
+ logger.error(
367
+ f"Fatal error in send consumer, will retry: {e}", exc_info=True
368
+ )
369
+ raise
255
370
 
256
371
  async def shutdown(self) -> None:
257
372
  async with self.lock:
@@ -85,13 +85,24 @@ class WebSocketConnectionManagerImpl(WebSocketConnectionManager):
85
85
  await self.backend.broadcast(message)
86
86
 
87
87
  async def _broadcast_from_backend(self, message: bytes) -> None:
88
- for websocket in self.all_websockets:
88
+ # Create a copy of the websockets set to avoid modification during iteration
89
+ async with self.lock:
90
+ websockets_to_send = list(self.all_websockets)
91
+
92
+ disconnected_websockets: list[WebSocket] = []
93
+
94
+ for websocket in websockets_to_send:
89
95
  try:
90
96
  if websocket.client_state == WebSocketState.CONNECTED:
91
97
  await websocket.send_bytes(message)
92
98
  except WebSocketDisconnect:
93
- async with self.lock: # TODO: check if this can cause concurrency slowdown issues
94
- self.all_websockets.remove(websocket)
99
+ disconnected_websockets.append(websocket)
100
+
101
+ # Clean up disconnected websockets in a single lock acquisition
102
+ if disconnected_websockets:
103
+ async with self.lock:
104
+ for websocket in disconnected_websockets:
105
+ self.all_websockets.discard(websocket)
95
106
 
96
107
  async def send(self, rooms: list[str], message: WebSocketMessageBase) -> None:
97
108
 
@@ -103,16 +114,28 @@ class WebSocketConnectionManagerImpl(WebSocketConnectionManager):
103
114
  )
104
115
 
105
116
  async def _send_from_backend(self, rooms: list[str], message: bytes) -> None:
117
+ # Create a copy of room memberships to avoid modification during iteration
118
+ async with self.lock:
119
+ room_websockets: dict[str, list[WebSocket]] = {
120
+ room: list(self.rooms.get(room, set())) for room in rooms
121
+ }
122
+
123
+ disconnected_by_room: dict[str, list[WebSocket]] = {room: [] for room in rooms}
124
+
125
+ for room, websockets in room_websockets.items():
126
+ for websocket in websockets:
127
+ try:
128
+ if websocket.client_state == WebSocketState.CONNECTED:
129
+ await websocket.send_bytes(message)
130
+ except WebSocketDisconnect:
131
+ disconnected_by_room[room].append(websocket)
132
+
133
+ # Clean up disconnected websockets in a single lock acquisition
106
134
  async with self.lock:
107
- for room in rooms:
108
- for websocket in self.rooms.get(room, set()):
109
- try:
110
- if websocket.client_state == WebSocketState.CONNECTED:
111
- await websocket.send_bytes(message)
112
- except WebSocketDisconnect:
113
- async with self.lock:
114
- if websocket in self.rooms[room]:
115
- self.rooms[room].remove(websocket)
135
+ for room, disconnected_websockets in disconnected_by_room.items():
136
+ if room in self.rooms:
137
+ for websocket in disconnected_websockets:
138
+ self.rooms[room].discard(websocket)
116
139
 
117
140
  async def join(self, rooms: list[str], websocket: WebSocket) -> None:
118
141
  for room in rooms:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes