diff --git a/.prettierignore b/.prettierignore index bd5535a603..90f6aad12c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ pnpm-lock.yaml +.pytest_cache diff --git a/backend/chainlit/cli/__init__.py b/backend/chainlit/cli/__init__.py index 85daf7dea6..d492b1a59a 100644 --- a/backend/chainlit/cli/__init__.py +++ b/backend/chainlit/cli/__init__.py @@ -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 ( diff --git a/backend/chainlit/session.py b/backend/chainlit/session.py index d88160d73a..3f837fa728 100644 --- a/backend/chainlit/session.py +++ b/backend/chainlit/session.py @@ -325,31 +325,48 @@ 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. + Once overrides are successfully applied the result is cached + on ``self.config`` and subsequent calls return immediately. """ 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 @@ -357,11 +374,12 @@ def get_config(self) -> "ChainlitConfig": 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.""" diff --git a/backend/chainlit/socket.py b/backend/chainlit/socket.py index 74306005ff..a7ea53c19d 100644 --- a/backend/chainlit/socket.py +++ b/backend/chainlit/socket.py @@ -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, @@ -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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a6b8e048a7..3f5395c89b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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", @@ -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", diff --git a/backend/tests/test_cli.py b/backend/tests/test_cli.py new file mode 100644 index 0000000000..2de6113270 --- /dev/null +++ b/backend/tests/test_cli.py @@ -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)." + ) diff --git a/backend/tests/test_session.py b/backend/tests/test_session.py index f1a26656f3..72aaeef5db 100644 --- a/backend/tests/test_session.py +++ b/backend/tests/test_session.py @@ -763,3 +763,248 @@ async def _runner(): await mcp.close() # second call should be safe assert task.done() + + +def test_get_config_returns_global_config_by_default(): + """get_config() returns the global config when no profile is set.""" + from chainlit.config import config as global_config + + session = WebsocketSession( + id="ws_id", + socket_id="socket_cfg_1", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + ) + assert session.get_config() is global_config + + +def test_get_config_returns_global_config_with_profile_before_resolve(): + """get_config() returns global config even with a profile, before resolve_config().""" + from chainlit.config import config as global_config + + session = WebsocketSession( + id="ws_id", + socket_id="socket_cfg_2", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + chat_profile="gpt-4", + ) + assert session.get_config() is global_config + + +@pytest.mark.asyncio +async def test_resolve_config_noop_without_chat_profile(): + """resolve_config() is a no-op when there is no chat profile.""" + from chainlit.config import config as global_config + + session = WebsocketSession( + id="ws_id", + socket_id="socket_cfg_3", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + ) + result = await session.resolve_config() + assert result is global_config + assert session.get_config() is global_config + + +@pytest.mark.asyncio +async def test_resolve_config_applies_overrides(monkeypatch): + """resolve_config() applies chat-profile config overrides.""" + from chainlit.config import ( + ChainlitConfigOverrides, + UISettings, + config as global_config, + ) + from chainlit.types import ChatProfile + + profiles = [ + ChatProfile( + name="custom", + markdown_description="Custom profile", + config_overrides=ChainlitConfigOverrides( + ui=UISettings(name="Custom App"), + ), + ), + ] + + async def mock_set_chat_profiles(user, language): + return profiles + + monkeypatch.setattr(global_config.code, "set_chat_profiles", mock_set_chat_profiles) + + session = WebsocketSession( + id="ws_id", + socket_id="socket_cfg_4", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + chat_profile="custom", + ) + + result = await session.resolve_config() + assert result is not global_config + assert result.ui.name == "Custom App" + # get_config() should return the resolved config + assert session.get_config().ui.name == "Custom App" + + +@pytest.mark.asyncio +async def test_resolve_config_no_overrides_for_profile(monkeypatch): + """resolve_config() returns global config when profile has no overrides.""" + from chainlit.config import config as global_config + from chainlit.types import ChatProfile + + async def mock_set_chat_profiles(user, language): + return [ChatProfile(name="basic", markdown_description="Basic profile")] + + monkeypatch.setattr(global_config.code, "set_chat_profiles", mock_set_chat_profiles) + + session = WebsocketSession( + id="ws_id", + socket_id="socket_cfg_5", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + chat_profile="basic", + ) + result = await session.resolve_config() + assert result is global_config + + +@pytest.mark.asyncio +async def test_resolve_config_idempotent(monkeypatch): + """Calling resolve_config() twice returns the same cached result.""" + from chainlit.config import ( + ChainlitConfigOverrides, + UISettings, + config as global_config, + ) + from chainlit.types import ChatProfile + + call_count = 0 + + async def mock_set_chat_profiles(user, language): + nonlocal call_count + call_count += 1 + return [ + ChatProfile( + name="custom", + markdown_description="Custom profile", + config_overrides=ChainlitConfigOverrides( + ui=UISettings(name="Custom App"), + ), + ), + ] + + monkeypatch.setattr(global_config.code, "set_chat_profiles", mock_set_chat_profiles) + + session = WebsocketSession( + id="ws_id", + socket_id="socket_cfg_6", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + chat_profile="custom", + ) + + first = await session.resolve_config() + second = await session.resolve_config() + assert first is second + assert call_count == 1 # callback only invoked once + + +@pytest.mark.asyncio +async def test_resolve_config_handles_callback_exception(monkeypatch): + """resolve_config() falls back to global config if the callback raises.""" + from chainlit.config import config as global_config + + async def broken_set_chat_profiles(user, language): + raise RuntimeError("something went wrong") + + monkeypatch.setattr( + global_config.code, "set_chat_profiles", broken_set_chat_profiles + ) + + session = WebsocketSession( + id="ws_id", + socket_id="socket_cfg_7", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + chat_profile="some-profile", + ) + result = await session.resolve_config() + assert result is global_config + + +@pytest.mark.asyncio +async def test_resolve_config_unknown_profile(monkeypatch): + """resolve_config() returns global config for a profile name not in the list.""" + from chainlit.config import config as global_config + from chainlit.types import ChatProfile + + async def mock_set_chat_profiles(user, language): + return [ChatProfile(name="known", markdown_description="Known profile")] + + monkeypatch.setattr(global_config.code, "set_chat_profiles", mock_set_chat_profiles) + + session = WebsocketSession( + id="ws_id", + socket_id="socket_cfg_8", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + chat_profile="unknown", + ) + result = await session.resolve_config() + assert result is global_config + + +def test_get_config_does_not_use_run_until_complete(monkeypatch): + """get_config() must not call asyncio.get_event_loop().run_until_complete(). + + This is the key regression test: the old implementation used + run_until_complete() which required nest_asyncio and broke on + Python 3.14. + """ + import asyncio + + from chainlit.config import config as global_config + from chainlit.types import ChatProfile + + async def mock_set_chat_profiles(user, language): + return [ChatProfile(name="test", markdown_description="Test")] + + monkeypatch.setattr(global_config.code, "set_chat_profiles", mock_set_chat_profiles) + + session = WebsocketSession( + id="ws_id", + socket_id="socket_cfg_9", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + chat_profile="test", + ) + + # get_config() should return immediately without calling + # run_until_complete. Verify by patching it to raise. + loop = asyncio.get_event_loop() + monkeypatch.setattr( + loop, "run_until_complete", Mock(side_effect=RuntimeError("must not be called")) + ) + config = session.get_config() + # Should return global config (overrides not yet resolved) + assert config is global_config diff --git a/cypress.config.ts b/cypress.config.ts index 7f036f3b19..87c31a030c 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,16 +1,56 @@ +import { ChildProcessWithoutNullStreams } from 'child_process'; import { defineConfig } from 'cypress'; import cypressSplit from 'cypress-split'; import fkill from 'fkill'; +import { createServer } from 'net'; import { runChainlit } from './cypress/support/run'; export const CHAINLIT_APP_PORT = 8000; +// Track the running Chainlit process so we can kill the entire process group +// (uv → chainlit → uvicorn) rather than just the uvicorn socket owner. +let chainlitProcess: ChildProcessWithoutNullStreams | null = null; + +/** + * Poll until the given port is free (no process listening). + * Prevents EADDRINUSE / [Errno 48] when the OS has not yet released + * the socket after killing the process. + */ +async function waitForPortFree(port: number, timeoutMs = 10000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const free = await new Promise((resolve) => { + const server = createServer(); + server.once('error', () => resolve(false)); + server.once('listening', () => { + server.close(() => resolve(true)); + }); + server.listen(port, '127.0.0.1'); + }); + if (free) return; + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error(`Port ${port} not free after ${timeoutMs}ms`); +} + async function killChainlit() { + // Kill the entire process group (uv + chainlit + uvicorn workers). + // Requires spawn() to use detached: true in run.ts. + if (chainlitProcess?.pid) { + try { + process.kill(-chainlitProcess.pid, 'SIGKILL'); + } catch { + // Process may have already exited — ignore + } + chainlitProcess = null; + } + // Fallback: catch any orphan that slipped through await fkill(`:${CHAINLIT_APP_PORT}`, { force: true, silent: true }); + await waitForPortFree(CHAINLIT_APP_PORT); } ['SIGTERM', 'SIGINT', 'SIGHUP', 'SIGBREAK'].forEach((signal) => { @@ -39,11 +79,11 @@ export default defineConfig({ cypressSplit(on, config); await killChainlit(); // Fallback to ensure no previous instance is running - await runChainlit(); // Start Chainlit before running tests as Cypress require + chainlitProcess = await runChainlit(); // Start Chainlit before running tests as Cypress require on('before:spec', async (spec) => { await killChainlit(); - await runChainlit(spec); + chainlitProcess = await runChainlit(spec); }); on('after:spec', async () => { @@ -62,7 +102,8 @@ export default defineConfig({ restartChainlit(spec: Cypress.Spec) { return new Promise((resolve) => { killChainlit().then(() => { - runChainlit(spec).then(() => { + runChainlit(spec).then((proc) => { + chainlitProcess = proc; setTimeout(() => { resolve(null); }, 1000); diff --git a/cypress/support/run.ts b/cypress/support/run.ts index 6b876f0cca..a9db871425 100644 --- a/cypress/support/run.ts +++ b/cypress/support/run.ts @@ -47,7 +47,10 @@ export const runChainlit = async ( env: { ...process.env, CHAINLIT_APP_ROOT: testDir - } + }, + // Create a new process group so the entire tree (uv → chainlit → uvicorn) + // can be killed with process.kill(-pid, 'SIGKILL') in killChainlit(). + detached: true }; const chainlit = spawn(command, args, options); diff --git a/pyproject.toml b/pyproject.toml index ab0884b0bf..c0e9c71e00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,6 @@ module = [ "langflow", "lazify", "plotly", - "nest_asyncio", "socketio.*", "syncer", "azure.storage.filedatalake", diff --git a/uv.lock b/uv.lock index 5b75c257b6..1fd0421352 100644 --- a/uv.lock +++ b/uv.lock @@ -3,8 +3,7 @@ revision = 3 requires-python = ">=3.10, <3.14.0" resolution-markers = [ "python_full_version >= '3.13'", - "python_full_version >= '3.12.4' and python_full_version < '3.13'", - "python_full_version >= '3.12' and python_full_version < '3.12.4'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", "python_full_version < '3.11'", ] @@ -701,7 +700,6 @@ dependencies = [ { name = "lazify" }, { name = "literalai" }, { name = "mcp" }, - { name = "nest-asyncio" }, { name = "packaging" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -727,6 +725,7 @@ custom-data = [ { name = "sqlalchemy" }, ] mypy = [ + { name = "hatchling" }, { name = "mypy" }, { name = "mypy-boto3-dynamodb" }, { name = "pandas-stubs" }, @@ -778,6 +777,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.116.1" }, { name = "filetype", specifier = ">=1.2.0,<2.0.0" }, { name = "google-cloud-storage", marker = "extra == 'custom-data'", specifier = ">=2.19.0,<4.0.0" }, + { name = "hatchling", marker = "extra == 'mypy'" }, { name = "httpx", specifier = ">=0.23.0" }, { name = "langchain", marker = "extra == 'tests'", specifier = ">=1.0.0" }, { name = "lazify", specifier = ">=0.4.0,<0.5.0" }, @@ -788,7 +788,6 @@ requires-dist = [ { name = "moto", marker = "extra == 'tests'", specifier = ">=5.0.14,<6.0.0" }, { name = "mypy", marker = "extra == 'mypy'", specifier = ">=1.13,<2.0.0" }, { name = "mypy-boto3-dynamodb", marker = "extra == 'mypy'", specifier = ">=1.34.113,<2.0.0" }, - { name = "nest-asyncio", specifier = ">=1.6.0,<2.0.0" }, { name = "openai", marker = "extra == 'tests'", specifier = ">=2.0.0,<3.0.0" }, { name = "packaging", specifier = ">=23.1" }, { name = "pandas", marker = "extra == 'tests'", specifier = ">=2.2.2,<4.0.0" }, @@ -1025,8 +1024,7 @@ version = "1.3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.13'", - "python_full_version >= '3.12.4' and python_full_version < '3.13'", - "python_full_version >= '3.12' and python_full_version < '3.12.4'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] dependencies = [ @@ -3048,8 +3046,7 @@ version = "3.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.13'", - "python_full_version >= '3.12.4' and python_full_version < '3.13'", - "python_full_version >= '3.12' and python_full_version < '3.12.4'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/e8/fc/7b6fd4d22c8c4dc5704430140d8b3f520531d4fe7328b8f8d03f5a7950e8/networkx-3.6.tar.gz", hash = "sha256:285276002ad1f7f7da0f7b42f004bcba70d381e936559166363707fdad3d72ad", size = 2511464, upload-time = "2025-11-24T03:03:47.158Z" } @@ -5213,8 +5210,7 @@ version = "1.16.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.13'", - "python_full_version >= '3.12.4' and python_full_version < '3.13'", - "python_full_version >= '3.12' and python_full_version < '3.12.4'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] dependencies = [