hotfix jointlist

This commit is contained in:
Mateusz Gruszczyński
2026-09-12 14:25:40 +02:00
parent 94abe403ab
commit f66a4d304c
4 changed files with 164 additions and 90 deletions
+9 -3
View File
@@ -31,10 +31,16 @@ sub vcl_recv {
# omijamy cache dla healthchecków / wewnętrznych nagłówków
if (req.url == "/healthcheck" || req.http.X-Internal-Check) { return (pass); }
# Specjalna obsługa WebSocket i socket.io
if (req.http.Upgrade ~ "(?i)websocket" || req.url ~ "^/socket.io/") {
# WebSocket wymaga surowego tunelu TCP, ale zwykły transport polling
# Socket.IO jest normalnym HTTP i nie może trafiać do pipe. Puszczenie całego
# /socket.io/ przez pipe może pozostawić połączenie HTTP przypięte do backendu
# po uśpieniu mobilnej przeglądarki i utrudniać późniejszy refresh/nawigację.
if (req.http.Upgrade ~ "(?i)websocket") {
return (pipe);
}
if (req.url ~ "^/socket.io/") {
return (pass);
}
# metody inne niż GET/HEAD bez cache
if (req.method != "GET" && req.method != "HEAD") { return (pass); }
@@ -103,7 +109,7 @@ sub vcl_recv {
sub vcl_pipe {
if (req.http.Upgrade) {
set bereq.http.Upgrade = req.http.Upgrade;
set bereq.http.Connection = req.http.Connection;
set bereq.http.Connection = "Upgrade";
}
}
+13 -2
View File
@@ -221,6 +221,7 @@ def handle_join(data):
global active_users
room = str(data["room"])
username = data.get("username", "Gość")
silent = bool(data.get("silent", False))
sid = request.sid
# Trzymamy obecność per SID, a nie tylko per nazwa użytkownika. Dzięki temu
@@ -245,10 +246,20 @@ def handle_join(data):
emit(
"joined_confirmation",
{"room": room, "list_title": list_title},
{
"room": room,
"list_title": list_title,
"users": sorted(set(room_users.values())),
"silent": silent,
},
to=sid,
)
return {"ok": True, "room": room, "already_joined": already_joined}
return {
"ok": True,
"room": room,
"already_joined": already_joined,
"silent": silent,
}
@socketio.on("disconnect")
+8 -2
View File
@@ -1,4 +1,10 @@
const socket = io();
const socket = io({
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 500,
reconnectionDelayMax: 5000,
timeout: 8000
});
/*──────────────── placeholder pustej listy ────────────────*/
function toggleEmptyPlaceholder() {
@@ -121,7 +127,7 @@ function setupList(listId, username) {
}
if (typeof window.recoverListSocket === 'function') {
window.recoverListSocket({ force: true });
window.recoverListSocket();
}
});
}
+134 -83
View File
@@ -1,17 +1,29 @@
let didReceiveFirstFullList = false;
// Karta może zostać zamrożona lub transport może umrzeć bez natychmiastowej
// zmiany socket.connected. Po wznowieniu najpierw weryfikujemy połączenie ACK-em.
// Reconnect robimy dopiero wtedy, gdy transport faktycznie nie odpowiada.
const WAKE_RECONNECT_DEBOUNCE_MS = 200;
const WAKE_WATCHDOG_INTERVAL_MS = 10000;
const WAKE_WATCHDOG_GAP_MS = 25000;
let recoveryTimer = null;
let recoveryForceRequested = false;
let recoveryInProgress = false;
let lastWatchdogTick = Date.now();
// Stan dołączenia do aktualnego pokoju. Pierwsze udane wejście ma pokazać
// użytkownikowi potwierdzenie i listę obecnych osób. Każdy kolejny join po
// reconnectcie jest cichy po stronie tej karty.
let joinedSocketId = null;
let joinedRoomId = null;
let didCompleteInitialJoin = false;
let currentRoomUsers = [];
// Safari i inne mobilne przeglądarki potrafią zamrozić kartę razem z transportem.
// Po wznowieniu najpierw robimy krótki request z ACK. Jeżeli stary transport
// faktycznie nie odpowiada, zamykamy wyłącznie transport Engine.IO i pozwalamy
// wbudowanemu mechanizmowi Socket.IO zestawić nowe połączenie. Nie wywołujemy
// socket.disconnect(), bo jest to ręczne rozłączenie i może wejść w konflikt
// z automatycznym reconnectem.
const WAKE_CHECK_DEBOUNCE_MS = 250;
const WAKE_ACK_TIMEOUT_MS = 4000;
const WAKE_WATCHDOG_INTERVAL_MS = 5000;
const WAKE_WATCHDOG_GAP_MS = 15000;
const WAKE_RETRY_CONNECT_MS = 1200;
let wakeCheckTimer = null;
let wakeCheckInProgress = false;
let reconnectFallbackTimer = null;
let lastWatchdogTick = Date.now();
function hasActiveListRoom() {
return Boolean(window.LIST_ID && window.usernameForReconnect);
@@ -20,7 +32,6 @@ function hasActiveListRoom() {
function disableCheckboxes(disable) {
document.querySelectorAll('#items input[type="checkbox"]').forEach(cb => {
if (disable) {
// Zapamiętaj stan biznesowy tylko przy pierwszym nałożeniu blokady transportu.
if (cb.dataset.socketDisabledBefore === undefined) {
cb.dataset.socketDisabledBefore = cb.disabled ? '1' : '0';
}
@@ -41,99 +52,119 @@ function ensureCurrentListJoined() {
const roomId = String(window.LIST_ID);
const socketId = socket.id || null;
// join_list może przyjść zarówno z setupList(), jak i z eventu connect.
// Ten klucz sprawia, że dla jednego fizycznego połączenia wysyłamy go tylko raz.
if (joinedSocketId === socketId && joinedRoomId === roomId) return false;
joinedSocketId = socketId;
joinedRoomId = roomId;
socket.emit('join_list', {
room: window.LIST_ID,
username: window.usernameForReconnect
username: window.usernameForReconnect,
// Pierwsze wejście jest widoczne. Reconnect tej samej strony jest cichy.
silent: didCompleteInitialJoin
});
return true;
}
window.ensureCurrentListJoined = ensureCurrentListJoined;
function requestCurrentList({ verify = false } = {}) {
function requestCurrentList() {
if (!socket.connected || !window.LIST_ID) return;
socket.emit('request_full_list', { list_id: window.LIST_ID });
}
if (!verify) {
socket.emit('request_full_list', { list_id: window.LIST_ID });
function forceCloseStaleTransport() {
wakeCheckInProgress = false;
joinedSocketId = null;
joinedRoomId = null;
if (window.LIST_ID) disableCheckboxes(true);
try {
const engine = socket.io && socket.io.engine;
const transport = engine && engine.transport;
// Zamknięcie samego transportu zachowuje automatyczny reconnect Managera.
// Transport.close() emituje close od razu, zamiast czekać na opróżnienie
// bufora starego, zamrożonego połączenia.
if (transport && transport.readyState !== 'closed') {
transport.close();
} else if (!socket.connected) {
socket.connect();
}
} catch (err) {
console.warn('Nie udało się zamknąć starego transportu Socket.IO', err);
if (!socket.connected) socket.connect();
}
clearTimeout(reconnectFallbackTimer);
reconnectFallbackTimer = setTimeout(() => {
if (document.hidden || !hasActiveListRoom() || socket.connected) return;
socket.connect();
}, WAKE_RETRY_CONNECT_MS);
}
function runWakeCheck() {
if (document.hidden || !hasActiveListRoom()) return;
if (typeof navigator !== 'undefined' && navigator.onLine === false) return;
if (!socket.connected) {
disableCheckboxes(true);
socket.connect();
return;
}
socket.timeout(4000).emit('request_full_list', { list_id: window.LIST_ID }, (err, response) => {
if (!err && response && response.ok === true) return;
recoverListSocket({ force: true });
});
const engine = socket.io && socket.io.engine;
if (!engine || engine.readyState !== 'open' || !engine.transport || engine.transport.readyState !== 'open') {
forceCloseStaleTransport();
return;
}
if (wakeCheckInProgress) return;
wakeCheckInProgress = true;
// request_full_list pełni jednocześnie rolę health-checka i synchronizacji.
socket.timeout(WAKE_ACK_TIMEOUT_MS).emit(
'request_full_list',
{ list_id: window.LIST_ID },
(err, response) => {
wakeCheckInProgress = false;
if (!err && response && response.ok === true) {
// Jeśli połączenie jest zdrowe, nie wykonujemy reconnectu i nie emitujemy
// ponownego join_list. Samo przełączenie karty pozostaje więc całkowicie ciche.
return;
}
forceCloseStaleTransport();
}
);
}
function recoverListSocket({ force = false } = {}) {
recoveryForceRequested = recoveryForceRequested || force;
clearTimeout(recoveryTimer);
recoveryTimer = setTimeout(() => {
const shouldForce = recoveryForceRequested;
recoveryForceRequested = false;
if (document.hidden) return;
if (!socket.connected) {
if (window.LIST_ID) disableCheckboxes(true);
socket.connect();
return;
}
if (shouldForce && hasActiveListRoom()) {
if (recoveryInProgress) return;
recoveryInProgress = true;
disableCheckboxes(true);
joinedSocketId = null;
joinedRoomId = null;
// Twardy reconnect tylko po realnym błędzie/timeout ACK, nie po samym
// przełączeniu karty lub powrocie aplikacji na pierwszy plan.
socket.disconnect();
setTimeout(() => socket.connect(), 0);
return;
}
if (hasActiveListRoom()) {
// Jeśli transport żyje, synchronizacja wystarczy. Timeout sam uruchomi reconnect.
requestCurrentList({ verify: true });
}
}, WAKE_RECONNECT_DEBOUNCE_MS);
function recoverListSocket() {
clearTimeout(wakeCheckTimer);
wakeCheckTimer = setTimeout(runWakeCheck, WAKE_CHECK_DEBOUNCE_MS);
}
// Udostępnione dla akcji w live.js (np. timeout checkboxa).
window.recoverListSocket = recoverListSocket;
// Powrót po zablokowaniu telefonu, zmianie aplikacji lub karty.
// Najważniejszy przypadek: powrót po zablokowaniu telefonu / zmianie aplikacji.
document.addEventListener('visibilitychange', () => {
if (!document.hidden) recoverListSocket();
});
// BFCache może przywrócić stronę ze starym transportem Socket.IO.
window.addEventListener('pageshow', () => {
recoverListSocket();
// pageshow bez persisted występuje także przy zwykłym pierwszym ładowaniu strony.
// Reconnect jest potrzebny wyłącznie przy rzeczywistym powrocie z BFCache.
window.addEventListener('pageshow', event => {
if (event.persisted) recoverListSocket();
});
window.addEventListener('focus', () => {
recoverListSocket();
});
window.addEventListener('online', recoverListSocket);
window.addEventListener('online', () => {
recoverListSocket();
});
// Dla środowisk emitujących zdarzenie resume (np. webview/PWA wrapper).
document.addEventListener('resume', recoverListSocket);
document.addEventListener('resume', () => {
recoverListSocket();
});
// Watchdog łapie także wznowienia, przy których przeglądarka nie wyśle poprawnie
// visibilitychange/pageshow (np. po agresywnym uśpieniu procesu lub karty).
// Fallback dla przeglądarek, które po wybudzeniu nie wyślą visibilitychange.
setInterval(() => {
const now = Date.now();
const gap = now - lastWatchdogTick;
@@ -145,7 +176,8 @@ setInterval(() => {
}, WAKE_WATCHDOG_INTERVAL_MS);
socket.on('connect', function () {
recoveryInProgress = false;
wakeCheckInProgress = false;
clearTimeout(reconnectFallbackTimer);
joinedSocketId = null;
joinedRoomId = null;
@@ -156,19 +188,36 @@ socket.on('connect', function () {
});
socket.on('disconnect', function () {
wakeCheckInProgress = false;
joinedSocketId = null;
joinedRoomId = null;
if (window.LIST_ID) disableCheckboxes(true);
});
socket.on('connect_error', function () {
wakeCheckInProgress = false;
if (window.LIST_ID) disableCheckboxes(true);
});
socket.off('joined_confirmation');
socket.on('joined_confirmation', function () {
// Potwierdzenie dołączenia nie jest komunikatem dla użytkownika. Po zwykłym
// reconnectcie karta ma wrócić do działania bez sztucznego "ponownie dołączono".
socket.on('joined_confirmation', function (data) {
const isInitialVisibleJoin = !didCompleteInitialJoin && !data.silent;
didCompleteInitialJoin = true;
if (Array.isArray(data.users)) {
currentRoomUsers = data.users;
window.currentRoomUsers = data.users;
}
if (isInitialVisibleJoin) {
const title = data.list_title ? `: ${data.list_title}` : '';
showToast(`Dołączono do listy${title}`, 'success');
if (currentRoomUsers.length > 0) {
showToast(`Obecni: ${currentRoomUsers.join(', ')}`, 'info');
}
}
requestCurrentList();
});
@@ -181,10 +230,11 @@ socket.on('user_left', function (data) {
});
socket.on('user_list', function (data) {
if (data.users.length > 0) {
const userList = data.users.join(', ');
showToast(`Obecni: ${userList}`, 'info');
}
// Stan obecności przechowujemy, ale nie pokazujemy dodatkowego toasta przy
// każdej zmianie. Pierwsze wejście pokazuje pełną listę z joined_confirmation,
// a później wystarczają komunikaty user_joined / user_left.
currentRoomUsers = Array.isArray(data.users) ? data.users : [];
window.currentRoomUsers = currentRoomUsers;
});
socket.on('receipt_added', function (data) {
@@ -227,6 +277,7 @@ socket.on("items_reordered", data => {
socket.on('full_list', function (data) {
const itemsContainer = document.getElementById('items');
if (!itemsContainer) return;
const oldItems = Array.from(
itemsContainer.querySelectorAll('li[data-name], li[id^="item-"]')
@@ -237,7 +288,7 @@ socket.on('full_list', function (data) {
window.currentItems = data.items;
updateListSmoothly(data.items);
disableCheckboxes(false);
recoveryInProgress = false;
wakeCheckInProgress = false;
if (typeof window.syncSortModeUI === 'function') {
window.syncSortModeUI();
}
@@ -255,4 +306,4 @@ socket.on('item_marked_not_purchased', data => {
socket.on('item_unmarked_not_purchased', data => {
socket.emit('request_full_list', { list_id: window.LIST_ID });
});
});