Safari: web socket bug redirect workaround (#28109)

This commit is contained in:
Michael Geers 2026-03-11 13:12:21 +01:00 • committed by GitHub
parent c9876e38ae
commit 12be9ad7d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 64 additions and 9 deletions

View file

@ -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();
};

View file

@ -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 });
});