Skip to content

🐛 fix(async-rw): end only the closing wrapper's holds - #832

Merged
gaborbernat merged 20 commits into
mainfrom
fix/async-rw-close
Oct 17, 2026
Merged

gaborbernat merged 20 commits into
mainfrom
fix/async-rw-close

Conversation

@gaborbernat

@gaborbernat gaborbernat commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

With the default is_singleton=True, every AsyncReadWriteLock and AsyncSoftReadWriteLock on a path is a wrapper around one shared sync lock. All such wrappers also share one task table (the record of which asyncio task 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 ran read_lock() or write_lock() bodies while another process held the write lock. 🔓 This PR makes close() 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() ran ReadWriteLock.close() on the shared sync lock and then reset the shared table. The sync close() documents that it ends every thread's hold on this instance. So a program that builds one wrapper per request and closes it in a finally, as the async read / write how-to shows, lost its lock when any request finished. A task still inside async with rw.write_lock() through another wrapper kept running while another process acquired the write lock. A synchronous ReadWriteLock user of the same path lost its hold the same way. The holder learned of it only when its own release raised RuntimeError. 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
    end
Loading

Each task's hold now records its nesting count per wrapper it took a level through, in _TaskHold. A wrapper's close() 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 with is_singleton=False owns 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 for release().

A reader arriving during close() joined the ending hold

close() awaited its executor call with the wrapper's closed flag still unset and the table still showing a read hold. A task that called acquire_read() in that window, about 0.2 ms per close, joined the hold without any sync-lock call. It entered its read_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 lock
Loading

The 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 release

Synchronous code can call ReadWriteLock(path).close() or release(force=True) on the singleton the async wrapper shares; the how-to's ReadWriteLock example ends with close() in a finally. The sync call ended the task's level, but the table still listed that task as a reader. Every later acquire_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. AsyncSoftReadWriteLock behaved the same after a synchronous SoftReadWriteLock.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;
Loading

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 sync close() the next async acquire raises sqlite3.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 shared ReadWriteLock or SoftReadWriteLock. 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 raises Timeout, as it does during any other in-flight transition. The sync-lock check and the admission are not one atomic step. A synchronous close() that lands in the few bytecodes between them can still admit that one task into the ended hold, which close() now states.

@gaborbernat gaborbernat changed the title fix(async-rw): end only the closing wrapper's holds 🐛 fix(async-rw): end only the closing wrapper's holds Oct 16, 2026
@gaborbernat
gaborbernat force-pushed the fix/async-rw-close branch 5 times, most recently from 54b9513 to 1cfa37b Compare October 16, 2026 13:45
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
gaborbernat force-pushed the fix/async-rw-close branch 2 times, most recently from 67fe0f1 to 770733b Compare October 16, 2026 17:01
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
gaborbernat changed the base branch from main to fix/interrupted-release October 16, 2026 19:57
Base automatically changed from fix/interrupted-release to main October 17, 2026 03:05
@gaborbernat
gaborbernat merged commit 1dee540 into main Oct 17, 2026
44 of 45 checks passed
@gaborbernat
gaborbernat deleted the fix/async-rw-close branch October 17, 2026 03:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant