fix: lock Noble.setup so concurrent first requests fetch the Go-lib once

Noble.setup guarded the one-time Go shared-library fetch with a bare 'if self._updated' flag — a TOCTOU race where concurrent first requests both passed the check before either set the flag, running the download multiple times. now guarded by a per-instance asyncio.Lock with a check-lock-recheck. verified under load: 2/10/100/500 concurrent setups run the fetch exactly once each (a no-lock control runs it N times).

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
disqualifier 2026-06-28 17:46:20 -04:00
parent 7ea8ecf888
commit ae4c653ecc
2 changed files with 24 additions and 15 deletions

View File

@ -22,17 +22,17 @@ you want; importing the package never fails because an extra is missing.
`requirements.txt` (pick the extra you need): `requirements.txt` (pick the extra you need):
``` ```
aioweb_tls[curl] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb_tls.git@v0.1.0 aioweb_tls[curl] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb_tls.git@v0.1.1
aioweb_tls[noble] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb_tls.git@v0.1.0 aioweb_tls[noble] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb_tls.git@v0.1.1
aioweb_tls[all] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb_tls.git@v0.1.0 aioweb_tls[all] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb_tls.git@v0.1.1
``` ```
Direct: Direct:
```bash ```bash
pip install "aioweb_tls[curl] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb_tls.git@v0.1.0" pip install "aioweb_tls[curl] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb_tls.git@v0.1.1"
pip install "aioweb_tls[noble] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb_tls.git@v0.1.0" pip install "aioweb_tls[noble] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb_tls.git@v0.1.1"
pip install "aioweb_tls[all] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb_tls.git@v0.1.0" pip install "aioweb_tls[all] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb_tls.git@v0.1.1"
``` ```
- `[curl]` → curl_cffi backend · `[noble]` → noble_tls backend · `[all]` → both. - `[curl]` → curl_cffi backend · `[noble]` → noble_tls backend · `[all]` → both.
@ -73,8 +73,8 @@ async with TLSSession(backend=Noble(client="chrome_133")) as s:
- `Noble(client="chrome_133")` — accepts a `noble_tls.Client` enum or a string name. - `Noble(client="chrome_133")` — accepts a `noble_tls.Client` enum or a string name.
- noble_tls downloads a Go shared library on first use. `await s.setup()` fetches it - noble_tls downloads a Go shared library on first use. `await s.setup()` fetches it
once at startup; if you skip it, the first request fetches it lazily (guarded to run once at startup; if you skip it, the first request fetches it lazily. The fetch is
once). guarded by a lock, so even concurrent first requests download it exactly once.
## Writing your own backend (the `TLSBackend` protocol) ## Writing your own backend (the `TLSBackend` protocol)

View File

@ -11,6 +11,7 @@ is not installed raises a clear RuntimeError naming the extra to install. import
this module never fails because an extra is missing. this module never fails because an extra is missing.
""" """
import asyncio
import logging import logging
import math import math
@ -136,6 +137,7 @@ class Noble:
) from _NOBLE_ERROR ) from _NOBLE_ERROR
self.client = self._resolve_client(client) self.client = self._resolve_client(client)
self._updated = False self._updated = False
self._setup_lock = asyncio.Lock()
@staticmethod @staticmethod
def _resolve_client(client): def _resolve_client(client):
@ -145,20 +147,27 @@ class Noble:
return client return client
async def setup(self) -> None: async def setup(self) -> None:
"""fetch the noble_tls Go shared library once; idempotent """fetch the noble_tls Go shared library once; idempotent and concurrency-safe
download_if_necessary handles the first-time fetch (no lib present); download_if_necessary handles the first-time fetch (no lib present);
update_if_necessary refreshes an existing one. try download first so a update_if_necessary refreshes an existing one. try download first so a
clean environment works, falling back to update. clean environment works, falling back to update.
guarded by an asyncio.Lock with a check-lock-recheck so concurrent first
requests don't both run the fetch: the fast path returns once _updated is
set, and only the first caller through the lock does the work.
""" """
if self._updated: if self._updated:
return return
download = getattr(noble_tls, "download_if_necessary", None) async with self._setup_lock:
if download is not None: if self._updated:
await download() return
else: download = getattr(noble_tls, "download_if_necessary", None)
await noble_tls.update_if_necessary() if download is not None:
self._updated = True await download()
else:
await noble_tls.update_if_necessary()
self._updated = True
def create_session(self, headers, timeout, **kwargs): def create_session(self, headers, timeout, **kwargs):
"""build the noble_tls Session""" """build the noble_tls Session"""