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

TCP reactor · срез 0.0.28

Завершён в PR #6, слитом в main. Платформенные проверки Linux/macOS/Windows прошли; это не расширяет область доказательств Liza Verified.

liza-reactor обслуживает до восьми TCP-клиентов в одном потоке. Протокол сохраняется из message server: четыре байта длины big-endian, затем до 65536 байтов тела. Ответ содержит только полный кадр. Медленный отправитель или читатель не блокирует обслуживание остальных.

python -m liza build examples/apps/liza_reactor.liza -O 2 -o build/liza-reactor
./build/liza-reactor

По умолчанию сервер слушает 127.0.0.1:9002 и печатает порт с LF. --ephemeral выбирает свободный порт ОС. --once также выбирает порт ОС, обслуживает одно соединение до EOF/ошибки и закрывает все ресурсы. Коды завершения: 0 — нормальный EOF, 2 — неверные аргументы, 3 — ошибка I/O/аллокации/закрытия, 4 — ошибка протокола в режиме --once. В постоянном режиме ошибка отдельного клиента закрывает только его соединение; ошибка самого poll/accept завершает сервер, кроме ожидаемых временных исходов.

Кто владеет сокетами

net.Reactor — непрозрачный move-only ресурс. Он владеет listener и всеми принятыми соединениями. net.reactor_close(move reactor) закрывает каждый живой сокет и listener, освобождает метаданные и потребляет ресурс даже при ошибке закрытия. Возвращается первая ошибка; остальные закрытия всё равно выполняются. Пользовательских ресурсных структур или массивов net.Stream этот срез не вводит.

net.Connection — непрозрачное копируемое значение, которое не владеет сокетом. Каждая операция требует живого reactor. Runtime проверяет идентичность владельца, слот и поколение. Чужой, устаревший или повторно закрытый ID даёт InvalidConnection. После повторного использования слота старый ID не становится действительным. Счётчики не оборачиваются: исчерпанный слот больше не используется, а исчерпание пространства владельцев даёт Exhausted.

Это явный составной владелец в доверенном runtime, а не перенос анализа V4 на сокеты. Компилятор проверяет линейность reactor; проверка актуальности ID выполняется во время исполнения. Сам ID можно сохранить после закрытия владельца, но это не даёт доступа к сокету или возможности закрыть его повторно.

API

Все операции ниже заимствуют reactor, кроме создания и закрытия. Создание и закрытие имеют эффекты io,alloc; остальные операции с reactor — io. net.connection_slot — чистая операция. Нового эффекта block нет.

Операция Результат и контракт
net.reactor_create(port, capacity) Result<net.Reactor,ReactorError>. IPv4 loopback; порт u16, 0 — выбор ОС; вместимость u32 от 1 до 32
net.reactor_port(reactor) Result<u32,ReactorError>, фактический порт
net.reactor_accept(reactor) Result<net.Connection,ReactorError>. Одно неблокирующее принятие; новый сокет сразу принадлежит reactor
net.connection_slot(id) u32, индекс для таблицы состояния приложения; сам по себе не подтверждает актуальность ID
net.reactor_interest(reactor, id, flags) Result<u32,ReactorError>. Маска u32: 1 — чтение, 2 — запись, 3 — оба направления, 0 — не опрашивать
net.reactor_poll(reactor, timeout_ms) Result<u32,ReactorError>. Число готовых записей, включая listener; тайм-аут — Ok(0)
net.reactor_ready(reactor, id) Result<u32,ReactorError>. Снимок последнего poll: 1 — чтение, 2 — запись, 4 — ошибка/закрытие peer; биты могут сочетаться
net.reactor_read(reactor, id, move buffer, start, count) Линейное произведение исходного Buffer<u8> и Result<u32,ReactorError>
net.reactor_write(reactor, id, data, start, count) Result<u32,ReactorError>, число принятых байтов из View<u8>
net.reactor_disconnect(reactor, id) Result<u32,ReactorError>. Закрывает сокет, сразу инвалидирует ID, даже при ошибке close
net.reactor_close(move reactor) Result<u32,ReactorError>. Завершает владение всем набором

ReactorError: AddressInUse, PermissionDenied, Interrupted, WouldBlock, Disconnected, InvalidRange, Other, InvalidCapacity, Full, InvalidConnection, Exhausted, OutOfMemory.

Срез 0.0.30 TCP Proxy добавляет исходящий reactor_connect, явный reactor_finish_connect и reactor_shutdown_write. Для исходящего ID перед read/write нужно подтвердить Connected. Принятые через accept соединения по-прежнему сразу готовы к неблокирующему I/O. Подробный контракт pending, ошибок подключения и half-close описан на странице proxy.

Неизвестные ошибки ОС отображаются в Other. Full не принимает и не теряет новый сокет; очередь ОС остаётся ограниченной настройками listen и ОС.

Readiness и прогресс

Все сокеты reactor неблокирующие. Только poll намеренно ожидает события: тайм-аут задаётся в миллисекундах от 0 до 2147483647, большее значение даёт InvalidRange. Ноль означает немедленный опрос. Это предел ожидания poll, а не обещание точного времени возврата при планировании ОС и не idle deadline.

Перед каждым poll снимки readiness очищаются, в том числе при ошибке. Принятый сокет начинает с нулевой готовности и интересом к чтению. При отсутствии свободных слотов listener не опрашивается. Сокеты с нулевым интересом пропускаются; пустой набор ожидает только тайм-аут. Один poll обновляет снимки всех участвующих соединений; последующий ready не потребляет событие.

Readiness — подсказка. WouldBlock и Interrupted остаются нормальными исходами следующих read/write/accept, без скрытого retry. Бит 4 сам по себе не означает, что все входные данные уже прочитаны: приложение выполняет соответствующую I/O операцию и учитывает её результат. EOF — Ok(0) при непустом чтении.

Диапазоны имеют тип u32: start <= len, count <= len-start. Нулевой count при read — InvalidRange, чтобы не путать его с EOF. При write пустой диапазон даёт Ok(0); успешная запись непустого диапазона всегда возвращает положительное число. Нулевой результат ОС для такой записи становится Disconnected. Короткие записи допустимы, ошибка не обещает откат уже выполненных предыдущих записей. При любой ошибке read буфер возвращается с тем же размещением и origin. Живой readonly view запрещает передачу буфера в read.

Runtime использует POSIX poll и Windows WSAPoll. Платформенные флаги переводятся в описанные выше маски Liza. Структуры C не передаются через внешний aggregate ABI: адаптеры используют скаляры и указатели.

Цикл обслуживания на Liza

Исходник: examples/apps/liza_reactor.liza.

На каждый слот приложение хранит копируемый Client: ID, FrameState, курсор входного чанка и положение отправки ответа. Все переходы фрейминга и решение, какой интерес включить, выполняются на Liza.

  1. Если в памяти остался ещё не разобранный вход и нет ответа на отправку, следующий poll выполняется с нулевым тайм-аутом: для этих байтов не требуется нового события TCP.
  2. За оборот принимается не больше одного нового клиента, затем по одному разу рассматриваются все восемь слотов.
  3. Для клиента выполняется не больше одного read на 2048 байтов, одного framing.feed и одного write. Сохраняется ограниченный объём работы за оборот, даже при потоке пустых кадров.
  4. Полный кадр помещается в исходящий диапазон вместе с заголовком. Пока он не отправлен целиком, интерес переключается на запись; дальнейшее чтение этого клиента остановлено. Остаток склеенных кадров остаётся во входном чанке.
  5. После отправки вновь разрешается чтение/разбор сохранённых байтов. Поэтому half-close после нескольких кадров не теряет уже принятые полные сообщения.
  6. EOF проверяется через framing.finish. Ошибка фрейминга закрывает соединение; следующий клиент получает новый parser и нулевую логическую длину ответа.

Память и стоимость

Один регион на запуск сервера: 540704 байта. В нём два буфера: 8 × 2048 = 16384 входных байта и 8 × (65536 + 4) = 524320 исходящих. Регион и буферы создаются до event loop и переиспользуются между клиентами. После завершения цикла буферы release, reactor close, регион close.

Создание reactor отдельно выделяет фиксированные runtime-метаданные для максимум 32 слотов. accept не выделяет пользовательский payload-буфер; создание сокета и сетевые буферы ядра ОС имеют собственную стоимость и не входят в указанные 540704 байта. Фиксированный массив Client, стек и метаданные региона также учитываются отдельно.

Каждый байт тела копируется из входного буфера в исходящий один раз. Это не zero-copy протокол. Read/write передают существующее размещение в системный API, без полной копии буфера; цикл не создаёт регионы и не вызывает reserve. Poll линейно обходит ограниченный набор. Это portable reactor, без epoll/kqueue/IOCP.

Проверки и границы

tests/test_reactor.py проверяет O0/O2: остановившийся клиент A, независимые ответы B/C, фрагментацию, склейку и пустые кадры, 64-КиБ сообщения, half-close, повторное использование слотов, заполнение восьми мест и ожидание девятого, медленного читателя и ошибки протокола. Fault runtime ограничивает read тремя байтами, write семью и возвращает WouldBlock после readiness.

Отдельные проверки покрывают линейность владельца, origin буфера, запрет read при view, чужие/stale ID, повторное закрытие, границы диапазонов, исчерпание поколений, закрытие живых детей и отсутствие malloc/calloc/realloc и копирования payload в горячих runtime-операциях. Тесты включены в Windows suite.

Нет многопоточности, публичной сетевой привязки, DNS, TLS, маршрутизации, динамического увеличения числа клиентов или коллекций произвольных ресурсов. Нет idle timeout, ограничения времени отправки кадра или завершения по сигналу: восемь неактивных клиентов могут занять все слоты. Завершение процесса извне не обещает пользовательский cleanup. Нормальный выход --once и ошибки самого event loop проходят явное закрытие ресурсов. Это ограниченный прототип, не готовый публичный сервер и не формально проверенный сетевой runtime.