Skip to content

Commit c7d58ec

Browse files
dkropachevclaude
andcommitted
Fix AsyncioConnection race conditions causing EBADF errors (#614)
Fix four race conditions in AsyncioConnection that cause "[Errno 9] Bad file descriptor" errors during node restarts, especially with TLS: 1. close() now waits for _close() to complete when called from outside the event loop thread, eliminating the window where is_closed=True but the socket fd is still open. 2. handle_read() sets last_error on server EOF so factory() detects dead connections instead of returning them to callers. 3. push() rejects data when the connection is already closed, preventing writes from being enqueued to a shutting-down connection. 4. handle_write() treats BrokenPipeError/ConnectionResetError as clean peer disconnections instead of defuncting, and both I/O handlers skip defunct() if the connection is already shutting down. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b4e4b59 commit c7d58ec

2 files changed

Lines changed: 337 additions & 1 deletion

File tree

cassandra/io/asyncioreactor.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,20 @@ def close(self):
142142

143143
# close from the loop thread to avoid races when removing file
144144
# descriptors
145-
asyncio.run_coroutine_threadsafe(
145+
future = asyncio.run_coroutine_threadsafe(
146146
self._close(), loop=self._loop
147147
)
148148

149+
# When called from outside the event loop thread, wait for _close()
150+
# to complete so the socket is actually closed when close() returns.
151+
# This prevents a race window where is_closed=True but the socket fd
152+
# is still open, which causes EBADF in pending I/O operations.
153+
if threading.current_thread() != self._loop_thread:
154+
try:
155+
future.result(timeout=5)
156+
except Exception:
157+
pass
158+
149159
async def _close(self):
150160
log.debug("Closing connection (%s) to %s" % (id(self), self.endpoint))
151161
if self._write_watcher:
@@ -168,6 +178,10 @@ async def _close(self):
168178
self.connected_event.set()
169179

170180
def push(self, data):
181+
if self.is_closed or self.is_defunct:
182+
raise ConnectionShutdown(
183+
"Connection to %s is already closed" % (self.endpoint,))
184+
171185
buff_size = self.out_buffer_size
172186
if len(data) > buff_size:
173187
chunks = []
@@ -202,6 +216,15 @@ async def handle_write(self):
202216
if next_msg:
203217
await self._loop.sock_sendall(self._socket, next_msg)
204218
except socket.error as err:
219+
# Peer disconnected — do a clean close instead of defunct
220+
if isinstance(err, (BrokenPipeError, ConnectionResetError)):
221+
log.debug("Connection %s closed by peer during write: %s",
222+
self, err)
223+
self.close()
224+
return
225+
# Connection is already shutting down, just exit
226+
if self.is_closed or self.is_defunct:
227+
return
205228
log.debug("Exception in send for %s: %s", self, err)
206229
self.defunct(err)
207230
return
@@ -223,6 +246,9 @@ async def handle_read(self):
223246
await asyncio.sleep(0)
224247
continue
225248
except socket.error as err:
249+
# Connection is already shutting down, just exit
250+
if self.is_closed or self.is_defunct:
251+
return
226252
log.debug("Exception during socket recv for %s: %s",
227253
self, err)
228254
self.defunct(err)
@@ -234,5 +260,7 @@ async def handle_read(self):
234260
self.process_io_buffer()
235261
else:
236262
log.debug("Connection %s closed by server", self)
263+
self.last_error = ConnectionShutdown(
264+
"Connection to %s was closed by server" % self.endpoint)
237265
self.close()
238266
return
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
# Copyright DataStax, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Reproduction tests for issue #614:
17+
"[Errno 9] Bad file descriptor" race conditions in AsyncioConnection.
18+
19+
These tests use real socket pairs to demonstrate race conditions where:
20+
- close() schedules _close() asynchronously, creating a window where
21+
is_closed=True but the socket fd is still open
22+
- factory() can return a dead connection when the server closes during setup
23+
- push() accepts data after close() has been called
24+
- handle_write() gets EBADF/BrokenPipe when the server closes mid-write
25+
26+
Each test asserts the CORRECT expected behavior. Tests FAIL on the current
27+
codebase because the bugs exist, proving the race conditions are real.
28+
"""
29+
30+
import logging
31+
import socket
32+
import time
33+
import unittest
34+
35+
from cassandra.connection import DefaultEndPoint, ConnectionShutdown
36+
37+
AsyncioConnection, ASYNCIO_AVAILABLE = None, False
38+
try:
39+
from cassandra.io.asyncioreactor import AsyncioConnection
40+
ASYNCIO_AVAILABLE = True
41+
except (ImportError, SyntaxError, AttributeError):
42+
AsyncioConnection = None
43+
ASYNCIO_AVAILABLE = False
44+
45+
from tests import is_monkey_patched
46+
47+
log = logging.getLogger(__name__)
48+
49+
skip_me = is_monkey_patched() or not ASYNCIO_AVAILABLE
50+
51+
52+
class SocketPairAsyncioConnection(AsyncioConnection):
53+
"""
54+
A subclass of AsyncioConnection that uses a pre-provided socket from
55+
a socketpair instead of connecting to a real CQL server.
56+
57+
This bypasses _connect_socket() and _send_options_message() so we can
58+
test I/O race conditions with a real fd registered on the event loop.
59+
"""
60+
61+
_test_socket = None # Set before __init__
62+
63+
def _connect_socket(self):
64+
"""Use the pre-provided test socket instead of connecting."""
65+
self._socket = self._test_socket
66+
67+
def _send_options_message(self):
68+
"""Skip the CQL handshake -- we don't have a real server."""
69+
pass
70+
71+
72+
def _make_connection_with_socketpair():
73+
"""
74+
Create a SocketPairAsyncioConnection backed by a real socketpair.
75+
76+
Returns (connection, server_socket) where server_socket is the
77+
other end of the pair that can be used to send data to / close
78+
the connection.
79+
"""
80+
client_sock, server_sock = socket.socketpair()
81+
client_sock.setblocking(False)
82+
server_sock.setblocking(False)
83+
84+
SocketPairAsyncioConnection._test_socket = client_sock
85+
conn = SocketPairAsyncioConnection(
86+
DefaultEndPoint("127.0.0.1"),
87+
connect_timeout=5,
88+
)
89+
return conn, server_sock
90+
91+
92+
@unittest.skipIf(is_monkey_patched(), 'runtime is monkey patched for another reactor')
93+
@unittest.skipUnless(ASYNCIO_AVAILABLE, "asyncio is not available for this runtime")
94+
class TestAsyncioRaceConditions614(unittest.TestCase):
95+
"""
96+
Reproduction tests for race conditions in AsyncioConnection that
97+
cause "[Errno 9] Bad file descriptor" errors (issue #614).
98+
99+
These tests FAIL on the current codebase, proving the bugs exist.
100+
"""
101+
102+
@classmethod
103+
def setUpClass(cls):
104+
if skip_me:
105+
return
106+
AsyncioConnection.initialize_reactor()
107+
108+
def test_close_leaves_socket_open_window(self):
109+
"""
110+
BUG: close() returns with is_closed=True but the socket fd is still
111+
open, because _close() is scheduled asynchronously.
112+
113+
This is the root cause of all EBADF errors in issue #614. Any I/O
114+
operation (handle_read's sock_recv, handle_write's sock_sendall) that
115+
is in-flight when _close() eventually runs will get EBADF.
116+
117+
Expected correct behavior: after close() returns, the socket should
118+
no longer be usable for I/O (either closed or detached from the loop).
119+
"""
120+
conn, server_sock = _make_connection_with_socketpair()
121+
try:
122+
# Give handle_read time to start awaiting sock_recv
123+
time.sleep(0.1)
124+
125+
# close() sets is_closed=True and schedules _close() asynchronously
126+
conn.close()
127+
128+
# BUG: is_closed is True but the socket is still open.
129+
# _close() hasn't run yet because it was scheduled via
130+
# run_coroutine_threadsafe and we're checking immediately.
131+
self.assertTrue(conn.is_closed)
132+
133+
# The socket fd should be closed after close() returns.
134+
# On a correctly implemented close(), the socket would be
135+
# synchronously closed or at least detached.
136+
try:
137+
fd = conn._socket.fileno()
138+
socket_still_open = (fd != -1)
139+
except OSError:
140+
socket_still_open = False
141+
142+
self.assertFalse(
143+
socket_still_open,
144+
"BUG: close() returned with is_closed=True but socket fd={} "
145+
"is still open. This creates a window where pending I/O "
146+
"operations (sock_recv, sock_sendall) can get EBADF when "
147+
"_close() eventually closes the fd.".format(fd)
148+
)
149+
150+
finally:
151+
# Wait for _close() to finish before cleanup
152+
time.sleep(0.5)
153+
server_sock.close()
154+
155+
def test_factory_returns_closed_connection_on_server_eof(self):
156+
"""
157+
BUG: When the server closes the connection during handshake,
158+
factory() returns a closed (dead) connection instead of raising.
159+
160+
The sequence:
161+
1. Server closes its end -> handle_read gets empty buffer (EOF)
162+
2. handle_read calls close() -> _close() sets connected_event
163+
3. factory() wakes up: connected_event is set, last_error is None
164+
4. factory() returns the connection -- but it's already closed!
165+
166+
Expected correct behavior: factory() should detect that the connection
167+
is closed and raise an exception instead of returning a dead connection.
168+
"""
169+
conn, server_sock = _make_connection_with_socketpair()
170+
try:
171+
# Give handle_read time to start awaiting on sock_recv
172+
time.sleep(0.1)
173+
174+
# Server closes its end -- handle_read sees EOF (empty recv)
175+
server_sock.close()
176+
177+
# Wait for handle_read to process EOF and call close() -> _close()
178+
# _close() sets connected_event when not defunct
179+
conn.connected_event.wait(timeout=2.0)
180+
181+
# Simulate what factory() does after connected_event.wait():
182+
# if conn.last_error:
183+
# raise conn.last_error
184+
# elif not conn.connected_event.is_set():
185+
# raise OperationTimedOut(...)
186+
# else:
187+
# return conn <-- BUG: returns closed connection
188+
factory_would_raise = False
189+
if conn.last_error:
190+
factory_would_raise = True
191+
elif not conn.connected_event.is_set():
192+
factory_would_raise = True
193+
194+
# factory() should raise when the connection is dead
195+
self.assertTrue(
196+
factory_would_raise,
197+
"BUG: factory() would return a dead connection! "
198+
"connected_event.is_set()={}, last_error={}, is_closed={}, "
199+
"is_defunct={}. factory() has no check for is_closed before "
200+
"returning, so it returns a connection that is already "
201+
"closed.".format(
202+
conn.connected_event.is_set(), conn.last_error,
203+
conn.is_closed, conn.is_defunct
204+
)
205+
)
206+
207+
finally:
208+
if not server_sock._closed:
209+
server_sock.close()
210+
211+
def test_push_accepts_data_after_close(self):
212+
"""
213+
BUG: push() does not check is_closed, so data can be enqueued
214+
to the write queue after close() has been called.
215+
216+
This is part of the TOCTOU race in send_msg(): send_msg checks
217+
is_closed, then calls push(). But even without the TOCTOU in
218+
send_msg, push() itself never validates the connection state.
219+
220+
Expected correct behavior: push() should raise ConnectionShutdown
221+
(or similar) when called on a closed connection.
222+
"""
223+
conn, server_sock = _make_connection_with_socketpair()
224+
try:
225+
# Give handle_read time to start
226+
time.sleep(0.1)
227+
228+
# close() sets is_closed=True and schedules _close()
229+
conn.close()
230+
self.assertTrue(conn.is_closed)
231+
232+
# BUG: push() succeeds even though is_closed=True.
233+
# The data gets enqueued to _write_queue and may cause
234+
# EBADF when handle_write tries to send it after _close()
235+
# closes the socket.
236+
push_succeeded = False
237+
try:
238+
conn.push(b'\x00' * 100)
239+
push_succeeded = True
240+
except (ConnectionShutdown, OSError):
241+
push_succeeded = False
242+
243+
self.assertFalse(
244+
push_succeeded,
245+
"BUG: push() accepted data after close() was called "
246+
"(is_closed=True). This data will be enqueued to the "
247+
"write queue and handle_write() will attempt to send it, "
248+
"potentially getting EBADF when _close() closes the socket."
249+
)
250+
251+
finally:
252+
time.sleep(0.5)
253+
server_sock.close()
254+
255+
def test_handle_write_gets_broken_pipe_on_server_close(self):
256+
"""
257+
BUG: handle_write() gets BrokenPipeError when the server closes
258+
while data is queued for writing, causing the connection to become
259+
defunct instead of cleanly closing.
260+
261+
The sequence:
262+
1. Push data into the write queue
263+
2. Close server end -> handle_read sees EOF, calls close()
264+
3. handle_write tries sock_sendall -> BrokenPipeError
265+
4. handle_write calls defunct() with the error
266+
267+
Expected correct behavior: when the server closes, pending writes
268+
should be cancelled cleanly (not cause defunct with EBADF/BrokenPipe).
269+
The connection should end up closed but NOT defunct.
270+
"""
271+
conn, server_sock = _make_connection_with_socketpair()
272+
try:
273+
# Give handle_read time to start
274+
time.sleep(0.1)
275+
276+
# Push data so handle_write has work to do
277+
for _ in range(10):
278+
conn.push(b'\x00' * 65536)
279+
280+
# Close server end -- triggers EOF in handle_read and
281+
# BrokenPipe in handle_write
282+
server_sock.close()
283+
284+
# Wait for the chain reaction
285+
time.sleep(1.0)
286+
287+
self.assertTrue(conn.is_closed,
288+
"Connection should be closed after server EOF")
289+
290+
# BUG: The connection becomes defunct (with BrokenPipeError or
291+
# EBADF) instead of closing cleanly. A clean close should cancel
292+
# pending writes without defuncting.
293+
self.assertFalse(
294+
conn.is_defunct,
295+
"BUG: Connection became defunct instead of closing cleanly. "
296+
"handle_write() got an error after the server closed: {}. "
297+
"Pending writes should be cancelled by _close() before they "
298+
"can hit a broken socket.".format(conn.last_error)
299+
)
300+
301+
finally:
302+
if not server_sock._closed:
303+
server_sock.close()
304+
305+
306+
if __name__ == '__main__':
307+
logging.basicConfig(level=logging.DEBUG)
308+
unittest.main()

0 commit comments

Comments
 (0)