Skip to content

Commit fb4dc18

Browse files
committed
Fix cross-lock race in libev reactor thread exit check (free-threaded 3.14t hang)
LibevLoop._live_conns is written under a lock in connection_created()/connection_destroyed(), while _run_loop()'s exit check decides whether to stop the reactor thread. An earlier version of this fix moved the _live_conns read onto the same lock the writers used, but the started/shutdown flags were still read and set under a separate lock afterward -- leaving a gap where connection_created() could still register a connection between the read and the state transition. CI on that version reproduced the exact hang this fix is meant to eliminate. Fix: merge _lock and the former _conn_set_lock into a single lock that guards both _live_conns/_new_conns/_closed_conns *and* the _started/_shutdown transitions read in the exit check. This makes the exit decision and connection registration mutually exclusive rather than just reading from a shared lock: a concurrent connection_created() either finishes before the exit check's critical section (its connection is visible in _live_conns, so the reactor keeps running) or finishes after _started is set to False inside that same critical section (so the subsequent maybe_start() call, which always follows connection_created(), sees _started == False and starts a fresh thread). There is no interleaving in which the new connection is invisible to both checks, closing the race rather than narrowing it. The two locks didn't need to stay separate: _run_loop() already nested "with self._lock: with self._conn_set_lock:", and connection_created()/connection_destroyed()/_loop_will_run() never call anything that reacquires _lock, so merging them introduces no reentrancy or ordering issue. Also add a regression test (LibevLoopRaceTest) that forces the exact interleaving from issue #980 deterministically: it pauses the reactor thread's exit-check via an instrumented lock right after it starts deciding, then tries to register a connection from another thread. The test asserts connection_created() cannot complete until the reactor's decision is committed, and that maybe_start() correctly restarts the reactor if the connection lands just after. Verified this test fails (reliably, not flakily) against the git history's prior attempt at this fix and passes against this one. Fixes #980.
1 parent 7643078 commit fb4dc18

2 files changed

Lines changed: 199 additions & 7 deletions

File tree

cassandra/io/libevreactor.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,19 +57,27 @@ def __init__(self):
5757

5858
self._started = False
5959
self._shutdown = False
60+
# Single lock for _started/_shutdown *and* _live_conns/_new_conns/
61+
# _closed_conns. The exit check in _run_loop() must see the started/
62+
# shutdown flags and the live connection set as one atomic snapshot,
63+
# otherwise connection_created() can register a connection in the
64+
# instant after the exit check reads an empty _live_conns but before
65+
# _started is flipped to False -- a lost wakeup that hangs the
66+
# connection forever (see issue #980). Two separate locks can't give
67+
# that atomicity no matter which one each side takes, so there is
68+
# only one lock here, not a hold-both-locks protocol.
6069
self._lock = Lock()
6170
self._lock_thread = Lock()
6271

6372
self._thread = None
6473

6574
# set of all connections; only replaced with a new copy
66-
# while holding _conn_set_lock, never modified in place
75+
# while holding _lock, never modified in place
6776
self._live_conns = set()
6877
# newly created connections that need their write/read watcher started
6978
self._new_conns = set()
7079
# recently closed connections that need their write/read watcher stopped
7180
self._closed_conns = set()
72-
self._conn_set_lock = Lock()
7381

7482
self._preparer = libev.Prepare(self._loop, self._loop_will_run)
7583
# prevent _preparer from keeping the loop from returning
@@ -101,6 +109,16 @@ def _run_loop(self):
101109
self._loop.start()
102110
# there are still active watchers, no deadlock
103111
with self._lock:
112+
# Reading _live_conns and deciding/committing the exit here
113+
# happen atomically under the same lock that guards
114+
# connection_created()/connection_destroyed(). So any
115+
# concurrent connection_created() either finishes-before this
116+
# read (its connection is seen in _live_conns, loop
117+
# restarts) or finishes-after this block sets _started =
118+
# False (maybe_start(), called right after
119+
# connection_created(), then observes _started == False and
120+
# starts a fresh thread). There is no interleaving in which
121+
# the new connection is invisible to both.
104122
if not self._shutdown and self._live_conns:
105123
log.debug("Restarting event loop")
106124
continue
@@ -159,7 +177,7 @@ def notify(self):
159177
self._notifier.send()
160178

161179
def connection_created(self, conn):
162-
with self._conn_set_lock:
180+
with self._lock:
163181
new_live_conns = self._live_conns.copy()
164182
new_live_conns.add(conn)
165183
self._live_conns = new_live_conns
@@ -169,7 +187,7 @@ def connection_created(self, conn):
169187
self._new_conns = new_new_conns
170188

171189
def connection_destroyed(self, conn):
172-
with self._conn_set_lock:
190+
with self._lock:
173191
new_conns = self._new_conns.copy()
174192
new_conns.discard(conn)
175193
self._new_conns = new_conns
@@ -198,7 +216,7 @@ def _loop_will_run(self, prepare):
198216
changed = True
199217

200218
if self._new_conns:
201-
with self._conn_set_lock:
219+
with self._lock:
202220
to_start = self._new_conns
203221
self._new_conns = set()
204222

@@ -209,7 +227,7 @@ def _loop_will_run(self, prepare):
209227
changed = True
210228

211229
if self._closed_conns:
212-
with self._conn_set_lock:
230+
with self._lock:
213231
to_stop = self._closed_conns
214232
self._closed_conns = set()
215233

tests/unit/io/test_libevreactor.py

Lines changed: 175 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14+
import threading
1415
import unittest
1516

1617
from unittest.mock import patch, Mock
@@ -20,9 +21,10 @@
2021

2122
try:
2223
from cassandra.io.libevreactor import _cleanup as libev__cleanup
23-
from cassandra.io.libevreactor import LibevConnection
24+
from cassandra.io.libevreactor import LibevConnection, LibevLoop
2425
except (ImportError, DependencyException):
2526
LibevConnection = None # noqa
27+
LibevLoop = None # noqa
2628

2729
from tests.unit.io.utils import ReactorTestMixin, TimerTestMixin
2830

@@ -95,6 +97,178 @@ def test_watchers_are_finished(self):
9597
_global_loop._preparer.start()
9698

9799

100+
class _InstrumentedLock(object):
101+
"""
102+
Wraps a real threading.Lock and calls a hook the first time it is
103+
acquired. Used to pause a thread *while it holds the lock* so a second
104+
thread's attempt to acquire the same lock can be observed as blocking
105+
(or not).
106+
"""
107+
108+
def __init__(self, on_first_acquire):
109+
self._real_lock = threading.Lock()
110+
self._on_first_acquire = on_first_acquire
111+
self._acquire_count = 0
112+
self._count_lock = threading.Lock()
113+
114+
def acquire(self, *args, **kwargs):
115+
got = self._real_lock.acquire(*args, **kwargs)
116+
if got:
117+
with self._count_lock:
118+
self._acquire_count += 1
119+
first = self._acquire_count == 1
120+
if first:
121+
self._on_first_acquire()
122+
return got
123+
124+
def release(self):
125+
self._real_lock.release()
126+
127+
def __enter__(self):
128+
self.acquire()
129+
return self
130+
131+
def __exit__(self, exc_type, exc_val, exc_tb):
132+
self.release()
133+
134+
135+
class LibevLoopRaceTest(unittest.TestCase):
136+
"""
137+
Regression tests for GH-980: LibevLoop._run_loop()'s decision to exit
138+
the reactor thread (based on _live_conns being empty) must be atomic
139+
with connection_created() registering a new connection. If it isn't,
140+
a connection can be added in the instant after the exit check reads an
141+
empty _live_conns but before the reactor commits to exiting -- and
142+
since maybe_start() (called right after connection_created()) also
143+
sees the stale "already started" state, nobody ever starts a new
144+
reactor thread for that connection. It is silently orphaned forever.
145+
"""
146+
147+
def setUp(self):
148+
if LibevLoop is None:
149+
raise unittest.SkipTest('libev does not appear to be installed correctly')
150+
151+
def test_connection_created_cannot_race_the_exit_check(self):
152+
"""
153+
Force the exact interleaving that produces the hang: pause the
154+
reactor thread right after it enters the critical section that
155+
decides whether to exit (i.e. right after it acquires the lock
156+
that must guard both _live_conns and the started/shutdown state),
157+
then try to register a new connection from another thread. With
158+
the fix, connection_created() must block until the reactor
159+
finishes its decision, so the two operations can never interleave.
160+
161+
@jira_ticket GH-980
162+
"""
163+
loop = LibevLoop()
164+
165+
# No real watchers are involved in this test; make each pass of
166+
# the reactor loop return immediately.
167+
loop._loop = Mock()
168+
loop._shutdown = False
169+
loop._live_conns = set() # nothing live -> the reactor wants to exit
170+
loop._started = True # simulate an already-running reactor thread
171+
172+
reactor_in_critical_section = threading.Event()
173+
release_reactor = threading.Event()
174+
175+
def pause_reactor():
176+
reactor_in_critical_section.set()
177+
# Hold the lock open long enough to give connection_created()
178+
# a real chance to race in while we're "deciding".
179+
release_reactor.wait(timeout=5)
180+
181+
loop._lock = _InstrumentedLock(pause_reactor)
182+
183+
reactor_thread = threading.Thread(target=loop._run_loop, name="test_reactor", daemon=True)
184+
reactor_thread.start()
185+
self.addCleanup(reactor_thread.join, 5)
186+
187+
self.assertTrue(
188+
reactor_in_critical_section.wait(timeout=5),
189+
"reactor thread never entered its exit-check critical section")
190+
191+
conn = Mock()
192+
connection_created_done = threading.Event()
193+
194+
def create_connection():
195+
loop.connection_created(conn)
196+
connection_created_done.set()
197+
198+
creator_thread = threading.Thread(target=create_connection, name="test_creator", daemon=True)
199+
creator_thread.start()
200+
self.addCleanup(creator_thread.join, 5)
201+
202+
# While the reactor is still deciding, connection_created() must
203+
# NOT be able to complete -- if it does, the exit decision and the
204+
# connection registration were not atomic (the bug from GH-980:
205+
# a two-lock split where a writer could slip a connection in
206+
# between the reactor's read of _live_conns and its commit to
207+
# exit/started=False).
208+
raced_in = connection_created_done.wait(timeout=0.5)
209+
self.assertFalse(
210+
raced_in,
211+
"connection_created() completed while the reactor thread was "
212+
"still deciding whether to exit -- the exit check and "
213+
"connection registration are not atomic, reproducing GH-980")
214+
215+
# Let the reactor finish its decision (it will see the pre-race
216+
# empty _live_conns, and exit).
217+
release_reactor.set()
218+
creator_thread.join(timeout=5)
219+
reactor_thread.join(timeout=5)
220+
221+
self.assertFalse(reactor_thread.is_alive())
222+
self.assertTrue(connection_created_done.is_set())
223+
# The connection was registered (never lost)...
224+
self.assertIn(conn, loop._live_conns)
225+
# ...and because it landed strictly after the reactor committed to
226+
# exiting, _started correctly reflects "not running": a
227+
# subsequent maybe_start() (as LibevConnection.__init__ always
228+
# calls right after connection_created()) will see this and spin
229+
# up a fresh thread instead of stranding the connection.
230+
self.assertFalse(loop._started)
231+
232+
with patch('cassandra.io.libevreactor.Thread') as mock_thread_cls:
233+
loop.maybe_start()
234+
mock_thread_cls.assert_called_once()
235+
self.assertTrue(loop._started)
236+
237+
def test_live_connection_prevents_exit(self):
238+
"""
239+
Sanity check for the other side of the same critical section: if
240+
connection_created() completes (and is visible) before the
241+
reactor's exit check runs, the reactor must see the connection and
242+
keep the loop running rather than exit.
243+
"""
244+
loop = LibevLoop()
245+
246+
conn = Mock()
247+
loop.connection_created(conn)
248+
loop._shutdown = False
249+
250+
calls = {'n': 0}
251+
252+
def fake_start():
253+
calls['n'] += 1
254+
if calls['n'] == 1:
255+
return
256+
# Second pass: simulate the connection being closed so the
257+
# loop can actually terminate instead of spinning forever.
258+
loop.connection_destroyed(conn)
259+
260+
loop._loop = Mock()
261+
loop._loop.start = fake_start
262+
263+
reactor_thread = threading.Thread(target=loop._run_loop, name="test_reactor", daemon=True)
264+
reactor_thread.start()
265+
reactor_thread.join(timeout=5)
266+
267+
self.assertFalse(reactor_thread.is_alive())
268+
self.assertEqual(calls['n'], 2)
269+
self.assertFalse(loop._started)
270+
271+
98272
class LibevTimerPatcher(unittest.TestCase):
99273

100274
@classmethod

0 commit comments

Comments
 (0)