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

Остановка чата и границы сопровождения

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

Последнее состояние объединяет «Линию»: HTTP-сессия, WebSocket-комнаты, проверка команд, подтверждения, журнал, восстановление, присутствие, SSE и пределы буфера. Добавим управляемую остановку и опишем контракт сопровождения. Сервер с длинными соединениями нельзя завершать только прекращением приёма HTTP-запросов: открытые каналы, подписчики и таймеры имеют собственный жизненный цикл.

Снимок stages/16 является полным локальным примером серии, а не выпуском публичного сервиса. Статус not_run: зависимости не устанавливались, приложения и тесты не запускались, сборка и деплой не выполнялись. Архив позволяет читателю самостоятельно воспроизвести состояние и проверить ожидаемые эффекты. Нельзя ссылаться на этот текст как на подтверждение успешного нагрузочного испытания или готовности обслуживать реальные обращения.

Остановка состоит из нескольких действий

При SIGTERM или SIGINT ставим признак stopping. Повторный сигнал не запускает ту же процедуру заново. Затем закрываем SSE-ответы, останавливаем heartbeat, предлагаем WebSocket-клиентам нормальное закрытие и перестаём принимать новые HTTP-соединения. Через пять секунд оставшиеся каналы принудительно освобождаются. Срок является пределом учебной процедуры, а не измеренным временем завершения конкретной инфраструктуры.

Полный server.js последнего снимка показывает порядок действий вместе с маршрутами. Здесь нет скрытого внешнего процесса, который автоматически переносит историю или восстанавливает сессии после завершения.

import express from 'express';
import { createServer } from 'node:http';
import { fileURLToPath } from 'node:url';
import { actors, roomIds } from './contract.js';
import { createDemoSession, readSession, origin } from './identity.js';
import { attachSockets } from './socket.js';
import { subscribe, closeEvents } from './events.js';

const app = express();
let stopping = false;
app.use(express.json({ limit: '2kb' }));
app.use(express.static(fileURLToPath(new URL('./public', import.meta.url))));
app.get('/health', (request, response) => {
  response.status(stopping ? 503 : 200).json({ ready: !stopping });
});
app.post('/demo/session', (request, response) => {
  if (request.get('Origin') !== origin) return response.sendStatus(403);
  const id = createDemoSession(request.body?.actorId);
  if (!id) return response.status(400).json({ error: 'invalid_demo_actor' });
  response.set('Set-Cookie',
    `line_demo=${id}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`);
  response.json({ actor: actors[request.body.actorId] });
});
app.get('/api/rooms', (request, response) => {
  const session = readSession(request);
  if (!session) return response.sendStatus(401);
  response.json({ rooms: session.actor.rooms });
});

app.get('/events', (request, response) => {
  const session = readSession(request);
  if (!session) return response.sendStatus(401);
  subscribe(request, response, session.actor);
});

app.use((error, request, response, next) => {
  if (response.headersSent) return next(error);
  response.status(error.status || 500).json({ error: 'request_failed' });
});
const server = createServer(app);
const sockets = attachSockets(server);
server.listen(4310, '127.0.0.1');

function shutdown() {
  if (stopping) return;
  stopping = true;
  closeEvents();
  sockets.close();
  server.close();
  const deadline = setTimeout(() => {
    for (const client of sockets.wss.clients) client.terminate();
    server.closeAllConnections();
  }, 5000);
  deadline.unref();
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

server.close() управляет HTTP-сервером, но уже переключённые WebSocket-сокеты требуется закрывать отдельно. Поэтому объект, возвращаемый attachSockets(), содержит close() и wss. В нормальном пути используем код 1001 — сервер прекращает работу. Клиент видит закрытие и может начать свою процедуру восстановления, если сервер снова доступен и контекст сессии ещё существует. После настоящего рестарта учебная сессия исчезает.

closeAllConnections() относится к HTTP-соединениям и вызывается после прекращения приёма новых подключений. Переключённые сокеты освобождаем через terminate() отдельно. Не стоит считать один общий метод универсальной очисткой всех транспортов. Сам deadline.unref() не удерживает процесс ради таймера, но при оставшихся сетевых ресурсах таймер всё ещё сможет завершить принудительную часть процедуры.

Готовность — договор с окружающей системой

Маршрут /health после начала остановки должен возвращать 503, а до неё — JSON с ready true. Такая надпись отражает состояние процесса и его намерение принимать работу. В нашем примере нет базы или провайдера входа, поэтому проверка не обещает их исправность. Для production нужно определить, какие зависимости действительно необходимы для обслуживания и как долго readiness может оставаться положительным при их отказе.

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

Ожидаемый пользовательский эффект — закрытие канала и выключение отправки. SSE также завершает поток. История не переносится в другой процесс, cookie перестаёт соответствовать серверной карте после рестарта, а новая эпоха журнала не имеет старых сообщений. Повторное создание учебной сессии открывает новый локальный разговор. Это конкретная граница данного приложения, которая важнее внешнего сходства с промышленным чатом.

SIGTERM → stopping=true
SSE завершён; heartbeat очищен; WebSocket закрывается
Новые HTTP-соединения не принимаются
Через deadline оставшиеся соединения принудительно освобождаются

Что хранит текущий процесс

Журнал содержит последние двести сообщений каждой комнаты, окно принятых команд — две тысячи результатов, SSE — сто уведомлений. Сессия живёт час, карта создаётся в памяти. Эти пределы предотвращают некоторые виды накопления, но не создают долговечность и не заменяют общее ограничение числа соединений. В README зафиксировано, что все данные вымышлены и сервер слушает только loopback-адрес.

Для реального сервиса потребуется долговечный журнал с атомарным назначением последовательности и записью ключа команды. Одновременно необходимо выбрать настоящий механизм входа и проверку прав, включая отзыв доступа у уже подключённого клиента. Отдельная проверка Origin остаётся полезной для браузерного источника, но не доказывает личность и не ограничивает произвольный программный клиент. Учебный выбор роли не должен попадать в публичную конфигурацию.

Несколько серверных процессов потребуют общей координации событий и подписок. Локальный wss.clients видит только свой экземпляр. Если добавить Redis Pub/Sub для уведомления между серверами, нельзя считать его долговечным журналом: отключённый подписчик пропустит событие. Источником восстановления должны оставаться сохранённые записи с курсором, а канал рассылки помогает узнать об изменении. При смене транспорта смысл принятия и восстановления сохраняется.

Диагностика без содержимого переписки

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

При расследовании дубля сначала сравнивают clientMessageId, messageId, epoch и seq. Один клиентский ID с разными messageId после смены эпохи показывает границу памяти процесса. Разные клиентские ID с одинаковым текстом могут быть намеренными отдельными действиями человека. Повтор одного messageId в DOM относится к отображению или повторной истории. Такая классификация сокращает догадки и связывает проблему с конкретным слоем модели.

Если клиент часто закрывается с 1013, нужно изучить рост буфера и размер выдаваемой истории. Если наблюдается 1008, проверяют срок сессии и политику доступа. Ошибка room_forbidden относится к разрешению комнаты, а text_length — к вводу. Нельзя исправлять все случаи увеличением таймаута reconnect: это меняет частоту попыток, но не устраняет неподходящий договор или неправильное право пользователя.

Управление соединениями описано в HTTP API Node.js 24 и ws. Полные состояния доступны в архиве «Линии». Распакуйте его в отдельную папку и выбирайте один stages/NN; не объединяйте разные номера и не подключайте реальные обращения. Полученный пример завершает учебный договор от ввода до восстановления, сохраняя явные границы памяти, идентичности и выпуска.