mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-07 21:40:14 +08:00
Compare commits
5 Commits
v4.26.5
...
codex/even
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58b58aaf31 | ||
|
|
a18054c8b4 | ||
|
|
f683adc74c | ||
|
|
f5b08eb135 | ||
|
|
54124979b0 |
@@ -1,4 +1,4 @@
|
||||
import logging
|
||||
|
||||
__version__ = "4.26.5"
|
||||
__version__ = "4.26.4"
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
@@ -392,16 +392,10 @@ def _normalize_mcp_input_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
class MCPClient:
|
||||
def __init__(self) -> None:
|
||||
# Initialize session and client objects
|
||||
self.session: mcp.ClientSession | None = None
|
||||
|
||||
# Each connection runs in its own task so that anyio cancel scopes
|
||||
# are always exited from the task that entered them, preventing
|
||||
# RuntimeError: Attempted to exit cancel scope in a different task
|
||||
self._connection_task: asyncio.Task | None = None
|
||||
self._old_connection_tasks: list[asyncio.Task] = []
|
||||
|
||||
# Internal; managed exclusively by _run_connection.
|
||||
self.exit_stack: AsyncExitStack | None = None
|
||||
self.exit_stack = AsyncExitStack()
|
||||
self._old_exit_stacks: list[AsyncExitStack] = [] # Track old stacks for cleanup
|
||||
|
||||
self.name: str | None = None
|
||||
self.active: bool = True
|
||||
@@ -409,67 +403,14 @@ class MCPClient:
|
||||
self.server_errlogs: list[str] = []
|
||||
self.running_event = asyncio.Event()
|
||||
|
||||
# Store connection config for reconnection
|
||||
self._mcp_server_config: dict | None = None
|
||||
self._server_name: str | None = None
|
||||
self._reconnect_lock = asyncio.Lock() # Lock for thread-safe reconnection
|
||||
self._reconnecting: bool = False
|
||||
|
||||
async def _run_connection(
|
||||
self,
|
||||
mcp_server_config: dict,
|
||||
name: str,
|
||||
ready: asyncio.Future,
|
||||
) -> None:
|
||||
"""Own the full lifetime of one MCP connection.
|
||||
|
||||
This coroutine is always run inside a dedicated asyncio.Task
|
||||
(_connection_task). Because *this task* is the one that enters every
|
||||
anyio cancel scope (via sse_client / streamablehttp_client), anyio's
|
||||
_host_task check is always satisfied when the stack is later closed —
|
||||
either in the task's own finally block (normal path) or when the task
|
||||
is cancelled from outside (cleanup / reconnect path).
|
||||
|
||||
This avoids the
|
||||
RuntimeError: Attempted to exit cancel scope in a different task
|
||||
that previously occurred when aclose() was called from a different task
|
||||
or from the asyncio async-generator GC finalizer.
|
||||
"""
|
||||
# Capture the stack in a local variable so that if self.exit_stack is
|
||||
# overwritten by a concurrent _run_connection (during reconnect), this
|
||||
# task's finally block still closes only the resources it opened.
|
||||
stack = self.exit_stack = AsyncExitStack()
|
||||
try:
|
||||
try:
|
||||
await self._do_connect(mcp_server_config, name)
|
||||
except Exception as exc:
|
||||
if not ready.done():
|
||||
ready.set_exception(exc)
|
||||
raise
|
||||
else:
|
||||
if not ready.done():
|
||||
ready.set_result(None)
|
||||
# Hold the connection open until cancelled.
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
try:
|
||||
await stack.aclose()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing exit stack for {name}: {e}")
|
||||
# Clear the instance reference only if it still points to this task's
|
||||
# stack; a concurrent reconnect may have already replaced it.
|
||||
if self.exit_stack is stack:
|
||||
self.exit_stack = None
|
||||
# Guard against the task exiting before ready was resolved.
|
||||
if not ready.done():
|
||||
ready.set_exception(RuntimeError("Connection task exited early"))
|
||||
self._reconnecting: bool = False # For logging and debugging
|
||||
|
||||
async def connect_to_server(self, mcp_server_config: dict, name: str) -> None:
|
||||
"""Connect to MCP server by spawning a dedicated owner task.
|
||||
|
||||
The owner task (_connection_task) holds the AsyncExitStack and all
|
||||
anyio cancel scopes for the lifetime of this connection. To disconnect,
|
||||
cancel _connection_task — the finally block in _run_connection will call
|
||||
aclose() from within the correct task context.
|
||||
"""Connect to MCP server
|
||||
|
||||
If `url` parameter exists:
|
||||
1. When transport is specified as `streamable_http`, use Streamable HTTP connection.
|
||||
@@ -480,47 +421,10 @@ class MCPClient:
|
||||
mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server
|
||||
|
||||
"""
|
||||
# Store config for reconnection
|
||||
self._mcp_server_config = mcp_server_config
|
||||
self._server_name = name
|
||||
|
||||
ready: asyncio.Future = asyncio.get_running_loop().create_future()
|
||||
|
||||
# Defensively cancel any existing connection task that was not cleaned
|
||||
# up before this call (e.g. if connect_to_server is called twice).
|
||||
if self._connection_task and not self._connection_task.done():
|
||||
self._cancel_connection_task(self._connection_task)
|
||||
self._connection_task = None
|
||||
|
||||
self._connection_task = asyncio.create_task(
|
||||
self._run_connection(mcp_server_config, name, ready),
|
||||
name=f"mcp-conn:{name}",
|
||||
)
|
||||
|
||||
try:
|
||||
await ready
|
||||
except asyncio.CancelledError:
|
||||
# Caller was cancelled while waiting — tear down the connection task.
|
||||
# cancel() is asynchronous; the task will not finish until the next
|
||||
# event-loop iteration, so we track it in _old_connection_tasks so
|
||||
# that cleanup() can await it later.
|
||||
if self._connection_task and not self._connection_task.done():
|
||||
self._cancel_connection_task(self._connection_task)
|
||||
self._connection_task = None
|
||||
raise
|
||||
except Exception:
|
||||
# _do_connect raised; the connection task's finally block may still
|
||||
# be running (e.g. awaiting stack.aclose()). Track it so that
|
||||
# cleanup() can await it, but do NOT cancel it — we want the
|
||||
# finally block to finish cleaning up resources naturally.
|
||||
if self._connection_task and not self._connection_task.done():
|
||||
self._old_connection_tasks.append(self._connection_task)
|
||||
self._connection_task = None
|
||||
raise
|
||||
|
||||
async def _do_connect(self, mcp_server_config: dict, name: str) -> None:
|
||||
"""Internal: perform the actual connection inside _run_connection's task."""
|
||||
# exit_stack is always set by _run_connection before _do_connect is called.
|
||||
assert self.exit_stack is not None
|
||||
cfg = _prepare_config(mcp_server_config.copy())
|
||||
|
||||
def logging_callback(
|
||||
@@ -659,32 +563,16 @@ class MCPClient:
|
||||
self.tools = response.tools
|
||||
return response
|
||||
|
||||
def _cancel_connection_task(self, task: asyncio.Task) -> None:
|
||||
"""Cancel a connection owner task and track it until it finishes."""
|
||||
# Prune already-finished tasks to avoid accumulating references over
|
||||
# many reconnections in a long-running process.
|
||||
self._old_connection_tasks = [
|
||||
t for t in self._old_connection_tasks if not t.done()
|
||||
]
|
||||
if task.done():
|
||||
return
|
||||
task.cancel()
|
||||
self._old_connection_tasks.append(task)
|
||||
|
||||
async def _reconnect(self) -> None:
|
||||
"""Reconnect to the MCP server using the stored configuration.
|
||||
|
||||
Cancels the current _connection_task (which owns the exit_stack and all
|
||||
anyio cancel scopes) and starts a fresh one. Because each connection
|
||||
task enters and exits its own anyio cancel scope, there is no
|
||||
cross-task cancel-scope violation and no GC finalizer surprise.
|
||||
|
||||
Uses asyncio.Lock to ensure thread-safe reconnection in concurrent environments.
|
||||
|
||||
Raises:
|
||||
Exception: raised when reconnection fails
|
||||
"""
|
||||
async with self._reconnect_lock:
|
||||
# Check if already reconnecting (useful for logging)
|
||||
if self._reconnecting:
|
||||
logger.debug(
|
||||
f"MCP Client {self._server_name} is already reconnecting, skipping"
|
||||
@@ -700,16 +588,17 @@ class MCPClient:
|
||||
f"Attempting to reconnect to MCP server {self._server_name}..."
|
||||
)
|
||||
|
||||
# Cancel the old connection task. Its finally block will call
|
||||
# exit_stack.aclose() from within the correct task context, so
|
||||
# anyio cancel scopes are exited cleanly without triggering the
|
||||
# GC-finalizer busy-spin bug.
|
||||
if self._connection_task and not self._connection_task.done():
|
||||
self._cancel_connection_task(self._connection_task)
|
||||
self._connection_task = None
|
||||
# Save old exit_stack for later cleanup (don't close it now to avoid cancel scope issues)
|
||||
if self.exit_stack:
|
||||
self._old_exit_stacks.append(self.exit_stack)
|
||||
|
||||
# Mark old session as invalid
|
||||
self.session = None
|
||||
|
||||
# Reconnect — this creates a new _connection_task.
|
||||
# Create new exit stack for new connection
|
||||
self.exit_stack = AsyncExitStack()
|
||||
|
||||
# Reconnect using stored config
|
||||
await self.connect_to_server(self._mcp_server_config, self._server_name)
|
||||
await self.list_tools_and_save()
|
||||
|
||||
@@ -774,20 +663,19 @@ class MCPClient:
|
||||
return await _call_with_retry()
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Clean up resources by cancelling the connection owner task."""
|
||||
# Cancel current and any old connection tasks via the shared helper so
|
||||
# all cancellation + tracking behaviour goes through one code path.
|
||||
if self._connection_task:
|
||||
self._cancel_connection_task(self._connection_task)
|
||||
self._connection_task = None
|
||||
"""Clean up resources including old exit stacks from reconnections"""
|
||||
# Close current exit stack
|
||||
try:
|
||||
await self.exit_stack.aclose()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing current exit stack: {e}")
|
||||
|
||||
if self._old_connection_tasks:
|
||||
pending = [t for t in self._old_connection_tasks if not t.done()]
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
self._old_connection_tasks.clear()
|
||||
# Don't close old exit stacks as they may be in different task contexts
|
||||
# They will be garbage collected naturally
|
||||
# Just clear the list to release references
|
||||
self._old_exit_stacks.clear()
|
||||
|
||||
# Set running_event to unblock any waiting tasks
|
||||
# Set running_event first to unblock any waiting tasks
|
||||
self.running_event.set()
|
||||
|
||||
|
||||
|
||||
@@ -502,7 +502,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
logger.info("[QQOfficial] 回复消息失败: %s, 尝试使用主动发送接口。", err)
|
||||
if payload.get("msg_id"):
|
||||
fallback_payload = payload.copy()
|
||||
fallback_payload.pop("msg_id", None)
|
||||
try:
|
||||
ret = await send_func(fallback_payload)
|
||||
logger.info("[QQOfficial] 使用主动发送接口发送成功。")
|
||||
|
||||
@@ -723,21 +723,8 @@ class FunctionToolManager:
|
||||
logger.debug(f"MCP client {name} task was cancelled")
|
||||
raise
|
||||
finally:
|
||||
# Cleanup in the same task that entered the anyio contexts:
|
||||
# asyncio.shield() would schedule the coroutine as a separate
|
||||
# Task, and anyio cancel scopes cannot exit across tasks (#9068).
|
||||
# Absorb late cancellations so a forced shutdown cannot abort
|
||||
# the cleanup halfway.
|
||||
task = asyncio.current_task()
|
||||
while True:
|
||||
try:
|
||||
await self._terminate_mcp_client(name)
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
# Task.uncancel() is 3.11+; on 3.10 absorbing the
|
||||
# cancellation is sufficient.
|
||||
if task is not None and hasattr(task, "uncancel"):
|
||||
task.uncancel()
|
||||
# Cleanup in the same task that entered the anyio contexts
|
||||
await asyncio.shield(self._terminate_mcp_client(name))
|
||||
|
||||
lifecycle_task = asyncio.create_task(
|
||||
connect_and_lifecycle(), name=f"mcp-client:{name}"
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
## What's Changed
|
||||
|
||||
- feat(platform): 支持扫码创建时自定义机器人ID (#9067) (9f50c900b)
|
||||
- fix: enable only synced ModelScope MCP servers (#9084) (89b80a6ca)
|
||||
- feat: add ChatUI project workspaces (#9066) (e7d5be632)
|
||||
- fix: Improve MCP client startup and shutdown handling to prevent idle spinning when a server is disabled and ensure proper cleanup on errors, timeouts, and cancellation (#9070) (3ce66576f)
|
||||
- fix(kb): replace fragile error string matching with pre-check for duplicate kb_name (#9121) (aecee6fc9)
|
||||
- fix(kb): improve retrieval resilience on per-KB failure and rerank selection (#9122) (468eea99c)
|
||||
- fix(kb): clean up KBMedia records on document delete, fix update_kb_stats (#9120) (e2f3b0008)
|
||||
- fix: align knowledge base CRUD API contract with kb_name, canonical payload fields, and matching OpenAPI types (#9000) (ea9e3421d)
|
||||
- fix: constrain custom workspace paths (6d798908a)
|
||||
- fix(core): improve web search API key failover (8a31cf01f)
|
||||
- fix: preserve absolute custom workspace paths (20008f179)
|
||||
- feat: improve ChatUI attachment display (#9134) (b43cc6dee)
|
||||
- fix: enforce ownership when reading ChatUI sessions (#9141) (041fba4df)
|
||||
- fix: secure project update temp staging (#9083) (30426c4f6)
|
||||
- fix: qq_official websocket适配器发送消息收到None时现在会重试了 (#8977) (#8979) (cc0b34750)
|
||||
- fix: adapt MiMo STT to V2.5 models and reject non-WAV audio payloads (#9118) (c9eed7b65)
|
||||
- feat: add note on cross-platform compatibility and Python version support (85b653b6f)
|
||||
- test: cover POSIX file URI root preservation (#8906) (3b8caf37e)
|
||||
- feat: add sanitation for malformed tool call names in ToolLoopAgentRunner (#9144) (25cbd41e0)
|
||||
- feat: show model metadata in selectors (6627fd53c)
|
||||
- fix: keep model metadata separate from providers (#9161) (18e067fab)
|
||||
- fix: serialize webchat history timestamps as UTC-aware ISO strings (#9159) (56326c755)
|
||||
- feat: add chat token usage indicator (ab2502c17)
|
||||
- fix(webui): sync plugin activation status on enable (#9156) (ba7f8ebfb)
|
||||
- fix: preserve qq official quoted message context (#9164) (4166d9ab5)
|
||||
- fix: exit MCP anyio contexts in the lifecycle task on disable (#9132) (afb007911)
|
||||
- fix(mcp): attempt to fix epoll busy-wait caused by leaked SSE exit stack (#8307) (56f253395)
|
||||
- fix: remove qq official reply id on proactive fallback so oversized passive replies can succeed via proactive sending (#9169) (2a7c02af8)
|
||||
- feat: add event loop diagnostics (#9168) (0dee40bd9)
|
||||
- docs: add diagnostics guide (0a5d6485a)
|
||||
- chore: log event loop watchdog startup at info (510e83369)
|
||||
- docs: broaden diagnostics guide (1124dfe29)
|
||||
- style: polish snackbar appearance (#9173) (bf18fadbe)
|
||||
@@ -7,12 +7,9 @@
|
||||
<v-snackbar v-if="toastStore.current" v-model="snackbarShow" :color="toastStore.current.color"
|
||||
:timeout="toastStore.current.timeout" :multi-line="toastStore.current.multiLine"
|
||||
:location="toastStore.current.location" close-on-back>
|
||||
<div class="app-snackbar-message">
|
||||
<v-icon v-if="toastIcon" :icon="toastIcon" size="24" />
|
||||
<span>{{ toastStore.current.message }}</span>
|
||||
</div>
|
||||
{{ toastStore.current.message }}
|
||||
<template #actions v-if="toastStore.current.closable">
|
||||
<v-btn icon="mdi-close" variant="text" size="small" aria-label="Close notification" @click="snackbarShow = false" />
|
||||
<v-btn variant="text" @click="snackbarShow = false">关闭</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</template>
|
||||
@@ -35,14 +32,6 @@ const snackbarShow = computed({
|
||||
}
|
||||
})
|
||||
|
||||
const toastIcon = computed(() => ({
|
||||
success: 'mdi-check-circle-outline',
|
||||
error: 'mdi-alert-circle-outline',
|
||||
warning: 'mdi-alert-outline',
|
||||
info: 'mdi-information-outline',
|
||||
primary: 'mdi-information-outline'
|
||||
})[toastStore.current?.color] || '')
|
||||
|
||||
onMounted(() => {
|
||||
const desktopBridge = window.astrbotDesktop
|
||||
if (!desktopBridge?.onTrayRestartBackend) {
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar :timeout="3000" elevation="6" :color="save_message_success" v-model="save_message_snack" location="top">
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack" location="top">
|
||||
{{ save_message }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
|
||||
@@ -746,7 +746,7 @@
|
||||
v-model="snackbar.show"
|
||||
:timeout="3500"
|
||||
:color="snackbar.color"
|
||||
elevation="6"
|
||||
elevation="24"
|
||||
>
|
||||
{{ snackbar.message }}
|
||||
</v-snackbar>
|
||||
|
||||
@@ -314,7 +314,7 @@ watch(viewMode, async (mode) => {
|
||||
/>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<v-snackbar :timeout="2000" elevation="6" :color="snackbar.color" v-model="snackbar.show">
|
||||
<v-snackbar :timeout="2000" elevation="24" :color="snackbar.color" v-model="snackbar.show">
|
||||
{{ snackbar.message }}
|
||||
</v-snackbar>
|
||||
</template>
|
||||
|
||||
@@ -21,10 +21,6 @@ export default createVuetify({
|
||||
VCard: {
|
||||
rounded: 'lg'
|
||||
},
|
||||
VSnackbar: {
|
||||
elevation: 6,
|
||||
rounded: 'lg'
|
||||
},
|
||||
VTextField: {
|
||||
rounded: 'lg'
|
||||
},
|
||||
|
||||
@@ -20,42 +20,6 @@ html {
|
||||
.v-overlay.v-snackbar {
|
||||
--v-layout-left: 0px !important;
|
||||
--v-layout-right: 0px !important;
|
||||
|
||||
.v-snackbar__wrapper {
|
||||
min-height: 52px;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 10px 24px rgba(16, 24, 40, 0.16) !important;
|
||||
}
|
||||
|
||||
.v-snackbar__content {
|
||||
padding: 14px 18px;
|
||||
line-height: 1.35;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.v-snackbar__actions {
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
|
||||
.v-snackbar__actions .v-btn {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.v-snackbar__wrapper.bg-success {
|
||||
color: #ffffff !important;
|
||||
background-color: #457c3c !important;
|
||||
}
|
||||
}
|
||||
|
||||
.app-snackbar-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
|
||||
.v-icon {
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
.customizer-btn .icon {
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar :timeout="3000" elevation="6" :color="save_message_success" v-model="save_message_snack">
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack">
|
||||
{{ save_message }}
|
||||
</v-snackbar>
|
||||
|
||||
|
||||
@@ -377,7 +377,7 @@
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar :timeout="3000" elevation="6" :color="messageType" v-model="showMessage" location="top">
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="messageType" v-model="showMessage" location="top">
|
||||
{{ message }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
|
||||
@@ -510,7 +510,7 @@ const updateDialogPluginLogo = computed(() => {
|
||||
|
||||
<v-snackbar
|
||||
:timeout="2000"
|
||||
elevation="6"
|
||||
elevation="24"
|
||||
:color="snack_success"
|
||||
v-model="snack_show"
|
||||
location="bottom center"
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar :timeout="3000" elevation="6" :color="save_message_success" v-model="save_message_snack"
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
|
||||
location="top">
|
||||
{{ save_message }}
|
||||
</v-snackbar>
|
||||
|
||||
@@ -650,7 +650,7 @@
|
||||
</v-dialog>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<v-snackbar v-model="snackbar" :timeout="3000" elevation="6" :color="snackbarColor" location="top">
|
||||
<v-snackbar v-model="snackbar" :timeout="3000" elevation="24" :color="snackbarColor" location="top">
|
||||
{{ snackbarText }}
|
||||
</v-snackbar>
|
||||
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar :timeout="3000" elevation="6" :color="messageType" v-model="showMessage" location="top">
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="messageType" v-model="showMessage" location="top">
|
||||
{{ message }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
|
||||
@@ -231,7 +231,6 @@ export default defineConfig({
|
||||
base: "/others",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "异常诊断", link: "/diagnostics" },
|
||||
{ text: "自部署文转图", link: "/self-host-t2i" },
|
||||
{ text: "插件下载不了?试试自建 GitHub 加速服务", link: "/github-proxy" },
|
||||
],
|
||||
@@ -485,7 +484,6 @@ export default defineConfig({
|
||||
base: "/en/others",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "Diagnostics", link: "/diagnostics" },
|
||||
{ text: "Self-hosted HTML to Image", link: "/self-host-t2i" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
# Diagnostics
|
||||
|
||||
This page provides a general checklist for diagnosing AstrBot issues. When something goes wrong, first identify which stage is affected, then collect the relevant logs. This makes issue reports easier to reproduce and investigate.
|
||||
|
||||
## Common Issue Types
|
||||
|
||||
- Startup failures: the process exits after startup, WebUI is unavailable, or database/config loading fails.
|
||||
- Platform connection issues: QQ, OneBot, Telegram, WeCom, or other platforms cannot connect, receive messages, or send messages.
|
||||
- Model request issues: replies take too long, requests time out, 429/5xx errors appear, or the proxy/API base is unavailable.
|
||||
- Plugin or MCP issues: enabling fails, tool calls fail, dependencies fail to install, or an MCP service does not respond.
|
||||
- Slow tasks or abnormal CPU usage: messages, proactive tasks, or scheduled tasks start but continue much later; or the process keeps unusually high CPU usage.
|
||||
|
||||
## Logs to Check First
|
||||
|
||||
Start with the main AstrBot log:
|
||||
|
||||
```text
|
||||
data/logs/astrbot.log
|
||||
```
|
||||
|
||||
For Docker deployments, also check container logs:
|
||||
|
||||
```bash
|
||||
docker logs <container-name>
|
||||
```
|
||||
|
||||
If the issue involves slow tasks, abnormal CPU usage, or multiple sessions becoming slow at the same time, also check the event loop diagnostic logs:
|
||||
|
||||
```text
|
||||
data/logs/event_loop_watchdog.log
|
||||
data/logs/event_loop_watchdog.log.1
|
||||
```
|
||||
|
||||
`event_loop_watchdog.log` rotates to `.1` after it exceeds 1 MB.
|
||||
|
||||
## General Checklist
|
||||
|
||||
1. Identify the time of the incident, then collect logs from 1 to 3 minutes before and after it.
|
||||
2. Check the scope: all platforms, one platform, one group, one user, or one plugin.
|
||||
3. For slow or missing model replies, check the model provider status, API key, API base, proxy, network, request timeout, and retry logs.
|
||||
4. For plugin or MCP issues, disable recently installed or updated plugins first, then check plugin dependencies and MCP service logs.
|
||||
5. For platform messaging issues, check whether the adapter is connected, platform-side settings are correct, and callback/WebSocket URLs are reachable.
|
||||
6. For slow tasks or abnormal CPU usage, see "Event Loop Lag Diagnostics" below.
|
||||
|
||||
## Event Loop Lag Diagnostics
|
||||
|
||||
The event loop schedules messages, plugins, scheduled jobs, model requests, and tool calls. If it is blocked by synchronous code, many features can look delayed at once.
|
||||
|
||||
Common symptoms:
|
||||
|
||||
- Logs stop after `ready to request llm provider`, `acquired session lock for llm request`, or a tool result, then continue much later.
|
||||
- A proactive or scheduled task starts, but one step in the middle takes a long time to continue.
|
||||
- Multiple platforms or sessions become slow at the same time.
|
||||
- CPU usage stays at 100%, or CPU usage is low but requests keep waiting for an external service.
|
||||
|
||||
If the main log contains the following entry, the event loop experienced visible scheduling delay:
|
||||
|
||||
```text
|
||||
Event loop lag detected: 18.432s (threshold 15.000s).
|
||||
```
|
||||
|
||||
If the event loop does not resume for a long time, AstrBot writes Python thread stacks to:
|
||||
|
||||
```text
|
||||
data/logs/event_loop_watchdog.log
|
||||
```
|
||||
|
||||
When reading this file, focus on the top frames. Useful clues often include plugin functions, platform adapters, MCP tools, synchronous network requests, `time.sleep()`, `subprocess.run()`, or CPU-heavy loops.
|
||||
|
||||
## What to Include in an Issue
|
||||
|
||||
When filing an issue, include as much of the following as possible:
|
||||
|
||||
- Approximate time of the incident and timezone.
|
||||
- AstrBot version, deployment method (Docker, manual deployment, desktop client, etc.), and operating system.
|
||||
- Trigger path: startup, normal chat, group chat, platform callback, scheduled task, MCP tool, plugin feature, etc.
|
||||
- Scope: all sessions, one platform, one group, one user, or one plugin.
|
||||
- Logs from `data/logs/astrbot.log` for 1 to 3 minutes around the incident.
|
||||
- If the issue involves lag or abnormal CPU usage, include `data/logs/event_loop_watchdog.log` and `data/logs/event_loop_watchdog.log.1`.
|
||||
- For Docker deployments, include the matching `docker logs` output.
|
||||
- Installed plugin list, and whether the issue still happens after disabling third-party plugins.
|
||||
|
||||
Before sharing logs, redact API keys, tokens, cookies, private chat content, and other sensitive information.
|
||||
@@ -1,83 +0,0 @@
|
||||
# 异常诊断
|
||||
|
||||
本文用于整理 AstrBot 出现异常时的通用排查方法。遇到问题时,先确定问题发生在哪个阶段,再收集对应日志;这样提交 Issue 时更容易复现和定位。
|
||||
|
||||
## 常见问题类型
|
||||
|
||||
- 启动失败:进程启动后退出、WebUI 打不开、数据库或配置加载失败。
|
||||
- 平台连接异常:QQ、OneBot、Telegram、企业微信等平台无法连接、收不到消息或无法发送消息。
|
||||
- 模型请求异常:长时间无回复、频繁超时、报 429/5xx、代理或 API Base 不可用。
|
||||
- 插件或 MCP 异常:启用失败、工具调用失败、依赖安装失败、MCP 服务无响应。
|
||||
- 任务卡顿或 CPU 异常:消息、主动任务、定时任务已经触发,但中间步骤很久才继续;或进程 CPU 长时间异常升高。
|
||||
|
||||
## 先看哪些日志
|
||||
|
||||
优先查看 AstrBot 主日志:
|
||||
|
||||
```text
|
||||
data/logs/astrbot.log
|
||||
```
|
||||
|
||||
如果使用 Docker 部署,也可以查看容器日志:
|
||||
|
||||
```bash
|
||||
docker logs <container-name>
|
||||
```
|
||||
|
||||
如果问题和任务卡顿、CPU 异常、多个会话同时变慢有关,还要查看事件循环诊断日志:
|
||||
|
||||
```text
|
||||
data/logs/event_loop_watchdog.log
|
||||
data/logs/event_loop_watchdog.log.1
|
||||
```
|
||||
|
||||
`event_loop_watchdog.log` 超过 1MB 后会轮转为 `.1`。
|
||||
|
||||
## 通用排查步骤
|
||||
|
||||
1. 确认问题发生的时间点,并截取该时间前后 1 到 3 分钟的日志。
|
||||
2. 确认问题范围:是所有平台都异常,还是只有某个平台、某个群、某个用户、某个插件异常。
|
||||
3. 如果是模型无回复或很慢,检查模型服务商状态、API Key、API Base、代理、网络、请求超时和重试日志。
|
||||
4. 如果是插件或 MCP 问题,先禁用最近安装或更新的插件,观察问题是否消失;同时检查插件依赖和 MCP 服务日志。
|
||||
5. 如果是平台收发消息异常,检查平台适配器是否已连接、平台后台配置是否正确、回调地址或 WebSocket 地址是否可访问。
|
||||
6. 如果是卡顿或 CPU 异常,参考下方“事件循环卡顿诊断”。
|
||||
|
||||
## 事件循环卡顿诊断
|
||||
|
||||
事件循环负责调度消息、插件、定时任务、模型请求和工具调用。如果它被同步代码阻塞,很多功能都会表现为延迟。
|
||||
|
||||
常见现象:
|
||||
|
||||
- 日志停在 `ready to request llm provider`、`acquired session lock for llm request`、工具调用结果之后,很久才继续。
|
||||
- 主动任务或定时任务已经触发,但中间某一步迟迟不继续。
|
||||
- 多个平台或多个会话同时变慢。
|
||||
- CPU 长时间 100%,或 CPU 不高但请求一直等待外部服务返回。
|
||||
|
||||
如果主日志出现以下内容,说明事件循环经历了明显延迟:
|
||||
|
||||
```text
|
||||
Event loop lag detected: 18.432s (threshold 15.000s).
|
||||
```
|
||||
|
||||
如果事件循环长时间没有恢复,AstrBot 会把 Python 线程栈写入:
|
||||
|
||||
```text
|
||||
data/logs/event_loop_watchdog.log
|
||||
```
|
||||
|
||||
查看该文件时,重点关注栈顶附近正在执行的代码。常见线索包括插件函数、平台适配器、MCP 工具、同步网络请求、`time.sleep()`、`subprocess.run()`、CPU 密集循环等。
|
||||
|
||||
## 提交 Issue 时请附带
|
||||
|
||||
提交问题时,请尽量提供以下信息:
|
||||
|
||||
- 问题发生的大致时间点和时区。
|
||||
- AstrBot 版本、部署方式(Docker、手动部署、桌面客户端等)、操作系统。
|
||||
- 触发方式:启动、普通聊天、群聊、平台回调、定时任务、MCP 工具、插件功能等。
|
||||
- 影响范围:所有会话、某个平台、某个群、某个用户,还是某个插件。
|
||||
- `data/logs/astrbot.log` 中问题发生前后 1 到 3 分钟的日志。
|
||||
- 如果存在卡顿或 CPU 异常,请附带 `data/logs/event_loop_watchdog.log` 和 `data/logs/event_loop_watchdog.log.1`。
|
||||
- 如果使用 Docker,请附带对应时间段的 `docker logs`。
|
||||
- 已安装插件列表,以及问题是否在禁用第三方插件后仍然出现。
|
||||
|
||||
提交日志前请先检查并遮盖 API Key、Token、Cookie、私聊内容等敏感信息。
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.26.5"
|
||||
version = "4.26.4"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
@@ -349,65 +348,6 @@ def test_firecrawl_tools_are_registered_as_builtin_tools():
|
||||
assert manager.is_builtin_tool("firecrawl_extract_web_page") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_shutdown_cleanup_runs_in_lifecycle_task(monkeypatch):
|
||||
"""Disabling an MCP server must clean up in the task that connected.
|
||||
|
||||
anyio cancel scopes entered in connect_to_server() can only be exited
|
||||
from the same task, otherwise the scope state is corrupted and its
|
||||
cancellation loop spins at 100% CPU (#9068).
|
||||
"""
|
||||
manager = FunctionToolManager()
|
||||
seen = {}
|
||||
|
||||
async def fake_connect(self, config, name):
|
||||
seen["connect_task"] = asyncio.current_task()
|
||||
|
||||
async def fake_list_tools(self):
|
||||
self.tools = []
|
||||
|
||||
async def fake_cleanup(self):
|
||||
seen["cleanup_task"] = asyncio.current_task()
|
||||
|
||||
monkeypatch.setattr(ftm.MCPClient, "connect_to_server", fake_connect)
|
||||
monkeypatch.setattr(ftm.MCPClient, "list_tools_and_save", fake_list_tools)
|
||||
monkeypatch.setattr(ftm.MCPClient, "cleanup", fake_cleanup)
|
||||
|
||||
await manager.enable_mcp_server("dummy", {"command": "python"}, timeout=5)
|
||||
await manager.disable_mcp_server("dummy", timeout=5)
|
||||
|
||||
assert seen["cleanup_task"] is seen["connect_task"]
|
||||
assert "dummy" not in manager.mcp_client_dict
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_shutdown_cleanup_survives_late_cancellation(monkeypatch):
|
||||
"""A cancellation arriving mid-cleanup must not abort the cleanup."""
|
||||
manager = FunctionToolManager()
|
||||
cleanup_calls = []
|
||||
|
||||
async def fake_connect(self, config, name):
|
||||
pass
|
||||
|
||||
async def fake_list_tools(self):
|
||||
self.tools = []
|
||||
|
||||
async def fake_cleanup(self):
|
||||
cleanup_calls.append(asyncio.current_task())
|
||||
if len(cleanup_calls) == 1:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
monkeypatch.setattr(ftm.MCPClient, "connect_to_server", fake_connect)
|
||||
monkeypatch.setattr(ftm.MCPClient, "list_tools_and_save", fake_list_tools)
|
||||
monkeypatch.setattr(ftm.MCPClient, "cleanup", fake_cleanup)
|
||||
|
||||
await manager.enable_mcp_server("dummy", {"command": "python"}, timeout=5)
|
||||
await manager.disable_mcp_server("dummy", timeout=5)
|
||||
|
||||
assert len(cleanup_calls) == 2
|
||||
assert "dummy" not in manager.mcp_client_dict
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modelscope_sync_enables_only_synced_servers(monkeypatch):
|
||||
class FakeResponse:
|
||||
|
||||
Reference in New Issue
Block a user