Fix WebUI startup blocking on slow gateway routes

This commit is contained in:
chengyongru 2026-06-13 13:26:49 +08:00 committed by Xubin Ren
parent 3a221d74cf
commit af0e3441d7
12 changed files with 280 additions and 17 deletions

View File

@ -685,6 +685,30 @@ class CliAppManager:
"catalog_updated_at": updated, "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: def _pip_package_from_install(self, app: dict[str, Any]) -> str | None:
install_cmd = str(app.get("install_cmd") or "") install_cmd = str(app.get("install_cmd") or "")
try: try:

View File

@ -736,6 +736,45 @@ class SessionManager:
return self._session_payload(repaired) return self._session_payload(repaired)
return None 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]]: def list_sessions(self) -> list[dict[str, Any]]:
""" """
List all sessions. List all sessions.

View File

@ -73,8 +73,11 @@ def _manager() -> CliAppManager:
) )
def cli_apps_payload() -> dict[str, Any]: def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
return _manager().payload() manager = _manager()
if installed_only:
return manager.installed_payload()
return manager.payload()
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]: def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:

View File

@ -107,7 +107,7 @@ class WebUISettingsRouter:
if path == "/api/settings/network-safety/update": if path == "/api/settings/network-safety/update":
return self._handle_settings_network_safety_update(request) return self._handle_settings_network_safety_update(request)
if path == "/api/settings/cli-apps": 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": if path == "/api/settings/cli-apps/install":
return await self._handle_settings_cli_apps_action(request, "install") return await self._handle_settings_cli_apps_action(request, "install")
if path == "/api/settings/cli-apps/update": if path == "/api/settings/cli-apps/update":
@ -299,11 +299,20 @@ class WebUISettingsRouter:
return self._error_response(e.status, e.message) return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="runtime")) 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): if not self._authorized(request):
return self._unauthorized() return self._unauthorized()
query = self._query(request)
installed_only = (query.get("installed_only") or [""])[0].lower() in {
"1",
"true",
"yes",
}
try: 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: except Exception:
self.logger.exception("failed to load CLI Apps payload") self.logger.exception("failed to load CLI Apps payload")
return self._error_response(500, "failed to load CLI Apps") return self._error_response(500, "failed to load CLI Apps")

View File

@ -182,7 +182,11 @@ class WebUIWorkspaceController:
def scope_for_session_key(self, session_key: str) -> WorkspaceScope: def scope_for_session_key(self, session_key: str) -> WorkspaceScope:
if self._sessions is None: if self._sessions is None:
return self.default_scope() 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 {} metadata = data.get("metadata", {}) if isinstance(data, dict) else {}
if not isinstance(metadata, dict) or WORKSPACE_SCOPE_METADATA_KEY not in metadata: if not isinstance(metadata, dict) or WORKSPACE_SCOPE_METADATA_KEY not in metadata:
return self.default_scope() return self.default_scope()

View File

@ -9,9 +9,11 @@ Also houses shared HTTP utility functions used by both this module and
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import mimetypes import mimetypes
import re import re
import time
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any 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.transcript import build_webui_thread_response
from nanobot.webui.workspaces import WebUIWorkspaceController from nanobot.webui.workspaces import WebUIWorkspaceController
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
@ -192,7 +196,21 @@ class GatewayHTTPHandler:
async def dispatch(self, connection: Any, request: WsRequest) -> Any | None: async def dispatch(self, connection: Any, request: WsRequest) -> Any | None:
"""Route an HTTP request. Returns Response or None.""" """Route an HTTP request. Returns Response or None."""
got, _ = _parse_request_path(request.path) 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 # Token issue endpoint
if self.config.token_issue_path: if self.config.token_issue_path:
issue_expected = _normalize_config_path(self.config.token_issue_path) issue_expected = _normalize_config_path(self.config.token_issue_path)
@ -209,7 +227,7 @@ class GatewayHTTPHandler:
return response return response
# Session routes # Session routes
response = self._dispatch_session_routes(request, got) response = await self._dispatch_session_routes(request, got)
if response is not None: if response is not None:
return response return response
@ -219,7 +237,7 @@ class GatewayHTTPHandler:
return response return response
# Misc routes # Misc routes
response = self._dispatch_misc_routes(connection, request, got) response = await self._dispatch_misc_routes(connection, request, got)
if response is not None: if response is not None:
return response return response
@ -235,6 +253,20 @@ class GatewayHTTPHandler:
return connection.respond(404, "Not Found") 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 -------------------------------------------------------- # -- Token issue --------------------------------------------------------
def _handle_token_issue(self, connection: Any, request: Any) -> Any: def _handle_token_issue(self, connection: Any, request: Any) -> Any:
@ -302,7 +334,7 @@ class GatewayHTTPHandler:
# -- Session routes ----------------------------------------------------- # -- 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) m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
if m: if m:
return self._handle_session_messages(request, m.group(1)) return self._handle_session_messages(request, m.group(1))
@ -325,11 +357,16 @@ class GatewayHTTPHandler:
return None 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): if not self.check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
if self.session_manager is None: if self.session_manager is None:
return _http_error(503, "session manager unavailable") 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) sessions = list_webui_sessions(self.session_manager)
from nanobot.session.webui_turns import websocket_turn_wall_started_at 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) scope = self.workspaces.scope_for_session_key(key)
row["workspace_scope"] = scope.payload() row["workspace_scope"] = scope.payload()
cleaned.append(row) cleaned.append(row)
return _http_json_response({"sessions": cleaned}) return {"sessions": cleaned}
def _handle_session_messages(self, request: WsRequest, key: str) -> Response: def _handle_session_messages(self, request: WsRequest, key: str) -> Response:
if not self.check_api_token(request): if not self.check_api_token(request):
@ -496,11 +533,11 @@ class GatewayHTTPHandler:
# -- Misc routes -------------------------------------------------------- # -- Misc routes --------------------------------------------------------
def _dispatch_misc_routes( async def _dispatch_misc_routes(
self, connection: Any, request: WsRequest, got: str self, connection: Any, request: WsRequest, got: str
) -> Response | None: ) -> Response | None:
if got == "/api/sessions": if got == "/api/sessions":
return self._handle_sessions_list(request) return await self._handle_sessions_list(request)
if got == "/api/commands": if got == "/api/commands":
return self._handle_commands(request) return self._handle_commands(request)
if got == "/api/workspaces": if got == "/api/workspaces":

View File

@ -2654,7 +2654,7 @@ def test_sessions_list_includes_active_run_started_at(monkeypatch) -> None:
try: try:
wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-1"] = 1_700_000_000.0 wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-1"] = 1_700_000_000.0
req = Request("/api/sessions", Headers([("Authorization", "Bearer tok")])) 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: finally:
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear() wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()

View File

@ -3,6 +3,8 @@
import asyncio import asyncio
import functools import functools
import json import json
import threading
import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@ -462,6 +464,85 @@ async def test_cli_apps_routes_require_token_and_return_payload(
await server_task 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 @pytest.mark.asyncio
async def test_mcp_presets_routes_require_token_and_return_payload( async def test_mcp_presets_routes_require_token_and_return_payload(
bus: MagicMock, bus: MagicMock,

View File

@ -7,8 +7,8 @@ from nanobot.webui.workspaces import (
read_webui_default_access_mode, read_webui_default_access_mode,
read_webui_workspace_state, read_webui_workspace_state,
webui_workspace_state_path, webui_workspace_state_path,
write_webui_default_access_mode,
workspaces_payload, 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.project_path == project.resolve()
assert scope.access_mode == "full" 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"

View File

@ -11,7 +11,12 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport"; import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport";
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream"; import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions"; import { useSessionHistory } from "@/hooks/useSessions";
import { fetchCliApps, fetchMcpPresets, fetchSettings, listSlashCommands } from "@/lib/api"; import {
fetchInstalledCliApps,
fetchMcpPresets,
fetchSettings,
listSlashCommands,
} from "@/lib/api";
import { import {
CLI_APPS_CHANGED_EVENT, CLI_APPS_CHANGED_EVENT,
installedCliAppsFromPayload, installedCliAppsFromPayload,
@ -265,7 +270,7 @@ export function ThreadShell({
const cliApps = useInstalledSettingItems({ const cliApps = useInstalledSettingItems({
token, token,
eventName: CLI_APPS_CHANGED_EVENT, eventName: CLI_APPS_CHANGED_EVENT,
fetchPayload: fetchCliApps, fetchPayload: fetchInstalledCliApps,
isPayload: isCliAppsPayload, isPayload: isCliAppsPayload,
selectItems: installedCliAppsFromPayload, selectItems: installedCliAppsFromPayload,
}); });

View File

@ -294,6 +294,18 @@ export async function fetchCliApps(
); );
} }
export async function fetchInstalledCliApps(
token: string,
base: string = "",
): Promise<CliAppsPayload> {
return request<CliAppsPayload>(
`${base}/api/settings/cli-apps?installed_only=1`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function runCliAppAction( export async function runCliAppAction(
token: string, token: string,
action: "install" | "update" | "uninstall" | "test", action: "install" | "update" | "uninstall" | "test",

View File

@ -5,6 +5,7 @@ import {
deleteSession, deleteSession,
fetchFilePreview, fetchFilePreview,
fetchCliApps, fetchCliApps,
fetchInstalledCliApps,
fetchMcpPresets, fetchMcpPresets,
fetchProviderModels, fetchProviderModels,
fetchSessionAutomations, 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 () => { it("reads MCP presets and serializes actions", async () => {
vi.mocked(fetch).mockResolvedValueOnce({ vi.mocked(fetch).mockResolvedValueOnce({
ok: true, ok: true,