В приложении с комнатой, где люди видят друг друга в реальном времени, сервер получает одно и то же событие «соединение закрылось», когда человек закрыл вкладку, обновил страницу, свернул браузер на телефоне или на секунду потерял Wi-Fi. А вести себя в этих случаях надо по-разному. Рассказываю, как в своём Planning Poker я добился того, чтобы закрывший вкладку исчезал из-за стола за доли секунды, а обновивший страницу оставался вместе со своим голосом, через какие грабли прошёл в Spring, RabbitMQ, MongoDB, Firefox и zone.js и как это измерял.

Предыстория

Я решил сделать свой Planning Poker: сервис без регистрации, где ведущий создаёт комнату, присылает ссылку, участники входят по именам, каждый втайне выбирает карту, карты открываются, оценка принимается. Казалось, что самое сложное здесь закрытые карты и синхронное открытие. На деле самой упрямой оказалась другая задача: кто сейчас сидит за столом.

За столом сидят люди, и всем видно, кто за ним сидит. Если кто-то закрыл вкладку, а его место осталось занятым, карты не откроются, пока остальные не разберутся, чей это «призрак». А если обновил страницу и пропал вместе со своим голосом, это тоже раздражает. По дороге к ответу я собрал заметную коллекцию граблей в Spring, RabbitMQ, MongoDB, Firefox и zone.js, и именно ими хочу поделиться.

Задача

Стек обычный: Spring Boot на Java 25, STOMP поверх WebSocket (SockJS), RabbitMQ как STOMP-брокер с топиком на комнату, MongoDB; на клиенте Angular с NgRx и stompjs 2.3.3, это старая библиотека Джеффа Меснила 2014 года (пакет stompjs в npm), а не современный @stomp/stompjs; к этому я ещё вернусь в конце. Экземпляр сервера один, так что «кто сейчас онлайн» можно держать в памяти.

Требования, к которым пришёл после нескольких итераций:

Что сделал человек

Что должны увидеть остальные

Закрыл вкладку или весь браузер

Сразу: человек ушёл

Обновил страницу

Ничего: остался за столом вместе со своим голосом, в любом браузере

Пропал интернет на пару секунд

Ничего: остался, а сам после восстановления видит актуальный стол

Пропал интернет надолго

Ушёл. Когда связь вернётся, он увидит форму входа с подставленным ником и вернётся одним нажатием

Переключился на другую вкладку, свернул браузер на компьютере

Ничего: остался

Почему это сложно

Сервер во всех пяти случаях видит одно и то же: WebSocket закрылся. Закрытие вкладки, обновление страницы, падение браузера и обрыв сети на уровне соединения неразличимы.

Браузер знает чуть больше. Событие pagehide говорит «страница уходит намеренно», и это отделяет закрытие и обновление от обрыва сети. Но и браузер не отличает обновление от закрытия: pagehide срабатывает в обоих случаях, а то, что это было обновление, узнаёт только новая страница после загрузки, задним числом (performance.getEntriesByType('navigation')[0].type === 'reload').

Знание разделено. Браузер знает «ухожу намеренно» в момент ухода. Сервер узнаёт «он вернулся» через секунду-две после этого. Всё решение построено вокруг этого факта, но дошёл я до него не сразу.

Грабли 0. Сначала проверьте, что хранилище не теряет изменения

До всякого присутствия я прогнал живой тест: 10 комнат по 8 человек входят одновременно. Из 80 входов терялось 26-29. Голоса пропадали, вышедшие оставались в комнатах.

Причина классическая. Каждое действие делало «прочитать комнату целиком → изменить в памяти → сохранить целиком». Два параллельных действия в одной комнате, и последнее сохранение молча затирает первое. Коварство в том, что событие о первом действии к этому моменту уже разослано: на экранах всё выглядит нормально, а в базе человека нет.

Первой мыслью была оптимистическая блокировка через @Version. Отказался в пользу атомарных обновлений MongoDB: каждое изменение - одна операция над документом комнаты.

Две детали реализации в Spring Data

Атомарные операции живут в custom repository fragment: RoomRepository extends MongoRepository<Room, UUID>, AtomicRoomRepository, а реализация AtomicRoomRepositoryImpl подхватывается по суффиксу Impl; это настолько неочевидно, что заслуживает комментария в коде.

Ники сравниваются без учёта регистра и пробелов по краям, а MongoDB сравнивает строки точно, поэтому у участника есть отдельное поле key с нормализованным ником, и все условия в запросах идут по нему.

Присутствие - это поток конкурентных вставок и удалений одного и того же человека. Если хранилище не атомарно, никакая логика сверху не спасёт.

Грабли 1. Исходная версия: «ушёл» по beforeunload

В первой версии клиент на beforeunload отправлял сообщение «я выхожу». Проблемы видны сразу:

  • обновление страницы = выход: голос пропадает, а если человек был один, комната удаляется;

  • упавший браузер, убитый процесс, разряженный телефон, обрыв сети: сообщение не уходит, за столом навсегда остаётся “призрак”;

  • beforeunload ненадёжен на мобильных и отключает back-forward cache.

Шаг 1. Присутствие по соединению и период ожидания

Идея: человек держит своё место открытым STOMP-соединением, а сервер сам решает, когда место освободилось.

На сервере (RoomPresence) при входе в комнату место (Seat) запоминает id STOMP-сессии. На SessionDisconnectEvent, если место больше никто не держит, планируется выход через presence.grace-period (PresenceConfig), у меня 10 секунд.

На клиенте вкладка запоминает своё место в sessionStorage под ключом с id комнаты: ник и роль (seat-storage.ts). sessionStorage принадлежит вкладке и переживает обновление, но не закрытие, ровно то, что нужно. После обновления или переподключения клиент отправляет /app/room/{id}/participants/return. Если место ещё ждёт, запланированный выход отменяется, и остальные ничего не замечают. Если не дождалось, показывается форма входа с прежним ником. После переподключения комната загружается заново.

Отдельный случай - перезапуск сервера. Сессии в памяти пропали, а участники в MongoDB остались. Поэтому при старте каждому сохранённому участнику даётся presence.startup-grace-period = 30 секунд на возвращение, кто не вернулся - удаляется.

Итог шага: обновление и короткий обрыв работают, призраков нет. Но закрывший вкладку сидит за столом ещё 10 секунд, а требование говорит «сразу».

Грабли 2. Пульс и фоновые вкладки

У stompjs по умолчанию heartbeat 10 секунд. Сервер замечает пропажу соединения после двух-трёх пропущенных ударов, плюс 10 секунд ожидания. В живой проверке людей при этом иногда выкидывало и при обрывах на 4-8 секунд.

Пульс

Обрыв замечен через

Человек уходит через

10с (по умолчанию)

20-30с

30-40с вместо «примерно десяти»

3с в обе стороны

6-9с

16-19с, обрывы короче ~10с никого не выкидывают

Цена короткого пульса: браузер, свёрнутый на телефоне дольше 15 секунд, теряет место, потому что мобильные ОС замораживают вкладку. Возврат - одним нажатием. На это пошёл сознательно.

Дальше вылезли фоновые вкладки на компьютере. Браузеры замедляют таймеры неактивных вкладок, Chrome до раза в минуту. Пульс останавливается, сервер считает соединение мёртвым и выкидывает человека, который просто переключился на другую вкладку. Решение: таймеры пульса тикают в Web Worker (worker-heartbeat.ts), его таймеры не замедляются. stompjs берёт таймеры из window.Stomp.setInterval и clearInterval, их и подменяем.

Заодно подкрутил переподключение: паузы 1, 2, 4, 5 секунд (раньше первая была 5 секунд, и за это время место успевало уйти) и немедленная попытка по событию online. И починил зависший запрос: тот, что ждал ответа в момент обрыва, ждал его на мёртвом соединении вечно, из-за чего проверка ника зависала, а кнопка «Войти» не работала. Теперь запрос повторяется на новом соединении.

Шаг 2. Как отличить закрытие от обрыва связи

Рассматривал три варианта.

Коды закрытия WebSocket: 1000 и 1001 при штатном закрытии,1006 при обрыве. Отказался: stompjs сам штатно закрывает сокет, когда пропускает пульсы сервера, и этот «чистый» close-фрейм после короткого обрыва вполне может дойти до сервера. Сервер принял бы обрыв сети за намеренное закрытие.

navigator.sendBeacon на отдельный HTTP-эндпоинт. Изучал, но не стал пробовать: пришлось бы связывать HTTP-запрос со STOMP-сессией, и тот же вопрос порядка (что придёт раньше, beacon или закрытие сокета) никуда не делся. Вживую не проверял, так что это альтернатива, а не отвергнутый по измерениям вариант.

Явный маркер закрытия по уже открытому соединению. Выбрал её. На pagehide клиент отправляет STOMP-сообщение /app/presence/page-closed с пустым телом; в базе оно не хранится.

На сервере всё сводится к двум событиям, маркеру закрытия и закрытию соединения, и к тому, в каком порядке они приходят:

Что произошло

Что делает сервер

Пришел маркер закрытия, соединение пока открыто

Запоминает: эта сессия закрывается намеренно

Соединение закрылось, и маркер закрытия от него был

Выводит человека из-за стола сразу

Соединение закрылось, а маркера закрытия не было

Считает это обрывом связи и ждёт 10 секунд: вдруг вернётся

Два нюанса на клиенте (room-web-socket.service.ts). Событие именно pagehide, а не beforeunload или unload. И если event.persisted === true, страница уходит в back-forward cache и может вернуться, поэтому маркер закрытия не отправляем; если её соединение потом закроется, сервер отнесётся к этому как к обрыву.

// room-web-socket.service.ts (сокращено)
private sayPageClosed(event: PageTransitionEvent) {
  if (!event.persisted && !this.pageClosedSaid && this.stompClient !== null && this.connected$.value) {
    this.stompClient.send(RoomDestinations.pageClosed, {}, "");
    this.pageClosedSaid = true;
  }
}
@Controller
public class PresenceController {
    // A page that is being closed says so right before its connection closes
    @MessageMapping("/presence/page-closed")
    void pageClosed(@Header(SimpMessageHeaderAccessor.SESSION_ID_HEADER) String sessionId) {
        roomPresence.pageClosed(sessionId);
    }
}

Грабли 3. Порядок кадров: маркер закрытия приходит после закрытия соединения

Spring по умолчанию обрабатывает входящие кадры одного соединения в пуле потоков, то есть не обязательно по порядку.

Узнал я об этом в другом месте: UNSUBSCRIBE, отправленный сразу после SUBSCRIBE, обгонял его по дороге в RabbitMQ, RabbitMQ отвечал кадром ERROR, а Spring после любого ERROR закрывает соединение браузера. В Firefox из-за этого зависала проверка ника.

Шаг 3. Включить порядок обработки кадров

Лечится одной строкой в registerStompEndpoints (WebSocketConfig): registry.setPreserveReceiveOrder(true). У этого есть побочный эффект. Ошибки сервер шлёт в личную очередь /user/topic/room.errors, и раньше клиент подписывался на неё в момент запроса, впритык. Пока кадры обрабатывались вразнобой, это сходило с рук. Теперь кадры идут строго по порядку: если запрос ушёл раньше подписки на ошибки, ответ с ошибкой доставляется раньше, чем подписка успела появиться, и пропадает. Поэтому клиент подписывается на ошибки один раз, при открытии комнаты, и держит эту подписку, пока комната открыта, вместо того чтобы подписываться и отписываться на каждый запрос.

Но даже при сохранённом порядке SessionDisconnectEvent может обработаться раньше маркера закрытия, если предыдущий кадр обрабатывался долго. Поэтому место запоминает сессию, которая закрылась последней, и опоздавший маркер закрытия обрывает уже начавшееся десятисекундное ожидание: человек уходит сейчас.

public void pageClosed(String sessionId) {
    synchronized(lock) {
        if(sessionSeats.containsKey(sessionId)) {   // соединение ещё открыто
            closingSessions.add(sessionId);
            return;
        }
        // соединение уже закрыто и место ждёт, как при обрыве: уходим сейчас
        seats.forEach((seatKey, seat) -> {
            if(sessionId.equals(seat.closedSessionId) && seat.sessionIds.isEmpty() && !seat.leaving
                    && seat.departure == null) {
                scheduleSteppingAway(seatKey, seat);
            }
        });
    }
}

Грабли 4. Firefox теряет маркер закрытия примерно в половине закрытий

Самая длинная часть расследования. Симптом: в Chrome закрывший вкладку исчезал за 0,1 секунды, в Firefox часто сидел ещё 11 секунд. В логе сервера маркер закрытия из Firefox просто не появлялся.

Путь был такой.

  1. Записывал, что делает закрывающаяся страница, в localStorage. Первая версия записи сама ломала страницу. Вторая работала, но меняла тайминги и вместе с ними результат: инструмент диагностики искажал измеряемое.

  2. Сделал отправку маркера короче и первой по порядку в обработчике. Не помогло.

  3. Отключил отладочный лог stompjs. Не помогло.

  4. Измерил без записи: на локальной копии сервера маркер отправляли 8 закрытий из 24, на тестовом стенде 18 из 24.

  5. Записи всё же показали главное: обработчик начинал работу и обрывался посреди отправки, без исключения и без ошибки в консоли. Следующие обработчики того же события при этом выполнялись.

  6. Подсказка пришла случайно. Когда тест добавлял на pagehide ещё один, более ранний обработчик, обрывался чаще всего именно он, а мой доходил: 22 раза из 24.

Вывод, и это наблюдение по измерениям, а не спецификация и не найденный баг в Bugzilla: Firefox при закрытии или перезагрузке вкладки может прервать скрипт, который выполняется в этот момент, а следующие обработчики того же события продолжают выполняться.

Шаг 4. Маркер закрытия отправляют два обработчика

Первый отправляет. Второй отправляет, только если первый не успел: флаг pageClosedSaid выставляется сразу после send.

Подвох Angular: zone.js вызывает все обработчики одного события и одной фазы из одного нативного колбэка. Если Firefox прерывает этот колбэк, умирают оба обработчика сразу. Поэтому они висят на разных фазах: один на capture, другой на bubble.

window.addEventListener("pagehide", this.onPageHide, true); // capture: срабатывает первым
window.addEventListener("pagehide", this.onPageHide);       // bubble: страхует первый

Результат: 36 закрытий и обновлений из 36. Остальные видят уход через 0,04-0,17 секунды на локальной копии и через 0,4-0,5 секунды на стенде за Cloudflare.

Грабли 5. Обновление страницы и мгновенный выход

Как только выход стал мгновенным, обновление страницы тоже стало выходом, в Chrome всегда. В Firefox место при обновлении сохранялось, но только потому, что маркер закрытия терялся: баг маскировал баг. Ручную проверку вдобавок путал закэшированный index.html: после выкладки браузер показывал старую сборку до Ctrl+Shift+R. Это лечится Cache-Control: no-cache на index.html, а бандлы с хэшем в имени можно кэшировать навсегда.

Варианты, между которыми выбирал:

  • Ждать 3 секунды, потом убирать. Обновившаяся страница успевает вернуться, и остальные ничего не замечают, голос цел. Но закрывший вкладку сидит за столом ещё 3 секунды, а хотелось «сразу».

  • Убирать сразу. Закрытие мгновенное, но обновление страницы стирает голос, а если человек был за столом один, вместе с ним удаляется и комната.

  • Сразу показывать место потускневшим, а убирать через 3 секунды. Остальные видят, что человек, кажется, уходит, но место ещё держится; если страница вернулась, место снова загорается. Выглядит честно, но это третье состояние места с собственным событием и анимацией, заметно сложнее первых двух.

Выбрал «и то и другое»: обновление оставляет за столом во всех браузерах, закрытие выводит сразу. Дальше инженерная часть.

Шаг 5. Итоговая схема: отошёл от стола и вернулся

Раз сервер не может отличить обновление от закрытия в момент закрытия, он и не пытается. Вместо этого он разделяет два понятия: «уйти из-за стола» и «уйти из комнаты».

  1. Пришёл маркер закрытия, и соединение закрылось: человек сразу уходит из-за стола вместе с голосом, всем летит PARTICIPANT_REMOVED. Остальные видят уход мгновенно.

  2. Но место 10 секунд помнит, кто ушёл и с каким голосом: Departure(participant, vote).

  3. Обновлённая страница через секунду-две присылает return, и сервер возвращает участника и голос в комнату, рассылая PARTICIPANT_ADDED и VOTE_ADDED.

  4. Никто не вернулся за 10 секунд: человек ушёл из комнаты окончательно, и только теперь пустая комната удаляется.

Закрытие вкладки: маркер закрытия, соединение закрыто, уход из-за стола сразу, через 10 секунд из комнаты
Закрытие вкладки: маркер закрытия, соединение закрыто, уход из-за стола сразу, через 10 секунд из комнаты
Обновление страницы: уход из-за стола, через 1-2 секунды return, участник и голос возвращаются
Обновление страницы: уход из-за стола, через 1-2 секунды return, участник и голос возвращаются
Обрыв сети: короткий - место ждёт и дожидается, долгий - пропущенные пульсы и уход через 16-19 секунд
Обрыв сети: короткий - место ждёт и дожидается, долгий - пропущенные пульсы и уход через 16-19 секунд

Цена этой схемы: при обновлении остальные на мгновение видят, как человек исчезает и возвращается, 0,4-0,9 секунды на локальной копии и 1,5-1,9 секунды на стенде. Это осознанный выбор, а не баг: мигание принципиально неустранимо без задержки ухода при закрытии.

Мелочи, без которых схема ломается:

  • пока единственный участник «отошёл», пустая комната не удаляется;

  • если за эти 10 секунд начался новый раунд, запомненный голос выбрасывается, причём очистка запомненных голосов вызывается до очистки голосов в базе, иначе гонка;

  • вернувшаяся страница может увидеть себя дважды, в снимке комнаты и в событии о своём возвращении, поэтому редьюсер не добавляет участника, который уже сидит за столом;

  • если за 10 секунд ник занял кто-то другой, вернуть нельзя: показываем форму входа с прежним ником.

Грабли 6. Гонка на возврате: вернулся, но его никто не видит

Редкий сбой: после обновления человек сидит за столом, а остальные его не видят.

Удаление из-за стола - два шага: изменить MongoDB, потом разослать событие. Параллельный return того же ника мог успеть разослать своё событие раньше. Остальные получали ADDED, который игнорировался, потому что человек ещё за столом, затем REMOVED, и человек пропадал. Плюс return, пришедший, пока шёл уход из-за стола, отклонялся, и страница показывала форму входа. Планировщик присутствия по умолчанию однопоточный (ThreadPoolTaskScheduler), поэтому уход одного человека мог стоять в очереди за массовым уходом в нагрузочном тесте достаточно долго. Честно скажу: причина выведена из кода и нагрузочного теста, вживую поймана не была.

Шаг 6. Лок от изменения в БД до рассылки события

SeatLocks: 64 полосатых лока по хэшу пары (комната, ник). Каждое изменение места человека (вход, выход, уход из-за стола, возврат) держит его лок от изменения в базе до рассылки события, так что события о человеке уходят в том порядке, в каком менялось хранилище. Отсюда следствие: события рассылаются вручную внутри лока, а не через @SendTo после возврата из метода. return во время ухода ждёт его окончания и сразу возвращает человека. Набор локов фиксированный, их не надо удалять, когда человек уходит; два человека, попавшие в один лок, просто подождут друг друга мгновение. Правило одно: держа один лок, не брать другой. Пул планировщика стал 4 потока.

public <T> T change(UUID roomId, String nickname, Supplier<T> change) {
    synchronized(lockOf(roomId, nickname)) {
        return change.get();
    }
}

private Object lockOf(UUID roomId, String nickname) {
    int hash = Objects.hash(roomId, Participant.normalizeNickname(nickname));
    return locks[Math.floorMod(hash, LOCK_COUNT)];
}

Грабли 7. Как это вообще тестировать

Юнит-тестов здесь мало: всё решают тайминги настоящих браузеров. Поэтому сделал живые E2E на Playwright, которые гоняются из GitHub Actions по тестовому стенду и по локальной копии сервера, собранной из веток.

Сценарии: два раунда голосования командой; люди на iPhone и Pixel; закрытие вкладки; закрытие браузера; обновление страницы; обрыв сети на 4,5 секунды и надолго; упавший браузер; 10 команд по 8 человек. Закрытие проверяется в Chrome, Firefox и WebKit. Тест измеряет, через сколько секунд остальные видят уход или возврат, а не только сам факт. Сбои вероятностные, поэтому важные сценарии гоняются по 24-36 раз подряд и сравниваются доли.

Подвохи Playwright, которые стоили времени
  • headless shell Chromium при context.close() не вызывает pagehide, нужен полный Chromium (channel: 'chromium');

  • WebKit в Playwright закрывает контекст грубо, не давая странице попрощаться, поэтому тест закрытия браузера там пропущен;

  • обрыв сети через setOffline честно работает только в Chromium;

  • WebKit в Playwright не передаёт HTTP basic auth при WebSocket-рукопожатии: за логином стенда SockJS тихо переключался на XHR. Решил cookie-логином на стенде.

Главный урок диагностики: она меняет поведение. Запись в localStorage сдвигала тайминги. Измерять лучше снаружи, по логу сервера и по тому, что видят другие участники.

Итог: что происходит в каждом случае

Случай

Браузер

Сервер

Остальные видят

Закрыл вкладку или браузер

pagehide → маркер закрытия → соединение закрыто

сразу убирает из-за стола, 10с помнит место, потом удаляет из комнаты

уход через 0,1-0,5с

Обновил страницу

через 1-2с return

возвращает участника с голосом

мигание 0,4-1,9с, голос на месте

Короткий обрыв сети

пульс не пропущен или переподключение за 1-5с + return

место ждёт 10с

ничего

Долгий обрыв

ничего

пропущенные пульсы (6-9с) + 10с ожидания

уход через 16-19с; вернувшийся видит форму с ником

Браузер упал

ничего

закрытие без маркера = обрыв

уход примерно через 11с

Страница ушла в bfcache

persisted = true, маркера нет

как обрыв

как обрыв

Фоновая вкладка

пульс тикает в Worker

ничего

ничего

Перезапуск сервера

переподключение + return

30с на возвращение всем из базы

ничего

Ограничения

  • Места хранятся в памяти одного экземпляра сервера. Для нескольких экземпляров нужно общее хранилище присутствия, например Redis с TTL, и маршрутизация событий о человеке через него.

  • Свёрнутый на телефоне браузер теряет место через 16-19 секунд: мобильные ОС замораживают вкладку, и Worker не спасает.

  • Поведение Firefox с прерыванием скрипта - наблюдение по измерениям; документированного описания я не нашёл. Если вы знаете соответствующий баг или спецификацию, напишите в комментариях.

Что я узнал уже после

Когда схема работала, я обнаружил, что stompjs, которым пользовался, давно не развивается, а под именем @stomp/stompjs живёт другая библиотека (сейчас версия 7). Судя по её документации и списку изменений, часть того, что я делал руками, там есть из коробки: переподключение с экспоненциальной задержкой, хук перед каждым подключением, а с версии 7.1 и отправка пульса из Web Worker, то есть как раз обход замороженных таймеров в фоновой вкладке из граблей 2. Для Angular есть и обёртка @stomp/rx-stomp.

Я ей ещё не пользовался, поэтому не знаю, насколько хорошо всё это работает на практике и закрыла бы она остальные грабли: порядок кадров, pagehide в Firefox и гонку на возврате к ней отношения не имеют. Но если вы начинаете проект сейчас, смотреть стоит сначала на неё; если уже перешли, расскажите в комментариях, что удалось выкинуть.

Выводы

  • Сервер не может отличить закрытие от обновления. Не гадать: разделить на «ушёл из-за стола» и «ушёл из комнаты».

  • О намерении знает только браузер, и сообщить его может только маркером закрытия по уже открытому соединению на pagehide. Коды закрытия WebSocket ненадёжны.

  • Маркер закрытия может опоздать и потеряться. Сервер должен работать и без неё, по пульсу, и уметь принять его после закрытия соединения.

  • Порядок важен на каждом уровне: кадры одного соединения, изменение в базе и событие о нём, уход и возврат одного человека.

  • Пульс настраивается под требования и переносится в Worker.

  • Тестировать в настоящих браузерах, много раз, с измерением времени, и помнить, что диагностика меняет измеряемое.


Всё описанное работает на pipoker.app, там это можно проверить самому: открыть комнату в двух браузерах, одну закрыть, другую обновить.

Код открыт:

Полезное чтение: pagehide на MDN, bfcache, порядок сообщений STOMP в Spring.

Комментарии (0)