Skip to content

Commit 1c41efc

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

1 file changed

Lines changed: 138 additions & 19 deletions

File tree

tests/integration/standard/test_client_routes.py

Lines changed: 138 additions & 19 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):
@@ -92,16 +94,12 @@ def start(self):
9294
self.target_host, self.target_port)
9395

9496
def stop(self):
95-
self._running = False
9697
if self._server_sock:
9798
try:
9899
self._server_sock.close()
99100
except Exception:
100101
pass
101-
with self._lock:
102-
for csock, tsock in list(self._connections):
103-
self._close_pair(csock, tsock)
104-
self._connections.clear()
102+
self._shutdown_and_join_connections(stopping=True)
105103
if self._thread:
106104
self._thread.join(timeout=5)
107105
log.info("TcpProxy stopped %s:%d", self.listen_host, self.listen_port)
@@ -120,12 +118,61 @@ def retarget(self, new_host, new_port):
120118

121119
def drop_connections(self):
122120
"""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()
121+
self._shutdown_and_join_connections()
127122
log.info("TcpProxy %s:%d dropped all connections", self.listen_host, self.listen_port)
128123

124+
def _shutdown_and_join_connections(self, stopping=False):
125+
"""
126+
Interrupt every active connection's forwarder thread and wait for
127+
it to exit before returning.
128+
129+
This must NOT call socket.close() itself: closing a file
130+
descriptor that another thread is (or might still be) blocked on
131+
in select()/recv() is a classic close-under-concurrent-user race
132+
-- the fd number can be silently reassigned by the OS to a brand
133+
new connection before the stale forwarder thread's blocked call
134+
unwinds, causing it to read/write/close a completely unrelated
135+
socket. socket.shutdown(SHUT_RDWR) is safe to call concurrently
136+
with a blocking select()/recv() on the same socket (it makes the
137+
blocked call return promptly, without invalidating the fd), so we
138+
use that to signal the thread and then join() it -- only the
139+
forwarder thread itself (in _forward_loop's `finally`) ever calls
140+
close() on its own sockets, and only after it has fully stopped
141+
using them.
142+
143+
:param stopping: if True (only ``stop()`` passes this), flips
144+
``_running`` to False in the *same* critical section that
145+
takes the ``_connections`` snapshot below. This is the other
146+
half of the atomic register-then-start protocol in
147+
``_handle_new_connection``: that method only ever publishes a
148+
new connection while holding ``_lock`` and after confirming
149+
``_running`` is still True, so once this method has taken
150+
_running False and snapshotted _connections under the same
151+
lock, no connection can be registered afterwards that this
152+
snapshot fails to observe -- there is no window where a new
153+
connection slips in after shutdown has effectively begun.
154+
``drop_connections()`` must NOT do this: it is meant to drop
155+
in-flight connections while leaving the proxy running (e.g.
156+
to exercise client reconnect logic), so it leaves _running
157+
untouched and new connections keep being accepted normally.
158+
"""
159+
with self._lock:
160+
if stopping:
161+
self._running = False
162+
connections = list(self._connections.items())
163+
for (csock, tsock), _thread in connections:
164+
self._shutdown_pair(csock, tsock)
165+
for (csock, tsock), thread in connections:
166+
thread.join(timeout=5)
167+
if thread.is_alive():
168+
log.warning(
169+
"TcpProxy %s:%d: forwarder thread %s did not exit "
170+
"within timeout; leaked fds are possible",
171+
self.listen_host, self.listen_port, thread.name)
172+
with self._lock:
173+
for key, _thread in connections:
174+
self._connections.pop(key, None)
175+
129176
def _run(self):
130177
while self._running:
131178
try:
@@ -153,14 +200,61 @@ def _handle_new_connection(self, client_sock, target_host=None, target_port=None
153200
client_sock.close()
154201
return
155202

156-
with self._lock:
157-
self._connections.add((client_sock, target_sock))
158-
self.total_connections += 1
159-
160203
t = threading.Thread(target=self._forward_loop,
161204
args=(client_sock, target_sock),
162205
daemon=True)
163-
t.start()
206+
# Register the thread into _connections and start() it as a
207+
# single atomic sequence, both under _lock, registration BEFORE
208+
# start(). Doing this any other way reopens a race:
209+
# - register-after-start (the previous approach) leaves a
210+
# window where the thread is running but not yet in
211+
# _connections; a very short-lived connection (e.g.
212+
# immediate EOF) can run _forward_loop's `finally` -- which
213+
# pops (a no-op, since the key isn't there yet) and closes
214+
# the sockets -- before this method inserts the entry,
215+
# leaving a stale/leaked dict entry that nothing ever cleans
216+
# up. Worse, a concurrent stop()/drop_connections() can
217+
# snapshot _connections during that same window, miss this
218+
# thread entirely, and (for stop()) return as if fully shut
219+
# down while the thread is still actively using its sockets.
220+
# - start-after-register-but-not-atomically (i.e. two separate
221+
# critical sections) reopens the same window between the two
222+
# lock acquisitions.
223+
# Keeping both operations inside one critical section closes
224+
# the window: _shutdown_and_join_connections() also takes its
225+
# _connections snapshot under _lock (see there), so it can only
226+
# ever observe this connection in a state where start() has
227+
# already been called -- eliminating both the leaked-entry case
228+
# and any join-before-start RuntimeError risk.
229+
#
230+
# We also re-check _running here, under the very same lock that
231+
# stop() uses to flip it False (see _shutdown_and_join_connections):
232+
# if shutdown has already begun, reject the new connection
233+
# outright instead of letting it slip in afterwards.
234+
with self._lock:
235+
if not self._running:
236+
target_sock.close()
237+
client_sock.close()
238+
return
239+
self._connections[(client_sock, target_sock)] = t
240+
self.total_connections += 1
241+
try:
242+
t.start()
243+
except Exception as e:
244+
# If start() itself fails (e.g. the OS refuses to create a
245+
# new thread), undo the registration immediately -- an
246+
# entry left behind here would point at a thread that was
247+
# never started, and a later stop()/drop_connections()
248+
# calling .join() on it would hit the exact
249+
# "cannot join thread before it is started" RuntimeError
250+
# this whole atomic register+start sequence exists to
251+
# prevent.
252+
self._connections.pop((client_sock, target_sock), None)
253+
self.total_connections -= 1
254+
log.warning("TcpProxy %s:%d failed to start forwarder thread: %s",
255+
self.listen_host, self.listen_port, e)
256+
client_sock.close()
257+
target_sock.close()
164258

165259
def _forward_loop(self, client_sock, target_sock):
166260
try:
@@ -178,7 +272,7 @@ def _forward_loop(self, client_sock, target_sock):
178272
pass
179273
finally:
180274
with self._lock:
181-
self._connections.discard((client_sock, target_sock))
275+
self._connections.pop((client_sock, target_sock), None)
182276
self._close_pair(client_sock, target_sock)
183277

184278
@staticmethod
@@ -189,6 +283,20 @@ def _close_pair(csock, tsock):
189283
except Exception:
190284
pass
191285

286+
@staticmethod
287+
def _shutdown_pair(csock, tsock):
288+
"""
289+
Best-effort shutdown of both sockets in a pair, to interrupt a
290+
thread that may be blocked in select()/recv() on them. Safe to
291+
call from a different thread than the one using the sockets
292+
(unlike close()); does not release the underlying fds.
293+
"""
294+
for s in (csock, tsock):
295+
try:
296+
s.shutdown(socket.SHUT_RDWR)
297+
except OSError:
298+
pass
299+
192300

193301
class NLBEmulator:
194302
"""
@@ -227,7 +335,10 @@ def __init__(self, discovery_port=0,
227335
self._node_proxies = {}
228336
self._discovery_proxy = None
229337
self._rr_index = 0
230-
self._lock = threading.Lock()
338+
# RLock: add_node()/remove_node() serialize against each other (see
339+
# below) and each also takes the lock internally via
340+
# _add_node_proxy(), so a plain Lock would deadlock on reentry.
341+
self._lock = threading.RLock()
231342
self._running = False
232343

233344
def start(self, node_addresses):
@@ -288,13 +399,21 @@ def stop(self):
288399
log.info("NLB stopped")
289400

290401
def add_node(self, node_id, addr):
291-
self._add_node_proxy(node_id, addr)
402+
# Serialize against remove_node() so a new proxy/connection can
403+
# never be created while another thread's remove_node() is still
404+
# in the middle of tearing one down -- TcpProxy.stop() now blocks
405+
# until its forwarder threads have joined (see TcpProxy), so by
406+
# the time this lock is available, any just-freed fds are fully
407+
# reaped and cannot be reused out from under a stale thread.
408+
with self._lock:
409+
self._add_node_proxy(node_id, addr)
292410

293411
def remove_node(self, node_id):
294412
with self._lock:
295413
proxy = self._node_proxies.pop(node_id, None)
414+
if proxy:
415+
proxy.stop()
296416
if proxy:
297-
proxy.stop()
298417
log.info("NLB removed node %d", node_id)
299418

300419
def node_port(self, node_id):

0 commit comments

Comments
 (0)