Repository: ethereum/web3.py

Issue:
`web3._utils.request.async_lock` sometimes incorrectly handles the cancellation of the task it is running in
* Version: 6.0.0b7
* Python: 3.9.13
* OS: linux
* `pip freeze` output

```
aiohttp==3.7.4.post0
aiostream @ file:///home/conda/feedstock_root/build_artifacts/aiostream_1639516509527/work
alabaster==0.7.12
anyio @ file:///home/conda/feedstock_root/build_artifacts/anyio_1652463862396/work/dist
asttokens @ file:///opt/conda/conda-bld/asttokens_1646925590279/work
async-exit-stack @ file:///home/conda/feedstock_root/build_artifacts/async-exit-stack_1617751130790/work
async-timeout==3.0.1
attrs @ file:///home/conda/feedstock_root/build_artifacts/attrs_1640799537051/work
aws-request-signer==1.1.1
Babel==2.10.3
backcall @ file:///home/ktietz/src/ci/backcall_1611930011877/work
backoff @ file:///home/conda/feedstock_root/build_artifacts/backoff_1654704012154/work
base58==2.1.1
beautifulsoup4==4.11.1
bitarray==2.6.0
bleach==5.0.1
boto3 @ file:///home/conda/feedstock_root/build_artifacts/boto3_1657783699883/work
botocore @ file:///home/conda/feedstock_root/build_artifacts/botocore_1657756950790/work
brotlipy @ file:///home/conda/feedstock_root/build_artifacts/brotlipy_1648854164373/work
cached-property==1.5.2
certifi==2022.9.24
cffi @ file:///opt/conda/conda-bld/cffi_1642701102775/work
chardet==4.0.0
charset-normalizer==2.1.0
coverage @ file:///home/conda/feedstock_root/build_artifacts/coverage_1657651766207/work
cryptography @ file:///tmp/build/80754af9/cryptography_1652101588893/work
cycler @ file:///tmp/build/80754af9/cycler_1637851556182/work
cytoolz==0.12.0
debugpy @ file:///tmp/build/80754af9/debugpy_1637091799509/work
decorator @ file:///opt/conda/conda-bld/decorator_1643638310831/work
defusedxml==0.7.1
discord==1.7.3
discord.py==2.0.1
docutils==0.18.1
entrypoints @ file:///tmp/build/80754af9/entrypoints_1649926439650/work
eth-abi==3.0.1
eth-account==0.7.0
eth-bloom==1.0.4
eth-hash==0.3.3
eth-keyfile==0.6.0
eth-keys==0.4.0
eth-rlp==0.3.0
eth-tester==0.7.0b1
eth-typing==3.1.0
eth-utils==2.0.0
executing @ file:///opt/conda/conda-bld/executing_1646925071911/work
fastjsonschema==2.16.1
fonttools==4.25.0
futureproof==0.3.1
h11 @ file:///home/conda/feedstock_root/build_artifacts/h11_1609518832927/work
h2 @ file:///home/conda/feedstock_root/build_artifacts/h2_1633502706969/work
hexbytes==0.2.2
hpack==4.0.0
httpcore @ file:///home/conda/feedstock_root/build_artifacts/httpcore_1652804258690/work
httpx @ file:///home/conda/feedstock_root/build_artifacts/httpx_1653493815085/work
hyperframe @ file:///home/conda/feedstock_root/build_artifacts/hyperframe_1619110129307/work
idna @ file:///home/conda/feedstock_root/build_artifacts/idna_1642433548627/work
imagesize==1.4.1
importlib-metadata==4.12.0
iniconfig @ file:///home/conda/feedstock_root/build_artifacts/iniconfig_1603384189793/work
ipfshttpclient==0.8.0a2
ipykernel @ file:///tmp/build/80754af9/ipykernel_1647000773790/work/dist/ipykernel-6.9.1-py3-none-any.whl
ipython @ file:///opt/conda/conda-bld/ipython_1657652213665/work
jedi @ file:///tmp/build/80754af9/jedi_1644297102865/work
Jinja2==3.1.2
jmespath @ file:///home/conda/feedstock_root/build_artifacts/jmespath_1655568249366/work
jsonschema==4.7.2
jupyter-client @ file:///opt/conda/conda-bld/jupyter_client_1650622202839/work
jupyter-core @ file:///opt/conda/conda-bld/jupyter_core_1651671229925/work
jupyterlab-pygments==0.2.2
kiwisolver @ file:///opt/conda/conda-bld/kiwisolver_1653292039266/work
loguru @ file:///home/conda/feedstock_root/build_artifacts/loguru_1649442973497/work
lru-dict==1.1.8
MarkupSafe==2.1.1
matplotlib @ file:///tmp/build/80754af9/matplotlib-suite_1647441664166/work
matplotlib-inline @ file:///tmp/build/80754af9/matplotlib-inline_1628242447089/work
mistune==0.8.4
multiaddr==0.0.9
multidict==6.0.2
munkres==1.1.4
mypy-extensions==0.4.3
nbclient==0.6.6
nbconvert==6.5.0
nbformat==5.4.0
nbsphinx==0.8.9
nest-asyncio @ file:///tmp/build/80754af9/nest-asyncio_1649847906199/work
netaddr==0.8.0
numpy @ file:///home/conda/feedstock_root/build_artifacts/numpy_1657483874185/work
packaging @ file:///home/conda/feedstock_root/build_artifacts/packaging_1637239678211/work
pandas==1.4.3
pandocfilters==1.5.0
parsimonious==0.8.1
parso @ file:///opt/conda/conda-bld/parso_1641458642106/work
pexpect @ file:///tmp/build/80754af9/pexpect_1605563209008/work
pickleshare @ file:///tmp/build/80754af9/pickleshare_1606932040724/work
Pillow==9.2.0
pluggy @ file:///home/conda/feedstock_root/build_artifacts/pluggy_1648772597647/work
prompt-toolkit @ file:///tmp/build/80754af9/prompt-toolkit_1633440160888/work
protobuf==4.21.8
psutil==5.9.1
ptyprocess @ file:///tmp/build/80754af9/ptyprocess_1609355006118/work/dist/ptyprocess-0.7.0-py2.py3-none-any.whl
pure-eval @ file:///opt/conda/conda-bld/pure_eval_1646925070566/work
py @ file:///home/conda/feedstock_root/build_artifacts/py_1636301881863/work
py-ecc==6.0.0
py-evm==0.6.0a1
py-geth==3.9.1
pycparser @ file:///home/conda/feedstock_root/build_artifacts/pycparser_1636257122734/work
pycryptodome==3.15.0
pydantic @ file:///home/conda/feedstock_root/build_artifacts/pydantic_1653020746401/work
pyethash==0.1.27
Pygments @ file:///opt/conda/conda-bld/pygments_1644249106324/work
pyOpenSSL @ file:///home/conda/feedstock_root/build_artifacts/pyopenssl_1643496850550/work
pyparsing @ file:///home/conda/feedstock_root/build_artifacts/pyparsing_1652235407899/work
PyQt5==5.12.3
PyQt5_sip==4.19.18
PyQtChart==5.12
PyQtWebEngine==5.12.1
pyrsistent==0.18.1
pysha3==1.0.2
PySocks @ file:///home/conda/feedstock_root/build_artifacts/pysocks_1648857263093/work
pytest==7.1.2
pytest-asyncio @ file:///home/conda/feedstock_root/build_artifacts/pytest-asyncio_1657894229401/work/dist
pytest-cov @ file:///home/conda/feedstock_root/build_artifacts/pytest-cov_1633356845954/work
python-dateutil @ file:///home/conda/feedstock_root/build_artifacts/python-dateutil_1626286286081/work
pytz @ file:///home/conda/feedstock_root/build_artifacts/pytz_1647961439546/work
pyzmq @ file:///opt/conda/conda-bld/pyzmq_1657724186960/work
requests==2.28.1
rfc3986 @ file:///home/conda/feedstock_root/build_artifacts/rfc3986_1620442452971/work
rlp==3.0.0
s3transfer @ file:///home/conda/feedstock_root/build_artifacts/s3transfer_1654039987929/work
scipy==1.9.1
semantic-version==2.10.0
sip==4.19.13
six @ file:///home/conda/feedstock_root/build_artifacts/six_1620240208055/work
sniffio @ file:///home/conda/feedstock_root/build_artifacts/sniffio_1648819188507/work
snowballstemmer==2.2.0
sortedcontainers==2.4.0
soupsieve==2.3.2.post1
Sphinx==5.0.2
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.0
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
stack-data @ file:///opt/conda/conda-bld/stack_data_1646927590127/work
tinycss2==1.1.1
toml @ file:///home/conda/feedstock_root/build_artifacts/toml_1604308577558/work
tomli @ file:///home/conda/feedstock_root/build_artifacts/tomli_1644342247877/work
toolz==0.12.0
tornado @ file:///tmp/build/80754af9/tornado_1606942317143/work
traitlets==5.3.0
trie==2.0.1
typing-extensions==3.10.0.2
ujson==5.4.0
urllib3 @ file:///home/conda/feedstock_root/build_artifacts/urllib3_1657224465922/work
varint==1.0.2
wcwidth @ file:///Users/ktietz/demo/mc3/conda-bld/wcwidth_1629357192024/work
web3==6.0.0b7
webencodings==0.5.1
websockets==10.3
wrapt @ file:///home/conda/feedstock_root/build_artifacts/wrapt_1651495236231/work
yarl==1.7.2
zipp==3.8.1
```


### What was wrong?

Consider a use of the [`web3._utils.request.async_lock`](https://github.com/ethereum/web3.py/blob/a59bcb3fb2ae3dc3d41447ab657fd397e2a98ea8/web3/_utils/request.py#L196-L203) async context manager inside  inside of an `asyncio.Task`. If that `asyncio.Task` is cancelled while `await loop.run_in_executor(_pool, lock.acquire)`  is running, then the lock will be acquired and the `asyncio.CancelledError` will be thrown **before** the subsequent `try/finally` statement can be run. As a result, the lock will never be released by the task, and all future uses of `async_lock` will hang.

This has happened to me in practice when managing many `asyncio.Task`s with different `Web3` instances using different `AsyncHTTPProvider`s which can be cancelled at any time. When this particular cancellation happens, my application hangs.

#### Reproducible Example

```python
from web3._utils.request import _async_session_cache, _async_session_cache_lock, async_lock, _pool
import asyncio


async def do_things_with_async_lock(name):
    print(name, "waiting to acquire lock")
    async with async_lock(_async_session_cache_lock):
        print(name, "acquired lock; doing some things")
        await asyncio.sleep(1)
        print(name, "finished doing things; about to release")
    print(name, "released lock")

outer = asyncio.create_task(do_things_with_async_lock("outer"))

await asyncio.sleep(0.1) # Let outer acquire lock

print("outer should have just acquired the lock")

inner = asyncio.create_task(do_things_with_async_lock("inner"))

await asyncio.sleep(0.1) # Ensure inner is waiting to acquire lock

inner.cancel() # CANCEL inner task. This should prevent the lock from ever being acquired by the inner task.
print("cancelled inner task while it was still waiting to acquire the lock")

await outer

print("lock should be unlocked. locked?", _async_session_cache_lock.locked()) # Sometimes False and sometimes True (depending upon thread execution order)

await asyncio.sleep(0.1)

print("lock should be unlocked. locked?", _async_session_cache_lock.locked()) # True. WTF?

print("since the lock is locked, later calls will hang while waiting to acquire the lock")

await do_things_with_async_lock("later") # This hangs, which means that even though the inner task was cancelled, it still managed to acquire the lock
```

### How can it be fixed?

Fortunately, the fix is easy. Just move the offending line into the `try` block. That way, if an `asyncio.CancelledError` hits, the `finally` statement will release the lock. Testing this solution on the above reproducible example works as expected.

```python
@contextlib.asynccontextmanager
async def async_lock(lock: threading.Lock) -> AsyncGenerator[None, None]:
    loop = asyncio.get_event_loop()
    try:
        await loop.run_in_executor(_pool, lock.acquire)
        yield
    finally:
        lock.release()
```


Discussion:

Resolving patch:
diff --git a/web3/_utils/request.py b/web3/_utils/request.py
--- a/web3/_utils/request.py
+++ b/web3/_utils/request.py
@@ -196,8 +196,8 @@ async def cache_and_return_async_session(
 @contextlib.asynccontextmanager
 async def async_lock(lock: threading.Lock) -> AsyncGenerator[None, None]:
     loop = asyncio.get_event_loop()
-    await loop.run_in_executor(_pool, lock.acquire)
     try:
+        await loop.run_in_executor(_pool, lock.acquire)
         yield
     finally:
         lock.release()


Test patch:
diff --git a/tests/core/utilities/test_request.py b/tests/core/utilities/test_request.py
--- a/tests/core/utilities/test_request.py
+++ b/tests/core/utilities/test_request.py
@@ -29,6 +29,8 @@
 )
 from web3._utils.request import (
     SessionCache,
+    _async_session_cache_lock,
+    async_lock,
     cache_and_return_async_session,
     cache_and_return_session,
 )
@@ -323,3 +325,23 @@ def target_function(endpoint_uri):
 
     # clear cache
     request._async_session_cache.clear()
+
+
+@pytest.mark.asyncio
+async def test_async_lock_releases_if_a_task_is_cancelled():
+    # inspired by issue #2693
+    # Note: this test will raise a `TimeoutError` if `request.async_lock` is not
+    # applied correctly
+
+    async def _utilize_async_lock():
+        async with async_lock(_async_session_cache_lock):
+            await asyncio.sleep(0.2)
+
+    asyncio.create_task(_utilize_async_lock())
+
+    inner = asyncio.create_task(_utilize_async_lock())
+    await asyncio.sleep(0.1)
+    inner.cancel()
+
+    outer = asyncio.wait_for(_utilize_async_lock(), 2)
+    await outer