Compare commits

..
2 Commits
Author SHA1 Message Date
matitos 7ad14449ce Never hang on connecting: time out the socket and fall back
CI/CD / backend (push) Successful in 5m53s
CI/CD / frontend (push) Successful in 5m11s
CI/CD / build (push) Successful in 21s
CI/CD / cleanup (push) Successful in 6s
CI/CD / deploy (push) Successful in 20s
2026-09-20 23:43:13 +00:00
matitos ed14019052 Never hang on "connecting": time out the socket and fall back
iOS Safari can leave a blocked wss:// upgrade hanging without firing
onclose/onerror, so the badge sat on "connecting". The socket now times out a
slow handshake after 5s, reports at most one failure per attempt, and the app
commits to polling after a 6s grace period if the socket never opens.
2026-09-20 23:43:12 +00:00
2 changed files with 43 additions and 11 deletions

No files matched your search

+13 -5
View File
@@ -38,15 +38,17 @@ const busy = ref(false)
const connected = ref(false) const connected = ref(false)
const socketError = ref('') const socketError = ref('')
const socketFailures = ref(0) const socketFailures = ref(0)
// Some mobile browsers (notably iOS Safari with an auth-gated page) abort the const pollingFallback = ref(false)
// wss:// upgrade even when the server accepts it. After a few failures we stop // Some mobile browsers (notably iOS Safari with an auth-gated page) abort or
// retrying, rely on the REST poll, and present it as a normal live state rather // silently hang the wss:// upgrade even when the server accepts it. We fall
// than a degraded "reconnecting". // back to the REST poll after a few failures, or after a short grace period if
const usePolling = computed(() => socketFailures.value >= 3) // 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 socket
let pollTimer let pollTimer
let statusTimer let statusTimer
let graceTimer
async function refreshAll() { async function refreshAll() {
const [p, o, e, q] = await Promise.all([ const [p, o, e, q] = await Promise.all([
@@ -151,6 +153,11 @@ onMounted(async () => {
else socketError.value = `socket closed (code ${code})` 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. // Poll fast enough that the UI feels live even when it is the only channel.
pollTimer = setInterval(refreshAll, 3000) pollTimer = setInterval(refreshAll, 3000)
statusTimer = setInterval(() => api.getStatus().then((s) => (status.value = s)), 3000) statusTimer = setInterval(() => api.getStatus().then((s) => (status.value = s)), 3000)
@@ -160,6 +167,7 @@ onUnmounted(() => {
socket?.close() socket?.close()
clearInterval(pollTimer) clearInterval(pollTimer)
clearInterval(statusTimer) clearInterval(statusTimer)
clearTimeout(graceTimer)
}) })
</script> </script>
+30 -6
View File
@@ -45,23 +45,47 @@ export function openSocket({ onMessage, onOpen, onClose, onError }) {
const connect = () => { const connect = () => {
if (closed) return if (closed) return
let opened = false
let reported = false
socket = new WebSocket(url) 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 = () => { socket.onopen = () => {
opened = true
clearTimeout(openTimeout)
attempt = 0 attempt = 0
onOpen?.() onOpen?.()
} }
socket.onmessage = (event) => onMessage?.(JSON.parse(event.data)) socket.onmessage = (event) => onMessage?.(JSON.parse(event.data))
socket.onclose = (event) => { socket.onclose = (event) => {
clearTimeout(openTimeout)
onClose?.() onClose?.()
// 1008 = policy violation (token rejected); 1006 = abnormal close, which if (!opened) {
// is what an auth/proxy rejection looks like to the browser. // 1008 = policy violation (token rejected); 1006 = abnormal close.
onError?.(event.code, event.reason) reportFailure(event.code, event.reason)
}
schedule() schedule()
} }
socket.onerror = (event) => { socket.onerror = (event) => {
// The browser hides details here; surface what it does give us. clearTimeout(openTimeout)
const detail = event?.message || 'connection error' if (!opened) reportFailure(0, event?.message || 'connection error')
onError?.(0, detail)
socket.close() socket.close()
} }
} }