Commit e11836c
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.
A follow-up pass over the same synchronization path (Copilot automated
review on the PR, flagged low-confidence so not posted as formal review
threads, but both genuine) found one more real gap and a missing test:
- _shutdown_and_join_connections() joined every forwarder thread with a
5s timeout, then unconditionally popped *every* connection out of
self._connections regardless of whether its thread had actually
exited. A thread that didn't finish in time was dropped from
tracking anyway, so active_connections under-reported live
connections, and a later stop()/drop_connections() could never
retry shutting it down -- permanently leaking that thread and its
fds. Fixed by only popping entries whose thread is confirmed dead
(`not thread.is_alive()`) after the join; still-alive entries stay
tracked until _forward_loop's own self-removal (already
lock-protected and idempotent) reaps them, so a subsequent shutdown
call can retry.
- Added tests/unit/test_tcp_proxy.py, a checked-in deterministic
regression test (TcpProxy has no CCM/cluster dependency, only
sockets, so it runs as a plain fast unit test against a local dummy
TCP echo backend). It covers: (a) the exact regression above --
neutering _shutdown_pair and shrinking one thread's join wait to
deterministically force the "still alive after the timeout" path,
and asserting the connection stays tracked until a retried
drop_connections() actually reaps it -- and (b) a concurrent stress
test that hammers drop_connections() from multiple threads while
other threads continuously open/close real connections, asserting
no unhandled exceptions and no forwarder threads left alive once
stop() returns.
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.
- New tests/unit/test_tcp_proxy.py: 30/30 clean runs with the
active_connections fix in place; with the fix reverted, the
targeted regression test failed deterministically 15/15 runs
(active_connections incorrectly reported 0 instead of 1 for a
still-alive forwarder thread), confirming the test actually catches
the bug it targets.
- Full tests/unit/ suite: 722 passed, 88 skipped, 0 failed.
Fixes #948.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>1 parent 9b5b037 commit e11836c
2 files changed
Lines changed: 351 additions & 21 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
73 | 73 | | |
74 | 74 | | |
75 | 75 | | |
76 | | - | |
| 76 | + | |
77 | 77 | | |
78 | 78 | | |
79 | 79 | | |
| |||
92 | 92 | | |
93 | 93 | | |
94 | 94 | | |
95 | | - | |
96 | 95 | | |
97 | 96 | | |
98 | 97 | | |
99 | 98 | | |
100 | 99 | | |
101 | | - | |
102 | | - | |
103 | | - | |
104 | | - | |
| 100 | + | |
105 | 101 | | |
106 | 102 | | |
107 | 103 | | |
| |||
120 | 116 | | |
121 | 117 | | |
122 | 118 | | |
123 | | - | |
124 | | - | |
125 | | - | |
126 | | - | |
| 119 | + | |
127 | 120 | | |
128 | 121 | | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
129 | 158 | | |
130 | 159 | | |
131 | 160 | | |
| |||
153 | 182 | | |
154 | 183 | | |
155 | 184 | | |
156 | | - | |
157 | | - | |
158 | | - | |
159 | | - | |
160 | 185 | | |
161 | 186 | | |
162 | 187 | | |
163 | | - | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
164 | 211 | | |
165 | 212 | | |
166 | 213 | | |
| |||
178 | 225 | | |
179 | 226 | | |
180 | 227 | | |
181 | | - | |
| 228 | + | |
182 | 229 | | |
183 | 230 | | |
184 | 231 | | |
| |||
189 | 236 | | |
190 | 237 | | |
191 | 238 | | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
192 | 248 | | |
193 | 249 | | |
194 | 250 | | |
| |||
227 | 283 | | |
228 | 284 | | |
229 | 285 | | |
230 | | - | |
| 286 | + | |
| 287 | + | |
231 | 288 | | |
232 | 289 | | |
233 | 290 | | |
| |||
288 | 345 | | |
289 | 346 | | |
290 | 347 | | |
291 | | - | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
292 | 352 | | |
293 | 353 | | |
294 | 354 | | |
295 | 355 | | |
| 356 | + | |
| 357 | + | |
296 | 358 | | |
297 | | - | |
298 | 359 | | |
299 | 360 | | |
300 | 361 | | |
| |||
328 | 389 | | |
329 | 390 | | |
330 | 391 | | |
331 | | - | |
332 | | - | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
333 | 404 | | |
334 | 405 | | |
335 | 406 | | |
| |||
0 commit comments