Skip to content

Commit 4f42fb7

Browse files
committed
fix: notify watcher after releasing the enforcer lock (#408)
1 parent 8a8b702 commit 4f42fb7

2 files changed

Lines changed: 265 additions & 4 deletions

File tree

‎casbin/synced_enforcer.py‎

Lines changed: 122 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,123 @@ def value(self, value):
3535
self._value = value
3636

3737

38+
class _DeferredNotifier:
39+
"""Queues the watcher notifications raised inside a locked section and delivers them
40+
once the lock has been released.
41+
42+
Watchers have a lock of their own, and some of them (the redis watcher, for one) hold
43+
it while running the update callback, which calls back into the enforcer to reload the
44+
policy. Notifying from inside the enforcer lock would let the two threads take those
45+
two locks in opposite orders and deadlock:
46+
https://github.com/casbin/pycasbin/issues/408
47+
"""
48+
49+
def __init__(self):
50+
self._local = threading.local()
51+
52+
def _stack(self):
53+
stack = getattr(self._local, "stack", None)
54+
if stack is None:
55+
stack = []
56+
self._local.stack = stack
57+
return stack
58+
59+
def defer(self, func, args, kwargs):
60+
"""queues a call, and returns whether it was queued. False means no lock is held
61+
by this thread, so the caller has to run the call itself."""
62+
stack = self._stack()
63+
if not stack:
64+
return False
65+
stack[-1].append((func, args, kwargs))
66+
return True
67+
68+
def begin(self):
69+
"""starts collecting, called right after the lock has been taken."""
70+
self._stack().append([])
71+
72+
def end(self, suppress_errors=False):
73+
"""delivers what was collected, called right after the lock has been released."""
74+
stack = self._stack()
75+
queued = stack.pop()
76+
if stack:
77+
# an outer lock is still held, so hand the calls over to it
78+
stack[-1].extend(queued)
79+
return
80+
81+
for func, args, kwargs in queued:
82+
try:
83+
func(*args, **kwargs)
84+
except Exception:
85+
# the caller is already unwinding an exception of its own, and hiding it
86+
# behind a notification failure would only make the original harder to find
87+
if not suppress_errors:
88+
raise
89+
90+
91+
class _NotifyAfterUnlock:
92+
"""wraps one of the enforcer locks so that _DeferredNotifier collects notifications
93+
for as long as the lock is held."""
94+
95+
def __init__(self, lock, notifier):
96+
self._lock = lock
97+
self._notifier = notifier
98+
99+
def __enter__(self):
100+
self._lock.__enter__()
101+
self._notifier.begin()
102+
return self
103+
104+
def __exit__(self, exc_type, exc_value, traceback):
105+
try:
106+
self._lock.__exit__(exc_type, exc_value, traceback)
107+
finally:
108+
self._notifier.end(suppress_errors=exc_type is not None)
109+
return False
110+
111+
112+
class _DeferredWatcher:
113+
"""stands in for the user's watcher on the wrapped enforcer, and routes every call it
114+
makes through a _DeferredNotifier."""
115+
116+
def __init__(self, watcher, notifier):
117+
self._watcher = watcher
118+
self._notifier = notifier
119+
120+
def __bool__(self):
121+
return bool(self._watcher)
122+
123+
def __getattr__(self, name):
124+
watcher = self.__dict__.get("_watcher")
125+
if watcher is None:
126+
raise AttributeError(name)
127+
128+
attr = getattr(watcher, name)
129+
if not callable(attr):
130+
return attr
131+
132+
notifier = self._notifier
133+
134+
def deferred(*args, **kwargs):
135+
# a deferred call has no result to hand back yet, but the enforcer ignores
136+
# what the watcher returns anyway
137+
if notifier.defer(attr, args, kwargs):
138+
return None
139+
return attr(*args, **kwargs)
140+
141+
return deferred
142+
143+
38144
class SyncedEnforcer:
39145
"""SyncedEnforcer wraps Enforcer and provides synchronized access.
40146
It's also a drop-in replacement for Enforcer"""
41147

42148
def __init__(self, model=None, adapter=None):
43149
self._e = self._new_enforcer(model, adapter)
44150
self._rwlock = RWLockWrite()
45-
self._rl = self._rwlock.gen_rlock()
46-
self._wl = self._rwlock.gen_wlock()
151+
self._notifier = _DeferredNotifier()
152+
self._rl = _NotifyAfterUnlock(self._rwlock.gen_rlock(), self._notifier)
153+
self._wl = _NotifyAfterUnlock(self._rwlock.gen_wlock(), self._notifier)
154+
self._watcher = None
47155
self._auto_loading = AtomicBool(False)
48156
self._auto_loading_thread = None
49157

@@ -115,9 +223,19 @@ def set_adapter(self, adapter):
115223
self._e.set_adapter(adapter)
116224

117225
def set_watcher(self, watcher):
118-
"""sets the current watcher."""
226+
"""sets the current watcher.
227+
228+
It is notified only once the enforcer lock has been released, so a watcher that
229+
holds a lock of its own while running the update callback cannot deadlock against
230+
the enforcer."""
119231
with self._wl:
120-
self._e.set_watcher(watcher)
232+
self._watcher = watcher
233+
self._e.set_watcher(None if watcher is None else _DeferredWatcher(watcher, self._notifier))
234+
235+
def get_watcher(self):
236+
"""gets the watcher that was passed to set_watcher()."""
237+
with self._rl:
238+
return self._watcher
121239

122240
def set_effector(self, eft):
123241
"""sets the current effector."""

‎tests/test_synced_enforcer.py‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# Copyright 2026 The casbin Authors. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import threading
16+
from unittest import TestCase
17+
18+
import casbin
19+
from tests.test_enforcer import get_examples
20+
21+
22+
class LockingWatcher:
23+
"""mimics the redis watcher, which holds a lock of its own both while notifying the
24+
other nodes and while running the update callback."""
25+
26+
def __init__(self):
27+
self.mutex = threading.Lock()
28+
self.callback = None
29+
self.updates = []
30+
31+
def set_update_callback(self, callback):
32+
self.callback = callback
33+
34+
def update(self):
35+
with self.mutex:
36+
self.updates.append("update")
37+
return True
38+
39+
def close(self):
40+
pass
41+
42+
43+
class TestSyncedEnforcerWatcher(TestCase):
44+
"""the lock the enforcer takes and the lock the watcher takes have to be taken in the
45+
same order everywhere, or the two deadlock. See
46+
https://github.com/casbin/pycasbin/issues/408"""
47+
48+
def get_enforcer(self, adapter):
49+
return casbin.SyncedEnforcer(get_examples("basic_model.conf"), adapter)
50+
51+
def test_watcher_is_notified(self):
52+
w = LockingWatcher()
53+
e = self.get_enforcer(casbin.persist.Adapter())
54+
e.set_watcher(w)
55+
56+
e.add_policy("alice", "data1", "read")
57+
58+
# the notification is deferred, but only until the lock is released: it still has
59+
# to have been delivered by the time add_policy() returns
60+
self.assertEqual(w.updates, ["update"])
61+
self.assertTrue(e.enforce("alice", "data1", "read"))
62+
63+
def test_get_watcher_returns_the_watcher_that_was_set(self):
64+
w = LockingWatcher()
65+
e = self.get_enforcer(casbin.persist.Adapter())
66+
e.set_watcher(w)
67+
68+
self.assertIs(e.get_watcher(), w)
69+
70+
def test_watcher_is_notified_outside_the_enforcer_lock(self):
71+
w = LockingWatcher()
72+
e = self.get_enforcer(casbin.persist.Adapter())
73+
e.set_watcher(w)
74+
75+
reader_finished = threading.Event()
76+
77+
def update():
78+
# a reader can only get through here if the write lock has been released
79+
reader = threading.Thread(target=lambda: (e.get_policy(), reader_finished.set()), daemon=True)
80+
reader.start()
81+
reader.join(timeout=5)
82+
83+
w.update = update
84+
e.add_policy("alice", "data1", "read")
85+
86+
self.assertTrue(reader_finished.is_set(), "the watcher was notified while the enforcer was still locked")
87+
88+
def test_save_policy_notifies_outside_the_enforcer_lock(self):
89+
"""save_policy() notifies the watcher too, and it holds the read lock while doing
90+
so, so the same deferral has to apply there."""
91+
w = LockingWatcher()
92+
e = self.get_enforcer(casbin.persist.Adapter())
93+
e.set_watcher(w)
94+
95+
writer_finished = threading.Event()
96+
97+
def update():
98+
# a writer can only get through here if the read lock has been released
99+
writer = threading.Thread(target=lambda: (e.load_policy(), writer_finished.set()), daemon=True)
100+
writer.start()
101+
writer.join(timeout=5)
102+
103+
w.update = update
104+
e.save_policy()
105+
106+
self.assertTrue(writer_finished.is_set(), "the watcher was notified while the enforcer was still locked")
107+
108+
def test_callback_holding_the_watcher_lock_does_not_deadlock(self):
109+
"""reproduces the issue: the subscribe thread holds the watcher lock and wants the
110+
enforcer lock, while the thread changing the policy holds the enforcer lock and
111+
wants the watcher lock."""
112+
w = LockingWatcher()
113+
enforcer_locked = threading.Event()
114+
callback_reached = threading.Event()
115+
116+
class SlowAdapter(casbin.persist.Adapter):
117+
def add_policy(self, sec, ptype, rule):
118+
# the write lock is held for as long as we stay in here, so this is where
119+
# the subscribe thread gets its chance to ask for it
120+
enforcer_locked.set()
121+
callback_reached.wait(timeout=5)
122+
123+
e = self.get_enforcer(SlowAdapter())
124+
e.set_watcher(w)
125+
w.set_update_callback(e.load_policy)
126+
127+
def subscribe():
128+
with w.mutex:
129+
enforcer_locked.wait(timeout=5)
130+
callback_reached.set()
131+
w.callback()
132+
133+
subscriber = threading.Thread(target=subscribe, daemon=True)
134+
subscriber.start()
135+
136+
added = threading.Event()
137+
writer = threading.Thread(target=lambda: (e.add_policy("alice", "data1", "read"), added.set()), daemon=True)
138+
writer.start()
139+
140+
self.assertTrue(added.wait(timeout=10), "add_policy deadlocked against the watcher lock")
141+
subscriber.join(timeout=10)
142+
self.assertFalse(subscriber.is_alive(), "the update callback deadlocked against the enforcer lock")
143+
self.assertEqual(w.updates, ["update"])

0 commit comments

Comments
 (0)