diff --git a/assets/js/views/App.vue b/assets/js/views/App.vue index f6ec564dc..fdaffd896 100644 --- a/assets/js/views/App.vue +++ b/assets/js/views/App.vue @@ -28,6 +28,9 @@ import HelpModal from "../components/HelpModal.vue"; import collector from "../mixins/collector"; import { defineComponent } from "vue"; +const WS_OPEN_TIMEOUT_MS = 5000; +const WS_RETRY_PARAM = "wsRetry"; + // assume offline if not data received for 5 minutes let lastDataReceived = new Date(); const maxDataAge = 60 * 1000 * 5; @@ -57,6 +60,7 @@ export default defineComponent({ data: () => { return { reconnectTimeout: null as number | null, + openTimeout: null as number | null, ws: null as WebSocket | null, authNotConfigured: false, }; @@ -125,6 +129,34 @@ export default defineComponent({ window.clearTimeout(this.reconnectTimeout); } }, + // Safari 26 bug: with hash fragment URLs the HTTP upgrade + // request is sometimes silently dropped when serving from cache. + // Recover by navigating without hash, once (wsRetry guards against loops). + startOpenTimeout() { + const url = new URL(window.location.href); + if (url.searchParams.has(WS_RETRY_PARAM)) return; + this.openTimeout = window.setTimeout(() => { + console.warn("websocket open timeout, forcing navigation"); + this.ws?.close(); + url.hash = ""; + url.searchParams.set(WS_RETRY_PARAM, "true"); + window.location.href = url.href; + }, WS_OPEN_TIMEOUT_MS); + }, + clearOpenTimeout(success = false) { + if (this.openTimeout) { + window.clearTimeout(this.openTimeout); + this.openTimeout = null; + } + if (success) { + const url = new URL(window.location.href); + if (url.searchParams.has(WS_RETRY_PARAM)) { + console.warn("websocket open timeout recovered, clearing retry param"); + url.searchParams.delete(WS_RETRY_PARAM); + window.history.replaceState(window.history.state, "", url.href); + } + } + }, pageShowHandler(event: PageTransitionEvent) { if (event.persisted) { this.clearReconnectTimeout(); @@ -148,6 +180,7 @@ export default defineComponent({ }, 2500); }, disconnect() { + this.clearOpenTimeout(); if (this.ws) { this.ws.onerror = null; this.ws.onopen = null; @@ -174,20 +207,22 @@ export default defineComponent({ const loc = new URL("ws", window.location.href); loc.protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - // force Safari to use a fresh connection - loc.searchParams.set("t", String(Date.now())); - const uri = loc.href; + this.ws = new WebSocket(loc.href); + + this.startOpenTimeout(); - this.ws = new WebSocket(uri); this.ws.onerror = () => { console.log({ message: "Websocket error. Trying to reconnect." }); + this.clearOpenTimeout(); this.ws?.close(); }; this.ws.onopen = () => { + this.clearOpenTimeout(true); console.log("websocket connected"); window.app.setOnline(); }; this.ws.onclose = () => { + this.clearOpenTimeout(); window.app.setOffline(); this.reconnect(); }; diff --git a/tests/ws.spec.ts b/tests/ws.spec.ts index 0e47c15fc..df6961d11 100644 --- a/tests/ws.spec.ts +++ b/tests/ws.spec.ts @@ -1,8 +1,6 @@ import { test, expect } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; -const wsPattern = /\/ws\?/; - test.use({ baseURL: baseUrl() }); test.describe.configure({ mode: "parallel" }); @@ -14,7 +12,7 @@ test.afterEach(async () => { }); test("show loadpoint with connect websocket", async ({ page }) => { - await page.routeWebSocket(wsPattern, (ws) => { + await page.routeWebSocket("/ws", (ws) => { const server = ws.connectToServer(); ws.onMessage((message) => { server.send(message); @@ -30,7 +28,7 @@ test("show loadpoint with connect websocket", async ({ page }) => { }); test("show no config screen while startup", async ({ page }) => { - await page.routeWebSocket(wsPattern, () => { + await page.routeWebSocket("/ws", () => { // connect, but don't send any messages }); await page.goto("/"); @@ -38,10 +36,32 @@ test("show no config screen while startup", async ({ page }) => { }); test("show offline when websocket is closed", async ({ page }) => { - await page.routeWebSocket(wsPattern, (ws) => { + await page.routeWebSocket("/ws", (ws) => { ws.close(); }); await page.goto("/"); await expect(page.getByText("Not connected to a server.")).toBeVisible(); await expect(page.getByRole("link", { name: "Let's start configuration" })).toBeHidden(); }); + +test("force navigation after websocket open timeout", async ({ page }) => { + // Replace WebSocket with a mock that never fires onopen, + // simulating Safari's bug where the upgrade request is silently dropped. + await page.addInitScript(() => { + (window as any).WebSocket = class { + readyState = 0; + onopen: (() => void) | null = null; + onclose: ((ev: CloseEvent) => void) | null = null; + onerror: (() => void) | null = null; + onmessage: (() => void) | null = null; + close() { + this.readyState = 3; + this.onclose?.(new CloseEvent("close")); + } + send() {} + }; + }); + await page.goto("/"); + // after the 5s open timeout the app navigates to strip the hash fragment + await page.waitForURL(/wsRetry/, { timeout: 10000 }); +});