mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 16:51:53 +03:00
fix(webui): make mutations reconnect-safe
This commit is contained in:
@@ -109,6 +109,12 @@ interface PendingRequest<T> {
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
type WebUIRequestFrame = Extract<Outbound, { type: "webui_request" }>;
|
||||
|
||||
interface PendingWebUIRequest extends PendingRequest<unknown> {
|
||||
frame: WebUIRequestFrame;
|
||||
}
|
||||
|
||||
export class WebUIMutationError extends Error {
|
||||
status: number;
|
||||
|
||||
@@ -215,7 +221,7 @@ export class NanobotClient {
|
||||
private pendingNewChat: PendingChatRequest | null = null;
|
||||
private pendingTranscriptions = new Map<string, PendingRequest<string>>();
|
||||
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
|
||||
private sendQueue: Outbound[] = [];
|
||||
private reconnectAttempts = 0;
|
||||
@@ -833,9 +839,9 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one non-replayable WebUI mutation over the authenticated socket.
|
||||
* A client-side timeout only abandons the reply; the server may finish work
|
||||
* that already started, so timed-out requests are never retried automatically.
|
||||
* Send one WebUI mutation over the authenticated socket. Pending requests are
|
||||
* replayed with the same request_id after reconnect so the gateway can join or
|
||||
* replay the original operation. A client-side timeout still ends all retries.
|
||||
*/
|
||||
requestMutation<T>(
|
||||
action: string,
|
||||
@@ -875,6 +881,7 @@ export class NanobotClient {
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
timer,
|
||||
frame,
|
||||
});
|
||||
try {
|
||||
socket.send(JSON.stringify(frame));
|
||||
@@ -1020,6 +1027,9 @@ export class NanobotClient {
|
||||
for (const chatId of this.knownChats) {
|
||||
this.rawSend({ type: "attach", chat_id: chatId });
|
||||
}
|
||||
for (const pending of this.pendingWebUIRequests.values()) {
|
||||
this.rawSend(pending.frame);
|
||||
}
|
||||
// Flush anything queued during reconnect.
|
||||
const queued = this.sendQueue.splice(0);
|
||||
for (const frame of queued) this.rawSend(frame);
|
||||
@@ -1252,19 +1262,22 @@ export class NanobotClient {
|
||||
private handleClose(event?: { code?: number }): void {
|
||||
this.socket = null;
|
||||
this.clearTemporaryChats();
|
||||
const willReconnect = !this.intentionallyClosed && this.shouldReconnect;
|
||||
if (this.pendingNewChat) {
|
||||
clearTimeout(this.pendingNewChat.timer);
|
||||
this.pendingNewChat.reject(new Error("socket closed"));
|
||||
this.pendingNewChat = null;
|
||||
}
|
||||
this.rejectAllTranscriptions("socket closed");
|
||||
for (const pending of this.pendingWebUIRequests.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(
|
||||
new WebUIMutationError(503, "Socket closed before WebUI response"),
|
||||
);
|
||||
if (!willReconnect) {
|
||||
for (const pending of this.pendingWebUIRequests.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(
|
||||
new WebUIMutationError(503, "Socket closed before WebUI response"),
|
||||
);
|
||||
}
|
||||
this.pendingWebUIRequests.clear();
|
||||
}
|
||||
this.pendingWebUIRequests.clear();
|
||||
for (const pending of this.pendingSystemCommands.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error("socket closed"));
|
||||
@@ -1312,7 +1325,7 @@ export class NanobotClient {
|
||||
}
|
||||
this.socketPendingMessageSendKeys.clear();
|
||||
this.lastSocketMessageSendKey = null;
|
||||
if (this.intentionallyClosed || !this.shouldReconnect) {
|
||||
if (!willReconnect) {
|
||||
this.setStatus("closed");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
Reference in New Issue
Block a user