@@ -624,6 +624,78 @@ def acquirer() -> None:
624624 lock .release ()
625625
626626
627+ def test_read_acquire_rolls_back_begin_when_select_loses_lock_race (lock_file : str ) -> None :
628+ # A read acquire runs BEGIN (a deferred transaction that takes no lock) and only then the SELECT that takes
629+ # the SHARED lock. If a writer grabs EXCLUSIVE between the two, the SELECT fails but BEGIN's transaction is
630+ # left open on the shared connection; without a rollback the next acquire's BEGIN dies with "cannot start a
631+ # transaction within a transaction" and the instance is wedged. Force that interleaving and confirm the
632+ # connection is rolled back and the instance still works.
633+ reader = ReadWriteLock (lock_file , is_singleton = False )
634+ writer = ReadWriteLock (lock_file , is_singleton = False )
635+ real_con = reader ._con
636+
637+ class _RaceCon :
638+ def execute (self , sql : str ) -> object : # noqa: PLR6301
639+ if sql .lstrip ().upper ().startswith ("SELECT" ) and not writer ._con .in_transaction :
640+ writer .acquire_write () # writer slips in between the reader's BEGIN and SELECT
641+ return real_con .execute (sql )
642+
643+ def __getattr__ (self , name : str ) -> object :
644+ return getattr (real_con , name )
645+
646+ reader ._con = _RaceCon () # ty: ignore[invalid-assignment]
647+ with pytest .raises (Timeout ):
648+ reader .acquire_read (timeout = 0.3 )
649+ reader ._con = real_con
650+ assert real_con .in_transaction is False
651+
652+ writer .release ()
653+ with reader .read_lock (timeout = 1 ):
654+ assert reader ._current_mode == "read"
655+ reader .close ()
656+ writer .close ()
657+
658+
659+ def test_failed_reentrant_double_check_keeps_a_concurrent_holders_lock (lock_file : str ) -> None :
660+ # The acquire-failure handler must not roll back on a reentrant-validation RuntimeError: a writer that reaches
661+ # _do_acquire_inner's double-check after a reader took the lock would otherwise roll back the reader's still-open
662+ # transaction on the shared connection, dropping a lock the reader believes it holds. Force that interleaving.
663+ lock = ReadWriteLock (lock_file , is_singleton = False )
664+ writer_at_gate = threading .Event ()
665+ reader_acquired = threading .Event ()
666+ real_acquire_tx = lock ._acquire_transaction_lock
667+ writer_tid : dict [str , int ] = {}
668+ errors : dict [str , RuntimeError ] = {}
669+
670+ def gated (* , blocking : bool , timeout : float ) -> None :
671+ if threading .get_ident () == writer_tid .get ("id" ): # writer has passed its early check at lock_level == 0
672+ writer_at_gate .set ()
673+ reader_acquired .wait (5 ) # let the reader fully acquire (and open its transaction) first
674+ real_acquire_tx (blocking = blocking , timeout = timeout )
675+
676+ def acquire_write_in_thread () -> None :
677+ writer_tid ["id" ] = threading .get_ident ()
678+ try :
679+ lock .acquire_write (timeout = 5 )
680+ except RuntimeError as exc :
681+ errors ["writer" ] = exc
682+
683+ lock ._acquire_transaction_lock = gated # ty: ignore[invalid-assignment]
684+ thread = threading .Thread (target = acquire_write_in_thread )
685+ thread .start ()
686+ assert writer_at_gate .wait (5 )
687+ lock .acquire_read (timeout = 5 ) # reader opens the SHARED transaction on the shared connection
688+ reader_acquired .set ()
689+ thread .join (5 )
690+
691+ assert "writer" in errors # the upgrade was rejected with RuntimeError, as expected
692+ assert lock ._con .in_transaction is True # the reader's transaction survived the writer's failure
693+ assert lock ._lock_level == 1
694+ assert lock ._current_mode == "read"
695+ lock .release ()
696+ lock .close ()
697+
698+
627699def test_pytest_session_finish_calls_cleanup (mocker : MockerFixture ) -> None :
628700 mock = mocker .patch ("conftest._cleanup_connections" )
629701 pytest_sessionfinish ()
0 commit comments