mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-08 10:02:56 +08:00
fix: stop reconnecting on protocol mismatch (#98414)
* fix: stop reconnecting on protocol mismatch * fix(ui): stop protocol mismatch reconnects * test(ui): assert protocol mismatch recovery copy * test(ui): count gateway reconnect attempts * test(gateway): keep detail-less reconnect case typed * test(gateway): keep pairing retry fixture typed --------- Co-authored-by: haruaiclone-droid <281899875+haruaiclone-droid@users.noreply.github.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
|
||||
- **Plugin install diagnostics:** suppress the misleading hook-pack fallback after plugin install failures only when the hook manifest is absent, while preserving actionable malformed hook-pack errors. (#100554) Thanks @vincentkoc.
|
||||
- **Config validation diagnostics:** emit each unchanged sanitized validation-warning payload once per config path, reset deduplication after a clean validation, and preserve the warning fingerprint across transient invalid reads and failed refreshes. (#100569, #25574) Thanks @vincentkoc.
|
||||
- **Config size-drop guard:** compare writes against canonical bytes for parseable object configs instead of raw BOM and indentation overhead, while preserving raw audit telemetry and the conservative malformed-input fallback. (#100591, #71865) Thanks @vincentkoc.
|
||||
- **Control UI protocol mismatches:** stop automatic reconnect loops after incompatible Gateway and Control UI versions while preserving the actionable mismatch error. (#98414) Thanks @haruaiclone-droid.
|
||||
- **Control UI coalesced updates:** show a clear queued-restart completion banner when an update joins an already-running Gateway restart. (#93082) Thanks @goutamadwant.
|
||||
- **Control UI connection errors:** preserve structured pairing and authentication failures for pending RPC callers while keeping generic disconnect behavior unchanged. (#54758) Thanks @ruanrrn.
|
||||
- **TUI startup status:** show `starting up` during post-connect initialization without overwriting active-run or reconnect state. (#93999) Thanks @ml12580.
|
||||
|
||||
@@ -1,75 +1,73 @@
|
||||
// Reconnect gating tests keep UI reconnect policy aligned with gateway protocol
|
||||
// auth error details that cannot recover by retrying.
|
||||
// failures that cannot recover by retrying.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js";
|
||||
import { type GatewayErrorInfo, isNonRecoverableAuthError } from "../../ui/src/api/gateway.ts";
|
||||
import { type GatewayErrorInfo, isNonRecoverableConnectError } from "../../ui/src/api/gateway.ts";
|
||||
|
||||
function makeError(detailCode: string): GatewayErrorInfo {
|
||||
return { code: "connect_failed", message: "auth failed", details: { code: detailCode } };
|
||||
}
|
||||
|
||||
describe("isNonRecoverableAuthError", () => {
|
||||
describe("isNonRecoverableConnectError", () => {
|
||||
it("returns false for undefined error (normal disconnect)", () => {
|
||||
expect(isNonRecoverableAuthError(undefined)).toBe(false);
|
||||
expect(isNonRecoverableConnectError(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for errors without detail codes (network issues)", () => {
|
||||
expect(isNonRecoverableAuthError({ code: "connect_failed", message: "timeout" })).toBe(false);
|
||||
expect(isNonRecoverableConnectError({})).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks reconnect for AUTH_TOKEN_MISSING (misconfigured client)", () => {
|
||||
expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_TOKEN_MISSING))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.AUTH_TOKEN_MISSING)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks reconnect for AUTH_BOOTSTRAP_TOKEN_INVALID", () => {
|
||||
expect(
|
||||
isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID)),
|
||||
isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks reconnect for AUTH_PASSWORD_MISSING", () => {
|
||||
expect(
|
||||
isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING)),
|
||||
isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks reconnect for AUTH_PASSWORD_MISMATCH (wrong password won't self-correct)", () => {
|
||||
expect(
|
||||
isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH)),
|
||||
isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks reconnect for AUTH_RATE_LIMITED (reconnecting burns more slots)", () => {
|
||||
expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_RATE_LIMITED))).toBe(
|
||||
expect(isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.AUTH_RATE_LIMITED))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks reconnect for AUTH_DEVICE_TOKEN_MISMATCH", () => {
|
||||
expect(
|
||||
isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH)),
|
||||
isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks reconnect for AUTH_SCOPE_MISMATCH", () => {
|
||||
expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_SCOPE_MISMATCH))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.AUTH_SCOPE_MISMATCH)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks reconnect for PAIRING_REQUIRED", () => {
|
||||
expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.PAIRING_REQUIRED))).toBe(
|
||||
expect(isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.PAIRING_REQUIRED))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows reconnect for PAIRING_REQUIRED when retry hints keep reconnect active", () => {
|
||||
expect(
|
||||
isNonRecoverableAuthError({
|
||||
code: "connect_failed",
|
||||
message: "auth failed",
|
||||
isNonRecoverableConnectError({
|
||||
details: {
|
||||
code: ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
||||
reason: "not-paired",
|
||||
@@ -83,12 +81,18 @@ describe("isNonRecoverableAuthError", () => {
|
||||
it("allows reconnect for AUTH_TOKEN_MISMATCH (device-token fallback flow)", () => {
|
||||
// Browser client can queue a single trusted-device retry after shared token mismatch.
|
||||
// Blocking reconnect on mismatch here would skip that bounded recovery attempt.
|
||||
expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH))).toBe(
|
||||
false,
|
||||
expect(
|
||||
isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH)),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks reconnect for PROTOCOL_MISMATCH", () => {
|
||||
expect(isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.PROTOCOL_MISMATCH))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows reconnect for unrecognized detail codes (future-proof)", () => {
|
||||
expect(isNonRecoverableAuthError(makeError("SOME_FUTURE_CODE"))).toBe(false);
|
||||
expect(isNonRecoverableConnectError(makeError("SOME_FUTURE_CODE"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js";
|
||||
import {
|
||||
MIN_CLIENT_PROTOCOL_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
@@ -103,6 +104,8 @@ const {
|
||||
CONTROL_UI_OPERATOR_SCOPES,
|
||||
GatewayBrowserClient,
|
||||
GatewayRequestError,
|
||||
isNonRecoverableConnectError,
|
||||
resolveGatewayErrorDetailCode,
|
||||
shouldRetryWithDeviceToken,
|
||||
} = await import("./gateway.ts");
|
||||
|
||||
@@ -388,6 +391,8 @@ describe("GatewayBrowserClient", () => {
|
||||
});
|
||||
|
||||
expect(error.message).toBe(`protocol mismatch: Control UI v${PROTOCOL_VERSION}`);
|
||||
expect(resolveGatewayErrorDetailCode(error)).toBe(ConnectErrorDetailCodes.PROTOCOL_MISMATCH);
|
||||
expect(isNonRecoverableConnectError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it("reuses cached device token scopes when connecting from bootstrap handoff", async () => {
|
||||
@@ -1267,6 +1272,35 @@ describe("GatewayBrowserClient", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not auto-reconnect on PROTOCOL_MISMATCH", async () => {
|
||||
useNodeFakeTimers();
|
||||
|
||||
const client = new GatewayBrowserClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "shared-auth-token",
|
||||
});
|
||||
|
||||
const { ws: ws1, connectFrame: connect } = await startConnect(client);
|
||||
|
||||
ws1.emitMessage({
|
||||
type: "res",
|
||||
id: connect.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "protocol mismatch",
|
||||
details: { code: "PROTOCOL_MISMATCH" },
|
||||
},
|
||||
});
|
||||
await expectSocketClosed(ws1);
|
||||
ws1.emitClose(4008, "connect failed");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(wsInstances).toHaveLength(1);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps reconnecting on PAIRING_REQUIRED when retry hints keep reconnect active", async () => {
|
||||
useNodeFakeTimers();
|
||||
localStorage.clear();
|
||||
|
||||
@@ -67,15 +67,16 @@ export class GatewayRequestError extends Error {
|
||||
readonly retryAfterMs?: number;
|
||||
|
||||
constructor(error: GatewayErrorInfo) {
|
||||
const details = enrichProtocolMismatchDetails(error.message, error.details);
|
||||
super(
|
||||
formatConnectErrorMessage({
|
||||
message: error.message,
|
||||
details: enrichProtocolMismatchDetails(error.message, error.details),
|
||||
details,
|
||||
}),
|
||||
);
|
||||
this.name = "GatewayRequestError";
|
||||
this.gatewayCode = error.code;
|
||||
this.details = error.details;
|
||||
this.details = details;
|
||||
this.retryable = error.retryable === true;
|
||||
this.retryAfterMs = error.retryAfterMs;
|
||||
}
|
||||
@@ -111,14 +112,10 @@ function shouldContinueReconnectForPairingRequired(details: unknown): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth errors that won't resolve without user action — don't auto-reconnect.
|
||||
*
|
||||
* NOTE: AUTH_TOKEN_MISMATCH is intentionally NOT included here because the
|
||||
* browser client supports a bounded one-time retry with a cached device token
|
||||
* when the endpoint is trusted. Reconnect suppression for mismatch is handled
|
||||
* with client state (after retry budget is exhausted).
|
||||
* Connect failures that cannot recover while client and server state stay unchanged.
|
||||
* AUTH_TOKEN_MISMATCH stays out: the close handler owns its bounded cached-token retry.
|
||||
*/
|
||||
export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined): boolean {
|
||||
export function isNonRecoverableConnectError(error: { details?: unknown } | undefined): boolean {
|
||||
if (!error) {
|
||||
return false;
|
||||
}
|
||||
@@ -137,6 +134,7 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined):
|
||||
code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
|
||||
code === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH ||
|
||||
code === ConnectErrorDetailCodes.AUTH_SCOPE_MISMATCH ||
|
||||
code === ConnectErrorDetailCodes.PROTOCOL_MISMATCH ||
|
||||
code === ConnectErrorDetailCodes.PAIRING_REQUIRED ||
|
||||
code === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
|
||||
code === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED
|
||||
@@ -597,7 +595,7 @@ export class GatewayBrowserClient {
|
||||
!this.closed &&
|
||||
(connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH
|
||||
? this.pendingDeviceTokenRetry
|
||||
: !isNonRecoverableAuthError(connectError));
|
||||
: !isNonRecoverableConnectError(connectError));
|
||||
this.notifyClose({ code: ev.code, reason, error: connectError, willRetry });
|
||||
if (willRetry) {
|
||||
this.scheduleReconnect();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Control UI tests cover the responsive disconnected login gate.
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
installMockGateway,
|
||||
resolvePlaywrightChromiumExecutablePath,
|
||||
startControlUiE2eServer,
|
||||
type ControlUiE2eServer,
|
||||
@@ -78,6 +80,32 @@ describeControlUiE2e("Control UI responsive login gate E2E", () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("shows a protocol mismatch without reconnecting", async () => {
|
||||
const context = await browser.newContext({ viewport: { height: 900, width: 1280 } });
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, { deferredMethods: ["connect"] });
|
||||
|
||||
try {
|
||||
await page.goto(server.baseUrl);
|
||||
await gateway.waitForRequest("connect");
|
||||
await gateway.rejectDeferred("connect", {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "protocol mismatch",
|
||||
details: { code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH },
|
||||
});
|
||||
|
||||
const failure = page.locator(".login-gate__failure-summary");
|
||||
await failure.waitFor({ timeout: 10_000 });
|
||||
expect((await failure.textContent())?.toLowerCase()).toContain(
|
||||
"supported connection protocol",
|
||||
);
|
||||
await page.waitForTimeout(1_600);
|
||||
expect(await gateway.getRequests("connect")).toHaveLength(1);
|
||||
} finally {
|
||||
await closeContext(context);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps mobile controls compact, touchable, and keyboard-friendly", async () => {
|
||||
const context = await browser.newContext({
|
||||
hasTouch: true,
|
||||
|
||||
Reference in New Issue
Block a user