Skip to content

Commit 9c947eb

Browse files
committed
fix(socket-mode): stop EOF busy loops while preserving complete frames
1 parent dd61579 commit 9c947eb

2 files changed

Lines changed: 161 additions & 13 deletions

File tree

‎slack_sdk/socket_mode/builtin/internals.py‎

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ def receive(specific_buffer_size: Optional[int] = None):
205205
with sock_receive_lock:
206206
try:
207207
received_bytes = sock.recv(size)
208+
if not received_bytes:
209+
raise EOFError("Connection is closed")
208210
if all_message_trace_enabled:
209211
if len(received_bytes) > 0:
210212
logger.debug(f"Received bytes: {received_bytes!r}")
@@ -216,18 +218,26 @@ def receive(specific_buffer_size: Optional[int] = None):
216218
# Note that bad connections can be detected by monitoring threads
217219
# the Socket Mode client automatically reconnects to a new endpoint later.
218220
logger.debug("The connection seems to be already closed.")
219-
return bytes()
221+
raise EOFError("Connection is closed") from e
220222
raise e
221223

222-
return _fetch_messages(
223-
messages=[],
224-
receive=receive,
225-
remaining_bytes=None,
226-
current_mask_key=None,
227-
current_header=None,
228-
current_data=bytes(),
229-
logger=logger,
230-
)
224+
messages: List[Tuple[Optional[FrameHeader], bytes]] = []
225+
try:
226+
return _fetch_messages(
227+
messages=messages,
228+
receive=receive,
229+
remaining_bytes=None,
230+
current_mask_key=None,
231+
current_header=None,
232+
current_data=bytes(),
233+
logger=logger,
234+
)
235+
except EOFError as e:
236+
# Deliver complete frames before terminating on the next read. Never
237+
# deliver the incomplete frame that was being assembled at EOF.
238+
if messages:
239+
return messages
240+
raise ConnectionError("Connection is closed") from e
231241

232242

233243
def _fetch_messages(
@@ -251,7 +261,7 @@ def _fetch_messages(
251261

252262
if current_header is None:
253263
# new message
254-
if len(remaining_bytes) <= 2:
264+
while len(remaining_bytes) < 2:
255265
remaining_bytes += receive() # type: ignore[call-arg]
256266

257267
if remaining_bytes[0] == 10: # \n
@@ -276,12 +286,12 @@ def _fetch_messages(
276286
current_data_length: int = b2 & 0b01111111
277287
idx_after_length_part: int = 2
278288
if current_data_length == 126:
279-
if len(remaining_bytes) < 4:
289+
while len(remaining_bytes) < 4:
280290
remaining_bytes += receive(1024)
281291
current_data_length = struct.unpack("!H", bytes(remaining_bytes[2:4]))[0]
282292
idx_after_length_part = 4
283293
elif current_data_length == 127:
284-
if len(remaining_bytes) < 10:
294+
while len(remaining_bytes) < 10:
285295
remaining_bytes += receive(1024)
286296
current_data_length = struct.unpack("!Q", bytes(remaining_bytes[2:10]))[0]
287297
idx_after_length_part = 10
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import errno
2+
import logging
3+
import random
4+
import socket
5+
import struct
6+
import threading
7+
import unittest
8+
from unittest.mock import Mock
9+
10+
from slack_sdk.socket_mode.builtin.connection import Connection, ConnectionState
11+
from slack_sdk.socket_mode.builtin.internals import _receive_messages
12+
13+
14+
class TestBuiltinEOF(unittest.TestCase):
15+
def test_closed_socket_errors_terminate_without_losing_complete_messages(self):
16+
for code in (errno.EBADF, errno.ENOTSOCK):
17+
with self.subTest(code=code):
18+
with self.assertRaises(ConnectionError):
19+
self.receive([OSError(code, "closed")])
20+
self.assertEqual(
21+
self.receive([b"\x81\x02ok\x81\x05ab", OSError(code, "closed")]),
22+
[(1, b"ok")],
23+
)
24+
25+
def test_random_transport_chunk_boundaries(self):
26+
rng = random.Random(1246)
27+
payloads = [b"", b"hello", b"a" * 126, b"b" * 1024]
28+
frames = [b"\x81\x00", b"\x81\x05hello"]
29+
frames += [b"\x81\x7e" + struct.pack("!H", len(p)) + p for p in payloads[2:]]
30+
wire = b"".join(frames)
31+
for iteration in range(100):
32+
with self.subTest(iteration=iteration):
33+
chunks, offset = [], 0
34+
while offset < len(wire):
35+
size = rng.randint(1, 64)
36+
chunks.append(wire[offset : offset + size])
37+
offset += size
38+
sock = Mock()
39+
sock.recv.side_effect = chunks + [b""]
40+
received = []
41+
while True:
42+
try:
43+
received += _receive_messages(sock, threading.Lock(), logging.getLogger(__name__))
44+
except ConnectionError:
45+
break
46+
self.assertEqual([(h.opcode, data) for h, data in received], [(1, p) for p in payloads])
47+
48+
def test_real_socket_delivers_complete_message_before_eof(self):
49+
self.run_socket_case(b"\x81\x02ok\x81\x05ab", ["ok"], [], 1)
50+
51+
def test_real_socket_empty_close_is_not_reported_as_error(self):
52+
self.run_socket_case(b"\x81\x02ok\x88\x00", ["ok"], [(1005, "")], 0)
53+
54+
def run_socket_case(self, wire, expected_messages, expected_closes, error_count):
55+
local, peer = socket.socketpair()
56+
local.settimeout(0.1)
57+
messages, closes, errors = [], [], []
58+
connection = Connection(
59+
url="ws://localhost",
60+
logger=logging.getLogger(__name__),
61+
on_message_listener=messages.append,
62+
on_close_listener=lambda code, reason: closes.append((code, reason)),
63+
on_error_listener=errors.append,
64+
)
65+
connection.sock = local
66+
state = ConnectionState()
67+
peer.sendall(wire)
68+
peer.shutdown(socket.SHUT_WR)
69+
worker = threading.Thread(target=connection.run_until_completion, args=(state,), daemon=True)
70+
worker.start()
71+
try:
72+
worker.join(timeout=1)
73+
self.assertFalse(worker.is_alive())
74+
self.assertEqual(messages, expected_messages)
75+
self.assertEqual(closes, expected_closes)
76+
self.assertEqual(len(errors), error_count)
77+
self.assertTrue(state.terminated)
78+
self.assertFalse(connection.is_active())
79+
finally:
80+
state.terminated = True
81+
peer.close()
82+
worker.join(timeout=1)
83+
connection.close()
84+
85+
def test_eof_during_header_preserves_only_complete_messages(self):
86+
for partial in (b"\x81", b"\x81\x7e\x00", b"\x81\x7f\x00\x00"):
87+
with self.subTest(partial=partial):
88+
self.assertEqual(self.receive([b"\x81\x02ok" + partial, b""]), [(1, b"ok")])
89+
with self.assertRaises(ConnectionError):
90+
self.receive([partial, b""])
91+
92+
def test_extended_header_split_across_many_reads(self):
93+
self.assertEqual(self.receive([b"\x81\x7e", b"\x00", b"\x03", b"hey"]), [(1, b"hey")])
94+
95+
def receive(self, chunks):
96+
sock = Mock()
97+
sock.recv.side_effect = chunks
98+
result = _receive_messages(sock, threading.Lock(), logging.getLogger(__name__))
99+
for call in sock.recv.call_args_list:
100+
self.assertGreater(call.args[0], 0)
101+
return [(header.opcode, data) for header, data in result]
102+
103+
def test_empty_frames_do_not_read_past_the_frame(self):
104+
for opcode in (1, 8, 9, 10):
105+
with self.subTest(opcode=opcode):
106+
self.assertEqual(self.receive([bytes([0x80 | opcode, 0])]), [(opcode, b"")])
107+
108+
def test_complete_messages_survive_eof_during_next_payload(self):
109+
self.assertEqual(self.receive([b"\x81\x02ok\x81\x05ab", b""]), [(1, b"ok")])
110+
111+
def test_incomplete_message_is_not_delivered(self):
112+
with self.assertRaises(ConnectionError):
113+
self.receive([b"\x81\x05ab", b""])
114+
115+
def test_split_message(self):
116+
self.assertEqual(self.receive([b"\x81", b"\x05h", b"ello"]), [(1, b"hello")])
117+
118+
def test_timeout_is_not_eof(self):
119+
with self.assertRaises(socket.timeout):
120+
self.receive([socket.timeout()])
121+
122+
def test_transport_eof_terminates_receiver(self):
123+
local, peer = socket.socketpair()
124+
connection = Connection(url="ws://localhost", logger=logging.getLogger(__name__))
125+
connection.sock = local
126+
state = ConnectionState()
127+
peer.close()
128+
worker = threading.Thread(target=connection.run_until_completion, args=(state,), daemon=True)
129+
worker.start()
130+
try:
131+
worker.join(timeout=0.5)
132+
self.assertFalse(worker.is_alive(), "EOF must terminate the receiver instead of spinning")
133+
self.assertTrue(state.terminated)
134+
self.assertFalse(connection.is_active())
135+
finally:
136+
state.terminated = True
137+
worker.join(timeout=1)
138+
connection.close()

0 commit comments

Comments
 (0)