Repository navigation
fix: prevent deadlock and lock poisoning on cancelled async_lock - #3872
patrickswedish wants to merge 1 commit into
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
80f6883 to
e359e9b
Compare
|
One regression-test suggestion for head That branch can be exercised deterministically by substituting a pending asyncio future for @pytest.mark.asyncio
async def test_completed_acquisition_before_cancelled_awaiter_resumes(monkeypatch):
loop = asyncio.get_running_loop()
future = loop.create_future()
submitted = asyncio.Event()
lock = threading.Lock()
entered = []
def submit(*args):
submitted.set()
return future
monkeypatch.setattr(loop, "run_in_executor", submit)
async def waiter():
async with async_lock(None, lock):
entered.append(True)
task = asyncio.create_task(waiter())
await asyncio.wait_for(submitted.wait(), timeout=1)
future.set_result(lock.acquire())
task.cancel() # No await between completing acquisition and cancelling.
with pytest.raises(asyncio.CancelledError):
await task
assert not entered
assert not lock.locked()
An isolated Python 3.12 stdlib harness using this ordering passes against this PR head. Two additional helper-level checks also pass: cancelling a waiting task preserves the current owner's lock, and a delayed acquisition releases after cancellation. Those two checks fail against current main. This is focused helper verification with controlled futures, not a full package/integration-suite run. The extra case would document the pre-yield branch without relying on timing sleeps. |
e359e9b to
f90c9ab
Compare
|
Thank you for the thoughtful analysis and suggestion, @itzKLAUS! I've added the deterministic regression test est_completed_acquisition_before_cancelled_awaiter_resumes to ests/core/caching-utils/test_async_caching.py to cleanly exercise the pre-yield \ut.done()\ branch in the cancellation handler without timing dependencies. The branch has been rebased cleanly on latest \main, squashed into a single atomic commit (\90c9ab7), and verified locally with pytest and ruff. |
…WorX#3867) When a task awaiting async_lock was cancelled during executor lock acquisition, the previous implementation entered its finally block and unconditionally released the lock if it was locked. This caused two severe bugs: 1. If another task currently owned the lock, the cancelled task released that unrelated task's lock prematurely, violating mutual exclusion. 2. The executor worker thread blocked on lock.acquire() would eventually acquire the lock once released, but since the waiting coroutine was already cancelled, the lock remained permanently held with no owner to release it, poisoning subsequent requests and leading to deadlock in HTTPSessionManager. This change: - Shields the background executor future from coroutine cancellation. - Only releases the lock in finally if this coroutine actually acquired it. - Attaches a safe done-callback to the background future if cancellation occurs before acquisition completes, ensuring that any eventual acquisition by the worker thread is immediately and safely released. - Adds comprehensive regression tests verifying normal acquisition, cancellation while waiting without releasing another owner's lock, orphaned acquisition cleanup, and multi-waiter cancellation draining. - Adds newsfragment 3867.bugfix.rst. Fixes ApeWorX#3867
f90c9ab to
0c01859
Compare
What was wrong?
Closes #3867
In
web3/_utils/async_caching.py,async_lockused:When an async request was cancelled (such as from a client-side
asyncio.wait_for(...)timeout or task cancellation) while waiting onlock.acquire:finally:was immediately triggered withacquired == False. If another concurrent task currently held the lock,if lock.locked(): lock.release()released the lock out from under the active holder, breaking mutual exclusion.thread_poolcontinued executinglock.acquire(). Once the active holder released, the orphaned thread acquired the lock, but because the coroutine had already exited, nobody would ever release it, permanently deadlockingHTTPSessionManager._lockfor the entire process.How was it fixed?
async_lockto trackacquiredstate and shield the executor future (asyncio.shield(fut)).asyncio.CancelledErrorbefore acquisition completes, attachedfut.add_done_callback(lambda _: lock.release())so any background acquisition that finishes post-cancellation immediately releases the lock.finally:, only release the lock ifacquired == True.tests/core/caching-utils/test_async_caching.pytesting standard acquisition and concurrency cancellation under lock contention.newsfragments/3867.bugfix.rst.Todo: