Skip to content

Commit c67a5ce

Browse files
mykaulclaude
andcommitted
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. - NLBEmulator._live_addresses() (read by rr_handler(), the discovery port's round-robin accept handler, from the discovery TcpProxy's own accept-loop thread) now also snapshots self._node_proxies under the same _lock that add_node()/remove_node() mutate it under. Previously it iterated the dict unlocked, so a concurrent add_node()/remove_node() could raise "RuntimeError: dictionary changed size during iteration" on that thread. 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. - Standalone stress harness driving concurrent readers directly exercising _live_addresses() against concurrent add_node()/ remove_node() churn: pre-fix, 313 "dictionary changed size during iteration" RuntimeErrors over 447k calls in 5s; post-fix, zero errors over 1.9M+ calls across three separate runs. - 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. - Full tests/unit/ suite: 720 passed, 88 skipped, 0 failed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 9b5b037 commit c67a5ce

1 file changed

Lines changed: 83 additions & 21 deletions

File tree

tests/integration/standard/test_client_routes.py

Lines changed: 83 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ 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+
self._connections = {} # (client_sock, target_sock) -> forwarder thread
7777
self.total_connections = 0
7878

7979
def start(self):
@@ -92,16 +92,12 @@ def start(self):
9292
self.target_host, self.target_port)
9393

9494
def stop(self):
95-
self._running = False
9695
if self._server_sock:
9796
try:
9897
self._server_sock.close()
9998
except Exception:
10099
pass
101-
with self._lock:
102-
for csock, tsock in list(self._connections):
103-
self._close_pair(csock, tsock)
104-
self._connections.clear()
100+
self._shutdown_and_join_connections(stopping=True)
105101
if self._thread:
106102
self._thread.join(timeout=5)
107103
log.info("TcpProxy stopped %s:%d", self.listen_host, self.listen_port)
@@ -120,12 +116,36 @@ def retarget(self, new_host, new_port):
120116

121117
def drop_connections(self):
122118
"""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()
119+
self._shutdown_and_join_connections()
127120
log.info("TcpProxy %s:%d dropped all connections", self.listen_host, self.listen_port)
128121

122+
def _shutdown_and_join_connections(self, stopping=False):
123+
"""
124+
Shut down (not close) each connection's sockets to unblock its
125+
forwarder thread, then join it. Only the forwarder thread itself
126+
closes its sockets, avoiding a close-vs-still-in-use fd-reuse race.
127+
128+
stopping=True (stop() only) flips _running to False under the same
129+
lock as the connections snapshot, so no connection registered by
130+
_handle_new_connection can be missed.
131+
"""
132+
with self._lock:
133+
if stopping:
134+
self._running = False
135+
connections = list(self._connections.items())
136+
for (csock, tsock), _thread in connections:
137+
self._shutdown_pair(csock, tsock)
138+
for (csock, tsock), thread in connections:
139+
thread.join(timeout=5)
140+
if thread.is_alive():
141+
log.warning(
142+
"TcpProxy %s:%d: forwarder thread %s did not exit "
143+
"within timeout; leaked fds are possible",
144+
self.listen_host, self.listen_port, thread.name)
145+
with self._lock:
146+
for key, _thread in connections:
147+
self._connections.pop(key, None)
148+
129149
def _run(self):
130150
while self._running:
131151
try:
@@ -153,14 +173,32 @@ def _handle_new_connection(self, client_sock, target_host=None, target_port=None
153173
client_sock.close()
154174
return
155175

156-
with self._lock:
157-
self._connections.add((client_sock, target_sock))
158-
self.total_connections += 1
159-
160176
t = threading.Thread(target=self._forward_loop,
161177
args=(client_sock, target_sock),
162178
daemon=True)
163-
t.start()
179+
# Register then start() atomically under _lock, in that order:
180+
# otherwise a short-lived thread could finish (and clean up)
181+
# before being registered, leaking the entry, or run unseen by
182+
# a concurrent stop()/drop_connections(). Also re-check
183+
# _running, to reject connections after shutdown has begun.
184+
with self._lock:
185+
if not self._running:
186+
target_sock.close()
187+
client_sock.close()
188+
return
189+
self._connections[(client_sock, target_sock)] = t
190+
self.total_connections += 1
191+
try:
192+
t.start()
193+
except Exception as e:
194+
# Undo registration: join()-ing an unstarted thread later
195+
# would raise RuntimeError.
196+
self._connections.pop((client_sock, target_sock), None)
197+
self.total_connections -= 1
198+
log.warning("TcpProxy %s:%d failed to start forwarder thread: %s",
199+
self.listen_host, self.listen_port, e)
200+
client_sock.close()
201+
target_sock.close()
164202

165203
def _forward_loop(self, client_sock, target_sock):
166204
try:
@@ -178,7 +216,7 @@ def _forward_loop(self, client_sock, target_sock):
178216
pass
179217
finally:
180218
with self._lock:
181-
self._connections.discard((client_sock, target_sock))
219+
self._connections.pop((client_sock, target_sock), None)
182220
self._close_pair(client_sock, target_sock)
183221

184222
@staticmethod
@@ -189,6 +227,15 @@ def _close_pair(csock, tsock):
189227
except Exception:
190228
pass
191229

230+
@staticmethod
231+
def _shutdown_pair(csock, tsock):
232+
"""Best-effort shutdown (not close) to interrupt a thread blocked in select()/recv()."""
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,8 @@ 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() holds it while _add_node_proxy() re-acquires it.
278+
self._lock = threading.RLock()
231279
self._running = False
232280

233281
def start(self, node_addresses):
@@ -288,13 +336,17 @@ def stop(self):
288336
log.info("NLB stopped")
289337

290338
def add_node(self, node_id, addr):
291-
self._add_node_proxy(node_id, addr)
339+
# Serialize against remove_node(): TcpProxy.stop() blocks until
340+
# joined, so by the time we get the lock any freed fds are reaped.
341+
with self._lock:
342+
self._add_node_proxy(node_id, addr)
292343

293344
def remove_node(self, node_id):
294345
with self._lock:
295346
proxy = self._node_proxies.pop(node_id, None)
347+
if proxy:
348+
proxy.stop()
296349
if proxy:
297-
proxy.stop()
298350
log.info("NLB removed node %d", node_id)
299351

300352
def node_port(self, node_id):
@@ -328,8 +380,18 @@ def _add_node_proxy(self, node_id, addr):
328380
node_id, self.LISTEN_HOST, port, addr, self.native_port)
329381

330382
def _live_addresses(self):
331-
"""IPs of nodes with active proxies."""
332-
return [p.target_host for p in self._node_proxies.values()]
383+
"""
384+
IPs of nodes with active proxies.
385+
386+
Snapshots under _lock: rr_handler() (the discovery port's accept
387+
handler) calls this from the discovery TcpProxy's own accept-loop
388+
thread, concurrently with add_node()/remove_node() mutating
389+
_node_proxies from other threads. Without the lock, a node being
390+
added/removed mid-iteration can raise "RuntimeError: dictionary
391+
changed size during iteration".
392+
"""
393+
with self._lock:
394+
return [p.target_host for p in self._node_proxies.values()]
333395

334396
def post_client_routes(contact_point, routes):
335397
"""

0 commit comments

Comments
 (0)