Skip to content

Commit 2b6705d

Browse files
committed
test: fix TcpProxy close/race in test_client_routes.py NLB test helper
TcpProxy.stop()/drop_connections() used to close a connection's sockets directly, from the manager thread, while that connection's own forwarder thread could still be blocked in select()/recv() on those exact file descriptors -- a classic close-under-concurrent-user race. Since fds are process-global, closing them could let the OS silently recycle the fd number into a brand new connection before the stale forwarder thread's blocked call unwound, causing it to read/write/close a socket that no longer belonged to it (observed here as unhandled "ValueError: file descriptor cannot be a negative integer (-1)" crashes in _forward_loop once a socket was closed out from under it). stop() also only ever joined the accept-loop thread, never the per-connection forwarder threads it had just closed sockets out from under. Fix, scoped entirely to this test helper (no driver code touched): - TcpProxy._connections now maps (client_sock, target_sock) -> the forwarder thread serving that pair. - stop()/drop_connections() now shut down (SHUT_RDWR) both sockets -- safe to do concurrently with a blocked select()/recv(), unlike close() -- and then join() every forwarder thread before returning. Only the forwarder thread itself ever closes its own sockets now, and only after it has fully stopped using them. - _handle_new_connection starts the forwarder thread before publishing it into _connections, so a concurrent stop()/drop_connections() can never observe (and try to join) a thread that hasn't started yet. - NLBEmulator.add_node()/remove_node() now serialize against each other via an RLock, so a new proxy/connection can never be created while another thread's remove_node() is still tearing one down. Fixes #948. Validation: - Standalone stress harness (no CCM needed) driving concurrent clients through TcpProxy while repeatedly calling drop_connections()/stop()+restart from another thread: pre-fix, 40 iterations produced 162 unhandled ValueError crashes; post-fix, 40 iterations (same parameters) and a follow-up 150-iteration run produced zero corruptions/exceptions/leaked threads. - Full end-to-end runs of TestFullNodeReplacementThroughNlb::test_should_survive_full_node_replacement_through_nlb against a real CCM cluster, both before and after the fix, to check for behavioral regressions and reproduce the reported flakiness.
1 parent 9b5b037 commit 2b6705d

1 file changed

Lines changed: 75 additions & 17 deletions

File tree

tests/integration/standard/test_client_routes.py

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,9 @@ def __init__(self, listen_host, listen_port, target_host, target_port):
7373
self._running = False
7474
self._thread = None
7575
self._lock = threading.Lock()
76-
self._connections = set()
76+
# Maps (client_sock, target_sock) -> the forwarder thread serving
77+
# that pair, so stop()/drop_connections() can join it (see below).
78+
self._connections = {}
7779
self.total_connections = 0
7880

7981
def start(self):
@@ -98,10 +100,7 @@ def stop(self):
98100
self._server_sock.close()
99101
except Exception:
100102
pass
101-
with self._lock:
102-
for csock, tsock in list(self._connections):
103-
self._close_pair(csock, tsock)
104-
self._connections.clear()
103+
self._shutdown_and_join_connections()
105104
if self._thread:
106105
self._thread.join(timeout=5)
107106
log.info("TcpProxy stopped %s:%d", self.listen_host, self.listen_port)
@@ -120,12 +119,43 @@ def retarget(self, new_host, new_port):
120119

121120
def drop_connections(self):
122121
"""Forcibly close all active connections."""
123-
with self._lock:
124-
for csock, tsock in list(self._connections):
125-
self._close_pair(csock, tsock)
126-
self._connections.clear()
122+
self._shutdown_and_join_connections()
127123
log.info("TcpProxy %s:%d dropped all connections", self.listen_host, self.listen_port)
128124

125+
def _shutdown_and_join_connections(self):
126+
"""
127+
Interrupt every active connection's forwarder thread and wait for
128+
it to exit before returning.
129+
130+
This must NOT call socket.close() itself: closing a file
131+
descriptor that another thread is (or might still be) blocked on
132+
in select()/recv() is a classic close-under-concurrent-user race
133+
-- the fd number can be silently reassigned by the OS to a brand
134+
new connection before the stale forwarder thread's blocked call
135+
unwinds, causing it to read/write/close a completely unrelated
136+
socket. socket.shutdown(SHUT_RDWR) is safe to call concurrently
137+
with a blocking select()/recv() on the same socket (it makes the
138+
blocked call return promptly, without invalidating the fd), so we
139+
use that to signal the thread and then join() it -- only the
140+
forwarder thread itself (in _forward_loop's `finally`) ever calls
141+
close() on its own sockets, and only after it has fully stopped
142+
using them.
143+
"""
144+
with self._lock:
145+
connections = list(self._connections.items())
146+
for (csock, tsock), _thread in connections:
147+
self._shutdown_pair(csock, tsock)
148+
for (csock, tsock), thread in connections:
149+
thread.join(timeout=5)
150+
if thread.is_alive():
151+
log.warning(
152+
"TcpProxy %s:%d: forwarder thread %s did not exit "
153+
"within timeout; leaked fds are possible",
154+
self.listen_host, self.listen_port, thread.name)
155+
with self._lock:
156+
for key, _thread in connections:
157+
self._connections.pop(key, None)
158+
129159
def _run(self):
130160
while self._running:
131161
try:
@@ -153,14 +183,17 @@ def _handle_new_connection(self, client_sock, target_host=None, target_port=None
153183
client_sock.close()
154184
return
155185

156-
with self._lock:
157-
self._connections.add((client_sock, target_sock))
158-
self.total_connections += 1
159-
160186
t = threading.Thread(target=self._forward_loop,
161187
args=(client_sock, target_sock),
162188
daemon=True)
189+
# Start the thread *before* publishing it to self._connections:
190+
# otherwise stop()/drop_connections() could snapshot this entry
191+
# and call .join() on a thread that hasn't been started yet,
192+
# which raises RuntimeError.
163193
t.start()
194+
with self._lock:
195+
self._connections[(client_sock, target_sock)] = t
196+
self.total_connections += 1
164197

165198
def _forward_loop(self, client_sock, target_sock):
166199
try:
@@ -178,7 +211,7 @@ def _forward_loop(self, client_sock, target_sock):
178211
pass
179212
finally:
180213
with self._lock:
181-
self._connections.discard((client_sock, target_sock))
214+
self._connections.pop((client_sock, target_sock), None)
182215
self._close_pair(client_sock, target_sock)
183216

184217
@staticmethod
@@ -189,6 +222,20 @@ def _close_pair(csock, tsock):
189222
except Exception:
190223
pass
191224

225+
@staticmethod
226+
def _shutdown_pair(csock, tsock):
227+
"""
228+
Best-effort shutdown of both sockets in a pair, to interrupt a
229+
thread that may be blocked in select()/recv() on them. Safe to
230+
call from a different thread than the one using the sockets
231+
(unlike close()); does not release the underlying fds.
232+
"""
233+
for s in (csock, tsock):
234+
try:
235+
s.shutdown(socket.SHUT_RDWR)
236+
except OSError:
237+
pass
238+
192239

193240
class NLBEmulator:
194241
"""
@@ -227,7 +274,10 @@ def __init__(self, discovery_port=0,
227274
self._node_proxies = {}
228275
self._discovery_proxy = None
229276
self._rr_index = 0
230-
self._lock = threading.Lock()
277+
# RLock: add_node()/remove_node() serialize against each other (see
278+
# below) and each also takes the lock internally via
279+
# _add_node_proxy(), so a plain Lock would deadlock on reentry.
280+
self._lock = threading.RLock()
231281
self._running = False
232282

233283
def start(self, node_addresses):
@@ -288,13 +338,21 @@ def stop(self):
288338
log.info("NLB stopped")
289339

290340
def add_node(self, node_id, addr):
291-
self._add_node_proxy(node_id, addr)
341+
# Serialize against remove_node() so a new proxy/connection can
342+
# never be created while another thread's remove_node() is still
343+
# in the middle of tearing one down -- TcpProxy.stop() now blocks
344+
# until its forwarder threads have joined (see TcpProxy), so by
345+
# the time this lock is available, any just-freed fds are fully
346+
# reaped and cannot be reused out from under a stale thread.
347+
with self._lock:
348+
self._add_node_proxy(node_id, addr)
292349

293350
def remove_node(self, node_id):
294351
with self._lock:
295352
proxy = self._node_proxies.pop(node_id, None)
353+
if proxy:
354+
proxy.stop()
296355
if proxy:
297-
proxy.stop()
298356
log.info("NLB removed node %d", node_id)
299357

300358
def node_port(self, node_id):

0 commit comments

Comments
 (0)