mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(control-ui): keep the dashboard mounted with a reconnect banner on gateway drops (#100479)
* fix(control-ui): keep the dashboard mounted with a reconnect banner on gateway drops Once a session is established, a dropped gateway WebSocket no longer unmounts the dashboard into the login gate. The client's close handler now reports willRetry (the same fact that drives its reconnect scheduling), the gateway store derives a `reconnecting` snapshot state from it (everConnected && willRetry), and the app shell stays mounted with an amber "Gateway connection lost - reconnecting" banner plus a Retry now action while the client retries with backoff. The login gate is reserved for first connects, credential rejections, and manual gate submissions; event-gap recovery also no longer flashes the gate. createApplicationGateway moved from bootstrap.ts to gateway-store.ts with an injectable client factory for direct behavior tests. Adds the previously unstyled `.callout.warn` variant and a "Connection loss and reconnect" docs section. Fixes #100475 * chore(i18n): regenerate control-ui locale bundles for connection banner strings * chore(control-ui): unbreak CI - add reconnecting to overlays snapshot fixture, regenerate docs map * chore(i18n): re-sync locale metadata after rebase onto refreshed main locales * chore(i18n): refresh raw-copy baseline after rebase
This commit is contained in:
committed by
GitHub
parent
4a5bdc6ae3
commit
e596e2850d
@@ -446,6 +446,7 @@ describe("GatewayBrowserClient", () => {
|
||||
);
|
||||
expect(closeErrorDetails.code).toBe("BROWSER_WEBSOCKET_SECURITY_ERROR");
|
||||
expect(closeErrorDetails.browserErrorName).toBe("SecurityError");
|
||||
expect(close.willRetry).toBe(false);
|
||||
expect(wsInstances).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
@@ -483,6 +484,7 @@ describe("GatewayBrowserClient", () => {
|
||||
expect(closeErrorDetails.code).toBe("BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR");
|
||||
expect(closeErrorDetails.browserErrorName).toBe("TypeError");
|
||||
expect(closeErrorDetails.browserMessage).toBe("constructor failed");
|
||||
expect(close.willRetry).toBe(false);
|
||||
expect(wsInstances).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
@@ -742,6 +744,7 @@ describe("GatewayBrowserClient", () => {
|
||||
code: 1006,
|
||||
reason: "socket lost",
|
||||
error: undefined,
|
||||
willRetry: true,
|
||||
});
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
"[gateway] close handler error:",
|
||||
@@ -1005,6 +1008,7 @@ describe("GatewayBrowserClient", () => {
|
||||
retryable: false,
|
||||
retryAfterMs: undefined,
|
||||
},
|
||||
willRetry: false,
|
||||
});
|
||||
} finally {
|
||||
client.stop();
|
||||
@@ -1102,6 +1106,7 @@ describe("GatewayBrowserClient", () => {
|
||||
retryable: false,
|
||||
retryAfterMs: undefined,
|
||||
},
|
||||
willRetry: false,
|
||||
});
|
||||
|
||||
client.stop();
|
||||
@@ -1326,6 +1331,38 @@ describe("GatewayBrowserClient", () => {
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("reports willRetry=false on credential rejections so the UI can fall back to the login gate", async () => {
|
||||
useNodeFakeTimers();
|
||||
const onClose = vi.fn();
|
||||
|
||||
const client = new GatewayBrowserClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
password: "wrong-password",
|
||||
onClose,
|
||||
});
|
||||
|
||||
const { ws, connectFrame } = await startConnect(client);
|
||||
ws.emitMessage({
|
||||
type: "res",
|
||||
id: connectFrame.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "unauthorized",
|
||||
details: { code: "AUTH_PASSWORD_MISMATCH" },
|
||||
},
|
||||
});
|
||||
await expectSocketClosed(ws);
|
||||
ws.emitClose(4008, "connect failed");
|
||||
|
||||
const close = requireFirstMockArg(onClose, "close");
|
||||
expect(close.willRetry).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(wsInstances).toHaveLength(1);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRetryWithDeviceToken", () => {
|
||||
|
||||
+24
-10
@@ -298,7 +298,12 @@ export type GatewayBrowserClientOptions = {
|
||||
instanceId?: string;
|
||||
onHello?: (hello: GatewayHelloOk) => void;
|
||||
onEvent?: (evt: GatewayEventFrame) => void;
|
||||
onClose?: (info: { code: number; reason: string; error?: GatewayErrorInfo }) => void;
|
||||
onClose?: (info: {
|
||||
code: number;
|
||||
reason: string;
|
||||
error?: GatewayErrorInfo;
|
||||
willRetry: boolean;
|
||||
}) => void;
|
||||
onGap?: (info: { expected: number; received: number }) => void;
|
||||
onRequestTiming?: (timing: GatewayRequestTiming) => void;
|
||||
onConnectTiming?: (timing: GatewayConnectTiming) => void;
|
||||
@@ -549,6 +554,9 @@ export class GatewayBrowserClient {
|
||||
? "security error"
|
||||
: "websocket error",
|
||||
error,
|
||||
// Constructor failures (bad URL, mixed content) never resolve on
|
||||
// their own; no reconnect is scheduled for them.
|
||||
willRetry: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -579,15 +587,16 @@ export class GatewayBrowserClient {
|
||||
return;
|
||||
}
|
||||
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
|
||||
this.notifyClose({ code: ev.code, reason, error: connectError });
|
||||
const connectErrorCode = resolveGatewayErrorDetailCode(connectError);
|
||||
if (connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH) {
|
||||
if (this.pendingDeviceTokenRetry) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isNonRecoverableAuthError(connectError)) {
|
||||
// willRetry drives both the reconnect scheduling below and the app
|
||||
// layer's "still reconnecting vs gave up" rendering; keep them in sync.
|
||||
const willRetry =
|
||||
!this.closed &&
|
||||
(connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH
|
||||
? this.pendingDeviceTokenRetry
|
||||
: !isNonRecoverableAuthError(connectError));
|
||||
this.notifyClose({ code: ev.code, reason, error: connectError, willRetry });
|
||||
if (willRetry) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
});
|
||||
@@ -985,7 +994,12 @@ export class GatewayBrowserClient {
|
||||
}
|
||||
}
|
||||
|
||||
private notifyClose(info: { code: number; reason: string; error?: GatewayErrorInfo }): void {
|
||||
private notifyClose(info: {
|
||||
code: number;
|
||||
reason: string;
|
||||
error?: GatewayErrorInfo;
|
||||
willRetry: boolean;
|
||||
}): void {
|
||||
try {
|
||||
this.opts.onClose?.(info);
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user