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

Повторное соединение и согласование состояния

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

В состоянии lesson-07 provider включает повторные попытки с увеличивающейся задержкой. Исходные имена draft-42 и markdown остаются прежними; Y.Doc не уничтожается при кратком обрыве. Сохранение реплики позволяет подготовить недостающие серверу операции после возвращения. Локального дискового кеша пока нет, поэтому закрытие вкладки всё ещё отличается от временной потери WebSocket.

Обрыв транспорта

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

Событие close содержит код завершения. Учебный provider повторяет обычный сетевой обрыв, но прекращает автоматические попытки после отказа по политике или ошибки протокола. Если сервер отверг права комнаты, бесконечный reconnect не исправит разрешение. При истёкшей сессии пользователю нужен повторный вход, а при несовместимом формате — исправление клиента. Эти ситуации нельзя маскировать одинаковой надписью «Пробуем снова».

Ниже приведён точный фрагмент обработки закрытия в src/provider.js. this.attempt, this.retry, this.closed и функция connect определены в полном файле. Новый флаг retry включён при создании provider из src/app.js; предыдущий каталог не изменяется. Задержка включает небольшой случайный разброс, чтобы несколько клиентов не повторяли соединение строго одновременно.

    ws.onclose = ({code}) => {
      if (this.closed) return;
      this.status.textContent = 'Связь прервана; документ остаётся локально';
      if (this.retry && ![1008,1002].includes(code)) {
        this.timer = setTimeout(() => this.connect(), Math.min(10000,500 * 2 ** this.attempt++) + Math.random()*250);
      }
    };

Почему задержка увеличивается

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

Успешный обмен sync сбрасывает счётчик попыток. Мы делаем это после согласования состояния, а не сразу после открытия сокета. WebSocket может успешно открыться, но сервер затем отклонит комнату или сообщение hello. В такой ситуации транспорт формально доступен, а приложение ещё не восстановило рабочую сессию. Различение этих этапов предотвращает слишком раннее обещание готовности редактора.

После самостоятельного наблюдения краткого разрыва связь должна восстановиться без создания нового Y.Doc. Правки, сделанные локально во время обрыва, должны быть переданы серверу при новом обмене состояниями, а чужие пропущенные правки должны появиться в локальной реплике. Это ожидаемый результат протокола; авторского сетевого эксперимента не было. Порядок визуального появления строк не следует выдавать за измеренную задержку доставки.

Обмен после возвращения

Клиент отправляет актуальный вектор своего состояния. Сервер кодирует операции, которых клиент не знает, и также передаёт собственный вектор. После применения входящего update редактор кодирует локальную часть относительно серверного вектора. Таким образом восстанавливаются обе стороны: новые серверные операции идут вниз, накопленные локальные операции идут вверх. Отдельная очередь строковых команд для этого не нужна.

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

Не очищайте локальный документ перед reconnect, пытаясь сделать его «чистым». Такое действие выбросит правки, которые ещё не были переданы. Также не вставляйте заново текст из предварительного просмотра: это создаст новые операции. Правильный объект восстановления — существующая реплика и её двоичная структура. Полная замена текста имеет отдельный смысл импорта и должна выполняться по явному пользовательскому решению.

Присутствие после обрыва

Сервер удаляет awareness потерянного соединения, а новое соединение снова публикует локальное состояние. Это не долговременная запись присутствия: список строится заново из активных соединений. При двух вкладках Анны каждое состояние имеет собственный идентификатор реплики. Provider должен корректно сопоставить возвращающееся состояние, а сервер — не позволить новой сессии заявить идентификатор уже активного другого клиента.

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

Закрытие пользователем

Явная кнопка закрытия устанавливает флаг closed, удаляет таймер и завершает сокет. Обработчик close при этом не должен планировать новое соединение. Иначе пользователь нажмёт «Закрыть редактор», а приложение через секунду снова появится в комнате. Это типичная ошибка жизненного цикла, которую невозможно исправить свойствами CRDT: причина находится в управлении таймерами и обработчиками.

Повторный вход также не должен оставлять старый provider. В текущей странице перед новым открытием вызывается dispose. При самостоятельном разборе проследите, что подписки документа и awareness снимаются именно с прежнего объекта, а таймер принадлежит прежнему provider. Один активный редактор должен иметь один управляемый транспортный жизненный цикл, даже если попыток соединения было несколько.

Наконец, рассмотрите длительный офлайн. Пока вкладка открыта, она держит историю в памяти; закрытая вкладка без IndexedDB не сможет восстановить её. Поэтому reconnect и автономное хранение являются разными результатами. Этот урок возвращает связь живой реплике. Следующий научит восстанавливать локальный черновик после перезагрузки, сохранив тот же двоичный формат и понятный момент готовности.

Перед новым WebSocket provider проверяет доступность действующей сессии через собственный локальный endpoint. Это помогает отличить потерянный сервер от требуемого повторного входа: отказ upgrade не всегда сообщает браузеру понятный close code. После нового sync локальный awareness обновляет свой clock, чтобы удалённое ранее присутствие снова принималось другими репликами.

Оглавление серии · Исходные состояния проекта.

API сверены с первичной документацией 11 октября 2026 года. Пример имеет статус not_run: фактический запуск и наблюдение результата читателю предстоит выполнить самостоятельно.