This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-pycasbin.git


The following commit(s) were added to refs/heads/master by this push:
     new 4f42fb7  fix: notify watcher after releasing the enforcer lock (#408)
4f42fb7 is described below

commit 4f42fb759d32d3b9d64403546c2b014cb32266c2
Author: Yang Luo <[email protected]>
AuthorDate: Thu Aug 6 21:40:32 2026 +0800

    fix: notify watcher after releasing the enforcer lock (#408)
---
 casbin/synced_enforcer.py     | 126 +++++++++++++++++++++++++++++++++++--
 tests/test_synced_enforcer.py | 143 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 265 insertions(+), 4 deletions(-)

diff --git a/casbin/synced_enforcer.py b/casbin/synced_enforcer.py
index 39b11a5..a10a57f 100644
--- a/casbin/synced_enforcer.py
+++ b/casbin/synced_enforcer.py
@@ -35,6 +35,112 @@ class AtomicBool:
             self._value = value
 
 
+class _DeferredNotifier:
+    """Queues the watcher notifications raised inside a locked section and 
delivers them
+    once the lock has been released.
+
+    Watchers have a lock of their own, and some of them (the redis watcher, 
for one) hold
+    it while running the update callback, which calls back into the enforcer 
to reload the
+    policy. Notifying from inside the enforcer lock would let the two threads 
take those
+    two locks in opposite orders and deadlock:
+    https://github.com/casbin/pycasbin/issues/408
+    """
+
+    def __init__(self):
+        self._local = threading.local()
+
+    def _stack(self):
+        stack = getattr(self._local, "stack", None)
+        if stack is None:
+            stack = []
+            self._local.stack = stack
+        return stack
+
+    def defer(self, func, args, kwargs):
+        """queues a call, and returns whether it was queued. False means no 
lock is held
+        by this thread, so the caller has to run the call itself."""
+        stack = self._stack()
+        if not stack:
+            return False
+        stack[-1].append((func, args, kwargs))
+        return True
+
+    def begin(self):
+        """starts collecting, called right after the lock has been taken."""
+        self._stack().append([])
+
+    def end(self, suppress_errors=False):
+        """delivers what was collected, called right after the lock has been 
released."""
+        stack = self._stack()
+        queued = stack.pop()
+        if stack:
+            # an outer lock is still held, so hand the calls over to it
+            stack[-1].extend(queued)
+            return
+
+        for func, args, kwargs in queued:
+            try:
+                func(*args, **kwargs)
+            except Exception:
+                # the caller is already unwinding an exception of its own, and 
hiding it
+                # behind a notification failure would only make the original 
harder to find
+                if not suppress_errors:
+                    raise
+
+
+class _NotifyAfterUnlock:
+    """wraps one of the enforcer locks so that _DeferredNotifier collects 
notifications
+    for as long as the lock is held."""
+
+    def __init__(self, lock, notifier):
+        self._lock = lock
+        self._notifier = notifier
+
+    def __enter__(self):
+        self._lock.__enter__()
+        self._notifier.begin()
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        try:
+            self._lock.__exit__(exc_type, exc_value, traceback)
+        finally:
+            self._notifier.end(suppress_errors=exc_type is not None)
+        return False
+
+
+class _DeferredWatcher:
+    """stands in for the user's watcher on the wrapped enforcer, and routes 
every call it
+    makes through a _DeferredNotifier."""
+
+    def __init__(self, watcher, notifier):
+        self._watcher = watcher
+        self._notifier = notifier
+
+    def __bool__(self):
+        return bool(self._watcher)
+
+    def __getattr__(self, name):
+        watcher = self.__dict__.get("_watcher")
+        if watcher is None:
+            raise AttributeError(name)
+
+        attr = getattr(watcher, name)
+        if not callable(attr):
+            return attr
+
+        notifier = self._notifier
+
+        def deferred(*args, **kwargs):
+            # a deferred call has no result to hand back yet, but the enforcer 
ignores
+            # what the watcher returns anyway
+            if notifier.defer(attr, args, kwargs):
+                return None
+            return attr(*args, **kwargs)
+
+        return deferred
+
+
 class SyncedEnforcer:
     """SyncedEnforcer wraps Enforcer and provides synchronized access.
     It's also a drop-in replacement for Enforcer"""
@@ -42,8 +148,10 @@ class SyncedEnforcer:
     def __init__(self, model=None, adapter=None):
         self._e = self._new_enforcer(model, adapter)
         self._rwlock = RWLockWrite()
-        self._rl = self._rwlock.gen_rlock()
-        self._wl = self._rwlock.gen_wlock()
+        self._notifier = _DeferredNotifier()
+        self._rl = _NotifyAfterUnlock(self._rwlock.gen_rlock(), self._notifier)
+        self._wl = _NotifyAfterUnlock(self._rwlock.gen_wlock(), self._notifier)
+        self._watcher = None
         self._auto_loading = AtomicBool(False)
         self._auto_loading_thread = None
 
@@ -115,9 +223,19 @@ class SyncedEnforcer:
             self._e.set_adapter(adapter)
 
     def set_watcher(self, watcher):
-        """sets the current watcher."""
+        """sets the current watcher.
+
+        It is notified only once the enforcer lock has been released, so a 
watcher that
+        holds a lock of its own while running the update callback cannot 
deadlock against
+        the enforcer."""
         with self._wl:
-            self._e.set_watcher(watcher)
+            self._watcher = watcher
+            self._e.set_watcher(None if watcher is None else 
_DeferredWatcher(watcher, self._notifier))
+
+    def get_watcher(self):
+        """gets the watcher that was passed to set_watcher()."""
+        with self._rl:
+            return self._watcher
 
     def set_effector(self, eft):
         """sets the current effector."""
diff --git a/tests/test_synced_enforcer.py b/tests/test_synced_enforcer.py
new file mode 100644
index 0000000..862baa9
--- /dev/null
+++ b/tests/test_synced_enforcer.py
@@ -0,0 +1,143 @@
+# Copyright 2026 The casbin Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import threading
+from unittest import TestCase
+
+import casbin
+from tests.test_enforcer import get_examples
+
+
+class LockingWatcher:
+    """mimics the redis watcher, which holds a lock of its own both while 
notifying the
+    other nodes and while running the update callback."""
+
+    def __init__(self):
+        self.mutex = threading.Lock()
+        self.callback = None
+        self.updates = []
+
+    def set_update_callback(self, callback):
+        self.callback = callback
+
+    def update(self):
+        with self.mutex:
+            self.updates.append("update")
+        return True
+
+    def close(self):
+        pass
+
+
+class TestSyncedEnforcerWatcher(TestCase):
+    """the lock the enforcer takes and the lock the watcher takes have to be 
taken in the
+    same order everywhere, or the two deadlock. See
+    https://github.com/casbin/pycasbin/issues/408""";
+
+    def get_enforcer(self, adapter):
+        return casbin.SyncedEnforcer(get_examples("basic_model.conf"), adapter)
+
+    def test_watcher_is_notified(self):
+        w = LockingWatcher()
+        e = self.get_enforcer(casbin.persist.Adapter())
+        e.set_watcher(w)
+
+        e.add_policy("alice", "data1", "read")
+
+        # the notification is deferred, but only until the lock is released: 
it still has
+        # to have been delivered by the time add_policy() returns
+        self.assertEqual(w.updates, ["update"])
+        self.assertTrue(e.enforce("alice", "data1", "read"))
+
+    def test_get_watcher_returns_the_watcher_that_was_set(self):
+        w = LockingWatcher()
+        e = self.get_enforcer(casbin.persist.Adapter())
+        e.set_watcher(w)
+
+        self.assertIs(e.get_watcher(), w)
+
+    def test_watcher_is_notified_outside_the_enforcer_lock(self):
+        w = LockingWatcher()
+        e = self.get_enforcer(casbin.persist.Adapter())
+        e.set_watcher(w)
+
+        reader_finished = threading.Event()
+
+        def update():
+            # a reader can only get through here if the write lock has been 
released
+            reader = threading.Thread(target=lambda: (e.get_policy(), 
reader_finished.set()), daemon=True)
+            reader.start()
+            reader.join(timeout=5)
+
+        w.update = update
+        e.add_policy("alice", "data1", "read")
+
+        self.assertTrue(reader_finished.is_set(), "the watcher was notified 
while the enforcer was still locked")
+
+    def test_save_policy_notifies_outside_the_enforcer_lock(self):
+        """save_policy() notifies the watcher too, and it holds the read lock 
while doing
+        so, so the same deferral has to apply there."""
+        w = LockingWatcher()
+        e = self.get_enforcer(casbin.persist.Adapter())
+        e.set_watcher(w)
+
+        writer_finished = threading.Event()
+
+        def update():
+            # a writer can only get through here if the read lock has been 
released
+            writer = threading.Thread(target=lambda: (e.load_policy(), 
writer_finished.set()), daemon=True)
+            writer.start()
+            writer.join(timeout=5)
+
+        w.update = update
+        e.save_policy()
+
+        self.assertTrue(writer_finished.is_set(), "the watcher was notified 
while the enforcer was still locked")
+
+    def test_callback_holding_the_watcher_lock_does_not_deadlock(self):
+        """reproduces the issue: the subscribe thread holds the watcher lock 
and wants the
+        enforcer lock, while the thread changing the policy holds the enforcer 
lock and
+        wants the watcher lock."""
+        w = LockingWatcher()
+        enforcer_locked = threading.Event()
+        callback_reached = threading.Event()
+
+        class SlowAdapter(casbin.persist.Adapter):
+            def add_policy(self, sec, ptype, rule):
+                # the write lock is held for as long as we stay in here, so 
this is where
+                # the subscribe thread gets its chance to ask for it
+                enforcer_locked.set()
+                callback_reached.wait(timeout=5)
+
+        e = self.get_enforcer(SlowAdapter())
+        e.set_watcher(w)
+        w.set_update_callback(e.load_policy)
+
+        def subscribe():
+            with w.mutex:
+                enforcer_locked.wait(timeout=5)
+                callback_reached.set()
+                w.callback()
+
+        subscriber = threading.Thread(target=subscribe, daemon=True)
+        subscriber.start()
+
+        added = threading.Event()
+        writer = threading.Thread(target=lambda: (e.add_policy("alice", 
"data1", "read"), added.set()), daemon=True)
+        writer.start()
+
+        self.assertTrue(added.wait(timeout=10), "add_policy deadlocked against 
the watcher lock")
+        subscriber.join(timeout=10)
+        self.assertFalse(subscriber.is_alive(), "the update callback 
deadlocked against the enforcer lock")
+        self.assertEqual(w.updates, ["update"])


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to