Repository navigation
🐛 fix(async-rw): end only the closing wrapper's holds - #832
Merged
Merged
Conversation
gaborbernat
force-pushed
the
fix/async-rw-close
branch
5 times, most recently
from
October 16, 2026 13:45
54b9513 to
1cfa37b
Compare
Every backend gives up the OS lock before it clears the descriptor. A signal handler that raised between those two steps left the instance reporting a hold the OS had already dropped, so the next acquire on that instance nested into it while another process took the real lock. This hits code that catches a KeyboardInterrupt or a deadline exception raised in a with block's exit. When the backend release raises anything but a failed system call, the release now runs once more and then re-raises. A failed system call (an OSError with errno set) still keeps the hold so a later release can retry the unlock.
A signal handler that took a lock while the interrupted code was releasing the same lock either nested into a hold the OS had already dropped, or took a new hold whose release the interrupted one swallowed, leaving the lock held after every with block exited. It now raises RuntimeError, as an acquire during an interrupted acquire already does.
An exception raised by a signal handler after the OS lock was taken but before the descriptor was recorded as held skipped every cleanup: the descriptor kept the lock until the process exited while the instance reported it unlocked, so every acquire, its own included, timed out. The soft lock left its marker behind the same way, and the strict lock a claim release(force=True) could not remove. One guard now covers the stretch from taking the lock to recording the hold in each backend, and the hold is recorded before the pending slot clears.
An interrupt in the thread-hold bookkeeping after the descriptor was recorded as held made the cleanup close the handle, which drops the byte-range lock, while is_locked stayed true. Every later acquire then nested without locking, and a later release could close an unrelated file that reused the number. The cleanup now clears the recorded hold before it closes.
CPython runs __del__ before it clears weak references, so while a dropped is_singleton lock was being finalized the singleton cache still handed it to any thread that asked. The finalizer then released the hold that thread had just taken, and another process got the lock while the thread was still in its with block. The async lock had the same gap on the branches that release in place. While the cache can still find the lock, the finalizer now waits for a weak reference callback, which runs once nothing can reach the lock, and releases every hold from there on a fresh instance bound to the same state. The deadlock registry names the holder by a stored id so that instance can drop the entry.
ReadWriteLock.__del__ closed the SQLite connection of a lock dropped while held but left it marked held, and the singleton cache could hand that instance to another thread while the finalizer ran. Its read_lock() then re-entered a hold with no database lock behind it while a writer in another process acquired. The connection now lives in a holder only the lock references, closed by the holder's own finalizer, which runs once the cache can no longer find the lock. The cache lookup also reads the entry once, since the last reference can go between a membership test and the read.
A signal handler raising while the before-fork hook waited for other threads does not stop the fork, but the hook then never recorded its snapshot, and the after-fork hook failed before it reopened admission. Every other thread then blocked forever in its next lock call, later forks failed, and the child kept the parent's holds. The snapshot is now recorded and admission reopened in finally blocks.
ReadWriteLock acquire kept a fork transition open for its whole wait on a busy database. A fork from another thread waits for open transitions and admits no new ones meanwhile, so the fork and every other lock call in the process hung until the database freed up, ignoring their own timeouts. With the holder in the same process the three threads deadlocked. When another thread could fork, the wait now runs in attempts of at most 50 ms, each in its own transition, until the timeout. A process with a single thread still waits in one piece, which keeps a waiting writer's claim against new readers.
The retry in _release_with_fork_tracking only wrapped the backend call. A handler that raised after release() began but before the backend ran left the hold in place, which the earlier change claimed no longer happens. release() now repeats the release when the counter and descriptor show nothing was committed and the error is not a failed system call. The async release without an executor does the same.
A handler that raises while the before-fork hook takes the gate leaves no depth stored for the thread. The after-fork hook then indexed the missing entry and CPython printed a KeyError. The after hook now treats a missing depth as the last level.
gaborbernat
force-pushed
the
fix/async-rw-close
branch
2 times, most recently
from
October 16, 2026 17:01
67fe0f1 to
770733b
Compare
Closing a default (singleton) AsyncReadWriteLock or AsyncSoftReadWriteLock closed the shared sync lock and cleared the shared task table, so tasks holding through another wrapper, and sync users of the same path, lost their lock without being told while another process could take it. A reader that arrived while close() waited for its backend call joined the hold being closed. After sync code closed or force-released the shared ReadWriteLock, the task table kept letting new readers join a hold the backend no longer had. The task table now records the wrapper each hold came through. close() ends those holds as one table transition, so arrivals wait instead of joining, and it leaves the backend only when no other hold remains; a wrapper built with is_singleton=False still closes its own sync lock. Before answering that a task joins an existing hold, the table asks the backend whether it still has the level, and forgets the holds when it does not, so the next task takes the lock again.
The task table asks the backend before admitting a task into an existing hold, so a synchronous close() or forced release of the shared lock is noticed by the next acquire. One that lands in the few bytecodes between that check and the admission still admits that task into the ended hold; say so on close() rather than leave it implied.
Three tests reclaimed a dead owner's or a malformed marker under a one second lock timeout, which stood in for the test's budget. The reclaim takes two polls and a third to acquire, and a loaded free-threaded Windows runner spent most of that second on two polls, so the lock gave up before the reclaim although nothing was wrong with it. The tests now poll non-blocking, once per call, and require the lock within three polls: a step count instead of a wall-clock budget, which a stalled runner cannot miss. (cherry picked from commit 76a307c)
The close() cases lived in files of their own. They test AsyncReadWriteLock and AsyncSoftReadWriteLock, which already have test files, so they belong there.
Split the comments and docstrings that explained the task table's backend check in one long sentence each, and say the same in the changelog entry.
A singleton wrapper's close() left the backend for the level taken for tasks even when synchronous code had already closed or force-released it, so the sync lock raised "not held" and close() failed. Check that the backend still holds before leaving it, and finish the transition without a backend call otherwise. release() reports the same condition as a hold the task no longer has.
Two wrappers on one path share a task table. A task that nested through a second wrapper kept a single entry tagged with the first wrapper, so closing the first ended the second wrapper's level and left the backend under a task that still believed it held the lock. Each task hold now records its nesting depth per wrapper. A release ends the level of the wrapper it goes through, and close ends only the levels taken through that wrapper. The hold ends with its last level.
gaborbernat
force-pushed
the
fix/async-rw-close
branch
from
October 16, 2026 19:57
770733b to
14fcde9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
With the default
is_singleton=True, everyAsyncReadWriteLockandAsyncSoftReadWriteLockon a path is a wrapper around one shared sync lock. All such wrappers also share one task table (the record of whichasynciotask holds the lock and how many times), the model #746 introduced. Three paths left that table believing in holds the sync lock no longer had. Tasks then ranread_lock()orwrite_lock()bodies while another process held the write lock. 🔓 This PR makesclose()end only the holds taken through the closing wrapper, and makes the table ask the sync lock before it lets a task join an existing hold.Closing one wrapper ended every wrapper's hold
AsyncReadWriteLock.close()ranReadWriteLock.close()on the shared sync lock and then reset the shared table. The syncclose()documents that it ends every thread's hold on this instance. So a program that builds one wrapper per request and closes it in afinally, as the async read / write how-to shows, lost its lock when any request finished. A task still insideasync with rw.write_lock()through another wrapper kept running while another process acquired the write lock. A synchronousReadWriteLockuser of the same path lost its hold the same way. The holder learned of it only when its own release raisedRuntimeError.AsyncSoftReadWriteLock.close()had the same two steps. The behavior dates from #506, which added the class.sequenceDiagram participant T1 as task 1 (wrapper A) participant T2 as task 2 (wrapper B) participant L as shared ReadWriteLock participant P as another process T1->>L: acquire_write() rect rgb(255, 214, 214) Note over T2,L: before this PR T2->>L: close() (rollback, close connection) P->>L: acquire_write() L-->>P: granted while task 1 is still inside end rect rgb(216, 243, 220) Note over T2,L: after this PR T2->>L: close() drops only wrapper B's holds P->>L: acquire_write(blocking=False) L-->>P: Timeout endEach task's hold now records its nesting count per wrapper it took a level through, in
_TaskHold. A wrapper'sclose()ends only the levels a task took through that wrapper, and the task's hold ends with its last level.close()retires those holds through_TaskOwners.retire(). The wrapper gives back the sync lock's level (its count of nested acquisitions) only when no other hold remains. A wrapper built withis_singleton=Falseowns its sync lock and still closes it. The trade-off: a singleton wrapper no longer closes the SQLite connection itself. The sync lock closes it when the last level goes, as it already did forrelease().A reader arriving during
close()joined the ending holdclose()awaited its executor call with the wrapper's closed flag still unset and the table still showing a read hold. A task that calledacquire_read()in that window, about 0.2 ms per close, joined the hold without any sync-lock call. It entered itsread_lock()body with no SQLite transaction behind it. A service closing its lock under load met this on most shutdowns. The window came in with the per-task table in #746.sequenceDiagram participant C as closing task participant R as arriving reader participant T as task table participant B as sync lock C->>T: close(): mark closed, drop its holds rect rgb(207, 228, 255) Note over T: transitioning: joins wait C->>B: release the level (executor) R->>T: acquire_read() T-->>R: wait B-->>C: done C->>T: finish transition end T-->>R: enter R->>R: closed wrapper raises#59; other wrapper takes the lockThe closing wrapper now sets its closed flag before any
await, so it refuses later acquires. The retirement runs as a table transition: arrivals wait instead of joining, including a reader already queued behind a writer. A task that waited and then enters re-checks the wrapper, so a close that landed meanwhile raises the closed-database error.The table did not see a synchronous
close()or forced releaseSynchronous code can call
ReadWriteLock(path).close()orrelease(force=True)on the singleton the async wrapper shares; the how-to'sReadWriteLockexample ends withclose()in afinally. The sync call ended the task's level, but the table still listed that task as a reader. Every lateracquire_read()through any wrapper on the path was then answered "held" without touching the sync lock. In-process writers timed out against that phantom reader until the process exited.AsyncSoftReadWriteLockbehaved the same after a synchronousSoftReadWriteLock.close().flowchart LR A[acquire_read] --> Q{table shows a hold?} Q -- no --> E[enter sync lock] Q -- yes --> J{sync lock still has the tasks' level?} J -- yes --> H[join the hold] J -- no --> F[forget stale holds] --> E classDef ok fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332; classDef bad fill:#ffd6d6,stroke:#c1121f,color:#5c0000; classDef info fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a; class A,Q,J info; class H,E ok; class F bad;Before answering "held",
_admit()now asks the wrapper whether the sync lock still has the level taken for the tasks, through_holds_level_of(). When it is gone, the table forgets the holds. After a syncclose()the next async acquire raisessqlite3.ProgrammingError, as every other path does. After a sync forced release it takes a fresh lock.close()on a wrapper whose tasks' level sync code already ended finishes without a backend call. The check reads the sync lock's level without its internal lock: the event loop must not wait out a release running on another thread, and a stale answer is what a caller asking a moment earlier would have had.Behavior changes
close()on a singleton wrapper no longer closes the sharedReadWriteLockorSoftReadWriteLock. Tasks holding through other wrappers and synchronous users of the same path keep their locks; the how-to says so. A non-blocking acquire that lands during a close raisesTimeout, as it does during any other in-flight transition. The sync-lock check and the admission are not one atomic step. A synchronousclose()that lands in the few bytecodes between them can still admit that one task into the ended hold, whichclose()now states.