Message Router · срез 0.0.29
Прикладной этап завершён в PR #7,
слитом в main, отдельно от TCP reactor.
Восемь loopback-клиентов могут пересылать друг другу бинарные сообщения.
Core, Semantic IR, stdlib и платформенный runtime не изменяются: маршрутизация
написана на Liza в examples/apps/liza_router.liza.
По умолчанию адрес — 127.0.0.1:9003. Сервер печатает выбранный порт с LF.
--ephemeral выбирает порт ОС; --once также выбирает порт ОС, но обслуживает
одно соединение до EOF или ошибки. Self-send позволяет проверить маршрутизацию
и в этом режиме. Коды выхода: 0 — нормальное завершение, 2 — неверные аргументы,
3 — I/O/аллокация/исчерпание поколений, 4 — ошибка протокола в --once.
В постоянном режиме ошибка клиента закрывает его соединение, а не весь сервер.
Три разных понятия
net.Reactor владеет сокетами. net.Connection — непрозрачный runtime-ID,
проверяемый вместе с владельцем. Он не попадает в протокол.
RouterId — прикладная пара slot:u32, generation:u64. В таблице она связана
с текущим net.Connection. Поколения ведёт само приложение, независимо от
внутреннего поколения reactor. Ноль не выдаётся; каждый повторный приём в слот
увеличивает поколение. При исчерпании u64 сервер завершает работу с закрытием
всего reactor, без оборачивания счётчика и повторной выдачи ID.
RouterId действителен только внутри одного запуска процесса. Epoch в этом wire-протоколе отсутствует. Между перезапусками ID могут совпасть; старые адреса нельзя переносить в новый запуск. Глобальная уникальность, аутентификация и авторизация адресатов не заявляются. Знание RouterId не подтверждает личность.
Accepted означает полное копирование сообщения в очередь адресата. Это не
подтверждение получения приложением адресата. При последующем отключении или
ошибке отправки сообщение может быть потеряно; повторных отправок, сохранения
на диск и уведомления отправителю о такой потере нет.
Обратное тоже неверно: если отправитель не получил Accepted из-за разрыва связи,
сообщение уже могло попасть в очередь. Дедупликации и безопасного автоматического
повтора запросов этот протокол не предоставляет.
Wire-протокол
Каждый кадр начинается с четырёх байтов длины тела, unsigned big-endian.
Длина не включает собственный заголовок и не превышает 65536. В теле первым
идёт однобайтовый type. RouterId кодируется ровно 12 байтами:
slot — 4 байта big-endian, затем generation — 8 байтов big-endian.
Нативный layout структуры, padding и host endian не сериализуются.
| Type | Направление | Тело кадра | Длина тела |
|---|---|---|---|
| 1 — Register | Сервер → клиент, сразу после accept | 01 + own RouterId |
13 |
| 2 — Send | Клиент → сервер | 02 + destination RouterId + payload |
13…65536 |
| 3 — Accepted | Сервер → отправитель | 03 |
1 |
| 4 — Busy | Сервер → отправитель | 04 |
1 |
| 5 — Unavailable | Сервер → отправитель | 05 |
1 |
| 6 — Deliver | Сервер → адресат | 06 + sender RouterId + payload |
13…65536 |
| 7 — ProtocolError | Сервер → клиент, затем закрытие | 07 + reason:u8 |
2 |
Максимальный payload: 65523 байта, включая возможность нулевой длины. У Send и Deliver одинаковый размер служебной части: 1 + 12 байтов. Источник в Deliver сервер формирует из собственного состояния соединения, а не из переданного клиентом sender ID.
Клиент не отправляет Register: это приветствие сервера. Любой входной type,
кроме Send, либо тело короче 13 байтов — ProtocolError(1).
Объявленная длина больше 65536 — ProtocolError(2) сразу после заголовка,
без чтения всего oversized тела и без дополнительной аллокации.
EOF внутри заголовка/тела — ProtocolError(3). EOF на границе кадров нормален:
RouterId перестаёт принимать новые сообщения, уже поставленный Deliver
досылается перед закрытием, если peer продолжает читать.
Нулевой кадр допустим для framing, но не является допустимым запросом Router.
Ошибка протокола немедленно делает RouterId недоступным для новой маршрутизации. Сервер пытается отправить ограниченный кадр ошибки, затем закрывает сокет. Если peer уже не читает или закрыл соединение, доставка ошибки не гарантируется. Обычная ошибка I/O закрывает соединение без обещания отправить ProtocolError.
Ответы Accepted/Busy/Unavailable идут в порядке запросов данного отправителя; request ID в этом протоколе нет. Deliver от других клиентов может появляться между ответами. Кадры никогда не перемешиваются побайтово.
Постановка в очередь
Когда полный Send собран, выполняются последовательные шаги:
- Проверить диапазон slot, наличие соединения, поколение и отсутствие closing. Иначе — Unavailable.
- Проверить свободную позицию Deliver адресата. Даже частично отправленный кадр занимает позицию целиком. Если она занята — Busy.
- Записать заголовок, type, настоящий ID отправителя и весь payload в заранее выделенный исходящий диапазон адресата.
- Только после этого установить опубликованную длину ожидающего Deliver.
- Поставить Accepted в служебную позицию отправителя.
Busy и Unavailable не меняют позицию Deliver адресата. После Accepted staging отправителя можно переиспользовать: исходящая копия от него не зависит. Это логическая атомарность однопоточного приложения, не транзакция внешнего I/O и не гарантия восстановления после trap или аварии процесса.
Если отключение адресата ещё не замечено, занятая очередь остаётся Busy. Нельзя заменить частично отправленный кадр, предполагая, что peer уже ушёл. После обнаружения отключения очередь сбрасывается, а старый RouterId перестаёт разрешаться. Новый клиент того же слота получает другое поколение и пустые логические очереди; старые байты ему не отправляются.
Состояния и прогресс
Состояние каждого клиента содержит parser, курсоры входа, две исходящие длины, активный вид кадра, смещение отправки и флаг closing. Новых конструкций языка нет.
- Receiving: служебная позиция свободна и нет closing; разрешено читать и собирать Send, в том числе параллельно отправке Deliver.
- ResponsePending: служебная позиция содержит Register, ответ или ошибку.
- Sending: выбран один кадр; его вид сохраняется до завершения всех коротких записей. Второй кадр не может заменить уже отправленный префикс.
- Closing: новые Send не разбираются и новые Deliver не принимаются; после служебной ошибки выполняется close, либо немедленный close при I/O-ошибке.
Две фиксированные позиции — одна служебная и одна Deliver — позволяют принять self-send: сначала уходит Accepted, затем Deliver. Они также позволяют адресовать клиента, у которого ещё отправляется Register. При выборе нового исходящего кадра при пустом планировщике служебный имеет приоритет; уже начатый Deliver не прерывается. После завершения кадра уже ожидающий кадр другой очереди сразу резервируется следующим, до обработки нового запроса.
Пока занята служебная позиция, новые read и разбор следующего запроса этого клиента приостановлены. Уже полученные склеенные байты остаются во входном чанке. Один ожидающий Deliver не запрещает чтение: иначе постоянные доставки от других клиентов могли бы вытеснять собственные запросы адресата. При этом после одного нового запроса его служебная позиция занята и дальнейшее чтение останавливается. Начатый или уже зарезервированный Deliver завершается прежде такого ответа. Поэтому кадры не перемешиваются, а постоянное пополнение одной очереди не обходит уже ожидающий кадр другой очереди.
За оборот принимается не более одного соединения. Каждый из восьми слотов получает один проход: не более одного read на 2048 байтов, одного framing.feed, одной маршрутизации ограниченного кадра и одного неблокирующего write. WouldBlock/Interrupted сохраняют состояние; readiness не считается гарантией успеха. Один непрерывно активный клиент не получает внутреннего бесконечного цикла обслуживания. Это ограничение работы планировщика, а не real-time гарантия ОС или обещание равной доли доставок при конкуренции за одну очередь.
После всех проходов обновляются интересы всех соединений. Это существенно: поздний слот может поставить Deliver раннему слоту, уже обработанному в этом обороте. Следующий poll должен учитывать новую запись. Если остался готовый к разбору входной остаток, используется poll с нулевым тайм-аутом.
Память и стоимость
Один регион на запуск: 1065128 байтов, выделяется до event loop.
| Область | Вместимость |
|---|---|
| Входные TCP-чанки | 8 × 2048 = 16384 байта |
| Staging полных Send | 8 × 65536 = 524288 байтов |
| Исходящие Deliver с framing | 8 × 65540 = 524320 байтов |
| Служебные кадры с framing | 8 × 17 = 136 байтов |
Последние две области размещены в одном Buffer размером 524456 байтов. Служебный максимум — Register из 17 байтов с четырёхбайтовой длиной; ответы занимают 5 байтов, ProtocolError — 6. Максимальный Deliver занимает 65540. Ни одна очередь не растёт. Метаданные клиентов, reactor, региона, стек и сетевые буферы ОС учитываются отдельно.
Payload сначала копируется из TCP-чанка в staging отправителя, затем один раз из staging в исходящий диапазон адресата. Это две прикладные копии payload, не zero-copy. После постановки staging и исходящая копия независимы. Размер работы зависит от длины сообщения, а не всей ёмкости региона. Во время event loop не создаются пользовательские буферы и не вызывается reserve; это не утверждение об отсутствии внутренних выделений ОС при сетевом I/O.
Проверки и ограничения
tests/test_router.py собирает реальные программы на O0/O2 и проверяет точные
байты Register, ответов и Deliver, self-send, пустой/максимальный payload,
последний слот, фрагментацию и склейку, stale ID, неправильные поколения и slot,
ошибки протокола и нормальный EOF. Активный отправитель работает одновременно
с независимой доставкой между другими клиентами.
Fault runtime отправляет по два байта и может остановить частичный Deliver или Accepted через WouldBlock. Проверяется сохранение первого кадра после Busy, пауза разбора следующего запроса, двусторонний прогресс при частичном Deliver, досылка очереди при half-close, прогресс другого клиента и потеря очереди после Accepted/отключения без доставки новому владельцу слота. Эти подмены существуют только в тестах; производственный runtime не изменён.
Нет TLS, авторизации, persistence, federation, подтверждения доставки, маршрутизации между процессами, idle deadlines или завершения по сигналу. Медленный клиент может занимать свой слот неограниченно долго; восемь таких клиентов исчерпывают лимит сервера. V0–V4 не доказывают свойства Router или сетевого runtime. Это прикладной прототип с ограниченной памятью и тестируемым контрактом, не готовый публичный мессенджер.