Summary
sb_cdp.get_rd_url() calls nest_asyncio.apply() as a side effect. On Python 3.14 that patch permanently breaks asyncio.current_task() for the whole process, which in turn breaks anyio and sniffio — so any library built on them stops working after a single call to sb.cdp.get_endpoint_url() / get_rd_url().
The patch itself is unchanged and correct-looking; what changed is CPython. This is not a regression in SeleniumBase, but the blast radius is large and the symptom points nowhere near the cause.
Environment
- Python 3.14.6 (CPython, uv-managed)
- SeleniumBase 4.51.8 (verified still present on
master and in 4.51.9)
- Linux
Reproduction — no browser needed
import asyncio, sys
import seleniumbase
from seleniumbase.core import nest_asyncio
async def probe():
return asyncio.current_task()
print("python :", sys.version.split()[0])
print("seleniumbase :", seleniumbase.__version__)
print("before apply :", asyncio.run(probe()) is not None)
nest_asyncio.apply()
print("after apply :", asyncio.run(probe()) is not None)
Output:
python : 3.14.6
seleniumbase : 4.51.8
before apply : True
after apply : False
asyncio.current_task() returns None from inside a running task, forever after, on every subsequent event loop in that process.
Downstream effect — anyio can no longer detect the event loop
import asyncio
import anyio
from seleniumbase.core import nest_asyncio
nest_asyncio.apply()
async def main():
async with anyio.create_task_group():
pass
asyncio.run(main())
anyio.NoEventLoopError: Not currently running on any asynchronous event loop.
Available async backends: asyncio, trio
The chain is anyio.get_async_backend() → sniffio.current_async_library() → asyncio.current_task(). With current_task() blind, sniffio raises AsyncLibraryNotFoundError and anyio concludes there is no loop:
sniffio before apply: asyncio
sniffio after apply: AsyncLibraryNotFoundError: unknown async library, or not in async context
Root cause
_patch_asyncio() in seleniumbase/core/nest_asyncio.py replaces the C implementations with the pure-Python ones:
asyncio.tasks.Task = asyncio.tasks._PyTask # line 113
asyncio.Task = asyncio.tasks._CTask = asyncio.tasks.Task
asyncio.Future = asyncio.futures._CFuture = asyncio.futures.Future = asyncio.futures._PyFuture
Through Python 3.13 this was safe: the C current_task() read the module-level _current_tasks dict, which _PyTask maintains, so both implementations agreed.
Python 3.14 moved current-task tracking onto the thread state (CPython gh-107803):
typedef struct PyThreadState {
PyObject *asyncio_current_loop;
PyObject *asyncio_current_task;
} PyThreadState;
Only the C Task writes that field. After the patch, tasks are _PyTask, which still updates the old _current_tasks dict, while asyncio.current_task() reads the thread state and finds nothing. Both halves are individually consistent and disagree with each other — _current_tasks is populated and current_task() returns None at the same moment.
How it is reached in normal use
get_rd_url() is the only nest_asyncio.apply() call site in the codebase (sb_cdp.py:253-254), and it is reached by the documented public API:
sb.cdp.get_rd_url()
sb.cdp.get_endpoint_url() → get_rd_url()
Both are listed in CDP Mode APIs with no indication they mutate global asyncio state. get_rd_host() and get_rd_port() (sb_cdp.py:229,236) return the same information and are unaffected. The Browser-level get_rd_url() in undetected/cdp_driver/browser.py:298 is also unaffected — only the CDPMethods one applies the patch.
Note that browser_launcher.py rebinds cdp.get_rd_url/cdp.get_endpoint_url onto the CDPMethods versions, so UC Mode reaches the same hazard.
Why CI does not catch this
The nightly matrix runs 3.14 and is green. Nothing in the example suites calls get_rd_url() and then uses a second event loop in the same process, which is the only way the breakage becomes visible. A test that calls get_endpoint_url() and later does anything anyio-based would fail immediately.
Impact
Any process that combines SeleniumBase CDP Mode with an anyio-based library — mcp, httpx, FastAPI/Starlette, trio-compatible code — breaks after the first get_endpoint_url() call, on Python 3.14 only.
The failure is also badly misleading. In our case it surfaced as a 30-second connection timeout reported as a transport failure, ~1000 lines away from the cause, because the broken current_task() made anyio's CancelScope.__enter__ die on _task_states[None] (TypeError: cannot create weak reference to 'NoneType') inside a task-group startup, which then simply never completed.
Possible fixes
Roughly in order of how much they change:
- Make
apply() opt-in for this path. The docstring says the patch exists for the Playwright integration; callers who just want the URL do not need it. A get_rd_url(apply_nest_asyncio=True) default-off — or having get_endpoint_url() skip the patch — would remove the surprise for everyone else.
- Skip the patch on 3.14+, where it cannot work correctly in this form, and raise or warn if the Playwright path genuinely needs it there.
- Make the vendored
nest_asyncio 3.14-aware. It already has sys.version_info < (3, 14, 0) branches, so the intent is there; the _PyTask swap at line 113 is the part that no longer holds. Upstream nest_asyncio is archived (last push 2024-01-31), so this would be SeleniumBase's own to carry.
- Document it at minimum, so the global side effect is discoverable from the API docs.
Workaround for anyone hitting this
Compose the endpoint from the two accessors that do not apply the patch:
endpoint = f"http://{sb.cdp.get_rd_host()}:{sb.cdp.get_rd_port()}"
Byte-identical to what get_rd_url() returns. One caveat worth knowing: get_rd_url() also sets os.environ["NODE_NO_WARNINGS"] = "1" (sb_cdp.py:255), so anything relying on that suppression should set it explicitly.
Happy to open a PR for whichever direction you prefer.
Summary
sb_cdp.get_rd_url()callsnest_asyncio.apply()as a side effect. On Python 3.14 that patch permanently breaksasyncio.current_task()for the whole process, which in turn breaks anyio and sniffio — so any library built on them stops working after a single call tosb.cdp.get_endpoint_url()/get_rd_url().The patch itself is unchanged and correct-looking; what changed is CPython. This is not a regression in SeleniumBase, but the blast radius is large and the symptom points nowhere near the cause.
Environment
masterand in 4.51.9)Reproduction — no browser needed
Output:
asyncio.current_task()returnsNonefrom inside a running task, forever after, on every subsequent event loop in that process.Downstream effect — anyio can no longer detect the event loop
The chain is
anyio.get_async_backend()→sniffio.current_async_library()→asyncio.current_task(). Withcurrent_task()blind, sniffio raisesAsyncLibraryNotFoundErrorand anyio concludes there is no loop:Root cause
_patch_asyncio()inseleniumbase/core/nest_asyncio.pyreplaces the C implementations with the pure-Python ones:Through Python 3.13 this was safe: the C
current_task()read the module-level_current_tasksdict, which_PyTaskmaintains, so both implementations agreed.Python 3.14 moved current-task tracking onto the thread state (CPython gh-107803):
Only the C
Taskwrites that field. After the patch, tasks are_PyTask, which still updates the old_current_tasksdict, whileasyncio.current_task()reads the thread state and finds nothing. Both halves are individually consistent and disagree with each other —_current_tasksis populated andcurrent_task()returnsNoneat the same moment.How it is reached in normal use
get_rd_url()is the onlynest_asyncio.apply()call site in the codebase (sb_cdp.py:253-254), and it is reached by the documented public API:sb.cdp.get_rd_url()sb.cdp.get_endpoint_url()→get_rd_url()Both are listed in CDP Mode APIs with no indication they mutate global asyncio state.
get_rd_host()andget_rd_port()(sb_cdp.py:229,236) return the same information and are unaffected. TheBrowser-levelget_rd_url()inundetected/cdp_driver/browser.py:298is also unaffected — only theCDPMethodsone applies the patch.Note that
browser_launcher.pyrebindscdp.get_rd_url/cdp.get_endpoint_urlonto theCDPMethodsversions, so UC Mode reaches the same hazard.Why CI does not catch this
The nightly matrix runs 3.14 and is green. Nothing in the example suites calls
get_rd_url()and then uses a second event loop in the same process, which is the only way the breakage becomes visible. A test that callsget_endpoint_url()and later does anything anyio-based would fail immediately.Impact
Any process that combines SeleniumBase CDP Mode with an anyio-based library —
mcp,httpx, FastAPI/Starlette,trio-compatible code — breaks after the firstget_endpoint_url()call, on Python 3.14 only.The failure is also badly misleading. In our case it surfaced as a 30-second connection timeout reported as a transport failure, ~1000 lines away from the cause, because the broken
current_task()made anyio'sCancelScope.__enter__die on_task_states[None](TypeError: cannot create weak reference to 'NoneType') inside a task-group startup, which then simply never completed.Possible fixes
Roughly in order of how much they change:
apply()opt-in for this path. The docstring says the patch exists for the Playwright integration; callers who just want the URL do not need it. Aget_rd_url(apply_nest_asyncio=True)default-off — or havingget_endpoint_url()skip the patch — would remove the surprise for everyone else.nest_asyncio3.14-aware. It already hassys.version_info < (3, 14, 0)branches, so the intent is there; the_PyTaskswap at line 113 is the part that no longer holds. Upstreamnest_asynciois archived (last push 2024-01-31), so this would be SeleniumBase's own to carry.Workaround for anyone hitting this
Compose the endpoint from the two accessors that do not apply the patch:
Byte-identical to what
get_rd_url()returns. One caveat worth knowing:get_rd_url()also setsos.environ["NODE_NO_WARNINGS"] = "1"(sb_cdp.py:255), so anything relying on that suppression should set it explicitly.Happy to open a PR for whichever direction you prefer.