diff --git a/frontend/src/App.vue b/frontend/src/App.vue index aa3dbac..49ba96f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -38,15 +38,17 @@ const busy = ref(false) const connected = ref(false) const socketError = ref('') const socketFailures = ref(0) -// Some mobile browsers (notably iOS Safari with an auth-gated page) abort the -// wss:// upgrade even when the server accepts it. After a few failures we stop -// retrying, rely on the REST poll, and present it as a normal live state rather -// than a degraded "reconnecting". -const usePolling = computed(() => socketFailures.value >= 3) +const pollingFallback = ref(false) +// Some mobile browsers (notably iOS Safari with an auth-gated page) abort or +// silently hang the wss:// upgrade even when the server accepts it. We fall +// back to the REST poll after a few failures, or after a short grace period if +// the socket never opens at all, and present it as a normal live state. +const usePolling = computed(() => pollingFallback.value || socketFailures.value >= 3) let socket let pollTimer let statusTimer +let graceTimer async function refreshAll() { const [p, o, e, q] = await Promise.all([ @@ -151,6 +153,11 @@ onMounted(async () => { else socketError.value = `socket closed (code ${code})` }, }) + // If the socket hasn't opened shortly after load, commit to polling so the + // badge never hangs on "connecting". + graceTimer = setTimeout(() => { + if (!connected.value) pollingFallback.value = true + }, 6000) // Poll fast enough that the UI feels live even when it is the only channel. pollTimer = setInterval(refreshAll, 3000) statusTimer = setInterval(() => api.getStatus().then((s) => (status.value = s)), 3000) @@ -160,6 +167,7 @@ onUnmounted(() => { socket?.close() clearInterval(pollTimer) clearInterval(statusTimer) + clearTimeout(graceTimer) }) diff --git a/frontend/src/api.js b/frontend/src/api.js index f3d49f2..368d47b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -45,23 +45,47 @@ export function openSocket({ onMessage, onOpen, onClose, onError }) { const connect = () => { if (closed) return + let opened = false + let reported = false socket = new WebSocket(url) + + // Report at most one failure per attempt (Safari often fires error *and* + // close for the same bad handshake). + const reportFailure = (code, detail) => { + if (reported) return + reported = true + onError?.(code, detail) + } + + // Some mobile browsers leave a blocked upgrade hanging without firing + // onclose/onerror. Treat a slow handshake as a failure so the caller can + // fall back to polling instead of waiting forever. + const openTimeout = setTimeout(() => { + if (!opened) { + reportFailure(0, 'handshake timed out') + socket.close() + } + }, 5000) + socket.onopen = () => { + opened = true + clearTimeout(openTimeout) attempt = 0 onOpen?.() } socket.onmessage = (event) => onMessage?.(JSON.parse(event.data)) socket.onclose = (event) => { + clearTimeout(openTimeout) onClose?.() - // 1008 = policy violation (token rejected); 1006 = abnormal close, which - // is what an auth/proxy rejection looks like to the browser. - onError?.(event.code, event.reason) + if (!opened) { + // 1008 = policy violation (token rejected); 1006 = abnormal close. + reportFailure(event.code, event.reason) + } schedule() } socket.onerror = (event) => { - // The browser hides details here; surface what it does give us. - const detail = event?.message || 'connection error' - onError?.(0, detail) + clearTimeout(openTimeout) + if (!opened) reportFailure(0, event?.message || 'connection error') socket.close() } }