Skip to content

Commit ba01de9

Browse files
committed
Prevent deadlock when mocking large async HTTP responses.
1 parent c35a05c commit ba01de9

3 files changed

Lines changed: 142 additions & 15 deletions

File tree

‎mocket/io.py‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@
33
from __future__ import annotations
44

55
import io
6-
import os
7-
8-
from mocket.mocket import Mocket
96

107

118
class MocketSocketIO(io.BytesIO):
@@ -21,17 +18,12 @@ def __init__(self, address: tuple) -> None:
2118
super().__init__()
2219

2320
def write(self, content: bytes) -> int:
24-
"""Write content to the buffer and the pipe if available.
21+
"""Write content to the in-memory buffer.
2522
2623
Args:
2724
content: Bytes to write
2825
2926
Returns:
3027
Number of bytes written
3128
"""
32-
super().write(content)
33-
34-
_, w_fd = Mocket.get_pair(self._address)
35-
if w_fd:
36-
os.write(w_fd, content)
37-
return len(content)
29+
return super().write(content)

‎mocket/mocket.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@
2222
class Mocket:
2323
"""Singleton class managing all mock socket operations and entries."""
2424

25+
_socket_ios: ClassVar[dict[Address, Any]] = {}
2526
_socket_pairs: ClassVar[dict[Address, tuple[int, int]]] = {}
27+
_pipe_uses_data: ClassVar[dict[Address, bool]] = {}
28+
_pending_readables: ClassVar[dict[Address, int]] = {}
2629
_address: ClassVar[Address | tuple[None, None]] = (None, None)
2730
_entries: ClassVar[dict[Address, list[MocketEntry]]] = collections.defaultdict(list)
2831
_requests: ClassVar[list] = []
@@ -90,6 +93,39 @@ def set_pair(cls, address: Address, pair: tuple[int, int]) -> None:
9093
"""
9194
cls._socket_pairs[address] = pair
9295

96+
@classmethod
97+
def get_io(cls, address: Address) -> Any:
98+
"""Get the shared socket I/O buffer for an address, if any."""
99+
return cls._socket_ios.get(address)
100+
101+
@classmethod
102+
def set_io(cls, address: Address, socket_io: Any) -> None:
103+
"""Store the shared socket I/O buffer for an address."""
104+
cls._socket_ios[address] = socket_io
105+
106+
@classmethod
107+
def pipe_uses_data(cls, address: Address) -> bool:
108+
"""Return whether the pipe for an address carries mirrored response bytes."""
109+
return cls._pipe_uses_data.get(address, False)
110+
111+
@classmethod
112+
def set_pipe_uses_data(cls, address: Address, uses_data: bool) -> None:
113+
"""Store whether the pipe for an address carries mirrored response bytes."""
114+
cls._pipe_uses_data[address] = uses_data
115+
116+
@classmethod
117+
def get_pending_readables(cls, address: Address) -> int:
118+
"""Get the number of readiness bytes currently queued for an address."""
119+
return cls._pending_readables.get(address, 0)
120+
121+
@classmethod
122+
def set_pending_readables(cls, address: Address, count: int) -> None:
123+
"""Store the number of readiness bytes queued for an address."""
124+
if count > 0:
125+
cls._pending_readables[address] = count
126+
else:
127+
cls._pending_readables.pop(address, None)
128+
93129
@classmethod
94130
def register(cls, *entries: MocketEntry) -> None:
95131
"""Register mock entries with Mocket.
@@ -132,10 +168,15 @@ def collect(cls, data: Any) -> None:
132168
@classmethod
133169
def reset(cls) -> None:
134170
"""Reset all Mocket state and clean up file descriptors."""
171+
for socket_io in cls._socket_ios.values():
172+
socket_io.close()
135173
for r_fd, w_fd in cls._socket_pairs.values():
136174
os.close(r_fd)
137175
os.close(w_fd)
176+
cls._socket_ios = {}
138177
cls._socket_pairs = {}
178+
cls._pipe_uses_data = {}
179+
cls._pending_readables = {}
139180
cls._entries = collections.defaultdict(list)
140181
cls._requests = []
141182
cls._record_storage = None

‎mocket/socket.py‎

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,14 @@ def proto(self) -> int:
200200
@property
201201
def io(self) -> MocketSocketIO:
202202
"""Get or create the socket I/O object."""
203-
if self._io is None:
204-
self._io = MocketSocketIO((self._host, self._port))
203+
if self._io is None or getattr(self._io, "closed", False):
204+
address = self._address_key()
205+
self._io = Mocket.get_io(address)
206+
if self._io is not None and getattr(self._io, "closed", False):
207+
self._io = None
208+
if self._io is None:
209+
self._io = MocketSocketIO(address)
210+
Mocket.set_io(address, self._io)
205211
return self._io
206212

207213
def fileno(self) -> int:
@@ -214,9 +220,76 @@ def fileno(self) -> int:
214220
r_fd, _ = Mocket.get_pair(address)
215221
if not r_fd:
216222
r_fd, w_fd = os.pipe()
223+
os.set_blocking(r_fd, False)
224+
os.set_blocking(w_fd, False)
217225
Mocket.set_pair(address, (r_fd, w_fd))
226+
if self._io is not None and self._buffered_bytes():
227+
if Mocket.pipe_uses_data(address):
228+
self._mirror_buffer_to_pipe()
229+
else:
230+
self._sync_readable_pipe()
218231
return r_fd
219232

233+
def _address_key(self) -> Address:
234+
"""Return the current socket address tuple."""
235+
return self._host, self._port
236+
237+
def _buffered_bytes(self) -> int:
238+
"""Return the number of unread bytes buffered in the socket I/O."""
239+
return len(self.io.getbuffer()) - self.io.tell()
240+
241+
def _clear_readable_pipe(self) -> None:
242+
"""Drain any stale readiness bytes from the pipe for this socket."""
243+
address = self._address_key()
244+
r_fd, _ = Mocket.get_pair(address)
245+
if not r_fd:
246+
return
247+
248+
while True:
249+
try:
250+
if not os.read(r_fd, self._buflen):
251+
break
252+
except BlockingIOError:
253+
break
254+
255+
Mocket.set_pending_readables(address, 0)
256+
257+
def _mirror_buffer_to_pipe(self) -> None:
258+
"""Mirror unread response bytes into the pipe for small payloads."""
259+
address = self._address_key()
260+
_, w_fd = Mocket.get_pair(address)
261+
if not w_fd:
262+
return
263+
264+
unread = self.io.getbuffer()[self.io.tell() :]
265+
if unread:
266+
os.write(w_fd, unread)
267+
268+
def _sync_readable_pipe(self) -> None:
269+
"""Keep the readiness pipe in sync with the unread buffer size.
270+
271+
The pipe is used only to wake selector-based async clients. Response bytes
272+
remain in the in-memory buffer so large payloads do not block on OS pipe
273+
capacity.
274+
"""
275+
address = self._address_key()
276+
_, w_fd = Mocket.get_pair(address)
277+
if not w_fd:
278+
return
279+
280+
pending = Mocket.get_pending_readables(address)
281+
desired = min(self._buffered_bytes(), self._buflen)
282+
if desired <= pending:
283+
return
284+
285+
try:
286+
written = os.write(w_fd, b"\0" * (desired - pending))
287+
except BlockingIOError:
288+
written = 0
289+
290+
if written:
291+
Mocket.set_pending_readables(address, pending + written)
292+
220293
def gettimeout(self) -> float | None:
221294
"""Get the socket timeout.
222295
@@ -375,10 +448,17 @@ def sendall(
375448
response = self.true_sendall(data, *args, **kwargs)
376449

377450
if response is not None:
451+
address = self._address_key()
378452
self.io.seek(0)
453+
self._clear_readable_pipe()
379454
self.io.write(response)
380455
self.io.truncate()
381456
self.io.seek(0)
457+
Mocket.set_pipe_uses_data(address, len(response) <= self._buflen)
458+
if Mocket.pipe_uses_data(address):
459+
self._mirror_buffer_to_pipe()
460+
else:
461+
self._sync_readable_pipe()
382462

383463
def sendmsg(
384464
self,
@@ -537,11 +617,25 @@ def recv(self, buffersize: int, flags: int | None = None) -> bytes:
537617
Raises:
538618
BlockingIOError: If socket is non-blocking and no data available
539619
"""
540-
r_fd, _ = Mocket.get_pair((self._host, self._port))
541-
if r_fd:
542-
return os.read(r_fd, buffersize)
620+
address = self._address_key()
621+
r_fd, _ = Mocket.get_pair(address)
622+
if r_fd and Mocket.pipe_uses_data(address):
623+
try:
624+
return os.read(r_fd, buffersize)
625+
except BlockingIOError:
626+
pass
627+
628+
pending = Mocket.get_pending_readables(address)
629+
if r_fd and self._buffered_bytes() and pending == 0:
630+
self._sync_readable_pipe()
631+
pending = Mocket.get_pending_readables(address)
632+
543633
data = self.io.read(buffersize)
544634
if data:
635+
if r_fd and pending:
636+
drained = os.read(r_fd, min(len(data), pending))
637+
Mocket.set_pending_readables(address, pending - len(drained))
638+
self._sync_readable_pipe()
545639
return data
546640
# used by Redis mock
547641
exc = BlockingIOError()

0 commit comments

Comments
 (0)