Перейти к содержанию

Присутствие и проверка живого соединения

Оглавление серии

Событие закрытия не всегда приходит сразу после потери сети. Соединение может некоторое время считаться открытым, хотя клиент недоступен. Добавим heartbeat — периодическую проверку ответа протокола. Одновременно покажем присутствие участников комнаты, сохранив различие: живой сокет подтверждает техническую доступность канала, а присутствие человека остаётся приблизительной прикладной моделью.

В stages/12 сервер использует ping/pong библиотеки ws. Браузер сам отвечает на протокольный ping; JavaScript страницы не получает обычное событие пользовательской команды для такого кадра. Пример имеет статус not_run; интервалы и исчезновение участников описаны как ожидаемые эффекты. Они не являются измеренным временем обнаружения сбоя конкретной сети или браузера.

Проверка живого соединения

При подключении ставим client.isAlive = true, а на pong снова устанавливаем true. Каждые тридцать секунд сервер проверяет значение. Если предыдущая проверка не получила ответа, соединение принудительно завершается. Если ответ есть, сервер сбрасывает признак и отправляет новый ping. Таким образом, один цикл даёт возможность ответить, а следующий оценивает результат предыдущего.

Полный файл presence.js содержит heartbeat и рассылку состава комнаты. Важно, что обе операции используют один набор соединений, но отвечают на разные вопросы.

export function announcePresence(wss, roomId, send) {
  const actors = new Set();
  for (const client of wss.clients) {
    if (client.roomId === roomId) actors.add(client.actor.id);
  }
  const event = { version: 1, type: 'presence', roomId, actors: [...actors] };
  for (const client of wss.clients) {
    if (client.roomId === roomId) send(client, event);
  }
}

export function startHeartbeat(wss) {
  const timer = setInterval(() => {
    for (const client of wss.clients) {
      if (client.isAlive === false) {
        client.terminate();
        continue;
      }
      client.isAlive = false;
      client.ping();
    }
  }, 30000);
  timer.unref();
  return () => clearInterval(timer);
}

Метод terminate() немедленно освобождает неисправное соединение. Обычный close() выполняет процедуру закрытия и ожидает участия удалённой стороны, поэтому для клиента, который уже перестал отвечать, такой путь может затянуться. Принудительное завершение уместно после явной проверки ответа. Для добровольного выхода или плановой остановки сервер использует нормальное закрытие с объяснимым кодом, а не всегда уничтожает транспорт.

Признак isAlive не является таймером активности пользователя. Человек может читать сообщение, не нажимая кнопки, а браузер продолжит отвечать на ping. И наоборот, задержка pong может быть вызвана сетью, перегруженным устройством или остановленным процессом. Поэтому не подписывайте это состояние «оператор внимательно читает» и не используйте его как доказательство, что задача получила человеческое внимание.

Несколько сокетов одного участника

Присутствие собирается через Set идентификаторов участников. Если Алиса открыла две вкладки с той же учебной сессией и обе вошли в комнату, в списке она появляется один раз. Закрытие одной вкладки не удаляет её, пока остаётся другое соединение. Это простое правило лучше отражает присутствие пользователя, чем количество сокетов, хотя всё ещё не учитывает устройства, статус занятости или реальную активность человека.

Функция announcePresence() проходит по клиентам, выбравшим комнату, строит множество авторов и отправляет одно событие всем подписчикам этой комнаты. Список не содержит людей из других обращений. Проверка прав уже происходит до join, поэтому присутствие не раскрывается сокету, которому запрещено войти. Если позднее появится отдельный HTTP-маршрут состава комнаты, он также должен проверять разрешение, а не полагаться на защиту канала чата.

Ожидаемое событие после входа Алисы и оператора имеет такую форму. Порядок массива в этом примере не является обещанием сортировки имён.

{
  "version": 1,
  "type": "presence",
  "roomId": "ticket-17",
  "actors": ["alice", "agent"]
}

Браузер выводит состав отдельной строкой и не добавляет его в журнал сообщений. Присутствие является изменяемым состоянием, а переписка — последовательностью записей. Если каждый heartbeat превращать в запись истории, журнал быстро заполнится техническими событиями и потеряет содержание разговора. Если каждое изменение состава показывать как фразу от участника, авторство также станет неточным: это событие сервера, а не сообщение человека.

Вход, выход и смена комнаты

После join сервер объявляет состав новой комнаты. При смене подписки необходимо обновить также старую комнату, потому что участник исчез из её состава. В обработчике запоминается previousRoom перед изменением свойства, затем рассылаются два состояния. Если обновить только новую комнату, люди в прежнем обращении будут видеть устаревший список до следующего случайного входа или закрытия другого соединения.

При close объявляем состав текущей комнаты снова. Серверная библиотека удаляет закрытый сокет из своего набора, поэтому итоговое множество отражает оставшиеся подключения. Если перейти к собственному реестру подписок, потребуется соблюдать порядок удаления и объявления. Рассылка состояния до удаления дала бы людям тот же устаревший состав, несмотря на корректно обработанное событие закрытия.

Рассмотрим ожидаемую ситуацию: оператор переходит из комнаты 17 в комнату 18, сохраняя одно соединение. В первой комнате состав меняется на Алису, во второй появляется оператор вместе с Борисом. Число соединений сервера не меняется, а присутствие двух комнат меняется. Этот пример показывает, почему наличие сокета и членство в конкретном разговоре должны храниться раздельно и почему одного глобального счётчика online недостаточно.

Heartbeat запускает таймер и возвращает функцию остановки. При завершении сервера таймер необходимо очищать, иначе в процессе останется ресурс, продолжающий обращаться к уже закрытому набору клиентов. unref() позволяет самому таймеру не удерживать процесс живым, но не заменяет логическую очистку. Управление жизненным циклом ресурса и решение о том, мешает ли он завершению процесса, имеют разные цели.

В этой модели присутствие находится только в одном процессе. Несколько серверов не знают о сокетах друг друга, поэтому простой Set каждого экземпляра дал бы неполный состав. Общая модель обычно хранит записи с сроком жизни и идентификаторами подключений, а не один вечный флаг online. Удаление одной записи не должно скрывать другие устройства участника; срок помогает очистить данные после аварийного завершения сервера.

Срок учебной сессии в данной серии контролируется при подключении и командах; heartbeat проверяет транспорт. Поэтому истёкшая простаивающая сессия требует отдельной политики удаления, если нужно немедленно прекратить показ присутствия и получение событий. Для production нельзя считать ответы pong продлением права доступа. Соединение может быть технически исправно и одновременно потерять разрешение на чтение обращения.

Протокольная проверка основана на heartbeat-примере ws. После этого состояния сервер обнаруживает молча потерянные каналы и показывает приблизительный состав комнат. Следующий урок добавит иной транспорт для односторонних уведомлений, сохранив правила прав, отдельного журнала и ограниченного восстановления.