Skip to content

Commit ec074d2

Browse files
committed
Fix a race condition when receiving fragmented messages.
When get() was cancelled e.g. due to timing out in the same turn of the event loop where put() received the next frame, it would crash. Also fix a similar issue in the trio implementation. The sync implementation already handled the race condition. Fix #1773.
1 parent 0437fbc commit ec074d2

5 files changed

Lines changed: 65 additions & 18 deletions

File tree

‎docs/project/changelog.rst‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ Improvements
3939

4040
.. _uvloop: https://uvloop.readthedocs.io/
4141

42+
Bug fixes
43+
.........
44+
45+
* Fixed a race condition between receiving the next frame of a fragmented
46+
message and canceling :meth:`~asyncio.connection.Connection.recv`.
47+
4248
.. _17.2:
4349

4450
17.2

‎src/websockets/asyncio/messages.py‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import asyncio
44
import codecs
55
import collections
6-
from collections.abc import AsyncIterator, Iterable
6+
from collections.abc import AsyncIterator, Sequence
77
from typing import Any, Callable, Generic, Literal, TypeVar, overload
88

99
from ..exceptions import ConcurrencyError
@@ -54,11 +54,10 @@ async def get(self, block: bool = True) -> T:
5454
self.get_waiter = None
5555
return self.queue.popleft()
5656

57-
def reset(self, items: Iterable[T]) -> None:
58-
"""Put back items into an empty, idle queue."""
59-
assert self.get_waiter is None, "cannot reset() while get() is running"
60-
assert not self.queue, "cannot reset() while queue isn't empty"
61-
self.queue.extend(items)
57+
def put_back(self, items: Sequence[T]) -> None:
58+
"""Put back items at the front of the queue."""
59+
assert self.get_waiter is None, "cannot put_back() while get() is running"
60+
self.queue.extendleft(reversed(items))
6261

6362
def abort(self) -> None:
6463
"""Close the queue, raising EOFError in get() if necessary."""
@@ -167,9 +166,11 @@ async def get(self, decode: bool | None = None) -> Data:
167166
try:
168167
frame = await self.frames.get(not self.closed)
169168
except asyncio.CancelledError:
170-
# Put frames already received back into the queue
171-
# so that future calls to get() can return them.
172-
self.frames.reset(frames)
169+
# Put frames already received back into the queue so that
170+
# future calls to get() can return them. The queue may be
171+
# non-empty if put() added frames in the same iteration of
172+
# the event loop where get() was canceled.
173+
self.frames.put_back(frames)
173174
raise
174175
self.maybe_resume()
175176
assert frame.opcode is CONT

‎src/websockets/trio/messages.py‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,18 @@ async def get(self, decode: bool | None = None) -> Data:
123123
try:
124124
frame = await self.recv_frames.receive()
125125
except trio.Cancelled:
126-
# Put frames already received back into the queue
127-
# so that future calls to get() can return them.
128-
# Bypass the statistics() method for performance.
126+
# Put frames already received back into the queue so that
127+
# future calls to get() can return them. The queue may be
128+
# non-empty if put() added at least two frames before get()
129+
# was canceled: the first one goes to the waiting receive()
130+
# and the second one is buffered.
129131
state = self.send_frames._state
130132
assert not state.receive_tasks, "no task should receive"
131-
assert not state.data, "queue should be empty"
132-
for frame in frames:
133-
self.send_frames.send_nowait(frame)
133+
# Since the queue is unbounded, we don't need to assert that
134+
# state.send_tasks is empty. It's always empty.
135+
# Now, since no tasks are sending or receiving, it should be
136+
# safe to mutate the underlying deque directly.
137+
state.data.extendleft(reversed(frames))
134138
raise
135139
except trio.EndOfChannel:
136140
raise EOFError("stream of frames ended")

‎tests/asyncio/test_messages.py‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,19 @@ async def test_get_then_put(self):
3838
item = await getter_task
3939
self.assertEqual(item, 42)
4040

41-
async def test_reset(self):
42-
"""reset sets the content of the queue."""
43-
self.queue.reset([42])
41+
async def test_put_back(self):
42+
"""put_back sets the content of an empty queue."""
43+
self.queue.put_back([42])
4444
item = await self.queue.get()
4545
self.assertEqual(item, 42)
4646

47+
async def test_put_back_non_empty_queue(self):
48+
"""put_back restores items at the start of a non-empty queue."""
49+
self.queue.put(3)
50+
self.queue.put_back([1, 2])
51+
items = [await self.queue.get() for _ in range(3)]
52+
self.assertEqual(items, [1, 2, 3])
53+
4754
async def test_abort(self):
4855
"""abort throws an exception in get."""
4956
getter_task = asyncio.create_task(self.queue.get())
@@ -212,6 +219,21 @@ async def test_cancel_get_after_first_frame(self):
212219
message = await self.assembler.get()
213220
self.assertEqual(message, "café")
214221

222+
async def test_cancel_get_after_first_frame_with_buffered_frames(self):
223+
"""get can be canceled safely when more frames were put meanwhile."""
224+
self.assembler.put(Frame(TEXT, b"ca", fin=False))
225+
226+
getter_task = asyncio.create_task(self.assembler.get())
227+
await asyncio.sleep(0) # let the event loop start getter_task
228+
self.assembler.put(Frame(CONT, b"f\xc3", fin=False))
229+
self.assembler.put(Frame(CONT, b"\xa9"))
230+
getter_task.cancel()
231+
with self.assertRaises(asyncio.CancelledError):
232+
await getter_task
233+
234+
message = await self.assembler.get()
235+
self.assertEqual(message, "café")
236+
215237
# Test get_iter.
216238

217239
async def test_get_iter_text_message_already_received(self):

‎tests/trio/test_messages.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,20 @@ async def test_cancel_get_after_first_frame(self):
205205
message = await self.assembler.get()
206206
self.assertEqual(message, "café")
207207

208+
async def test_cancel_get_after_first_frame_with_buffered_frames(self):
209+
"""get can be canceled safely when more frames were put meanwhile."""
210+
self.assembler.put(Frame(TEXT, b"ca", fin=False))
211+
212+
async with trio.open_nursery() as nursery:
213+
nursery.start_soon(self.assembler.get)
214+
await trio.testing.wait_all_tasks_blocked()
215+
self.assembler.put(Frame(CONT, b"f\xc3", fin=False))
216+
self.assembler.put(Frame(CONT, b"\xa9"))
217+
nursery.cancel_scope.cancel()
218+
219+
message = await self.assembler.get()
220+
self.assertEqual(message, "café")
221+
208222
# Test get_iter.
209223

210224
async def test_get_iter_text_message_already_received(self):

0 commit comments

Comments
 (0)