From af0e3441d719f5d4005453cd0035fe1ab1f4be36 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sat, 13 Jun 2026 13:26:49 +0800 Subject: [PATCH] Fix WebUI startup blocking on slow gateway routes --- nanobot/apps/cli/service.py | 24 ++++++ nanobot/session/manager.py | 39 ++++++++++ nanobot/webui/cli_apps_api.py | 7 +- nanobot/webui/settings_routes.py | 15 +++- nanobot/webui/workspaces.py | 6 +- nanobot/webui/ws_http.py | 51 ++++++++++-- tests/channels/test_websocket_channel.py | 2 +- tests/channels/test_websocket_http_routes.py | 81 ++++++++++++++++++++ tests/utils/test_webui_workspaces.py | 31 +++++++- webui/src/components/thread/ThreadShell.tsx | 9 ++- webui/src/lib/api.ts | 12 +++ webui/src/tests/api.test.ts | 20 +++++ 12 files changed, 280 insertions(+), 17 deletions(-) diff --git a/nanobot/apps/cli/service.py b/nanobot/apps/cli/service.py index b0ed0937..63a98043 100644 --- a/nanobot/apps/cli/service.py +++ b/nanobot/apps/cli/service.py @@ -685,6 +685,30 @@ class CliAppManager: "catalog_updated_at": updated, } + def installed_payload(self) -> dict[str, Any]: + installed = self._load_installed() + rows = [] + for name, raw_entry in sorted(installed.items()): + entry = raw_entry if isinstance(raw_entry, dict) else {} + strategy = str(entry.get("strategy") or "bundled") + app = { + "name": str(name), + "display_name": str(entry.get("display_name") or name), + "category": str(entry.get("category") or "installed"), + "description": str(entry.get("description") or ""), + "requires": str(entry.get("requires") or ""), + "_source": str(entry.get("source") or "local"), + "entry_point": str(entry.get("entry_point") or ""), + "package_manager": strategy, + "install_strategy": strategy, + } + rows.append(self._app_payload(app, installed)) + return { + "apps": rows, + "installed_count": len(rows), + "catalog_updated_at": None, + } + def _pip_package_from_install(self, app: dict[str, Any]) -> str | None: install_cmd = str(app.get("install_cmd") or "") try: diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 890b25c2..5f0af684 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -736,6 +736,45 @@ class SessionManager: return self._session_payload(repaired) return None + def read_session_metadata(self, key: str) -> dict[str, Any] | None: + """Load only the metadata record from a session file. + + This is used by WebUI routes that need session-level metadata but not the + full conversation transcript. + """ + path = self._get_session_path(key) + if not path.exists(): + return None + try: + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + data = json.loads(line) + if data.get("_type") != "metadata": + return None + metadata = data.get("metadata", {}) + return { + "key": data.get("key") or key, + "created_at": data.get("created_at"), + "updated_at": data.get("updated_at"), + "metadata": metadata if isinstance(metadata, dict) else {}, + } + return None + except Exception as e: + logger.warning("Failed to read session metadata {}: {}", key, e) + repaired = self._repair(key) + if repaired is not None: + logger.info("Recovered read-only session metadata {} from corrupt file", key) + return { + "key": repaired.key, + "created_at": repaired.created_at.isoformat(), + "updated_at": repaired.updated_at.isoformat(), + "metadata": repaired.metadata, + } + return None + def list_sessions(self) -> list[dict[str, Any]]: """ List all sessions. diff --git a/nanobot/webui/cli_apps_api.py b/nanobot/webui/cli_apps_api.py index 1e6fdfea..b9170d2e 100644 --- a/nanobot/webui/cli_apps_api.py +++ b/nanobot/webui/cli_apps_api.py @@ -73,8 +73,11 @@ def _manager() -> CliAppManager: ) -def cli_apps_payload() -> dict[str, Any]: - return _manager().payload() +def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]: + manager = _manager() + if installed_only: + return manager.installed_payload() + return manager.payload() def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]: diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py index 01765233..0969db8e 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -107,7 +107,7 @@ class WebUISettingsRouter: if path == "/api/settings/network-safety/update": return self._handle_settings_network_safety_update(request) if path == "/api/settings/cli-apps": - return self._handle_settings_cli_apps(request) + return await self._handle_settings_cli_apps(request) if path == "/api/settings/cli-apps/install": return await self._handle_settings_cli_apps_action(request, "install") if path == "/api/settings/cli-apps/update": @@ -299,11 +299,20 @@ class WebUISettingsRouter: return self._error_response(e.status, e.message) return self._json_response(self._with_restart_state(payload, section="runtime")) - def _handle_settings_cli_apps(self, request: WsRequest) -> Response: + async def _handle_settings_cli_apps(self, request: WsRequest) -> Response: if not self._authorized(request): return self._unauthorized() + query = self._query(request) + installed_only = (query.get("installed_only") or [""])[0].lower() in { + "1", + "true", + "yes", + } try: - payload = cli_apps_payload() + if installed_only: + payload = await asyncio.to_thread(cli_apps_payload, installed_only=True) + else: + payload = await asyncio.to_thread(cli_apps_payload) except Exception: self.logger.exception("failed to load CLI Apps payload") return self._error_response(500, "failed to load CLI Apps") diff --git a/nanobot/webui/workspaces.py b/nanobot/webui/workspaces.py index 774f2857..2d462417 100644 --- a/nanobot/webui/workspaces.py +++ b/nanobot/webui/workspaces.py @@ -182,7 +182,11 @@ class WebUIWorkspaceController: def scope_for_session_key(self, session_key: str) -> WorkspaceScope: if self._sessions is None: return self.default_scope() - data = self._sessions.read_session_file(session_key) + metadata_reader = getattr(self._sessions, "read_session_metadata", None) + if callable(metadata_reader): + data = metadata_reader(session_key) + else: + data = self._sessions.read_session_file(session_key) metadata = data.get("metadata", {}) if isinstance(data, dict) else {} if not isinstance(metadata, dict) or WORKSPACE_SCOPE_METADATA_KEY not in metadata: return self.default_scope() diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 2ccdedac..8d2694f6 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -9,9 +9,11 @@ Also houses shared HTTP utility functions used by both this module and from __future__ import annotations +import asyncio import json import mimetypes import re +import time from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, Any @@ -76,6 +78,8 @@ from nanobot.webui.thread_disk import delete_webui_thread from nanobot.webui.transcript import build_webui_thread_response from nanobot.webui.workspaces import WebUIWorkspaceController +_SLOW_WEBUI_HTTP_LOG_MS = 1_000 + if TYPE_CHECKING: from nanobot.bus.queue import MessageBus from nanobot.cron.service import CronService @@ -192,7 +196,21 @@ class GatewayHTTPHandler: async def dispatch(self, connection: Any, request: WsRequest) -> Any | None: """Route an HTTP request. Returns Response or None.""" got, _ = _parse_request_path(request.path) + started = time.perf_counter() + response: Any | None = None + try: + response = await self._dispatch_resolved(connection, request, got) + return response + finally: + self._log_slow_http(got, response, started) + + async def _dispatch_resolved( + self, + connection: Any, + request: WsRequest, + got: str, + ) -> Any | None: # Token issue endpoint if self.config.token_issue_path: issue_expected = _normalize_config_path(self.config.token_issue_path) @@ -209,7 +227,7 @@ class GatewayHTTPHandler: return response # Session routes - response = self._dispatch_session_routes(request, got) + response = await self._dispatch_session_routes(request, got) if response is not None: return response @@ -219,7 +237,7 @@ class GatewayHTTPHandler: return response # Misc routes - response = self._dispatch_misc_routes(connection, request, got) + response = await self._dispatch_misc_routes(connection, request, got) if response is not None: return response @@ -235,6 +253,20 @@ class GatewayHTTPHandler: return connection.respond(404, "Not Found") + def _log_slow_http(self, path: str, response: Any | None, started: float) -> None: + elapsed_ms = int((time.perf_counter() - started) * 1000) + if elapsed_ms < _SLOW_WEBUI_HTTP_LOG_MS: + return + if not (path.startswith("/api/") or path == "/webui/bootstrap"): + return + status = getattr(response, "status_code", None) + self._log.warning( + "slow webui http route path={} status={} duration_ms={}", + path, + status if status is not None else "none", + elapsed_ms, + ) + # -- Token issue -------------------------------------------------------- def _handle_token_issue(self, connection: Any, request: Any) -> Any: @@ -302,7 +334,7 @@ class GatewayHTTPHandler: # -- Session routes ----------------------------------------------------- - def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None: + async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None: m = re.match(r"^/api/sessions/([^/]+)/messages$", got) if m: return self._handle_session_messages(request, m.group(1)) @@ -325,11 +357,16 @@ class GatewayHTTPHandler: return None - def _handle_sessions_list(self, request: WsRequest) -> Response: + async def _handle_sessions_list(self, request: WsRequest) -> Response: if not self.check_api_token(request): return _http_error(401, "Unauthorized") if self.session_manager is None: return _http_error(503, "session manager unavailable") + payload = await asyncio.to_thread(self._sessions_list_payload) + return _http_json_response(payload) + + def _sessions_list_payload(self) -> dict[str, Any]: + assert self.session_manager is not None sessions = list_webui_sessions(self.session_manager) from nanobot.session.webui_turns import websocket_turn_wall_started_at @@ -346,7 +383,7 @@ class GatewayHTTPHandler: scope = self.workspaces.scope_for_session_key(key) row["workspace_scope"] = scope.payload() cleaned.append(row) - return _http_json_response({"sessions": cleaned}) + return {"sessions": cleaned} def _handle_session_messages(self, request: WsRequest, key: str) -> Response: if not self.check_api_token(request): @@ -496,11 +533,11 @@ class GatewayHTTPHandler: # -- Misc routes -------------------------------------------------------- - def _dispatch_misc_routes( + async def _dispatch_misc_routes( self, connection: Any, request: WsRequest, got: str ) -> Response | None: if got == "/api/sessions": - return self._handle_sessions_list(request) + return await self._handle_sessions_list(request) if got == "/api/commands": return self._handle_commands(request) if got == "/api/workspaces": diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 44eae486..b695665c 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -2654,7 +2654,7 @@ def test_sessions_list_includes_active_run_started_at(monkeypatch) -> None: try: wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-1"] = 1_700_000_000.0 req = Request("/api/sessions", Headers([("Authorization", "Bearer tok")])) - resp = channel.gateway.http._handle_sessions_list(req) + resp = asyncio.run(channel.gateway.http._handle_sessions_list(req)) finally: wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear() diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 63dba486..fe74666c 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -3,6 +3,8 @@ import asyncio import functools import json +import threading +import time from pathlib import Path from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -462,6 +464,85 @@ async def test_cli_apps_routes_require_token_and_return_payload( await server_task +@pytest.mark.asyncio +async def test_cli_apps_catalog_does_not_block_other_webui_http_routes( + bus: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + entered = threading.Event() + release = threading.Event() + + def slow_payload() -> dict[str, Any]: + entered.set() + release.wait(2.0) + return {"apps": [], "installed_count": 0, "catalog_updated_at": None} + + monkeypatch.setattr("nanobot.webui.settings_routes.cli_apps_payload", slow_payload) + channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29935) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + try: + boot = await _http_get("http://127.0.0.1:29935/webui/bootstrap") + token = boot.json()["token"] + auth = {"Authorization": f"Bearer {token}"} + + started = time.perf_counter() + catalog_task = asyncio.create_task( + _http_get("http://127.0.0.1:29935/api/settings/cli-apps", headers=auth) + ) + assert await asyncio.to_thread(entered.wait, 2.0) + assert time.perf_counter() - started < 1.0 + + workspaces_started = time.perf_counter() + workspaces = await _http_get("http://127.0.0.1:29935/api/workspaces", headers=auth) + assert time.perf_counter() - workspaces_started < 1.0 + assert workspaces.status_code == 200 + + release.set() + catalog = await catalog_task + assert catalog.status_code == 200 + assert catalog.json()["apps"] == [] + finally: + release.set() + await channel.stop() + await server_task + + +@pytest.mark.asyncio +async def test_cli_apps_route_supports_installed_only_payload( + bus: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[bool] = [] + + def payload(*, installed_only: bool = False) -> dict[str, Any]: + calls.append(installed_only) + return {"apps": [], "installed_count": 0, "catalog_updated_at": None} + + monkeypatch.setattr("nanobot.webui.settings_routes.cli_apps_payload", payload) + channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29936) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + try: + boot = await _http_get("http://127.0.0.1:29936/webui/bootstrap") + token = boot.json()["token"] + auth = {"Authorization": f"Bearer {token}"} + + resp = await _http_get( + "http://127.0.0.1:29936/api/settings/cli-apps?installed_only=1", + headers=auth, + ) + + assert resp.status_code == 200 + assert resp.json()["apps"] == [] + assert calls == [True] + finally: + await channel.stop() + await server_task + + @pytest.mark.asyncio async def test_mcp_presets_routes_require_token_and_return_payload( bus: MagicMock, diff --git a/tests/utils/test_webui_workspaces.py b/tests/utils/test_webui_workspaces.py index cf7941b6..eedbd086 100644 --- a/tests/utils/test_webui_workspaces.py +++ b/tests/utils/test_webui_workspaces.py @@ -7,8 +7,8 @@ from nanobot.webui.workspaces import ( read_webui_default_access_mode, read_webui_workspace_state, webui_workspace_state_path, - write_webui_default_access_mode, workspaces_payload, + write_webui_default_access_mode, ) @@ -152,3 +152,32 @@ def test_webui_default_access_does_not_override_explicit_session_scope(tmp_path, assert scope.project_path == project.resolve() assert scope.access_mode == "full" + + +def test_scope_for_session_key_reads_metadata_without_full_history( + tmp_path, + monkeypatch, +) -> None: + monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui") + default = tmp_path / "default" + project = tmp_path / "project" + default.mkdir() + project.mkdir() + sessions = SessionManager(tmp_path / "sessions") + controller = WebUIWorkspaceController( + session_manager=sessions, + default_workspace=default, + default_restrict_to_workspace=True, + ) + explicit = default_workspace_scope(project, restrict_to_workspace=False) + controller.persist_scope("metadata-only", explicit) + + def fail_full_read(_key: str) -> None: + raise AssertionError("scope lookup should not read full session history") + + monkeypatch.setattr(sessions, "read_session_file", fail_full_read) + + scope = controller.scope_for_session_key("websocket:metadata-only") + + assert scope.project_path == project.resolve() + assert scope.access_mode == "full" diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 3d9d332f..113f660a 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -11,7 +11,12 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice"; import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport"; import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream"; import { useSessionHistory } from "@/hooks/useSessions"; -import { fetchCliApps, fetchMcpPresets, fetchSettings, listSlashCommands } from "@/lib/api"; +import { + fetchInstalledCliApps, + fetchMcpPresets, + fetchSettings, + listSlashCommands, +} from "@/lib/api"; import { CLI_APPS_CHANGED_EVENT, installedCliAppsFromPayload, @@ -265,7 +270,7 @@ export function ThreadShell({ const cliApps = useInstalledSettingItems({ token, eventName: CLI_APPS_CHANGED_EVENT, - fetchPayload: fetchCliApps, + fetchPayload: fetchInstalledCliApps, isPayload: isCliAppsPayload, selectItems: installedCliAppsFromPayload, }); diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 5a6fee9c..ec047c50 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -294,6 +294,18 @@ export async function fetchCliApps( ); } +export async function fetchInstalledCliApps( + token: string, + base: string = "", +): Promise { + return request( + `${base}/api/settings/cli-apps?installed_only=1`, + token, + undefined, + API_READ_TIMEOUT_MS, + ); +} + export async function runCliAppAction( token: string, action: "install" | "update" | "uninstall" | "test", diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index 71a5b864..7957b975 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -5,6 +5,7 @@ import { deleteSession, fetchFilePreview, fetchCliApps, + fetchInstalledCliApps, fetchMcpPresets, fetchProviderModels, fetchSessionAutomations, @@ -375,6 +376,25 @@ describe("webui API helpers", () => { ); }); + it("reads installed CLI Apps without fetching the full catalog", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + apps: [], + installed_count: 0, + catalog_updated_at: null, + }), + } as Response); + + await expect(fetchInstalledCliApps("tok")).resolves.toMatchObject({ apps: [] }); + expect(fetch).toHaveBeenCalledWith( + "/api/settings/cli-apps?installed_only=1", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + }); + it("reads MCP presets and serializes actions", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true,