Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pnpm-lock.yaml
.pytest_cache
14 changes: 9 additions & 5 deletions backend/chainlit/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@
import sys

import click
import nest_asyncio
import uvicorn

# Not sure if it is necessary to call nest_asyncio.apply() before the other imports
nest_asyncio.apply()

# ruff: noqa: E402
# nest_asyncio was intentionally removed (see https://github.com/Chainlit/chainlit/issues/2767).
# nest_asyncio ≤ 1.6.0 patches loop.run_until_complete() via
# asyncio.ensure_future(future, loop=self), where the ``loop=`` keyword was
# deprecated in Python 3.8 and **removed in Python 3.14** (bpo-39529).
# Applying it at import time silently corrupted asyncio task registration:
# asyncio.current_task() returned None inside running coroutines, which
# caused anyio.NoEventLoopError on every static-asset request (HTTP 500 /
# white page) on Python 3.14. The entry point asyncio.run(start()) is a
# top-level call and has never needed re-entrant loop support.
from chainlit.auth import ensure_jwt_secret
from chainlit.cache import init_lc_cache
from chainlit.config import (
Expand Down
48 changes: 33 additions & 15 deletions backend/chainlit/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,43 +325,61 @@ def __init__(
)
self.language = match.group(1) if match else "en-US"

self.config: ChainlitConfig = self.get_config()
# Start with global config; chat-profile overrides are applied
# asynchronously via resolve_config() after construction.
from chainlit.config import config as global_config

self.config: ChainlitConfig = global_config

ws_sessions_id[self.id] = self
ws_sessions_sid[socket_id] = self

def get_config(self) -> "ChainlitConfig":
"""
Return the config for this session: overridden if chat profile exists and has overrides, else global config.
Return the config for this session.

If ``resolve_config()`` has already been awaited the returned
object includes any chat-profile overrides; otherwise it falls
back to the global config.
"""
return self.config

async def resolve_config(self) -> "ChainlitConfig":
"""
Resolve chat-profile config overrides asynchronously.

Must be awaited from an async context (e.g. the SocketIO
``connect`` handler) *after* the session has been constructed.
Subsequent calls are a no-op — the resolved config is cached
on ``self.config``.
"""
from chainlit.config import config as global_config

# If no chat profile, always fallback to global config
# Nothing to resolve when there is no chat profile.
if not self.chat_profile:
return global_config
# If already computed, use self.config
if hasattr(self, "config") and self.config:
return self.config
# Try to compute overrides
cfg = global_config
if global_config.code.set_chat_profiles:
import asyncio

# Already resolved — return cached value.
if self.config is not global_config:
return self.config

if global_config.code.set_chat_profiles:
try:
profiles = asyncio.get_event_loop().run_until_complete(
global_config.code.set_chat_profiles(self.user, self.language)
profiles = await global_config.code.set_chat_profiles(
self.user, self.language
)
current_profile = next(
(p for p in profiles if p.name == self.chat_profile), None
)
if current_profile and getattr(
current_profile, "config_overrides", None
):
cfg = global_config.with_overrides(current_profile.config_overrides)
self.config = global_config.with_overrides(
current_profile.config_overrides
)
except Exception:
pass
self.config = cfg
return cfg
return self.config

def restore(self, new_socket_id: str):
"""Associate a new socket id to the session."""
Expand Down
7 changes: 6 additions & 1 deletion backend/chainlit/socket.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ def emit_call_fn(event: Literal["ask", "call_fn"], data, timeout):
unquote(url_encoded_chat_profile) if url_encoded_chat_profile else None
)

WebsocketSession(
session = WebsocketSession(
id=session_id,
socket_id=sid,
emit=emit_fn,
Expand All @@ -202,6 +202,11 @@ def emit_call_fn(event: Literal["ask", "call_fn"], data, timeout):
environ=environ,
)

# Resolve chat-profile config overrides asynchronously.
# This must happen after construction so that the session is
# registered and the event loop is available.
await session.resolve_config()

return True


Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ dependencies = [
"syncer>=2.0.3,<3.0.0",
"asyncer>=0.0.8,<0.1.0",
"mcp>=1.11.0,<2.0.0",
"nest-asyncio>=1.6.0,<2.0.0",
"click>=8.1.3,<9.0.0",
"tomli>=2.0.1,<3.0.0",
"pydantic>=2.7.2,<3",
Expand Down Expand Up @@ -95,6 +94,7 @@ tests = [
]
mypy = [
"mypy>=1.13,<2.0.0",
"hatchling",
"types-requests>=2.31.0.2,<3.0.0",
"types-aiofiles>=23.1.0.5,<26.0.0",
"mypy-boto3-dynamodb>=1.34.113,<2.0.0",
Expand Down
33 changes: 33 additions & 0 deletions backend/tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Regression tests for chainlit.cli import behaviour.

Ensures nest_asyncio is not imported by chainlit.cli.

Background
----------
nest_asyncio ≤ 1.6.0 patches ``asyncio.BaseEventLoop.run_until_complete`` via
``asyncio.ensure_future(future, loop=self)``. The ``loop=`` keyword was
deprecated in Python 3.8 and **removed in Python 3.14** (bpo-39529). When
``nest_asyncio.apply()`` ran at module-import time it silently corrupted asyncio
task registration: ``asyncio.current_task()`` returned ``None`` inside running
coroutines, causing ``anyio.NoEventLoopError`` on every static-asset request
and a white screen for users on Python 3.14.

See https://github.com/Chainlit/chainlit/issues/2767
"""

import chainlit.cli


def test_nest_asyncio_not_in_cli_namespace():
"""chainlit.cli must not expose nest_asyncio in its module namespace.

If ``import nest_asyncio`` is ever re-added to cli/__init__.py this test
will fail immediately, preventing the Python 3.14 regression from
being reintroduced.
"""
assert not hasattr(chainlit.cli, "nest_asyncio"), (
"chainlit.cli exposes 'nest_asyncio' in its namespace. "
"Remove 'import nest_asyncio' and 'nest_asyncio.apply()' from "
"backend/chainlit/cli/__init__.py — nest_asyncio breaks Python 3.14 "
"via the removed loop= kwarg in asyncio.ensure_future (bpo-39529)."
)
Loading