Skip to content

Commit 70c87a1

Browse files
committed
refac
1 parent 51b200c commit 70c87a1

2 files changed

Lines changed: 55 additions & 6 deletions

File tree

backend/open_webui/main.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1847,13 +1847,28 @@ async def process_chat(request, form_data, user, metadata, model):
18471847
except Exception:
18481848
pass
18491849
finally:
1850+
# Clean up MCP clients. Shield the entire block from
1851+
# CancelledError so disconnect() can finish even when the
1852+
# task is being stopped. Each client is isolated so one
1853+
# failure doesn't skip the rest.
18501854
try:
18511855
if mcp_clients := metadata.get('mcp_clients'):
1852-
for client in reversed(mcp_clients.values()):
1853-
await client.disconnect()
1856+
1857+
async def _cleanup_mcp():
1858+
for client in reversed(list(mcp_clients.values())):
1859+
try:
1860+
await client.disconnect()
1861+
except Exception as e:
1862+
log.debug(f'Error disconnecting MCP client: {e}')
1863+
1864+
await asyncio.wait_for(
1865+
asyncio.shield(_cleanup_mcp()),
1866+
timeout=10.0,
1867+
)
1868+
except asyncio.TimeoutError:
1869+
log.warning('MCP client cleanup timed out after 10 s')
18541870
except Exception as e:
1855-
log.debug(f'Error cleaning up: {e}')
1856-
pass
1871+
log.debug(f'Error cleaning up MCP clients: {e}')
18571872
# Emit chat:active=false when task completes
18581873
try:
18591874
if metadata.get('chat_id'):

backend/open_webui/utils/mcp/client.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import asyncio
2+
import logging
23
from typing import Optional
34
from contextlib import AsyncExitStack
45

6+
log = logging.getLogger(__name__)
7+
58
import anyio
69

710
from mcp import ClientSession
@@ -136,8 +139,39 @@ async def read_resource(self, uri: str) -> Optional[dict]:
136139
return result_dict
137140

138141
async def disconnect(self):
139-
# Clean up and close the session
140-
await self.exit_stack.aclose()
142+
"""Clean up and close the session.
143+
144+
This method is idempotent — calling it multiple times or on a
145+
client that was never connected is safe. It shields the close
146+
operation from CancelledError and adds a timeout so a hung MCP
147+
server cannot block the event loop indefinitely.
148+
"""
149+
exit_stack = self.exit_stack
150+
if exit_stack is None:
151+
return
152+
153+
# Prevent double-close from concurrent callers
154+
self.exit_stack = None
155+
self.session = None
156+
157+
try:
158+
await asyncio.wait_for(
159+
asyncio.shield(exit_stack.aclose()),
160+
timeout=5.0,
161+
)
162+
except asyncio.TimeoutError:
163+
log.warning('MCPClient.disconnect() timed out after 5 s')
164+
except RuntimeError as exc:
165+
# The MCP SDK's streamable_http transport uses anyio task
166+
# groups and async generators internally. When we close
167+
# a session that was interrupted mid-flight these can
168+
# raise RuntimeError ("aclose(): asynchronous generator is
169+
# already running" or "Attempted to exit cancel scope in a
170+
# different task"). Swallowing the error here prevents the
171+
# orphaned coroutines from spinning at 100 % CPU.
172+
log.debug('MCPClient.disconnect() suppressed RuntimeError: %s', exc)
173+
except Exception as exc:
174+
log.debug('MCPClient.disconnect() error: %s', exc)
141175

142176
async def __aenter__(self):
143177
await self.exit_stack.__aenter__()

0 commit comments

Comments
 (0)