fix(cli,session,sync): remove nest_asyncio to enable Python 3.14 support - #2953
fix(cli,session,sync): remove nest_asyncio to enable Python 3.14 support#2953pidefrem wants to merge 12 commits into
Conversation
There was a problem hiding this comment.
1 issue found across 3 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
|
This PR is stale because it has been open for 14 days with no activity. |
|
Not stale — this PR is active. I just pushed It's been waiting on a maintainer review. Could someone take a look when they have a chance? Happy to rebase or address feedback. Thanks! 🙏 |
dokterbob
left a comment
There was a problem hiding this comment.
Thanks! Some much welcomed cleanup/fixup.
|
This PR is stale because it has been open for 14 days with no activity. |
Head branch was pushed to by a user without write access
6aec8da to
dbda012
Compare
nest_asyncio.apply() was called at import time in chainlit/cli/__init__.py. nest_asyncio <= 1.6.0 internally calls asyncio.ensure_future(future, loop=self), where the loop= keyword was removed in Python 3.14 (bpo-39529). This corrupts asyncio task registration, causing asyncio.current_task() to return None inside running coroutines — which surfaced as a white page (HTTP 500 on every static asset) on every Chainlit app running on Python 3.14. asyncio.run(start()) is a top-level entry point and does not need re-entrant loop support, so nest_asyncio is removed entirely, along with the nest-asyncio dependency and its mypy override. A regression test guards against silent reintroduction. Refs Chainlit#2767
WebsocketSession.get_config() called asyncio.get_event_loop().run_until_complete() from within the already-running event loop (the SocketIO connect handler). This only worked because nest_asyncio patched run_until_complete to be re-entrant; removing nest_asyncio would silently break chat-profile config overrides on all Python versions. get_config() now returns the cached config immediately, and a new async resolve_config() properly awaits set_chat_profiles(). The connect() handler calls await session.resolve_config() after construction. Adds hatchling to the mypy optional deps (it ships py.typed; fixes a pre-existing import-not-found error in build.py). New tests cover overrides, idempotency, error handling, unknown profiles, and a regression test verifying run_until_complete is never called.
Kill the entire Chainlit process group (uv -> chainlit -> uvicorn) between specs and poll until the port is free, resolving an EADDRINUSE race in waitForPortFree. Exclude .pytest_cache from prettier checks. Also ensure the Cypress binary is present before tests run (a restored-but- incomplete cache, seen on windows-latest, leaves the npm package installed while the binary is missing) and set fail-fast: false so one shard's flake no longer cancels the rest of the matrix.
dbda012 to
42058b9
Compare
run_sync() from the main thread needs a reentrant event loop, which this branch's removal of nest_asyncio broke on every Python version, not only 3.14: syncer.sync() now raises "This event loop is already running" on 3.10-3.13 as well. nest_asyncio.apply() is not a safe fix for this, on any version: it globally rebinds asyncio.Task/Future to their pure-Python implementations while asyncio.current_task stays bound to the C accelerator, so current_task() returns None inside running coroutines and anyio raises NoEventLoopError on every request. That is the bug this PR set out to fix. Restore reentrancy without that rebind: - run_sync() calls nest_asyncio._patch_loop(loop) directly (scoped to the running loop's class, not nest_asyncio.apply()) on Python <3.14, where it is anyio-safe. - On Python >=3.14 no anyio-safe reentrant mechanism exists (the C task registry rejects same-thread reentrancy independently of nest_asyncio), so run_sync() raises an actionable RuntimeError pointing at cl.make_async() instead of hanging or raising a confusing error. - nest-asyncio is restored as a dependency, gated `python_version < '3.14'`. - Corrected the incorrect bpo-39529/"loop= removed" rationale in cli/__init__.py and test_cli.py to the actual mechanism above. Adds backend/tests/test_sync.py covering both run_sync() dispatch branches, patch idempotency, and the Python 3.14+ error path.
Head branch was pushed to by a user without write access
There was a problem hiding this comment.
All reported issues were addressed across 7 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
cl.run_sync() called from the main thread must re-enter the running event loop. It did so through nest_asyncio, whose apply() rebinds asyncio.Task and asyncio.Future to the pure Python classes while asyncio.current_task stays bound to the C accelerator; current_task() then returns None inside running coroutines and anyio, which weak-references it, raises NoEventLoopError. nest_asyncio needs to own the scheduler because it suspends the current task around every individual callback, which forces it to reimplement _run_once and to patch loop.__class__. run_sync only needs the task suspended around one nested run: chainlit/_reentrant_loop.py suspends it once, drives the stdlib _run_once until the coroutine's future completes, and restores it. No asyncio global, class or loop instance is mutated. The suspension primitive is version-gated because Python 3.14 moved current-task tracking off asyncio.tasks._current_tasks and into the thread state, which is what wedged nest_asyncio there: _asyncio._swap_current_task on 3.12+, the _current_tasks mapping on 3.10-3.11. A contract test asserts each private API relied on exists, so a future Python removing one fails loudly. Verified on 3.10, 3.11, 3.12, 3.13 and 3.14; the CI matrix exercises both branches of the gate. Fixes Chainlit#2767
There was a problem hiding this comment.
All reported issues were addressed across 9 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
test_run_sync_without_a_running_loop ended its finally with asyncio.set_event_loop(None), leaving the thread with no ambient loop. The suite passed only because test_session sorts before test_sync under alphabetical collection; running the two files in the other order failed with "RuntimeError: There is no current event loop in thread 'MainThread'". Restore the thread's loop instead. Two assertions did not test what they claimed: - threading.current_thread() is not threading.main_thread was missing its call parens, comparing a Thread to a bound method, so it held unconditionally. It also ran on the main thread, where the claim is meaningless. Moved into worker() and given the parens. - test_get_config_does_not_use_run_until_complete patched run_until_complete on the ambient loop to assert that get_config(), whose body is `return self.config`, never calls it. Removed; it was also the only bare asyncio.get_event_loop() caller in the suite. Deduplicate the rest: - The asyncio.Task is _asyncio.Task invariant was asserted in three files. Keep it on the primitive that does the work (test_reentrant_loop) and on chainlit.cli import, which asserts the distinct import-time property; drop test_sync's, since run_sync only delegates. - Merge the two private-API tests into one, keeping the _run_once/_stopping existence checks alongside the stronger assertion that the current-task store is the one asyncio.current_task() actually reads. - Drop test_run_sync_from_main_thread_is_repeatable and test_anyio_task_group_still_works_in_the_outer_task_afterwards, both recombinations of cases already covered.
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
BaseEventLoop._run_once snapshots ntodo = len(self._ready) and then calls popleft() exactly that many times. run_coroutine_reentrant is driven from inside one of those callbacks, and the nested _run_once it calls drains the entries the enclosing iteration is still counting on, so the next enclosing popleft() raises "IndexError: pop from an empty deque" and kills the loop. Two callbacks queued back to back, the first calling cl.run_sync(), is enough to trigger it. nest_asyncio guards its own reimplemented _run_once with "if not ready: break", which works only because it also patches the enclosing loop. Nothing is patched here, so the nested run instead appends one cancelled handle per entry it borrowed; _run_once pops those and skips them. Padding the queue rather than swapping an empty one in for the duration is deliberate: hiding the borrowed callbacks strands any nested coroutine waiting on work they complete. Isolating the queue deadlocks the existing suite. Also address two test precision issues: - test_cli asserted asyncio.Task.__module__ == "_asyncio", which conflates "the C accelerator exists" with "nothing rebound asyncio.Task", and fails on an interpreter built without _asyncio for reasons unrelated to the regression it guards. Compare against the class itself, behind importorskip, matching the identity check test_reentrant_loop already uses. - test_run_sync_without_a_running_loop restored context_var with set(None). It is a sync test, so the ambient context var survives it, and get_context() catches only LookupError -- a None leak makes later tests receive None where they should get ChainlitContextException. Use reset(token).
Reinstates test_anyio_task_group_still_works_in_the_outer_task_afterwards, dropped as redundant with test_outer_task_is_restored_afterwards. The identity assertion covers asyncio's current-task store; this one covers anyio's own per-task state, which is keyed on a WeakKeyDictionary of host tasks and is the level at which the bug this module fixes actually surfaced. Also narrows test_nothing_in_asyncio_is_globally_patched's docstring: the ready-queue padding does mutate the loop instance for the duration of a nested run, so the blanket 'no loop instance' claim no longer held.
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
The ready-queue length was measured after ensure_future, which schedules the nested task's first step with call_soon. That handle is appended after the enclosing _run_once took its ntodo snapshot, so it is not owed back, and counting it left one cancelled handle behind on every call. Measure before ensure_future instead. The count remains an upper bound rather than an exact one, since handles queued by earlier callbacks in the same iteration are also outside ntodo; over-repaying is harmless where under-repaying raises.
Problem
Chainlit does not run on Python 3.14. Every request fails with
anyio.NoEventLoopErrorand the UI renders a white page.The cause is
nest_asyncio.apply(), called at CLI startup to makeloop.run_until_complete()reentrant.apply()swaps the global task and futureclasses to their pure-Python implementations, while
asyncio.current_taskremainsbound to the C accelerator, which reads a registry the pure-Python
Taskneverpopulates:
asyncio.current_task()returnsNoneinside a running coroutine.anyioindexes_task_states[host_task]— aWeakKeyDictionary— with thatNone:This surfaces as
anyio.NoEventLoopErroron every request, including static assets.asyncio.ensure_future(..., loop=...)is not implicated: on 3.14.1 the signature isstill
(coro_or_future, *, loop=None), the call succeeds, andnest_asyncio.apply()does not raise.
Fixes #2767.
Solution
Remove
nest_asyncioentirely, and eliminate the reentrancy it was compensating forwherever the framework itself needed it.
1. Stop patching the loop globally at CLI startup — the top-level
asyncio.run(start())inchainlit/cli/__init__.pynever needed a reentrant loop, sothe import and the
apply()call are removed from there.2. Run reentrantly without patching anything — where a coroutine genuinely does need
to run reentrantly (
cl.run_sync()called from the main thread while the loop is alreadyrunning),
chainlit/sync.pynow drives the loop directly through a newchainlit/_reentrant_loop.py, instead of patching it:asyncio refuses to enter a task while another is executing, so the outer task is
suspended for the duration of the nested run and restored afterwards — the same technique
nest_asynciouses, applied once around the whole nested run rather than around everyindividual callback. Because the suspension is that coarse, the stdlib
_run_oncecan becalled as-is, and none of
nest_asyncio's scheduler reimplementation,run_forever,_check_runningorloop.__class__patching is needed. No asyncio global or class isrebound. In particular
asyncio.Taskandasyncio.Futurekeep their C implementations,so
current_task()stays correct andanyiois unaffected — that rebind is preciselywhat
apply()did and what broke 3.14 (see Problem).Calling the stdlib
_run_onceas-is has one consequence to repay. It snapshotsntodo = len(self._ready)and then pops exactly that many handles, so a nested runstarted from inside one of those callbacks drains entries the enclosing iteration is still
counting on, and its next
popleft()raisesIndexError: pop from an empty deque. Twocallbacks queued back to back, the first calling
cl.run_sync(), is enough to trigger it.nest_asyncioguards its own reimplemented_run_oncewithif not ready: break, whichworks only because it patches the enclosing loop too. Since nothing is patched here, the
nested run instead appends one cancelled handle per entry it borrowed;
_run_oncepopsthose and skips them. The queue is padded rather than swapped out for the duration so the
borrowed callbacks still run during the nested run — withholding them deadlocks any nested
coroutine waiting on work they complete.
The task is suspended through
_asyncio._swap_current_task()on Python 3.12+ and throughthe
asyncio.tasks._current_tasksdict on 3.10–3.11, which is where the current task isactually tracked on each. Both branches are covered by the existing CI matrix
(
3.10–3.13). This is whynest_asyncioitself fails on 3.14: it only ever clears thedict, and 3.14 moved current-task tracking into the thread state, so its own suspension
became a silent no-op.
nest-asynciois removed frombackend/pyproject.tomland from the rootpyproject.tomlmypy overrides.3. Make chat-profile config resolution async-safe —
WebsocketSession.get_config()called
loop.run_until_complete()from inside the running loop, which was the primaryreason reentrancy was required in the framework's own code. It is now an
async def resolve_config()awaited from the asyncconnect()handler insocket.py.get_config()remains as a non-resolving accessor, so existing callers are unaffected.No reentrant
run_until_complete()calls remain elsewhere inbackend/chainlit/.Compatibility
No breaking change.
cl.run_sync()behaves as it always has on every supported version:async@cl.on_messagecalling a synchelper that calls
cl.run_sync(...). Works on 3.10 through 3.14, now withoutnest_asyncio.cl.make_async(fn)(). Unchanged on every version; thatpath uses
asyncio.run_coroutine_threadsafe()and never depended onnest_asyncio.syncer.sync()drives the loop itself.requires-pythonis unchanged at>=3.10,<3.14.0. This PR removes the blocker to 3.14but does not itself declare 3.14 support, which needs a broader test pass first.
chainlit/_reentrant_loop.pyrelies on five private asyncio APIs (loop._run_once,loop._stopping,loop._ready,_asyncio._swap_current_task,asyncio.tasks._current_tasks). Each isdocumented in the module, and a contract test asserts they exist and still behave as the
module assumes on the running interpreter, so a future Python changing one fails loudly
rather than silently degrading — which is exactly how
nest_asynciobroke on 3.14.Testing
backend/tests/test_reentrant_loop.pycovers the nested run in isolation: return valueand exception propagation,
current_task()correct during the nested run and restoredafter it, unrelated tasks continuing to progress while it runs, an
anyiotask grouprunning inside the nested coroutine (the failure this PR fixes),
asyncio.Taskremaining the C implementation, an
anyiotask group still working in the outer taskafter the nested run returns, sibling callbacks ready in the same scheduler iteration
surviving the nested run rather than underflowing the enclosing
_run_once, and acontract test over the private APIs the module relies on — asserting not merely that the
current-task store exists but that it is the one
asyncio.current_task()actuallyconsults.
backend/tests/test_session.pycovers the new async config resolution path.backend/tests/test_sync.pycovers bothrun_sync()dispatch branches (main thread andworker thread) and the no-running-loop fallback.
backend/tests/test_cli.pyasserts that importingchainlit.clileavesasyncio.Taskbound to the C accelerator class — the invariant the fix depends on, rather than the
absence of one particular import.
step/sync-spec.cy.ts(main-threadcl.run_sync()),step/async-spec.cy.ts, andcontext/spec.cy.ts(worker-threadcl.run_sync()) all pass.The reentrant run was verified on 3.10, 3.11, 3.12, 3.13 and 3.14 — both branches of the
version gate — and the app starts on 3.14.1 with no
NoEventLoopError.