fix(webui): make mutations reconnect-safe

This commit is contained in:
chengyongru
2026-08-16 21:40:14 +08:00
committed by chengyongru
parent dec89a49a3
commit e51ffc8978
4 changed files with 325 additions and 47 deletions
+123 -36
View File
@@ -3,14 +3,17 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import hmac import hmac
import ipaddress import ipaddress
import json import json
import re import re
import ssl import ssl
import time
import uuid import uuid
from collections.abc import Callable from collections.abc import Callable
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Self, TypeGuard, cast from typing import Any, Self, TypeGuard, cast
from urllib.parse import urlsplit, urlunsplit from urllib.parse import urlsplit, urlunsplit
@@ -92,6 +95,8 @@ from nanobot.webui.websocket_logging import websockets_server_logger
# Plain HTTP WebUI routes also run through websockets.process_request. # Plain HTTP WebUI routes also run through websockets.process_request.
_WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0 _WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0
_WEBUI_REQUEST_CACHE_TTL_S = 5 * 60.0
_WEBUI_REQUEST_CACHE_MAX = 256
_ROUTING_ASSERTION_HEADERS = frozenset( _ROUTING_ASSERTION_HEADERS = frozenset(
@@ -348,6 +353,21 @@ def _is_websocket_upgrade(request: WsRequest) -> bool:
return True return True
@dataclass(frozen=True)
class _WebUIRequestResult:
result: Any = None
status: int | None = None
message: str | None = None
@dataclass
class _WebUIRequestOperation:
action: str
payload_digest: bytes
task: asyncio.Task[_WebUIRequestResult]
completed_at: float | None = None
class WebSocketChannel(BaseChannel): class WebSocketChannel(BaseChannel):
"""Run a local WebSocket server; forward text/JSON messages to the message bus.""" """Run a local WebSocket server; forward text/JSON messages to the message bus."""
@@ -373,14 +393,14 @@ class WebSocketChannel(BaseChannel):
self._conn_default: dict[ServerConnection, str] = {} self._conn_default: dict[ServerConnection, str] = {}
# Connections authenticated with a one-time token from /webui/bootstrap. # Connections authenticated with a one-time token from /webui/bootstrap.
self._webui_connections: set[ServerConnection] = set() self._webui_connections: set[ServerConnection] = set()
# Request/reply mutations aren't replayed across reconnects. Tasks may # Delivery tasks are connection-bound, while operations are keyed only
# finish after a client-side deadline so an already-started mutation # by request_id so reconnect retries join or replay the original work.
# isn't ambiguously cancelled halfway through.
self._webui_request_tasks: dict[ self._webui_request_tasks: dict[
tuple[ServerConnection, str], tuple[ServerConnection, str],
asyncio.Task[None], asyncio.Task[None],
] = {} ] = {}
# Preserve request/response order for non-replayable mutations from one self._webui_request_operations: dict[str, _WebUIRequestOperation] = {}
# Preserve request/response order for mutations from one
# UI. Without this, an earlier slow settings response can overwrite a # UI. Without this, an earlier slow settings response can overwrite a
# newer settings snapshot in the client. # newer settings snapshot in the client.
self._webui_request_locks: dict[ServerConnection, asyncio.Lock] = {} self._webui_request_locks: dict[ServerConnection, asyncio.Lock] = {}
@@ -1182,32 +1202,107 @@ class WebSocketChannel(BaseChannel):
) )
return return
key = (connection, request_id) payload_digest = hashlib.sha256(
if key in self._webui_request_tasks: json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).digest()
self._prune_webui_request_operations()
operation = self._webui_request_operations.get(request_id)
if operation is not None and (
operation.action != action or operation.payload_digest != payload_digest
):
await self._send_webui_response( await self._send_webui_response(
connection, connection,
request_id, request_id,
status=409, status=409,
message="duplicate WebUI request_id", message="request_id was already used for a different WebUI mutation",
) )
return return
task = asyncio.create_task( if operation is None:
self._complete_webui_request( operation_task = asyncio.create_task(
self._execute_webui_request(
connection,
action,
cast(dict[str, Any], payload),
)
)
new_operation = _WebUIRequestOperation(
action=action,
payload_digest=payload_digest,
task=operation_task,
)
operation = new_operation
self._webui_request_operations[request_id] = new_operation
def mark_complete(_task: asyncio.Task[_WebUIRequestResult]) -> None:
current = self._webui_request_operations.get(request_id)
if current is not new_operation:
return
new_operation.completed_at = time.monotonic()
self._prune_webui_request_operations()
operation_task.add_done_callback(mark_complete)
key = (connection, request_id)
if key in self._webui_request_tasks:
return
delivery_task = asyncio.create_task(
self._deliver_webui_request(
connection, connection,
request_id, request_id,
action, operation.task,
cast(dict[str, Any], payload),
) )
) )
self._webui_request_tasks[key] = task self._webui_request_tasks[key] = delivery_task
async def _complete_webui_request( def _prune_webui_request_operations(self) -> None:
now = time.monotonic()
for request_id, operation in tuple(self._webui_request_operations.items()):
if (
operation.completed_at is not None
and now - operation.completed_at >= _WEBUI_REQUEST_CACHE_TTL_S
):
self._webui_request_operations.pop(request_id, None)
completed = sorted(
(
(operation.completed_at, request_id)
for request_id, operation in self._webui_request_operations.items()
if operation.completed_at is not None
),
key=lambda item: item[0],
)
for _, request_id in completed[:-_WEBUI_REQUEST_CACHE_MAX]:
self._webui_request_operations.pop(request_id, None)
async def _deliver_webui_request(
self, self,
connection: ServerConnection, connection: ServerConnection,
request_id: str, request_id: str,
operation_task: asyncio.Task[_WebUIRequestResult],
) -> None:
try:
result = await asyncio.shield(operation_task)
await self._send_webui_response(
connection,
request_id,
result=result.result,
status=result.status,
message=result.message,
)
finally:
self._webui_request_tasks.pop((connection, request_id), None)
async def _execute_webui_request(
self,
connection: ServerConnection,
action: str, action: str,
payload: dict[str, Any], payload: dict[str, Any],
) -> None: ) -> _WebUIRequestResult:
try: try:
lock = self._webui_request_locks.setdefault(connection, asyncio.Lock()) lock = self._webui_request_locks.setdefault(connection, asyncio.Lock())
async with lock: async with lock:
@@ -1222,27 +1317,17 @@ class WebSocketChannel(BaseChannel):
try: try:
result = json.loads(body) result = json.loads(body)
except json.JSONDecodeError: except json.JSONDecodeError:
await self._send_webui_response( return _WebUIRequestResult(
connection,
request_id,
status=502, status=502,
message="WebUI mutation returned an invalid response", message="WebUI mutation returned an invalid response",
) )
return
if action == "sidebar.update" and isinstance(result, dict): if action == "sidebar.update" and isinstance(result, dict):
await self._broadcast_webui_event( await self._broadcast_webui_event(
"sidebar_state_updated", "sidebar_state_updated",
state=result, state=result,
) )
await self._send_webui_response( return _WebUIRequestResult(result=result)
connection, return _WebUIRequestResult(
request_id,
result=result,
)
return
await self._send_webui_response(
connection,
request_id,
status=status, status=status,
message=body or response.reason_phrase, message=body or response.reason_phrase,
) )
@@ -1250,14 +1335,10 @@ class WebSocketChannel(BaseChannel):
raise raise
except Exception: except Exception:
self.logger.exception("WebUI mutation '{}' failed", action) self.logger.exception("WebUI mutation '{}' failed", action)
await self._send_webui_response( return _WebUIRequestResult(
connection,
request_id,
status=500, status=500,
message="WebUI mutation failed", message="WebUI mutation failed",
) )
finally:
self._webui_request_tasks.pop((connection, request_id), None)
async def _send_webui_response( async def _send_webui_response(
self, self,
@@ -1328,13 +1409,19 @@ class WebSocketChannel(BaseChannel):
except Exception as e: except Exception as e:
self.logger.warning("server task error during shutdown: {}", e) self.logger.warning("server task error during shutdown: {}", e)
self._server_task = None self._server_task = None
mutation_tasks = tuple(self._webui_request_tasks.values()) delivery_tasks = tuple(self._webui_request_tasks.values())
for task in mutation_tasks: operation_tasks = tuple(
operation.task for operation in self._webui_request_operations.values()
)
for task in (*delivery_tasks, *operation_tasks):
task.cancel() task.cancel()
if mutation_tasks: if delivery_tasks:
await asyncio.gather(*mutation_tasks, return_exceptions=True) await asyncio.gather(*delivery_tasks, return_exceptions=True)
if operation_tasks:
await asyncio.gather(*operation_tasks, return_exceptions=True)
self._webui_request_tasks.clear() self._webui_request_tasks.clear()
self._webui_request_locks.clear() self._webui_request_locks.clear()
self._webui_request_operations.clear()
self._subs.clear() self._subs.clear()
self._conn_chats.clear() self._conn_chats.clear()
self._conn_default.clear() self._conn_default.clear()
@@ -1002,6 +1002,126 @@ async def test_webui_mutations_preserve_request_and_response_order(bus: MagicMoc
] ]
@pytest.mark.asyncio
async def test_webui_request_reuses_inflight_operation_after_reconnect(bus: MagicMock) -> None:
channel = _ch(bus)
first_conn = AsyncMock()
retry_conn = AsyncMock()
channel._webui_connections.update({first_conn, retry_conn})
started = asyncio.Event()
release = asyncio.Event()
async def mutate(*_args: Any) -> Any:
started.set()
await release.wait()
return _http_json_response({"ran": True})
channel.gateway.http.dispatch_webui_mutation = AsyncMock(side_effect=mutate)
envelope = {
"type": "webui_request",
"request_id": "request-retry",
"action": "automation.run",
"payload": {"id": "daily-summary"},
}
await channel._dispatch_envelope(first_conn, "webui-client", envelope)
await started.wait()
await channel._dispatch_envelope(retry_conn, "webui-client", envelope)
pending = tuple(channel._webui_request_tasks.values())
release.set()
await asyncio.gather(*pending)
channel.gateway.http.dispatch_webui_mutation.assert_awaited_once_with(
first_conn,
"automation.run",
{"id": "daily-summary"},
)
expected = {
"event": "webui_response",
"request_id": "request-retry",
"ok": True,
"result": {"ran": True},
}
assert json.loads(first_conn.send.await_args.args[0]) == expected
assert json.loads(retry_conn.send.await_args.args[0]) == expected
@pytest.mark.asyncio
async def test_webui_request_replays_completed_result_after_reconnect(bus: MagicMock) -> None:
channel = _ch(bus)
first_conn = AsyncMock()
retry_conn = AsyncMock()
channel._webui_connections.update({first_conn, retry_conn})
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
return_value=_http_json_response({"installed": True})
)
envelope = {
"type": "webui_request",
"request_id": "request-completed",
"action": "skill.install",
"payload": {"skill": "demo"},
}
await channel._dispatch_envelope(first_conn, "webui-client", envelope)
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
await channel._dispatch_envelope(retry_conn, "webui-client", envelope)
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
channel.gateway.http.dispatch_webui_mutation.assert_awaited_once()
assert json.loads(retry_conn.send.await_args.args[0]) == {
"event": "webui_response",
"request_id": "request-completed",
"ok": True,
"result": {"installed": True},
}
@pytest.mark.asyncio
async def test_webui_request_rejects_request_id_reuse_with_different_payload(
bus: MagicMock,
) -> None:
channel = _ch(bus)
first_conn = AsyncMock()
retry_conn = AsyncMock()
channel._webui_connections.update({first_conn, retry_conn})
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
return_value=_http_json_response({"ran": True})
)
await channel._dispatch_envelope(
first_conn,
"webui-client",
{
"type": "webui_request",
"request_id": "request-conflict",
"action": "automation.run",
"payload": {"id": "job-a"},
},
)
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
await channel._dispatch_envelope(
retry_conn,
"webui-client",
{
"type": "webui_request",
"request_id": "request-conflict",
"action": "automation.run",
"payload": {"id": "job-b"},
},
)
channel.gateway.http.dispatch_webui_mutation.assert_awaited_once()
assert json.loads(retry_conn.send.await_args.args[0]) == {
"event": "webui_response",
"request_id": "request-conflict",
"ok": False,
"error": {
"status": 409,
"message": "request_id was already used for a different WebUI mutation",
},
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_webui_request_returns_correlated_route_error(bus: MagicMock) -> None: async def test_webui_request_returns_correlated_route_error(bus: MagicMock) -> None:
channel = _ch(bus) channel = _ch(bus)
+24 -11
View File
@@ -109,6 +109,12 @@ interface PendingRequest<T> {
timer: ReturnType<typeof setTimeout>; timer: ReturnType<typeof setTimeout>;
} }
type WebUIRequestFrame = Extract<Outbound, { type: "webui_request" }>;
interface PendingWebUIRequest extends PendingRequest<unknown> {
frame: WebUIRequestFrame;
}
export class WebUIMutationError extends Error { export class WebUIMutationError extends Error {
status: number; status: number;
@@ -215,7 +221,7 @@ export class NanobotClient {
private pendingNewChat: PendingChatRequest | null = null; private pendingNewChat: PendingChatRequest | null = null;
private pendingTranscriptions = new Map<string, PendingRequest<string>>(); private pendingTranscriptions = new Map<string, PendingRequest<string>>();
private pendingSystemCommands = new Map<string, PendingRequest<void>>(); private pendingSystemCommands = new Map<string, PendingRequest<void>>();
private pendingWebUIRequests = new Map<string, PendingRequest<unknown>>(); private pendingWebUIRequests = new Map<string, PendingWebUIRequest>();
// Frames queued while the socket is not yet OPEN // Frames queued while the socket is not yet OPEN
private sendQueue: Outbound[] = []; private sendQueue: Outbound[] = [];
private reconnectAttempts = 0; private reconnectAttempts = 0;
@@ -833,9 +839,9 @@ export class NanobotClient {
} }
/** /**
* Send one non-replayable WebUI mutation over the authenticated socket. * Send one WebUI mutation over the authenticated socket. Pending requests are
* A client-side timeout only abandons the reply; the server may finish work * replayed with the same request_id after reconnect so the gateway can join or
* that already started, so timed-out requests are never retried automatically. * replay the original operation. A client-side timeout still ends all retries.
*/ */
requestMutation<T>( requestMutation<T>(
action: string, action: string,
@@ -875,6 +881,7 @@ export class NanobotClient {
resolve: (value) => resolve(value as T), resolve: (value) => resolve(value as T),
reject, reject,
timer, timer,
frame,
}); });
try { try {
socket.send(JSON.stringify(frame)); socket.send(JSON.stringify(frame));
@@ -1020,6 +1027,9 @@ export class NanobotClient {
for (const chatId of this.knownChats) { for (const chatId of this.knownChats) {
this.rawSend({ type: "attach", chat_id: chatId }); this.rawSend({ type: "attach", chat_id: chatId });
} }
for (const pending of this.pendingWebUIRequests.values()) {
this.rawSend(pending.frame);
}
// Flush anything queued during reconnect. // Flush anything queued during reconnect.
const queued = this.sendQueue.splice(0); const queued = this.sendQueue.splice(0);
for (const frame of queued) this.rawSend(frame); for (const frame of queued) this.rawSend(frame);
@@ -1252,19 +1262,22 @@ export class NanobotClient {
private handleClose(event?: { code?: number }): void { private handleClose(event?: { code?: number }): void {
this.socket = null; this.socket = null;
this.clearTemporaryChats(); this.clearTemporaryChats();
const willReconnect = !this.intentionallyClosed && this.shouldReconnect;
if (this.pendingNewChat) { if (this.pendingNewChat) {
clearTimeout(this.pendingNewChat.timer); clearTimeout(this.pendingNewChat.timer);
this.pendingNewChat.reject(new Error("socket closed")); this.pendingNewChat.reject(new Error("socket closed"));
this.pendingNewChat = null; this.pendingNewChat = null;
} }
this.rejectAllTranscriptions("socket closed"); this.rejectAllTranscriptions("socket closed");
for (const pending of this.pendingWebUIRequests.values()) { if (!willReconnect) {
clearTimeout(pending.timer); for (const pending of this.pendingWebUIRequests.values()) {
pending.reject( clearTimeout(pending.timer);
new WebUIMutationError(503, "Socket closed before WebUI response"), pending.reject(
); new WebUIMutationError(503, "Socket closed before WebUI response"),
);
}
this.pendingWebUIRequests.clear();
} }
this.pendingWebUIRequests.clear();
for (const pending of this.pendingSystemCommands.values()) { for (const pending of this.pendingSystemCommands.values()) {
clearTimeout(pending.timer); clearTimeout(pending.timer);
pending.reject(new Error("socket closed")); pending.reject(new Error("socket closed"));
@@ -1312,7 +1325,7 @@ export class NanobotClient {
} }
this.socketPendingMessageSendKeys.clear(); this.socketPendingMessageSendKeys.clear();
this.lastSocketMessageSendKey = null; this.lastSocketMessageSendKey = null;
if (this.intentionallyClosed || !this.shouldReconnect) { if (!willReconnect) {
this.setStatus("closed"); this.setStatus("closed");
return; return;
} }
+58
View File
@@ -172,6 +172,64 @@ describe("NanobotClient", () => {
}); });
}); });
it("retries in-flight WebUI mutations with the same request id after reconnect", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: true,
maxBackoffMs: 10,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
const firstSocket = lastSocket();
firstSocket.fakeOpen();
const pending = client.requestMutation<{ ran: boolean }>(
"automation.run",
{ id: "daily-summary" },
);
const frame = firstSocket.sent.at(-1) as string;
const requestId = JSON.parse(frame).request_id;
const settled = expect(pending).resolves.toEqual({ ran: true });
firstSocket.fakeCloseWithCode(1006);
await vi.advanceTimersByTimeAsync(20);
const retrySocket = lastSocket();
retrySocket.fakeOpen();
expect(retrySocket.sent).toEqual([frame]);
retrySocket.fakeMessage({
event: "webui_response",
request_id: requestId,
ok: true,
result: { ran: true },
});
await settled;
});
it("does not retry a WebUI mutation after its timeout expires", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: true,
maxBackoffMs: 1_000,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
const firstSocket = lastSocket();
firstSocket.fakeOpen();
const pending = expect(
client.requestMutation("skill.install", { skill: "docs" }, 25),
).rejects.toMatchObject({ status: 504 });
firstSocket.fakeCloseWithCode(1006);
await vi.advanceTimersByTimeAsync(25);
await pending;
await vi.advanceTimersByTimeAsync(1_000);
const retrySocket = lastSocket();
retrySocket.fakeOpen();
expect(retrySocket.sent).toEqual([]);
});
it("does not queue WebUI mutations before the authenticated socket opens", async () => { it("does not queue WebUI mutations before the authenticated socket opens", async () => {
const client = new NanobotClient({ const client = new NanobotClient({
url: "ws://test", url: "ws://test",