Thanks for the detailed report and the py-spy dump. The analysis is correct. I reproduced it by forcing a GC pass inside add_observer while a dead cyclic observer was still registered, and the thread deadlocks on its own lock.

The race you mention for the RLock fix can't happen: purge_observer only removes keys whose set actually held the purged observer, so it never drops another caller's new empty set. Making the lock reentrant does expose two smaller issues, which are fixed in the same change:

A nested purge could delete a key between the remove and the del in remove_observer/discard_observer, raising KeyError.
A nested purge could also change the dict while purge_observer iterates over it.

Keys are now deleted with pop(key, None), and purge_observer iterates over a copy.

About the deferred-queue option: post_notification doesn't take the lock, so the queue couldn't be drained there. Deferring would also change purge_observer from removing immediately to removing eventually. For this bug the reentrant lock is the smaller change.

The fix will be in the next python3-application release, and the Blink builds will pick it up.
