Skip to content

fix(cli,session,sync): remove nest_asyncio to enable Python 3.14 support - #2953

Open
pidefrem wants to merge 12 commits into
Chainlit:mainfrom
pidefrem:fix/remove-nest-asyncio-python-3-14
Open

fix(cli,session,sync): remove nest_asyncio to enable Python 3.14 support#2953
pidefrem wants to merge 12 commits into
Chainlit:mainfrom
pidefrem:fix/remove-nest-asyncio-python-3-14

Conversation

@pidefrem

@pidefrem pidefrem commented Jun 8, 2026

Copy link
Copy Markdown

Problem

Chainlit does not run on Python 3.14. Every request fails with
anyio.NoEventLoopError and the UI renders a white page.

The cause is nest_asyncio.apply(), called at CLI startup to make
loop.run_until_complete() reentrant. apply() swaps the global task and future
classes to their pure-Python implementations, while asyncio.current_task remains
bound to the C accelerator, which reads a registry the pure-Python Task never
populates:

before apply: asyncio.Task is <class '_asyncio.Task'>
after  apply: asyncio.Task is <class 'asyncio.tasks.Task'>

_py_current_task(): <Task pending name='Task-1' …>
_c_current_task():  None
asyncio.current_task() is _c_current_task: True

asyncio.current_task() returns None inside a running coroutine. anyio indexes
_task_states[host_task] — a WeakKeyDictionary — with that None:

TypeError: cannot create weak reference to 'NoneType' object

This surfaces as anyio.NoEventLoopError on every request, including static assets.

asyncio.ensure_future(..., loop=...) is not implicated: on 3.14.1 the signature is
still (coro_or_future, *, loop=None), the call succeeds, and nest_asyncio.apply()
does not raise.

Fixes #2767.

Solution

Remove nest_asyncio entirely, and eliminate the reentrancy it was compensating for
wherever the framework itself needed it.

1. Stop patching the loop globally at CLI startup — the top-level
asyncio.run(start()) in chainlit/cli/__init__.py never needed a reentrant loop, so
the 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 already
running), chainlit/sync.py now drives the loop directly through a new
chainlit/_reentrant_loop.py, instead of patching it:

outer_task = _suspend_current_task(loop)
try:
    while not future.done():
        loop._run_once()          # the stdlib scheduler, not a reimplementation
        if loop._stopping:
            break
finally:
    _resume_current_task(loop, outer_task)

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_asyncio uses, applied once around the whole nested run rather than around every
individual callback. Because the suspension is that coarse, the stdlib _run_once can be
called as-is, and none of nest_asyncio's scheduler reimplementation, run_forever,
_check_running or loop.__class__ patching is needed. No asyncio global or class is
rebound.
In particular asyncio.Task and asyncio.Future keep their C implementations,
so current_task() stays correct and anyio is unaffected — that rebind is precisely
what apply() did and what broke 3.14 (see Problem).

Calling the stdlib _run_once as-is has one consequence to repay. It snapshots
ntodo = len(self._ready) and then pops exactly that many handles, so a nested run
started from inside one of those callbacks drains entries the enclosing iteration is still
counting on, and its next popleft() raises IndexError: pop from an empty deque. 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 patches the enclosing loop too. Since nothing is patched here, the
nested run instead appends one cancelled handle per entry it borrowed; _run_once pops
those 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 through
the asyncio.tasks._current_tasks dict on 3.10–3.11, which is where the current task is
actually tracked on each. Both branches are covered by the existing CI matrix
(3.103.13). This is why nest_asyncio itself fails on 3.14: it only ever clears the
dict, and 3.14 moved current-task tracking into the thread state, so its own suspension
became a silent no-op.

nest-asyncio is removed from backend/pyproject.toml and from the root
pyproject.toml mypy overrides.

3. Make chat-profile config resolution async-safeWebsocketSession.get_config()
called loop.run_until_complete() from inside the running loop, which was the primary
reason reentrancy was required in the framework's own code. It is now an async def resolve_config() awaited from the async connect() handler in socket.py.
get_config() remains as a non-resolving accessor, so existing callers are unaffected.

No reentrant run_until_complete() calls remain elsewhere in backend/chainlit/.

Compatibility

No breaking change. cl.run_sync() behaves as it always has on every supported version:

  • Main thread, loop already running — e.g. an async @cl.on_message calling a sync
    helper that calls cl.run_sync(...). Works on 3.10 through 3.14, now without
    nest_asyncio.
  • Worker thread — e.g. inside cl.make_async(fn)(). Unchanged on every version; that
    path uses asyncio.run_coroutine_threadsafe() and never depended on nest_asyncio.
  • No loop running — unchanged; syncer.sync() drives the loop itself.

requires-python is unchanged at >=3.10,<3.14.0. This PR removes the blocker to 3.14
but does not itself declare 3.14 support, which needs a broader test pass first.

chainlit/_reentrant_loop.py relies on five private asyncio APIs (loop._run_once,
loop._stopping, loop._ready, _asyncio._swap_current_task,
asyncio.tasks._current_tasks). Each is
documented 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_asyncio broke on 3.14.

Testing

  • backend/tests/test_reentrant_loop.py covers the nested run in isolation: return value
    and exception propagation, current_task() correct during the nested run and restored
    after it, unrelated tasks continuing to progress while it runs, an anyio task group
    running inside the nested coroutine (the failure this PR fixes), asyncio.Task
    remaining the C implementation, an anyio task group still working in the outer task
    after the nested run returns, sibling callbacks ready in the same scheduler iteration
    surviving the nested run rather than underflowing the enclosing _run_once, and a
    contract 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() actually
    consults.
  • backend/tests/test_session.py covers the new async config resolution path.
  • backend/tests/test_sync.py covers both run_sync() dispatch branches (main thread and
    worker thread) and the no-running-loop fallback.
  • backend/tests/test_cli.py asserts that importing chainlit.cli leaves asyncio.Task
    bound to the C accelerator class — the invariant the fix depends on, rather than the
    absence of one particular import.
  • E2E: hardened Chainlit process cleanup and Cypress CI stability;
    step/sync-spec.cy.ts (main-thread cl.run_sync()), step/async-spec.cy.ts, and
    context/spec.cy.ts (worker-thread cl.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.

@dosubot dosubot Bot added size:S This PR changes 10-29 lines, ignoring generated files. backend Pertains to the Python backend. dependencies Pull requests that update a dependency file labels Jun 8, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 4 files

Re-trigger cubic

@pidefrem pidefrem changed the title fix(cli): remove nest_asyncio to restore Python 3.14 compatibility fix(cli,session): remove nest_asyncio, make chat-profile config async-safe Jun 8, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread cypress.config.ts Outdated
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. and removed size:S This PR changes 10-29 lines, ignoring generated files. labels Jun 9, 2026
@github-actions

Copy link
Copy Markdown

This PR is stale because it has been open for 14 days with no activity.

@github-actions github-actions Bot added the stale Issue has not had recent activity or appears to be solved. Stale issues will be automatically closed label Jun 24, 2026
@pidefrem

Copy link
Copy Markdown
Author

Not stale — this PR is active. I just pushed 012f46d1 (docstring clarification for resolve_config caching), and the branch is up to date with main with no conflicts. It fixes #2952.

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! 🙏

@github-actions github-actions Bot removed the stale Issue has not had recent activity or appears to be solved. Stale issues will be automatically closed label Jun 27, 2026

@dokterbob dokterbob left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Some much welcomed cleanup/fixup.

@github-actions

Copy link
Copy Markdown

This PR is stale because it has been open for 14 days with no activity.

@github-actions github-actions Bot added the stale Issue has not had recent activity or appears to be solved. Stale issues will be automatically closed label Jul 15, 2026
auto-merge was automatically disabled July 15, 2026 13:53

Head branch was pushed to by a user without write access

@pidefrem
pidefrem force-pushed the fix/remove-nest-asyncio-python-3-14 branch 2 times, most recently from 6aec8da to dbda012 Compare July 15, 2026 14:05
pidefrem added 3 commits July 15, 2026 16:06
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.
@pidefrem
pidefrem force-pushed the fix/remove-nest-asyncio-python-3-14 branch from dbda012 to 42058b9 Compare July 15, 2026 14:06
@github-actions github-actions Bot removed the stale Issue has not had recent activity or appears to be solved. Stale issues will be automatically closed label Jul 16, 2026
@dokterbob
dokterbob enabled auto-merge July 29, 2026 13:47
@pidefrem pidefrem changed the title fix(cli,session): remove nest_asyncio, make chat-profile config async-safe fix(cli,session): remove nest_asyncio for python 3.14 compatibility, make chat-profile config async-safe Aug 5, 2026
@pidefrem pidefrem changed the title fix(cli,session): remove nest_asyncio for python 3.14 compatibility, make chat-profile config async-safe fix(cli,session): scope nest_asyncio to Python <3.14, make chat-profile config async-safe Aug 5, 2026
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.
auto-merge was automatically disabled August 5, 2026 13:09

Head branch was pushed to by a user without write access

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread backend/tests/test_sync.py Outdated
Comment thread backend/tests/test_sync.py Outdated
Comment thread backend/tests/test_cli.py Outdated
@pidefrem pidefrem changed the title fix(cli,session): scope nest_asyncio to Python <3.14, make chat-profile config async-safe fix(cli,session,sync): remove nest_asyncio to enable Python 3.14 support Aug 5, 2026
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

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread backend/chainlit/_reentrant_loop.py
Comment thread backend/tests/test_sync.py Outdated
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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread backend/tests/test_reentrant_loop.py
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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread backend/chainlit/_reentrant_loop.py
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Pertains to the Python backend. dependencies Pull requests that update a dependency file size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chainlit Python 3.14 anyio.NoEventLoopError: Not currently running on any asynchronous event loop. Available async backends: asyncio, trio

2 participants