Почему сообщения меняются местами ещё до отправки

Порядок сообщений нужно закрепить до их асинхронной подготовки. Шифрование второго сообщения может закончиться раньше первого, и WebSocket получит их уже в обратной последовательности. Очередь должна сохранять место каждого сообщения с самого начала, позволяя готовому результату дождаться своей отправки.

Учебные примеры подготовлены в октябре 2026 года. В них использованы современные возможности JavaScript и платформы; архивная дата не относится к этим реализациям.

Уникальные номера помогают заметить перестановку, но не предотвращают её. Счётчик может правильно выдать номера 1 и 2, а вызовы send() всё равно произойдут в порядке 2, 1. Для получателя, который ждёт строго следующий номер, второе сообщение окажется ошибочным, хотя его номер никто не использовал дважды.

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

Воспроизводим перестановку

Для начала нам не нужны ни сокет, ни настоящее шифрование. Вместо них будут массив отправленных сообщений и два промиса, которые мы завершим сами. Это позволяет получить нужную последовательность событий при каждом запуске, без подбора задержек.

const first = Promise.withResolvers();
const second = Promise.withResolvers();
const sent = [];
let counter = 0;

async function send(text, preparation) {
  const number = ++counter;
  await preparation;
  sent.push({ number, text });
}

const a = send('Первое', first.promise);
const b = send('Второе', second.promise);

second.resolve();
await b;
first.resolve();
await a;

console.log(sent);

Этот файл можно сохранить с расширением .mjs и запустить через Node.js. Получится такой массив:

[
  { number: 2, text: 'Второе' },
  { number: 1, text: 'Первое' }
]

Номера выделены правильно. До первого await функция успела увеличить счётчик, поэтому второй вызов уже увидел новое значение. А вот завершение подготовки мы намеренно развернули.

Событие Первое сообщение Второе сообщение Отправлено
Вызвали обе функции Получило номер 1, ждёт Получило номер 2, ждёт Ничего
Завершили второй промис Ждёт Готово 2
Завершили первый промис Готово Уже отправлено 2, 1

Если вместо sent.push() поставить socket.send(), приложение попросит передать сначала второе сообщение. WebSocket не знает, какое действие пользователя было первым. Его метод send() получает уже подготовленные данные, а не историю их подготовки.

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

async function send(text, preparation) {
  const number = counter + 1;
  await preparation;
  counter = number;
  sent.push({ number, text });
}

Здесь мы получим два сообщения с номером 1. Перенос увеличения перед await устраняет повтор, но возвращает нас к первому примеру: номера разные, порядок неправильный. Исправили одну ошибку, а вторая осталась.

Что должна охватывать очередь

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

Первое:  выделить номер → подготовить → отправить
Второе:                                выделить номер → подготовить → отправить

Очередь только вокруг send() здесь не поможет. Если второе шифрование закончится раньше, второе сообщение первым встанет в эту очередь. Мы аккуратно сохраним уже испорченный порядок.

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

Первое:  выделить номер → подготовка........ → отправить
Второе:  выделить номер → подготовка → ждёт  → отправить

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

Очередь с ограниченным числом мест

Ниже целая реализация для одного отправителя. Она принимает Uint8Array и сохраняет копию: после вызова send() вызывающий код может менять свой массив, не меняя уже принятого сообщения. Параметр parallel ограничивает одновременно запущенную подготовку, а capacity включает все принятые сообщения до передачи в write().

allocate() синхронно выделяет номер. prepare() возвращает промис с подготовленными данными. write() выполняется синхронно: например, проверяет состояние сокета и вызывает его send(). Ошибка любой из этих операций останавливает отправитель. Позже разберём, почему для строгой последовательности номеров нельзя просто пропустить неудачное сообщение.

export function createSender({
  allocate,
  prepare,
  write,
  parallel = 1,
  capacity = 4,
  maxBytes = 65536
}) {
  if (
    !Number.isSafeInteger(parallel) ||
    !Number.isSafeInteger(capacity) ||
    !Number.isSafeInteger(maxBytes) ||
    parallel < 1 ||
    capacity < parallel ||
    maxBytes < 1
  ) {
    throw new RangeError('Invalid queue limits');
  }

  const jobs = [];
  let active = 0;
  let failure;

  function stop(reason) {
    if (failure) {
      return;
    }

    failure = reason instanceof Error ? reason : new Error(String(reason));

    for (const job of jobs) {
      job.reject(failure);
    }

    jobs.length = 0;
  }

  function flush() {
    while (!failure && jobs[0]?.ready) {
      const job = jobs[0];

      try {
        write(job.frame, job.counter);
      } catch (error) {
        stop(error);
        return;
      }

      jobs.shift();
      job.resolve(job.counter);
    }
  }

  function pump() {
    if (failure) {
      return;
    }

    for (const job of jobs) {
      if (active === parallel) {
        break;
      }

      if (job.started) {
        continue;
      }

      try {
        job.counter = allocate();
      } catch (error) {
        stop(error);
        return;
      }

      job.started = true;
      active += 1;

      Promise.resolve()
        .then(() => {
          if (failure) {
            throw failure;
          }

          return prepare(job.data, job.counter);
        })
        .then(
          frame => {
            active -= 1;

            if (failure) {
              return;
            }

            job.data = null;
            job.frame = frame;
            job.ready = true;
            flush();
            pump();
          },
          error => {
            active -= 1;
            stop(error);
          }
        );
    }
  }

  function send(data) {
    if (failure) {
      return Promise.reject(failure);
    }

    if (!(data instanceof Uint8Array) || data.byteLength > maxBytes) {
      return Promise.reject(new RangeError('Invalid message size'));
    }

    if (jobs.length === capacity) {
      return Promise.reject(new Error('Queue is full'));
    }

    const result = Promise.withResolvers();
    jobs.push({ ...result, data: new Uint8Array(data), started: false, ready: false });
    pump();
    return result.promise;
  }

  return { send, stop };
}

При parallel: 1 второе сообщение ждёт в jobs, пока первое не будет подготовлено и отправлено. При parallel: 2 подготовка двух сообщений может перекрываться. Но flush() смотрит только на начало массива. Готовое второе не получится отправить раньше первого, даже если у него уже есть результат.

После подготовки счётчик active уменьшается, но сообщение остаётся в jobs до вызова write(). Поэтому готовый результат тоже занимает место в очереди. Когда все места заняты, новый вызов получает ошибку до выделения номера. Чтение файла может подождать завершения принятых операций, а интерфейс переписки может оставить текст в поле и сообщить, что отправка пока занята. Если вместо ожидания складывать отклонённые сообщения в ещё одну очередь без предела, ограничение памяти потеряет смысл.

С текущими значениями очередь принимает не больше четырёх сообщений по 65 536 байт. Это предел входных данных в самой очереди, а не обещание о всей памяти браузера. Подготовка создаёт свой результат и может делать промежуточные копии. Для оценки памяти нужен ещё известный предел размера подготовленного сообщения. У сокета свой буфер, который эта очередь не ограничивает.

Копию создаёт new Uint8Array(data). Здесь не стоит вызывать data.slice(): в Node.js входом может оказаться Buffer, который тоже проходит проверку instanceof Uint8Array. Его метод slice() создаёт представление общей памяти, поэтому изменение исходного буфера затронуло бы принятое сообщение.

Проверяем тот же порядок завершения

Используем управляемые промисы ещё раз. Теперь они стоят внутри prepare(), а результат пишется через write(). Этот пример запускается вместе с функцией createSender() из предыдущего раздела:

const first = Promise.withResolvers();
const second = Promise.withResolvers();
const secondPrepared = Promise.withResolvers();
const sent = [];
let counter = 0n;

const sender = createSender({
  allocate: () => ++counter,
  async prepare(data, number) {
    const frame = await (number === 1n ? first.promise : second.promise);

    if (number === 2n) {
      secondPrepared.resolve();
    }

    return frame;
  },
  write: frame => {
    sent.push(frame);
  },
  parallel: 2
});

const a = sender.send(new Uint8Array([1]));
const b = sender.send(new Uint8Array([2]));

second.resolve('Второе');
await secondPrepared.promise;
console.log(sent);

first.resolve('Первое');
await Promise.all([a, b]);

console.log(sent);

Первый вывод будет пустым массивом, второй покажет ['Первое', 'Второе']. Промис secondPrepared разрешается внутри второй подготовки, когда она уже получила результат. Только дождавшись этого события, мы освобождаем первое сообщение. Так мы воспроизводим нужный порядок завершений, не подбирая задержки.

В последовательном режиме, с parallel: 1, второе шифрование даже не начнётся до завершения первого. В такой проверке нужно сначала вызвать first.resolve(): ожидание secondPrepared перед ним навсегда остановит пример. Порядок отправки останется тем же, но ожидать будет исходный массив, а не готовый результат. Я бы сохранял последовательную подготовку, пока измерения не покажут, что она мешает задаче. Параллельный вариант добавляет состояния и удерживает готовые данные; сам по себе он не обещает ускорения.

Один отправитель нужен всем сообщениям, которые получатель проверяет по общему счётчику. Например, текст «Передаю файл» получает номер 1, первый фрагмент файла номер 2, следующий текст номер 3, следующий фрагмент номер 4. Если последние подготовились раньше первого, они всё равно ждут. Тип сообщения определяет обработку его содержимого, но не даёт ему права обойти очередь. Отдельные очереди для текста и файлов снова позволят более быстрой подготовке поменять общий порядок, даже если счётчик у них один.

Ошибка после выделения номера

Допустим, получатель уже принял сообщение 16 и ждёт 17. Мы выделили номер 17, но подготовка завершилась ошибкой. Сообщение 18 готово. Отправить его следующим можно только в том протоколе, который допускает такой пропуск.

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

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

Почему не уменьшить счётчик и не попробовать номер 17 ещё раз? Для обычной нумерации такое решение иногда допустимо. Но номер может участвовать и в шифровании: например, из него строят вектор инициализации AES-GCM, который должен различаться при каждом шифровании с одним ключом. Повторно использовать такой вектор опасно. Требование к уникальности описано в разделе 8 рекомендации NIST SP 800–38D. Ошибка отправки не доказывает, что шифрование ещё не выполнялось, поэтому выделенный номер мы назад не возвращаем.

Мы останавливаем сессию, потому что получатель ждёт непрерывную последовательность. Другой протокол может разрешать пропуски или отделять номера сообщений от счётчика шифрования. Тогда ошибка подготовки необязательно завершает сессию. Но это изменение нужно согласовать с правилами приёма: одного catch() на стороне отправки недостаточно.

Что означает успешная отправка

Промис нашего send() выполняется после успешного возврата из write(). Это ещё не подтверждение доставки. Браузерный WebSocket.send() ставит данные на отправку; ожидать его через await ради доставки бессмысленно, промис он не возвращает. Если интерфейс обещает «получено», понадобится ответ принимающей стороны.

Сам write() тоже требует внимания. Согласно алгоритму WebSocket, в состояниях CLOSING и CLOSED данные отбрасываются. Отсутствие исключения не подтверждает, что сообщение принято в буфер. Состояние надо проверить явно:

function write(frame) {
  if (socket.readyState !== WebSocket.OPEN) {
    throw new Error('Connection is not open');
  }

  if (socket.bufferedAmount + frame.byteLength > 262144) {
    throw new Error('Socket buffer is full');
  }

  socket.send(frame);
}

Здесь frame уже является бинарным сообщением, а 262 144 байта выбраны как учебный предел буфера. При превышении мы завершаем попытку, потому что номер уже выделен. Этот предел не гарантирует, что браузеру хватит памяти: при переполнении собственного буфера он может закрыть соединение без исключения из send(). Для передачи больших файлов нужна отдельная схема ожидания освобождения буфера. Она не должна удерживать неограниченное количество готовых сообщений.

Если соединение закрыто ещё до обращения к очереди, можно отказать раньше, не тратя номер. Но сообщения, которые уже готовятся, тоже нужно остановить. Для этого обработчик закрытия вызывает sender.stop(new Error('Connection closed')), а write() проверяет состояние непосредственно перед отправкой. Для нового сокета создаём новый отправитель, чтобы поздняя подготовка из старой очереди не отправила данные в другое соединение.

После перезагрузки счётчик тоже должен продолжаться

Пока живёт одна страница, ++counter выделяет разные значения до первого ожидания. Но после перезагрузки переменная снова станет нулём. Если ключ шифрования сохранился, появится повтор уже использованных значений.

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

Например, записываем границу 4, затем используем номера 1 и 2. После перезагрузки знаем только, что номера до 4 включительно уже нельзя брать. Следующим будет 5. Неиспользованные 3 и 4 мы сознательно теряем.

Такой счётчик можно устроить следующим образом:

export function createCounter(saved, persist, block = 4n) {
  const maximum = (1n << 64n) - 1n;

  if (
    typeof saved !== 'bigint' ||
    typeof block !== 'bigint' ||
    saved < 0n ||
    saved > maximum ||
    block < 1n ||
    block > maximum
  ) {
    throw new RangeError('Invalid counter state');
  }

  let current = saved;
  let reserved = saved;

  return function allocate() {
    if (current === maximum) {
      throw new RangeError('Counter exhausted');
    }

    if (current === reserved) {
      const next = reserved + block;
      const upper = next > maximum ? maximum : next;
      persist(upper);
      reserved = upper;
    }

    current += 1n;
    return current;
  };
}

saved содержит последнюю сохранённую верхнюю границу. persist() в этом примере имеет строгий контракт: она синхронно сохраняет новое значение до возврата. Если запись не удалась, функция выбрасывает ошибку. Отправитель остановится раньше шифрования. Если реальное хранилище асинхронное, ожидание подтверждённой записи надо включить в последовательный участок выделения номеров; заменить persist() асинхронной функцией в этом коде нельзя.

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

let saved = 0n;

function persist(value) {
  saved = value;
}

const allocate = createCounter(saved, persist);
console.log(allocate(), saved);
console.log(allocate(), saved);

const restored = createCounter(saved, persist);
console.log(restored(), saved);

Результат будет 1n, 4n, затем 2n, 4n, после восстановления 5n, 8n. Эта проверка показывает порядок резервирования и использования, но ничего не доказывает о сохранности данных конкретного браузерного хранилища. Если ключ удалось восстановить, а надёжное состояние счётчика потеряно, начинать с нуля с тем же ключом нельзя.

Отдельный случай: запись могла пройти, но подтверждение потерялось. Мы всё равно не шифруем и останавливаем отправитель. При восстановлении нужно прочитать достоверную границу. Неопределённость результата записи не даёт права считать, что ничего не записалось.

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

Что скажет получатель о пропущенных номерах

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

Один вариант: согласованное восстановление разрешает разрыв только у первого сообщения. Получатель проверяет, что новый номер больше последнего принятого, а после успешной проверки подлинности снова переходит к строгой последовательности. Номер тоже должен быть защищён от подмены: например, участвовать в построении вектора инициализации или входить в аутентифицируемые дополнительные данные AES-GCM. Нельзя разрешать разрыв просто потому, что пришло неожиданно большое число. Само восстановление тоже должно быть согласовано, а последнее принятое значение сохранено; иначе ослабляется защита от повтора старых сообщений. Роль вектора инициализации и номера сообщения в защите от повтора описана в приложении D той же рекомендации NIST.

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

В коде выше реализованы очередь и выдача номеров. Установление ключей и согласование восстановления к их контракту не относятся. Встраивая такой отправитель, я бы проверял обе стороны на одном расписании: отправили до 2, сохранили резерв до 4, восстановились и отправили 5. Если получатель ждёт 3, проблему нельзя исправить только на стороне отправки.

Какие остановки стоит проверить

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

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

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

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