Safari: web socket bug redirect workaround (#28109)
This commit is contained in:
parent
c9876e38ae
commit
12be9ad7d2
2 changed files with 64 additions and 9 deletions
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue