|
| 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